e8d231ec39
统一笔画配置与彩色 patch 的投影网格,替换旧脚本及产物。 已验证 Blender 导出、16 项相关测试、文档与 lint 门禁。
678 lines
32 KiB
Markdown
678 lines
32 KiB
Markdown
# 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 `docs/workflow/sensor-design-workflow.md` in the
|
||
workspace root (`D:\orisys_ws\docs\workflow\sensor-design-workflow.md`).
|
||
This repository intentionally implements a **minimal v1** for a small team.
|
||
|
||
Measured properties of the geometry and cameras — topology, surface area, normals, incidence,
|
||
cell pitch, and the runtime unroll domain — are in
|
||
[`docs/geometry-measurements.md`](docs/geometry-measurements.md). The multi-view ownership design
|
||
and the optics model both depend on them, and they expire when a camera moves.
|
||
|
||
## 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
|
||
|
||
```text
|
||
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; see below)
|
||
|
||
Install the CLI (editable):
|
||
|
||
```powershell
|
||
cd D:\orisys_ws\orisys-sensor-design
|
||
python -m pip install -e .
|
||
```
|
||
|
||
The base install is stdlib + PyYAML. Feature extras are `dev`, `preview`, `optics`,
|
||
`viz` and `probe`; see `requirements.txt` for the flat list, which is what the full
|
||
test suite needs. Blender is only used by the in-scene commands, so `validate`,
|
||
`export`, `texture` and `manifest` run without it.
|
||
|
||
No Git LFS is required — binaries are committed as ordinary files.
|
||
|
||
### Finding Blender on Windows
|
||
|
||
The CLI looks for Blender in this order:
|
||
|
||
1. Env `ORISYS_BLENDER` or `BLENDER_EXE` (absolute path to `blender.exe`)
|
||
2. `blender` / `blender.exe` on `PATH`
|
||
3. Auto-scan `%ProgramFiles%\Blender Foundation\Blender *\blender.exe`
|
||
(prefers `blender.lock.json` version when installed, otherwise newest)
|
||
4. Optional `"executable"` in `blender.lock.json` (local only; do not commit machine paths)
|
||
|
||
```powershell
|
||
$env:ORISYS_BLENDER = "C:\Program Files\Blender Foundation\Blender 5.0\blender.exe"
|
||
```
|
||
|
||
Convenience launcher (auto-finds Blender for this process, then exports):
|
||
|
||
```powershell
|
||
.\tools\export-compute.bat
|
||
```
|
||
|
||
## Commands
|
||
|
||
Four commands for v1:
|
||
|
||
```powershell
|
||
# 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`:
|
||
|
||
```powershell
|
||
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:
|
||
|
||
```powershell
|
||
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:
|
||
|
||
```text
|
||
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:
|
||
|
||
```powershell
|
||
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.
|
||
|
||
```powershell
|
||
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:
|
||
|
||
```text
|
||
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:
|
||
|
||
```yaml
|
||
vector:
|
||
width_mm: 164
|
||
height_mm: 160
|
||
```
|
||
|
||
CLI flags override the shared rendering values for every output:
|
||
|
||
```powershell
|
||
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:
|
||
|
||
```powershell
|
||
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:
|
||
|
||
```powershell
|
||
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
|
||
```
|
||
|
||
`stroke-texture` exports black capsule strokes using the same camera grid,
|
||
projection and UV output size as `texture_patch`. First generate the single-view
|
||
texture job, then run `orisys-design stroke-texture sensors/finger-xynova/r01`.
|
||
The stroke output is independent of the color artwork pixels. The revision's
|
||
`stroke_pattern` block controls the seed, width and length ratios, and orientation
|
||
mode; `--output-dir` selects a fresh destination. The command writes PNG/SVG/PDF,
|
||
resolved settings and a hash manifest. Two-view `--views` jobs are not supported.
|
||
The projected-cell ratios do not guarantee a 0.2 mm physical stroke width;
|
||
measure the production SVG before manufacturing.
|
||
|
||
## 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.
|
||
|
||
```powershell
|
||
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:
|
||
|
||
```yaml
|
||
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:
|
||
|
||
```json
|
||
{ "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.
|
||
|
||
```powershell
|
||
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:
|
||
|
||
```powershell
|
||
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`](docs/probe-pipeline.md).
|
||
|
||
```powershell
|
||
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.
|
||
|
||
```powershell
|
||
# 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`](docs/probe-findings.md) for the conclusions, the tables and
|
||
the numbers; [method](docs/probe-method.md) for why.** The short version:
|
||
|
||
An earlier claim here — that nx=31 is the best pattern and that "the marker cell must match
|
||
the estimator's effective window" — is **retracted**. It was driven by `floor_px`, which is a
|
||
render-seed antialiasing artefact, not sensor noise: its trend reverses under additive noise,
|
||
and it understates the real idle floor (0.2267 px p95 |flow| on `data/dd02-0703/190.mp4`) by
|
||
roughly 2–6x, understating the coarse patterns most.
|
||
|
||
Under the calibrated model, the shipped **80x80 has the highest clean response on both axes
|
||
and the best noise retention** (84–99% vs 59–73%), so it is competitive or better, not worse.
|
||
Not yet re-ranked: the process (250x250) scale and the `radius_mm=1.5` block, where the score
|
||
ordering differs. Every report carries `noise_models` and `noise_calibration`, and
|
||
`settings_response.png` has a retention panel.
|
||
|
||
### 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_sensor` relative to `origin_sensor`. Writes
|
||
`mesh.npz`, `camera_intrinsics.npz`, `world_to_cam.npy` (here “world” means the
|
||
sensor frame) and `unroll_map_uv_s<scale>.npz`, the unwarp/valid-mask domain built from
|
||
the profile's `uv_map:` block. Studio and the wheel build consume this tree via
|
||
`orisys-sdk` → `assets_dir`.
|
||
- **`studio`** — documents how to compile a `.oryscene` from the **compute** export
|
||
plus the measured unroll map in SDK. Observation cameras in Studio are derived from
|
||
mesh bounds; no Blender `cam_viz` is required.
|
||
|
||
## Promotion (SDK / App)
|
||
|
||
After export:
|
||
|
||
1. Tag this repo, e.g. `finger-r01-assets-v1.0.0`.
|
||
2. Copy or attach the `build/export/finger/r01/compute/` folder to the SDK asset path
|
||
(or publish as a release artifact).
|
||
3. Keep `build/export/finger/r01/manifest.json` beside the payload, and update
|
||
optional `design-lock.json` in `orisys-sdk` with commit + bundle SHA256.
|
||
4. Run `orisys-app` `compile_scene.py` using 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 after `tools/scene_assets/compare_uv_map.py` agrees 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.001` in `design.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.npy`
|
||
for SDK compatibility (sensor frame → camera).
|
||
- **Binary files**: `.blend`, textures and exported `*.npz` are committed as
|
||
ordinary files (no Git LFS). `.gitattributes` marks them `-text` only.
|
||
|
||
## 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)
|
||
|
||
1. **Enable LFS in Gitea** — edit `app.ini` (or Docker env):
|
||
|
||
```ini
|
||
[server]
|
||
LFS_START_SERVER = true
|
||
|
||
[lfs]
|
||
PATH = /data/git/lfs
|
||
```
|
||
|
||
Docker Compose equivalent:
|
||
|
||
```yaml
|
||
environment:
|
||
- GITEA__server__LFS_START_SERVER=true
|
||
```
|
||
|
||
Point `PATH` at persistent NAS storage (same volume as `/data` is fine).
|
||
|
||
2. **Restart Gitea** after changing config.
|
||
|
||
3. **Optional**: set `LFS_JWT_SECRET` in Docker/env for stable LFS auth across restarts
|
||
(generate once, keep in your NAS secrets).
|
||
|
||
4. **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.
|
||
|
||
5. **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)
|
||
|
||
```powershell
|
||
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 |
|