32 KiB
orisys-sensor-design
Nominal sensor geometry and Blender authoring for Orisys. This repo is the design
source of truth. Measured calibration, unroll maps, and shipped runtime bundles live in
orisys-sdk and compiled .oryscene packages live in orisys-app.
For the full long-term workflow see sensor-design-workflow.md in the workspace root.
This repository intentionally implements a minimal v1 for a small team.
Two buckets (daily work)
| Bucket | Where | Contents |
|---|---|---|
| Design | this repo | .blend, nominal export, release manifest |
| Runtime | orisys-sdk / orisys-app |
measured cal, unroll map, wheel/App assets, .oryscene |
Never copy runtime NPZ files into this repo to “fix” a mismatch. Promote through a manifest + lock file instead.
Layout
sensors/finger/
├── current.yaml # active revision pointer
└── r01/
├── design.yaml # units, frames, object names
├── source/geometry.blend # named collections + objects
├── profiles/
│ ├── compute.yaml # → SDK bundle layout (Studio runtime)
│ └── studio.yaml # inputs for orisys-app compile_scene
└── golden/ # optional preview + metrics
Prerequisites
- Python 3.10+
- Blender version pinned in
blender.lock.json(install separately)
No Git LFS is required — binaries are committed as ordinary files.
Finding Blender on Windows
The CLI looks for Blender in this order:
- Env
ORISYS_BLENDERorBLENDER_EXE(absolute path toblender.exe) blender/blender.exeonPATH- Auto-scan
%ProgramFiles%\Blender Foundation\Blender *\blender.exe(prefersblender.lock.jsonversion when installed, otherwise newest) - Optional
"executable"inblender.lock.json(local only; do not commit machine paths)
$env:ORISYS_BLENDER = "C:\Program Files\Blender Foundation\Blender 5.0\blender.exe"
Convenience launcher (auto-finds Blender for this process, then exports):
.\tools\export-compute.bat
Commands
Install the CLI (editable):
cd D:\orisys_ws\orisys-sensor-design
python -m pip install -e .
Four commands for v1:
# 1) Check revision metadata and (optionally) Blender scene conventions
orisys-design validate sensors/finger/r01
# 2) Export nominal geometry plus the unwarp/valid-mask map (compute → SDK layout)
orisys-design export sensors/finger/r01 --profile compute
# 3) Generate the colored patch texture headlessly
orisys-design texture sensors/finger/r01
# 4) Write release manifest with file hashes
orisys-design manifest sensors/finger/r01
Headless Blender export uses a JSON job file (stdlib json inside Blender — no
PyYAML required). orisys-design export writes it automatically from design.yaml:
orisys-design export sensors/finger/r01 --profile compute
# writes build/export/finger/r01/_export_job.json then runs Blender
Manual Blender invocation with the same job:
blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/export.py -- `
--job D:\orisys_ws\orisys-sensor-design\build\export\finger\r01\_export_job.json
Optional flag overrides (--output, --mesh, --cam, --origin) win over job values.
Export output is grouped by revision:
build/export/finger/r01/
├── _export_job.json # Blender input, regenerated by export
├── manifest.json # release manifest from `orisys-design manifest`
└── compute/ # clean SDK-shaped payload
├── mesh.npz
├── camera_intrinsics.npz
├── world_to_cam.npy
└── unroll_map_uv_s0.42.npz # unwarp/valid-mask domain (name carries source_scale)
The last one is the UV domain the application treats as sensing: everything outside
its valid_mask island renders grey. Blender writes no mask, so the export builds it
afterwards with the SDK's own builder (orisys.unroll.precompute), from the profile's
uv_map: block — no Blender involved, so the mask can also be rebuilt from an exported
payload alone:
python tools/build_uv_map.py --export-dir build/export/finger/r01/compute `
--profile sensors/finger/r01/profiles/compute.yaml
The parameters are never guessed (a wrong output size or source scale still produces a
plausible-looking mask), and a failed map build exits non-zero before the artifact check,
so a bundle whose mask is missing or stale cannot be reported as complete. The SDK asset
copy is updated only when the revision is promoted — gated by
orisys-app/tools/scene_assets/compare_uv_map.py.
Texture patches (headless)
orisys-design texture generates a PNG, editable SVG, and native vector PDF for
each UV map on the sensing surface. The SVG/PDF are built directly from the UV
mesh and camera-space patch polygons — they do not embed or trace the PNG.
UV unwrapping is still an in-app Blender step: this command
consumes the UV maps already stored in source_blend and never creates or
unwraps one. Unwrap the mesh in the Blender GUI, save the .blend, then run the
command. A .blend saved in Edit mode is fine — the headless run enters Object
mode in memory and never writes the file.
orisys-design texture sensors/finger/r01
# writes build/textures/finger/r01/_texture_job.json then runs Blender
Output is revision-scoped, so nothing has to be copied by hand:
build/textures/finger/r01/
├── _texture_job.json # Blender input, regenerated by texture (gitignored)
├── patch_texture_produce.png # raster preview/use (tracked)
├── patch_texture_produce.svg # editable grouped vector artwork
├── patch_texture_produce.pdf # vector production/print handoff (tracked)
├── patch_texture_compute.png
├── patch_texture_compute.svg
└── patch_texture_compute.pdf
produce is the hardware-production layout and compute the compute/runtime
layout; they are the two UV maps authored on sensing_surface. Both outputs
share one set of settings and one seed, and both read the same camera-space
patch grid, so a given physical patch carries the same colour in either file —
the images differ only in how the unwrap arranges the patches.
Square in which space? The patch grid is defined in the camera image, not in
flat UV coordinates or physical surface distances. For square camera pixels,
nx / ny must match the camera image aspect ratio (80×80 for a square frame,
80×45 for 16:9). Changing the PNG dimensions does not change the camera grid.
Curvature and UV unwrapping can make these camera-space squares look rectangular,
skewed, or curved in the flat texture; that alone is not a defect. Thin spikes
outside the UV surface, however, are not expected projection distortion.
Generated artifacts are committed so a revision's textures can be synced to
hardware production. They are stored as real files, not Git LFS pointers, so a
plain git clone yields usable images and .blend files; .gitattributes only
marks binaries as non-text to protect them from line-ending conversion. Only
_texture_job.json is ignored, because it records absolute machine paths and is
rewritten every run.
SVG imports into CorelDRAW as named groups (background, mesh, patches, and
reference-cross) containing individual editable polygons. PDF contains the
same filled vector paths on a correctly sized page, with no embedded bitmap.
Seam-wrapped duplicate polygons are clipped to the page, so no stray objects sit
outside the artwork.
Defaults come from the texture_patch block in design.yaml — mesh, camera, the
uv_outputs map (UV map → artifact stem), raster dimensions, vector page size in
millimetres, patch grid, colour separation, edge sampling, reference cross, and
an optional seed. The current production page is configurable as:
vector:
width_mm: 164
height_mm: 160
CLI flags override the shared rendering values for every output:
orisys-design texture sensors/finger/r01 `
--width 1024 --height 1024 --nx 16 --ny 12 --seed 7 --no-cross
Manual Blender invocation with the same job:
blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/random_patch_dd02c.py -- `
--job D:\orisys_ws\orisys-sensor-design\build\textures\finger\r01\_texture_job.json
A single manual target is also possible (this cannot be combined with --job).
--output names the PNG; same-stem SVG/PDF files are generated alongside it.
The vector page defaults to 164×160 mm and can be overridden manually:
blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/random_patch_dd02c.py -- `
--uv-map produce --output D:\tmp\produce.png --width 1024 --height 999 `
--vector-width-mm 164 --vector-height-mm 160
Only the colored "patches" mode is reachable headlessly; the checker and dot
helpers remain in random_patch_dd02c.py but are not exposed by the CLI. The
default 3280×3200 grid matches the in-app script and is deliberately slow
(pure-Python rasterization) — use smaller --width/--height/--nx/--ny for quick
check runs, and the full default grid for the textures you actually ship.
Inspect printability (advisory)
orisys-design inspect measures an already generated texture and reports
whether it can be produced. It reads the vector SVG, never runs Blender, and
never gates: a failed constraint is reported but the command still exits 0.
orisys-design inspect sensors/finger-xynova/r01
# writes build/textures/<sensor>/<revision>/texture_report.json
Millimetres come from the SVG's own declared page (width/height in mm) and
its viewBox, so the numbers describe the artifact that exists. When that page
disagrees with design.yaml, the report fills page_mismatch with both sizes and
a reason — a texture generated before a page change is not silently accepted.
design.yaml carries the thresholds; CLI flags override them:
printability:
printer_dpi: 600 # dot pitch = 25.4 / dpi = 0.0423 mm
min_dots: 2 # printer dots across the smallest feature
min_feature_mm: 0.10
min_edge_mm: 0.05
max_sliver_ratio: 4.0
min_patch_area_mm2: 1.0e-4
Report fields
The report (orisys-sensor-design/texture-report-v2) keeps only what drives a
decision. Top level: schema, generated_at, revision (repo-relative),
settings, overall, and maps[]. Each map carries:
| Field | Meaning |
|---|---|
uv_map, file |
which map, and the SVG inspected (name, relative_path, sha256) |
page_mm, mm_per_px, uniform_scale |
physical page and its scale onto the viewBox |
raster_dpi |
effective raster resolution; informational, never gates |
nominal_cell |
grid cell size in mm and across its shortest side in printer dots |
patches |
total, material, debris, below_dot, degenerate, nonconvex |
min_width_mm, min_edge_mm |
min, p5, p50 in mm (min_width_mm.method notes the caliper basis) |
max_sliver_ratio |
worst longest-edge ÷ minimum-width ratio |
coverage |
patch_area_fraction of the mesh, and mesh_polygons |
colours |
distinct fills and the closest min_rgb_distance between two of them |
reference_cross |
arm thickness_mm and whether it is printable at the configured dots |
page_mismatch |
null, or {artifact_mm, declared_mm, reason} when stale |
notes |
situational explanations only |
checks, verdict |
one entry per constraint: name, required, actual, passed |
offenders |
worst material patches, then debris examples, each with a locator |
Locating a reported patch
A patch id is an emission-order ordinal, not a stable name: it renumbers
whenever the texture is regenerated, and on its own it says nothing about where
the patch is. file.sha256 therefore identifies the exact SVG a report
describes, and every offender carries a locator:
{ "kind": "material", "id": "patch-33927", "fill": "#56faa1",
"centroid_px": [2483.924, 1255.508], "centroid_mm": [12.419618, 6.277541],
"min_width_mm": 0.000417, "sliver_ratio": 1208.131, "area_mm2": 0.000105 }
Use the id to find the <polygon> in the SVG, centroid_px to locate it in the
viewBox, centroid_mm to place it on the printed page, and fill to recognise
it on screen. --csv writes the reported offenders; --csv-all writes every
material patch with the same columns, which is the practical way to triage
thousands of patches in a spreadsheet or CorelDRAW.
Only the SVG is read, so file names the SVG even though the PDF remains the
print master and the PNG the raster preview.
Reading the checks
Measured feature statistics use material patches, those at or above
min_patch_area_mm2. Cell clipping emits a long tail of numerically degenerate
slivers (~1% of polygons below one output pixel on the Xynova revision); counting
those as features would make every revision fail. They are not hidden:
patches.debris, patches.below_dot and the debris entries in offenders
report them, and notes says how many were excluded.
Checks are per polygon. Abutting polygons sharing one fill merge into a single
printed feature, so a cell split by triangle or seam boundaries prints whole even
though each fragment is smaller. Compare nominal_cell.dots against
min_dots to judge that; per-polygon minima matter for processes that resolve
every object separately, such as cutting or weeding. A failing min_feature_mm
alongside a passing nominal_cell.dots means the artwork is fragmented, not that
the pattern itself is unprintable.
Preview the export
orisys-design preview opens the built export in a minimal PyOpenGL window so a
revision can be checked before it reaches Studio: the sensing surface highlighted,
the exported housing around it, and the exported camera frustum.
python -m pip install -e .[preview] # numpy, PyQt5, PyOpenGL
orisys-design preview sensors/finger-xynova/r01
Controls: left-drag orbit, middle/right-drag pan, wheel zoom, H toggle housing,
W wireframe, C frustum. --self-test renders one frame, reports how many
mesh pixels were drawn, and exits without waiting for a window — use it to check
a machine's GL setup. --screenshot PATH writes that frame to a PNG so a view
can be reviewed or shared without describing it. --no-housing and
--no-frustum simplify the view.
Normals are crease-aware: faces are grouped per vertex and split where the
dihedral angle exceeds --crease-angle (default 35°), so machined edges read as
creases instead of smooth roll-offs. --smooth disables splitting. This matters
because the housing is sharp and the sensing surface is not — 13.5% of frame_L
edges exceed 30° while the sensing surface has none, and the default leaves its
2508 vertices untouched.
Exit codes: 2 for a missing optional dependency, 3 for a payload that has not
been exported, 4 when no OpenGL context can be created (the offscreen/minimal
Qt platform cannot create one at all, so run on a desktop session).
Housing is a separate artifact
export also writes housing.npz for the objects in the ASSEMBLY
collection, transformed into the same sensor frame as the sensing surface. It is
deliberately not merged into mesh.npz: orisys-sdk's unroll consumes
mesh.npz vertices as the sensing surface, so housing vertices there would
corrupt it. Declare the collections with --housing-collections or via
design.yaml's collections.assembly.
Lighting mirrors Studio, by value
This window reimplements a small renderer rather than importing Studio's, so the
look is copied rather than shared. Every mirrored value — clear colour, light
direction, the 0.35 + 0.65 * lambert shading, housing graphite — lives in
preview/appearance.py with the gl_backend.py line it came from, and
tests/test_preview.py re-reads Studio's source and fails if it changes. That
keeps the duplication honest and makes adopting real reuse a one-module swap.
The sensing surface is drawn in a preview-only highlight colour; Studio colours it from the measurement LUT instead. Treat this as a design-time check, not the authoritative renderer.
One caveat about export units
The exported sensor frame is not metres. origin_sensor carries a uniform
scale, so a ~21 mm assembly exports with bounds around ±574 units and
world_to_cam is a scaled (non-rigid) pose: its rotation column norms are
0.003937 on Xynova, not 1. Projection is still self-consistent — all 2508
sensing vertices land inside the 1000×1000 frame — but any consumer that assumes
metres will be wrong. The preview therefore frames the model from its own
bounding sphere rather than Studio's fixed metre limits, and prints a warning
when the pose is not rigid.
Collection check:
blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/validate.py -- sensors/finger/r01
Probe sensitivity bench
orisys-design deform|render|track|sensitivity|optimise answers "which marker
pattern gives the best tracking sensitivity on this sensor?" It deforms the
sensing surface through a known displacement, renders what cam_sensor
actually sees, measures DIS optical flow against the zero-displacement reference,
and reports a ranked, auditable score. The full interface contract is in
docs/probe-pipeline.md.
python -m pip install -e .[probe] # numpy + opencv-python
# 1) deform 31 frames (1 reference + 3 axes x 10 steps)
orisys-design deform sensors/finger-xynova/r01
# 2) render what the camera sees for each frame
orisys-design render sensors/finger-xynova/r01
# 3) DIS flow against the reference
orisys-design track sensors/finger-xynova/r01
# 4) score and rank (writes sensitivity_report.json)
orisys-design sensitivity sensors/finger-xynova/r01 --skip-deform --skip-render --skip-track
# 5) search patch-pattern parameters
orisys-design optimise sensors/finger-xynova/r01 --dry-run
# 6) compare different settings and plot the measured optical flow
orisys-design compare sensors/finger-xynova/r01
Visualising the flow and comparing settings
orisys-design compare runs one marker pattern under several estimator settings and
writes three figures plus settings_comparison.json into
build/probe/<sensor>/<revision>/comparison/:
| figure | what it shows |
|---|---|
settings_flow.png |
one panel per setting: the rendered marker around the probe with the measured flow as arrows, the ROI, the cell grid and the effective window drawn on it, and the applied vs measured displacement. Below each panel, the measured |flow| field on a shared colour scale. |
settings_tuning.png |
score vs the camera-space cell, and vs the cell/window ratio, one series per setting. |
settings_response.png |
two panels. Signal: response vs applied displacement per setting, with each curve's own noise floor drawn as a line and the ideal 1:1 line. Retention: gain(σ)/gain(clean) vs injected noise σ, so a pattern that looks strong but collapses under noise is visible at a glance. |
Only the settings that need re-running do: nx regenerates the texture, radius_mm
regenerates the deformation and frames, and dis_mode reuses the same frames (the
variant directory holds a manifest with absolute frame paths), so the decisive
estimator comparison costs seconds rather than a re-render.
# the shipped setting vs the full-resolution estimator, on identical frames
orisys-design compare sensors/finger-xynova/r01 --grid 31,56,80 --dis-modes 1,0
# a different rubber radius changes the optimum, so compare for stability
orisys-design compare sensors/finger-xynova/r01 --radii 0.5,1.0,1.5
orisys-design sensitivity chains all of steps 1–4 itself, so a single run is
usually enough; the --skip-* flags let you re-score without re-rendering. All
artifacts land in build/probe/<sensor>/<revision>/.
How the deformation is defined
The vertices in the probe vertex group move rigidly by a displacement expressed
in a probe-average orthonormal frame — x and y are shear, z is the surface
normal, i.e. normal force. The 0.01 mm steps run from 0 to 0.10 mm per axis.
Vertices outside the group follow Blender's proportional-editing falloff
(smooth/linear/sharp/constant) out to radius_mm, representing the outer
rubber.
The falloff is computed explicitly in millimetres rather than through
bpy.ops.transform.translate(use_proportional_edit=True): that operator is
viewport-modal and its radius is screen-space, so it is neither available nor
reproducible under blender -b. The .blend is never written — the whole
pipeline is read-only towards it.
deform_report.json reports how many vertices actually moved. That matters: on
the Xynova revision the median mesh edge is 0.63 mm, so the default 0.5 mm radius
reaches only 5 of 2502 vertices outside the group, producing a sharp 6-vertex
island rather than a smooth bulge. Raise --radius-mm to ~1.0 mm (17 vertices)
or 1.5 mm (30 vertices) for a rounded indentation.
Reading the report
render_manifest.json records the render settings actually applied. Two are
forced for correctness: the view transform must be Standard (the scene ships
with AgX, a filmic tonemapper that silently rewrites every marker value) and
denoising is off (it smears the pattern the tracker is meant to measure). The
render also asserts that re-rendering the reference frame produces identical
bytes, because a non-reproducible render invalidates every later number.
sensitivity_report.json carries, per axis and per scale, the measured gain
(measured mm of flow per mm of applied displacement), the snr against the
null-frame floor, cross-axis leakage, and resolvable_step_mm.
Be careful reading small steps. The Xynova design camera renders 500x500, which
is 13.25 px/mm at the probe, so 0.01 mm is only 0.13 px. That is a per-pixel
precision limit, not an estimator limit: because the response is averaged over the
probe ROI (~139 px), the measured zero-input floor is only 0.029 px and a single
0.01 mm step (0.13 px) clears it about 4.5x over. The report therefore states
resolvable_step_mm per axis, reports the floor and the step separately in pixels,
and evaluates the process scale (250x250 by default) alongside the render scale so you
can see what the runtime can actually resolve. Do not assume a sub-pixel step is
unmeasurable — check the floor.
Sensitivity here is a relative ranking under a fixed estimator and camera, not a force calibration, and it must be read under a stated noise model — see the section above. The optimizer's winner is the best of a bounded deterministic candidate grid, and is not a proven global optimum.
The measured gain is confounded with the ROI size
This is the most important caveat in the tool, and it is measured rather than
theoretical. On Xynova the probe patch plus its falloff taper spans only about
13 px, i.e. 1-2 DIS patches (8 px), so DIS regularizes toward the static
background and the coherent motion is measured at roughly 0.67x of the applied
displacement. Injecting a synthetic rigid translation of the same 1.3248 px recovers
17% at a patch radius of 6.6 px (equal to the ROI), 51% at r 10, 81% at
r 20 and 95% at r 40. So the absolute gain depends strongly on radius_mm:
compare patterns only while the radius, ROI and scale are held fixed.
orisys-design optimise varies nx/ny/seed and never the radius for exactly this
reason.
Attribution matters too: the deficit is not aliasing, noise or a diluted average. The real flow's radial profile is flat inside the ROI, and the texture-level known-answer recovers a 2 px shift as 2.0000 px. Only strongly periodic synthetic textures alias (a 3.11 px checker turns a 2 px shift into 8.17 px); the shipped random per-cell pattern does not.
The noise floor was wrong, and it changed the answer
Read docs/probe-findings.md for the conclusions and
recommendations. The short version is below.
This section replaces an earlier claim that nx=31 is the best pattern and that "the marker cell must match the estimator's effective window". That claim is retracted. It was driven by the noise floor, and the floor was not a valid noise model.
floor_px is measured as reference vs a re-render at seed + 1 — Cycles
antialiasing jitter. On this data it differs by ~5 grey levels RMS on half the pixels and
scales with edge contrast. It is not sensor noise, and three checks show it cannot rank
patterns:
-
Its trend reverses under additive noise. p95 |flow| over the ROI, same DIS config:
pattern render-seed injected σ=2 σ=4 nx=80 0.0292 0.0161 0.0278 nx=31 0.0376 0.0084 0.0182 nx=56 0.0134 0.0102 0.0203 -
It understates the real floor. Measured on idle dd02 footage (
data/dd02-0703/190.mp4, 1280x720) with the same estimator: 0.2267 px p95 |flow| over the ROI, dominant marker period 25.5 px, i.e. 0.0089 floor per period. Short baseline (consecutive frames) equals long baseline (vs frame 0) — ratio 0.98 — so this is frame-independent white noise, not drift, and the clip is genuinely idle. Every simulated floor per cell is well below it, and the coarse patterns are understated most (nx=56 by ~6x, nx=31 by ~4x, nx=80 by ~2x) — exactly the bias that made nx=31 and nx=56 look best. -
Signal is not noise-invariant either, so gain must be quoted under a stated model. At +0.10 mm on the x axis, retention is
gain(σ)/gain(clean):pattern σ=4 σ=16 nx=80 dm1 99% 84% nx=56 dm1 96% 71% nx=31 dm1 72% 59%
What the calibrated model supports: at the render scale, radius_mm=0.5, dis_mode=1,
nx=80 has the highest clean response on both axes (x 9.10 vs 4.74 px/mm; y 11.69 vs
10.90) and the best noise retention (84–99% vs 59–73%), so on this evidence the
shipped 80x80 is competitive or better, not worse. What is not yet re-ranked: the
process (250x250) scale and the radius_mm=1.5 block, where the score ordering differs.
Every report now carries noise_models (per-axis gain, floor and response_retention per
σ, measured under one model) and noise_calibration (the real idle numbers above), and
settings_response.png has a retention panel so a pattern that looks strong but collapses
under noise is visible.
What the bench measures
The marker grid is defined across the camera image, so a cell is
render_width / nx pixels, not a millimetre quantity: at 500x500 the shipped 80x80 grid
has a 6.25 px cell and nx=31 has 16.13 px. dis_mode=1 is OpenCV's
setFinestScale(1), so DIS computes flow at half resolution and upscales, giving an
effective window of patch_px * 2**dis_mode = 16 px. Those are the right quantities to
watch — but the earlier reading of them as the explanation of the ranking was an artefact
of the floor above, so treat the ratio as a useful diagnostic, not a validated law.
The measured gain is also confounded with the ROI size: the probe patch plus its
falloff taper spans only ~13 px (1-2 DIS patches), so DIS regularizes toward the static
background. Injecting a synthetic rigid translation of the same 1.3248 px recovers ~17% at
a patch radius of 6.6 px (equal to the ROI), 51% at r 10, 81% at r 20 and 95% at r 40.
Compare patterns only with the radius, ROI and scale held fixed — which is why
orisys-design optimise varies nx/ny/seed and never the radius.
Profiles
compute—sensing_surface+cam_sensorrelative toorigin_sensor. Writesmesh.npz,camera_intrinsics.npz,world_to_cam.npy(here “world” means the sensor frame) andunroll_map_uv_s<scale>.npz, the unwarp/valid-mask domain built from the profile'suv_map:block. Studio and the wheel build consume this tree viaorisys-sdk→assets_dir.studio— documents how to compile a.oryscenefrom the compute export plus the measured unroll map in SDK. Observation cameras in Studio are derived from mesh bounds; no Blendercam_vizis required.
Promotion (SDK / App)
After export:
- Tag this repo, e.g.
finger-r01-assets-v1.0.0. - Copy or attach the
build/export/finger/r01/compute/folder to the SDK asset path (or publish as a release artifact). - Keep
build/export/finger/r01/manifest.jsonbeside the payload, and update optionaldesign-lock.jsoninorisys-sdkwith commit + bundle SHA256. - Run
orisys-appcompile_scene.pyusing the locked compute export + the exported unroll map. The map now travels with the payload; the SDK asset copy is refreshed only here, and only aftertools/scene_assets/compare_uv_map.pyagrees with what ships.
Example lock file: examples/design-lock.json.
Conventions
- Units: metres at export boundaries. Author in mm if you like; set
meters_per_source_unit: 0.001indesign.yaml. - Collections:
FRAMES,EXPORT_COMPUTE,ASSEMBLY,DESIGN_SIM,EXCLUDE. - Objects:
origin_sensor,sensing_surface,cam_sensor. - Matrix names must state direction; exported pose file remains
world_to_cam.npyfor SDK compatibility (sensor frame → camera). - Binary files:
.blend, textures and exported*.npzare committed as ordinary files (no Git LFS)..gitattributesmarks them-textonly.
Git LFS on Gitea (NAS) — not currently used
Status: this repository does not use Git LFS. .gitattributes only marks
binaries as non-text, and real .blend / .png / .pdf bytes are committed
directly. This is deliberate: LFS pointers are unusable without a reachable LFS
server, so a plain clone or git checkout would hand you 131-byte files instead
of images. The notes below are kept as a migration guide for when the NAS server
is ready.
Short answer: not difficult. Gitea has built-in LFS; NAS hosting is common. Plan for extra disk on the NAS volume that holds LFS objects, not just the git bare repos.
Server (one-time, ~5 minutes)
-
Enable LFS in Gitea — edit
app.ini(or Docker env):[server] LFS_START_SERVER = true [lfs] PATH = /data/git/lfsDocker Compose equivalent:
environment: - GITEA__server__LFS_START_SERVER=truePoint
PATHat persistent NAS storage (same volume as/datais fine). -
Restart Gitea after changing config.
-
Optional: set
LFS_JWT_SECRETin Docker/env for stable LFS auth across restarts (generate once, keep in your NAS secrets). -
Reverse proxy: LFS uploads use HTTP(S) batch API. Ensure your NAS proxy allows large request bodies (nginx
client_max_body_size, etc.). SSH git push still works for normal objects; LFS file transfer is usually HTTPS unless you enable pure SSH LFS. -
Backup: include the LFS directory in NAS backup — it is not inside git history.
Requirements: Git ≥ 2.1.2 on the server (Gitea’s embedded git is enough).
Clients (each developer)
git lfs install
git clone git@your-nas:team/orisys-sensor-design.git
cd orisys-sensor-design
git lfs pull
This repo’s .gitattributes does not track anything through LFS today. To turn
LFS on, add the extensions back with a filter (the file contains a ready-made
example line), run git add --renormalize . so real bytes become pointers, and
commit .gitattributes together with the renormalized files.
Typical pitfalls
| Issue | Fix |
|---|---|
Push succeeds but .blend is a tiny pointer file |
Run git lfs install before commit; re-add file |
| LFS upload 413 / timeout | Increase proxy body size and Gitea [server] LFS_HTTP_TIMEOUT if needed |
| Wrong permissions on NAS volume | Match container USER_UID / USER_GID to volume owner |
| Repo already has large blobs in history | Do not rewrite history casually; use git lfs migrate only with team agreement |
Pure SSH LFS (LFS_ALLOW_PURE_SSH) is optional and still has client quirks; HTTPS LFS
behind your NAS reverse proxy is the path of least resistance.
Phase plan
| Phase | Goal |
|---|---|
| A (now) | Repo + r01 + headless compute export |
| B | Lock file in SDK/App + CI reproduces export hashes |
| C (later) | Markers, camera metrics, simulation datasets |