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:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user