# Probe sensitivity pipeline — frozen interfaces This document is the **interface freeze** for the probe sensitivity bench. Four workstreams code against it in parallel. Treat every schema, path, and name here as a contract: if it must change, the Lead changes this file first and tells everyone. Scope: deform the sensing surface through a known displacement, render what `cam_sensor` sees, measure optical flow against the zero-displacement reference, and report a ranked, auditable sensitivity score per marker pattern. Measured ground truth for `sensors/finger-xynova/r01` (from `geometry.blend`, read-only; the tool never writes the `.blend`): | Fact | Value | |---|---| | `sensing_surface` | 2508 verts, 4834 polys, UV maps `compute`, `produce`; no modifiers, no shape keys | | `probe` vertex group | 6 verts `[215, 216, 218, 219, 327, 329]`, weight 1.0 | | Probe patch | 0.755 x 1.018 mm, flat to 7.6 um, all 6 normals within 2.6 deg of their mean | | Authoring unit | 1 Blender unit = 1 mm | | Camera | `cam_sensor` PERSP, lens 0.5469, sensor_width 1.4, fit HORIZONTAL | | Render | 500x500, CYCLES, PNG RGBA | | Projection | mesh spans x 8.13..491.88, y 33.13..397.59 (fully inside) | | Scale | **13.430 px/mm** (p50) -> 0.01 mm = **0.134 px**, 0.1 mm = **1.34 px** | | Mesh edges | min 0.0504, p50 0.6285, p90 0.991, max 1.816 mm | | Falloff at r=0.5 mm | **only 5 of 2502** non-probe verts (0.285 x1, 0.4161..0.4168 x4; next 0.796) | | DIS patch (8 px) | 0.5957 mm at 500x500; an 80x80 marker cell is 0.2313 mm = 3.11 px | | Blend sha256 | `1C8A050B8490B6832D3A9F269DFEF3DAFBF94DBFE3E943B3956BE1104A50D9C9` | ## Pipeline ``` design.yaml [probe:] | +-- deform.py (host, py310, no bpy) -> _deform_job.json | probe_deform.py (Blender) -> probe_frames.npz + deform_report.json | +-- render.py (host, no bpy) -> _render_job.json | probe_render.py (Blender) -> frames/*.png + render_manifest.json | +-- flow.py (host, cv2) -> _track_job.json, flow/, flow_report.json | (or is called in-process by cli.py) | +-- sensitivity.py -> sensitivity_report.json +-- optimise.py -> optimizer_report.json ``` `deform.frame_plan()` is the **single ordering authority**. Every job carries the resolved frame list so render and analysis cannot disagree about frame order. All artifacts live under: ``` build/probe/// ``` resolved the same way `texture.py` resolves `build/textures/...` (see `texture_revision_rel`; reuse it, do not re-invent it). ## Frame plan Positive-only sweep, axes in the order `probe.axes` (default `x, y, z`), steps `step_mm`, `2*step_mm`, ... up to and including `max_mm`. | index | name | axis | step_mm | |---|---|---|---| | 0 | `000_reference` | `null` | 0.0 | | 1 | `001_x_+0.01mm` | `x` | 0.01 | | ... | ... | ... | ... | | 10 | `010_x_+0.10mm` | `x` | 0.10 | | 11..20 | `0NN_y_+0.0Xmm` | `y` | ... | | 21..30 | `0NN_z_+0.0Xmm` | `z` | ... | `F = 1 + len(axes) * round(max_mm / step_mm)` = **31** for the default settings. PNG for frame `i` is `frames/.png`. The displacement for frame `i` is the frame's unit axis of the **probe-average basis** (frozen below) scaled by `step_mm`. All 6 probe vertices move by exactly that vector; every other vertex moves by `weight * vector`. ## The probe-average basis Built from the area-independent average of the 6 probe vertex normals, then made orthonormal so the three sweeps are exactly orthogonal and comparable: ``` z = normalize(sum(normal_i for i in probe)) # surface normal x = normalize(cross(Vector((0,0,1)), z)) # fall back to (1,0,0) if degenerate y = cross(z, x) origin = mean(co_i for i in probe) ``` Measured for Xynova (orthonormal to 1e-10): ``` z = (-0.000027, -0.061749, 0.998092) x = ( 1.000000, -0.000431, 0.000000) y = ( 0.000430, 0.998092, 0.061749) origin = (74.76961, 16.21550, 120.80202) ``` An axis vector for displacement is `{'x': x, 'y': y, 'z': z}[axis] * step_mm` in the **mesh object's local space**. Since `sensing_surface.matrix_world.to_scale() == (1,1,1)` and `meters_per_source_unit == 0.001`, local units are mm and the vector is in mm. ## Proportional falloff We deliberately do **not** use `bpy.ops.transform.translate(use_proportional_edit=True)`: that operator is viewport-modal and its `proportional_size` is in screen pixels relative to a 3D-view region, which does not exist under `blender -b` and would not be reproducible. The falloff is computed explicitly, in mm, in a bpy-free module. Semantics match Blender's proportional editing: the falloff distance is measured from the **original** positions of the selected (probe) vertices, so the weight set is constant across all frames. ``` t = d / radius_mm # d = min distance to any probe vertex smooth: w = 1 - (3t^2 - 2t^3) # Blender's "Smooth" curve; 1 at t=0, 0 at t>=1 linear: w = 1 - t sharp: w = (1 - t)^2 constant: w = 1 w = 0 whenever t >= 1, and w = 1 for probe members ``` ## `design.yaml` `probe:` block ```yaml probe: mesh: sensing_surface camera: cam_sensor group: probe uv_map: compute texture: patch_texture_compute.png radius_mm: 0.5 falloff: smooth step_mm: 0.01 max_mm: 0.10 axes: [x, y, z] resolution: [500, 500] process_resolution: [250, 250] tracker: algorithm: dis dis_preset: medium dis_mode: 1 render: engine: CYCLES samples: 16 denoise: false view_transform: Standard interpolate: Closest seed: 0 ``` Built-in fallbacks live in `deform.DEFAULT_PROBE` and are merged `DEFAULT_PROBE <- design.yaml probe <- CLI overrides`, exactly like `texture.merge_texture_patch`. Unknown keys are an error. Validation returns one message per problem and raises `ProbeConfigError`. ## Job files (stdlib JSON; Blender never reads YAML) ### `_deform_job.json` ```json { "schema": "orisys-sensor-design/probe-deform-job-v1", "revision_dir": "", "source_blend": "", "output_dir": "", "objects": {"mesh": "sensing_surface", "group": "probe"}, "settings": { "radius_mm": 0.5, "falloff": "smooth", "step_mm": 0.01, "max_mm": 0.10, "axes": ["x", "y", "z"] }, "frames": [ {"index": 0, "name": "000_reference", "axis": null, "step_mm": 0.0}, {"index": 1, "name": "001_x_+0.01mm", "axis": "x", "step_mm": 0.01} ], "artifacts": {"frames": "/probe_frames.npz", "report": "/deform_report.json"} } ``` ### `_render_job.json` ```json { "schema": "orisys-sensor-design/probe-render-job-v1", "revision_dir": "", "source_blend": "", "output_dir": "", "objects": {"mesh": "sensing_surface", "camera": "cam_sensor"}, "settings": { "uv_map": "compute", "texture": "", "width": 500, "height": 500, "radius_mm": 0.5, "engine": "CYCLES", "samples": 16, "denoise": false, "view_transform": "Standard", "interpolate": "Closest", "seed": 0 }, "frames": [""], "null_frame": {"name": "reference_null", "seed": 1}, "artifacts": {"frames_npz": "/probe_frames.npz", "report": "/render_manifest.json"} } ``` `null_frame` is a second render of the reference at a different Cycles seed. It is never passed to the tracker: it exists only to measure the estimator's zero-input floor. ### `_track_job.json` ```json { "schema": "orisys-sensor-design/probe-track-job-v1", "output_dir": "", "settings": { "axes": ["x", "y", "z"], "step_mm": 0.01, "max_mm": 0.10, "radius_mm": 0.5, "process_resolution": [250, 250], "tracker": {"algorithm": "dis", "dis_preset": "medium", "dis_mode": 1} }, "scales": [ {"name": "render", "width": 500, "height": 500, "resample": false}, {"name": "process", "width": 250, "height": 250, "resample": true} ], "artifacts": {"manifest": "/render_manifest.json", "strategy": "/strategy_probe.json"} } ``` ## `probe_frames.npz` The contract between the deformation and rendering workstreams. | Key | Shape / dtype | Meaning | |---|---|---| | `base_vertices` | `(V, 3) float64` | original object-local coords, all V vertices | | `vertices` | `(F, V, 3) float64` | deformed coords per frame; index 0 = reference | | `frame_names` | `(F,) ", "source_blend": {"path": "", "sha256": "..."}, "mesh": "sensing_surface", "group": "probe", "probe_indices": [215, 216, 218, 219, 327, 329], "origin": [74.76961, 16.2155, 120.80202], "basis": [[1.0, -0.000431, 0.0], [0.00043, 0.998092, 0.061749], [-0.000027, -0.061749, 0.998092]], "basis_orthonormal": true, "normal_angle_max_deg": 2.553, "flatness_mm": 0.007611, "radius_mm": 0.5, "falloff": "smooth", "vertex_count": 2508, "edge_p50_mm": 0.628501, "weights": {"min": 0.0, "max": 1.0, "members_at_one": 6, "partial": 5, "unaffected": 2497}, "affect_bands": {"1.0": 6, "0.3-0.5": 1, "0.05-0.1": 4}, "frames": [{"index": 0, "name": "000_reference", "axis": null, "step_mm": 0.0, "max_delta_mm": 0.0, "moved_vertices": 0}], "warnings": ["radius 0.5 mm reaches only 5 vertices outside the probe group ..."] } ``` `moved_vertices` is the number of vertices whose displacement is non-zero **in that frame**: `0` for the reference, and `6 + partial` (= 11 on Xynova) for any non-zero step. It is not the probe member count, and it is not the same for every frame — reporting 6 on the reference would contradict `max_delta_mm: 0.0`. The `weights` block remains the authority for how many vertices are partially weighted. `warnings` is the honesty channel. Emit one whenever `affected (partial) < 4 * len(probe_indices)`, or `radius_mm < edge_p50_mm`. ## `render_manifest.json` ```json { "schema": "orisys-sensor-design/probe-render-manifest-v1", "generated_at": "", "source_blend": {"path": "", "sha256": "..."}, "texture": {"path": "", "sha256": "..."}, "uv_map": "compute", "width": 500, "height": 500, "settings_applied": {"engine": "CYCLES", "samples": 16, "denoise": false, "view_transform": "Standard", "look": "None", "exposure": 0.0, "interpolate": "Closest", "seed": 0}, "mesh_px_per_mm": {"min": 10.499, "p05": 10.946, "p50": 13.430, "p95": 22.886, "max": 38.594, "basis": "per-mesh-edge projected px / 3D mm, all 7341 edges"}, "probe_px_per_mm": {"p50": 13.248, "x": 13.248, "y": 17.670, "z": 1.519, "basis": "probe-local: |centroid_px(step) - centroid_px(0)| / step_mm, per axis at its largest step"}, "probe_roi_px": {"u": 0.0, "v": 0.0, "radius": 0.0}, "reference_index": 0, "reference_deterministic": true, "frames": [{"index": 0, "name": "000_reference", "axis": null, "step_mm": 0.0, "png": "frames/000_reference.png", "sha256": "...", "width": 500, "height": 500, "centroid_px": [249.61, 214.38]}], "null": {"name": "reference_null", "seed": 1, "png": "frames/reference_null.png", "sha256": "..."} } ``` `reference_deterministic` reports the result of rendering frame 0 twice in the same session with the same seed and comparing the saved bytes. It must be `true`; if it is not, the render is not reproducible and the CLI must fail. **Compare decoded pixels, not file bytes, and allow a 1-LSB tolerance.** This was established the hard way: with literal whole-file byte equality the check rejected candidate `nx=36` on the real run, and PNG chunk analysis showed why — the two files had **identical chunk structure** (the only `tEXt` chunk was the deterministic `cycles.ViewLayer.samples`) yet their decompressed pixel data differed in **2 bytes of 250,250**, at two scanline offsets. That is Cycles CPU thread-order floating-point nondeterminism tipping a boundary pixel by one 8-bit step, not a metadata artefact and not a defect in the render. A gate that fails on a 1-LSB difference is not measuring reproducibility; it is measuring thread scheduling, and because it is **content-dependent** it silently disqualifies some patterns and biases a pattern search. So: - Strip only the known-volatile `tEXt` chunks as before (`Date`, `Time`, `RenderTime`, `cycles.ViewLayer.*_time`); keep image chunks byte-for-byte. - Decode both PNGs and require the same shape, `max |delta| <= 1` per 8-bit channel, and a differing-pixel fraction at or below **0.05%**. Record the measured `max_abs_delta` and `differing_fraction` in the manifest either way. - Fail loudly when a tolerance is exceeded, so real nondeterminism still stops the run. - Never compare raw file bytes: PNG compression and metadata are not the measurement. `probe_roi_px` is the probe centroid projected to pixels plus the radius in pixels that corresponds to `radius_mm` (`radius = radius_mm * probe_px_per_mm.p50`), all measured from the actual projection (not assumed). The tracker uses it to build its ROI, and the ROI is **fixed at the reference centroid** for every frame — a moving ROI would bias the ROI mean as the probe moves, which is exactly the signal we are trying to measure. ### Two different pixel scales, and why they must not be confused - `mesh_px_per_mm` is a **mesh-wide** statistic: for every mesh edge, its projected length in pixels divided by its 3D length in mm, summarised over all 7341 edges. It describes how the whole surface is sampled in the camera. It is **not** the gain to convert the probe's measured flow into millimetres. - `probe_px_per_mm` is the **probe-local** scale, and it is the one the scorer must use: `|centroid_px(step) - centroid_px(0)| / step_mm`, per axis, i.e. how many image pixels the probe actually moves per millimetre applied. It is anisotropic, and `z` (motion along the surface normal) is expected to be **much smaller** than x and y because normal motion is largely out of the image plane. That is a real physical property of the sensor, not a defect. Because the `z` scale can approach zero, dividing a measured response by it is ill-conditioned. The scorer therefore reports gain in **px per mm** as the primary quantity and only derives mm per mm when the axis scale clears `sensitivity.min_local_scale_px_per_mm` (default **1.0**; a module constant in `sensitivity.py`, deliberately not a `design.yaml` key so it cannot be tuned into silence). Never divide by a scale below that; report `null` and warn instead. A large "mm/mm" gain produced by dividing by a near-zero scale would be an artefact, not a sensitivity. The default of 1.0 is validated by real data rather than chosen by feel. Measured per-axis probe scales: **x 13.248, y 17.670, z 1.519 px/mm at the render scale**, and **x 6.624, y 8.835, z 0.760 px/mm at the process scale**. So 1.0 discriminates exactly where it should on this revision: render-scale `z` (1.519) is admitted and yields a derived `gain_mm_per_mm` of 0.445 backed by 7 steps above the floor at `snr` 4.2, while process-scale `z` (0.760) is correctly gated to `null`. Note what this implies about the earlier worry that `z` would be unmeasurable: at the **process** scale a 0.1 mm normal displacement is only 0.076 px against a 0.19 px floor, so it genuinely is unmeasurable there; at the **render** scale it is 0.152 px against a 0.028 px floor, so it is measurable from about `0.04 mm` upward. The gate is a guard against dividing by a degenerate scale, **not** a substitute for the measured `snr`, `points_above_floor` and `resolvable_step_mm`, which are the quantities that decide whether an axis is useful. Report all of them; do not suppress a number that the data supports. ### What actually limits the measurement (measured, not assumed) Do **not** attribute the response deficit to aliasing, noise, or ROI dilution. The render-scale deficit is **spatial resolution of the measurement region**, established by these measurements on the real frames: - The real `x +0.10 mm` flow radial profile is essentially uniform inside the ROI (`-0.9162`, `-0.9096`, `-0.8725` px at r 0-2, 2-4, 4-6.6), with `|flow|` p5..p100 of `0.81..0.93` px. So it *is* a coherent translation, not noise and not a diluted average: the motion is simply shrunk to about `0.67x` of the applied `1.3248 px`. - Injecting a synthetic **local rigid translation** of `1.3248 px` into the reference and measuring it over the same ROI recovers only **17%** at a patch radius of 6.6 px (equal to the ROI), then **33%** at r 8, **51%** at r 10, **63%** at r 13, **81%** at r 20 and **95%** at r 40. - Therefore the attenuation is caused by the moving probe patch plus its falloff taper spanning only about **13 px, i.e. 1-2 DIS patches (8 px)**, so DIS regularizes toward the static background. At the process scale the same region is about 6.5 px, under one patch, and the response collapses while the forward-backward error grows from 1.6 to 8.8 (x) and 15.8 (y). Two consequences the report must state rather than bury: 1. **The measured gain is confounded with the ROI size.** It grows strongly with `radius_mm` because a larger radius gives DIS more moving texture to lock onto. Relative ranking of marker patterns is valid **only** while `radius_mm`, the ROI and the scale are held fixed across candidates — which is why `run_optimise` varies `nx`/`ny`/`seed` and never the radius. 2. **"Below one pixel" and "below the estimator floor" are different statements and must not be conflated.** The measured zero-input floor is **0.0292 px at the render scale**, so a `0.01 mm` step (`0.1325 px`) is about **4.5x above the floor**: the ROI-averaged response genuinely resolves single steps at 500x500, with monotone per-step responses (`0.0810, 0.1316, 0.1971, ... 0.8859` px). The earlier expectation that the sub-pixel step would be lost in the floor is **not** supported by the measurement; what is lost is *per-pixel* precision, not the ROI mean. Report the floor and the step in pixels and let the numbers speak. Each `frames[]` entry also carries `centroid_px`: the probe centroid projected to pixels **for that frame's deformation**, as full-precision floats (not rounded). This is the authoritative source for the image-space displacement direction the sensitivity stage needs. It is a small number of pixels (1.34 px across the whole 0.1 mm sweep at 500x500), which is why it is recorded as a float and why the scorer regresses over all steps rather than differencing a single pair. ## `flow_report.json` ```json { "schema": "orisys-sensor-design/flow-report-v1", "generated_at": "", "tracker": {"algorithm": "dis", "dis_preset": "medium", "dis_mode": 1, "finest_scale": 1, "source": "design.yaml probe.tracker"}, "known_answer": {"passed": true, "cases": [ {"dx": 2, "dy": 0, "measured_u": 2.0, "measured_v": 0.0, "tol": 0.25}, {"dx": 0, "dy": 2, "measured_u": 0.0, "measured_v": 2.0, "tol": 0.25}]}, "reference_texture_known_answer": { "passed": false, "gating": false, "note": "a known shift applied to the real rendered marker; a periodic " "pattern legitimately fails this when its cell approaches the DIS " "patch, so it is a diagnostic, not a plumbing gate", "cases": [{"dx": 2, "dy": 0, "measured_u": 8.17, "expected_u": 2.0}]}, "reference_self": {"max_abs_flow_px": 0.0}, "scales": [{ "name": "render", "width": 500, "height": 500, "resample": false, "px_per_mm": 13.43, "mm_per_px": 0.07446, "frames": [{ "index": 1, "name": "001_x_+0.01mm", "axis": "x", "step_mm": 0.01, "roi": {"u": 249.6, "v": 214.4, "radius": 6.7, "pixels": 141}, "flow": {"mean_u": 0.02, "mean_v": -0.01, "mean_mag": 0.03, "p50_mag": 0.02, "p95_mag": 0.08, "max_mag": 0.31, "coverage": 0.41}, "response_proj_px": 0.02, "response_mm": 0.0015, "forward_backward_error": 1.87} ], "null": {"floor_px": 0.05, "floor_source": "reference_null"} }] } ``` Per-frame flow arrays are also written to `flow//.npy` as `float32 (H, W, 2)` so the sensitivity stage and any later inspection can reuse them without re-running DIS. `response_mm` and `response_proj_px` are informational. **Pixels are authoritative** (`response_proj_px`); `response_mm` is derived through a single isotropic scalar scale (`px_per_mm`, the probe-local p50) and therefore *understates or overstates* the true conversion on an anisotropic axis — notably `z`. The scorer must use `response_proj_px` plus the per-axis local scale from `render_manifest.json` and must not consume `response_mm`. Each scale records `px_per_mm_source` naming the exact manifest field it came from, so the provenance of the scalar is auditable. ## `sensitivity_report.json` ```json { "schema": "orisys-sensor-design/sensitivity-report-v1", "generated_at": "", "revision": "sensors/finger-xynova/r01", "texture": {"path": "", "sha256": "...", "grid": [80, 80], "cell_mm": [0.23125, 0.205]}, "probe": {"group": "probe", "origin": [...], "basis": [...], "radius_mm": 0.5, "falloff": "smooth", "affected_vertices": 5}, "tracker": {"dis_preset": "medium", "dis_mode": 1, "patch_px": 8}, "scales": [{ "name": "render", "px_per_mm": 13.43, "displacement_px_per_step": 0.1343, "cell_px": 3.11, "cell_over_patch_px": 0.389, "null_floor_px": 0.05, "null_floor_basis": "render_seed_reproducibility", "axes": [{ "axis": "x", "direction_px": [0.999, -0.031], "direction_source": "centroid_regression", "points": [{"step_mm": 0.01, "applied_px": 0.134, "response_px": 0.02, "above_floor": false}], "summary": {"gain_px_per_mm": 6.2, "gain_mm_per_mm": 0.46, "scale_px_per_mm": 13.42, "snr": 0.42, "spearman": 0.95, "resolvable_step_mm": null, "saturation_mm": null, "cross_axis_leak": 0.18} }], "score": 0.11, "verdict": "unresolvable" }], "overall": {"score": 0.11, "verdict": "unresolvable", "best_scale": "render", "best_axis": "z"}, "warnings": ["0.01 mm is 0.134 px at 500x500; a single step is below the estimator floor"], "notes": [] } ``` ### Scoring (every input measured; no assumed constants) For one scale and one axis: 1. `d_hat_px` — unit image-space direction of the applied displacement, from `render_manifest.json`'s per-frame `centroid_px`. Regress `centroid_px` on `step_mm` over every frame of that axis and normalize the slope vector `(du/dstep, dv/dstep)`; this is preferred over a single 0 -> `+max_mm` finite difference because the whole sweep is only ~1.3 px. Fall back to the two-point finite difference when the regression is degenerate, and to the ROI-mean flow at the `+max_mm` frame when `centroid_px` is absent. Record which was used as `direction_source` per axis. 2. `response_i` = mean over ROI pixels of `dot(flow_i, d_hat_px)`, in px. Sign kept. 3. `floor_px` = p95 of `|response|` measured on `reference_null`, projected on the same `d_hat_px`. Reported per axis. 4. `gain_px_per_mm` = OLS slope of `response_px` vs `applied_mm`, over the steps with `|response_i| > floor_px`. Requires >= 3 such points, else `null`. **This is the primary sensitivity**: measured pixels of probe response per millimetre applied. 5. `scale_px_per_mm` = `probe_px_per_mm[axis]`. Then `gain_mm_per_mm` = `gain_px_per_mm / scale_px_per_mm`, reported **only** when `scale_px_per_mm >= min_local_scale_px_per_mm` (default 1.0); otherwise `null` plus a warning. Never divide by a near-zero scale: for the `z` axis that would manufacture an enormous gain no camera could observe. 6. `snr` = `|gain_px_per_mm| * max_mm / max(floor_px, eps)` — both terms in px, so it is dimensionally consistent and comparable across patterns and axes. 7. `spearman` = Spearman rank correlation of `response_i` vs `applied_mm` over all steps; clamped to `[0, 1]`. 8. `cross_axis_leak` = mean `|dot(flow, d_perp_px)|` / mean `|dot(flow, d_hat_px)|` over the ROI and steps, clamped to `[0, 1]`. 9. `score` = `snr * spearman * (1 - min(leak, 0.5))`. 10. `resolvable_step_mm` = the smallest `step_mm` with `|response_i| > floor_px`, else `null`. 11. `saturation_mm` = the first step where `response` stops increasing (monotonically decreasing after a rise), else `null`. `verdict`, in precedence order: - **`ambiguous`** — the tracker's `reference_texture_known_answer` failed for this scale: a known shift applied to the *actual rendered marker* was not recovered. For a **periodic** marker this is expected once the cell approaches the DIS patch size — DIS locks onto the period and reports the shift modulo that period — and it means the measured direction and magnitude are not trustworthy, so no score can promote this pattern. This takes precedence over everything below. - `resolvable` — `gain_px_per_mm` is not null and `snr >= 3`. - `marginal` — `gain_px_per_mm` is not null and `snr < 3`. - `unresolvable` — `gain_px_per_mm` is null. `overall.score` = mean of `score` over scales and axes. `overall.verdict` = `resolvable` only if every scale and axis is `resolvable`; `ambiguous` if any scale is `ambiguous`; otherwise the worst remaining verdict. Measured evidence for why `ambiguous` must exist (estimator, 500x500, probe scale 13.43 px/mm, DIS medium, `dis_mode=1`): a **2 px** known shift measured **8.17 px** on a 3.11 px checker cell (aliased onto the 6.22 px period), **0.0 px** on a 1 px cell, **-19.8 px** on 3.11 px grid lines, but **exactly 2.000 px** on a 12.4 px cell and **exactly 2.000 px** on a broadband random texture. A periodic pattern aliases a sub-period shift onto the wrong period; a broadband one does not. That is the whole reason the anisotropy and periodicity of the marker matter, and it is measured, not assumed. ## The floor is not a noise model `flow_report.json`'s `scales[].null.floor_px` is measured as `reference` vs a re-render at **render seed + 1**: Cycles antialiasing jitter, ~5 grey levels RMS on half the pixels and scaling with edge contrast. It is labelled `floor_basis: "render_seed_reproducibility"` and **must not be used to rank patterns**. Measured on this bench: - its ordering **reverses** under additive noise (render-seed: nx56 < nx80 < nx31; σ=2: nx31 < nx56 < nx80); - it **understates** the real idle floor, coarse patterns most. The calibrated replacement is: - **`design.yaml` `probe.noise`**: `idle_footage` (real no-contact recording), `roi_fraction`, `max_frames`, `sigmas`, `draws`, `seed`, `models`, `scales`. - **`floorcal.measure_idle_floor`**: runs the *same* `FixedReferenceFlow` on idle footage and reports `floor_px_short` (consecutive frames, noise), `floor_px_long` (vs frame 0, noise + drift), `dominant_period_px`, `floor_per_period_short|long` and `drift_ratio`. A ratio near 1 means the clip is idle and the floor is frame-independent white noise. On `data/dd02-0703/190.mp4` (1280x720): **0.2267 px**, period **25.5 px**, **0.0089 floor per period**, ratio **0.98**. - **`flow.noise_model_pass`**: additive white Gaussian applied to **both** the reference and the displaced frames, so the signal and the floor come from one model. Written to each scale as `noise_models` (`basis`, `draws`, `sigmas`, per-axis `gain_px_per_mm`, `floor_px`, `floor_samples`). - **`sensitivity.attach_noise_models`**: adds `response_retention` (`gain(σ)/gain(clean)`) and `snr_noise` (same-model floor) per axis, and emits a `model_disagreement` note when the response moves more than 15%. - **`sensitivity.noise_calibration`**: top-level `noise_calibration` block with the real numbers, cached as `idle_floor.json` keyed by the source's size and mtime. Skip with `--no-calibration`. Schema: `flow-report-v2` and `sensitivity-report-v3`. All additions are additive; a report without `noise_model` is still valid and means the render-seed model only. ### Consequence for the ranking Under the calibrated model 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 retention** (99%/84% at σ=4/16 against nx=31's 72%/59%). The earlier "nx=31 wins" and "the cell must match the effective window" conclusions were artefacts of the render-seed floor and are retracted; the window ratio remains a useful diagnostic, not a validated law. The process scale and the `radius_mm=1.5` block are not yet re-ranked. ## Out-of-scope / next hardware ask The idle clip gives only one pattern, so it validates the floor's **magnitude** and **mechanism**, not its pattern trend. A deliberate no-contact recording at the shipped configuration would let the trend itself be measured, and is the natural next ask. ## `optimizer_report.json` ```json { "schema": "orisys-sensor-design/optimizer-report-v1", "generated_at": "", "search": {"grid": [15, 20, 24, 28, 31, 36, 42, 48, 56, 64, 80], "seeds": [7], "render_resolution": [250, 250], "candidates": 11, "objective": "sensitivity_report.overall.score"}, "candidates": [{"nx": 31, "ny": 31, "seed": 7, "score": 0.44, "verdict": "marginal", "cell_px": 8.01, "report": "", "texture_sha256": "..."}], "ranked": ["31@7", "36@7"], "winner": {"nx": 31, "ny": 31, "seed": 7, "score": 0.44, "confirmed_at_resolution": [500, 500], "confirmed_score": 0.39, "report": ""}, "notes": ["Ranked by the measured score under a fixed estimator and camera; this is not a global optimum."] } ``` The optimizer searches `(nx, ny, seed)`. Candidates are enumerated in a **deterministic sorted order** so two runs produce byte-identical rankings. Each candidate regenerates the marker texture, then runs deform -> render -> track -> sensitivity at the search resolution. Only the winner is re-run at the full `probe.resolution` and its confirmed score recorded. ### The search resolution must be the resolution you care about A reduced search resolution **answers a different question** and must not be used to rank patterns. Measured on the real run, searching at 250x250 and confirming at 500x500 produced a winner that the real resolution disagrees with: | pattern | search score @250 | render score @500 | overall | |---|---|---|---| | `nx=48` (search winner) | **8.94** | 20.95 | 15.39 | | `nx=80` (shipped) | **1.77** (ranked 10th of 10) | **36.77** | **22.22** | At 250x250 the `nx=80` cell is 1.53 px, below the DIS-patch Nyquist limit, so it scored near the bottom; at 500x500 the same cell is 3.06 px and it scored *top*. The shipped pattern is therefore the best of the grid at the real resolution and one of the worst at the processed one — and a 250-based search excludes it. That is physics (the two scales genuinely resolve different cells), not a bug, but it means the default `search_resolution` must be `probe.resolution`, not a reduced size. Rendering 31 frames at 500x500 costs about 14 s, so a full-resolution search over the default grid is only a few minutes and there is no reason to trade correctness for it. Two further consequences for the report: - Record **`search_resolution` and `confirm_resolution`**, and mark the winner's `confirmed_score`, so a reader can see whether the search and the confirm measured the same thing. A large gap between them means the search resolution was wrong. - Rank and report candidates **per scale** as well as by the mean. "Best" is scale-dependent here: `nx=80` wins the render scale while `nx=48` balances both. `overall.score` is the mean over scales, which is a deliberate modelling choice, not a neutral one, and the notes must say so. ## CLI surface ``` orisys-design deform [--output-dir DIR] [--radius-mm F] [--falloff MODE] [--step-mm F] [--max-mm F] [--group NAME] [--mesh NAME] [--axes x,y,z] orisys-design render [--output-dir DIR] [--resolution WxH] [--samples N] [--uv-map NAME] [--texture PATH] [--seed N] orisys-design track [--output-dir DIR] [--process-resolution WxH] [--no-process-scale] orisys-design sensitivity [--output-dir DIR] [--skip-deform] [--skip-render] [--skip-track] [--radius-mm F] [...] orisys-design optimise [--output-dir DIR] [--grid LIST] [--seeds LIST] [--search-resolution N] [--top N] [--dry-run] ``` Default `--output-dir` is `build/probe//`. **All argparse wiring lives in `cli.py`, owned by the Lead.** The feature modules expose only plain entry points and must not add their own argument helpers or duplicate `cmd_*` functions: - `deform.py`: `resolve_probe`, `probe_overrides_from_args`, `frame_plan`, `build_deform_job`, `write_deform_job`, `resolve_probe_output_dir` - `render.py`: `resolve_marker_texture`, `build_render_job`, `write_render_job`, `render_manifest_path` - `flow.py`: `build_track_job`, `write_track_job`, `run_track_job` - `sensitivity.py`: `run_sensitivity(settings, output_dir, *, manifest_path=None, flow_report_path=None, progress=print)` — **scoring only**; it must not re-run the deform/render/track stages, because `cli.py` already chains them (and `--skip-*` exists precisely to reuse their artifacts) - `optimise.py`: `run_optimise(paths, settings, *, grid, seeds, search_resolution, top, dry_run=False, runner=None, progress=print)`. The per-candidate `runner` is an injected callable; `cli.py` supplies one that runs the real stages, and tests supply a fake. Do not resolve stage functions by `inspect` or by guessing module layout. Exit codes, matching the existing convention: | Code | Meaning | |---|---| | 0 | success | | 1 | scene / metadata / geometry error | | 2 | config error, or a missing optional dependency | | 3 | a required artifact is missing or empty | | 4 | (reserved; not used by this pipeline) | ## Failure modes that must fail loudly - Probe group missing, empty, or not on the requested mesh. - The basis is not orthonormal within 1e-6. - A displacement vector cannot be expressed in the basis. - The marker texture PNG is missing or empty. - `view_transform` is not `Standard` (AgX or Filmic silently destroys marker values). - Frame count/order in `frames/` disagrees with the plan, or a PNG is missing/empty. - `reference_deterministic` is false. - The tracker's known-answer test fails. - `cv2` is unavailable -> exit 2 with `python -m pip install -e .[probe]`. - The probe ROI falls outside the frame at the process scale -> warn and truncate, do not silently report a full-ROI number. ## Non-goals Large-deformation robustness beyond `max_mm`; signed/bidirectional sweeps; per-vertex normal indentation; refraction through `shell`/`rubber-1`; wiring the SDK processed image pipeline (`resolution_stitch`, `source_scale`) into the estimator; absolute force calibration. Sensitivity here is a **relative ranking under a fixed estimator and camera**, not a force measurement.