1. added blender deformation probe for marker size picking and optimization

2. added unwarp map export in current cli, don't need to generate by precompute in sensor running
This commit is contained in:
ykcheungab
2026-09-25 15:44:44 +08:00
parent bc5a928852
commit c377c14474
34 changed files with 18032 additions and 1 deletions
+382
View File
@@ -0,0 +1,382 @@
# Probe bench: findings and recommendations
Short, decision-oriented summary of what the sensitivity bench has established. The
mechanics are in `docs/probe-pipeline.md` (contract) and `docs/probe-method.md` (operator
guide). This file is the *conclusion* — read it first.
Last updated after the nx 60-120 sweep at 500x500 and the 250x250 cross-check.
---
## 1. Retraction: the noise floor was not a noise model
An earlier version of these docs claimed **nx=31 is the best pattern** and **"the marker
cell must match the estimator's effective window"**. Both are **retracted**.
`floor_px` was measured as `reference` versus a re-render at **render seed + 1** — Cycles
antialiasing jitter, about 5 grey levels RMS on half the pixels, scaling with edge
contrast. It is not sensor noise, and it cannot rank patterns:
1. **Its trend reverses** under additive noise. p95 |flow| over the ROI, same DIS config:
| pattern | render-seed | injected sigma=2 | sigma=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 |
2. **It understates the real floor, coarse patterns most.** Measured on idle footage
(`data/dd02-0703/190.mp4`, 1280x720) with the same estimator: **0.2267 px**,
dominant marker period **25.5 px**, i.e. **0.0089 floor per period**; short baseline
equals long baseline (ratio **0.98**), so it is frame-independent white noise and the
clip is genuinely idle. Simulated floors per cell sit about **2x** (nx=80), **4x**
(nx=31) and **6x** (nx=56) below it — the bias that made the coarse patterns win.
3. **Signal is not noise-invariant either.** Retention `gain(sigma)/gain(clean)` at
+0.10 mm, x axis, render scale: nx=80 dm1 **99%** at sigma=4 and **84%** at sigma=16,
against nx=31 dm1 **72%** and **59%**.
**What replaced it.** The render-seed floor is kept only as a reproducibility diagnostic
and is labelled `floor_basis: "render_seed_reproducibility"`. Signal and noise are now
measured under **one** injected-noise model (`noise_models` per axis: gain, floor,
`response_retention`, `snr_noise`), calibrated against the real idle footage. The real
number is cached as `idle_floor.json`; skip with `--no-calibration`.
---
## 2. What actually governs the measurement: the estimator window vs the moving region
This is the most useful result, and it explains every resolution effect seen. Earlier notes
called it "ROI versus effective window"; that was the right *variable* but the wrong
*mechanism*, and measuring it changed the fix. See `build/probe/finger-xynova/r01/roi_regime.png`.
### The two regions, concretely
- **The moving region.** `radius_mm` (0.5) is the proportional-editing falloff radius for
the deformation, so the surface that actually moves is roughly a 1 mm disc — about
**13.25 px across at 500x500** and **6.62 px at 250x250**. It is a fixed *physical* size,
so its pixel size is set by the render resolution.
- **The measurement ROI.** A disc of radius `radius_mm x px_per_mm` centred on the projected
probe centroid. The response is the **mean of `dot(flow, d_hat)` over that disc**.
At 500x500 it is 6.62 px radius / 139 pixels; at 250x250, 3.31 px radius / **37 pixels**.
- **The estimator window.** DIS matches an 8 px patch, and `dis_mode` is OpenCV's
`setFinestScale`, so the flow is computed at `2**dis_mode` lower resolution and upscaled.
The effective support is therefore `8 x 2**dis_mode` **input** pixels: **16 px at
`dis_mode=1`, 8 px at `dis_mode=0`**.
### The mechanism is attenuation inside the estimator, not dilution in the average
Both were plausible. They are distinguished by comparing the flow **at the probe centroid**
against the **mean over the ROI**, and by sweeping the ROI radius over one fixed flow field:
| config | window | applied | flow @ centroid | mean over ROI | recovery |
|---|---|---|---|---|---|
| 500 dm1 | 16 px | 1.3248 px | -0.928 | -0.897 | **70%** |
| 500 dm0 | 8 px | 1.3248 px | -1.170 | -1.134 | **88%** |
| 250 dm1 | 16 px | 0.6624 px | **-0.166** | -0.161 | **25%** |
| 250 dm0 | 8 px | 0.6624 px | -0.482 | -0.429 | **73%** |
The centroid value and the ROI mean agree closely **in every case**, and sweeping the ROI
radius from 1 px to 30 px shows the response is **flat across the whole moving region** and
only falls off once the disc grows past it. So the ROI is well placed in all four cases and
the average is not diluting anything. The attenuation is already present in the flow field
that DIS produces.
**Why DIS attenuates it.** DIS minimises a photometric term plus a smoothness term over a
patch-and-propagation neighbourhood of about the window size. When the true displacement is
nonzero only inside a region *smaller* than that neighbourhood, most of the pixels the fit
sees are static, and both terms pull the estimate toward zero: the photometric term is best
satisfied by the static majority, and the smoothness term penalises a localised spike.
The attenuation therefore depends on how much of the window actually moves.
### The law
Recovery is a function of **`window / moving-region diameter`**, not of resolution:
| config | window / moving diameter | recovery |
|---|---|---|
| 500 dm0 | 8 / 13.25 = **0.60** | 88% |
| 500 dm1 | 16 / 13.25 = **1.21** | 70% |
| 250 dm0 | 8 / 6.62 = **1.21** | 73% |
| 250 dm1 | 16 / 6.62 = **2.42** | 25% |
The two configurations at ratio 1.21 differ by a **factor of two in resolution** and agree
to **3 percentage points**. That is the whole story: the governing quantity is the ratio of
the estimator's support to the physical size of the thing you are measuring.
### Which mode for a spatially resolved deformation map?
`dis_mode` changes **two** things, and only one is obvious:
1. the **window** (`8 x 2**dis_mode` px), which sets the attenuation; and
2. the **internal solve grid**. `setFinestScale(n)` solves the flow at `2**n` lower
resolution and bilinearly upscales to the output size. So:
| mode | output | actually solved at | window | recovers | background error | tail @1.88 mm |
|---|---|---|---|---|---|---|
| **500 dm0** | 500x500 | **500x500** | 8 px | **86%** | **0.74 um** | **1.4%** |
| 500 dm1 | 500x500 | 250x250 | 16 px | 68% | 1.77 um | 4.8% |
| 250 dm0 | 250x250 | 250x250 | 8 px | 66% | 1.56 um | 4.6% |
| 250 dm1 | 250x250 | 125x125 | 16 px | 25% | 4.24 um | **26.7%** |
("tail" is the residual flow at 1.88 mm from the probe, where the surface is undeformed,
as a fraction of each mode's own plateau; "background error" is the mean spurious
displacement in the 1.5-3.0 mm annulus, in micrometres. Figure:
`build/probe/finger-xynova/r01/edge_quality.png`.)
**So 250x250 `dm0` is *not* sharper than 500x500.** In physical units the edge width is the
same (`r50` = 1.12 mm for 500 dm0, 500 dm1 and 250 dm0) and the normalised profiles of
**500 dm1 and 250 dm0 nearly coincide** — because they are the *same computation*: both
solve on a 250x250 grid with an 8 px window there. A half-resolution round trip through the
500 dm1 field leaves a residual of only **0.019** of its RMS, confirming it carries no detail
above 250x250; it is a 250x250 field upsampled onto 4x as many pixels.
**The sharpest mode is 500x500 `dm0`**, the only one that solves at its full output
resolution. It recovers 86% of the true displacement (vs 66%), has a 2x cleaner background,
and a **3x smaller edge tail** (1.4% vs 4.6%).
An apparent "sharper falloff" for 250 dm0 is a **unit artefact**: on the ROI-radius sweep
plotted in *pixels* the 250 curve looks steeper only because the whole scene is half the
size in pixels. Plotted in millimetres the two profiles overlay (0.287 vs 0.280 of plateau
at 1.12 mm).
**250 dm1 is unusable for a map at any ROI size**: 25% recovery, and the profile never
returns to zero (26.7% of the plateau at 1.88 mm, still 7.3% at 2.88 mm).
**Important qualification: the bench does not currently produce a map.** It reports one
scalar per probe position — the mean of `dot(flow, d_hat)` over a small disc (139 px at
500, 37 px at 250). The profiles above describe the *footprint of a single probe*, measured
by evaluating the full flow field. The far-field numbers do show that field returning to
~0 outside the deformation (0.74-1.56 um), so a genuine spatial map is feasible — but it
would be a new output, and it should be built on 500 dm0.
### Consequences
1. **Keep `window / moving diameter` at or below ~1.2.** Above ~2 the estimator is mostly
measuring its own smoothness prior. This is why 250x250 with the shipped `dis_mode=1`
(ratio 2.42) loses ~3x of its signal, and why `dis_mode=0` fixes it.
2. **A larger ROI does not help.** The sweep is monotone *decreasing* past the moving
region, so widening the ROI to "get more signal" only adds static background. Three
levers exist instead: raise the render resolution, reduce the window (`dis_mode`), or
make the deformation physically larger (`radius_mm` / a bigger probe).
3. **Even at ratio ~1.2 you only recover ~70%.** Every absolute sensitivity number in this
bench is attenuated by 12-75% depending on configuration. Relative *pattern* rankings are
still valid because the geometry is held fixed, but do not read `gain_px_per_mm` as the
true displacement sensitivity.
4. **At 250 dm1 the field is distorted, not merely scaled.** `max|du|` reaches 121% of the
applied displacement somewhere in the frame while the probe region sits at 25% — spurious
high-flow pixels plus an attenuated centre. At 250 dm0 the peak (73%) matches the ROI
recovery, i.e. a clean uniform scale factor. So the 250/dm1 response is not a reliable
fraction of the truth at any ROI size.
---
## 3. Pattern choice at 500x500: nx = 80-90
13 candidates, nx 60-120, `radius_mm=0.5`, `dis_mode=1`, render scale, 5 noise draws.
Gain is px per mm of applied displacement at 0.10 mm, measured **under the calibrated
sigma=4 noise model** (clean gain is within 5% of these, so the noise term is not what
separates the candidates).
| nx | cell (mm) | cell (px) | x gain | y gain | retention x/y |
|---|---|---|---|---|---|
| 60 | 0.308 | 8.33 | 5.81 | 11.65 | 93% / 93% |
| 65 | 0.285 | 7.69 | 7.97 | 12.04 | 98% / 98% |
| 70 | 0.264 | 7.14 | 7.81 | 9.55 | 96% / 98% |
| 75 | 0.247 | 6.67 | 7.02 | 11.37 | 96% / 98% |
| **80** | **0.231** | **6.25** | **9.08** | 11.68 | **100% / 100%** |
| **85** | **0.218** | **5.88** | 8.68 | **12.88** | **101% / 99%** |
| **90** | **0.206** | **5.56** | 8.57 | 12.15 | 100% / 98% |
| 100 | 0.185 | 5.00 | 7.52 | 10.03 | 99% / 100% |
| 105 | 0.176 | 4.76 | 8.37 | 12.95 | 101% / 100% |
| 120 | 0.154 | 4.17 | 7.11 | 11.25 | 100% / 98% |
- **There is no sharp optimum — there is a broad plateau across nx 65-105.**
- **nx=85** has the best combined clean gain (21.6) with 101%/99% retention. **nx=80**
(the shipped value) is statistically indistinguishable (20.8, best x gain at 9.08,
100%/100% retention).
- **Coarsening past ~70 costs sensitivity**: nx=60 loses 36% of x gain (9.08 -> 5.81) and
its retention drops to 93%/93%. Its ROI holds only ~1.6 cells.
- **Going finer than ~105 does not pay**: nx=110-120 decline on both gain and retention.
- Retention is flat (96-101%) across 65-115, so noise robustness does **not**
discriminate in this range; it only penalises the coarsest candidate.
**On manufacturing:** at 500x500 the cell runs 154-308 um against a 50 um production
minimum, i.e. every option is 3-6x the process capability. **Manufacturability is not
binding at 500x500**, and coarsening past nx~70 buys nothing.
---
## 4. Pattern choice at 250x250
At 250x250 the camera-space cell halves, so the same *cell in pixels* needs half the nx
(the analogue of nx=80 at 500 is nx=40 at 250). 13 candidates, nx 30-120, `radius_mm=0.5`,
`dis_mode=0`, render scale, 5 noise draws. Gain is px per mm at 0.10 mm under the
calibrated sigma=4 model.
| nx | cell (mm) | cell (px) | x gain | y gain | retention x/y | mfg margin |
|---|---|---|---|---|---|---|
| 30 | 0.617 | 8.33 | 3.47 | 4.09 | 78% / 84% | 12.3x |
| 35 | 0.529 | 7.14 | 3.14 | 5.70 | 83% / 87% | 10.6x |
| **40** | **0.463** | **6.25** | 3.86 | 4.89 | 90% / 87% | **9.2x** |
| 45 | 0.411 | 5.56 | 4.58 | 4.40 | 90% / 84% | 8.2x |
| 50 | 0.370 | 5.00 | 2.22 | 5.73 | 74% / 93% | 7.4x |
| 55 | 0.336 | 4.55 | 3.04 | 3.62 | 82% / 85% | 6.7x |
| 60 | 0.308 | 4.17 | 3.02 | 5.29 | 87% / 90% | 6.2x |
| **65** | **0.285** | **3.85** | 4.32 | 5.32 | 95% / 98% | **5.7x** |
| 70 | 0.264 | 3.57 | 4.06 | 4.74 | 98% / 96% | 5.3x |
| 80 | 0.231 | 3.12 | 4.44 | 5.63 | 97% / 97% | 4.6x |
| **90** | **0.206** | **2.78** | 4.46 | 6.00 | 98% / 99% | **4.1x** |
| 105 | 0.176 | 2.38 | 4.29 | 6.11 | 98% / 97% | 3.5x |
| 120 | 0.154 | 2.08 | 3.44 | 5.07 | 88% / 91% | 3.1x |
**`dis_mode` must be 0 at 250x250.** With the shipped `dis_mode=1` the window is 2.4x the
moving region (window/moving = 2.42, section 2) and every candidate is weak or
unresolvable (nx=40: 1.42 px/mm instead of
4.30, and 16.8 instead of 25.0 on the render-scale score).
**Recommended at 250: nx = 40-65.**
- **nx=40** (cell 0.463 mm, **9.2x** the production minimum) if manufacturability dominates:
gain 3.86/4.89 px/mm, retention 90%/87%.
- **nx=65** (cell 0.285 mm, still **5.7x** the minimum) if you want the sensitivity and
noise robustness back: gain 4.32/5.32, retention 95%/98%.
- **Do not go finer than ~65** for sensitivity reasons: gain plateaus at ~4.3-4.5 px/mm
across nx 65-105 while the cell keeps shrinking, so extra nx buys no signal. nx=120 is
past the point where it helps and its retention drops.
- **Do not go coarser than ~40**: at nx=30-35 retention falls to 78-87%, because the ROI
holds fewer than two marker cells.
**Caveat — the 250 ranking is less discriminating than the 500 one.** The ROI is only
6.6 px across, so the response is comparatively flat and some of the ordering is
regulariser behaviour rather than pattern quality. Treat the 250 optimum as the band
nx 40-65, not as a sharp point.
**Two further 250 limitations, measured on the end-to-end run at nx=80, `dis_mode=0`:**
- **The ROI holds only 37 pixels** (radius 3.31 px), against 139 at 500x500. The response
is an ROI mean over 37 samples, so its own sampling error is much larger. This is the
main reason the 250 sweep looks flat.
- **A 0.01 mm step is only 0.066 px** at 250 (0.133 px at 500). The ROI mean still resolves
motion, but the per-step verdict machinery is markedly more conservative: the shipped
nx=80 at 250/dm0 scores overall `unresolvable` even though the gain is a healthy
4.21/5.57 px/mm. **If the application needs 0.01 mm steps resolved, 500x500 is the safer
operating point**; 250 is better suited to coarser displacement resolution.
- At sigma=16 the small z response at 250 goes **negative** (-0.05 px/mm), i.e. noise can
flip the sign of an axis whose signal is near the floor.
**Trade-off summary.** 250x250 forces `dis_mode=0` and roughly halves the absolute gain in
px/mm (4.30 vs 9.10 on x at the matched cell), but it buys a **2x coarser cell for the same
sensitivity**: 0.463 mm at nx=40 versus 0.231 mm at nx=80. Against a 50 um production
minimum that is 9.2x versus 4.6x of margin.
---
## 5. Which mode to run
| | 250x250 dm0 | 500x500 dm1 | 500x500 dm0 |
|---|---|---|---|
| output / solved at | 250 / **250** | 500 / 250 (upscaled) | 500 / **500** |
| window (`8*2**dm`) | 8 px | 16 px | 8 px |
| window / moving region | 1.21 | 1.21 | **0.60** |
| recovers of the true displacement | 66% | 68% | **86%** |
| background error (truth = 0) | 1.56 um | 1.77 um | **0.74 um** |
| edge tail at 1.88 mm | 4.6% | 4.8% | **1.4%** |
| ROI pixels | **37** | 139 | 139 |
| 0.01 mm step in px | **0.066** | 0.133 | 0.133 |
| DIS cost (medium, 500 px input) | 7.4 ms | 7.9 ms | **19.8 ms** |
| clean gain at nx=85 (x+y) | not comparable | 21.7 px/mm | **28.3 px/mm** |
| mirrors the deployed SDK config | no | **yes** | no |
Clean-gain units differ between resolutions (px per mm scales with the render width), so
compare 500 dm0 with 500 dm1 directly. At matched nx the clean gain is **1.31-1.35x higher
under dm0** (nx=80: 26.7 vs 20.8; nx=85: 28.3 vs 21.7; nx=90: 28.4 vs 21.0; nx=105: 28.1 vs
21.2 px/mm combined), i.e. **about a third more signal**. Compare 250 dm0 against the 500
modes only in *recovery* terms.
**Recommendation: 500x500 `dm0`**, with two conditions that override it.
1. **If the camera is physically 250x250, run 250x250 `dm0`** — resolution is set by
hardware, not a free choice, and `dm1` there is broken (25% recovery, the field never
returns to zero). 250 dm0 is informationally identical to 500 dm1 (same 250x250 solve,
same window, matching profiles), so nothing is lost against an upsampled 500 field.
2. **If the deployed tracker is fixed at `dis_mode=1`**, run 500x500 `dm1` to predict
deployment honestly — but know it is the pessimistic configuration: it discards 18
percentage points of recovery by solving at half resolution.
**The single most actionable item: set `dis_mode=0` if the frame budget allows.** At
500x500 it is a ~30-35% sensitivity gain for 2.5x tracker time (19.8 ms vs 7.9 ms per frame;
40% of a 20 fps budget vs 16%), with no hardware change. That is a much larger effect than
any pattern choice in the plateau, which spans only ~5%.
**The pattern optimum survives the mode change.** Under both `dm0` and `dm1` at 500x500,
clean gain is flat within ~5% across nx 75-105, with nx 80-90 in the top group of both, and
nx <= 70 and nx >= 115 declining. So nx 80-90 is a safe choice either way; the dm0 sweep's
top candidates were nx 65/105/85/90/100, the dm1 sweep's were nx 85/105/90/80.
## 6. Recommendations
1. **Pick the mode first** (section 5), because it moves the instrument by ~30%.
Then pick the pattern.
- **Camera 500x500 and the tracker can run `dm0` → 500x500 `dm0`**, pattern
**nx 80-90** (cell 0.231-0.206 mm, 4.6x-4.1x the production minimum). Best on every
measured axis.
- **Camera 500x500 but the tracker is fixed at `dm1` → 500x500 `dm1`**, pattern
**nx 80-90**. Honest for deployment prediction; 18 percentage points of recovery are
discarded by solving at half resolution.
- **Camera 250x250 → 250x250 `dm0`** (mandatory), pattern **nx 40-65**
(cell 0.463-0.285 mm, 9.2x-5.7x the production minimum).
- **Never 250x250 with `dm1`**: window/moving-region = 2.42, roughly 3x of signal lost
and the field is distorted rather than uniformly scaled.
2. **Set `dis_mode=0` wherever the frame budget allows.** At 500x500 that is ~30-35% more
signal for 2.5x tracker time (19.8 ms vs 7.9 ms per frame). It is a software change with
no hardware impact, and it is a far bigger effect than the ~5% spread across the pattern
plateau.
3. **Do not treat the second scale as evidence** unless its window-to-moving-region ratio is
checked. At 125x125 the ROI radius is 1.66 px and the moving region only ~3.3 px, against
an 8 px window — ratio 2.4, the same broken regime as 250/dm1.
4. **Before committing to a final nx, average 3-5 seeds per nx.** The y gain is
non-monotonic at both resolutions (9.55 at nx=70 vs 12.88 at nx=85 at 500 dm1), and with
a single seed per nx part of that is *which* pattern rather than the grid size.
5. **Re-run `optimise` under the noise-aware objective** before trusting any automated
winner. The stored optimizer result (nx=31) came from the retracted render-seed score
and has been removed.
---
## 7. What is not established
- **Absolute force calibration.** Sensitivity is a relative ranking under a fixed estimator
and camera, not newtons.
- **The process scale** (250x250 at 500 native) and the `radius_mm=1.5` block have not been
re-ranked under the calibrated model.
- **The real floor's pattern trend.** The idle clip gives one pattern, so it validates the
floor's magnitude and mechanism but not how it varies with nx. A deliberate no-contact
recording at the shipped configuration would settle it.
- **The 500x500 optimum's dependence on the random draw.** One seed per nx.
- **Multi-seed null rendering** — now diagnostic only, not part of the score.
---
## 8. Defects found and fixed while producing this
Recorded because each one silently corrupted results at the time:
- **`build_track_job` omitted the `noise` block**, so `design.yaml`'s sigmas/draws/scales
and every `--noise-*` flag were ignored and the pass ran on built-in defaults that
happened to match. Caught when a 5-draw run reproduced 1-draw numbers exactly.
- **The injected floor used a signed p95** of the projected flow, so it came out negative
or near-zero and the objective divided by ~0 — scores of ~1e7. Now `p95(|projection|)`,
guarded below by the render-seed reproducibility limit.
- **Re-running `track --output-dir` on a variant silently rebuilt the job from
`design.yaml`**, overwriting a `dis_mode=0` run with `dis_mode=1`. Now the existing job is
reused unless `--rebuild-job` is passed.
- **Manual re-scoring without the candidate pattern** reset every candidate's `cell_px` to
the `design.yaml` grid. `compare` does this correctly; ad-hoc re-scoring must pass
`pattern=` explicitly.
- **The response figure's label omitted the radius**, so `nx80@r0.5@dm0` and
`nx80@r1.5@dm0` produced two identical legend entries, and one global floor band belonged
to no plotted curve. Both fixed.
+323
View File
@@ -0,0 +1,323 @@
# Reading the probe sensitivity report
This is the operator's guide to `sensitivity_report.json` and
`optimizer_report.json`. The interface itself is frozen in
[`probe-pipeline.md`](probe-pipeline.md); this document explains what the numbers
mean, what they do **not** mean, and how to use the optimizer.
The bench deforms the sensing surface through a known positive x/y/z sweep
(0.01 mm .. 0.10 mm in 0.01 mm steps) in a local probe-average basis, renders what
`cam_sensor` sees, and measures DIS optical flow against the zero-displacement
reference at two scales (the real render and the runtime's processed resolution).
```
deform -> render -> track (DIS flow) -> sensitivity (score + report) -> optimise
```
Every stage writes JSON under `build/probe/<sensor>/<revision>/`. The sensitivity
stage reads `render_manifest.json`, `flow_report.json` and `flow/<scale>/*.npy`
and writes `sensitivity_report.json`. It never re-runs a stage; use the CLI's
`--skip-deform/--skip-render/--skip-track` to reuse existing artifacts.
## The one thing to remember
**Sensitivity here is a relative ranking under a fixed estimator (DIS) and a
fixed camera. It is not force calibration.** The absolute `gain_mm_per_mm`
carries the measurement geometry with it, so only compare scores produced with the
same `radius_mm`, ROI, resolution and tracker settings.
## The floor is not a noise model (read this first)
**An earlier version of this document stated that the marker cell must match the
estimator's effective window, and that nx=31 was the best pattern. That is retracted.**
The conclusion was driven by `floor_px`, and `floor_px` is not a valid noise model.
`floor_px` is measured as `reference` vs a re-render at **seed + 1** — Cycles antialiasing
jitter, ~5 grey levels RMS on half the pixels, scaling with edge contrast. It cannot rank
patterns, for three measured reasons:
1. **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 |
2. **It understates the real floor, coarse patterns most.** Measured on idle dd02 footage
with the same estimator: **0.2267 px** at 1280x720, dominant period **25.5 px**,
**0.0089 per period**, with short baseline == long baseline (ratio 0.98), so it is
white noise and the clip is idle. Simulated floors per cell sit ~2x (nx=80), ~4x
(nx=31) and ~6x (nx=56) below it.
3. **Signal is not noise-invariant**, so gain is only meaningful with a stated model.
Retention `gain(σ)/gain(clean)` at +0.10 mm, x axis: nx=80 dm1 **99%** at σ=4 and
**84%** at σ=16, against nx=31 dm1 **72%** and **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 retention, so the shipped 80x80 is competitive or better
on this evidence. The process scale and the `radius_mm=1.5` block have not been
re-ranked.
So: report and compare `noise_models` (gain, floor and `response_retention` per σ, all
measured under one model), not `gain / floor_px`. Every scale records
`null.floor_basis: render_seed_reproducibility` so the distinction is machine-readable.
## The quantities that are still worth watching
- The marker grid is authored **across the camera image**, so a cell is
`render_width / nx`. It is not a millimetre quantity: a millimetre cell times the
*probe-local* px/mm understates it by about half, because the probe-local scale
(~13.2 px/mm) differs from the camera-grid scale (~27 px/mm). `cell_px` is the
camera-space value and governs; `nominal_cell_px` is the old millimetre-derived value.
- `effective_patch_px = patch_px * 2**finest_scale` is the correlation window in input
pixels (16 px on r01, because `dis_mode=1`). `cell_over_effective_patch_px` is a
useful diagnostic — the ratio hovered around 1 for the patterns that scored well — but
it is **not** a validated law, since the score was floor-driven.
- **The measured gain is confounded with the ROI size.** The probe patch plus falloff
taper spans only ~13 px, so DIS regularizes toward the static background; a synthetic
rigid translation recovers ~17% at r=6.6 px (the ROI) but 81% at r=20. Compare patterns
only at a fixed radius, ROI and scale.
- **The optimum moves with `radius_mm`** (at r=1.5 the score ordering differs), and
**`overall.score` is the unweighted mean of the two scales**. Read `scale_scores` and
`scale_ranks` before trusting a winner.
## Top-level fields
| Field | Meaning |
|---|---|
| `texture` | The marker pattern actually measured: PNG path + sha256, `grid` `[nx, ny]`, and the **physical** cell `cell_mm = [vector_width_mm/nx, vector_height_mm/ny]`. `grid`/`cell_mm` are `null` rather than assumed when the pattern cannot be established. |
| `probe` | `group`, `origin`, `basis`, `radius_mm`, `falloff`, and `affected_vertices` (how many vertices outside the probe group actually move). |
| `tracker` | `dis_preset`, `dis_mode`, `patch_px` (the nominal patch) and `finest_scale` (the pyramid level `dis_mode` selects, which is what turns `patch_px` into `effective_patch_px`). |
| `overall` | `score` (mean over scales and axes), `verdict` (worst axis wins), `best_scale`, `best_axis`. |
| `warnings` | The honesty channel. Read it before the score. |
| `notes` | Measured context, including which pattern source was used and the ROI-size confound. |
## Per-scale fields
| Field | Meaning |
|---|---|
| `px_per_mm` | The scale's **probe-local** scalar: `render_manifest.probe_px_per_mm.p50`, halved at the process scale (13.248 at render, 6.624 at process on r01). Used for the sub-pixel warnings and the marker cell in pixels. **Never** a gain divisor — the per-axis `scale_px_per_mm` is, and the two differ (the probe-local p50 is 13.248 while the mesh-wide p50 is 13.430). |
| `mesh_px_per_mm` | The **mesh-wide** p50 (13.430 at render): p50 over all 7341 mesh edges of projected px / 3D mm. Recorded for context only; never used as a gain divisor or as the probe scale. |
| `displacement_px_per_step` | `step_mm * px_per_mm`: how many pixels a single 0.01 mm step is at that scale. |
| `cell_px` | The **camera-space** marker cell, `width / nx`. The pattern grid is defined across the camera image, so this is the cell the estimator sees. `cell_px_source` records that. |
| `nominal_cell_px` | The old millimetre-derived cell (`cell_mm[0] * px_per_mm`) at the probe-local scale; about half of `cell_px`, kept only so an older expectation can be compared. Not the governing value. |
| `effective_patch_px` | `patch_px * 2**finest_scale`, the correlation window in **input** pixels. 16 px on r01, because `dis_mode=1`. |
| `cell_over_patch_px` | `cell_px / patch_px`, the nominal ratio. |
| `cell_over_effective_patch_px` | `cell_px / effective_patch_px` — **the ratio to steer by.** Near 1 is where the measured optima sit. |
| `null_floor_px` | The largest per-axis estimator floor at this scale (see below). |
| `local_scale_source` | `per_axis` (measured per-axis scales), `p50_fallback` (old manifest without per-axis keys), or `unavailable`. |
| `score`, `verdict` | Mean/aggregate of the axes. |
## Per-axis fields
```json
{"axis": "x", "direction_px": [-1.0, 0.000589], "direction_source": "centroid_regression",
"points": [{"step_mm": 0.01, "applied_px": 0.1325, "response_px": 0.081, "above_floor": true}],
"summary": {"gain_px_per_mm": 9.247, "gain_mm_per_mm": 0.698, "scale_px_per_mm": 13.248,
"gain_gate": "derived", "snr": 33.5, "spearman": 1.0,
"resolvable_step_mm": 0.01, "saturation_mm": null,
"cross_axis_leak": 0.038, "score": 32.25, "verdict": "resolvable",
"floor_px": 0.0276, "floor_source": "null_projection_p95",
"px_per_mm_axis": 13.248}}
```
- `direction_px` — the unit image-space direction the probe moves for this axis,
measured from `render_manifest.frames[].centroid_px` by regressing the projected
centroid on `step_mm` (`direction_source: "centroid_regression"`). Fallbacks are
a two-point finite difference and, when no centroids exist at all, the ROI-mean
flow at the largest step. The per-frame `roi.u/v` is deliberately not used: the
ROI is fixed at the reference centroid.
- `points[].applied_px` — the applied displacement **in pixels for that axis**
(`step_mm * px_per_mm_axis`), not `step_mm * px_per_mm`. The isotropic scalar
would overstate z (1.519 px/mm) by ~8.7x.
- `floor_px` — p95 of the null-frame flow projected on `direction_px`
(`floor_source: null_projection_p95`). If the null array is missing the report
falls back to the flow report's scalar magnitude p95 and says so
(`reported_magnitude_p95_fallback`), which is a different quantity.
- `gain_px_per_mm` — **the primary sensitivity**: the OLS slope of `response_px`
against `applied_mm` over the steps whose `|response| > floor_px` (at least
three such points, else `null`). Units: measured pixels of probe response per
millimetre applied. This is the number to rank patterns by.
- `scale_px_per_mm` — the probe-local, per-axis scale from
`render_manifest.probe_px_per_mm[axis]`, rescaled to this scale's pixel grid.
- `gain_mm_per_mm` — derived as `gain_px_per_mm / scale_px_per_mm` **only** when
`scale_px_per_mm >= min_local_scale_px_per_mm` (currently **1.0**). Otherwise it
is `null` with `gain_gate` = `below_min_local_scale` (or `scale_unknown`), plus
a warning. Dividing by a near-zero scale would manufacture a huge ratio no
camera could observe; the gate makes that structurally impossible. The current
process-scale z axis (0.760 px/mm) is gated for exactly this reason.
- `snr` — `|gain_px_per_mm| * max_mm / max(floor_px, eps)`. Both numerator and
denominator are in pixels, so it is dimensionally consistent and comparable.
- `spearman` — rank correlation of `response_px` against `applied_mm` over the
axis's steps, clamped to `[0, 1]`. A 1.0 means the response grows monotonically.
- `cross_axis_leak` — mean perpendicular response over mean in-axis response,
clamped to `[0, 1]`.
- `resolvable_step_mm` — the smallest step whose `|response| > floor_px`, else
`null`. This is the "is a single step measurable" answer, and it is a floor
comparison, not a pixel-size comparison.
- `saturation_mm` — the first step where the response stops increasing (a decrease
after a rise), else `null`.
- `px_per_mm_axis` — the probe-local px/mm **measured independently** from the
centroid regression at this scale. It should agree with `scale_px_per_mm`; a
>25 % disagreement is reported as a note.
- `score` — `snr * spearman * (1 - min(leak, 0.5))`. A dimensionless ranking
number, not a percentage.
- `verdict` — in precedence order:
- `ambiguous` — the tracker's `reference_texture_known_answer` failed, i.e. a
known shift applied to the *actual rendered marker* was not recovered. A
periodic pattern aliases a sub-period shift onto its period, so direction and
magnitude are untrustworthy and no score may promote the pattern. This
outranks everything else.
- `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.verdict` is `resolvable` only if every scale and axis is; `ambiguous` if
any scale is; otherwise the worst remaining verdict.
## Honest limits (measured, not assumed)
1. **Sub-pixel steps, and why they still measure.** At 500x500 the probe scale is
~13.25 px/mm, so 0.01 mm is ~0.13 px and the whole 0.1 mm sweep is ~1.33 px.
The measured zero-input floor is ~0.03 px, so a step is ~4.5x above the floor
and the ROI mean genuinely resolves single steps at the render scale
(`resolvable_step_mm = 0.01`). **"Below one pixel" and "below the estimator
floor" are different statements**; the report states the step and the floor
separately in pixels. What is lost below one pixel is per-pixel precision, not
the ROI-averaged response. At the process scale the same step is ~0.066 px and
the floor comparison decides, not the pixel size.
2. **The measured gain is confounded with the ROI size.** The probe patch plus its
falloff taper spans only ~13 px (1-2 DIS patches of 8 px), so DIS regularises
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. Relative ranking of marker patterns
is valid **only** while `radius_mm`, the ROI and the scale are held fixed. That
is why `run_optimise` varies `nx`/`ny`/`seed` and never the radius.
3. **The render-scale deficit is measurement-region resolution**, not noise,
aliasing or a diluted average: the real flow's radial profile is flat inside the
ROI (-0.9162, -0.9096, -0.8725 px at r 0-2, 2-4, 4-6.6), the texture-level
known-answer passed (2 px recovered as 2.0000 px), and the coherent motion is
measured at about 0.67x of the applied displacement. Only synthetic *periodic*
textures alias, and that is what the `ambiguous` verdict catches.
4. **The process scale is much weaker.** At 250x250 the region is ~6.5 px, under
one DIS patch, and the response collapses while the forward-backward error
grows. A low process score is a real result about the runtime resolution, not a
bug in the scorer.
## The three pre-registered hypotheses, as the artifact shows them
The report deliberately records the numbers needed to test these rather than
asserting them:
- **(a) the cell should be comparable to the DIS patch.** `cell_px` and
`cell_over_patch_px` are per scale. For the shipped nx=80 pattern at 500x500 the
cell is 3.06 px = 0.38 of the 8 px patch; at 250x250 it is 1.53 px, below
Nyquist, and the report warns. Measured outcome: the hypothesis is
**scale-dependent, not a straight win** — nx=80 is the *best* pattern at the render
scale (score 36.77) and near the worst at the process scale (1.77), so no single
nx is best on both. That is why the report ranks per scale.
- **(b) an 80x80 grid cannot track at 250x250.** Supported at the process scale: that
is the `cell_px < 2` warning plus the measured collapse of the process responses
(nx=80 ranks 10/10 there).
- **(c) no single 0.01 mm step is resolvable.** Falsified at the render scale by
measurement: `resolvable_step_mm` is 0.01 on x/y (and 0.04 on z) because the ROI
aggregation clears the ~0.03 px floor; it is not assumed either way.
## Warnings you should expect
- `step_mm=... is ... px at <scale>, i.e. below one pixel` — a per-pixel precision
note that also states the per-axis `floor_px`, so you can see whether the step
clears the floor.
- `marker cell is ... px at <scale> ... below 2 px` — the cell is at or under the
DIS patch Nyquist limit.
- `no step is resolvable on axis ... at <scale>` — every `|response|` is inside
the estimator floor.
- `the null floor (...) exceeds the full ... mm response` — the aggregate response
never rises out of the floor.
- `axis ... has a probe-local scale of ... below min_local_scale_px_per_mm` — the
derived `gain_mm_per_mm` was refused.
- `reference_texture_known_answer FAILED ...` — the scale's axes are `ambiguous`;
the warning quotes measured vs expected shift.
- `the probe ROI is not fully inside the ... frame` — the ROI was truncated at
that scale and only the pixels actually inside were scored.
## Using the optimizer
```
orisys-design optimise <revision_dir> [--output-dir DIR] [--grid LIST]
[--seeds LIST] [--search-resolution N] [--top N] [--dry-run]
```
- The default grid is `[15, 20, 24, 28, 31, 36, 42, 48, 56, 64, 80]` with seed `7`.
It **brackets the DIS-patch-matched cell** (an 8 px patch is ~0.6 mm, i.e. nx ~ 28-36
for this marker page), and it includes nx=80 because that is the opposite case, not
because it aliases: measured on the real run nx=80 **wins the render scale**
(36.77 at 500x500) and **loses the process scale** (1.77 at 250x250, rank 10/10),
because its cell is 3.06 px at 500x500 but 1.53 px at 250x250. The shipped random
texture does **not** alias (a known 2 px shift is recovered as 2.0000 px); aliasing
onto the DIS period was measured only for strongly periodic synthetic textures, which
is why the report carries an `ambiguous` verdict gate rather than a score penalty.
- Candidates are `(nx, ny, seed)`, enumerated in a **sorted** order and ranked by
`(-score, nx, ny, seed)`, so two runs produce byte-identical rankings.
- **The search resolution defaults to `settings.resolution` (the real render scale),
not to a reduced `250x250`.** A reduced search answers a different question: on the
real data a 250-based search picks nx=48 (search score 8.94) while nx=80 is the best
pattern at 500x500 (36.77) and near the worst at 250 (1.77). Use
`--search-resolution N` only as a deliberate speed/correctness trade, and read the
**search-vs-confirm gap** note: a large gap is the signal that the search resolution
was wrong.
- 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.
- `--dry-run` enumerates the candidates and writes the report without rendering
anything (no candidate directories, no Blender, no cv2).
- The winner is a **measured ranking under a fixed estimator and camera; it is not
a global optimum**. The report says so in `notes`, and it also warns if the
top-scoring candidate's verdict is `ambiguous`.
- **`best` is scale-dependent.** `overall.score` is the *unweighted mean* of the
per-scale scores, which is a modelling choice: on the real data nx=80 wins the render
scale while a mid-grid pattern balances both scales, so the overall winner need not be
the best render-scale pattern. Every candidate row therefore carries
`scale_scores`, `scale_verdicts` and `scale_ranks` (1 = best on that scale), and the
report carries `ranked_by_scale` alongside the overall `ranked` list and
`winner.scale_scores`. Read those before trusting the winner.
- Candidate rows carry `score`, `verdict`, `cell_px` (reported at the full render
scale), `report`, `texture_sha256` and the per-scale fields above. A candidate whose
run raised is kept in the table with a `null` score, an `error` string, and ranks
last.
## Reproducing and testing
```
orisys-design sensitivity sensors/<sensor>/<revision> # score only
orisys-design optimise sensors/<sensor>/<revision> --dry-run
& "C:\Users\cheun\anaconda3\envs\py310\python.exe" -m pytest tests/test_sensitivity.py tests/test_optimise.py
```
`tests/test_sensitivity.py` includes the primary correctness evidence: a synthetic
flow field with a **known injected gain** (13.248 px/mm) whose OLS recovery is
asserted within 0.2 px/mm (measured recovery 13.2584 px/mm, floor 0.0459 px,
`snr` 28.9), plus the gated-`gain_mm_per_mm` path, the `ambiguous` verdict guard,
resolvable-step selection, the leak penalty, saturation detection, report schema
and determinism. `tests/test_optimise.py` covers enumeration, ranking stability,
winner selection, full-resolution re-confirmation, the dry run and the absence of
any global-optimum claim. Neither test needs Blender or cv2.
### Measured example (this scorer, real `finger-xynova/r01` artifacts)
| scale | axis | `gain_px_per_mm` | `gain_mm_per_mm` | `floor_px` | `resolvable_step_mm` | `snr` | verdict |
|---|---|---|---|---|---|---|---|
| render 500x500, 13.248 px/mm, cell 3.06 px | x | 9.247 | 0.698 | 0.0276 | 0.01 | 33.5 | resolvable |
| | y | 12.019 | 0.680 | 0.0160 | 0.01 | 75.3 | resolvable |
| | z | 0.677 | 0.445 | 0.0160 | 0.04 | 4.24 | resolvable |
| process 250x250, 6.624 px/mm, cell 1.53 px | x | 1.427 | 0.215 | 0.0158 | 0.01 | 9.0 | resolvable |
| | y | 0.931 | 0.105 | 0.0070 | 0.01 | 13.2 | resolvable |
| | z | 0.227 | `null` (gated, scale 0.760 < 1.0) | 0.0070 | 0.03 | 3.23 | resolvable |
`overall.score` 22.22, `overall.verdict` `resolvable`, `best_scale` render,
`best_axis` y. The render-scale `gain_mm_per_mm` of ~0.68-0.70 is the ~0.67x
coherent-motion shrinkage described above, not a noise floor. These numbers are
re-produced by re-running `sensitivity` after any change to the upstream stages.
+763
View File
@@ -0,0 +1,763 @@
# 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/<sensor>/<revision>/
```
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/<name>.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": "<abs>",
"source_blend": "<abs>",
"output_dir": "<abs>",
"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": "<abs>/probe_frames.npz", "report": "<abs>/deform_report.json"}
}
```
### `_render_job.json`
```json
{
"schema": "orisys-sensor-design/probe-render-job-v1",
"revision_dir": "<abs>",
"source_blend": "<abs>",
"output_dir": "<abs>",
"objects": {"mesh": "sensing_surface", "camera": "cam_sensor"},
"settings": {
"uv_map": "compute",
"texture": "<abs path to the marker PNG>",
"width": 500, "height": 500,
"radius_mm": 0.5,
"engine": "CYCLES", "samples": 16,
"denoise": false, "view_transform": "Standard",
"interpolate": "Closest", "seed": 0
},
"frames": ["<same frames array as the deform job>"],
"null_frame": {"name": "reference_null", "seed": 1},
"artifacts": {"frames_npz": "<abs>/probe_frames.npz", "report": "<abs>/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": "<abs>",
"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": "<abs>/render_manifest.json", "strategy": "<abs>/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,) <U...` | frame name, matching the plan |
| `frame_axes` | `(F,) <U4` | `null` for the reference, else `x`/`y`/`z` |
| `frame_steps_mm` | `(F,) float64` | applied displacement magnitude in mm |
| `weights` | `(V,) float64` | falloff weight per vertex (constant across frames) |
| `origin` | `(3,) float64` | probe centroid, object-local |
| `basis` | `(3, 3) float64` | rows are `x`, `y`, `z` unit axes, object-local |
| `probe_indices` | `(P,) int32` | member vertex indices |
`probe_render.py` must assert that `len(vertices) == len(frame_names)` and that the
plan in `_render_job.json` matches `frame_names` element-wise, then fail with exit 1
if not. It writes each frame to the mesh and renders; it never re-derives weights.
## `deform_report.json`
```json
{
"schema": "orisys-sensor-design/probe-deform-report-v1",
"generated_at": "<iso8601 utc>",
"source_blend": {"path": "<abs>", "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": "<iso8601 utc>",
"source_blend": {"path": "<abs>", "sha256": "..."},
"texture": {"path": "<abs>", "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": "<iso8601 utc>",
"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/<scale>/<name>.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": "<iso8601 utc>",
"revision": "sensors/finger-xynova/r01",
"texture": {"path": "<abs>", "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": "<iso8601 utc>",
"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": "<abs>", "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": "<abs>"},
"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 <revision_dir> [--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 <revision_dir> [--output-dir DIR] [--resolution WxH]
[--samples N] [--uv-map NAME] [--texture PATH] [--seed N]
orisys-design track <revision_dir> [--output-dir DIR]
[--process-resolution WxH] [--no-process-scale]
orisys-design sensitivity <revision_dir> [--output-dir DIR] [--skip-deform]
[--skip-render] [--skip-track] [--radius-mm F] [...]
orisys-design optimise <revision_dir> [--output-dir DIR] [--grid LIST]
[--seeds LIST] [--search-resolution N] [--top N] [--dry-run]
```
Default `--output-dir` is `build/probe/<sensor>/<revision>`.
**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.