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
+8
View File
@@ -13,6 +13,14 @@ build/textures/**/_texture_job.json
# Printability reports are regenerated output, not design input. Use
# `orisys-design inspect --out <tracked path>` to keep one deliberately.
build/textures/**/texture_report.json
# The probe sensitivity bench writes ~90 MB of derived measurement artifacts
# (31 rendered frames, per-frame flow arrays, JSON reports) plus per-run job
# files. They are regenerated output like texture_report.json, not design input,
# so only a deliberate `optimise` winner should be promoted by hand.
build/probe/
# Local verification/scratch: pytest basetemps, sweep logs and the lead's independent
# deform/sensitivity auditors. Regenerate with the commands in docs/probe-method.md.
build/_*
.pytest_cache/
# Blender
+193
View File
@@ -367,6 +367,199 @@ Collection check:
blender -b sensors/finger/r01/source/geometry.blend -P blender/scripts/validate.py -- sensors/finger/r01
```
## Probe sensitivity bench
`orisys-design deform|render|track|sensitivity|optimise` answers *"which marker
pattern gives the best tracking sensitivity on this sensor?"* It deforms the
sensing surface through a **known** displacement, renders what `cam_sensor`
actually sees, measures DIS optical flow against the zero-displacement reference,
and reports a ranked, auditable score. The full interface contract is in
[`docs/probe-pipeline.md`](docs/probe-pipeline.md).
```powershell
python -m pip install -e .[probe] # numpy + opencv-python
# 1) deform 31 frames (1 reference + 3 axes x 10 steps)
orisys-design deform sensors/finger-xynova/r01
# 2) render what the camera sees for each frame
orisys-design render sensors/finger-xynova/r01
# 3) DIS flow against the reference
orisys-design track sensors/finger-xynova/r01
# 4) score and rank (writes sensitivity_report.json)
orisys-design sensitivity sensors/finger-xynova/r01 --skip-deform --skip-render --skip-track
# 5) search patch-pattern parameters
orisys-design optimise sensors/finger-xynova/r01 --dry-run
# 6) compare different settings and plot the measured optical flow
orisys-design compare sensors/finger-xynova/r01
```
### Visualising the flow and comparing settings
`orisys-design compare` runs one marker pattern under several estimator settings and
writes three figures plus `settings_comparison.json` into
`build/probe/<sensor>/<revision>/comparison/`:
| figure | what it shows |
|---|---|
| `settings_flow.png` | one panel per setting: the rendered marker around the probe with the measured flow as arrows, the ROI, the cell grid and the **effective window** drawn on it, and the applied vs measured displacement. Below each panel, the measured `\|flow\|` field on a shared colour scale. |
| `settings_tuning.png` | score vs the camera-space cell, and vs the cell/window ratio, one series per setting. |
| `settings_response.png` | two panels. **Signal**: response vs applied displacement per setting, with each curve's *own* noise floor drawn as a line and the ideal 1:1 line. **Retention**: `gain(σ)/gain(clean)` vs injected noise σ, so a pattern that looks strong but collapses under noise is visible at a glance. |
Only the settings that need re-running do: `nx` regenerates the texture, `radius_mm`
regenerates the deformation and frames, and `dis_mode` reuses the *same frames* (the
variant directory holds a manifest with absolute frame paths), so the decisive
estimator comparison costs seconds rather than a re-render.
```powershell
# the shipped setting vs the full-resolution estimator, on identical frames
orisys-design compare sensors/finger-xynova/r01 --grid 31,56,80 --dis-modes 1,0
# a different rubber radius changes the optimum, so compare for stability
orisys-design compare sensors/finger-xynova/r01 --radii 0.5,1.0,1.5
```
`orisys-design sensitivity` chains all of steps 1–4 itself, so a single run is
usually enough; the `--skip-*` flags let you re-score without re-rendering. All
artifacts land in `build/probe/<sensor>/<revision>/`.
### How the deformation is defined
The vertices in the `probe` vertex group move rigidly by a displacement expressed
in a **probe-average orthonormal frame** — x and y are shear, z is the surface
normal, i.e. normal force. The 0.01 mm steps run from 0 to 0.10 mm per axis.
Vertices outside the group follow Blender's proportional-editing falloff
(`smooth`/`linear`/`sharp`/`constant`) out to `radius_mm`, representing the outer
rubber.
The falloff is computed explicitly in millimetres rather than through
`bpy.ops.transform.translate(use_proportional_edit=True)`: that operator is
viewport-modal and its radius is screen-space, so it is neither available nor
reproducible under `blender -b`. The `.blend` is never written — the whole
pipeline is read-only towards it.
`deform_report.json` reports how many vertices actually moved. That matters: on
the Xynova revision the median mesh edge is 0.63 mm, so the default 0.5 mm radius
reaches only 5 of 2502 vertices outside the group, producing a sharp 6-vertex
island rather than a smooth bulge. Raise `--radius-mm` to ~1.0 mm (17 vertices)
or 1.5 mm (30 vertices) for a rounded indentation.
### Reading the report
`render_manifest.json` records the render settings actually applied. Two are
forced for correctness: the view transform must be `Standard` (the scene ships
with AgX, a filmic tonemapper that silently rewrites every marker value) and
denoising is off (it smears the pattern the tracker is meant to measure). The
render also asserts that re-rendering the reference frame produces identical
bytes, because a non-reproducible render invalidates every later number.
`sensitivity_report.json` carries, per axis and per scale, the measured `gain`
(measured mm of flow per mm of applied displacement), the `snr` against the
null-frame floor, cross-axis leakage, and `resolvable_step_mm`.
**Be careful reading small steps.** The Xynova design camera renders 500x500, which
is **13.25 px/mm** at the probe, so 0.01 mm is only **0.13 px**. That is a *per-pixel*
precision limit, not an estimator limit: because the response is averaged over the
probe ROI (~139 px), the measured zero-input floor is only **0.029 px** and a single
0.01 mm step (0.13 px) clears it about 4.5x over. The report therefore states
`resolvable_step_mm` per axis, reports the floor and the step separately in pixels,
and evaluates the process scale (250x250 by default) alongside the render scale so you
can see what the runtime can actually resolve. Do not assume a sub-pixel step is
unmeasurable — check the floor.
Sensitivity here is a **relative ranking under a fixed estimator and camera**, not a
force calibration, and it must be read under a **stated noise model** — see the section
above. The optimizer's winner is the best of a bounded deterministic candidate grid, and
is not a proven global optimum.
### The measured gain is confounded with the ROI size
This is the most important caveat in the tool, and it is measured rather than
theoretical. On Xynova the probe patch plus its falloff taper spans only about
**13 px**, i.e. 1-2 DIS patches (8 px), so DIS regularizes toward the static
background and the coherent motion is measured at roughly **0.67x** of the applied
displacement. Injecting a synthetic rigid translation of the same 1.3248 px recovers
**17%** at a patch radius of 6.6 px (equal to the ROI), **51%** at r 10, **81%** at
r 20 and **95%** at r 40. So the absolute gain depends strongly on `radius_mm`:
compare patterns only while the radius, ROI and scale are held fixed.
`orisys-design optimise` varies `nx`/`ny`/`seed` and never the radius for exactly this
reason.
Attribution matters too: the deficit is **not** aliasing, noise or a diluted average.
The real flow's radial profile is flat inside the ROI, and the texture-level
known-answer recovers a 2 px shift as 2.0000 px. Only strongly *periodic* synthetic
textures alias (a 3.11 px checker turns a 2 px shift into 8.17 px); the shipped random
per-cell pattern does not.
### The noise floor was wrong, and it changed the answer
**Read [`docs/probe-findings.md`](docs/probe-findings.md) for the conclusions and
recommendations.** The short version is below.
**This section replaces an earlier claim that nx=31 is the best pattern and that "the
marker cell must match the estimator's effective window". That claim is retracted.** It
was driven by the noise floor, and the floor was not a valid noise model.
`floor_px` is measured as `reference` vs a re-render at **seed + 1** — Cycles
antialiasing jitter. On this data it differs by ~5 grey levels RMS on half the pixels and
scales with edge contrast. It is not sensor noise, and three checks show it cannot rank
patterns:
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.** Measured on idle dd02 footage
(`data/dd02-0703/190.mp4`, 1280x720) with the same estimator:
**0.2267 px** p95 |flow| over the ROI, dominant marker period **25.5 px**, i.e.
**0.0089 floor per period**. Short baseline (consecutive frames) equals long baseline
(vs frame 0) — ratio 0.98 — so this is frame-independent white noise, not drift, and
the clip is genuinely idle. Every simulated floor per cell is well below it, and the
**coarse patterns are understated most** (nx=56 by ~6x, nx=31 by ~4x, nx=80 by ~2x) —
exactly the bias that made nx=31 and nx=56 look best.
3. **Signal is not noise-invariant either**, so gain must be quoted under a stated model.
At +0.10 mm on the x axis, retention is `gain(σ)/gain(clean)`:
| pattern | σ=4 | σ=16 |
|---|---|---|
| nx=80 dm1 | **99%** | **84%** |
| nx=56 dm1 | 96% | 71% |
| nx=31 dm1 | 72% | 59% |
**What the calibrated model supports:** at the render scale, `radius_mm=0.5`, `dis_mode=1`,
nx=80 has the **highest clean response on both axes** (x 9.10 vs 4.74 px/mm; y 11.69 vs
10.90) and the **best noise retention** (84–99% vs 59–73%), so on this evidence the
shipped 80x80 is competitive or better, not worse. What is *not* yet re-ranked: the
process (250x250) scale and the `radius_mm=1.5` block, where the score ordering differs.
Every report now carries `noise_models` (per-axis gain, floor and `response_retention` per
σ, measured under one model) and `noise_calibration` (the real idle numbers above), and
`settings_response.png` has a retention panel so a pattern that looks strong but collapses
under noise is visible.
### What the bench measures
The marker grid is defined **across the camera image**, so a cell is
`render_width / nx` pixels, not a millimetre quantity: at 500x500 the shipped 80x80 grid
has a **6.25 px** cell and nx=31 has **16.13 px**. `dis_mode=1` is OpenCV's
`setFinestScale(1)`, so DIS computes flow at half resolution and upscales, giving an
effective window of `patch_px * 2**dis_mode` = **16 px**. Those are the right quantities to
watch — but the earlier reading of them as *the* explanation of the ranking was an artefact
of the floor above, so treat the ratio as a useful diagnostic, not a validated law.
The measured gain is also **confounded with the ROI size**: the probe patch plus its
falloff taper spans only ~13 px (1-2 DIS patches), so DIS regularizes toward the static
background. Injecting a synthetic rigid translation of the same 1.3248 px recovers ~17% at
a patch radius of 6.6 px (equal to the ROI), 51% at r 10, 81% at r 20 and 95% at r 40.
Compare patterns only with the radius, ROI and scale held fixed — which is why
`orisys-design optimise` varies `nx`/`ny`/`seed` and never the radius.
## Profiles
- **`compute`** — `sensing_surface` + `cam_sensor` relative to `origin_sensor`. Writes
+449
View File
@@ -0,0 +1,449 @@
"""Blender headless probe deformation (mm, object-local).
blender -b sensors/finger-xynova/r01/source/geometry.blend \\
-P blender/scripts/probe_deform.py -- \\
--job <abs/build/probe/finger-xynova/r01/_deform_job.json>
Reads the job written by ``orisys_sensor_design.deform``, resolves the mesh and
vertex group, builds the probe-average orthonormal basis, computes the
proportional falloff ONCE from the probe members' ORIGINAL positions (so the
weight vector is constant across every frame), then writes::
probe_frames.npz the deformation/rendering contract
deform_report.json measured probe statistics + honesty warnings
THE .BLEND IS NEVER WRITTEN. No ``bpy.ops.wm.save_mainfile`` /
``save_as_mainfile`` call exists in this script; the source file is opened
read-only and only read from. The script does leave Edit mode if the opened file
happens to be in it, but that changes the in-memory session only.
WHY NOT bpy.ops.transform.translate(use_proportional_edit=True)
--------------------------------------------------------------
That operator is viewport-modal. It needs a 3D-view region for the pivot and its
``tool_settings.proportional_size`` is interpreted in SCREEN SPACE (pixels)
relative to that region. Under ``blender -b`` there is no 3D-view region and no
screen-space scale, so the affected vertex set would depend on GUI state and
zoom and could not be reproduced headlessly. Every displacement here is computed
explicitly in millimetres by ``probe_falloff.py``::
delta_i = weight_i * (axis_vector * step_mm) (mm, object-local)
``weight_i = 1.0`` for the probe members, otherwise the falloff curve of
``d_i / radius_mm`` where ``d_i`` is the distance from the vertex's ORIGINAL
position to the nearest probe vertex. Do not "fix" this back to the operator.
Exit codes: 0 success, 1 scene/geometry error, 2 config/job error.
"""
# pyright: reportMissingImports=false
from __future__ import annotations
import argparse
import hashlib
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import bpy
import numpy as np
# Blender -P does not consistently include the script directory on sys.path.
sys.path.insert(0, str(Path(__file__).resolve().parent))
from probe_falloff import ( # noqa: E402
FALLOFF_MODES,
affected_bands,
edge_lengths_mm,
falloff_weights,
flatness_mm,
orthonormal_basis,
percentile,
)
JOB_SCHEMA = "orisys-sensor-design/probe-deform-job-v1"
REPORT_SCHEMA = "orisys-sensor-design/probe-deform-report-v1"
FRAMES_NPZ_FILENAME = "probe_frames.npz"
DEFORM_REPORT_FILENAME = "deform_report.json"
AXIS_ROWS = {"x": 0, "y": 1, "z": 2}
VALID_AXES = (None, "x", "y", "z")
class ConfigError(Exception):
"""Bad or incomplete job file (exit 2)."""
class SceneError(Exception):
"""Blend contents do not match the job (exit 1)."""
def _argv_after_double_dash() -> list[str]:
if "--" not in sys.argv:
return []
return sys.argv[sys.argv.index("--") + 1 :]
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(prog="probe_deform.py")
parser.add_argument(
"--job",
type=Path,
required=True,
help="Absolute path to the _deform_job.json written by orisys-design deform",
)
return parser.parse_args(argv)
def load_job(path: Path) -> dict[str, Any]:
"""Load and structurally validate the deform job (raises ConfigError)."""
if not path.is_file():
raise ConfigError(f"Deform job not found: {path}")
try:
job = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ConfigError(f"Deform job is not readable JSON: {path}: {exc}") from exc
if not isinstance(job, dict):
raise ConfigError(f"Deform job must be a JSON object: {path}")
if job.get("schema") != JOB_SCHEMA:
raise ConfigError(
f"Deform job schema must be {JOB_SCHEMA!r}, got {job.get('schema')!r}"
)
for key in ("source_blend", "output_dir", "settings", "frames", "artifacts"):
if key not in job:
raise ConfigError(f"Deform job is missing {key!r}")
objects = job.get("objects")
if not isinstance(objects, dict) or not objects.get("mesh") or not objects.get("group"):
raise ConfigError("Deform job 'objects' must name a mesh and a vertex group")
settings = job["settings"]
if not isinstance(settings, dict):
raise ConfigError("Deform job 'settings' must be a mapping")
for key in ("radius_mm", "falloff", "step_mm", "max_mm", "axes"):
if key not in settings:
raise ConfigError(f"Deform job settings is missing {key!r}")
if not isinstance(settings["axes"], list) or not settings["axes"]:
raise ConfigError("Deform job settings.axes must be a non-empty list")
radius = float(settings["radius_mm"])
if not radius > 0.0:
raise ConfigError(f"Deform job settings.radius_mm must be > 0, got {radius!r}")
falloff = str(settings["falloff"])
if falloff not in FALLOFF_MODES:
raise ConfigError(
f"Deform job settings.falloff must be one of "
f"{', '.join(FALLOFF_MODES)}, got {falloff!r}"
)
step = float(settings["step_mm"])
max_mm = float(settings["max_mm"])
if not step > 0.0 or not max_mm > 0.0:
raise ConfigError("Deform job settings.step_mm/max_mm must be > 0")
frames = job["frames"]
if not isinstance(frames, list) or not frames:
raise ConfigError("Deform job 'frames' must be a non-empty list")
for position, frame in enumerate(frames):
if not isinstance(frame, dict):
raise ConfigError(f"frames[{position}] must be a mapping")
for key in ("index", "name", "axis", "step_mm"):
if key not in frame:
raise ConfigError(f"frames[{position}] is missing {key!r}")
if frame["index"] != position:
raise ConfigError(
f"frames[{position}].index must be {position}, got {frame['index']!r}"
)
if not isinstance(frame["name"], str) or not frame["name"]:
raise ConfigError(f"frames[{position}].name must be a non-empty string")
if frame["axis"] not in VALID_AXES:
raise ConfigError(
f"frames[{position}].axis must be null/x/y/z, got {frame['axis']!r}"
)
if float(frame["step_mm"]) < 0.0:
raise ConfigError(f"frames[{position}].step_mm must be >= 0")
if frames[0]["axis"] is not None or float(frames[0]["step_mm"]) != 0.0:
raise ConfigError("frames[0] must be the zero-displacement reference")
artifacts = job["artifacts"]
if not isinstance(artifacts, dict) or not artifacts.get("frames") or not artifacts.get("report"):
raise ConfigError("Deform job 'artifacts' must name 'frames' and 'report'")
source_blend = Path(str(job["source_blend"]))
if not source_blend.is_file():
raise ConfigError(f"Deform job source_blend not found: {source_blend}")
return job
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _require_mesh(name: str):
obj = bpy.data.objects.get(name)
if obj is None:
raise SceneError(f"Object not found: {name!r}")
if obj.type != "MESH":
raise SceneError(f"Object {name!r} has type {obj.type!r}, expected 'MESH'")
if obj.data is None or len(obj.data.vertices) == 0:
raise SceneError(f"Mesh object {name!r} has no vertices")
return obj
def _ensure_object_mode(obj) -> None:
"""Leave Edit mode if the opened file is in it (in-memory only, never saved)."""
if obj.mode == "OBJECT":
return
try:
view_layer = bpy.context.view_layer
if view_layer.objects.active is not obj:
view_layer.objects.active = obj
bpy.ops.object.mode_set(mode="OBJECT")
except Exception as exc: # pragma: no cover - depends on the opened file
raise SceneError(f"Could not leave {obj.mode!r} mode for {obj.name!r}: {exc}") from exc
def _probe_members(obj, group_name: str) -> tuple[list[int], list[float]]:
group = obj.vertex_groups.get(group_name)
if group is None:
available = ", ".join(sorted(g.name for g in obj.vertex_groups)) or "<none>"
raise SceneError(
f"Vertex group {group_name!r} not found on {obj.name!r} (have: {available})"
)
members: list[int] = []
group_weights: list[float] = []
for vertex in obj.data.vertices:
for assignment in vertex.groups:
if assignment.group == group.index:
members.append(int(vertex.index))
group_weights.append(float(assignment.weight))
break
if not members:
raise SceneError(f"Vertex group {group_name!r} on {obj.name!r} is empty")
return members, group_weights
def _write_frames_npz(
path: Path,
*,
base_vertices: np.ndarray,
vertices: np.ndarray,
frame_names: list[str],
frame_axes: list[str],
frame_steps_mm: list[float],
weights: np.ndarray,
origin: np.ndarray,
basis: np.ndarray,
probe_indices: np.ndarray,
) -> None:
if len(vertices) != len(frame_names):
raise SceneError(
f"frame count mismatch: {len(vertices)} vertex sets, {len(frame_names)} names"
)
path.parent.mkdir(parents=True, exist_ok=True)
np.savez(
path,
base_vertices=np.asarray(base_vertices, dtype=np.float64),
vertices=np.asarray(vertices, dtype=np.float64),
frame_names=np.array(frame_names),
frame_axes=np.array(frame_axes, dtype="<U4"),
frame_steps_mm=np.asarray(frame_steps_mm, dtype=np.float64),
weights=np.asarray(weights, dtype=np.float64),
origin=np.asarray(origin, dtype=np.float64),
basis=np.asarray(basis, dtype=np.float64),
probe_indices=np.asarray(probe_indices, dtype=np.int32),
)
def main() -> int:
args = parse_args(_argv_after_double_dash())
try:
job = load_job(args.job.resolve())
except ConfigError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
try:
mesh_name = str(job["objects"]["mesh"])
group_name = str(job["objects"]["group"])
obj = _require_mesh(mesh_name)
_ensure_object_mode(obj)
settings = job["settings"]
radius_mm = float(settings["radius_mm"])
falloff = str(settings["falloff"])
members, group_weights = _probe_members(obj, group_name)
mesh = obj.data
base_vertices = np.array(
[vertex.co[:] for vertex in mesh.vertices], dtype=np.float64
)
normals = np.array(
[vertex.normal[:] for vertex in mesh.vertices], dtype=np.float64
)
member_array = np.asarray(sorted(members), dtype=np.int64)
origin, basis, basis_report = orthonormal_basis(
normals[member_array], origin=base_vertices[member_array].mean(axis=0)
)
if not basis_report["orthonormal"]:
raise SceneError(
"probe-average basis is not orthonormal within "
f"{1e-6:g} (error {basis_report['orthonormality_error']:.3g})"
)
# Weights come from the ORIGINAL coordinates, computed exactly once, so
# every frame shares one falloff vector (Blender proportional-edit
# semantics: distance is measured from the selected vertices' start).
weights = falloff_weights(base_vertices, member_array, radius_mm, falloff)
edges = np.array(
[edge.vertices[:] for edge in mesh.edges], dtype=np.int64
).reshape(-1, 2)
edge_lengths = edge_lengths_mm(base_vertices, edges)
if edge_lengths.size == 0:
raise SceneError(f"Mesh object {mesh_name!r} has no edges to measure")
edge_p50_mm = percentile(edge_lengths, 50.0)
except SceneError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
except Exception as exc:
print(f"ERROR: probe deformation failed: {exc}", file=sys.stderr)
return 1
frames = job["frames"]
frame_count = len(frames)
vertices = np.empty((frame_count, base_vertices.shape[0], 3), dtype=np.float64)
frame_deltas: list[float] = []
frame_moved: list[int] = []
for position, frame in enumerate(frames):
axis = frame["axis"]
if axis is None:
vector = np.zeros(3, dtype=np.float64)
else:
vector = basis[AXIS_ROWS[axis]] * float(frame["step_mm"])
vertices[position] = base_vertices + weights[:, None] * vector[None, :]
delta = vertices[position] - base_vertices
frame_deltas.append(float(np.linalg.norm(delta, axis=1).max()))
# Contract: vertices displaced by a non-zero amount IN THIS FRAME.
# 0 for the reference, 6 + partial (11 on Xynova) for any non-zero step.
frame_moved.append(int(np.count_nonzero(np.any(delta != 0.0, axis=1))))
if not np.array_equal(vertices[0], base_vertices):
print(
"ERROR: reference frame differs from the base vertices",
file=sys.stderr,
)
return 1
members_at_one = int((weights >= 1.0).sum())
partial = int(((weights > 0.0) & (weights < 1.0)).sum())
unaffected = int((weights == 0.0).sum())
warnings: list[str] = []
if partial < 4 * len(member_array):
warnings.append(
f"radius {radius_mm:g} mm reaches only {partial} vertices outside the "
f"probe group (threshold {4 * len(member_array)} = 4 * "
f"{len(member_array)} members); the deformation is local to the probe "
"neighbourhood"
)
if radius_mm < edge_p50_mm:
warnings.append(
f"radius {radius_mm:g} mm is below the mesh median edge length "
f"{edge_p50_mm:.6f} mm; affected vertices may be isolated"
)
if any(abs(weight - 1.0) > 1e-9 for weight in group_weights):
warnings.append(
f"probe group weights are not all 1.0 ({group_weights}); the contract "
"displaces probe members by the full vector regardless"
)
scale = [float(value) for value in obj.matrix_world.to_scale()]
if max(abs(value - 1.0) for value in scale) > 1e-4:
warnings.append(
f"mesh object scale {scale} is not 1; displacement units are "
"object-local, not world millimetres"
)
source_blend = Path(str(job["source_blend"]))
report = {
"schema": REPORT_SCHEMA,
"generated_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
"source_blend": {
"path": str(source_blend.resolve()),
"sha256": _sha256_file(source_blend),
},
"mesh": mesh_name,
"group": group_name,
"probe_indices": [int(index) for index in member_array],
"origin": [round(float(value), 9) for value in origin],
"basis": [[round(float(value), 9) for value in row] for row in basis],
"basis_orthonormal": bool(basis_report["orthonormal"]),
"normal_angle_max_deg": round(float(basis_report["normal_angle_max_deg"]), 3),
"flatness_mm": round(flatness_mm(base_vertices[member_array], origin, basis[2]), 6),
"radius_mm": radius_mm,
"falloff": falloff,
"vertex_count": int(base_vertices.shape[0]),
"edge_p50_mm": round(edge_p50_mm, 6),
"weights": {
"min": float(weights.min()),
"max": float(weights.max()),
"members_at_one": members_at_one,
"partial": partial,
"unaffected": unaffected,
},
"affect_bands": affected_bands(weights),
"frames": [
{
"index": int(frame["index"]),
"name": str(frame["name"]),
"axis": frame["axis"],
"step_mm": float(frame["step_mm"]),
"max_delta_mm": round(frame_deltas[position], 9),
# Vertices with a non-zero displacement in this frame: 0 for the
# reference, 6 + partial (= 11 on Xynova) for any non-zero step.
"moved_vertices": frame_moved[position],
}
for position, frame in enumerate(frames)
],
"warnings": warnings,
}
artifacts = job["artifacts"]
frames_path = Path(str(artifacts["frames"]))
report_path = Path(str(artifacts["report"]))
try:
_write_frames_npz(
frames_path,
base_vertices=base_vertices,
vertices=vertices,
frame_names=[str(frame["name"]) for frame in frames],
frame_axes=["null" if frame["axis"] is None else str(frame["axis"]) for frame in frames],
frame_steps_mm=[float(frame["step_mm"]) for frame in frames],
weights=weights,
origin=origin,
basis=basis,
probe_indices=member_array.astype(np.int32),
)
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
except (OSError, SceneError) as exc:
print(f"ERROR: could not write deform artifacts: {exc}", file=sys.stderr)
return 1
print(
f"Probe deform complete: {frame_count} frames, {base_vertices.shape[0]} vertices, "
f"{members_at_one} probe members, {partial} partial, {unaffected} unaffected"
)
for warning in warnings:
print(f"WARNING: {warning}", file=sys.stderr)
print(f" {frames_path}")
print(f" {report_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+270
View File
@@ -0,0 +1,270 @@
"""bpy-FREE probe geometry maths: proportional falloff, basis, edge stats.
Why the falloff is computed here instead of with an operator
------------------------------------------------------------
``bpy.ops.transform.translate(use_proportional_edit=True)`` is deliberately NOT
used anywhere in this pipeline. That operator is viewport-modal: it needs a 3D
view region to define the pivot and, crucially, its
``tool_settings.proportional_size`` is interpreted in SCREEN SPACE (pixels)
relative to that region. Under ``blender -b`` there is no 3D-view region and no
screen-space scale, so the same script would affect a different vertex set
depending on GUI state or zoom -- and could not be reproduced at all headlessly.
The falloff is therefore computed explicitly, in millimetres, by this module.
Semantics replicate Blender's proportional editing with the probe members as the
selection:
* ``d`` = distance from the **original** position of a vertex to the nearest
probe vertex;
* ``t = d / radius_mm``;
* ``w = curve(t)``, with ``w = 0`` whenever ``t >= 1`` and ``w = 1`` for probe
members.
Because ``d`` is measured from the original (base) coordinates, the weight
vector is constant across every frame of the sweep -- exactly Blender's
behaviour, where the proportional falloff is computed once from the selected
vertices' starting positions.
Only numpy is required, so the normal pytest suite imports this module without
bpy (the same pattern as ``blender/scripts/patch_geometry.py``).
"""
from __future__ import annotations
from typing import Any, Sequence
import numpy as np
#: Blender proportional-edit falloff modes accepted by the pipeline contract.
FALLOFF_MODES = ("smooth", "linear", "sharp", "constant")
DEFAULT_FALLOFF = "smooth"
#: Contract failure threshold for the probe-average basis ("not orthonormal
#: within 1e-6" must fail loudly). The measured Xynova basis is orthonormal to
#: ~2.2e-16.
ORTHONORMAL_TOLERANCE = 1e-6
#: Weight histogram bands, lowest-inclusive/highest-exclusive except "1.0".
#: Empty bands are omitted so the report only prints what actually moves.
_WEIGHT_BANDS = (
("1.0", 1.0, 1.0),
("0.5-1.0", 0.5, 1.0),
("0.3-0.5", 0.3, 0.5),
("0.1-0.3", 0.1, 0.3),
("0.05-0.1", 0.05, 0.1),
("0.0-0.05", 0.0, 0.05),
)
def smooth_weight(t: float) -> float:
"""Blender's "Smooth" proportional curve: 1 at t=0, 0 at t>=1."""
return 1.0 - (3.0 * t * t - 2.0 * t * t * t)
def linear_weight(t: float) -> float:
"""Blender's "Linear" proportional curve: 1 - t."""
return 1.0 - t
def sharp_weight(t: float) -> float:
"""Blender's "Sharp" proportional curve: (1 - t)^2."""
return (1.0 - t) ** 2
def constant_weight(t: float) -> float:
"""Blender's "Constant" proportional curve: 1 everywhere inside radius."""
return 1.0
_FALLOFF_CURVES = {
"smooth": smooth_weight,
"linear": linear_weight,
"sharp": sharp_weight,
"constant": constant_weight,
}
def falloff_weight(t: float, mode: str = DEFAULT_FALLOFF) -> float:
"""Return the scalar falloff weight for a normalised distance ``t``.
``t <= 0`` gives 1.0 and ``t >= 1`` gives 0.0 for every mode.
"""
if mode not in _FALLOFF_CURVES:
allowed = ", ".join(FALLOFF_MODES)
raise ValueError(f"Unknown falloff mode {mode!r} (allowed: {allowed})")
t = float(t)
if t <= 0.0:
return 1.0
if t >= 1.0:
return 0.0
return float(_FALLOFF_CURVES[mode](t))
def _curve_values(t: np.ndarray, mode: str) -> np.ndarray:
clipped = np.clip(t, 0.0, 1.0)
if mode == "smooth":
values = 1.0 - (3.0 * clipped**2 - 2.0 * clipped**3)
elif mode == "linear":
values = 1.0 - clipped
elif mode == "sharp":
values = (1.0 - clipped) ** 2
else:
values = np.ones_like(clipped)
return np.where(t >= 1.0, 0.0, values)
def falloff_weights(
coords: np.ndarray,
member_indices: Sequence[int],
radius_mm: float,
mode: str = DEFAULT_FALLOFF,
) -> np.ndarray:
"""Return one falloff weight per vertex, measured from ORIGINAL positions.
``coords`` is ``(V, 3)`` object-local millimetres, ``member_indices`` are the
probe vertices (weight forced to exactly 1.0), ``radius_mm`` is the
proportional radius in millimetres. The result is constant across frames:
callers must pass the undeformed base coordinates every time.
"""
if mode not in _FALLOFF_CURVES:
allowed = ", ".join(FALLOFF_MODES)
raise ValueError(f"Unknown falloff mode {mode!r} (allowed: {allowed})")
coords = np.asarray(coords, dtype=np.float64)
if coords.ndim != 2 or coords.shape[1] != 3:
raise ValueError(f"coords must have shape (V, 3), got {coords.shape}")
members = np.asarray(list(member_indices), dtype=np.int64)
if members.size == 0:
raise ValueError("member_indices must not be empty")
if members.min() < 0 or members.max() >= coords.shape[0]:
raise ValueError("member_indices contains an out-of-range vertex index")
radius_mm = float(radius_mm)
if not radius_mm > 0.0:
raise ValueError(f"radius_mm must be > 0, got {radius_mm!r}")
deltas = coords[:, None, :] - coords[members][None, :, :]
distance = np.sqrt((deltas**2).sum(axis=-1)).min(axis=1)
weights = _curve_values(distance / radius_mm, mode)
weights[members] = 1.0
return weights
def affected_bands(weights: np.ndarray) -> dict[str, int]:
"""Histogram moved vertices by weight band; empty bands are omitted.
Matches the ``affect_bands`` block of ``deform_report.json``: the measured
Xynova default (radius 0.5 mm, smooth) is exactly
``{"1.0": 6, "0.3-0.5": 1, "0.05-0.1": 4}``.
"""
values = np.asarray(weights, dtype=np.float64).ravel()
bands: dict[str, int] = {}
for label, low, high in _WEIGHT_BANDS:
if label == "1.0":
mask = values >= 1.0
elif label == "0.0-0.05":
mask = (values > 0.0) & (values < high)
else:
mask = (values >= low) & (values < high)
count = int(mask.sum())
if count:
bands[label] = count
return bands
def orthonormality_error(basis: np.ndarray) -> float:
"""Return max ``|basis @ basis.T - I|`` (0.0 for a perfect frame)."""
matrix = np.asarray(basis, dtype=np.float64)
if matrix.shape != (3, 3):
raise ValueError(f"basis must have shape (3, 3), got {matrix.shape}")
return float(np.abs(matrix @ matrix.T - np.eye(3)).max())
def orthonormal_basis(
normals: np.ndarray,
origin: Sequence[float] | None = None,
) -> tuple[np.ndarray | None, np.ndarray, dict[str, Any]]:
"""Build the probe-average orthonormal basis from probe vertex normals.
Returns ``(origin, basis, report)``:
* ``basis`` rows are ``x``, ``y``, ``z`` in that order, object-local;
* ``z`` is the average of the unit normals (equal weight per vertex, so it
is area-independent), ``x = normalize(cross((0,0,1), z))`` falling back to
``(1, 0, 0)`` when that is degenerate, ``y = cross(z, x)``;
* ``origin`` is the probe centroid in object-local mm. Normals carry no
position, so the caller must pass it (usually
``base[member_indices].mean(axis=0)``); when omitted, ``None`` is returned
unchanged and ``report["origin_given"]`` is ``False``.
``report`` carries ``member_count``, ``normal_angle_max_deg``,
``orthonormality_error``, ``orthonormal`` and ``x_fallback``.
"""
normals = np.asarray(normals, dtype=np.float64)
if normals.ndim != 2 or normals.shape[1] != 3 or normals.shape[0] == 0:
raise ValueError(f"normals must have shape (P, 3), got {normals.shape}")
lengths = np.linalg.norm(normals, axis=1)
if not np.all(lengths > 0.0):
raise ValueError("probe vertex normals must be non-zero")
unit = normals / lengths[:, None]
mean_normal = unit.mean(axis=0)
mean_length = float(np.linalg.norm(mean_normal))
if mean_length <= 1e-12:
raise ValueError("probe vertex normals cancel out; no surface normal")
z = mean_normal / mean_length
x = np.cross(np.array([0.0, 0.0, 1.0]), z)
x_length = float(np.linalg.norm(x))
x_fallback = x_length < 1e-9
x = np.array([1.0, 0.0, 0.0]) if x_fallback else x / x_length
y = np.cross(z, x)
basis = np.vstack([x, y, z])
angles = np.degrees(np.arccos(np.clip(unit @ z, -1.0, 1.0)))
error = orthonormality_error(basis)
report: dict[str, Any] = {
"member_count": int(normals.shape[0]),
"normal_angle_max_deg": float(angles.max()),
"orthonormality_error": error,
"orthonormal": error <= ORTHONORMAL_TOLERANCE,
"x_fallback": bool(x_fallback),
"origin_given": origin is not None,
}
origin_out = None if origin is None else np.asarray(origin, dtype=np.float64)
return origin_out, basis, report
def flatness_mm(coords: np.ndarray, origin: Sequence[float], normal: Sequence[float]) -> float:
"""Peak-to-valley deviation of ``coords`` along the unit ``normal``, in mm.
This is the contract's ``flatness_mm``: for the measured Xynova probe it is
0.007611 mm (signed offsets along z span -0.00066..+0.00427 and
-0.00334..+0.00369).
"""
axis = np.asarray(normal, dtype=np.float64)
axis_length = float(np.linalg.norm(axis))
if axis_length <= 0.0:
raise ValueError("normal must be non-zero")
offsets = (np.asarray(coords, dtype=np.float64) - np.asarray(origin, dtype=np.float64)) @ (
axis / axis_length
)
return float(offsets.max() - offsets.min())
def edge_lengths_mm(coords: np.ndarray, edges: np.ndarray) -> np.ndarray:
"""Return the length of every mesh edge, in object-local millimetres."""
coords = np.asarray(coords, dtype=np.float64)
edges = np.asarray(edges, dtype=np.int64).reshape(-1, 2)
if edges.size == 0:
return np.zeros(0, dtype=np.float64)
return np.linalg.norm(coords[edges[:, 0]] - coords[edges[:, 1]], axis=1)
def percentile(values: np.ndarray, q: float) -> float:
"""Linear-interpolated percentile of a non-empty 1-D array (numpy default)."""
values = np.asarray(values, dtype=np.float64).ravel()
if values.size == 0:
raise ValueError("percentile of an empty array is undefined")
q = float(q)
if not 0.0 <= q <= 100.0:
raise ValueError(f"percentile q must be in [0, 100], got {q!r}")
return float(np.percentile(values, q))
File diff suppressed because it is too large Load Diff
+538
View File
@@ -0,0 +1,538 @@
"""Tests for the bpy-free probe falloff and basis helpers.
The Xynova regression fixture below is the measured set of nearest-probe-vertex
distances (mm) for the 2502 non-probe vertices of ``sensing_surface`` in
``sensors/finger-xynova/r01/source/geometry.blend`` (sha256
``1C8A050B8490B6832D3A9F269DFEF3DAFBF94DBFE3E943B3956BE1104A50D9C9``). It was
dumped once from Blender and is embedded as base64 float32 because pytest cannot
import bpy: the measured distribution has to travel with the test. The values
are sorted ascending, so the first entries are the ones the radius can reach.
"""
from __future__ import annotations
import base64
import numpy as np
import pytest
from probe_falloff import (
FALLOFF_MODES,
affected_bands,
edge_lengths_mm,
falloff_weight,
falloff_weights,
flatness_mm,
linear_weight,
orthonormal_basis,
orthonormality_error,
percentile,
sharp_weight,
smooth_weight,
)
#: Sorted nearest-probe distances, mm, for the 2502 non-probe vertices.
_NONPROBE_DISTANCES_F32_B64 = (
"SRaSPkIO1T5RDtU+nmLVPrFi1T44xks/DpdUPxiXVD8n71Q/pu9UPzvEYj8SxmI/h6BoP4egaD+vtWg/cAh7P40Iez8uv4E/"
"Yb+BP0dMmD90TJg/RHKYP2Hjnj+a454/lSefP8wnnz8YDaI/Yg2iPzz8vD+i/Lw/T+HLP9Hhyz/g59I/T+jSPyNF0z+XRdM/"
"FlnZP0tP3z9sUN8/PRrkPzSD5j/ag+Y/yC/nP4ai5z8Yxec/+uTrP3Oj7z+oo+8/GJfxP0AY+j/4GPo/R3b6P82K+z99JAJA"
"oiQCQPgiA0BSIwNAAmADQF5gA0D4kwNAfB0HQHQtCUDJLQlA0pAJQFMwEUCLMBFA7lYSQAobE0C7rxRAG7AUQIchFUDjIRVA"
"4+cbQCR2HECkdhxAKMUcQH7FHEDdoB9AWKEfQCN/I0BZfyNAxJcjQIZtJEDkbSRAqQcmQJwZKEBCGihAHX8pQCqILUDEli1A"
"PpctQF0BL0DMAS9AWDw0QPFnNUCQaDVADas1QH2rNUBnzjVA4s41QFZJNkDwSTZA4t43QCLfN0AO6zdAXOs3QGraOUCirztA"
"hrM7QKTMO0D11TtAMHo9QJhXPkBUPD9AkAJAQJc5QECyO0FARjxBQP28QUCVvUFAOTlDQPKOQ0Aej0NAW71EQD+zRkDzx0ZA"
"RO9HQL3vR0BtUEhApz9KQH+FTEAQhkxAmf1MQDP+TECNVE1AF+hNQBOuTkDReVBARclQQCMhUUDdZ1JAKGhSQBeKUkA+glVA"
"6ZJXQH3KV0AUy1dAr1NYQEZUWEDbSVpA+OlaQInqWkCQIltA6D5bQLlSW0DTbVtAEY5cQDS7XECjul1AbNtdQPz/XUA7jF5A"
"o75eQBHdXkBgDV9A2ThfQBp7YECne2BAvy5iQBlPYkARuGJAqeFiQAkIY0DACGNAqaNjQEGkY0AmmWRA1ZlkQPBjZUC2hmVA"
"6O9lQGbwZUDWaGZARX1mQMSwZkDZgGdArahnQKysZ0CTHGhAoCloQL8AaUAG82tAqRNtQC9XbUBEfW1A38ZtQCPmbUDi521A"
"j2FuQCpibkBatG5AeLRuQHXrbkAs7G5Ab3lvQHpRckA/hXJA4wl0QMoqdEAYT3RA0E51QKTtdkB3DXdAomN3QDbHd0AkPnhA"
"zT54QMkkeUAKiXlApLx5QCMqekD4KnpAmOp7QFwNfED2fnxAj398QOnyfEDWo31AIMt9QIxrf0B/YoBAbm6AQN9xgECcuoBA"
"4LqAQOLFgECZ34BA8wiBQMIVgUAcFoFA6TuBQPipgUDkuoFAucGBQALSgUDp4IFA5+GBQKP0gUADHoJARDaCQBg5gkBxQoJA"
"BFOCQAdTgkBpaYJA3a+CQD2wgkAQF4NAqi+DQDh0g0AthINA4omDQLeRhECuqoRAjdiEQMHYhECp2YRAdxGFQFwehUANI4VA"
"aSOFQFckhUAqM4VA7zyFQIluhUB+gIVA0hOGQEAUhkDSWIZAC4aGQCmfhkAd9IZALQSHQKZPh0CnY4dA9X6HQB6IiEAL64hA"
"TQSJQFFXiUCgaYlAXnGJQJyCiUAZz4lAbOiJQM4FikD3GIpAwxmKQF4Yi0DOGItA/zSLQCV1i0CGt4tAxNCLQIcCjEASEoxA"
"jSyMQMssjEC+54xAjf2MQBo9jUBZPY1Au0+NQG/jjUC+8I1A//ONQI0CjkCJOo5Aik+OQGF0jkCkdI5AQLmOQAbIjkBK0o5A"
"JeWOQEj+jkAgaY9AnAqQQAkSkEA/JpBAfvKQQCMEkUAZCZFAkhuRQGRZkUCbQZJA1kGSQKtukkAXcJJAHoKSQBeJkkByvJJA"
"tcCSQK/HkkB1zJJAaFWTQHMflUB3MJVArjGVQCsylUDnT5VAf2KVQJF0lUCchpVAtvSVQOv+lUATCJZAdAuWQOwVlkCBHpZA"
"gDmWQO5BlkCUT5ZANlOWQINplkAJbJZA/oCWQLSJlkB2n5ZASQmXQIRDl0BhW5dAI4iXQIuIl0COnZdAq++XQED/l0Dzi5hA"
"DqSYQH20mEDmuJhAYMaYQH0ImUCyHJlAwGGZQHy8mUDRzJlACwGaQEkBmkDXK5pAaz2aQHc/mkDLP5pAyFeaQHKHmkAKoJpA"
"pwybQCUgm0BmHpxApR6cQD1XnECxb5xAOJicQGmpnEB41pxAYR+dQHIxnUALl51AwqmdQEy9nkD1y55AIc2eQHLknkDgAJ9A"
"NBmfQB4gn0BpMZ9AcXWfQPinn0AyqJ9AuUCgQFJKoEAQTKBA91+gQECpoEA4u6BAIfSgQC35oECfBKFAEg6hQGaWoUDHraFA"
"066hQGy/oUBywqFAcSSiQJ44okC/rKJAj7GjQJXJo0D10aNAb+WjQOvwo0BEEqRAxiGkQNlApEBhUaRAJVKkQH9ipEAbsqRA"
"aMqkQNPppEB5vaVAj82lQI0GpkBDGaZA3IGmQJWDpkAqk6ZAcIKnQHe5p0DJuadAyxyoQPg0qEBeWKhA72ioQPm/qEBKxqhA"
"OdioQKgCqUCnSalALlupQAKzqUBMs6lA3dGpQAQXqkBFHqpAZyqqQJ0sqkCiM6pAQ0CqQHBXqkCcbapARIGqQPeVqkA0napA"
"n7OqQOLlqkBo9apAYVurQHhvq0Cwb6tAdYarQIabq0CHrKtANtOrQCHrq0ClEKxA3yGsQDScrEBLs6xASLWsQJ7IrEBz3axA"
"COesQB/3rEDxAq1AKQOtQAXHrUAG/K1ATA2uQKEhrkDvOK5AL5auQNGorkAZh69AX9CvQFTir0AB6K9A7vKvQBP9r0CBFLBA"
"rVuwQNJqsEDVnrBA0vuwQLIEsUCtDLFAnBaxQKXfsUA577FA+iiyQHhAskDI/bJAzg6zQAEBtECHArRAtgq0QMATtEAPFrRA"
"wRy0QA0itEBEMbRArTS0QM9EtECen7RAG6q0QCi3tED+vbRAqMe0QPNStUAyU7VATom1QGmatUCTrLVAAbS1QMq4tUCEubVA"
"RMe1QHastkA3vbZAvy23QPctt0CPMbdA1EC3QFpEt0DYVrdAzVu3QChzt0CAjrdAJJ+3QExPuEAfYLhAlna4QJKNuEDh+7hA"
"gQu5QNxjuUDUdblAMaK5QOZLukAVTLpAXFq6QIdxukCAc7pASIS6QDuku0BjtLtA5Ba8QBsovEDyzbxA/dy8QHRGvUCwmr1A"
"sbG9QEzJvUCd3b1Az++9QMP5vUCfBL5Acwq+QLIOvkB0Ir5AySe+QMcovkAROL5AyDy+QF12vkCai75AY62+QBTNvkAg2r5A"
"NuC+QJDvvkABUr9AWV6/QK5nv0AKb79AIre/QNnHv0CfEMBAICPAQKY5wEBNScBA7XnAQN+PwEApG8FA7zHBQLV2wUBt5MFA"
"wufBQFL2wUA8+sFA7v3BQPsKwkDRysJAEcvCQA44w0AzSMNAAWPDQG2aw0BsrcNAv7DDQGy8w0AST8RASmDEQNiKxEAii8RA"
"49jEQFLvxEAzQcVAfFDFQMOPxUAxpsVAYzTGQPfHxkBLMcdA1UHHQOhTx0COZMdAcH3HQKV9x0DHhMdAWYfHQM+Yx0DamsdA"
"icPHQOTZx0AQ6MdAiPzHQAz/x0D6EchA4szIQAfjyEAV+shAdQzJQMCKyUCXmclApDHKQPJHykAkrspAKr3KQMHyykD3AstA"
"CZDLQC+Qy0AN08xAKdbMQJPjzEAN5cxAS+zMQHL6zEBSJc9ADzXPQAKiz0DDr89AzcXPQPYF0EBUHtBAlR7QQHxq0ECmf9BA"
"n47QQOQR0UA6JdFA8UfRQL9M0UC2WtFAYWDRQOR30UDYxtFACcfRQFDn0UBu+9FALP/RQGYR0kDrU9JATGjSQHK/0kAn09JA"
"NtXSQL7n0kC+RtNAgVjTQGhj00AIeNNARK3TQErh00Cm8NNAVQPUQBUY1EDnktRAIJPUQMax1ECzxtRAlSnVQLU61UCtbdVA"
"rYLVQD8F1kDUGtZA62HWQDly1kCdC9dAwCDXQIqv10AmwNdADATYQKFy2EA23NhAe/HYQLwb2UDkKtlAfYDZQMmV2UBHNdpA"
"tkraQCqp2kAZvNpAbdzaQDXe2kCf7NpAb/PaQAIc20BYL9tAuEXbQIjo20BN/NtAM/7bQHwO3ECHDt1AtiPdQCcr3UDgLN1A"
"WkDdQOJB3UBWSt1AnErdQCKt3kDtvN5Ar9DeQO3d3kAZ3t5As9/eQLxq30DKf99ACFPgQPln4EBqqOBAfb3gQKrW4EBC3uBA"
"5+rgQImH4UCuh+FA97PhQFPw4UBSBeJA9xnjQGkp40CbQuNAf1fjQCWt40CNvuNA0cHjQE7Y40CtCORA7xrkQCpG5ECsRuRA"
"/VfkQFtZ5ECYn+RAb7TkQILs5ECM/+RAwxHlQBcj5UBFOeVAaDnlQBHm5UBm+eVAugfmQGoc5kAqeuZA+3zmQAyL5kBtkOZA"
"siLnQEU250CRNudA4UrnQDl750DJj+dAfdXnQIrb50Ao6edALxnoQE0o6EALi+hA4pPoQH6b6ECup+hAWvroQMsO6UDKXOlA"
"o3DpQEfb6UABL+pA6ULqQM9L6kAITOpAO4XqQIeZ6kB8repANMHqQJRL60CIW+tAknXrQIiF60B+zOtAsszrQDvt60A/AexA"
"iKTtQFqz7UDIvu1ATs7tQOPS7UBi4u1ADVfuQDZX7kDXuu5AfLvuQA/L7kBzze5AstHvQLbc70DB5e9Alu/vQLPf8EC38/BA"
"SibxQBY68UB1qPFAKt/xQEzf8UDD9fFAwgnyQHrV8kC+5PJAvBPzQKQn80B43vNAXzn0QExN9EBqbfRAlG30QLmA9EB/m/RA"
"Ra/0QKNm9UCFevVARCD2QKqb9kBkr/ZAFr72QEvP9kBp6PZABPr2QB4N90DyHfdA3x/3QBIg90DbLvdAmof3QIaZ90ABqvdA"
"frX3QP/I90Cy2PdAXOz3QN4Z+EBeKvhAVIX4QHOP+ECnj/hAhZf4QGIe+UD9MflAucD5QBzL+UC60PlAjd35QKpQ+kArX/pA"
"S236QMOA+kAdg/pAu5X6QGf8+kC5/fpA5/36QKsP+0CiRvtAO1n7QOrF+0BN2ftAeBP8QGUc/EAqJvxAGCz8QGvn/EBX6PxA"
"Fvb8QCL7/ECpKP1A8Tv9QA3E/UDl1v1AHRf+QLUp/kDTX/5A+F/+QOuV/kB/pf5AF6n+QFu4/kCZL/9A+T7/QLU7AEEoRQBB"
"0cgAQUTSAEH1FQFBPy8BQbY4AUGVewFBFYMBQR6tAUGRtgFBLOQBQUXkAUGZLQJBETcCQb9cAkERZgJBG5QCQTSUAkHEsAJB"
"NLoCQUDrAkGgNgNBC0ADQQNJA0E5vgNBTr4DQSm/A0GUyANBO9UDQUHeA0F/OgRBykMEQXBKBEHIUwRBSWAEQRGmBEEKqwRB"
"3awEQUCtBEFaswRBBbUEQWjYBEG14QRBvusEQTT0BEFd+wRBXAMFQbkNBUG+XQVBz10FQUFjBUE1aQVB02sFQYFyBUFCnwVB"
"GKcFQdupBUEGswVBMP0FQVEFBkFwBgZBAA4GQVNDBkGESgZBrJQGQdudBkHKngZBcqYGQdTFBkGbzgZBhi0HQQgwB0FmNgdB"
"LjkHQbZkB0ERmwdB7aMHQaDPB0G42AdBzQoIQbETCEHCJAhB3CQIQcBzCEHTfAhB8HwIQdmFCEFxzQhBi80IQbscCUG7JQlB"
"XikJQR1mCUEJbwlB8rcJQUq/CUGL6wlBo+sJQZJTCkF5XApBTH4KQTmHCkECzApB6NQKQcZFC0GwTgtBvXoLQdJ6C0ENewtB"
"KqkLQfTAC0HRyQtB2+4LQXX3C0F/PQxBZ0YMQYNIDEF8TwxBvIoMQZe7DEFzxAxBRzsNQSVEDUFjSA1B7lANQbG8DUGExQ1B"
"XdwNQTnkDUH/8Q1Bu/oNQYEBDkHNAQ5BeQkOQYUJDkGJOw5B5D8OQZZCDkGtSA5BLlMOQUhTDkEcZg5BF2wOQTJuDkFRdQ5B"
"BMUOQcHNDkEg9Q5BM/UOQZH9DkHIBQ9BgEYPQTNMD0EmTw9B3lAPQedUD0FGWA9BALsPQUnDD0Hd1Q9BjN4PQfcHEEEICBBB"
"cGIQQRlrEEGVkRBB7JkQQW7yEEED+xBBkP0QQTN3EUGXfxFBMIYRQeWHEUHzhxFBvY4RQb4ZEkEgHhJB4SASQaUmEkGyYxJB"
"HmwSQZ66EkEVwxJB8lETQV9XE0FWWhNBqsgTQQrRE0HP6BNBwAIUQSMLFEEjDBRB8hIUQSg/FEGVRxRBJnAUQT1wFEGltRRB"
"DL4UQb4LFUHTCxVBWiwVQbg0FUGcUBVBulgVQVajFUG0qxVBkcAVQQcUFkEZFBZBvRoWQRojFkGIVxZBZF4WQXuSFkHCkhZB"
"hpoWQQ+bFkHHpxZBkwsXQeITF0GKFxdBoxkXQREfF0EXIRdBRl0XQexkF0FbhRdBj4UXQaSFF0GejRdB3KwXQRDfF0HN5hdB"
"QQAYQX0IGEFqHRhBkCQYQdpbGEFxfBhBp4QYQcSPGEGHlxhBLPoYQVwCGUHNYRlBp2kZQe15GUEOghlBLPwZQUcEGkEISBpB"
"508aQYOBGkGTiRpBpJoaQZKhGkFdChtBYhIbQRw4G0EJQBtBMEYbQSuXG0EnnxtB4O0bQXj0G0FkKBxB+ykcQVowHEHmMRxB"
"7aEcQeWpHEGrGB1BmCAdQbZZHUGYYR1B9Y0dQeCVHUH8AR5B5AkeQf90HkHkfB5BMIkeQc+QHkF0kh5BGZkeQUnnHkEp7x5B"
"CFkfQdtgH0FDth9B0r0fQWTKH0E50h9BKwUgQbY7IEGoPSBBg0MgQcVEIEG0WSBB4mAgQaRxIEGneCBBpXkgQbx5IEGIrCBB"
"NK0gQf20IEGBuiBBzcEgQRz5IEGM/iBBagUhQZ4JIUGzCSFBHh8hQd8mIUG7UyFBE1shQaaRIUFlmSFB4qEhQXP+IUGO/iFB"
"HAUiQc0MIkHLFyJBMR8iQbZ5IkFhgSJBA/AiQaz3IkHQ+CJBSQAjQTE1I0HSOyNBB0sjQW1VI0F9VSNBnGgjQTZwI0EL5CNB"
"U+kjQVDqI0Gi6yNBsvAjQcrwI0HgYiRBamokQQXVJEHM1SRBAd8kQY/lJEF35iRBE+0kQZNsJUEOdCVB29ElQVHZJUHESCZB"
"PlAmQYSJJkHpkCZBcL0mQefEJkHg5yZBPu4mQcAvJ0ExNydBSJ4nQe2fJ0F4pSdBX6cnQVQOKEHAFShBK1AoQUN7KEGfgihB"
"57goQQLAKEH35ihBXO4oQcZRKUEiWSlBkGopQVtxKUFAgilB7ogpQRqgKUH3pilB+bspQVHDKUGJ8ClBI/cpQQgXKkHsHSpB"
"9SUqQUItKkH9jypBQpcqQZzCKkGTySpBTfoqQZYBK0FEZStBfGwrQdtsK0H5lCtBAZwrQS7RK0Fl2CtBj+UrQfPrK0F6/itB"
"ngQsQQ0iLEGfPixBzUUsQa1ZLEEagCxBKYcsQTauLEFctSxBXs4sQXDOLEH41ixBiCAtQaonLUFUdi1BYX0tQSmWLUFCnS1B"
"hG0uQZR0LkFxjS5BeZQuQdrmLkHi7S5BP/AuQVHwLkGFVC9BplovQd1dL0HiZC9BAZgvQfaeL0Ef0i9BIdkvQYTzL0FUQzBB"
"WkowQduIMEEjlTBB6pswQW+xMEFruDBBiLwwQbYcMUGzIzFBsj0xQc5DMUGQhTFBiYwxQT2fMUHvpTFBaOwxQVjzMUGNUTJB"
"elgyQXSdMkEunjJB1qMyQaekMkFWtTJBQLwyQeroMkFv7zJBkvAyQdr2MkEuGDNBER8zQYVxM0EVeDNBhnozQWGBM0FTszNB"
"rdwzQYDjM0HuKjRBiTE0QfI+NEHHRTRBtaE0QXmoNEHSqDRB5q40QUkFNUFDBzVBDww1Qd4NNUFZajVBF3E1QX7RNUEz2DVB"
"TPg1Qez+NUGZqDZBQK82QW3wNkET9zZBD483QaiVN0HVvzdBxcI3QaXVN0GA2zdBr+U3QVDsN0G2XDhBjGI4QZhiOEFTYzhB"
"EIk4QZqPOEGR0DhBL9c4QfVAOUGKRzlBkE45QaNTOUFWcTlBqnc5QaCtOUExtDlBhRY6QRYdOkGuXTpBul06QQx8OkGagjpB"
"rt46QTXlOkHcPjtBYkU7QfqcO0F6oztBz8E7QdfHO0GT1ztBrd07QXL5O0G1/DtB4f87QbICPEHkBDxBDzA8QdgzPEELOjxB"
"uVQ8QSxbPEEVZDxBvGk8QVGvPEG8tTxBXso8QZHQPEGVCT1BAhA9QehjPUFIaj1BjHw9QVyCPUHijT1BGZQ9QZ++PUEAxT1B"
"MRo+QYggPkFscD5BonY+QUJ3PkGWfT5B1Ik+QW7WPkG33D5B7GM/QSJqP0HGnT9B/6M/QdFaQEEKYUBB5mhAQYJuQEHZdEBB"
"CntAQWRLQUGdUUFBdFJBQeRXQUHoX0FBDGZBQem+QUEdxUFBmSpCQY4uQkG8NEJBnTVCQZc7QkERmkJBRaBCQVUBQ0F7B0NB"
"7D1DQU9kQ0FxakNBeMNDQaPJQ0EJ0kNBFdJDQVwfREF6JURBd3hEQSR8REGMfkRBP89EQVfVREFv7kRBnfJEQSb0REG1E0VB"
"ZxZFQVsZRUEtHEVBLCRFQUIqRUHMd0VB2H1FQUyBRUEgh0VB9KhFQfyoRUGhykVBodBFQQb2RUEUHUZBoSJGQRYjRkF1KEZB"
"115GQVlkRkGPb0ZBkHVGQXvCRkFwyEZBJu1GQf3yRkFSFkdBSBxHQZNXR0FYw0dBO8lHQfzSR0Ha2EdB5XtIQcKBSEEuxkhB"
"CsxIQVYMSUGqEUlBy0RJQZlKSUFZuUlBN79JQQwfSkGyJEpB8C9KQck1SkEzo0pBBqlKQW7nSkEA7UpBRRJLQRwYS0GKVktB"
"6XxLQbeCS0HX4ktBpOhLQQxETEHUSUxBl6BMQVumTEH2+ExBuv5MQcoOTUHnE01BzE1NQYRTTUGWn01BSaVNQeLuTUGX9E1B"
"wgdOQboiTkGsJU5BGyhOQV80TkG1OU5BMTxOQeJBTkGgWk5BCmBOQSaITkHQjU5BtdFOQUnTTkEo105B59hOQREeT0GtI09B"
"BidPQRMnT0EITk9BOFNPQehoT0F9bk9BZ3tPQeeAT0GpAFBBMQZQQYZKUEEEUFBBJ59QQa2kUEHL21BB19tQQV8xUUHaNlFB"
"4klRQV1PUUEiv1FBkAVSQQQLUkEdIlJBmidSQQsuUkGOM1JBx4xSQRrDUkGXyFJBc89SQbbUUkHGD1NBQRVTQY5SU0EEWFNB"
"j2tTQYiCU0H+h1NBtYxTQe6RU0Gp4FNBFuZTQUbxU0G69lNBSVtUQbxgVEFKwFRBu8VUQbTsVEExIFVBmiVVQe96VUFagFVB"
"tdBVQRrWVUEVIlZBbydWQZtvVkH4dFZB+7lWQVS/VkHIAVdBGQdXQYpHV0HbTFdB2V1XQSVeV0HVYldBKmNXQaiBV0Hli1dB"
"LpFXQSakV0FAqVdBd89XQbfUV0GHJVhBoCpYQaNIWEGKTVhBKlZYQWhbWEGu1VhB2NpYQYLfWEG15FhBP3BZQWZ1WUGWp1lB"
"vaxZQRDrWUHjDVpBBhNaQdppWkHraVpB/nxaQa6BWkG2jVpB25JaQT69WkFTwlpBiXpbQad/W0FD/ltBUf5bQeQqXEHLL1xB"
"VGFcQW5mXEGmz1xBv9RcQXA5XUGDPl1B7J1dQf2iXUEJ811BjfddQfL8XUEEAl5BglZeQZFbXkGYkV5Bj6peQZ6vXkFq+V5B"
"cP5eQWM2X0GkQ19BqkhfQemJX0Hjjl9B5MxfQeDRX0Ey1V9BxddfQT0NYEEzEmBBiUtgQYFQYEF3iGBBZY1gQY2PYEE1lGBB"
"bKBgQSalYEGvxGBBmclgQSTzYEHf92BB/TxhQdxBYUFSfWFBEn5hQWp/YUEWgmFBerhhQVO9YUGRMmJBTjdiQSM8YkHvQGJB"
"Ys1iQSTSYkHeBWNBpwpjQQNxY0HFdWNBAupjQcXuY0GTxmRBJ8tkQcTRZEGM1mRBJixlQTChZUE9oWVBzLBlQY+1ZUE462VB"
"XRpmQR0fZkHVfmZBj4NmQZXdZkFR4mZB1RZnQeAWZ0F1NmdBLDtnQX2JZ0E1jmdBo8lnQdTWZ0F922dBrx5oQVQjaEHHYWhB"
"b2ZoQc2gaEFspWhBfNxoQRrhaEGHFWlBHhppQUkiaUGJTGlBIFFpQS+CaUHHhmlBPbdpQcu7aUFRyWlBqc1pQabpaUEB7mlB"
"giFqQQsmakHYR2pBPkxqQclcakEFYWpBqY9qQS+UakHF2WpBJd5qQZ8Ga0EZC2tB44trQVaQa0H2kmtBWJdrQRkdbEErIWxB"
"NCRsQZMkbEGdKGxBp2ZsQRZrbEHuR21BWkxtQVZibUGRZm1B5iluQU8ubkES7m5BoABvQQUFb0G7B29BqQtvQTZlb0GgaW9B"
"wmxvQUPEb0GdyG9BIh1wQXchcEHBb3BBHnRwQUa8cEGewHBBk9JwQaHScEHP8XBB0wJxQSUHcUG5Q3FBFUhxQcB/cUEMhHFB"
"nLdxQem7cUEV7HFBV/BxQab9cUHiHXJBIyJyQeQqckHvKnJBsk1yQfNRckEsqnJBba5yQacGc0HmCnNB/UZzQY5Hc0GYZ3NB"
"wmtzQZiic0GopnNB+tFzQR3Wc0FfO3RBaD90QU1LdEFqT3RBHoV0QTHYdEFE3HRBtfd0Qcr7dEEjy3VBOM91Qdb+dUG3AnZB"
"/qh2QQ2tdkHaT3dBlnZ3QbWEd0HCiHdBA+53QRLyd0HVUnhB3lZ4QVqyeEFntnhB6At5QesPeUHjXnlB6WJ5QVKreUFPr3lB"
"WPF5QVv1eUFfGXpBMDF6QS81ekFCa3pBQW96QU6gekFGpHpBE9F6QQvVekFu/npBYgJ7QRspe0ELLXtBzVF7QbhVe0Eon3tB"
"NqB7QRSke0Hz7ntBzvJ7QQECfEEQAnxBmUJ8QW5GfEG0U3xBXFd8QaeQfEFUlHxBu5V8QUaZfEE+oHxBEqR8QZcDfUFHB31B"
"aA19QTURfUFVPn1BWT59QZ6OfUFkkn1BoqJ9QVmmfUF45X1B7+h9QbdhfkFtZX5Bg+1+QZ/wfkFpNH9BIjh/QTcHgEETCYBB"
"1FOAQdpxgEGyc4BBeKSAQVKmgEGT1IBBataAQc8BgUGlA4FB2CuBQa4tgUFsUoFBQFSBQZB1gUFod4FBZpWBQTqXgUHKrIFB"
"BrKBQdezgUGxxYFBs8uBQYTNgUHK4oFBmOSBQbT3gUGB+YFB4QqCQbEMgkG1HIJBfx6CQZAtgkFXL4JBxU2CQZBPgkFOboJB"
"EHCCQXuRgkE8k4JB6LmCQaa7gkFL6oJBCeyCQeUkg0GeJoNBZ1SDQe2Yg0HxmINBrp+DQVChg0G5KYRBuymEQb40hEEINoRB"
"kbyEQUO+hEH4I4VBVSWFQZYlhUGjJYVBilSFQThWhUFhgoVBDoSFQSKthUHNroVBgNSFQSnWhUE5+IVB4/mFQVwYhkEIGoZB"
"ETWGQbc2hkF7ToZBIlCGQdxkhkGCZoZBl3iGQT16hkEfioZBwIuGQd2ZhkGAm4ZBPqGGQaG1hkE+t4ZBJL6GQf7OhkGV0IZB"
"P3aHQa93h0GGfIdB/X2HQaKoh0EdqodBw8GHQR3Dh0HM64dBS+2HQRL4h0FEQYhBxUKIQSmjiEGrpIhBygiJQY4LiUERDYlB"
"ATKJQQMyiUGvdIlBMXaJQb6niUFBqYlBDbaJQRC2iUHp2IlBatqJQYoHikEHCYpBLTOKQao0ikGIW4pBBV2KQUuAikHKgYpB"
"RKGKQcCiikGBvopB/L+KQSzYikGq2YpBeu6KQfDvikGnAYtBHAOLQR8Si0GWE4tBVCCLQcohi0G9LItBMC6LQbtBi0EuQ4tB"
"S1SLQbdVi0FGZ4tBtGiLQS59i0GZfotBMYSLQaOYi0EOmotBV7yLQcO9i0F16otB2OuLQRlLjEFmTIxBpMqMQVSMjUHlo45B"
"j6+OQRzNjkEezY5BDEWPQQxFj0EVxI9BX8WPQW7Sj0G2049Bt5aQQRyYkEFd25BBfdyQQcMUkUHjFZFBb2GRQZRikUHQvJFB"
"9b2RQTghkkFcIpJB/YiSQSSKkkHE7pJB6O+SQTL9kkFBH5NBZyCTQVNNk0F2TpNBX3iTQYN5k0ECoJNBJ6GTQfvDk0EbxZNB"
"/uOTQSDlk0HZ/5NB+gCUQa4XlEHSGJRBtSuUQdYslEEgPJRBQD2UQSdJlEFDSZRBYkqUQY9TlEGtVJRBbFuUQY9clEFcYZRB"
"fWKUQSpplEFIapRB92qUQflqlEFLbpRBZG+UQcVzlEHedJRBLnyUQUZ9lEE8ipRBUIuUQY+glEGhoZRBOMGUQUfClEEax5RB"
"Z9eUQWfXlEFrBZVBaAaVQR6JlUFs25VBBkWaQc1FmkGtkJpBdJGaQbXWmkHW5ppBoOeaQewJm0GnLZtBdEibQTlJm0EIsJtB"
"0rCbQQHMm0EtGJxB9xicQdd7nEGgfJxBnoycQW2qnEE3q5xBHdacQeXWnEFW/pxBHv+cQbsinUGCI51BDUOdQdZDnUEOX51B"
"1l+dQZF2nUFXd51BuomdQYOKnUFfi51B55WdQZSWnUGllp1BVZedQcuYnUGSmZ1BiJydQSOdnUFGnZ1B3Z2dQQGknUHKpJ1B"
"/qSdQcKlnUG1q51BeaydQUOtnUEHrp1BW66dQRivnUFTsJ1BFrGdQS+ynUFQsp1B9rKdQRaznUHH0Z1BcNKdQWgbnkG0lZ9B"
"7bCfQe6wn0HsBaBBHwagQT0IoEE/CKBBP4agQVGGoEGobaFBZW+kQQGepEH90aRBbeGkQd3hpEEB/aRBKk2lQZhNpUHntqVB"
"UrelQYK8pUHVGaZBPRqmQU1HpkG8R6ZBbXGmQdpxpkGpl6ZBE5imQbCwpkEEsaZBh7KmQd+ypkGLs6ZB8LOmQa25pkEZuqZB"
"G72mQYS9pkG+zaZBJc6mQT7XpkGu16ZBseKmQRrjpkEm8KZBkPCmQTL5pkGb+aZBNgSnQaAEp0FuDqdB1w6nQZcTp0EDFKdB"
"jx6nQf0ep0E6H6dBqB+nQeMkp0FNJadBZiWnQdMlp0EeKKdBcyinQYgop0HaKKdBhMOnQfUEqUGCrqlBh9upQfDnqUFa7qlB"
"0xaqQaUkqkHNZapB0WWqQZV3qkFZ1KpBd9uqQT/lqkHPQ6tBvk2rQeC2q0FXuqtBWB2sQcQerEHPfaxBtoOsQUDarEEL4qxB"
"f/KsQeAgrUEYKq1BOTCtQTY0rUGMWa1Bn1mtQRZnrUE7cq1BJHetQah4rUERfK1BdJytQYycrUGXpa1BlK6tQXK2rUGJuK1B"
"TdStQcjWrUFd2K1B6eWtQd7mrUEz+a1B4v2tQWYErkEFBa5BchGuQQwUrkGwFq5B6xauQSkXrkGWG65B3xuuQfkbrkFcHK5B"
)
#: Signed offsets of the 6 measured probe vertices from their centroid along the
#: basis z axis (mm). Peak-to-valley is the contract's flatness_mm = 0.007611.
_MEASURED_PROBE_OFFSETS_Z = (
-0.0006562,
0.0042672,
-0.0033438,
0.0036863,
-0.0033205,
-0.0006329,
)
def _measured_nonprobe_distances() -> np.ndarray:
raw = base64.b64decode(_NONPROBE_DISTANCES_F32_B64)
return np.frombuffer(raw, dtype="<f4").astype(np.float64)
def _measured_probe_cloud() -> tuple[np.ndarray, np.ndarray]:
"""Return (coords, member_indices) reproducing the measured radii exactly.
The 6 probe members sit at the origin; each non-probe vertex sits on the +x
axis at its measured nearest-probe distance, so the minimum distance to a
member equals the measured value.
"""
distances = _measured_nonprobe_distances()
members = np.zeros((6, 3), dtype=np.float64)
others = np.stack(
[distances, np.zeros_like(distances), np.zeros_like(distances)], axis=1
)
coords = np.vstack([members, others])
return coords, np.arange(6, dtype=np.int64)
# ------------------------------------------------------------- curves
def test_curve_endpoints() -> None:
assert smooth_weight(0.0) == 1.0
assert smooth_weight(1.0) == 0.0
assert linear_weight(0.0) == 1.0
assert linear_weight(1.0) == 0.0
assert sharp_weight(0.0) == 1.0
assert sharp_weight(1.0) == 0.0
assert smooth_weight(0.25) == pytest.approx(1.0 - (3 * 0.25**2 - 2 * 0.25**3))
def test_curve_monotonic_decreasing() -> None:
samples = np.linspace(0.0, 1.0, 101)
smooth = np.array([smooth_weight(t) for t in samples])
linear = np.array([linear_weight(t) for t in samples])
sharp = np.array([sharp_weight(t) for t in samples])
for curve in (smooth, linear, sharp):
assert np.all(np.diff(curve) <= 0.0)
assert curve.min() == pytest.approx(0.0)
assert curve.max() == pytest.approx(1.0)
def test_falloff_weight_thresholds_and_dispatch() -> None:
assert falloff_weight(0.0, "smooth") == 1.0
assert falloff_weight(-5.0, "sharp") == 1.0
assert falloff_weight(1.0, "constant") == 0.0
assert falloff_weight(1.5, "smooth") == 0.0
assert falloff_weight(0.4, "linear") == pytest.approx(0.6)
assert falloff_weight(0.4, "sharp") == pytest.approx(0.36)
assert falloff_weight(0.4, "constant") == 1.0
with pytest.raises(ValueError):
falloff_weight(0.5, "bounce")
def test_falloff_modes_list_matches_the_contract() -> None:
assert FALLOFF_MODES == ("smooth", "linear", "sharp", "constant")
# -------------------------------------------------------------- basis
def _mixed_normals() -> np.ndarray:
"""Six unit normals whose mean is tilted away from +z (mixed, not symmetric)."""
angle_a = np.radians(2.5533)
angle_b = np.radians(0.5)
tilted_a = np.array([np.sin(angle_a), 0.0, np.cos(angle_a)])
tilted_b = np.array([-np.sin(angle_b), 0.0, np.cos(angle_b)])
return np.array([tilted_a] * 3 + [tilted_b] * 3, dtype=np.float64)
def _tilted_normals(angle_deg: float) -> np.ndarray:
"""Six unit normals with mean exactly +z and max deviation angle_deg."""
angle = np.radians(angle_deg)
return np.array(
[[np.sin(angle), 0.0, np.cos(angle)]] * 3
+ [[-np.sin(angle), 0.0, np.cos(angle)]] * 3,
dtype=np.float64,
)
def test_orthonormal_basis_rows_are_x_y_z_and_right_handed() -> None:
normal = np.array([0.2, -0.3, 0.9])
normals = np.array([normal / np.linalg.norm(normal)] * 6)
origin, basis, report = orthonormal_basis(
normals, origin=[74.76961, 16.2155, 120.80202]
)
x, y, z = basis
assert report["member_count"] == 6
assert report["origin_given"] is True
assert origin == pytest.approx([74.76961, 16.2155, 120.80202])
assert z == pytest.approx(normals[0])
assert x == pytest.approx(
np.cross([0.0, 0.0, 1.0], normals[0])
/ np.linalg.norm(np.cross([0.0, 0.0, 1.0], normals[0]))
)
assert y == pytest.approx(np.cross(z, x))
assert np.allclose(np.cross(z, x), y, atol=1e-12)
assert orthonormality_error(basis) < 1e-12
assert report["orthonormal"] is True
assert report["x_fallback"] is False
assert report["normal_angle_max_deg"] == pytest.approx(0.0, abs=1e-9)
def test_orthonormal_basis_reports_max_normal_angle() -> None:
normals = _mixed_normals()
_, basis, report = orthonormal_basis(normals, origin=[0.0, 0.0, 0.0])
unit = normals / np.linalg.norm(normals, axis=1)[:, None]
expected = float(
np.degrees(np.arccos(np.clip(unit @ basis[2], -1.0, 1.0))).max()
)
assert report["normal_angle_max_deg"] == pytest.approx(expected, abs=1e-9)
assert 1.5 < report["normal_angle_max_deg"] < 1.6
assert orthonormality_error(basis) < 1e-12
def test_orthonormal_basis_origin_is_optional() -> None:
origin, basis, report = orthonormal_basis(_mixed_normals())
assert origin is None
assert report["origin_given"] is False
assert basis.shape == (3, 3)
def test_orthonormal_basis_degenerate_x_falls_back_to_plus_x() -> None:
origin, basis, report = orthonormal_basis(
np.array([[0.0, 0.0, 1.0]] * 4), origin=[0.0, 0.0, 0.0]
)
assert report["x_fallback"] is True
assert basis[0] == pytest.approx([1.0, 0.0, 0.0])
assert basis[1] == pytest.approx([0.0, 1.0, 0.0])
assert basis[2] == pytest.approx([0.0, 0.0, 1.0])
assert report["orthonormal"] is True
def test_orthonormal_basis_rejects_degenerate_input() -> None:
with pytest.raises(ValueError):
orthonormal_basis(np.zeros((0, 3)))
with pytest.raises(ValueError):
orthonormal_basis(np.zeros((2, 2)))
with pytest.raises(ValueError):
orthonormal_basis(np.array([[0.0, 0.0, 0.0]]))
with pytest.raises(ValueError):
# perfectly cancelling normals leave no surface normal
orthonormal_basis(
np.array([[1.0, 0.0, 0.0], [-1.0, 0.0, 0.0]])
)
def test_orthonormality_error_detects_a_bad_basis() -> None:
assert orthonormality_error(np.eye(3)) == 0.0
assert orthonormality_error(np.diag([2.0, 1.0, 1.0])) == pytest.approx(3.0)
with pytest.raises(ValueError):
orthonormality_error(np.eye(2))
# ----------------------------------------------------- falloff_weights
def test_falloff_weights_are_one_for_members_and_zero_at_radius() -> None:
coords = np.array(
[
[0.0, 0.0, 0.0], # member
[0.5, 0.0, 0.0], # member
[0.25, 0.0, 0.0], # inside (d = 0.25 from the first member)
[1.0, 0.0, 0.0], # exactly radius from the 0.5 member -> d = 0.5
[0.0, 0.9, 0.0], # outside
]
)
weights = falloff_weights(coords, [0, 1], 0.5, "smooth")
assert weights.dtype == np.float64
assert weights.shape == (5,)
assert weights[0] == 1.0
assert weights[1] == 1.0
assert weights[2] == pytest.approx(smooth_weight(0.5))
assert weights[3] == 0.0
assert weights[4] == 0.0
def test_falloff_weights_match_the_scalar_curve() -> None:
rng = np.random.default_rng(7)
coords = rng.uniform(-1.0, 1.0, size=(64, 3))
members = np.array([0, 1, 2])
for mode in FALLOFF_MODES:
weights = falloff_weights(coords, members, 0.75, mode)
for index in range(coords.shape[0]):
distance = float(
np.linalg.norm(coords[index] - coords[members], axis=1).min()
)
assert weights[index] == pytest.approx(
falloff_weight(distance / 0.75, mode), abs=1e-15
)
def test_falloff_weights_are_symmetric_in_offset_direction() -> None:
coords = np.array(
[
[0.0, 0.0, 0.0],
[0.3, 0.0, 0.0],
[-0.3, 0.0, 0.0],
[0.0, 0.3, 0.0],
[0.0, 0.0, 0.3],
[0.3 / np.sqrt(3)] * 3,
]
)
weights = falloff_weights(coords, [0], 0.5, "smooth")
for index in (2, 3, 4, 5):
assert weights[index] == pytest.approx(weights[1], abs=1e-15)
assert weights[1] == pytest.approx(smooth_weight(0.6))
def test_falloff_weights_constant_mode() -> None:
coords = np.array([[0.0, 0.0, 0.0], [0.4, 0.0, 0.0], [0.6, 0.0, 0.0]])
weights = falloff_weights(coords, [0], 0.5, "constant")
assert list(weights) == [1.0, 1.0, 0.0]
@pytest.mark.parametrize(
"kwargs",
[
{"radius_mm": 0.0},
{"radius_mm": -1.0},
{"mode": "bounce"},
],
)
def test_falloff_weights_reject_bad_settings(kwargs: dict) -> None:
coords = np.array([[0.0, 0.0, 0.0], [0.1, 0.0, 0.0]])
settings = {"radius_mm": 0.5}
settings.update(kwargs)
with pytest.raises(ValueError):
falloff_weights(coords, [0], **settings)
def test_falloff_weights_reject_bad_geometry() -> None:
with pytest.raises(ValueError):
falloff_weights(np.zeros((3, 2)), [0], 0.5)
with pytest.raises(ValueError):
falloff_weights(np.zeros((3, 3)), [], 0.5)
with pytest.raises(ValueError):
falloff_weights(np.zeros((3, 3)), [5], 0.5)
# -------------------------------------------------------- affected_bands
def test_affected_bands_omits_empty_bands() -> None:
weights = np.array([1.0, 1.0, 0.75, 0.4, 0.2, 0.07, 0.01, 0.0])
assert affected_bands(weights) == {
"1.0": 2,
"0.5-1.0": 1,
"0.3-0.5": 1,
"0.1-0.3": 1,
"0.05-0.1": 1,
"0.0-0.05": 1,
}
assert affected_bands(np.array([0.0, 0.0, 1.0])) == {"1.0": 1}
# ------------------------------------------- measured Xynova regression
def test_measured_xynova_radius_half_mm_reaches_exactly_five_vertices() -> None:
coords, members = _measured_probe_cloud()
assert coords.shape == (2508, 3)
assert len(members) == 6
weights = falloff_weights(coords, members, 0.5, "smooth")
inside = weights[(weights > 0.0) & (weights < 1.0)]
assert int((weights == 0.0).sum()) == 2497
assert int((weights >= 1.0).sum()) == 6
assert inside.size == 5, "radius 0.5 mm must touch exactly 5 non-probe vertices"
distances = _measured_nonprobe_distances()
assert float(distances[0]) == pytest.approx(0.2853262745311897, abs=1e-6)
assert float(distances[1]) == pytest.approx(0.41612439347389385, abs=1e-6)
assert float(distances[4]) == pytest.approx(0.41676858076285744, abs=1e-6)
assert float(distances[5]) == pytest.approx(0.795993, abs=1e-6)
# The gap between the 5th and 6th neighbour is what makes the count 5, not 11.
assert int((distances < 0.5).sum()) == 5
assert int((distances < 0.6).sum()) == 5
assert int((distances < 0.79).sum()) == 5
def test_measured_xynova_weight_bands_match_the_contract_report() -> None:
coords, members = _measured_probe_cloud()
weights = falloff_weights(coords, members, 0.5, "smooth")
inside = np.sort(weights[(weights > 0.0) & (weights < 1.0)])
assert affected_bands(weights) == {"1.0": 6, "0.3-0.5": 1, "0.05-0.1": 4}
assert inside[4] == pytest.approx(0.3947265, abs=1e-6) # d = 0.28533
assert inside[:4] == pytest.approx(
[0.0739043, 0.0739052, 0.0749794, 0.0749802], abs=1e-6
)
# One vertex in 0.3-0.5, four in 0.05-0.1, none anywhere else.
assert int(((inside >= 0.3) & (inside < 0.5)).sum()) == 1
assert int(((inside >= 0.05) & (inside < 0.1)).sum()) == 4
def test_measured_xynova_percentile_utility() -> None:
distances = _measured_nonprobe_distances()
assert percentile(distances, 0.0) == pytest.approx(float(distances.min()))
assert percentile(distances, 50.0) == pytest.approx(
float(np.percentile(distances, 50)), rel=1e-6
)
assert percentile(distances, 100.0) == pytest.approx(float(distances.max()))
# ----------------------------------------------------------- statistics
def test_edge_lengths_mm() -> None:
coords = np.array(
[[0.0, 0.0, 0.0], [3.0, 0.0, 0.0], [3.0, 4.0, 0.0], [0.0, 0.0, 2.0]]
)
edges = np.array([[0, 1], [1, 2], [0, 3]])
assert edge_lengths_mm(coords, edges) == pytest.approx([3.0, 4.0, 2.0])
assert edge_lengths_mm(coords, np.zeros((0, 2), dtype=np.int64)).size == 0
def test_edge_percentiles_match_the_measured_contract_row() -> None:
# Contract: mesh edges min 0.0504, p50 0.6285, p90 0.991, max 1.816 mm.
edges = np.array([0.0504, 0.628501, 0.991, 1.816])
assert percentile(edges, 0.0) == pytest.approx(0.0504)
assert percentile(edges, 100.0) == pytest.approx(1.816)
assert percentile(edges, 50.0) == pytest.approx(np.percentile(edges, 50))
def test_flatness_mm_is_peak_to_valley_along_the_normal() -> None:
offsets = np.array(_MEASURED_PROBE_OFFSETS_Z)
coords = np.stack([np.zeros_like(offsets), np.zeros_like(offsets), offsets], axis=1)
assert flatness_mm(coords, [0.0, 0.0, 0.0], [0.0, 0.0, 1.0]) == pytest.approx(
0.007611, abs=1e-6
)
assert flatness_mm(coords, [0.0, 0.0, 0.0], [0.0, 0.0, 2.0]) == pytest.approx(
0.007611, abs=1e-6
) # non-unit normals are normalised
with pytest.raises(ValueError):
flatness_mm(coords, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0])
def test_percentile_edges() -> None:
assert percentile([3.0, 1.0, 2.0], 0.0) == 1.0
assert percentile([3.0, 1.0, 2.0], 100.0) == 3.0
assert percentile([3.0, 1.0, 2.0], 50.0) == 2.0
with pytest.raises(ValueError):
percentile([], 50.0)
with pytest.raises(ValueError):
percentile([1.0], 101.0)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 815 KiB

After

Width:  |  Height:  |  Size: 233 KiB

+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.
+7
View File
@@ -32,6 +32,13 @@ viz = [
"numpy>=1.24",
"matplotlib>=3.7",
]
# The probe sensitivity bench (`orisys-design deform|render|track|sensitivity|
# optimise`). OpenCV is needed only for the DIS optical-flow estimator: the
# deformation and rendering stages run inside Blender and stay stdlib + numpy.
probe = [
"numpy>=1.24",
"opencv-python>=4.8",
]
[project.scripts]
orisys-design = "orisys_sensor_design.cli:main"
+76
View File
@@ -87,6 +87,82 @@ optics:
ior: 1.43
ior_source: assumed_silicone_literature
# Probe sensitivity bench (`orisys-design deform|render|track|sensitivity|optimise`).
# Deforms the vertices of `group` through known x/y/z displacements expressed in a
# local probe-average basis (normal force along z, shear along x/y), renders what
# `camera` sees, and measures DIS optical flow against the zero-displacement frame.
#
# Contract: docs/probe-pipeline.md. The `.blend` is never written by the pipeline.
probe:
mesh: sensing_surface
camera: cam_sensor
group: probe
uv_map: compute
texture: patch_texture_compute.png
# Proportional falloff radius for the outer rubber. Note this mesh has a median
# edge of 0.63 mm, so 0.5 mm reaches only the probe's immediate neighbours; the
# deform report prints how many vertices actually move rather than hiding it.
radius_mm: 0.5
falloff: smooth
step_mm: 0.01
max_mm: 0.10
axes: [x, y, z]
# Frames are rendered at the revision's real camera resolution. Keep this in step with
# the camera in source/geometry.blend: the pipeline overrides the scene resolution from
# here, so a blend edited to a different size is silently ignored otherwise.
#
# Single scale on purpose. The ROI radius is radius_mm x the probe-local px/mm, so at
# 125x125 it would be 1.66 px against an 8 px DIS window: the estimator measures its own
# regulariser, not the displacement. See docs/probe-findings.md section 2.
resolution: [250, 250]
process_resolution: [250, 250]
# Mirrors the SDK sensor tracker keys (orisys-sdk config/finger.json), EXCEPT
# dis_mode. The estimator window is 8 * 2**dis_mode input px (16 at dis_mode=1, 8 at 0)
# and the moving region is ~6.6 px across at 250x250, so the shipped dis_mode=1 gives
# window/moving = 2.42: DIS's smoothness pulls the estimate toward zero and the measured
# response drops ~3x (1.42 -> 4.30 px/mm at the matched cell). dis_mode=0 gives 1.21,
# the same ratio as 500x500 with dis_mode=1, which recovers to ~73%.
# See docs/probe-findings.md section 2.
# If the deployed tracker is fixed at dis_mode=1, run the bench at 500x500 instead.
tracker:
algorithm: dis
dis_preset: medium
dis_mode: 0
# Standard (not AgX) and denoise off: a filmic view transform silently distorts
# the marker values the tracker is supposed to measure.
render:
engine: CYCLES
samples: 16
denoise: false
view_transform: Standard
interpolate: Closest
seed: 0
# Noise calibration and the injected noise model.
#
# The estimator's `floor_px` is measured as reference vs a re-render at seed+1, which
# is Cycles antialiasing jitter, NOT sensor noise: on this data it differs by ~5 grey
# levels RMS on half the pixels and scales with edge contrast. Ranking patterns by
# gain/that-floor is unsound - the ordering reverses under additive noise, and it
# understates the real idle floor measured from the footage below, coarse patterns
# most. So the render-seed floor is kept only as a reproducibility diagnostic and the
# score is anchored to the injected model plus this measured calibration.
noise:
# Real idle (no-contact) footage, relative to the repo root. Measured: floor
# ~0.23 px at 1280x720 (short baseline == long baseline, so it is white noise and
# the clip is genuinely idle), dominant marker period ~25 px, i.e. ~0.0093 floor
# per period. That is 2-6x the simulated render-seed floor per cell.
idle_footage: ../data/dd02-0703/190.mp4
roi_fraction: 0.18
max_frames: 120
# Additive white Gaussian in grey levels. Defaults are deliberately small: each
# sigma costs a full DIS pass, and the process scale is a resample of the render
# one, so only the render scale is evaluated unless asked.
sigmas: [4.0, 16.0]
draws: 1
seed: 0
models: [render_seed, injected]
scales: [render]
collections:
frames: FRAMES
export_compute: EXPORT_COMPUTE
Binary file not shown.
+30
View File
@@ -16,6 +16,36 @@ artifacts:
runtime_owned_by_sdk:
- unroll_map_uv_s0.4.npz
# Parameters of the unroll/valid-mask domain. Recorded here because the mask decides which
# part of the sensing mesh the application treats as *sensing* (everything else renders
# grey), and because regenerating it wrongly still produces a plausible-looking map:
# * source_width/height are NOT recorded — they are derived from the export's own
# camera_intrinsics.npz (native size x source_scale). Inheriting another camera's
# source size produced 0.8 % island coverage instead of 40.8 %.
# * source_K must be scaled by source_scale (orisys.unroll.geometry.scale_camera_geometry).
# Passing the native K produced 3.5 % instead of 40.8 %.
# Rebuild with tools/build_uv_map.py --profile <this file>; it writes into output_dir first
# and is promoted to the SDK asset dir only after tools/scene_assets/compare_uv_map.py agrees.
uv_map:
out_width: 280 # = camera.resolution_stitch[0]; the field domain the app sees
out_height: 300 # = camera.resolution_stitch[1]
flip_v: true
# source_scale: the live frame is resized by this before undistort/remap, so it sets both the
# per-frame pixel work and the resolution the tracker/flow measures at. The floor that avoids
# upsampling the output grid is derived, not guessed:
# s >= max(out_width / native_width, out_height / native_height)
# * 1280x720 camera -> max(280/1280, 300/720) = 0.417 (source 533x300)
# * 1920x1080 camera -> max(280/1920, 300/1080) = 0.278 (source 533x300 — the same grid,
# because the rule follows the *output* requirement, not the camera)
# 0.4 is pinned because it is the value the running system and the existing recordings were
# measured at; at a 1280x720 camera it yields a 512x288 source, i.e. 4 % coarser than the
# 300-row output (mild upsampling). Changing it changes the flow magnitudes the app's
# thresholds were tuned against AND the map's field_domain_id, so it must move together with
# sensor.json's camera.source_scale and a recompiled scene package.
source_scale: 0.4
edge_band: 0.12
edge_expand: 1.0 # <= 1.0 means "resolve from the measured UV distortion"
validation:
# orisys-app validate_scene.py --legacy-dir <output_dir> --map <sdk unroll map>
require_sensing_surface_only: true
Binary file not shown.
File diff suppressed because it is too large Load Diff
+668
View File
@@ -0,0 +1,668 @@
"""Settings comparison sweep: run one marker pattern under several estimator settings.
The user-facing question this answers is *"is the winner real, or an artifact of the
settings I happened to use?"* A single grid at a single ``dis_mode`` cannot tell you
that, so this module runs a bounded matrix and reports the score for every cell of it,
plus the figures in :mod:`orisys_sensor_design.probe_plot`.
What each setting actually invalidates, which is what makes the sweep affordable:
====================== ===========================================================
setting invalidates
====================== ===========================================================
``nx`` the marker **texture** (regenerated once per grid)
``radius_mm`` the **deformation and frames** (``deform`` + ``render``)
``dis_mode`` only **track + sensitivity** -- the frames are reused
====================== ===========================================================
The ``dis_mode`` case is the important one. Changing it does not change the rendered
frames at all, so the variant is run in a sibling directory holding a rewritten
``render_manifest.json`` whose ``png`` entries are made **absolute** and therefore
point at the original frames. ``manifest_frame_entries`` resolves an absolute ``png``
to itself, so the estimator reads the very same pixels. That makes the decisive
experiment (does the optimum move when the effective window halves?) cost seconds
instead of a re-render.
``stage_runner`` is injected so the module is testable without Blender: it is a
callable ``(kind, **kwargs) -> int`` returning an exit code, where ``kind`` is one of
``"texture"``, ``"deform"``, ``"render"``, ``"track"``, ``"sensitivity"``. ``cli.py``
supplies one backed by the real stage helpers.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Mapping, Sequence
SCHEMA = "orisys-sensor-design/settings-comparison-v1"
FILENAME = "settings_comparison.json"
COMPARISON_DIRNAME = "comparison"
DEFAULT_DIS_MODES = (1, 0)
DEFAULT_RADII = (0.5, 1.5)
DEFAULT_GRID = (31, 56, 80)
DEFAULT_SEEDS = (7,)
#: Refuse to start a matrix larger than this unless the caller raises the bound.
DEFAULT_MAX_RUNS = 24
#: The two optima the render scale measured, for the tuning figure's markers.
NOMINAL_PATCH_PX = 8.0
StageRunner = Callable[..., int]
class CompareError(Exception):
"""Config or orchestration problem (exit code 2)."""
@dataclass(frozen=True)
class SweepEntry:
"""One cell of the matrix: a pattern, a radius and an estimator setting."""
nx: int
ny: int
seed: int
radius_mm: float
dis_mode: int
@property
def frame_key(self) -> str:
return f"nx{self.nx:03d}_r{self.radius_mm:g}"
@property
def id(self) -> str:
return f"nx{self.nx}@r{self.radius_mm:g}@dm{self.dis_mode}"
@property
def dirname(self) -> str:
"""Directory for this entry; only ``dis_mode`` differs within a frame set.
The ``dis_mode`` suffix is always present, including for ``dis_mode=0``: an
earlier truthiness test (`if self.dis_mode:`) silently gave ``0`` the
suffix-less name and made it collide with the frame-set directory instead of
being a distinct variant.
"""
return f"{self.frame_key}_dm{self.dis_mode}"
@dataclass
class SweepOutcome:
entry: SweepEntry
directory: Path
score: float | None = None
verdict: str | None = None
scale_scores: dict[str, float] = field(default_factory=dict)
scale_verdicts: dict[str, str] = field(default_factory=dict)
cell_px: float | None = None
effective_patch_px: float | None = None
error: str | None = None
def to_json(self) -> dict[str, Any]:
return {
"id": self.entry.id,
"nx": self.entry.nx,
"ny": self.entry.ny,
"seed": self.entry.seed,
"radius_mm": self.entry.radius_mm,
"dis_mode": self.entry.dis_mode,
"directory": str(self.directory),
"score": self.score,
"verdict": self.verdict,
"scale_scores": dict(self.scale_scores),
"scale_verdicts": dict(self.scale_verdicts),
"cell_px": self.cell_px,
"effective_patch_px": self.effective_patch_px,
"error": self.error,
}
def enumerate_sweep(
*,
grid: Sequence[int],
seeds: Sequence[int],
dis_modes: Sequence[int],
radii: Sequence[float],
) -> list[SweepEntry]:
"""Deterministically ordered matrix, sorted so two runs agree byte for byte."""
entries: list[SweepEntry] = []
for nx in sorted({int(value) for value in grid}):
for seed in sorted({int(value) for value in seeds}):
for radius in sorted({float(value) for value in radii}):
for dis_mode in sorted({int(value) for value in dis_modes}, reverse=True):
if nx < 1:
raise CompareError(f"grid values must be >= 1, got {nx}")
if radius <= 0.0:
raise CompareError(f"radius_mm values must be > 0, got {radius}")
if dis_mode < 0:
raise CompareError(f"dis_mode values must be >= 0, got {dis_mode}")
entries.append(
SweepEntry(nx=nx, ny=nx, seed=seed, radius_mm=radius, dis_mode=dis_mode)
)
return entries
def _load_json(path: Path) -> dict[str, Any]:
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return data if isinstance(data, dict) else {}
def _absolutise_manifest(manifest: Mapping[str, Any], run_dir: Path) -> dict[str, Any]:
"""Return a manifest copy whose frame *and null* PNG paths are absolute.
``manifest_frame_entries`` resolves an absolute ``png`` to itself (pathlib ignores
the left side when the right side is absolute), so a variant directory holding only
this manifest reads the original rendered frames with no re-render.
Two details that each cost a debugging round:
* ``png`` is stored **relative to the run directory** (``frames/<name>.png``), not to
its ``frames/`` subdirectory, so the manifest's own directory is the base. Passing
the subdirectory produced ``frames/frames/<name>.png`` and every variant run failed
with a missing-artifact exit 3.
* the ``null`` block is a separate path that the tracker loads for the zero-input
floor, so it has to be rewritten as well.
"""
def absolute(value: Any) -> Any:
if not isinstance(value, str) or not value:
return value
candidate = Path(value)
if candidate.is_absolute():
return str(candidate)
return str((run_dir / candidate).resolve())
clone = dict(manifest)
frames = []
for entry in manifest.get("frames") or []:
item = dict(entry)
if "png" in item:
item["png"] = absolute(item.get("png"))
frames.append(item)
clone["frames"] = frames
null = manifest.get("null")
if isinstance(null, Mapping):
null_clone = dict(null)
if "png" in null_clone:
null_clone["png"] = absolute(null_clone.get("png"))
clone["null"] = null_clone
return clone
def _collect(outcome: SweepOutcome, report: Mapping[str, Any]) -> None:
"""Copy the score fields out of a sensitivity report into the outcome."""
overall = report.get("overall") or {}
outcome.score = overall.get("score")
outcome.verdict = overall.get("verdict")
for scale in report.get("scales") or []:
name = scale.get("name")
if name is None:
continue
outcome.scale_scores[str(name)] = scale.get("score")
outcome.scale_verdicts[str(name)] = scale.get("verdict")
if scale.get("cell_px") is not None:
outcome.cell_px = scale.get("cell_px")
if scale.get("effective_patch_px") is not None:
outcome.effective_patch_px = scale.get("effective_patch_px")
def run_compare(
paths: Any,
settings: Any,
*,
stage_runner: StageRunner,
grid: Sequence[int] = DEFAULT_GRID,
seeds: Sequence[int] = DEFAULT_SEEDS,
dis_modes: Sequence[int] = DEFAULT_DIS_MODES,
radii: Sequence[float] = DEFAULT_RADII,
resolution: Any = None,
max_runs: int = DEFAULT_MAX_RUNS,
dry_run: bool = False,
output_dir: Path | None = None,
progress: Callable[[str], None] = print,
) -> tuple[dict[str, Any], int]:
"""Run the settings matrix and return ``(report, exit_code)``."""
import dataclasses
entries = enumerate_sweep(grid=grid, seeds=seeds, dis_modes=dis_modes, radii=radii)
if len(entries) > int(max_runs):
raise CompareError(
f"the requested matrix has {len(entries)} runs but --max-runs is {int(max_runs)}; "
"narrow --grid/--dis-modes/--radius-mm or raise --max-runs"
)
base = Path(output_dir) if output_dir is not None else _default_output_dir(paths)
base = base / COMPARISON_DIRNAME if base.name != COMPARISON_DIRNAME else base
width = None
if resolution:
width = int(resolution[0])
report: dict[str, Any] = {
"schema": SCHEMA,
"revision": str(getattr(paths, "root", "")),
"search": {
"grid": sorted({int(v) for v in grid}),
"seeds": sorted({int(v) for v in seeds}),
"dis_modes": sorted({int(v) for v in dis_modes}, reverse=True),
"radius_mm": sorted({float(v) for v in radii}),
"runs": len(entries),
"resolution": ([width, int(resolution[1])] if resolution else None),
"dry_run": bool(dry_run),
},
"runs": [],
"ranked": [],
"best_per_dis_mode": {},
"best_per_radius": {},
"notes": [],
}
if dry_run:
report["runs"] = [entry.id for entry in entries]
report["notes"].append(
"dry-run: enumerated the matrix and ran nothing; no score is reported."
)
return report, 0
outcomes: list[SweepOutcome] = []
textures: dict[int, Path] = {}
frame_dirs: dict[str, Path] = {}
for entry in entries:
directory = base / entry.dirname
directory.mkdir(parents=True, exist_ok=True)
outcome = SweepOutcome(entry=entry, directory=directory)
try:
# 1) The marker artwork for this grid (cached: nx is the only input).
if entry.nx not in textures:
texture_dir = base / f"tex_nx{entry.nx:03d}"
code = stage_runner(
"texture", settings=settings, output_dir=texture_dir, nx=entry.nx,
ny=entry.ny, seed=entry.seed,
)
if code != 0:
raise CompareError(f"texture stage failed (exit {code})")
textures[entry.nx] = texture_dir
# The probe bench reads one UV map; the texture stage writes that stem
# into the cached directory, so resolve it there.
texture_png = textures[entry.nx] / Path(str(settings.texture)).name
probe = _probe_for(settings, entry, resolution, texture_png, dataclasses)
# 2) The deformation and frames for this (nx, radius) (cached).
if entry.frame_key not in frame_dirs:
code = stage_runner("deform", settings=probe, output_dir=directory)
if code != 0:
raise CompareError(f"deform stage failed (exit {code})")
code = stage_runner("render", settings=probe, output_dir=directory)
if code != 0:
raise CompareError(f"render stage failed (exit {code})")
frame_dirs[entry.frame_key] = directory
elif directory != frame_dirs[entry.frame_key]:
# Same frames, different estimator setting: point at the originals.
# manifest_frame_entries resolves an absolute `png` to itself, so the
# estimator reads exactly the same pixels with no re-render.
source = _load_json(frame_dirs[entry.frame_key] / "render_manifest.json")
if not source:
raise CompareError(
f"no render_manifest.json in {frame_dirs[entry.frame_key]}"
)
rewritten = _absolutise_manifest(
source, frame_dirs[entry.frame_key]
)
(directory / "render_manifest.json").write_text(
json.dumps(rewritten, indent=2) + "\n", encoding="utf-8"
)
# 3) The estimator and the score (per dis_mode -- the cheap axis).
code = stage_runner("track", settings=probe, output_dir=directory)
if code != 0:
raise CompareError(f"track stage failed (exit {code})")
code = stage_runner("sensitivity", settings=probe, output_dir=directory)
score_report = _load_json(directory / "sensitivity_report.json")
if code != 0 and not score_report:
raise CompareError(f"sensitivity stage failed (exit {code})")
_collect(outcome, score_report)
progress(
f"{outcome.entry.id}: score="
+ (f"{outcome.score:.4f}" if outcome.score is not None else "None")
+ f" verdict={outcome.verdict} scales={outcome.scale_scores}"
)
except Exception as exc: # one bad cell must not lose the rest
outcome.error = f"{type(exc).__name__}: {exc}"
progress(f"{outcome.entry.id}: FAILED: {outcome.error}")
outcomes.append(outcome)
report["runs"] = [outcome.to_json() for outcome in outcomes]
scored = [outcome for outcome in outcomes if outcome.score is not None]
ranked = sorted(scored, key=lambda o: (-float(o.score), o.entry.nx, o.entry.dis_mode))
report["ranked"] = [outcome.entry.id for outcome in ranked]
for key_name, key_of in (
("best_per_dis_mode", lambda o: str(o.entry.dis_mode)),
("best_per_radius", lambda o: f"{o.entry.radius_mm:g}"),
):
best: dict[str, Any] = {}
for outcome in ranked:
best.setdefault(
key_of(outcome),
{"id": outcome.entry.id, "score": outcome.score, "verdict": outcome.verdict},
)
report[key_name] = best
report["notes"].append(
"Each cell of the matrix used the same estimator and camera; only the listed "
"settings vary. Scores are comparable within a dis_mode/radius block, and the "
"per-setting bests are reported so an optimum that moves with a setting is "
"visible rather than averaged away."
)
if len({outcome.entry.radius_mm for outcome in outcomes}) > 1:
report["notes"].append(
"radius_mm changes the measurement geometry as well as the deformation: the "
"probe ROI scales with it, and the measured gain is confounded with ROI size, "
"so compare radii for ranking stability rather than as absolute sensitivity."
)
if len({outcome.entry.dis_mode for outcome in outcomes}) > 1:
report["notes"].append(
"dis_mode is OpenCV's finest pyramid level, so the effective DIS window is "
"patch_px * 2**dis_mode. If the winning nx moves when dis_mode changes, the "
"optimum is governed by that window rather than by the pattern alone."
)
failures = [outcome for outcome in outcomes if outcome.error]
if failures:
report["notes"].append(
f"{len(failures)} run(s) failed and are recorded with an error: "
+ ", ".join(outcome.entry.id for outcome in failures)
)
(base / FILENAME).write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
return report, (1 if failures and not scored else 0)
def _default_output_dir(paths: Any) -> Path:
for attribute in ("root", "revision_dir"):
value = getattr(paths, attribute, None)
if value:
return Path(value)
raise CompareError("cannot resolve an output directory from the revision paths")
def _probe_for(
settings: Any, entry: SweepEntry, resolution: Any, texture_png: Path, dataclasses_module
) -> Any:
"""Per-entry probe settings: this pattern, this radius, this tracker setting.
``pattern`` must follow the candidate grid, not the revision default. Leaving it
alone made every run report ``pattern.nx`` from ``texture_patch`` (80), so
``cell_px`` was 6.25 for *every* candidate and the tuning curve was meaningless.
"""
changes: dict[str, Any] = {
"radius_mm": float(entry.radius_mm),
"texture": str(texture_png),
}
if resolution:
changes["resolution"] = [int(resolution[0]), int(resolution[1])]
changes["tracker"] = dict(getattr(settings, "tracker", {}) or {})
changes["tracker"]["dis_mode"] = int(entry.dis_mode)
pattern = getattr(settings, "pattern", None)
if pattern is not None:
try:
changes["pattern"] = dataclasses_module.replace(
pattern, nx=int(entry.nx), ny=int(entry.ny), seed=int(entry.seed)
)
except Exception: # a non-dataclass pattern must not break the sweep
pass
return dataclasses_module.replace(settings, **changes)
def select_runs(
scored: Sequence[Mapping[str, Any]],
*,
limit: int,
reference_radius: float | None = None,
) -> list[Mapping[str, Any]]:
"""Choose the runs a figure shows, deterministically and without duplicates.
The previous rule took best **and** worst of ``keys[:1]``, which sorted to
``dis_mode=0`` -- so dm0 was over-represented, one run appeared twice under
different radii, and the shipped dm1 setting was squeezed out by the 4-curve cap.
This rule: the global best and worst, then the best at the reference radius for
every ``dis_mode`` (so the shipped setting is always represented), then fill by
rank. Deduplicated by run id throughout.
"""
ranked = sorted(
scored, key=lambda run: -float(run.get("score") or 0.0)
)
if not ranked:
return []
chosen: list[Mapping[str, Any]] = []
seen: set[str] = set()
def add(run: Mapping[str, Any] | None) -> None:
if run is None or len(chosen) >= limit:
return
key = str(run.get("id"))
if key in seen:
return
seen.add(key)
chosen.append(run)
add(ranked[0])
add(ranked[-1])
radius = reference_radius
if radius is None:
radii = sorted({float(run.get("radius_mm") or 0.0) for run in ranked})
radius = radii[0] if radii else None
for dis_mode in sorted({int(run.get("dis_mode") or 0) for run in ranked}, reverse=True):
block = [
run for run in ranked
if int(run.get("dis_mode") or 0) == dis_mode
and float(run.get("radius_mm") or 0.0) == float(radius)
]
add(block[0] if block else None)
for run in ranked:
add(run)
return chosen
def build_figures(
report: Mapping[str, Any],
base_dir: Path,
*,
out_dir: Path | None = None,
axis: str = "x",
step_mm: float = 0.10,
scale: str = "render",
max_flow_panels: int = 4,
max_response_curves: int = 6,
dpi: int = 150,
progress: Callable[[str], None] = print,
) -> dict[str, Path]:
"""Write the tuning, flow-comparison and response figures for a sweep report.
Imported lazily so the sweep itself never requires matplotlib. A plotting failure
is reported but never discards the JSON report, which is already on disk.
"""
from orisys_sensor_design import probe_plot
base_dir = Path(base_dir)
out_dir = Path(out_dir) if out_dir is not None else base_dir
out_dir.mkdir(parents=True, exist_ok=True)
runs = [run for run in report.get("runs") or [] if isinstance(run, Mapping)]
scored = [run for run in runs if run.get("score") is not None]
figures: dict[str, Path] = {}
if not scored:
progress("no scored runs; nothing to plot")
return figures
# --- tuning: one series per (dis_mode, radius, scale) ----------------------
series: list[dict[str, Any]] = []
keys = sorted(
{
(int(run.get("dis_mode") or 0), float(run.get("radius_mm") or 0.0))
for run in scored
}
)
for dis_mode, radius in keys:
rows = [
run for run in scored
if int(run.get("dis_mode") or 0) == dis_mode
and float(run.get("radius_mm") or 0.0) == radius
]
for scale_name in sorted({s for run in rows for s in (run.get("scale_scores") or {})}):
points = [
{
"nx": run.get("nx"),
# The cell is a property of the pattern at the *render* width; the
# same pattern spans proportionally fewer pixels at a smaller
# estimator scale, which is why the process series falls off.
"cell_px": run.get("cell_px"),
"effective_patch_px": run.get("effective_patch_px"),
"score": (run.get("scale_scores") or {}).get(scale_name),
}
for run in rows
if (run.get("scale_scores") or {}).get(scale_name) is not None
and run.get("cell_px")
]
if points:
series.append(
{
"label": f"{scale_name} | dis_mode={dis_mode} | r={radius:g} mm",
"points": points,
}
)
try:
figures["tuning"] = probe_plot.render_tuning(
series=series,
out_path=out_dir / "settings_tuning.png",
title="Sensitivity vs camera-space cell, by setting",
patch_px=NOMINAL_PATCH_PX,
dpi=dpi,
)
except Exception as exc:
progress(f"could not render the tuning figure: {exc}")
# --- the runs every figure shows, chosen by one documented rule ---------------
reference_radius = min(
(float(run.get("radius_mm") or 0.0) for run in scored), default=None
)
flow_runs = select_runs(scored, limit=max_flow_panels, reference_radius=reference_radius)
# The response figure gets the whole reference-radius block instead of best/worst:
# selection by score would drop the pattern that clean gain says is best, which is
# exactly the comparison this figure exists to settle.
block = sorted(
(
run for run in scored
if float(run.get("radius_mm") or 0.0) == float(reference_radius)
),
key=lambda run: (-int(run.get("dis_mode") or 0), int(run.get("nx") or 0)),
)
response_runs = block[:max_response_curves] or select_runs(
scored, limit=max_response_curves, reference_radius=reference_radius
)
def run_label(run: Mapping[str, Any]) -> str:
"""Unique per run: nx, radius AND dis_mode all appear, unlike the old label."""
return (
f"nx={run.get('nx')} r={float(run.get('radius_mm') or 0.0):g} mm "
f"dm={run.get('dis_mode')}"
)
panels = []
for run in flow_runs:
directory = Path(str(run.get("directory")))
panel = probe_plot.panel_from_run(
label=run_label(run),
output_dir=directory,
axis=axis,
step_mm=step_mm,
scale=scale,
)
panel["label"] = f"{run_label(run)} -- overall {float(run.get('score') or 0.0):.2f}"
panels.append(panel)
try:
figures["flow"] = probe_plot.render_flow_comparison(
panels=panels,
out_path=out_dir / "settings_flow.png",
title=(
f"Measured optical flow, {scale} scale, axis {axis} at {step_mm:g} mm "
"-- comparing settings"
),
dpi=dpi,
)
except Exception as exc:
progress(f"could not render the flow-comparison figure: {exc}")
# --- response curves for the response-selected runs --------------------------
curves = []
for run in response_runs:
report_path = Path(str(run.get("directory"))) / "sensitivity_report.json"
block = _load_json(report_path)
block_scale = next(
(s for s in block.get("scales") or [] if s.get("name") == scale), None
)
axis_block = next(
(a for a in (block_scale or {}).get("axes") or [] if a.get("axis") == axis), None
)
if axis_block:
summary = axis_block.get("summary") or {}
retention = []
for entries in ((block_scale or {}).get("noise_models") or {}).get("axes", {}).get(axis, []) or []:
if isinstance(entries, Mapping) and entries.get("response_retention") is not None:
retention.append(
{
"sigma": entries.get("sigma"),
"response_retention": entries.get("response_retention"),
}
)
curves.append(
{
# Unique: nx, radius and dis_mode all appear. The old label was
# `nx=.. dm..` and omitted the radius, so `nx80@r0.5@dm0` and
# `nx80@r1.5@dm0` produced two identical legend entries.
"label": run_label(run),
"points": axis_block.get("points") or [],
"verdict": summary.get("verdict"),
"score": summary.get("score"),
# This curve's own floor; a single global band was the maximum
# across runs and belonged to no plotted curve.
"floor_px": summary.get("floor_px"),
"retention": retention,
}
)
try:
figures["response"] = probe_plot.render_response_curves(
curves=curves,
out_path=out_dir / "settings_response.png",
axis=axis,
title="Response vs applied displacement, by setting",
dpi=dpi,
)
except Exception as exc:
progress(f"could not render the response figure: {exc}")
report["figures"] = {
"flow_ids": [str(run.get("id")) for run in flow_runs],
"response_ids": [str(run.get("id")) for run in response_runs],
}
return figures
__all__ = [
"COMPARISON_DIRNAME",
"CompareError",
"DEFAULT_DIS_MODES",
"DEFAULT_GRID",
"DEFAULT_MAX_RUNS",
"DEFAULT_RADII",
"DEFAULT_SEEDS",
"FILENAME",
"SCHEMA",
"SweepEntry",
"SweepOutcome",
"build_figures",
"enumerate_sweep",
"run_compare",
"select_runs",
]
+770
View File
@@ -0,0 +1,770 @@
"""Headless probe-deformation settings, frame plan, output paths, and job I/O.
The host CLI (``orisys-design deform``) reads the ``probe`` mapping in
``design.yaml``, merges explicit CLI overrides, writes a stdlib-JSON job file,
and invokes ``blender/scripts/probe_deform.py`` headlessly. Blender never reads
YAML; every path handed to it is absolute.
This module is the **single ordering authority** for the sweep:
``frame_plan()`` produces the reference frame at index 0 followed by axis-major,
step-minor ascending positive steps. Every job carries that resolved list, so
render and analysis cannot disagree about frame order.
The deformation itself lives in Blender: ``probe_deform.py`` writes
``probe_frames.npz`` (the contract between the deformation and rendering
workstreams) and ``deform_report.json``. It never writes the ``.blend``.
Proportional falloff is computed explicitly, in millimetres, by the bpy-free
``blender/scripts/probe_falloff.py``: ``bpy.ops.transform.translate`` with
proportional editing is viewport-modal and its radius is screen-space, which
does not exist under ``blender -b``. Weights are derived from the probe
members' ORIGINAL positions, so one weight vector is shared by every frame.
Contract: ``docs/probe-pipeline.md``.
"""
from __future__ import annotations
import json
from collections.abc import Mapping
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Sequence
from orisys_sensor_design.manifest import RevisionPaths, export_object_names, load_yaml
from orisys_sensor_design.texture import (
TextureConfigError,
merge_texture_patch,
texture_revision_rel,
)
DEFORM_JOB_SCHEMA = "orisys-sensor-design/probe-deform-job-v1"
DEFORM_JOB_FILENAME = "_deform_job.json"
FRAMES_NPZ_FILENAME = "probe_frames.npz"
DEFORM_REPORT_FILENAME = "deform_report.json"
PROBE_RELATIVE_ROOT = Path("build") / "probe"
#: Sweep axes, and the basis rows they index (rows are x, y, z in that order).
AXIS_NAMES = ("x", "y", "z")
AXIS_ROWS = {"x": 0, "y": 1, "z": 2}
FALLOFF_MODES = ("smooth", "linear", "sharp", "constant")
#: Built-in fallbacks. sensors/<sensor>/<revision>/design.yaml is the source of
#: truth for a revision; these only apply when a key is absent there.
DEFAULT_PROBE: dict[str, Any] = {
"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,
},
# Real-noise calibration and the injected noise model. The render-seed floor is a
# reproducibility diagnostic only; these keys anchor the score to something real.
"noise": {
# Idle (no-contact) footage, relative to the repo root or absolute. Absent =
# skip calibration and warn, never fail.
"idle_footage": None,
"roi_fraction": 0.18,
"max_frames": 120,
# Injected additive white Gaussian noise, in grey levels. Kept small by
# default because each sigma costs a full DIS pass per scale.
"sigmas": [4.0, 16.0],
"draws": 1,
"seed": 0,
"models": ["render_seed", "injected"],
# The process scale is a resample of the render one, so evaluating only the
# render scale by default keeps the sweep affordable.
"scales": ["render"],
},
}
_PROBE_KEYS = frozenset(DEFAULT_PROBE)
_TRACKER_KEYS = frozenset(DEFAULT_PROBE["tracker"])
_RENDER_KEYS = frozenset(DEFAULT_PROBE["render"])
_NOISE_KEYS = frozenset(DEFAULT_PROBE["noise"])
_NESTED_KEYS = {
"tracker": _TRACKER_KEYS,
"render": _RENDER_KEYS,
"noise": _NOISE_KEYS,
}
_MAX_PROBE_EDGE = 32768
class ProbeConfigError(ValueError):
"""Raised when probe metadata or CLI overrides are invalid.
The message may span several lines; each line is one problem.
"""
@dataclass(frozen=True)
class ProbeFrame:
"""One frame of the deformation/rendering sweep."""
index: int
name: str
axis: str | None
step_mm: float
def to_job_entry(self) -> dict[str, Any]:
"""Return the job entry shared by the deform and render jobs."""
return {
"index": self.index,
"name": self.name,
"axis": self.axis,
"step_mm": self.step_mm,
}
@dataclass(frozen=True)
class PatternInfo(Mapping[str, Any]):
"""The marker pattern grid that was in play for a probe run.
Resolved from the same ``texture_patch`` block ``texture.py`` reads (the
defaults are reused, never duplicated). ``sensitivity_report.json`` needs it
because the optimizer searches over ``nx``/``ny``/``seed``: a score without
the grid that produced it cannot be compared with any other score.
It is a frozen dataclass (attribute access, equality) *and* a
``collections.abc.Mapping``: the sensitivity stage duck-types
``settings.pattern`` as a mapping and calls ``.get(...)`` on it, so both
access styles are required for that fallback path to work.
"""
nx: int
ny: int
seed: int | None
vector_width_mm: float
vector_height_mm: float
@property
def cell_mm(self) -> tuple[float, float]:
"""Pattern cell size on the vector page, in millimetres."""
return (self.vector_width_mm / self.nx, self.vector_height_mm / self.ny)
def to_job_entry(self) -> dict[str, Any]:
"""Return the JSON-serialisable pattern block recorded in job settings."""
return dict(self)
def _entries(self) -> dict[str, Any]:
return {
"nx": self.nx,
"ny": self.ny,
"seed": self.seed,
"vector_width_mm": self.vector_width_mm,
"vector_height_mm": self.vector_height_mm,
"cell_mm": [self.cell_mm[0], self.cell_mm[1]],
}
def __getitem__(self, key: str) -> Any:
return self._entries()[key]
def __iter__(self):
return iter(self._entries())
def __len__(self) -> int:
return len(self._entries())
@dataclass(frozen=True)
class ProbeSettings:
"""Resolved probe settings for one headless run.
``tracker`` and ``render`` are plain mappings matching the contract's
``design.yaml`` ``probe.tracker`` / ``probe.render`` blocks; the render and
analysis stages read them directly off this dataclass. ``pattern`` records
the marker grid resolved from ``texture_patch``.
"""
mesh: str
camera: str
group: str
uv_map: str
texture: str
radius_mm: float
falloff: str
step_mm: float
max_mm: float
axes: tuple[str, ...]
resolution: tuple[int, int]
process_resolution: tuple[int, int]
tracker: dict[str, Any]
render: dict[str, Any]
pattern: PatternInfo
noise: dict[str, Any] = field(default_factory=dict)
def to_job_settings(self) -> dict[str, Any]:
"""Return the deform job's ``settings`` block (contract `_deform_job.json`)."""
return {
"radius_mm": self.radius_mm,
"falloff": self.falloff,
"step_mm": self.step_mm,
"max_mm": self.max_mm,
"axes": list(self.axes),
"pattern": self.pattern.to_job_entry(),
}
def to_render_settings(self, texture_path: Path | str) -> dict[str, Any]:
"""Return the render job's ``settings`` block (contract `_render_job.json`)."""
return {
"uv_map": self.uv_map,
"texture": str(Path(texture_path).resolve()),
"width": self.resolution[0],
"height": self.resolution[1],
"engine": self.render["engine"],
"samples": self.render["samples"],
"denoise": self.render["denoise"],
"view_transform": self.render["view_transform"],
"interpolate": self.render["interpolate"],
"seed": self.render["seed"],
}
def to_track_settings(self) -> dict[str, Any]:
"""Return the track job's ``settings`` block (contract `_track_job.json`)."""
return {
"axes": list(self.axes),
"step_mm": self.step_mm,
"max_mm": self.max_mm,
"radius_mm": self.radius_mm,
"process_resolution": list(self.process_resolution),
"tracker": dict(self.tracker),
# Additive: the render-seed floor is a diagnostic, and this block is what
# lets the estimator measure signal and noise under the same model.
"noise": dict(self.noise),
}
def _is_int(value: Any) -> bool:
return isinstance(value, int) and not isinstance(value, bool)
def _is_number(value: Any) -> bool:
return isinstance(value, (int, float)) and not isinstance(value, bool)
def load_probe_block(paths: RevisionPaths) -> dict[str, Any]:
"""Return the raw ``probe`` mapping from design.yaml ({} when absent)."""
design = load_yaml(paths.design_yaml)
raw = design.get("probe")
if raw is None:
return {}
if not isinstance(raw, dict):
raise ProbeConfigError(
f"{paths.design_yaml}: 'probe' must be a mapping, got {type(raw).__name__}"
)
return raw
def _check_known_keys(block: dict[str, Any], where: str, errors: list[str]) -> None:
unknown = sorted(set(block) - _PROBE_KEYS)
if unknown:
allowed = ", ".join(sorted(_PROBE_KEYS))
errors.append(f"{where}: unknown key(s) {', '.join(unknown)} (allowed: {allowed})")
def merge_probe(
paths: RevisionPaths | None,
overrides: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Merge built-in defaults ← design.yaml ``probe`` ← CLI overrides.
Overrides with a value of ``None`` are ignored so an unset CLI flag never
erases a configured value. Unknown keys at the top level or inside
``tracker``/``render`` are an error.
"""
merged: dict[str, Any] = {
key: (dict(value) if isinstance(value, dict) else value)
for key, value in DEFAULT_PROBE.items()
}
errors: list[str] = []
blocks: list[tuple[dict[str, Any], str]] = []
if paths is not None:
blocks.append((load_probe_block(paths), f"{paths.design_yaml} probe"))
blocks.append(
(
{key: value for key, value in (overrides or {}).items() if value is not None},
"CLI overrides",
)
)
for block, where in blocks:
if not block:
continue
_check_known_keys(block, where, errors)
for key, value in block.items():
if key not in _PROBE_KEYS:
continue
if key in _NESTED_KEYS:
if not isinstance(value, dict):
errors.append(f"{where}.{key}: must be a mapping")
continue
allowed_nested = _NESTED_KEYS[key]
unknown_nested = sorted(set(value) - allowed_nested)
if unknown_nested:
errors.append(
f"{where}.{key}: unknown key(s) {', '.join(unknown_nested)} "
f"(allowed: {', '.join(sorted(allowed_nested))})"
)
for nested_key, nested_value in value.items():
if nested_key in allowed_nested:
merged[key][nested_key] = nested_value
continue
merged[key] = value
if errors:
raise ProbeConfigError("\n".join(errors))
return merged
def _bare_png_name_problems(value: Any, label: str) -> list[str]:
"""Return messages for a value that must be a bare ``.png`` file name."""
if not isinstance(value, str) or not value.strip():
return [f"{label}: must be a non-empty file name"]
if (
"/" in value
or "\\" in value
or Path(value).name != value
or value in {".", ".."}
):
return [f"{label}: must be a bare file name without directories ({value!r})"]
if Path(value).suffix.lower() != ".png":
return [f"{label}: must end with .png ({value!r})"]
return []
def _resolution_problems(value: Any, label: str) -> list[str]:
if not isinstance(value, (list, tuple)) or len(value) != 2:
return [f"{label}: must be a [width, height] pair (got {value!r})"]
problems: list[str] = []
for axis, raw in zip(("width", "height"), value):
if not _is_int(raw) or raw <= 0:
problems.append(
f"{label}.{axis}: must be a positive integer (got {raw!r})"
)
elif raw > _MAX_PROBE_EDGE:
problems.append(
f"{label}.{axis}: must be <= {_MAX_PROBE_EDGE} (got {raw})"
)
return problems
def _object_name_problems(value: Any, label: str) -> list[str]:
if not isinstance(value, str) or not value.strip():
return [f"{label}: must be a non-empty object name"]
return []
def _axes_problems(value: Any) -> list[str]:
if not isinstance(value, (list, tuple)) or not value:
return ["axes: must be a non-empty list of axis names"]
problems: list[str] = []
seen: set[str] = set()
for axis in value:
if not isinstance(axis, str) or axis.strip().lower() not in AXIS_NAMES:
problems.append(
f"axes: every entry must be one of {', '.join(AXIS_NAMES)} (got {axis!r})"
)
continue
name = axis.strip().lower()
if name in seen:
problems.append(f"axes: duplicate axis {name!r}")
seen.add(name)
return problems
def validate_probe(block: dict[str, Any], objects: dict[str, str]) -> list[str]:
"""Return one message per invalid value in a probe mapping.
``block`` is mutated in place only to fill in the object-name defaults from
design.yaml's ``objects`` mapping, mirroring ``validate_texture_patch``.
"""
errors: list[str] = []
_check_known_keys(block, "probe", errors)
for key, default in (
("mesh", objects["sensing_surface"]),
("camera", objects["cam_sensor"]),
("group", "probe"),
):
value = block.get(key)
if value is None:
block[key] = default
else:
errors.extend(_object_name_problems(value, key))
uv_map = block.get("uv_map")
if not isinstance(uv_map, str) or not uv_map.strip():
errors.append(f"uv_map: must be a non-empty UV map name (got {uv_map!r})")
errors.extend(_bare_png_name_problems(block.get("texture"), "texture"))
radius = block.get("radius_mm")
if not _is_number(radius) or float(radius) <= 0.0:
errors.append(f"radius_mm: must be a positive number (got {radius!r})")
falloff = block.get("falloff")
if not isinstance(falloff, str) or falloff.strip().lower() not in FALLOFF_MODES:
errors.append(
f"falloff: must be one of {', '.join(FALLOFF_MODES)} (got {falloff!r})"
)
step = block.get("step_mm")
step_ok = _is_number(step) and float(step) > 0.0
if not step_ok:
errors.append(f"step_mm: must be a positive number (got {step!r})")
max_mm = block.get("max_mm")
max_ok = _is_number(max_mm) and float(max_mm) > 0.0
if not max_ok:
errors.append(f"max_mm: must be a positive number (got {max_mm!r})")
if step_ok and max_ok and float(step) > float(max_mm):
errors.append(
f"max_mm: must be >= step_mm (got max_mm={max_mm!r}, step_mm={step!r})"
)
errors.extend(_axes_problems(block.get("axes")))
for key in ("resolution", "process_resolution"):
errors.extend(_resolution_problems(block.get(key), key))
tracker = block.get("tracker")
if not isinstance(tracker, dict):
errors.append("tracker: must be a mapping")
else:
unknown = sorted(set(tracker) - _TRACKER_KEYS)
if unknown:
errors.append(
f"tracker: unknown key(s) {', '.join(unknown)} "
f"(allowed: {', '.join(sorted(_TRACKER_KEYS))})"
)
algorithm = tracker.get("algorithm")
if not isinstance(algorithm, str) or not algorithm.strip():
errors.append(
f"tracker.algorithm: must be a non-empty name (got {algorithm!r})"
)
preset = tracker.get("dis_preset")
if not isinstance(preset, str) or not preset.strip():
errors.append(
f"tracker.dis_preset: must be a non-empty name (got {preset!r})"
)
dis_mode = tracker.get("dis_mode")
if not _is_int(dis_mode) or dis_mode < 0:
errors.append(
f"tracker.dis_mode: must be an integer >= 0 (got {dis_mode!r})"
)
render = block.get("render")
if not isinstance(render, dict):
errors.append("render: must be a mapping")
else:
unknown = sorted(set(render) - _RENDER_KEYS)
if unknown:
errors.append(
f"render: unknown key(s) {', '.join(unknown)} "
f"(allowed: {', '.join(sorted(_RENDER_KEYS))})"
)
engine = render.get("engine")
if not isinstance(engine, str) or not engine.strip():
errors.append(f"render.engine: must be a non-empty name (got {engine!r})")
samples = render.get("samples")
if not _is_int(samples) or samples < 1:
errors.append(
f"render.samples: must be an integer >= 1 (got {samples!r})"
)
if not isinstance(render.get("denoise"), bool):
errors.append(
f"render.denoise: must be true or false (got {render.get('denoise')!r})"
)
view_transform = render.get("view_transform")
if not isinstance(view_transform, str) or not view_transform.strip():
errors.append(
f"render.view_transform: must be a non-empty name (got {view_transform!r})"
)
elif view_transform.strip() != "Standard":
# AgX/Filmic silently destroys marker values; the contract makes this
# a loud failure rather than a warning.
errors.append(
"render.view_transform: must be 'Standard' "
f"(got {view_transform!r}); AgX/Filmic distorts marker values"
)
interpolate = render.get("interpolate")
if not isinstance(interpolate, str) or not interpolate.strip():
errors.append(
f"render.interpolate: must be a non-empty name (got {interpolate!r})"
)
if not _is_int(render.get("seed")):
errors.append(f"render.seed: must be an integer (got {render.get('seed')!r})")
return errors
def resolve_pattern(paths: RevisionPaths) -> PatternInfo:
"""Resolve the marker pattern grid from the shared ``texture_patch`` block.
Defaults are reused from ``texture.DEFAULT_TEXTURE_PATCH`` (via
``texture.merge_texture_patch``); this module never re-declares them. Raises
``ProbeConfigError`` so callers only need one config exception type.
"""
try:
block = merge_texture_patch(paths)
except TextureConfigError as exc:
raise ProbeConfigError(str(exc)) from exc
errors: list[str] = []
nx = block.get("nx")
ny = block.get("ny")
seed = block.get("seed")
if not _is_int(nx) or nx < 1:
errors.append(f"texture_patch.nx: must be an integer >= 1 (got {nx!r})")
if not _is_int(ny) or ny < 1:
errors.append(f"texture_patch.ny: must be an integer >= 1 (got {ny!r})")
if seed is not None and not _is_int(seed):
errors.append(f"texture_patch.seed: must be an integer or null (got {seed!r})")
vector = block.get("vector")
width: Any = None
height: Any = None
if not isinstance(vector, dict):
errors.append("texture_patch.vector: must be a mapping of page size in mm")
else:
width = vector.get("width_mm")
height = vector.get("height_mm")
if not _is_number(width) or float(width) <= 0.0:
errors.append(
f"texture_patch.vector.width_mm: must be a positive number (got {width!r})"
)
if not _is_number(height) or float(height) <= 0.0:
errors.append(
f"texture_patch.vector.height_mm: must be a positive number (got {height!r})"
)
if errors:
raise ProbeConfigError("\n".join(errors))
return PatternInfo(
nx=int(nx),
ny=int(ny),
seed=None if seed is None else int(seed),
vector_width_mm=float(width),
vector_height_mm=float(height),
)
def resolve_probe(
paths: RevisionPaths,
overrides: dict[str, Any] | None = None,
) -> ProbeSettings:
"""Merge and validate settings for a headless probe-deformation run."""
block = merge_probe(paths, overrides)
objects = export_object_names(paths)
errors = validate_probe(block, objects)
if errors:
raise ProbeConfigError("\n".join(errors))
tracker = block["tracker"]
render = block["render"]
return ProbeSettings(
mesh=str(block["mesh"]),
camera=str(block["camera"]),
group=str(block["group"]),
uv_map=str(block["uv_map"]),
texture=str(block["texture"]),
radius_mm=float(block["radius_mm"]),
falloff=str(block["falloff"]).strip().lower(),
step_mm=float(block["step_mm"]),
max_mm=float(block["max_mm"]),
axes=tuple(str(axis).strip().lower() for axis in block["axes"]),
resolution=(int(block["resolution"][0]), int(block["resolution"][1])),
process_resolution=(
int(block["process_resolution"][0]),
int(block["process_resolution"][1]),
),
tracker={
"algorithm": str(tracker["algorithm"]).strip().lower(),
"dis_preset": str(tracker["dis_preset"]).strip().lower(),
"dis_mode": int(tracker["dis_mode"]),
},
render={
"engine": str(render["engine"]),
"samples": int(render["samples"]),
"denoise": bool(render["denoise"]),
"view_transform": str(render["view_transform"]),
"interpolate": str(render["interpolate"]),
"seed": int(render["seed"]),
},
pattern=resolve_pattern(paths),
noise=dict(block["noise"]),
)
def _step_decimals(step_mm: float) -> int:
"""Decimals needed to name a step, never fewer than the contract's two.
``+0.01mm`` and ``+0.10mm`` in the frozen frame table are 2-decimal names;
a finer configured step (for example 0.005) widens every name in that plan
to 3 decimals instead of mixing widths or rounding into a duplicate name.
"""
text = repr(float(step_mm))
if "e" in text or "E" in text:
return 6
decimals = len(text.split(".", 1)[1]) if "." in text else 0
return max(2, decimals)
def _frame_name(index: int, axis: str, step_mm: float, decimals: int) -> str:
return f"{index:03d}_{axis}_+{step_mm:.{decimals}f}mm"
def frame_plan(settings: ProbeSettings) -> list[ProbeFrame]:
"""Return the resolved sweep: reference, then axis-major, step-minor.
``F = 1 + len(axes) * round(max_mm / step_mm)`` (31 for the defaults). The
reference frame is index 0 with ``axis=None`` and ``step_mm=0.0``. Steps
stop at the last multiple of ``step_mm`` that does not exceed ``max_mm``.
"""
if settings.step_mm <= 0.0:
raise ProbeConfigError(f"step_mm must be > 0 (got {settings.step_mm!r})")
if settings.max_mm <= 0.0:
raise ProbeConfigError(f"max_mm must be > 0 (got {settings.max_mm!r})")
if not settings.axes:
raise ProbeConfigError("axes must not be empty")
count = int(round(settings.max_mm / settings.step_mm))
while count > 0 and settings.step_mm * count > settings.max_mm + 1e-9:
count -= 1
if count < 1:
raise ProbeConfigError(
f"max_mm ({settings.max_mm}) must be at least step_mm ({settings.step_mm})"
)
frames = [ProbeFrame(index=0, name="000_reference", axis=None, step_mm=0.0)]
index = 1
decimals = _step_decimals(settings.step_mm)
for axis in settings.axes:
for multiple in range(1, count + 1):
step_mm = round(settings.step_mm * multiple, 12)
frames.append(
ProbeFrame(
index=index,
name=_frame_name(index, axis, step_mm, decimals),
axis=axis,
step_mm=step_mm,
)
)
index += 1
return frames
def probe_revision_rel(root: Path, paths: RevisionPaths) -> Path:
"""Return ``<sensor>/<revision>`` for the revision below the repo's sensors/.
Reuses ``texture.texture_revision_rel`` so probe output follows exactly the
same on-disk revision naming as the texture stage.
"""
return texture_revision_rel(root, paths)
def probe_output_dir(root: Path, paths: RevisionPaths) -> Path:
"""Default output directory: build/probe/<sensor>/<revision>."""
return (root / PROBE_RELATIVE_ROOT / probe_revision_rel(root, paths)).resolve()
def resolve_probe_output_dir(
root: Path,
paths: RevisionPaths,
output_dir: Path | None = None,
) -> Path:
"""Resolve the probe output directory (explicit --output-dir wins)."""
if output_dir is None:
return probe_output_dir(root, paths)
return (Path(output_dir).expanduser()).resolve()
def deform_job_path(output_dir: Path) -> Path:
"""Return the job file path written beside the deform artifacts."""
return Path(output_dir) / DEFORM_JOB_FILENAME
def probe_artifact_paths(output_dir: Path) -> dict[str, Path]:
"""Return the absolute ``probe_frames.npz`` / ``deform_report.json`` paths."""
resolved_dir = Path(output_dir).resolve()
return {
"frames": resolved_dir / FRAMES_NPZ_FILENAME,
"report": resolved_dir / DEFORM_REPORT_FILENAME,
}
def build_deform_job(
paths: RevisionPaths,
settings: ProbeSettings,
output_dir: Path,
) -> dict[str, Any]:
"""Build the JSON job dict that probe_deform.py consumes (stdlib JSON)."""
resolved_dir = Path(output_dir).resolve()
return {
"schema": DEFORM_JOB_SCHEMA,
"revision_dir": str(paths.root.resolve()),
"source_blend": str(paths.source_blend.resolve()),
"output_dir": str(resolved_dir),
"objects": {
"mesh": settings.mesh,
"group": settings.group,
},
"settings": settings.to_job_settings(),
"frames": [frame.to_job_entry() for frame in frame_plan(settings)],
"artifacts": {
key: str(path) for key, path in probe_artifact_paths(resolved_dir).items()
},
}
def write_deform_job(job: dict[str, Any], output_dir: Path) -> Path:
"""Write _deform_job.json into the output directory; return its path."""
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
job_path = deform_job_path(output_dir)
job_path.write_text(json.dumps(job, indent=2) + "\n", encoding="utf-8")
return job_path
def probe_overrides_from_args(args: Any) -> dict[str, Any]:
"""Collect CLI override values from an argparse namespace (None = unset)."""
overrides: dict[str, Any] = {
"mesh": getattr(args, "mesh", None),
"group": getattr(args, "group", None),
"radius_mm": getattr(args, "radius_mm", None),
"falloff": getattr(args, "falloff", None),
"step_mm": getattr(args, "step_mm", None),
"max_mm": getattr(args, "max_mm", None),
"axes": None,
}
axes = getattr(args, "axes", None)
if axes is not None:
names: Sequence[Any]
if isinstance(axes, str):
names = [name.strip() for name in axes.split(",")]
else:
names = list(axes)
overrides["axes"] = [name for name in names if name and str(name).strip()]
return overrides
+290
View File
@@ -0,0 +1,290 @@
"""Measure the true zero-input optical-flow floor from real idle sensor footage.
The probe bench's ``floor_px`` is measured as ``reference`` vs a re-render at
``seed + 1``. That is Cycles antialiasing jitter: on the real data it differs by
~5 grey levels RMS on half the pixels, scaling with edge contrast. It is **not** a
sensor-noise model, and it is demonstrably the wrong thing to divide a signal by -- the
ordering of patterns by that floor reverses under additive noise.
This module measures the real quantity instead. Given idle footage (no contact), it runs
the *same* DIS fixed-reference estimator the bench uses and reports how much apparent
flow appears when nothing is moving. Two baselines are measured on purpose:
* **long** -- frame 0 versus every later frame, which accumulates any slow drift
(illumination, thermal, creep) as well as noise;
* **short** -- each frame versus its successor, which is noise-dominated.
``drift_ratio = long / short`` therefore separates the two. On the dd02 idle clip it is
~0.93 (short is slightly *larger*), i.e. the floor is frame-independent white noise and
the clip is genuinely idle -- which is what makes it a usable calibration target.
Everything is expressed in pixels, and scale-invariantly as ``floor_per_period``: the
floor divided by the dominant marker period, so a 1280x720 recording can be compared with
a 500x500 render. That ratio is the number the bench should be calibrated against.
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable, Mapping, Sequence
import numpy as np
#: Default ROI radius as a fraction of the shorter image side.
DEFAULT_ROI_FRACTION = 0.18
DEFAULT_MAX_FRAMES = 120
#: Marker-period search band, in pixels. Wide enough to cover a 4 px to 120 px pitch.
DEFAULT_PERIOD_BAND = (4.0, 120.0)
#: High-pass sigma used before the spectrum, in pixels: removes illumination gradients.
DEFAULT_HIGHPASS_SIGMA = 12.0
IMAGE_SUFFIXES = (".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff")
class FloorCalibrationError(RuntimeError):
"""Idle footage could not be read, or held too few usable frames."""
@dataclass(frozen=True)
class IdleFloor:
"""The measured real zero-input floor for one recording."""
source: str
frames: int
width: int
height: int
roi_radius_px: float
roi_pixels: int
#: Median over frame pairs of the p95 |flow| inside the ROI.
floor_px_long: float
floor_px_short: float
#: Peak of the radial power spectrum, in pixels of marker period.
dominant_period_px: float
floor_per_period_short: float
floor_per_period_long: float
drift_ratio: float
def to_json(self) -> dict[str, Any]:
return {
"source": self.source,
"frames": int(self.frames),
"width": int(self.width),
"height": int(self.height),
"roi": {
"u": self.width / 2.0,
"v": self.height / 2.0,
"radius": float(self.roi_radius_px),
"pixels": int(self.roi_pixels),
},
"floor_px_long": float(self.floor_px_long),
"floor_px_short": float(self.floor_px_short),
"dominant_period_px": float(self.dominant_period_px),
"floor_per_period_short": float(self.floor_per_period_short),
"floor_per_period_long": float(self.floor_per_period_long),
"drift_ratio": float(self.drift_ratio),
"basis": (
"real idle footage: DIS fixed-reference p95 |flow| over the ROI; "
"short = consecutive frames (noise), long = versus frame 0 (drift+noise)"
),
}
def load_gray_frames(
source: str | Path,
*,
max_frames: int = DEFAULT_MAX_FRAMES,
stride: int = 1,
) -> list[np.ndarray]:
"""Read up to ``max_frames`` grayscale frames from a video or an image directory."""
source = Path(source)
if source.is_dir():
paths = sorted(
path for path in source.iterdir()
if path.suffix.lower() in IMAGE_SUFFIXES
)[:: max(1, int(stride))][: int(max_frames)]
frames = []
for path in paths:
image = _read_gray(path)
if image is not None:
frames.append(image)
if not frames:
raise FloorCalibrationError(f"no readable images in {source}")
return frames
if not source.is_file():
raise FloorCalibrationError(f"idle footage not found: {source}")
try:
import cv2
except ImportError as exc: # pragma: no cover - optional dependency path
raise FloorCalibrationError(
"reading idle footage needs OpenCV: python -m pip install -e .[probe]"
) from exc
capture = cv2.VideoCapture(str(source))
if not capture.isOpened():
raise FloorCalibrationError(f"cannot open idle footage: {source}")
frames = []
index = 0
try:
while len(frames) < int(max_frames):
ok, frame = capture.read()
if not ok or frame is None:
break
if index % max(1, int(stride)) == 0:
gray = frame if frame.ndim == 2 else cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
frames.append(np.asarray(gray))
index += 1
finally:
capture.release()
if len(frames) < 2:
raise FloorCalibrationError(f"idle footage yielded {len(frames)} frame(s): {source}")
return frames
def _read_gray(path: Path) -> np.ndarray | None:
try:
import cv2
image = cv2.imread(str(path), cv2.IMREAD_GRAYSCALE)
return None if image is None else image
except ImportError: # pragma: no cover
return None
def dominant_period_px(
gray: np.ndarray,
*,
band: Sequence[float] = DEFAULT_PERIOD_BAND,
highpass_sigma: float = DEFAULT_HIGHPASS_SIGMA,
) -> float | None:
"""Return the dominant spatial period of an image, in pixels.
The image is high-passed first so illumination gradients do not dominate, then the
radially averaged power spectrum is searched inside ``band``. Period is taken from the
radial frequency directly (``1 / r`` with ``r`` in cycles per pixel), so no
bin-to-period heuristics are involved.
"""
try:
import cv2
except ImportError: # pragma: no cover
return None
array = np.asarray(gray, dtype=np.float32)
height, width = array.shape[:2]
if height < 16 or width < 16:
return None
flattened = array - cv2.GaussianBlur(array, (0, 0), float(highpass_sigma))
window = np.outer(np.hanning(height), np.hanning(width)).astype(np.float32)
spectrum = np.abs(np.fft.fftshift(np.fft.fft2(flattened * window))) ** 2
yy, xx = np.mgrid[0:height, 0:width]
# Radial frequency in cycles per pixel, normalised per axis so the two halves of a
# non-square image are comparable.
radial = np.hypot((yy - height // 2) / float(height), (xx - width // 2) / float(width))
low, high = float(band[0]), float(band[1])
if low <= 0 or high <= low:
return None
r_lo, r_hi = 1.0 / high, 1.0 / low
selected = (radial >= r_lo) & (radial <= r_hi)
if not np.any(selected):
return None
bins = np.clip((radial[selected] - r_lo) / (r_hi - r_lo) * 512.0, 0, 511).astype(np.int32)
sums = np.bincount(bins, weights=spectrum[selected], minlength=512)
counts = np.bincount(bins, minlength=512)
profile = sums / np.maximum(counts, 1)
peak = int(np.argmax(profile))
r_peak = r_lo + (peak + 0.5) / 512.0 * (r_hi - r_lo)
if r_peak <= 0:
return None
return float(1.0 / r_peak)
def measure_idle_floor(
source: str | Path,
*,
roi_fraction: float = DEFAULT_ROI_FRACTION,
max_frames: int = DEFAULT_MAX_FRAMES,
stride: int = 1,
tracker: Mapping[str, Any] | None = None,
period_band: Sequence[float] = DEFAULT_PERIOD_BAND,
) -> IdleFloor:
"""Measure the real zero-input flow floor on idle footage.
Runs the bench's own ``FixedReferenceFlow`` with the configured tracker settings, so
the number is directly comparable with a simulated ``floor_px``.
"""
from orisys_sensor_design.flow import FixedReferenceFlow, region_masks
frames = load_gray_frames(source, max_frames=max_frames, stride=stride)
if len(frames) < 2:
# The video path already enforces this; the image-directory path must too, or a
# single frame yields an empty short baseline and a silent NaN floor.
raise FloorCalibrationError(
f"idle footage yielded {len(frames)} frame(s); at least 2 are needed to "
f"measure a floor: {source}"
)
height, width = frames[0].shape[:2]
radius = float(roi_fraction) * float(min(height, width))
mask, _ = region_masks((height, width), (width / 2.0, height / 2.0), radius)
if not np.any(mask):
raise FloorCalibrationError(
f"the ROI (r={radius:.1f} px) is empty on a {width}x{height} frame"
)
config = dict(tracker or {"dis_preset": "medium", "dis_mode": 1})
def p95_flow(reference: np.ndarray, moving: np.ndarray) -> float:
estimator = FixedReferenceFlow(config)
estimator.set_reference(reference)
flow = estimator.compute(moving)
magnitude = np.linalg.norm(np.asarray(flow, dtype=float), axis=-1)
return float(np.percentile(magnitude[mask], 95))
long_values = [p95_flow(frames[0], frames[k]) for k in range(1, len(frames))]
short_values = [p95_flow(frames[k], frames[k + 1]) for k in range(len(frames) - 1)]
floor_long = float(np.median(long_values))
floor_short = float(np.median(short_values))
period = dominant_period_px(frames[0], band=period_band) or float("nan")
return IdleFloor(
source=str(Path(source)),
frames=len(frames),
width=int(width),
height=int(height),
roi_radius_px=radius,
roi_pixels=int(mask.sum()),
floor_px_long=floor_long,
floor_px_short=floor_short,
dominant_period_px=period,
floor_per_period_short=float(floor_short / period) if period else float("nan"),
floor_per_period_long=float(floor_long / period) if period else float("nan"),
drift_ratio=float(floor_long / floor_short) if floor_short else float("inf"),
)
def resolve_idle_source(root: Path, configured: str | None) -> Path | None:
"""Resolve ``probe.noise.idle_footage`` against the repo root, tolerating absence."""
if not configured:
return None
candidate = Path(str(configured)).expanduser()
if not candidate.is_absolute():
candidate = (Path(root) / candidate)
candidate = candidate.resolve()
return candidate if candidate.exists() else None
__all__ = [
"DEFAULT_HIGHPASS_SIGMA",
"DEFAULT_MAX_FRAMES",
"DEFAULT_PERIOD_BAND",
"DEFAULT_ROI_FRACTION",
"FloorCalibrationError",
"IdleFloor",
"dominant_period_px",
"load_gray_frames",
"measure_idle_floor",
"resolve_idle_source",
]
File diff suppressed because it is too large Load Diff
+813
View File
@@ -0,0 +1,813 @@
"""Deterministic bounded search over marker patterns for the probe bench.
Contract: ``docs/probe-pipeline.md`` (``optimizer_report.json``). The optimizer
enumerates ``(nx, ny, seed)`` candidates in a **sorted** order, runs the probe
chain for each at the search resolution, and reports a ranked, auditable table.
Two runs must produce byte-identical rankings, so candidates are enumerated and
ranked with explicit sort keys and nothing relies on dict/set iteration order.
The winner is a **measured ranking under a fixed estimator and camera** (DIS,
preset and mode fixed by design.yaml; camera fixed) -- not a global optimum.
That sentence is written into ``notes`` of every report.
The per-candidate pipeline is injected as ``runner`` (``cli.py`` supplies the real
deform -> render -> track -> sensitivity chain; the unit tests inject a fake), so
this module imports neither Blender nor cv2 and is testable in isolation.
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Mapping, Sequence
from orisys_sensor_design.manifest import RevisionPaths, repo_root
from orisys_sensor_design.sensitivity import (
default_probe_output_dir,
resolution_pair,
)
from orisys_sensor_design.texture import texture_revision_rel
OPTIMIZER_REPORT_SCHEMA = "orisys-sensor-design/optimizer-report-v2"
OPTIMIZER_REPORT_FILENAME = "optimizer_report.json"
OPTIMIZER_OBJECTIVE = "sensitivity_report.overall.score"
DEFAULT_GRID = (15, 20, 24, 28, 31, 36, 42, 48, 56, 64, 80)
DEFAULT_SEEDS = (7,)
#: Fallback only. The search defaults to ``settings.resolution`` because the two
#: scales genuinely resolve different cells: measured on the real run, nx=80 scores
#: 1.77 at 250x250 (rank 10/10) but 36.77 at 500x500, so a 250-based search excludes
#: the actual winner. Use this constant only when the settings carry no resolution.
DEFAULT_SEARCH_RESOLUTION = (250, 250)
NOT_GLOBAL_OPTIMUM = (
"Ranked by the measured score under a fixed estimator and camera; this is not a "
"global optimum."
)
#: The grid brackets the DIS-patch-matched cell size; it is not an aliasing claim.
GRID_RATIONALE = (
"The grid brackets the marker cell that matches the estimator's *effective* "
"correlation window, not the nominal patch. The pattern is defined across the "
"camera image, so the cell is width/nx: at 500x500 that is 16.1 px for nx=31 and "
"6.25 px for nx=80. Because `dis_mode=1` is OpenCV `setFinestScale(1)`, DIS "
"computes flow at half resolution and bilinearly upscales, so an 8 px patch spans "
"16 px of the input image. The measured render-scale optimum is nx=31, whose cell "
"is 1.01x that window, with a second peak at nx=56 (8.9 px, ~0.5x). nx=80 is "
"included because its 6.25 px cell is 0.39x the window -- finer than the estimator "
"can use -- which is why it is mid-table at 500x500 and near the bottom at 250x250 "
"where its cell is just 3.1 px. The shipped random texture does NOT alias: the "
"texture-level known-answer recovers a 2 px shift as 2.0000 px and the flow radial "
"profile is flat. Aliasing onto the DIS period was measured only for strongly "
"periodic synthetic textures (a 3.11 px checker turns a 2 px shift into 8.17 px); "
"that is why the report carries an `ambiguous` verdict gate rather than a penalty."
)
#: overall.score averages scales, which is a modelling choice, not a neutral one.
SCALE_DEPENDENT_NOTE = (
"'Best' is scale-dependent: `overall.score` is the unweighted mean of the "
"per-scale scores, and the two scales genuinely resolve different marker cells. "
"Measured on the full 500x500 grid on r01, nx=20 scores 39.4 at the process scale "
"but only 10.5 at the render scale, while nx=80 scores 40.5 at the render scale "
"but only 5.5 at the process scale, so a pattern can win one scale and lose the "
"other. Read `scale_scores` / `scale_ranks` on each candidate before trusting the "
"winner, and check it tops the scale you actually care about."
)
#: The measured gain is confounded with the ROI size, so only the pattern varies.
ROI_CONFOUND_NOTE = (
"Only nx/ny/seed vary across candidates: radius_mm, the ROI and the scale are held "
"fixed, because the measured gain is confounded with the ROI size (the probe patch "
"plus its falloff taper spans only ~13 px, 1-2 DIS patches, so DIS regularises toward "
"the static background). Ranking candidates across different radii would compare "
"measurement geometry, not marker patterns."
)
__all__ = [
"OPTIMIZER_REPORT_SCHEMA",
"OPTIMIZER_REPORT_FILENAME",
"OPTIMIZER_OBJECTIVE",
"DEFAULT_GRID",
"DEFAULT_SEEDS",
"DEFAULT_SEARCH_RESOLUTION",
"NOT_GLOBAL_OPTIMUM",
"GRID_RATIONALE",
"SCALE_DEPENDENT_NOTE",
"ROI_CONFOUND_NOTE",
"OptimiseError",
"Candidate",
"CandidateRun",
"enumerate_candidates",
"rank_entries",
"run_optimise",
"write_optimizer_report",
]
class OptimiseError(Exception):
"""Invalid search configuration or an unusable candidate result."""
# --------------------------------------------------------------------------- #
# Candidates
# --------------------------------------------------------------------------- #
@dataclass(frozen=True, order=True)
class Candidate:
"""One ``(nx, ny, seed)`` marker-pattern candidate.
``order=True`` makes the dataclass sort by ``(nx, ny, seed)``, which is the
deterministic enumeration order the contract requires.
"""
nx: int
ny: int
seed: int
@property
def id(self) -> str:
"""Stable identity string, e.g. ``"31@7"`` (or ``"31x42@7"`` if oblong)."""
if self.nx == self.ny:
return f"{self.nx}@{self.seed}"
return f"{self.nx}x{self.ny}@{self.seed}"
@property
def dirname(self) -> str:
"""Filesystem-safe per-candidate directory name."""
return f"nx{self.nx:03d}_ny{self.ny:03d}_seed{self.seed}"
@dataclass(frozen=True)
class CandidateRun:
"""One request to the injected runner (search or winner re-confirmation)."""
candidate: Candidate
directory: Path
resolution: tuple[int, int]
stage: str
texture_path: Path | None = None
def _int_list(values: Sequence[Any], label: str, *, minimum: int = 1) -> list[int]:
cleaned: list[int] = []
if isinstance(values, (str, bytes)) or values is None:
raise OptimiseError(f"{label} must be a sequence of integers, got {values!r}")
for value in values:
if isinstance(value, bool):
raise OptimiseError(f"{label} must contain integers, got {value!r}")
try:
integer = int(value)
except (TypeError, ValueError) as exc:
raise OptimiseError(f"{label} must contain integers, got {value!r}") from exc
if integer < minimum:
raise OptimiseError(f"{label} values must be >= {minimum}, got {integer}")
if integer not in cleaned:
cleaned.append(integer)
if not cleaned:
raise OptimiseError(f"{label} must not be empty")
return sorted(cleaned)
def enumerate_candidates(
grid: Sequence[int],
seeds: Sequence[int],
grid_y: Sequence[int] | None = None,
) -> list[Candidate]:
"""Enumerate every candidate in deterministic ``(nx, ny, seed)`` order.
``grid`` lists the ``nx`` values; ``ny`` mirrors ``nx`` by default (the sensor
camera frame is square), or follows ``grid_y`` when given. Duplicates are
removed and both axes are sorted, so the result depends only on the *sets*
supplied -- never on their order.
"""
nxs = _int_list(grid, "grid")
seed_values = _int_list(seeds, "seeds", minimum=0)
if grid_y is None:
# Square patterns by default: the sensor camera frame is square, so equal
# counts make camera-space cells square.
pairs = [(value, value) for value in nxs]
else:
nys = _int_list(grid_y, "grid_y")
pairs = [(nx, ny) for nx in nxs for ny in nys]
return [
Candidate(nx, ny, seed)
for nx, ny in sorted(set(pairs))
for seed in seed_values
]
# --------------------------------------------------------------------------- #
# Ranking
# --------------------------------------------------------------------------- #
def _entry_sort_key(entry: Mapping[str, Any]) -> tuple[Any, ...]:
"""Deterministic rank key: best score first, then candidate order.
Unscored candidates always sort last, so a failure never outranks a
measurement, and ties break on ``(nx, ny, seed)`` so two runs agree.
"""
candidate: Candidate = entry["candidate"]
score = entry.get("score")
if score is None:
return (1, 0.0, candidate.nx, candidate.ny, candidate.seed)
return (0, -float(score), candidate.nx, candidate.ny, candidate.seed)
def rank_entries(entries: Sequence[Mapping[str, Any]]) -> list[Mapping[str, Any]]:
"""Return entries sorted by measured score (input order irrelevant)."""
return sorted(entries, key=_entry_sort_key)
def _as_float(value: Any) -> float | None:
if value is None:
return None
try:
number = float(value)
except (TypeError, ValueError):
return None
if number != number: # NaN
return None
return number
# --------------------------------------------------------------------------- #
# Settings adapters (duck-typed: ProbeSettings object or a mapping)
# --------------------------------------------------------------------------- #
def _settings_value(settings: Any, key: str, default: Any = None) -> Any:
if isinstance(settings, Mapping):
return settings.get(key, default)
return getattr(settings, key, default)
def _revision_dir(paths: Any) -> Path:
root = getattr(paths, "root", None)
if root is None:
raise OptimiseError("run_optimise needs paths with a '.root' revision directory")
return Path(root).resolve()
def _resolve_output_dir(paths: Any, settings: Any, output_dir: Path | None) -> Path:
if output_dir is not None:
return Path(output_dir).expanduser().resolve()
configured = _settings_value(settings, "output_dir", None)
if configured:
return Path(configured).expanduser().resolve()
revision_dir = _revision_dir(paths)
root = repo_root(revision_dir)
if isinstance(paths, RevisionPaths):
return default_probe_output_dir(root, paths)
return default_probe_output_dir(root, RevisionPaths.from_revision_dir(revision_dir))
def _revision_label(paths: Any) -> str:
revision_dir = _revision_dir(paths)
try:
root = repo_root(revision_dir)
resolved = (
paths if isinstance(paths, RevisionPaths)
else RevisionPaths.from_revision_dir(revision_dir)
)
return f"sensors/{texture_revision_rel(root, resolved).as_posix()}"
except (FileNotFoundError, OSError, ValueError):
return str(revision_dir).replace("\\", "/")
def _texture_vector(paths: Any) -> tuple[float, float] | None:
"""Marker page size in mm, used to report the candidate cell size in px."""
revision_dir = _revision_dir(paths)
try:
from orisys_sensor_design.texture import resolve_texture_settings
resolved = (
paths if isinstance(paths, RevisionPaths)
else RevisionPaths.from_revision_dir(revision_dir)
)
settings = resolve_texture_settings(resolved)
return (float(settings.vector_width_mm), float(settings.vector_height_mm))
except Exception:
return None
def _cell_px(
outcome: Mapping[str, Any],
candidate: Candidate,
search_resolution: tuple[int, int],
confirm_resolution: tuple[int, int] | None,
texture_vector_mm: tuple[float, float] | None,
) -> float | None:
"""Candidate marker cell in **camera-space** px at the confirmation resolution.
The pattern grid is authored across the camera image, so the cell is
``width / nx`` -- it scales with the render width and needs no millimetre
conversion. (An earlier version multiplied the physical cell by the probe-local
px/mm, which understated every cell by ~2x and hid the effective-window rule.)
``confirm_resolution`` is therefore the only thing that has to be known here.
"""
reported = _as_float(outcome.get("cell_px"))
width = None
if confirm_resolution and confirm_resolution[0] > 0:
width = int(confirm_resolution[0])
elif search_resolution and search_resolution[0] > 0:
width = int(search_resolution[0])
if width:
return float(width) / float(candidate.nx)
# No resolution available: fall back to the runner's own measurement.
return reported
# --------------------------------------------------------------------------- #
# Per-scale extraction
# --------------------------------------------------------------------------- #
def _scales_from_mapping(raw: Any) -> dict[str, dict[str, Any]]:
"""Normalise a ``scales`` block into ``{name: {"score", "verdict"}}``."""
result: dict[str, dict[str, Any]] = {}
if isinstance(raw, Mapping):
for name, value in raw.items():
if isinstance(value, Mapping):
result[str(name)] = {
"score": _as_float(value.get("score")),
"verdict": value.get("verdict"),
}
else:
result[str(name)] = {"score": _as_float(value), "verdict": None}
return result
if isinstance(raw, list):
for value in raw:
if not isinstance(value, Mapping) or value.get("name") is None:
continue
result[str(value["name"])] = {
"score": _as_float(value.get("score")),
"verdict": value.get("verdict"),
}
return result
def _scales_from_report_path(path: Any) -> dict[str, dict[str, Any]]:
"""Read per-scale scores from a candidate's ``sensitivity_report.json``.
``cli.py``'s runner returns only the report path, so this is how the real run
gets its per-scale dimension without changing the runner contract.
"""
if path is None:
return {}
report_path = Path(str(path))
if not report_path.is_file() or report_path.stat().st_size == 0:
return {}
try:
data = json.loads(report_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
if not isinstance(data, Mapping):
return {}
return _scales_from_mapping(data.get("scales"))
def _extract_scales(outcome: Mapping[str, Any]) -> dict[str, dict[str, Any]]:
"""Per-scale scores/verdicts for one candidate.
Prefer an explicit ``scales`` value on the runner result; otherwise read them
from the ``sensitivity_report.json`` path the runner returned.
"""
explicit = outcome.get("scales")
if explicit is not None:
parsed = _scales_from_mapping(explicit)
if parsed:
return parsed
return _scales_from_report_path(outcome.get("report"))
def _per_scale_sort_key(entry: Mapping[str, Any], scale_name: str) -> tuple[Any, ...]:
candidate: Candidate = entry["candidate"]
scale = (entry.get("scales") or {}).get(scale_name) or {}
score = scale.get("score")
if score is None:
return (1, 0.0, candidate.nx, candidate.ny, candidate.seed)
return (0, -float(score), candidate.nx, candidate.ny, candidate.seed)
def _per_scale_ranking(
entries: Sequence[Mapping[str, Any]], scale_name: str
) -> list[Mapping[str, Any]]:
return sorted(entries, key=lambda entry: _per_scale_sort_key(entry, scale_name))
# --------------------------------------------------------------------------- #
# Execution
# --------------------------------------------------------------------------- #
def _execute(
run: CandidateRun,
runner: Callable[[CandidateRun], Mapping[str, Any] | None],
progress: Callable[[str], None],
) -> dict[str, Any]:
"""Run one candidate, converting a failure into a ranked-last entry."""
entry: dict[str, Any] = {
"candidate": run.candidate,
"score": None,
"verdict": None,
"cell_px": None,
"report": None,
"texture_sha256": None,
"px_per_mm": None,
"scales": {},
"error": None,
}
try:
outcome = runner(run)
except Exception as exc: # one bad candidate must not abort the search
entry["error"] = f"{type(exc).__name__}: {exc}"
progress(f" {run.candidate.id} [{run.stage}] FAILED: {entry['error']}")
return entry
if outcome is None:
entry["error"] = "runner returned no result"
progress(f" {run.candidate.id} [{run.stage}] FAILED: runner returned no result")
return entry
outcome = dict(outcome)
entry["score"] = _as_float(outcome.get("score"))
entry["verdict"] = outcome.get("verdict")
entry["cell_px"] = _as_float(outcome.get("cell_px"))
entry["report"] = None if outcome.get("report") is None else str(outcome["report"])
entry["texture_sha256"] = outcome.get("texture_sha256")
entry["px_per_mm"] = _as_float(outcome.get("px_per_mm"))
entry["scales"] = _extract_scales(outcome)
if entry["score"] is None:
entry["error"] = entry["error"] or "runner produced no score"
scale_text = ", ".join(
f"{name}={data.get('score')}" for name, data in sorted(entry["scales"].items())
)
progress(
f" {run.candidate.id} [{run.stage}] score={entry['score']} "
f"verdict={entry['verdict']}" + (f" [{scale_text}]" if scale_text else "")
)
return entry
def _utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
def write_optimizer_report(report: Mapping[str, Any], path: Path) -> Path:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(report, indent=2, sort_keys=False) + "\n", encoding="utf-8")
return path
def _candidate_row(
entry: Mapping[str, Any],
search_resolution: tuple[int, int],
confirm_resolution: tuple[int, int] | None,
texture_vector_mm: tuple[float, float] | None,
scale_ranks: Mapping[str, int | None] | None = None,
) -> dict[str, Any]:
candidate: Candidate = entry["candidate"]
scales = dict(entry.get("scales") or {})
row = {
"nx": candidate.nx,
"ny": candidate.ny,
"seed": candidate.seed,
"score": entry["score"],
"verdict": entry["verdict"],
"cell_px": _cell_px(
entry, candidate, search_resolution, confirm_resolution, texture_vector_mm
),
"report": entry["report"],
"texture_sha256": entry["texture_sha256"],
# Per-scale dimension: `score` averages the scales, so the reader must be
# able to see a pattern that wins one and loses the other.
"scale_scores": {
name: data.get("score") for name, data in sorted(scales.items())
},
"scale_verdicts": {
name: data.get("verdict") for name, data in sorted(scales.items())
},
"scale_ranks": {
name: (scale_ranks or {}).get(name) for name in sorted(scales)
},
}
if entry.get("error"):
row["error"] = entry["error"]
return row
def _error_report(revision: str, message: str) -> dict[str, Any]:
return {
"schema": OPTIMIZER_REPORT_SCHEMA,
"generated_at": _utc_now(),
"revision": revision,
"search": {
"grid": [],
"seeds": [],
"render_resolution": None,
"candidates": 0,
"objective": OPTIMIZER_OBJECTIVE,
},
"candidates": [],
"ranked": [],
"ranked_by_scale": {},
"winner": None,
"notes": [NOT_GLOBAL_OPTIMUM],
"error": {"message": message},
}
def run_optimise(
paths: Any,
settings: Any,
*,
grid: Sequence[int] = DEFAULT_GRID,
seeds: Sequence[int] = DEFAULT_SEEDS,
search_resolution: Any = None,
top: int | None = None,
dry_run: bool = False,
runner: Callable[[CandidateRun], Mapping[str, Any] | None] | None = None,
output_dir: Path | None = None,
progress: Callable[[str], None] = print,
texture_vector_mm: Sequence[float] | None = None,
confirm_resolution: Any = None,
) -> tuple[dict[str, Any], int]:
"""Search marker patterns and write ``optimizer_report.json``.
Returns ``(report, exit_code)`` with 0 ok / 2 config / 1 no usable result.
``runner`` runs one candidate's full chain; ``cli.py`` supplies it. Only
``dry_run=True`` is valid without a runner.
"""
try:
revision = _revision_label(paths)
except OptimiseError:
revision = ""
output_dir_resolved: Path | None = None
try:
candidates = enumerate_candidates(grid, seeds)
output_dir_resolved = _resolve_output_dir(paths, settings, output_dir)
# The search must default to the resolution we care about: the two scales
# resolve different cells, and a reduced search excludes the real winner
# (measured: nx=80 scores 1.77 at 250x250 but 36.77 at 500x500).
search_res = (
resolution_pair(search_resolution)
or resolution_pair(_settings_value(settings, "resolution", None))
or DEFAULT_SEARCH_RESOLUTION
)
confirm_res = resolution_pair(confirm_resolution) or resolution_pair(
_settings_value(settings, "resolution", None)
)
vector = (
(float(texture_vector_mm[0]), float(texture_vector_mm[1]))
if texture_vector_mm is not None and len(texture_vector_mm) >= 2
else _texture_vector(paths)
)
if top is not None:
try:
top_value = int(top)
except (TypeError, ValueError) as exc:
raise OptimiseError(f"top must be a positive integer, got {top!r}") from exc
if isinstance(top, bool) or top_value < 1:
raise OptimiseError(f"top must be a positive integer, got {top!r}")
else:
top_value = None
search_block = {
"grid": sorted({candidate.nx for candidate in candidates}),
"seeds": sorted({candidate.seed for candidate in candidates}),
"render_resolution": [search_res[0], search_res[1]],
"confirm_resolution": (
[confirm_res[0], confirm_res[1]] if confirm_res else None
),
"candidates": len(candidates),
"objective": OPTIMIZER_OBJECTIVE,
"dry_run": bool(dry_run),
"scales": [],
}
if dry_run:
rows = [
{
"nx": candidate.nx,
"ny": candidate.ny,
"seed": candidate.seed,
"score": None,
"verdict": None,
"cell_px": None,
"report": None,
"texture_sha256": None,
"scale_scores": {},
"scale_verdicts": {},
"scale_ranks": {},
}
for candidate in candidates
]
report = {
"schema": OPTIMIZER_REPORT_SCHEMA,
"generated_at": _utc_now(),
"revision": revision,
"search": search_block,
"candidates": rows,
"ranked": [],
"ranked_by_scale": {},
"winner": None,
"notes": [
f"dry-run: enumerated {len(candidates)} candidate(s); nothing was "
"rendered and no score is reported.",
GRID_RATIONALE,
SCALE_DEPENDENT_NOTE,
ROI_CONFOUND_NOTE,
NOT_GLOBAL_OPTIMUM,
],
}
write_optimizer_report(report, output_dir_resolved / OPTIMIZER_REPORT_FILENAME)
progress(f"optimise: dry-run enumerated {len(candidates)} candidate(s)")
progress(f"Wrote {output_dir_resolved / OPTIMIZER_REPORT_FILENAME}")
return report, 0
if runner is None:
raise OptimiseError(
"run_optimise needs a runner callable that runs the probe chain for one "
"candidate; pass runner=... or use dry_run=True"
)
progress(
f"optimise: {len(candidates)} candidate(s) at "
f"{search_res[0]}x{search_res[1]}"
)
entries: list[dict[str, Any]] = []
for candidate in candidates:
directory = output_dir_resolved / "candidates" / candidate.dirname
directory.mkdir(parents=True, exist_ok=True)
entries.append(
_execute(
CandidateRun(
candidate=candidate,
directory=directory,
resolution=search_res,
stage="search",
),
runner,
progress,
)
)
ranked = rank_entries(entries)
scored = [entry for entry in ranked if entry.get("score") is not None]
winner_entry = scored[0] if scored else None
# Per-scale ranking, so a pattern that wins one scale and loses the other
# is visible instead of hidden by the mean.
scale_names = sorted(
{name for entry in entries for name in (entry.get("scales") or {})}
)
scale_rank_maps: dict[str, dict[str, int]] = {}
ranked_by_scale: dict[str, list[str]] = {}
for scale_name in scale_names:
ordered = _per_scale_ranking(entries, scale_name)
ranks: dict[str, int] = {}
ids: list[str] = []
for position, entry in enumerate(ordered, start=1):
scale_data = (entry.get("scales") or {}).get(scale_name) or {}
if scale_data.get("score") is None:
continue
ranks[entry["candidate"].id] = position
ids.append(entry["candidate"].id)
scale_rank_maps[scale_name] = ranks
ranked_by_scale[scale_name] = ids[:top_value] if top_value else ids
search_block["scales"] = scale_names
confirmed_score: float | None = None
confirmed_at: list[int] | None = None
winner_report: str | None = None
notes = [
NOT_GLOBAL_OPTIMUM,
GRID_RATIONALE,
SCALE_DEPENDENT_NOTE,
ROI_CONFOUND_NOTE,
]
if winner_entry is not None:
winner_candidate: Candidate = winner_entry["candidate"]
winner_report = winner_entry.get("report")
if confirm_res is not None:
confirm_dir = (
output_dir_resolved / "candidates" / winner_candidate.dirname / "confirm"
)
confirm_dir.mkdir(parents=True, exist_ok=True)
confirm_entry = _execute(
CandidateRun(
candidate=winner_candidate,
directory=confirm_dir,
resolution=confirm_res,
stage="confirm",
),
runner,
progress,
)
confirmed_score = confirm_entry.get("score")
confirmed_at = [confirm_res[0], confirm_res[1]]
if confirm_entry.get("report"):
winner_report = confirm_entry["report"]
notes.append(
f"the winner was re-run and confirmed at the full "
f"{confirm_res[0]}x{confirm_res[1]} resolution "
f"(score {confirmed_score})"
)
if confirmed_score is not None and winner_entry.get("score") is not None:
search_score = float(winner_entry["score"])
delta = float(confirmed_score) - search_score
ratio = (
float(confirmed_score) / search_score if search_score else None
)
notes.append(
f"winner score gap between the search and the confirmation: "
f"{search_score:.4g} at {search_res[0]}x{search_res[1]} vs "
f"{float(confirmed_score):.4g} at "
f"{confirm_res[0]}x{confirm_res[1]} "
f"(delta {delta:+.4g}"
+ (f", ratio {ratio:.3g}x" if ratio is not None else "")
+ "); a large gap means the search resolution answered a "
"different question from the one that matters"
)
else:
notes.append(
"the winner was not re-run at the full probe.resolution because no "
"resolution was available; set confirm_resolution to enable it"
)
if winner_entry.get("verdict") == "ambiguous":
notes.append(
"WARNING: the top-scoring candidate's verdict is 'ambiguous' -- the "
"reference-texture known-answer failed, so its measured score is not "
"trustworthy even though it ranks first"
)
else:
notes.append("no candidate produced a usable score")
ranked_ids = [entry["candidate"].id for entry in ranked]
if top is not None:
ranked_ids = ranked_ids[: int(top)]
winner = None
if winner_entry is not None:
winner_candidate = winner_entry["candidate"]
winner = {
"nx": winner_candidate.nx,
"ny": winner_candidate.ny,
"seed": winner_candidate.seed,
"score": winner_entry.get("score"),
"confirmed_at_resolution": confirmed_at,
"confirmed_score": confirmed_score,
"report": winner_report,
"scale_scores": {
name: data.get("score")
for name, data in sorted((winner_entry.get("scales") or {}).items())
},
}
report = {
"schema": OPTIMIZER_REPORT_SCHEMA,
"generated_at": _utc_now(),
"revision": revision,
"search": search_block,
"candidates": [
_candidate_row(
entry,
search_res,
confirm_res,
vector,
scale_ranks={
name: scale_rank_maps.get(name, {}).get(entry["candidate"].id)
for name in scale_names
},
)
for entry in ranked
],
"ranked": ranked_ids,
"ranked_by_scale": ranked_by_scale,
"winner": winner,
"notes": notes,
}
write_optimizer_report(report, output_dir_resolved / OPTIMIZER_REPORT_FILENAME)
if winner is not None:
progress(
f"optimise: winner {winner['nx']}@{winner['seed']} "
f"score={winner['score']} confirmed={confirmed_score}"
)
progress(f"Wrote {output_dir_resolved / OPTIMIZER_REPORT_FILENAME}")
return report, 0 if scored else 1
except OptimiseError as exc:
report = _error_report(revision, str(exc))
if output_dir_resolved is not None:
write_optimizer_report(
report, output_dir_resolved / OPTIMIZER_REPORT_FILENAME
)
progress(f"ERROR: {exc}")
return report, 2
+636
View File
@@ -0,0 +1,636 @@
"""Optical-flow figures for the probe bench: what the estimator actually measured.
Three figures, all built from artifacts the pipeline already wrote (rendered frames,
``flow/<scale>/*.npy``, the sensitivity report) so a figure explains a measurement
rather than adding one:
``render_flow_comparison``
One column per setting. The top row is the rendered reference around the probe
with the ROI, the marker cell grid and the estimator's **effective window** drawn
on it, plus the applied-displacement arrow against the measured one. The bottom
row is the measured ``|flow|`` field on a shared colour scale. This is the figure
that shows *why* one pattern tracks and another does not.
``render_tuning``
Score against the **camera-space** marker cell, one series per scale, with the
nominal patch and the effective window marked. Because the pattern grid is defined
across the camera image, the cell is ``width / nx``; an earlier millimetre-derived
cell was about half that and made the measured optimum look like it contradicted
``dis_mode``.
``render_response_curves``
Response against applied displacement per pattern, with the estimator's zero-input
floor as a band and the ideal 1:1 recovery as a diagonal, so damping, the
resolvable threshold and any saturation are visible together.
matplotlib is imported lazily and only inside the functions, and ``Agg`` is selected
before ``pyplot``, so the core pipeline keeps working without matplotlib and the
figures still render headlessly — the same contract ``optics_plot.py`` keeps.
Every figure is defensive on purpose: a panel whose artifact is missing is replaced by
a text panel naming the gap instead of being dropped or silently rescaled, because a
comparison figure that quietly omits a failed setting is worse than no figure.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Mapping, Sequence
import numpy as np
#: Default half-width of the crop drawn around the probe, in pixels.
DEFAULT_CROP_PX = 26
#: Cell/window ratios inside this band are where the measured optima sit.
USABLE_RATIO_BAND = (0.4, 2.5)
_APPLIED_COLOR = "#1f77b4"
_MEASURED_COLOR = "#d62728"
_ROI_COLOR = "#2ca02c"
_WINDOW_COLOR = "#9467bd"
_CELL_COLOR = "#ff7f0e"
def _pyplot():
"""Import matplotlib lazily and force the headless Agg backend."""
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
return plt
def _crop(array: np.ndarray, centre: Sequence[float], half: int) -> tuple[np.ndarray, tuple[int, int]]:
"""Return a clamped crop around ``centre`` and its ``(x0, y0)`` origin."""
height, width = array.shape[:2]
cu, cv = float(centre[0]), float(centre[1])
x0 = int(max(0, min(width - 1, round(cu) - half)))
x1 = int(max(x0 + 1, min(width, round(cu) + half)))
y0 = int(max(0, min(height - 1, round(cv) - half)))
y1 = int(max(y0 + 1, min(height, round(cv) + half)))
return array[y0:y1, x0:x1], (x0, y0)
def _load_reference(panel: Mapping[str, Any]) -> np.ndarray | None:
"""Load a panel's rendered reference frame as a 2-D grey array, or None."""
path = panel.get("reference_png")
if path is None:
return None
path = Path(path)
if not path.is_file():
return None
try:
import matplotlib.image as mpimg
image = mpimg.imread(path)
except Exception:
return None
array = np.asarray(image, dtype=float)
if array.ndim == 3:
array = array[..., :3].mean(axis=2)
return array
def _text_panel(axes, title: str, lines: Sequence[str]) -> None:
"""Draw a placeholder panel naming what is missing."""
axes.set_title(title, fontsize=9, color="#b22222")
axes.text(
0.5,
0.5,
"\n".join(lines),
ha="center",
va="center",
fontsize=8,
color="#b22222",
transform=axes.transAxes,
wrap=True,
)
axes.set_xticks([])
axes.set_yticks([])
def render_flow_comparison(
*,
panels: Sequence[Mapping[str, Any]],
out_path: Path,
title: str = "Measured optical flow per setting",
crop_px: int = DEFAULT_CROP_PX,
quiver_stride: int = 4,
dpi: int = 150,
) -> Path:
"""Write the flow-comparison figure and return its path.
Each panel supplies ``label``, ``flow`` (H,W,2 in px), optionally
``reference_png``, ``roi`` ({u, v, radius}), ``cell_px``, ``effective_patch_px``,
``applied_px``, ``response_px``, ``scale_note`` and ``note``.
"""
plt = _pyplot()
out_path = Path(out_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
columns = max(1, len(panels))
magnitudes = []
for panel in panels:
flow = panel.get("flow")
if flow is not None:
magnitudes.append(float(np.nanmax(np.linalg.norm(np.asarray(flow, dtype=float), axis=-1))))
vmax = max(magnitudes) if magnitudes else 1.0
vmax = max(vmax, 1e-6)
figure, grid = plt.subplots(
2, columns, figsize=(4.2 * columns, 6.6), dpi=dpi, squeeze=False,
gridspec_kw={"height_ratios": [1.0, 1.15]},
)
figure.suptitle(title, fontsize=13)
for index, panel in enumerate(panels):
top = grid[0][index]
bottom = grid[1][index]
label = str(panel.get("label") or f"setting {index + 1}")
flow = panel.get("flow")
roi = panel.get("roi") or {}
centre = (float(roi.get("u", 0.0)), float(roi.get("v", 0.0)))
radius = float(roi.get("radius", 0.0))
if flow is None:
_text_panel(top, label, ["no flow array", str(panel.get("note") or "")])
_text_panel(bottom, "", [""])
continue
flow = np.asarray(flow, dtype=float)
magnitude = np.linalg.norm(flow, axis=-1)
reference = _load_reference(panel)
# ------------------------------------------------------------- top row
crop_half = int(crop_px)
if reference is not None:
crop, (x0, y0) = _crop(reference, centre, crop_half)
top.imshow(crop, cmap="gray", origin="upper", vmin=0.0, vmax=1.0)
else:
x0 = int(round(centre[0])) - crop_half
y0 = int(round(centre[1])) - crop_half
top.imshow(
np.zeros((2 * crop_half, 2 * crop_half)), cmap="gray", origin="upper",
vmin=0.0, vmax=1.0,
)
top.text(
0.5, 0.02, "rendered frame unavailable", ha="center", va="bottom",
fontsize=7, color="#b22222", transform=top.transAxes,
)
# Marker cell grid: drawn at the camera-space pitch so the cell and the
# estimator window can be compared by eye.
cell_px = panel.get("cell_px")
if cell_px:
step = float(cell_px)
if step >= 2.0:
span = range(int(np.floor((x0) / step)), int(np.ceil((x0 + 2 * crop_half) / step)) + 1)
for k in span:
top.axvline(k * step - x0, color=_CELL_COLOR, lw=0.4, alpha=0.45)
top.axhline(k * step - y0, color=_CELL_COLOR, lw=0.4, alpha=0.45)
window = panel.get("effective_patch_px")
if window:
half = float(window) / 2.0
top.add_patch(
plt.Rectangle(
(centre[0] - half - x0, centre[1] - half - y0),
float(window), float(window),
fill=False, edgecolor=_WINDOW_COLOR, lw=1.2, ls="--",
)
)
if radius > 0:
top.add_patch(
plt.Circle(
(centre[0] - x0, centre[1] - y0), radius,
fill=False, edgecolor=_ROI_COLOR, lw=1.2,
)
)
# Applied vs measured displacement, both drawn from the ROI centre.
applied = float(panel.get("applied_px") or 0.0)
response = float(panel.get("response_px") or 0.0)
direction = panel.get("direction_px")
if direction is None:
direction = (1.0, 0.0)
direction = np.asarray(direction, dtype=float)
norm = float(np.linalg.norm(direction)) or 1.0
direction = direction / norm
arrow = 12.0
top.annotate(
"", xy=(centre[0] - x0 + direction[0] * arrow, centre[1] - y0 + direction[1] * arrow),
xytext=(centre[0] - x0, centre[1] - y0),
arrowprops=dict(arrowstyle="->", color=_APPLIED_COLOR, lw=1.6),
)
if applied > 0:
scale = arrow / applied
top.annotate(
"",
xy=(centre[0] - x0 + direction[0] * response * scale,
centre[1] - y0 + direction[1] * response * scale),
xytext=(centre[0] - x0, centre[1] - y0),
arrowprops=dict(arrowstyle="->", color=_MEASURED_COLOR, lw=1.6),
)
top.set_title(label, fontsize=9)
top.set_xticks([])
top.set_yticks([])
# The crop is a small square in a taller axes box; anchor it to the top so the
# marker stays next to its caption instead of floating in whitespace.
top.set_anchor("N")
caption = panel.get("scale_note") or ""
if applied > 0:
caption += (
f"\napplied {applied:.3f} px | measured {response:+.3f} px"
f" | recovery {100.0 * response / applied:.1f}%"
)
if panel.get("note"):
caption += f"\n{panel['note']}"
top.set_xlabel(caption, fontsize=7, labelpad=6)
# ---------------------------------------------------------- bottom row
crop_mag, _ = _crop(magnitude, centre, crop_half)
image = bottom.imshow(
crop_mag, cmap="magma", origin="upper", vmin=0.0, vmax=vmax
)
if radius > 0:
bottom.add_patch(
plt.Circle(
(centre[0] - x0, centre[1] - y0), radius,
fill=False, edgecolor=_ROI_COLOR, lw=1.2,
)
)
roi_value = float(magnitude[
max(0, int(round(centre[1])) - 1):int(round(centre[1])) + 2,
max(0, int(round(centre[0])) - 1):int(round(centre[0])) + 2,
].mean())
bottom.set_title(
f"|flow| px (ROI mean {roi_value:.3g}, vmax {vmax:.3g})", fontsize=9
)
bottom.set_xticks([])
bottom.set_yticks([])
if index == columns - 1:
figure.colorbar(image, ax=bottom, fraction=0.046, pad=0.02)
figure.tight_layout(rect=(0, 0, 1, 0.96))
figure.savefig(out_path)
plt.close(figure)
return out_path
def render_tuning(
*,
series: Sequence[Mapping[str, Any]],
out_path: Path,
title: str = "Sensitivity against marker cell (camera space)",
patch_px: float | None = None,
dpi: int = 150,
) -> Path:
"""Write the tuning curve: score vs camera-space cell, and vs the window ratio.
``series`` entries are ``{"label": str, "points": [{"nx", "cell_px",
"effective_patch_px", "score"}]}``. One series per setting combination, so an
optimum that moves with a setting is visible rather than averaged away.
"""
plt = _pyplot()
out_path = Path(out_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
usable = [
dict(item, **entry)
for item in series
for entry in item.get("points") or []
if entry.get("cell_px") and entry.get("score") is not None
]
figure, axes = plt.subplots(1, 2, figsize=(13.4, 5.4), dpi=dpi)
figure.suptitle(title, fontsize=13)
if not usable:
for ax in axes:
_text_panel(ax, "no scored runs", ["nothing to plot"])
figure.tight_layout(rect=(0, 0, 1, 0.94))
figure.savefig(out_path)
plt.close(figure)
return out_path
markers = ["o", "s", "^", "D", "v", "P", "X", "*"]
for position, item in enumerate(series):
points = [
entry for entry in (item.get("points") or [])
if entry.get("cell_px") and entry.get("score") is not None
]
if not points:
continue
label = str(item.get("label"))
marker = markers[position % len(markers)]
points = sorted(points, key=lambda entry: float(entry["cell_px"]))
axes[0].plot(
[float(entry["cell_px"]) for entry in points],
[float(entry["score"]) for entry in points],
marker=marker, ls="-", label=label, ms=6, lw=1.2,
)
for entry in points:
axes[0].annotate(
str(entry.get("nx")),
(float(entry["cell_px"]), float(entry["score"])),
fontsize=7, xytext=(3, 3), textcoords="offset points",
)
ratio_points = [entry for entry in points if entry.get("effective_patch_px")]
if ratio_points:
axes[1].plot(
[float(entry["cell_px"]) / float(entry["effective_patch_px"]) for entry in ratio_points],
[float(entry["score"]) for entry in ratio_points],
marker=marker, ls="-", label=label, ms=6, lw=1.2,
)
if patch_px:
axes[0].axvline(float(patch_px), color=_WINDOW_COLOR, ls=":", lw=1.1)
# Label the nominal patch on the left and the windows on the right: when
# dis_mode=0 the window *is* the nominal patch, so right-hand labels would
# overprint each other into unreadable text.
axes[0].annotate(
f"nominal patch {float(patch_px):g} px", (float(patch_px), 0.98),
xycoords=("data", "axes fraction"), fontsize=8, color=_WINDOW_COLOR,
xytext=(-6, -12), textcoords="offset points", rotation=90,
va="top", ha="right",
)
windows = sorted({float(entry["effective_patch_px"]) for entry in usable if entry.get("effective_patch_px")})
for position, window in enumerate(windows):
axes[0].axvline(window, color=_ROI_COLOR, ls="--", lw=1.1, alpha=0.8)
axes[0].annotate(
f"window {window:g} px", (window, 0.98),
xycoords=("data", "axes fraction"), fontsize=8, color=_ROI_COLOR,
xytext=(4 + 12 * position, -12), textcoords="offset points",
rotation=90, va="top", ha="left",
)
axes[0].set_xscale("log")
axes[0].set_xlabel("marker cell (camera px) = render_width / nx")
axes[0].set_ylabel("sensitivity score")
axes[0].set_title("score vs camera-space cell", fontsize=10)
axes[0].grid(alpha=0.25, which="both")
axes[0].legend(fontsize=8)
low, high = USABLE_RATIO_BAND
axes[1].axvspan(low, high, color=_ROI_COLOR, alpha=0.12)
axes[1].axvline(1.0, color=_WINDOW_COLOR, ls="--", lw=1.1)
axes[1].set_xscale("log")
axes[1].set_xlabel("cell / effective window (1.0 = matched)")
axes[1].set_ylabel("sensitivity score")
axes[1].set_title("score vs window ratio (shaded = usable band)", fontsize=10)
axes[1].grid(alpha=0.25, which="both")
axes[1].legend(fontsize=8)
figure.tight_layout(rect=(0, 0, 1, 0.94))
figure.savefig(out_path)
plt.close(figure)
return out_path
def render_response_curves(
*,
curves: Sequence[Mapping[str, Any]],
out_path: Path,
axis: str = "x",
title: str = "Response vs applied displacement",
dpi: int = 150,
) -> Path:
"""Write response-vs-displacement curves, plus a noise-retention panel.
Each curve supplies ``label`` (callers must make it unique -- include the radius and
the dis_mode, or two different runs collide in the legend), ``points``
(``step_mm``, ``response_px``, ``applied_px``), optionally ``floor_px``, ``score``,
``verdict`` and ``retention`` (a list of ``{"sigma", "response_retention"}``).
Two panels on purpose. The left one is **signal** against the ideal 1:1 line, with
each curve's own noise floor drawn as a horizontal line: a bigger response with a
bigger floor is the normal case, and a single global band hides that. The right one
is **retention** -- ``gain(sigma)/gain(clean)`` -- which is where a pattern that
looks strong on the left but collapses under noise becomes visible.
"""
plt = _pyplot()
out_path = Path(out_path)
out_path.parent.mkdir(parents=True, exist_ok=True)
has_retention = any(curve.get("retention") for curve in curves)
if has_retention:
figure, axes = plt.subplots(1, 2, figsize=(13.4, 5.6), dpi=dpi)
signal_axes, retention_axes = axes[0], axes[1]
else:
figure, signal_axes = plt.subplots(figsize=(8.4, 5.6), dpi=dpi)
retention_axes = None
figure.suptitle(f"{title} (axis {axis})", fontsize=12)
plotted = 0
for curve in curves:
points = [p for p in (curve.get("points") or []) if p.get("step_mm")]
if not points:
continue
points = sorted(points, key=lambda p: float(p["step_mm"]))
xs = [float(p["step_mm"]) for p in points]
ys = [float(p.get("response_px") or 0.0) for p in points]
label = str(curve.get("label"))
if curve.get("score") is not None:
label += f" [axis score {float(curve['score']):.1f}]"
if curve.get("verdict"):
label += f" [{curve['verdict']}]"
(line,) = signal_axes.plot(xs, ys, marker="o", ms=4, lw=1.3, label=label)
# This curve's own floor, in its own colour.
floor = curve.get("floor_px")
if floor:
signal_axes.axhline(
float(floor), color=line.get_color(), ls=":", lw=1.0, alpha=0.75
)
# Stagger the labels: several patterns' floors are within a few hundredths
# of a pixel, so a fixed offset overprints them into unreadable text.
signal_axes.annotate(
f"floor {float(floor):.4g} px",
(0.995, float(floor)),
xycoords=("axes fraction", "data"),
xytext=(0, 6 + 9 * (plotted % 5)),
textcoords="offset points",
fontsize=7,
color=line.get_color(),
ha="right",
va="bottom",
)
if plotted == 0:
applied = [float(p.get("applied_px") or 0.0) for p in points]
if any(applied):
signal_axes.plot(xs, applied, ls="--", color="#555555", lw=1.1,
label="applied (ideal 1:1)")
plotted += 1
if retention_axes is not None and curve.get("retention"):
entries = sorted(curve["retention"], key=lambda e: float(e.get("sigma") or 0.0))
retention_axes.plot(
[float(e["sigma"]) for e in entries],
[float(e.get("response_retention") or 0.0) for e in entries],
marker="s", ms=4, lw=1.3, color=line.get_color(),
label=str(curve.get("label")),
)
if not plotted:
_text_panel(signal_axes, "no curves available", ["nothing to plot"])
signal_axes.set_xlabel("applied displacement (mm)")
signal_axes.set_ylabel("measured response (px, projected on the applied direction)")
signal_axes.set_title("signal (one axis, one scale)", fontsize=10)
signal_axes.grid(alpha=0.25)
if plotted:
signal_axes.legend(fontsize=7)
if retention_axes is not None:
retention_axes.axhline(1.0, color="#555555", ls="--", lw=1.1)
retention_axes.axhspan(0.85, 1.15, color=_ROI_COLOR, alpha=0.12)
retention_axes.set_xlabel("injected noise sigma (grey levels)")
retention_axes.set_ylabel("gain(sigma) / gain(clean)")
retention_axes.set_title(
"response retention under noise (shaded = within 15%)", fontsize=10
)
retention_axes.grid(alpha=0.25)
retention_axes.legend(fontsize=7)
figure.tight_layout(rect=(0, 0, 1, 0.94))
figure.savefig(out_path)
plt.close(figure)
return out_path
def panel_from_run(
*,
label: str,
output_dir: Path,
axis: str = "x",
step_mm: float = 0.10,
scale: str = "render",
manifest: Mapping[str, Any] | None = None,
flow_report: Mapping[str, Any] | None = None,
sensitivity_report: Mapping[str, Any] | None = None,
) -> dict[str, Any]:
"""Assemble one comparison panel from a run's on-disk artifacts.
Any artifact that is missing yields a panel the figure draws as an annotated
placeholder, so a failed setting is visible in the comparison rather than absent
from it.
"""
import json
output_dir = Path(output_dir)
note: list[str] = []
if manifest is None:
path = output_dir / "render_manifest.json"
manifest = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
if flow_report is None:
path = output_dir / "flow_report.json"
flow_report = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
if sensitivity_report is None:
path = output_dir / "sensitivity_report.json"
sensitivity_report = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
frames = [f for f in (manifest.get("frames") or []) if f.get("axis") == axis]
entry = None
if frames:
entry = min(frames, key=lambda f: abs(float(f.get("step_mm", 0.0)) - float(step_mm)))
flow = None
if entry:
flow_path = output_dir / "flow" / scale / f"{entry['name']}.npy"
if flow_path.is_file():
flow = np.load(flow_path)
else:
note.append(f"missing flow array {flow_path.name}")
roi = None
scale_block = None
for block in flow_report.get("scales") or []:
if block.get("name") != scale:
continue
scale_block = block
for frame in block.get("frames") or []:
if entry and frame.get("name") == entry.get("name"):
roi = frame.get("roi")
if roi is None and block.get("frames"):
roi = (block["frames"][0] or {}).get("roi")
if roi is None:
roi = manifest.get("probe_roi_px")
if roi is not None:
note.append("ROI fell back to the manifest's reference ROI")
sensitivity_scale = None
for block in sensitivity_report.get("scales") or []:
if block.get("name") == scale:
sensitivity_scale = block
cell_px = (sensitivity_scale or {}).get("cell_px")
window = (sensitivity_scale or {}).get("effective_patch_px")
direction = None
response = None
response_applied = None
for axis_block in (sensitivity_scale or {}).get("axes") or []:
if axis_block.get("axis") != axis:
continue
direction = axis_block.get("direction_px")
for point in axis_block.get("points") or []:
if entry and abs(float(point.get("step_mm", 0.0)) - float(entry.get("step_mm", 0.0))) < 1e-9:
response = point.get("response_px")
response_applied = point.get("applied_px")
reference_png = None
if entry:
# Resolve through the manifest, never by guessing the layout: a dis_mode
# variant directory holds no frames of its own and carries absolute `png`
# paths into the rendered directory. Assuming `<output_dir>/frames/<name>.png`
# made every variant panel read as "rendered frame unavailable".
from orisys_sensor_design.render import (
RenderArtifactError,
manifest_frame_entries,
)
try:
entries = manifest_frame_entries(manifest, output_dir)
except RenderArtifactError:
entries = []
for item in entries:
path = item.get("png_path")
if item.get("name") == entry.get("name") and path is not None:
reference_png = path
break
if reference_png is None:
for item in entries:
if item.get("axis") is None and item.get("png_path") is not None:
reference_png = item["png_path"]
break
if reference_png is None:
candidate = output_dir / "frames" / f"{entry['name']}.png"
reference_png = candidate if candidate.is_file() else None
if reference_png is None or not Path(reference_png).is_file():
note.append("rendered frame for this panel is missing")
tracker = flow_report.get("tracker") or {}
scale_note = (
f"{scale} {manifest.get('width')}x{manifest.get('height')}"
f" | patch {tracker.get('patch_px')} px, finest {tracker.get('finest_scale')}"
)
if cell_px:
scale_note += f" | cell {float(cell_px):.2f} px"
if window:
scale_note += f" | window {float(window):.2f} px"
if cell_px:
scale_note += f" (ratio {float(cell_px) / float(window):.2f})"
return {
"label": label,
"flow": flow,
"reference_png": reference_png,
"roi": roi,
"cell_px": cell_px,
"effective_patch_px": window,
"applied_px": response_applied,
"response_px": response,
"direction_px": direction,
"scale_note": scale_note,
"note": "; ".join(note),
"axis": axis,
"step_mm": None if entry is None else entry.get("step_mm"),
}
+487
View File
@@ -0,0 +1,487 @@
"""Host-side probe-render job I/O, output paths, and marker-texture resolution.
The host CLI (``orisys-design render``) resolves :class:`deform.ProbeSettings`,
writes a stdlib-JSON ``_render_job.json``, and invokes
``blender/scripts/probe_render.py`` headlessly. Blender never reads YAML and
never writes the ``.blend``; every path handed to it is absolute.
Frame ordering is **not** decided here: :func:`deform.frame_plan` is the single
ordering authority, and this module calls it so the render job and the deform
job cannot disagree. For the same reason the render stage consumes the
``probe_frames.npz`` written by the deform stage instead of re-deriving any
geometry or falloff weights.
Exit-code convention (see ``docs/probe-pipeline.md``):
* :class:`RenderConfigError` -> 2 (invalid job/config)
* :class:`RenderArtifactError` -> 3 (required artifact missing or empty)
Both carry enough context to be printed as-is by the CLI.
"""
from __future__ import annotations
import json
import re
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from orisys_sensor_design.deform import (
ProbeConfigError,
ProbeFrame,
ProbeSettings,
frame_plan,
probe_artifact_paths,
resolve_probe_output_dir,
)
from orisys_sensor_design.manifest import RevisionPaths, repo_root
from orisys_sensor_design.texture import resolve_texture_output_dir
RENDER_JOB_SCHEMA = "orisys-sensor-design/probe-render-job-v1"
RENDER_MANIFEST_SCHEMA = "orisys-sensor-design/probe-render-manifest-v1"
RENDER_JOB_FILENAME = "_render_job.json"
MANIFEST_FILENAME = "render_manifest.json"
FRAMES_DIRNAME = "frames"
NULL_FRAME_NAME = "reference_null"
PNG_SUFFIX = ".png"
#: The only view transform the contract accepts. AgX/Filmic are filmic
#: tonemappers: they silently rewrite every marker pixel and invalidate the
#: measurement, so the render stage refuses anything else.
REQUIRED_VIEW_TRANSFORM = "Standard"
_MAX_PROBE_EDGE = 32768
#: `Frame` in this module is any of: a deform.ProbeFrame, the job dict entry, or
#: a bare frame name. All three are accepted where a frame is named.
FrameLike = ProbeFrame | Mapping[str, Any] | str
class RenderConfigError(ValueError):
"""Raised when render settings/overrides are invalid (exit code 2)."""
class RenderArtifactError(RuntimeError):
"""Raised when a required artifact is missing, empty or unusable (exit 3).
``exit_code`` lets the CLI map this straight onto the contract's exit-code
table without re-parsing the message.
"""
exit_code = 3
@dataclass(frozen=True)
class RenderPaths:
"""Default on-disk locations for one probe render run."""
root: Path
output_dir: Path
texture_dir: Path
job: Path
manifest: Path
frames_npz: Path
frames_dir: Path
texture: Path
def frame_png(self, frame: FrameLike) -> Path:
"""Return the PNG path for one frame (or bare frame name)."""
return self.frames_dir / frame_png_name(frame)
def as_dict(self) -> dict[str, Path]:
"""Return the paths as a plain mapping (stable key order)."""
return {
"job": self.job,
"manifest": self.manifest,
"frames_npz": self.frames_npz,
"frames_dir": self.frames_dir,
"texture": self.texture,
}
def frame_name(frame: FrameLike) -> str:
"""Return the frame name from a ProbeFrame, a job entry, or a bare name."""
if isinstance(frame, str):
if not frame:
raise RenderConfigError("frame name must be a non-empty string")
return frame
if isinstance(frame, Mapping):
name = frame.get("name")
if not isinstance(name, str) or not name:
raise RenderConfigError(f"frame entry has no usable 'name': {frame!r}")
return name
name = getattr(frame, "name", None)
if not isinstance(name, str) or not name:
raise RenderConfigError(f"frame has no usable 'name': {frame!r}")
return name
def frame_png_name(frame: FrameLike) -> str:
"""Return ``<frame name>.png``, the contract's per-frame artifact name."""
return f"{frame_name(frame)}{PNG_SUFFIX}"
def probe_frames_path(output_dir: Path) -> Path:
"""Return the absolute ``probe_frames.npz`` path beside the render artifacts."""
return Path(probe_artifact_paths(Path(output_dir))["frames"])
def render_job_path(output_dir: Path) -> Path:
"""Return the ``_render_job.json`` path for an output directory."""
return Path(output_dir).resolve() / RENDER_JOB_FILENAME
def render_manifest_path(output_dir: Path) -> Path:
"""Return the ``render_manifest.json`` path for an output directory."""
return Path(output_dir).resolve() / MANIFEST_FILENAME
def frames_dir(output_dir: Path) -> Path:
"""Return the ``frames/`` directory holding the per-frame PNGs."""
return Path(output_dir).resolve() / FRAMES_DIRNAME
def frame_png_path(output_dir: Path, frame: FrameLike) -> Path:
"""Return the absolute PNG path for one frame in an output directory."""
return frames_dir(output_dir) / frame_png_name(frame)
def render_output_paths(
output_dir: Path,
frames: Sequence[FrameLike] | None = None,
) -> dict[str, Any]:
"""Return the absolute artifact paths for one render run.
Always contains ``output_dir``, ``job``, ``manifest``, ``frames_npz`` and
``frames_dir``. When ``frames`` is given, an extra ``frames`` mapping of
frame name (plus the null frame) to PNG path is included.
"""
resolved_dir = Path(output_dir).resolve()
paths: dict[str, Any] = {
"output_dir": resolved_dir,
"job": render_job_path(resolved_dir),
"manifest": render_manifest_path(resolved_dir),
"frames_npz": probe_frames_path(resolved_dir),
"frames_dir": frames_dir(resolved_dir),
}
if frames is not None:
pngs = {frame_name(frame): frame_png_path(resolved_dir, frame) for frame in frames}
pngs[NULL_FRAME_NAME] = frame_png_path(resolved_dir, NULL_FRAME_NAME)
paths["frames"] = pngs
return paths
def default_render_paths(
root: Path,
paths: RevisionPaths,
settings: ProbeSettings,
output_dir: Path | None = None,
texture_dir: Path | None = None,
) -> RenderPaths:
"""Resolve the default locations for a render run.
``output_dir`` defaults to ``build/probe/<sensor>/<revision>`` (the same
revision naming ``texture_revision_rel`` produces); ``texture_dir``
defaults to ``build/textures/<sensor>/<revision>``. The marker texture is
resolved and must exist and be non-empty.
"""
resolved_dir = resolve_probe_output_dir(root, paths, output_dir)
resolved_texture_dir = resolve_texture_output_dir(root, paths, texture_dir)
return RenderPaths(
root=Path(root).resolve(),
output_dir=resolved_dir,
texture_dir=resolved_texture_dir,
job=render_job_path(resolved_dir),
manifest=render_manifest_path(resolved_dir),
frames_npz=probe_frames_path(resolved_dir),
frames_dir=frames_dir(resolved_dir),
texture=resolve_marker_texture(root, paths, settings, texture_dir=texture_dir),
)
def require_artifact(path: Path, label: str) -> Path:
"""Return ``path`` if it is an existing non-empty file, else raise exit-3."""
candidate = Path(path)
if not candidate.is_file():
raise RenderArtifactError(f"{label} not found: {candidate}")
if candidate.stat().st_size == 0:
raise RenderArtifactError(f"{label} is empty: {candidate}")
return candidate
def marker_texture_path(
root: Path,
paths: RevisionPaths,
texture: str,
texture_dir: Path | None = None,
) -> Path:
"""Resolve a configured marker-texture value to an absolute path.
The configured form is a bare file name resolved against the texture output
directory (``build/textures/<sensor>/<revision>`` by default, or an explicit
``texture_dir``). An absolute value is used as given.
"""
candidate = Path(texture).expanduser()
if candidate.is_absolute():
return candidate.resolve()
base = resolve_texture_output_dir(root, paths, texture_dir)
return (base / candidate).resolve()
def resolve_marker_texture(
root: Path,
paths: RevisionPaths,
settings: ProbeSettings,
texture_dir: Path | None = None,
) -> Path:
"""Resolve ``settings.texture`` to an existing, non-empty PNG.
Raises :class:`RenderArtifactError` (exit code 3) when the marker texture is
missing or empty; the contract's failure list requires that to fail loudly
rather than render a blank surface.
"""
path = marker_texture_path(root, paths, settings.texture, texture_dir=texture_dir)
return require_artifact(path, f"marker texture {settings.texture!r}")
def _as_positive_int(value: Any, label: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
raise RenderConfigError(f"{label}: must be a positive integer (got {value!r})")
if value > _MAX_PROBE_EDGE:
raise RenderConfigError(f"{label}: must be <= {_MAX_PROBE_EDGE} (got {value})")
return int(value)
def _as_int(value: Any, label: str) -> int:
if isinstance(value, bool) or not isinstance(value, int):
raise RenderConfigError(f"{label}: must be an integer (got {value!r})")
return int(value)
def parse_resolution(value: Any) -> tuple[int, int]:
"""Parse ``"500x500"`` / ``"500,500"`` / ``[500, 500]`` into ``(w, h)``."""
if isinstance(value, (list, tuple)):
parts: list[Any] = list(value)
elif isinstance(value, str):
parts = [part for part in re.split(r"[xX,\s]+", value.strip()) if part]
else:
raise RenderConfigError(
f"resolution: must be WxH, W,H or a [width, height] pair (got {value!r})"
)
if len(parts) != 2:
raise RenderConfigError(
f"resolution: must have exactly two values (got {value!r})"
)
try:
width, height = (int(str(part)) for part in parts)
except (TypeError, ValueError) as exc:
raise RenderConfigError(
f"resolution: width and height must be integers (got {value!r})"
) from exc
return _as_positive_int(width, "resolution.width"), _as_positive_int(
height, "resolution.height"
)
def render_overrides_from_args(args: Any) -> dict[str, Any]:
"""Collect CLI render override values from an argparse namespace.
Unset flags are ``None`` and are dropped, so they never erase a configured
value. ``--resolution WxH`` is expanded into ``width``/``height``;
explicit ``--width``/``--height`` win over it.
"""
overrides: dict[str, Any] = {
"samples": getattr(args, "samples", None),
"seed": getattr(args, "seed", None),
"uv_map": getattr(args, "uv_map", None),
"texture": getattr(args, "texture", None),
}
resolution = getattr(args, "resolution", None)
if resolution is not None:
width, height = parse_resolution(resolution)
overrides["width"] = width
overrides["height"] = height
width = getattr(args, "width", None)
height = getattr(args, "height", None)
if width is not None:
overrides["width"] = width
if height is not None:
overrides["height"] = height
return {key: value for key, value in overrides.items() if value is not None}
def _repo_root_for(paths: RevisionPaths) -> Path:
try:
return repo_root(paths.root)
except FileNotFoundError:
# A revision outside a repository layout still gets a deterministic
# location; resolve_texture_output_dir then falls back the same way
# texture.py already does.
return Path(paths.root).resolve()
def build_render_job(
paths: RevisionPaths,
settings: ProbeSettings,
output_dir: Path,
*,
width: int | None = None,
height: int | None = None,
samples: int | None = None,
seed: int | None = None,
uv_map: str | None = None,
texture: str | Path | None = None,
) -> dict[str, Any]:
"""Build the JSON job dict that ``probe_render.py`` consumes.
The frame list comes from :func:`deform.frame_plan` and is stored verbatim
so the Blender stage can assert it against ``probe_frames.npz``. The marker
texture is resolved to an absolute, existing path; a missing/empty texture
raises :class:`RenderArtifactError` (exit 3) before Blender is started.
``radius_mm`` is carried in ``settings`` in addition to the frozen keys:
``render_manifest.json.probe_roi_px.radius`` is ``radius_mm`` x the measured
px/mm, and the render job has no other way to learn the configured radius.
"""
resolved_dir = Path(output_dir).resolve()
if texture is not None:
texture_path = Path(texture).expanduser().resolve()
require_artifact(texture_path, f"marker texture {str(texture)!r}")
else:
texture_path = resolve_marker_texture(
_repo_root_for(paths), paths, settings
)
settings_block = settings.to_render_settings(texture_path)
if width is not None:
settings_block["width"] = _as_positive_int(width, "width")
if height is not None:
settings_block["height"] = _as_positive_int(height, "height")
if samples is not None:
settings_block["samples"] = _as_positive_int(samples, "samples")
if seed is not None:
settings_block["seed"] = _as_int(seed, "seed")
if uv_map is not None:
if not isinstance(uv_map, str) or not uv_map.strip():
raise RenderConfigError(f"uv_map: must be a non-empty name (got {uv_map!r})")
settings_block["uv_map"] = uv_map.strip()
settings_block["radius_mm"] = float(settings.radius_mm)
artifacts = probe_artifact_paths(resolved_dir)
return {
"schema": RENDER_JOB_SCHEMA,
"revision_dir": str(Path(paths.root).resolve()),
"source_blend": str(Path(paths.source_blend).resolve()),
"output_dir": str(resolved_dir),
"objects": {
"mesh": settings.mesh,
"camera": settings.camera,
},
"settings": settings_block,
"frames": [frame.to_job_entry() for frame in frame_plan(settings)],
"null_frame": {
"name": NULL_FRAME_NAME,
"seed": int(settings_block["seed"]) + 1,
},
"artifacts": {
"frames_npz": str(artifacts["frames"]),
"report": str(resolved_dir / MANIFEST_FILENAME),
},
}
def write_render_job(job: dict[str, Any], output_dir: Path) -> Path:
"""Write ``_render_job.json`` into the output directory; return its path."""
resolved_dir = Path(output_dir)
resolved_dir.mkdir(parents=True, exist_ok=True)
job_path = render_job_path(resolved_dir)
job_path.write_text(json.dumps(job, indent=2) + "\n", encoding="utf-8")
return job_path
def load_render_manifest(output_dir: Path) -> dict[str, Any]:
"""Load and minimally validate ``render_manifest.json``.
Raises :class:`RenderArtifactError` (exit 3) when the manifest is missing,
empty, not JSON, not a mapping, carries the wrong schema, or lists no
frames. Downstream stages should treat that as a missing artifact rather
than guessing.
"""
path = render_manifest_path(output_dir)
require_artifact(path, MANIFEST_FILENAME)
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise RenderArtifactError(f"{MANIFEST_FILENAME} is not valid JSON: {path}: {exc}") from exc
if not isinstance(data, dict):
raise RenderArtifactError(f"{MANIFEST_FILENAME} must be a JSON object: {path}")
schema = data.get("schema")
if schema != RENDER_MANIFEST_SCHEMA:
raise RenderArtifactError(
f"{MANIFEST_FILENAME} schema is {schema!r}, expected {RENDER_MANIFEST_SCHEMA!r}: {path}"
)
frames = data.get("frames")
if not isinstance(frames, list) or not frames:
raise RenderArtifactError(f"{MANIFEST_FILENAME} lists no frames: {path}")
return data
def manifest_frame_entries(
manifest: Mapping[str, Any],
output_dir: Path,
) -> list[dict[str, Any]]:
"""Return the manifest's frame entries, with each PNG path made absolute.
``png`` is stored relative to the output directory (``frames/<name>.png``);
a copy is returned with an added ``png_path`` absolute ``Path`` so callers
do not have to re-derive the layout.
"""
resolved_dir = Path(output_dir).resolve()
entries: list[dict[str, Any]] = []
for entry in manifest.get("frames", []):
if not isinstance(entry, Mapping):
raise RenderArtifactError(f"manifest frame entry must be an object: {entry!r}")
item = dict(entry)
png = item.get("png")
if not isinstance(png, str) or not png:
raise RenderArtifactError(f"manifest frame entry has no 'png': {entry!r}")
item["png_path"] = (resolved_dir / png).resolve()
entries.append(item)
return entries
__all__ = [
"FRAMES_DIRNAME",
"MANIFEST_FILENAME",
"NULL_FRAME_NAME",
"PNG_SUFFIX",
"RENDER_JOB_FILENAME",
"RENDER_JOB_SCHEMA",
"RENDER_MANIFEST_SCHEMA",
"REQUIRED_VIEW_TRANSFORM",
"RenderArtifactError",
"RenderConfigError",
"RenderPaths",
"ProbeConfigError",
"build_render_job",
"default_render_paths",
"frame_name",
"frame_png_name",
"frame_png_path",
"frames_dir",
"load_render_manifest",
"manifest_frame_entries",
"marker_texture_path",
"parse_resolution",
"probe_frames_path",
"render_job_path",
"render_manifest_path",
"render_output_paths",
"render_overrides_from_args",
"require_artifact",
"resolve_marker_texture",
"write_render_job",
]
File diff suppressed because it is too large Load Diff
+289
View File
@@ -0,0 +1,289 @@
"""Tests for `compare`: the settings sweep and its caching.
Every test injects a fake `stage_runner`, so nothing here needs Blender, cv2 or
matplotlib. The caching behaviour is the part worth testing hardest: if a `dis_mode`
variant re-rendered instead of reusing the frames, the sweep's runtime claim would be
false and the comparison would be comparing different pixels.
"""
from __future__ import annotations
import dataclasses
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
from orisys_sensor_design import compare
class RecordingRunner:
"""A fake stage runner that records calls and writes minimal artifacts."""
def __init__(self, *, fail_on: str | None = None, scores: dict | None = None) -> None:
self.calls: list[tuple[str, Path, dict]] = []
self.settings_by_kind: dict[str, list] = {}
self.fail_on = fail_on
self.scores = scores or {"render": 10.0, "process": 4.0}
def __call__(self, kind: str, *, settings=None, output_dir=None, **kwargs) -> int:
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
self.calls.append((kind, output_dir, dict(kwargs)))
self.settings_by_kind.setdefault(kind, []).append(settings)
if self.fail_on and kind == self.fail_on:
return 1
if kind == "texture":
(output_dir / "patch_texture_compute.png").write_bytes(b"png")
if kind == "deform":
(output_dir / "probe_frames.npz").write_bytes(b"npz")
if kind == "render":
(output_dir / "frames").mkdir(exist_ok=True)
manifest = {
"width": 500,
"height": 500,
"frames": [
{"name": "000_reference", "axis": None, "step_mm": 0.0,
"png": "frames/000_reference.png"},
],
"null": {"name": "reference_null", "seed": 1,
"png": "frames/reference_null.png"},
}
(output_dir / "render_manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
if kind == "sensitivity":
report = {
"overall": {"score": 12.0, "verdict": "resolvable"},
"scales": [
{"name": name, "score": score, "verdict": "resolvable",
"cell_px": 16.13, "effective_patch_px": 16.0}
for name, score in self.scores.items()
],
}
(output_dir / "sensitivity_report.json").write_text(json.dumps(report), encoding="utf-8")
return 0
def kinds(self) -> list[str]:
return [kind for kind, _directory, _kwargs in self.calls]
def dirs_for(self, kind: str) -> list[Path]:
return [directory for name, directory, _kwargs in self.calls if name == kind]
@dataclasses.dataclass(frozen=True)
class FakePattern:
"""Mirrors ``deform.PatternInfo``, which the sweep overrides per candidate."""
nx: int = 80
ny: int = 80
seed: int = 7
vector_width_mm: float = 18.5
vector_height_mm: float = 16.4
@dataclasses.dataclass(frozen=True)
class FakeSettings:
"""A frozen dataclass on purpose: ``run_compare`` uses ``dataclasses.replace``,
so a ``SimpleNamespace`` fixture would pass tests that the real
``deform.ProbeSettings`` path fails."""
radius_mm: float = 0.5
texture: str = "patch_texture_compute.png"
tracker: dict = dataclasses.field(
default_factory=lambda: {"dis_mode": 1, "dis_preset": "medium"}
)
resolution: tuple = (500, 500)
process_resolution: tuple = (250, 250)
pattern: FakePattern = dataclasses.field(default_factory=FakePattern)
def _settings() -> FakeSettings:
return FakeSettings()
def _run(tmp_path: Path, runner, **kwargs):
defaults = dict(
stage_runner=runner,
grid=(31, 80),
seeds=(7,),
dis_modes=(1, 0),
radii=(0.5,),
output_dir=tmp_path,
progress=lambda _message: None,
)
defaults.update(kwargs)
return compare.run_compare(SimpleNamespace(root=tmp_path), _settings(), **defaults)
# --------------------------------------------------------------------------- #
# Enumeration
# --------------------------------------------------------------------------- #
def test_enumerate_is_deterministic_and_sorted() -> None:
first = compare.enumerate_sweep(
grid=(80, 31), seeds=(7,), dis_modes=(0, 1), radii=(1.5, 0.5)
)
second = compare.enumerate_sweep(
grid=(31, 80), seeds=(7,), dis_modes=(1, 0), radii=(0.5, 1.5)
)
assert [entry.id for entry in first] == [entry.id for entry in second]
assert [entry.nx for entry in first] == sorted(entry.nx for entry in first)
# dis_mode 1 (the shipped setting) sorts before 0 within every n, rx block.
assert [entry.dis_mode for entry in first if entry.nx == 31 and entry.radius_mm == 0.5] == [1, 0]
def test_enumerate_rejects_bad_values() -> None:
with pytest.raises(compare.CompareError):
compare.enumerate_sweep(grid=(0,), seeds=(7,), dis_modes=(1,), radii=(0.5,))
with pytest.raises(compare.CompareError):
compare.enumerate_sweep(grid=(31,), seeds=(7,), dis_modes=(1,), radii=(0.0,))
with pytest.raises(compare.CompareError):
compare.enumerate_sweep(grid=(31,), seeds=(7,), dis_modes=(-1,), radii=(0.5,))
def test_dry_run_enumerates_and_writes_nothing(tmp_path: Path) -> None:
runner = RecordingRunner()
report, code = _run(tmp_path, runner, dry_run=True)
assert code == 0
assert runner.calls == []
assert len(report["runs"]) == 4 # ids, not outcomes
assert not (tmp_path / compare.COMPARISON_DIRNAME / compare.FILENAME).exists()
assert any("dry-run" in note for note in report["notes"])
def test_max_runs_refuses_a_large_matrix(tmp_path: Path) -> None:
with pytest.raises(compare.CompareError):
_run(tmp_path, RecordingRunner(), max_runs=2)
# --------------------------------------------------------------------------- #
# Caching: what each setting actually invalidates
# --------------------------------------------------------------------------- #
def test_texture_runs_once_per_grid_and_frames_once_per_radius(tmp_path: Path) -> None:
runner = RecordingRunner()
report, code = _run(tmp_path, runner)
assert code == 0
# 2 grids -> 2 textures; 2 (grid x radius) -> 2 deform + 2 render;
# 4 entries -> 4 track + 4 sensitivity.
assert runner.kinds().count("texture") == 2
assert runner.kinds().count("deform") == 2
assert runner.kinds().count("render") == 2
assert runner.kinds().count("track") == 4
assert runner.kinds().count("sensitivity") == 4
assert len(report["runs"]) == 4
def test_dis_mode_variant_reuses_frames_and_rewrites_the_manifest(tmp_path: Path) -> None:
"""The decisive experiment must not re-render: same pixels, different estimator."""
runner = RecordingRunner()
_run(tmp_path, runner)
render_dirs = runner.dirs_for("render")
assert len(render_dirs) == 2, "each (nx, radius) is rendered exactly once"
# The dm0 variant directory must exist, hold no frames of its own, and carry a
# manifest whose png entries are absolute and point into the rendered directory.
variants = [d for d in runner.dirs_for("track") if d.name.endswith("_dm0")]
assert variants, "expected dis_mode=0 variant directories"
for variant in variants:
assert not (variant / "frames").exists(), "the variant must not re-render"
manifest = json.loads((variant / "render_manifest.json").read_text(encoding="utf-8"))
for entry in list(manifest["frames"]) + [manifest["null"]]:
path = Path(entry["png"])
assert path.is_absolute()
assert path.parent.name == "frames"
# `png` is relative to the run directory, so the subdirectory must appear
# exactly once. Doubling it (`frames/frames/...`) failed every variant run
# with a missing-artifact exit 3.
assert "frames" + "\\" + "frames" not in str(path)
assert "frames/frames" not in str(path).replace("\\", "/")
def test_radius_change_reuses_the_texture_but_not_the_frames(tmp_path: Path) -> None:
runner = RecordingRunner()
_run(tmp_path, runner, radii=(0.5, 1.5))
# Two radii and two grids: still one texture per grid, but frames per radius.
assert runner.kinds().count("texture") == 2
assert runner.kinds().count("deform") == 4
assert runner.kinds().count("render") == 4
# Each deformation must carry its own radius, and the estimator its own dis_mode.
radii = {round(float(s.radius_mm), 3) for s in runner.settings_by_kind["deform"]}
assert radii == {0.5, 1.5}
dis_modes = {int(s.tracker["dis_mode"]) for s in runner.settings_by_kind["track"]}
assert dis_modes == {0, 1}
# A deform call must never be asked to redo a radius it already did.
assert runner.kinds().count("deform") == len({(s.radius_mm, s.texture) for s in runner.settings_by_kind["deform"]})
# --------------------------------------------------------------------------- #
# Report shape, robustness, determinism
# --------------------------------------------------------------------------- #
def test_report_records_per_setting_bests(tmp_path: Path) -> None:
report, code = _run(tmp_path, RecordingRunner())
assert code == 0
assert set(report) >= {
"schema", "search", "runs", "ranked", "best_per_dis_mode", "best_per_radius", "notes",
}
assert report["schema"] == compare.SCHEMA
assert set(report["best_per_dis_mode"]) == {"0", "1"}
assert set(report["best_per_radius"]) == {"0.5"}
assert report["ranked"]
# Notes must say the effective window is what dis_mode changes.
joined = " ".join(report["notes"])
assert "finest pyramid level" in joined
assert "patch_px * 2**dis_mode" in joined
def test_report_is_written_and_parses(tmp_path: Path) -> None:
_run(tmp_path, RecordingRunner())
path = tmp_path / compare.COMPARISON_DIRNAME / compare.FILENAME
assert path.is_file()
parsed = json.loads(path.read_text(encoding="utf-8"))
assert parsed["schema"] == compare.SCHEMA
def test_a_failing_run_is_recorded_without_aborting_the_sweep(tmp_path: Path) -> None:
runner = RecordingRunner(fail_on="sensitivity")
report, code = _run(tmp_path, runner)
assert all(run["error"] for run in report["runs"])
assert code == 1
assert any("failed and are recorded with an error" in note for note in report["notes"])
def test_two_runs_give_identical_reports(tmp_path: Path) -> None:
first, _ = _run(tmp_path, RecordingRunner())
second, _ = _run(tmp_path, RecordingRunner())
first.pop("revision", None)
second.pop("revision", None)
assert json.dumps(first, sort_keys=True) == json.dumps(second, sort_keys=True)
def test_build_figures_writes_three_figures(tmp_path: Path) -> None:
pytest.importorskip("matplotlib")
runner = RecordingRunner()
report, _code = _run(tmp_path, runner)
# Give one run real flow data so a panel can be drawn rather than placeholdered.
import numpy as np
for directory in runner.dirs_for("track")[:1]:
flow_dir = directory / "flow" / "render"
flow_dir.mkdir(parents=True, exist_ok=True)
np.save(flow_dir / "000_reference.npy", np.zeros((4, 4, 2), dtype=np.float32))
figures = compare.build_figures(
report, tmp_path / compare.COMPARISON_DIRNAME, progress=lambda _m: None
)
assert "tuning" in figures
assert figures["tuning"].is_file()
assert figures["tuning"].stat().st_size > 0
def test_build_figures_without_scored_runs_writes_nothing(tmp_path: Path) -> None:
pytest.importorskip("matplotlib")
figures = compare.build_figures(
{"runs": []}, tmp_path, progress=lambda _m: None
)
assert figures == {}
+625
View File
@@ -0,0 +1,625 @@
"""Tests for headless probe-deformation settings, frame plan, and job I/O."""
from __future__ import annotations
import argparse
import dataclasses
import json
from collections.abc import Mapping
from pathlib import Path
import pytest
from orisys_sensor_design.deform import (
DEFAULT_PROBE,
DEFORM_JOB_FILENAME,
DEFORM_JOB_SCHEMA,
FRAMES_NPZ_FILENAME,
DEFORM_REPORT_FILENAME,
ProbeConfigError,
ProbeFrame,
PatternInfo,
build_deform_job,
deform_job_path,
frame_plan,
load_probe_block,
merge_probe,
probe_artifact_paths,
probe_output_dir,
probe_overrides_from_args,
probe_revision_rel,
resolve_pattern,
resolve_probe,
resolve_probe_output_dir,
validate_probe,
write_deform_job,
)
from orisys_sensor_design.manifest import RevisionPaths, load_yaml, repo_root
XYNOVA_RENDER = {
"engine": "CYCLES",
"samples": 16,
"denoise": False,
"view_transform": "Standard",
"interpolate": "Closest",
"seed": 0,
}
XYNOVA_TRACKER = {"algorithm": "dis", "dis_preset": "medium", "dis_mode": 1}
@pytest.fixture()
def revision_dir() -> Path:
return repo_root() / "sensors" / "finger-xynova" / "r01"
@pytest.fixture()
def paths(revision_dir: Path) -> RevisionPaths:
return RevisionPaths.from_revision_dir(revision_dir)
def _write_revision(
tmp_path: Path,
probe_yaml: str = "",
*,
sensor_field: str = "finger",
sensor_dir: str = "finger",
revision_name: str = "r99",
) -> Path:
"""Create a minimal sensors/<sensor_dir>/<revision_name> revision under tmp_path."""
revision = tmp_path / "sensors" / sensor_dir / revision_name
(revision / "source").mkdir(parents=True)
(revision / "source" / "assembly.blend").write_bytes(b"")
design = (
f"sensor: {sensor_field}\n"
f"revision: {revision_name}\n"
"meters_per_source_unit: 0.001\n"
"source_blend: source/assembly.blend\n"
)
design += probe_yaml
(revision / "design.yaml").write_text(design, encoding="utf-8")
return revision
def _probe_namespace(**overrides: object) -> argparse.Namespace:
namespace = argparse.Namespace(
mesh=None,
group=None,
radius_mm=None,
falloff=None,
step_mm=None,
max_mm=None,
axes=None,
)
for key, value in overrides.items():
setattr(namespace, key, value)
return namespace
# ------------------------------------------------------------- settings
def _design_probe(paths: RevisionPaths) -> dict:
"""The ``probe`` block exactly as written in design.yaml.
Read rather than hard-coded. Resolution and ``dis_mode`` are deliberate choices that
change as the bench is re-targeted (250x250 needs ``dis_mode=0``; 500x500 does not),
and duplicating them here would fail on a legitimate edit while asserting nothing
extra about the pipeline. What the tests below must prove is that the resolved
settings *equal* what the design declares, at whatever value it declares.
"""
return load_yaml(paths.design_yaml)["probe"]
def _as_pair(value) -> tuple[int, int]:
return (int(value[0]), int(value[1]))
def test_defaults_come_from_design_yaml(paths: RevisionPaths) -> None:
settings = resolve_probe(paths)
design = _design_probe(paths)
assert settings.mesh == "sensing_surface"
assert settings.camera == "cam_sensor"
assert settings.group == "probe"
assert settings.uv_map == "compute"
assert settings.texture == "patch_texture_compute.png"
assert settings.radius_mm == pytest.approx(0.5)
assert settings.falloff == "smooth"
assert settings.step_mm == pytest.approx(0.01)
assert settings.max_mm == pytest.approx(0.10)
assert settings.axes == ("x", "y", "z")
assert settings.resolution == _as_pair(design["resolution"])
assert settings.process_resolution == _as_pair(design["process_resolution"])
assert settings.tracker == dict(design["tracker"])
assert settings.render == XYNOVA_RENDER
# The marker grid is resolved from the shared texture_patch block.
assert settings.pattern == PatternInfo(
nx=80, ny=80, seed=7, vector_width_mm=18.5, vector_height_mm=16.4
)
assert settings.pattern.cell_mm == pytest.approx((0.23125, 0.205))
def test_pattern_grid_resolves_from_texture_patch(paths: RevisionPaths) -> None:
pattern = resolve_pattern(paths)
assert pattern.to_job_entry() == {
"nx": 80,
"ny": 80,
"seed": 7,
"vector_width_mm": 18.5,
"vector_height_mm": 16.4,
"cell_mm": [0.23125, 0.205],
}
# The sensitivity stage duck-types settings.pattern as a mapping (.get).
assert isinstance(pattern, Mapping)
assert pattern.get("nx") == 80
assert pattern.get("seed") == 7
assert pattern.get("cell_mm") == [0.23125, 0.205]
assert dict(pattern) == pattern.to_job_entry()
def test_pattern_falls_back_to_texture_defaults(tmp_path: Path) -> None:
revision = _write_revision(tmp_path)
pattern = resolve_pattern(RevisionPaths.from_revision_dir(revision))
assert pattern == PatternInfo(
nx=80, ny=45, seed=None, vector_width_mm=164.0, vector_height_mm=160.0
)
@pytest.mark.parametrize(
("texture_yaml", "expected"),
[
("texture_patch:\n nx: 0\n", "texture_patch.nx"),
("texture_patch:\n ny: many\n", "texture_patch.ny"),
("texture_patch:\n seed: soon\n", "texture_patch.seed"),
("texture_patch:\n vector:\n width_mm: 0\n", "vector.width_mm"),
("texture_patch:\n vector:\n height_mm: -1\n", "vector.height_mm"),
("texture_patch:\n widht: 100\n", "widht"),
],
)
def test_pattern_rejects_bad_texture_patch(
tmp_path: Path, texture_yaml: str, expected: str
) -> None:
revision = _write_revision(tmp_path, texture_yaml)
with pytest.raises(ProbeConfigError) as excinfo:
resolve_pattern(RevisionPaths.from_revision_dir(revision))
assert expected in str(excinfo.value)
def test_builtin_defaults_when_probe_block_absent(tmp_path: Path) -> None:
revision = _write_revision(tmp_path)
paths = RevisionPaths.from_revision_dir(revision)
assert load_probe_block(paths) == {}
settings = resolve_probe(paths)
assert settings.radius_mm == DEFAULT_PROBE["radius_mm"]
assert settings.falloff == DEFAULT_PROBE["falloff"]
assert settings.step_mm == DEFAULT_PROBE["step_mm"]
assert settings.max_mm == DEFAULT_PROBE["max_mm"]
assert settings.axes == tuple(DEFAULT_PROBE["axes"])
# Object names fall back to design.yaml `objects` defaults.
assert settings.mesh == "sensing_surface"
assert settings.camera == "cam_sensor"
assert settings.group == "probe"
def test_design_yaml_overrides_builtin_defaults(tmp_path: Path) -> None:
revision = _write_revision(
tmp_path,
"probe:\n"
" radius_mm: 0.25\n"
" step_mm: 0.02\n"
" max_mm: 0.06\n"
" axes: [z, x]\n"
" tracker:\n"
" dis_preset: fast\n",
)
settings = resolve_probe(RevisionPaths.from_revision_dir(revision))
assert settings.radius_mm == pytest.approx(0.25)
assert settings.step_mm == pytest.approx(0.02)
assert settings.max_mm == pytest.approx(0.06)
assert settings.axes == ("z", "x")
assert settings.tracker["dis_preset"] == "fast"
# Untouched nested keys keep their defaults.
assert settings.tracker["dis_mode"] == 1
assert settings.render == XYNOVA_RENDER
def test_cli_overrides_beat_design_yaml(paths: RevisionPaths) -> None:
settings = resolve_probe(
paths,
{
"mesh": "other_mesh",
"group": "other_group",
"radius_mm": 0.25,
"falloff": "Sharp",
"step_mm": 0.02,
"max_mm": 0.08,
"axes": ["y", "z"],
},
)
assert settings.mesh == "other_mesh"
assert settings.group == "other_group"
assert settings.radius_mm == pytest.approx(0.25)
# Falloff is normalised to the contract's lowercase mode names.
assert settings.falloff == "sharp"
assert settings.step_mm == pytest.approx(0.02)
assert settings.max_mm == pytest.approx(0.08)
assert settings.axes == ("y", "z")
def test_unset_overrides_do_not_erase_configured_values(paths: RevisionPaths) -> None:
merged = merge_probe(
paths, {"radius_mm": None, "falloff": None, "axes": None, "tracker": None}
)
assert merged["radius_mm"] == 0.5
assert merged["falloff"] == "smooth"
assert merged["axes"] == ["x", "y", "z"]
# Compared against the design rather than a pinned literal: `dis_mode` is a
# deliberate per-resolution choice (250x250 needs 0, 500x500 uses 1), and the point
# of this test is that a None override does not erase what the design declares.
assert merged["tracker"] == dict(_design_probe(paths)["tracker"])
def test_probe_must_be_a_mapping(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, "probe: fast\n")
with pytest.raises(ProbeConfigError) as excinfo:
resolve_probe(RevisionPaths.from_revision_dir(revision))
assert "must be a mapping" in str(excinfo.value)
@pytest.mark.parametrize(
("probe_yaml", "expected"),
[
("probe:\n radus_mm: 0.4\n", "radus_mm"),
("probe:\n tracker:\n preset: medium\n", "preset"),
("probe:\n render:\n view_transfrom: Standard\n", "view_transfrom"),
("probe:\n tracker: fast\n", "tracker"),
("probe:\n render: cycles\n", "render"),
],
)
def test_unknown_or_mistyped_keys_are_rejected(
tmp_path: Path, probe_yaml: str, expected: str
) -> None:
revision = _write_revision(tmp_path, probe_yaml)
with pytest.raises(ProbeConfigError) as excinfo:
resolve_probe(RevisionPaths.from_revision_dir(revision))
assert expected in str(excinfo.value)
@pytest.mark.parametrize(
("overrides", "expected"),
[
({"mesh": ""}, "mesh"),
({"group": ""}, "group"),
({"uv_map": ""}, "uv_map"),
({"texture": "tex.jpg"}, "texture"),
({"texture": "nested/tex.png"}, "texture"),
({"texture": ""}, "texture"),
({"radius_mm": 0.0}, "radius_mm"),
({"radius_mm": -1.0}, "radius_mm"),
({"radius_mm": True}, "radius_mm"),
({"falloff": "bounce"}, "falloff"),
({"step_mm": 0.0}, "step_mm"),
({"step_mm": -0.01}, "step_mm"),
({"max_mm": 0.0}, "max_mm"),
({"step_mm": 0.2, "max_mm": 0.1}, "max_mm"),
({"axes": []}, "axes"),
({"axes": ["w"]}, "axes"),
({"axes": ["x", "x"]}, "axes"),
({"axes": "x,y"}, "axes"),
({"resolution": [0, 500]}, "resolution.width"),
({"resolution": [500]}, "resolution"),
({"resolution": [500, 32769]}, "resolution.height"),
({"process_resolution": ["a", "b"]}, "process_resolution"),
({"tracker": {"algorithm": ""}}, "tracker.algorithm"),
({"tracker": {"dis_preset": ""}}, "tracker.dis_preset"),
({"tracker": {"dis_mode": -1}}, "tracker.dis_mode"),
({"tracker": {"dis_mode": "1"}}, "tracker.dis_mode"),
({"render": {"engine": ""}}, "render.engine"),
({"render": {"samples": 0}}, "render.samples"),
({"render": {"denoise": "no"}}, "render.denoise"),
({"render": {"view_transform": "AgX"}}, "view_transform"),
({"render": {"interpolate": ""}}, "render.interpolate"),
({"render": {"seed": "x"}}, "render.seed"),
],
)
def test_invalid_overrides_are_rejected(
paths: RevisionPaths, overrides: dict, expected: str
) -> None:
with pytest.raises(ProbeConfigError) as excinfo:
resolve_probe(paths, overrides)
assert expected in str(excinfo.value)
def test_validation_reports_every_problem(paths: RevisionPaths) -> None:
block = merge_probe(paths, {"radius_mm": 0.0, "falloff": "bounce", "step_mm": -1.0})
errors = validate_probe(block, {"sensing_surface": "sensing_surface", "cam_sensor": "cam_sensor"})
assert len([line for line in errors if line]) == 3
with pytest.raises(ProbeConfigError) as excinfo:
resolve_probe(paths, {"radius_mm": 0.0, "falloff": "bounce"})
message = str(excinfo.value)
assert "radius_mm" in message
assert "falloff" in message
def test_agx_view_transform_fails_loudly(paths: RevisionPaths) -> None:
"""AgX/Filmic silently destroys marker values; the contract forbids it."""
with pytest.raises(ProbeConfigError) as excinfo:
resolve_probe(paths, {"render": {"view_transform": "Filmic"}})
assert "Standard" in str(excinfo.value)
# ------------------------------------------------------------ frame plan
def test_frame_plan_default_has_31_ordered_frames(paths: RevisionPaths) -> None:
frames = frame_plan(resolve_probe(paths))
assert len(frames) == 1 + 3 * 10 == 31
assert [frame.index for frame in frames] == list(range(31))
assert [frame.name for frame in frames] == [
f"{frame.index:03d}_" + (
"reference" if frame.axis is None else f"{frame.axis}_+{frame.step_mm:.2f}mm"
)
for frame in frames
]
assert frames[0] == ProbeFrame(0, "000_reference", None, 0.0)
assert frames[1] == ProbeFrame(1, "001_x_+0.01mm", "x", 0.01)
assert frames[10] == ProbeFrame(10, "010_x_+0.10mm", "x", 0.10)
assert frames[11] == ProbeFrame(11, "011_y_+0.01mm", "y", 0.01)
assert frames[20] == ProbeFrame(20, "020_y_+0.10mm", "y", 0.10)
assert frames[21] == ProbeFrame(21, "021_z_+0.01mm", "z", 0.01)
assert frames[30] == ProbeFrame(30, "030_z_+0.10mm", "z", 0.10)
def test_frame_plan_is_axis_major_then_step_minor(paths: RevisionPaths) -> None:
frames = frame_plan(resolve_probe(paths))
moved = frames[1:]
assert [frame.axis for frame in moved] == ["x"] * 10 + ["y"] * 10 + ["z"] * 10
for axis in ("x", "y", "z"):
steps = [frame.step_mm for frame in moved if frame.axis == axis]
assert steps == pytest.approx([0.01 * n for n in range(1, 11)])
def test_frame_plan_single_axis_and_custom_steps(paths: RevisionPaths) -> None:
settings = dataclasses.replace(
resolve_probe(paths), axes=("z",), step_mm=0.05, max_mm=0.2
)
frames = frame_plan(settings)
assert len(frames) == 1 + round(0.2 / 0.05) == 5
assert [frame.name for frame in frames] == [
"000_reference",
"001_z_+0.05mm",
"002_z_+0.10mm",
"003_z_+0.15mm",
"004_z_+0.20mm",
]
def test_frame_plan_never_exceeds_max_mm(paths: RevisionPaths) -> None:
settings = dataclasses.replace(
resolve_probe(paths), axes=("x",), step_mm=0.03, max_mm=0.10
)
frames = frame_plan(settings)
assert [frame.step_mm for frame in frames] == pytest.approx([0.0, 0.03, 0.06, 0.09])
assert all(frame.step_mm <= settings.max_mm for frame in frames)
def test_frame_plan_names_keep_sub_centistep_precision(paths: RevisionPaths) -> None:
settings = dataclasses.replace(
resolve_probe(paths), axes=("x",), step_mm=0.005, max_mm=0.01
)
assert [frame.name for frame in frame_plan(settings)] == [
"000_reference",
"001_x_+0.005mm",
"002_x_+0.010mm",
]
def test_frame_plan_to_job_entry() -> None:
frame = ProbeFrame(10, "010_x_+0.10mm", "x", 0.10)
assert frame.to_job_entry() == {
"index": 10,
"name": "010_x_+0.10mm",
"axis": "x",
"step_mm": 0.10,
}
@pytest.mark.parametrize(
"changes",
[
{"step_mm": 0.0},
{"max_mm": 0.0},
{"axes": ()},
],
)
def test_frame_plan_rejects_degenerate_settings(
paths: RevisionPaths, changes: dict
) -> None:
settings = dataclasses.replace(resolve_probe(paths), **changes)
with pytest.raises(ProbeConfigError):
frame_plan(settings)
# --------------------------------------------------------------- paths
def test_output_dir_is_revision_scoped(paths: RevisionPaths) -> None:
root = repo_root()
assert probe_revision_rel(root, paths).as_posix() == "finger-xynova/r01"
assert probe_output_dir(root, paths) == (
root / "build" / "probe" / "finger-xynova" / "r01"
).resolve()
def test_output_dir_explicit_override_wins(
paths: RevisionPaths, tmp_path: Path
) -> None:
explicit = tmp_path / "elsewhere"
assert resolve_probe_output_dir(repo_root(), paths, explicit) == explicit.resolve()
assert resolve_probe_output_dir(repo_root(), paths) == probe_output_dir(
repo_root(), paths
)
def test_output_dir_uses_directory_names_not_design_sensor_field(
tmp_path: Path,
) -> None:
revision = _write_revision(
tmp_path, sensor_field="finger", sensor_dir="finger-xynova", revision_name="r01"
)
paths = RevisionPaths.from_revision_dir(revision)
assert probe_revision_rel(tmp_path, paths) == Path("finger-xynova") / "r01"
def test_artifact_paths_and_job_path(tmp_path: Path) -> None:
artifacts = probe_artifact_paths(tmp_path / "out")
assert set(artifacts) == {"frames", "report"}
assert artifacts["frames"] == (tmp_path / "out" / FRAMES_NPZ_FILENAME).resolve()
assert artifacts["report"] == (tmp_path / "out" / DEFORM_REPORT_FILENAME).resolve()
assert deform_job_path(tmp_path / "out") == tmp_path / "out" / DEFORM_JOB_FILENAME
# --------------------------------------------------------------- job I/O
def test_job_round_trips_with_absolute_paths(
paths: RevisionPaths, tmp_path: Path
) -> None:
settings = resolve_probe(paths)
output_dir = tmp_path / "probe"
job = build_deform_job(paths, settings, output_dir)
assert job["schema"] == DEFORM_JOB_SCHEMA
assert set(job) == {
"schema",
"revision_dir",
"source_blend",
"output_dir",
"objects",
"settings",
"frames",
"artifacts",
}
assert Path(job["revision_dir"]).is_absolute()
assert Path(job["source_blend"]).is_absolute()
assert Path(job["output_dir"]).is_absolute()
assert job["objects"] == {"mesh": "sensing_surface", "group": "probe"}
assert job["settings"] == {
"radius_mm": 0.5,
"falloff": "smooth",
"step_mm": 0.01,
"max_mm": 0.10,
"axes": ["x", "y", "z"],
"pattern": {
"nx": 80,
"ny": 80,
"seed": 7,
"vector_width_mm": 18.5,
"vector_height_mm": 16.4,
"cell_mm": [0.23125, 0.205],
},
}
assert job["frames"] == [frame.to_job_entry() for frame in frame_plan(settings)]
assert len(job["frames"]) == 31
assert job["artifacts"] == {
"frames": str((output_dir / FRAMES_NPZ_FILENAME).resolve()),
"report": str((output_dir / DEFORM_REPORT_FILENAME).resolve()),
}
assert all(Path(path).is_absolute() for path in job["artifacts"].values())
job_path = write_deform_job(job, output_dir)
assert job_path == output_dir / DEFORM_JOB_FILENAME
loaded = json.loads(job_path.read_text(encoding="utf-8"))
assert loaded == job
assert loaded["frames"][10] == {
"index": 10,
"name": "010_x_+0.10mm",
"axis": "x",
"step_mm": 0.10,
}
def test_write_job_creates_output_directory(paths: RevisionPaths, tmp_path: Path) -> None:
settings = resolve_probe(paths)
nested = tmp_path / "a" / "b"
write_deform_job(build_deform_job(paths, settings, nested), nested)
assert (nested / DEFORM_JOB_FILENAME).is_file()
# -------------------------------------------------------- CLI overrides
def test_probe_overrides_from_args() -> None:
overrides = probe_overrides_from_args(
_probe_namespace(
mesh="m",
group="g",
radius_mm=0.25,
falloff="sharp",
step_mm=0.02,
max_mm=0.08,
axes="x, z",
)
)
assert overrides == {
"mesh": "m",
"group": "g",
"radius_mm": 0.25,
"falloff": "sharp",
"step_mm": 0.02,
"max_mm": 0.08,
"axes": ["x", "z"],
}
def test_probe_overrides_from_args_unset_stays_none() -> None:
overrides = probe_overrides_from_args(_probe_namespace())
assert overrides["axes"] is None
merged = merge_probe(None, overrides)
assert merged["radius_mm"] == DEFAULT_PROBE["radius_mm"]
assert merged["axes"] == ["x", "y", "z"]
def test_probe_overrides_from_args_accepts_axis_sequence() -> None:
overrides = probe_overrides_from_args(_probe_namespace(axes=["z", "y"]))
assert overrides["axes"] == ["z", "y"]
# ------------------------------------------------------ stage consumers
def test_stage_settings_blocks_match_the_contract(paths: RevisionPaths) -> None:
settings = resolve_probe(paths)
design = _design_probe(paths)
width, height = _as_pair(design["resolution"])
assert settings.to_render_settings(Path("textures") / "patch_texture_compute.png") == {
"uv_map": "compute",
"texture": str((Path("textures") / "patch_texture_compute.png").resolve()),
"width": width,
"height": height,
"engine": "CYCLES",
"samples": 16,
"denoise": False,
"view_transform": "Standard",
"interpolate": "Closest",
"seed": 0,
}
assert settings.to_track_settings() == {
"axes": ["x", "y", "z"],
"step_mm": 0.01,
"max_mm": 0.10,
"radius_mm": 0.5,
"process_resolution": list(_as_pair(design["process_resolution"])),
"tracker": dict(design["tracker"]),
# Additive: the render-seed floor is a reproducibility diagnostic, and this
# block is what lets the estimator measure signal and noise under one model.
"noise": {
"idle_footage": "../data/dd02-0703/190.mp4",
"roi_fraction": 0.18,
"max_frames": 120,
"sigmas": [4.0, 16.0],
"draws": 1,
"seed": 0,
"models": ["render_seed", "injected"],
"scales": ["render"],
},
}
+869
View File
@@ -0,0 +1,869 @@
"""Synthetic-image tests for the DIS optical-flow stage (no Blender required).
Every test here runs against constructed images: the estimator gate, the
reference-self check, the ROI geometry, the statistics and the job runner. The
one thing they cannot prove is how DIS behaves on the real Cycles renders -- that
is what the frozen ``known_answer`` gate in ``flow_report.json`` records for the
integration run.
"""
from __future__ import annotations
import hashlib
import json
import types
from pathlib import Path
import numpy as np
import pytest
from orisys_sensor_design import flow
RENDER = {"name": "render", "width": 500, "height": 500, "resample": False}
PROCESS = {"name": "process", "width": 250, "height": 250, "resample": True}
#: The probe.tracker block of the default design.yaml.
TRACKER = {"algorithm": "dis", "dis_preset": "medium", "dis_mode": 1}
# ---------------------------------------------------------------------------
# config / presets
# ---------------------------------------------------------------------------
def test_dis_preset_from_name_matches_sdk_mapping():
assert flow.dis_preset_from_name("ultrafast") == 0
assert flow.dis_preset_from_name("fast") == 1
assert flow.dis_preset_from_name("medium") == 2
# Mirrors DIS_no_integrate: unknown and None fall back to MEDIUM.
assert flow.dis_preset_from_name("FAST") == 1
assert flow.dis_preset_from_name(None) == 2
assert flow.dis_preset_from_name("bogus") == 2
def test_load_tracker_config_setdefaults_match_sdk():
config = flow.load_tracker_config({})
assert isinstance(config, flow.TrackerConfig)
assert dict(config) == {
"algorithm": "dis",
"dis_preset": "medium",
"dis_mode": 3,
"lower_threshold": 12.0,
"upper_threshold": 18.0,
"loop_closure_interval": 1,
}
# Attribute access, because the SDK reads config_tracker["dis_preset"].
assert config.dis_preset == "medium"
assert config.dis_mode == 3
assert config["algorithm"] == "dis"
def test_load_tracker_config_preserves_explicit_values():
config = flow.load_tracker_config({"dis_preset": "fast", "dis_mode": 1})
assert config["dis_preset"] == "fast"
assert config["dis_mode"] == 1
assert config["algorithm"] == "dis"
def test_load_tracker_config_unwraps_tracker_and_probe_blocks():
nested = flow.load_tracker_config({"tracker": {"dis_mode": 1, "dis_preset": "fast"}})
assert nested["dis_mode"] == 1 and nested["dis_preset"] == "fast"
design = flow.load_tracker_config(
{"probe": {"tracker": {"dis_mode": 1, "dis_preset": "fast"}}}
)
assert design["dis_mode"] == 1 and design["dis_preset"] == "fast"
def test_load_tracker_config_reads_json_and_yaml_files(tmp_path: Path):
json_path = tmp_path / "tracker.json"
json_path.write_text(json.dumps({"tracker": {"dis_mode": 1}}), encoding="utf-8")
assert flow.load_tracker_config(json_path)["dis_mode"] == 1
yaml_path = tmp_path / "design.yaml"
yaml_path.write_text("probe:\n tracker:\n dis_mode: 1\n dis_preset: fast\n", encoding="utf-8")
config = flow.load_tracker_config(str(yaml_path))
assert config["dis_mode"] == 1 and config["dis_preset"] == "fast"
def test_load_tracker_config_rejects_bad_sources(tmp_path: Path):
with pytest.raises(flow.FlowError, match="not found"):
flow.load_tracker_config(tmp_path / "nope.json")
with pytest.raises(flow.FlowError, match="mapping"):
flow.load_tracker_config(42) # type: ignore[arg-type]
with pytest.raises(flow.FlowError, match="only implements"):
flow.load_tracker_config({"algorithm": "raft"})
with pytest.raises(flow.FlowError, match="dis_mode"):
flow.load_tracker_config({"dis_mode": -1})
with pytest.raises(flow.FlowError, match="dis_mode"):
flow.load_tracker_config({"dis_mode": "1"})
# ---------------------------------------------------------------------------
# image helpers
# ---------------------------------------------------------------------------
def test_resample_shape_and_area_filter():
image = np.zeros((4, 4), dtype=np.uint8)
image[::2, ::2] = 255 # 2x2 blocks average to ~64 with INTER_AREA
out = flow.resample(image, (2, 2))
assert out.shape == (2, 2)
assert out.dtype == np.uint8
assert np.all((out > 0) & (out < 255)), f"INTER_NEAREST would give 0/255: {out.tolist()}"
assert np.allclose(out, 64, atol=2)
big = flow.resample(image, (8, 8))
assert big.shape == (8, 8)
with pytest.raises(flow.FlowError, match="positive"):
flow.resample(image, (0, 4))
def test_load_gray_roundtrip(tmp_path: Path):
cv2 = pytest.importorskip("cv2")
rng = np.random.default_rng(0)
image = rng.integers(0, 256, (32, 48), dtype=np.uint8)
path = tmp_path / "frame.png"
assert cv2.imwrite(str(path), image)
loaded = flow.load_gray(path)
assert loaded.shape == (32, 48)
assert np.array_equal(loaded, image)
with pytest.raises(flow.FlowError, match="cannot read"):
flow.load_gray(tmp_path / "missing.png")
def test_missing_cv2_raises_install_hint(monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(flow, "cv2", None)
with pytest.raises(ImportError, match=r"pip install -e \.\[probe\]"):
flow.load_gray("whatever.png")
with pytest.raises(ImportError, match=r"pip install -e \.\[probe\]"):
flow.FixedReferenceFlow(TRACKER)
with pytest.raises(ImportError, match=r"pip install -e \.\[probe\]"):
flow.dis_preset_from_name("medium")
# ---------------------------------------------------------------------------
# ROI geometry
# ---------------------------------------------------------------------------
def test_region_masks_disc_and_control_ring():
roi, control = flow.region_masks((101, 101), (50.0, 50.0), 10.0, 2.0)
# Gauss-circle lattice counts, independent of this implementation:
# |{p in Z^2 : |p| <= 10}| = 317, |{p : 10 < |p| <= 20}| = 1257 - 317 = 940.
assert int(roi.sum()) == 317
assert int(control.sum()) == 940
assert not (roi & control).any()
assert roi[50, 50]
assert roi[50, 60] and not control[50, 60] # exactly on the ROI boundary
assert control[50, 65] and not roi[50, 65] # d = 15, inside the ring
assert not roi[50, 75] and not control[50, 75] # d = 25, outside both
assert roi.dtype == bool and control.dtype == bool
def test_region_masks_rejects_bad_geometry():
with pytest.raises(flow.FlowError, match="radius"):
flow.region_masks((101, 101), (50.0, 50.0), 0.0)
with pytest.raises(flow.FlowError, match="control_scale"):
flow.region_masks((101, 101), (50.0, 50.0), 10.0, 1.0)
with pytest.raises(flow.FlowError, match="finite"):
flow.region_masks((101, 101), (np.nan, 50.0), 10.0)
def test_region_masks_truncate_at_the_frame_border():
roi, _ = flow.region_masks((51, 51), (0.0, 0.0), 10.0)
assert roi[0, 0]
assert int(roi.sum()) < flow.expected_disc_pixels(10.0) / 2
# Quarter disc: by 4-fold symmetry 317 = 4*Q + 4*10 axis points + 1 origin
# with Q = 69 interior points, so the x >= 0, y >= 0 quarter holds 90.
assert int(roi.sum()) == 90
# ---------------------------------------------------------------------------
# flow statistics
# ---------------------------------------------------------------------------
def test_flow_stats_on_constant_flow():
height = width = 41
flow_field = np.zeros((height, width, 2), dtype=np.float32)
flow_field[:, :, 0] = 3.0
flow_field[:, :, 1] = -4.0
roi, _ = flow.region_masks((height, width), (20.0, 20.0), 8.0)
stats = flow.flow_stats(flow_field, roi)
assert stats["mean_u"] == pytest.approx(3.0)
assert stats["mean_v"] == pytest.approx(-4.0)
assert stats["mean_mag"] == pytest.approx(5.0)
assert stats["p50_mag"] == pytest.approx(5.0)
assert stats["p95_mag"] == pytest.approx(5.0)
assert stats["max_mag"] == pytest.approx(5.0)
assert stats["coverage"] == 1.0
def test_flow_stats_coverage_counts_nonzero_pixels():
flow_field = np.zeros((21, 21, 2), dtype=np.float32)
flow_field[0, 0, 0] = 1.0 # outside the ROI
roi, _ = flow.region_masks((21, 21), (10.0, 10.0), 3.0)
stats = flow.flow_stats(flow_field, roi)
assert stats["coverage"] == 0.0
assert stats["max_mag"] == 0.0
flow_field[10, 10, 1] = 2.0
assert flow.flow_stats(flow_field, roi)["coverage"] == pytest.approx(1.0 / int(roi.sum()))
def test_flow_stats_rejects_bad_input():
roi, _ = flow.region_masks((21, 21), (10.0, 10.0), 3.0)
with pytest.raises(flow.FlowError, match="mask selects no pixels"):
flow.flow_stats(np.zeros((21, 21, 2), np.float32), roi & False)
with pytest.raises(flow.FlowError, match="H, W, 2"):
flow.flow_stats(np.zeros((21, 21), np.float32), roi)
bad = np.zeros((21, 21, 2), dtype=np.float32)
bad[10, 10, 0] = np.nan
with pytest.raises(flow.FlowError, match="non-finite"):
flow.flow_stats(bad, roi)
with pytest.raises(flow.FlowError, match="mask shape"):
flow.flow_stats(np.zeros((21, 21, 2), np.float32), np.ones((5, 5), bool))
# ---------------------------------------------------------------------------
# forward/backward residual (Tracker.__getForwardBackwardError port)
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def texture_200():
return flow.synthetic_texture(200, 200, seed=11)
def test_forward_backward_error_is_zero_for_identity(texture_200):
height, width = texture_200.shape
flow_field = np.zeros((height, width, 2), dtype=np.float32)
assert flow.forward_backward_error(texture_200, texture_200, flow_field) == 0.0
def test_forward_backward_error_grows_with_a_wrong_flow(texture_200):
height, width = texture_200.shape
shifted = flow.known_answer_shift(texture_200, 3, -2)
truth = np.zeros((height, width, 2), dtype=np.float32)
truth[:, :, 0] = 3.0
truth[:, :, 1] = -2.0
wrong = truth.copy()
wrong[:, :, 0] += 10.0
mask = np.zeros((height, width), dtype=bool)
mask[10:-10, 10:-10] = True
good = flow.forward_backward_error(texture_200, shifted, truth, mask)
bad = flow.forward_backward_error(texture_200, shifted, wrong, mask)
assert good < 5.0, f"a correct flow should leave almost no residual, got {good}"
assert bad > 5.0 * max(good, 1e-6), f"wrong flow residual {bad} vs {good}"
with pytest.raises(flow.FlowError, match="does not match"):
flow.forward_backward_error(texture_200, shifted, truth[:50, :50], mask)
def test_known_answer_shift_is_exact_for_integer_shifts(texture_200):
shifted = flow.known_answer_shift(texture_200, 2, 0)
assert shifted.shape == texture_200.shape
assert np.array_equal(shifted[:, 2:], texture_200[:, :-2])
assert np.array_equal(shifted[:, :2], texture_200[:, :1].repeat(2, axis=1))
# ---------------------------------------------------------------------------
# the estimator itself
# ---------------------------------------------------------------------------
def test_reference_self_is_exactly_zero():
estimator = flow.FixedReferenceFlow(TRACKER)
image = flow.synthetic_texture(160, 160, seed=2)
estimator.set_reference(image)
self_flow = estimator.compute(image)
assert self_flow.shape == (160, 160, 2)
assert self_flow.dtype == np.float32
assert float(np.abs(self_flow).max()) == 0.0
def test_compute_without_a_reference_behaves_like_fixed_ref_dis():
estimator = flow.FixedReferenceFlow(TRACKER)
image = flow.synthetic_texture(64, 64, seed=3)
first = estimator.compute(image) # becomes the reference, returns zeros
assert float(np.abs(first).max()) == 0.0
assert np.array_equal(estimator.reference, image)
with pytest.raises(flow.FlowError, match="does not match the fixed reference"):
estimator.compute(np.zeros((32, 32), np.uint8))
def test_patch_size_is_measured_from_the_dis_instance():
estimator = flow.FixedReferenceFlow(TRACKER)
assert estimator.patch_px == 8
assert estimator.finest_scale == 1
assert estimator.preset_name == "medium"
def test_known_answer_recovers_an_integer_shift_at_both_scales():
block = flow.validate_estimator(TRACKER, scales=[RENDER, PROCESS])
assert block["passed"] is True
assert block["tol"] == flow.DEFAULT_KNOWN_ANSWER_TOL_PX
assert [(case["dx"], case["dy"]) for case in block["cases"]] == [
(2, 0),
(0, 2),
(1, 0),
(0, 1),
]
for case in block["cases"]:
assert set(case) == {"dx", "dy", "measured_u", "measured_v", "tol"}
assert abs(case["measured_u"] - case["dx"]) <= case["tol"]
assert abs(case["measured_v"] - case["dy"]) <= case["tol"]
def test_known_answer_gate_rejects_a_wrong_expectation():
estimator = flow.FixedReferenceFlow(TRACKER)
reference = flow.synthetic_texture(200, 200, seed=4)
# Applied 2 px, but the gate is told to expect 5 px: it must refuse.
with pytest.raises(flow.FlowError, match="known-answer gate FAILED"):
flow.measure_known_answer(estimator, reference, 2, 0, expect=(5.0, 0.0))
# And it must accept the truth.
case = flow.measure_known_answer(estimator, reference, 2, 0)
assert abs(case["measured_u"] - 2.0) <= case["tol"]
class _StubEstimator:
"""Injectable estimator: returns a constant flow vector for every frame."""
patch_px = 8
def __init__(self, u: float, v: float) -> None:
self.u, self.v = float(u), float(v)
self._reference = None
def set_reference(self, gray: np.ndarray) -> np.ndarray:
self._reference = np.asarray(gray).copy()
return self._reference
def compute(self, gray: np.ndarray) -> np.ndarray:
shape = (*np.asarray(gray).shape[:2], 2)
return np.stack(
[np.full(shape[:2], self.u, np.float32), np.full(shape[:2], self.v, np.float32)],
axis=-1,
)
def test_known_answer_gate_rejects_a_broken_estimator(monkeypatch: pytest.MonkeyPatch):
# A stub that always reports zero flow cannot recover a 2 px shift.
monkeypatch.setattr(flow, "FixedReferenceFlow", lambda config=None: _StubEstimator(0.0, 0.0))
with pytest.raises(flow.FlowError, match="known-answer gate FAILED") as excinfo:
flow.validate_estimator(TRACKER, scales=[RENDER])
assert excinfo.value.report["passed"] is False
assert excinfo.value.report["cases"][0]["measured_u"] == 0.0
def test_known_answer_gate_accepts_a_correct_stub():
# The gate is not vacuous: an estimator that answers exactly right passes.
stub = _StubEstimator(2.0, 0.0)
case = flow.measure_known_answer(stub, flow.synthetic_texture(200, 200, seed=4), 2, 0)
assert case["measured_u"] == 2.0 and case["measured_v"] == 0.0
def test_reference_self_helper_rejects_nonzero_flow():
with pytest.raises(flow.FlowError, match="reference_self is not zero"):
flow._verify_reference_self(_StubEstimator(0.5, 0.0), np.zeros((16, 16), np.uint8), "render")
assert flow._verify_reference_self(_StubEstimator(0.0, 0.0), np.zeros((16, 16), np.uint8), "render") == 0.0
def test_evaluate_known_answer_scales_shifts_and_keeps_zero_components():
block, problems = flow.evaluate_known_answer([RENDER, PROCESS], TRACKER)
assert problems == []
assert [(case["dx"], case["dy"]) for case in block["cases"]] == [
(2, 0),
(0, 2),
(1, 0),
(0, 1),
]
# ---------------------------------------------------------------------------
# strategy / direction reading
# ---------------------------------------------------------------------------
def test_load_strategy_directions_accepts_contract_shapes(tmp_path: Path):
path = tmp_path / "strategy_probe.json"
path.write_text(
json.dumps({"axes": {"x": {"direction_px": [3.0, 4.0]}, "y": {"direction_px": [0, 2]}}}),
encoding="utf-8",
)
directions = flow.load_strategy_directions(path)
assert directions["x"] == pytest.approx((0.6, 0.8))
assert directions["y"] == pytest.approx((0.0, 1.0))
path.write_text(
json.dumps({"axes": [{"axis": "z", "unit_px": [0.0, -5.0]}]}), encoding="utf-8"
)
assert flow.load_strategy_directions(path)["z"] == pytest.approx((0.0, -1.0))
assert flow.load_strategy_directions(tmp_path / "absent.json") == {}
def test_directions_from_manifest_centroid_finite_difference():
manifest = {
"reference_index": 0,
"frames": [
{"index": 0, "axis": None, "step_mm": 0.0, "centroid_px": [250.0, 250.0]},
{"index": 1, "axis": "x", "step_mm": 0.01, "centroid_px": [250.01, 250.0]},
{"index": 2, "axis": "x", "step_mm": 0.10, "centroid_px": [250.13, 250.0]},
{"index": 3, "axis": "y", "step_mm": 0.10, "centroid_px": [250.0, 249.87]},
],
}
directions = flow.directions_from_manifest(manifest)
assert directions["x"] == pytest.approx((1.0, 0.0))
assert directions["y"] == pytest.approx((0.0, -1.0))
assert flow.directions_from_manifest({"frames": []}) == {}
# ---------------------------------------------------------------------------
# projection helpers (fallbacks; the render manifest is preferred)
# ---------------------------------------------------------------------------
def test_project_point_and_px_per_mm_at():
intrinsics = {"K": np.array([[500.0, 0, 500.0], [0, 500.0, 500.0], [0, 0, 1.0]]),
"width": 1000, "height": 1000}
scale = np.eye(4)
centre = flow.project_point(intrinsics, scale, (0.0, 0.0, 10.0), size=(500, 500))
assert centre == pytest.approx((249.5, 249.5), abs=1e-6)
px_per_mm = flow.px_per_mm_at(
intrinsics, scale, (0.0, 0.0, 10.0), (1.0, 0.0, 0.0), size=(500, 500)
)
assert px_per_mm == pytest.approx(25.0) # 500 px focal / 10 mm depth, halved by the resize
with pytest.raises(flow.FlowError, match="non-zero vector"):
flow.px_per_mm_at(intrinsics, scale, (0, 0, 10), (0, 0, 0), size=(500, 500))
# ---------------------------------------------------------------------------
# job building
# ---------------------------------------------------------------------------
def test_build_track_job_from_mapping_and_object(tmp_path: Path):
settings = {
"axes": ("x", "y", "z"),
"step_mm": 0.01,
"max_mm": 0.10,
"radius_mm": 0.5,
"resolution": (500, 500),
"process_resolution": (250, 250),
"tracker": dict(TRACKER),
}
job = flow.build_track_job(settings, tmp_path)
assert job["schema"] == flow.TRACK_JOB_SCHEMA
assert job["settings"]["axes"] == ["x", "y", "z"]
assert job["settings"]["tracker"]["dis_mode"] == 1
assert [scale["name"] for scale in job["scales"]] == ["render", "process"]
assert job["scales"][1]["resample"] is True
assert job["artifacts"]["manifest"] == str(tmp_path / flow.RENDER_MANIFEST_FILENAME)
assert job["artifacts"]["strategy"] == str(tmp_path / flow.STRATEGY_FILENAME)
namespace = types.SimpleNamespace(**settings)
assert flow.build_track_job(namespace, tmp_path) == job
assert flow.build_track_job(settings, tmp_path, include_process_scale=False)["scales"] == [
RENDER
]
same = dict(settings, process_resolution=(500, 500))
assert len(flow.build_track_job(same, tmp_path)["scales"]) == 1
with pytest.raises(flow.FlowError, match="resolution"):
flow.build_track_job({"resolution": (500, 500, 1)}, tmp_path)
def test_write_track_job_roundtrip(tmp_path: Path):
job = flow.build_track_job(
{"resolution": (500, 500), "process_resolution": (250, 250), "tracker": dict(TRACKER)},
tmp_path,
)
path = flow.write_track_job(job, tmp_path)
assert path.name == flow.TRACK_JOB_FILENAME
assert json.loads(path.read_text(encoding="utf-8")) == job
# ---------------------------------------------------------------------------
# run_track_job end to end on synthetic frames
# ---------------------------------------------------------------------------
def _sha256(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def _write_scene(root: Path, base: np.ndarray | None = None) -> Path:
"""Write synthetic frames + a contract-shaped render_manifest.json."""
cv2 = pytest.importorskip("cv2")
frames_dir = root / "frames"
frames_dir.mkdir(parents=True, exist_ok=True)
if base is None:
base = flow.synthetic_texture(500, 500, seed=7)
# 13.43 px/mm: 0.01 mm = 0.1343 px, 0.10 mm = 1.343 px.
shifts = {
"000_reference": (0.0, 0.0),
"001_x_+0.01mm": (0.1343, 0.0),
"002_x_+0.10mm": (1.343, 0.0),
"003_y_+0.10mm": (0.0, 1.343),
}
entries = []
for index, (name, (dx, dy)) in enumerate(shifts.items()):
image = base if (dx, dy) == (0.0, 0.0) else flow.known_answer_shift(base, dx, dy)
path = frames_dir / f"{name}.png"
assert cv2.imwrite(str(path), image)
axis = None if index == 0 else ("x" if "x_" in name else "y")
step = 0.0 if index == 0 else (0.01 if "0.01" in name else 0.10)
entries.append(
{
"index": index,
"name": name,
"axis": axis,
"step_mm": step,
"png": f"frames/{name}.png",
"sha256": _sha256(path),
"width": 500,
"height": 500,
"centroid_px": [250.0 + dx, 250.0 + dy],
}
)
rng = np.random.default_rng(21)
null = np.clip(
base.astype(np.int16) + rng.integers(-2, 3, base.shape, dtype=np.int16), 0, 255
).astype(np.uint8)
null_path = frames_dir / "reference_null.png"
assert cv2.imwrite(str(null_path), null)
manifest = {
"schema": flow.RENDER_MANIFEST_SCHEMA,
"width": 500,
"height": 500,
"reference_index": 0,
"probe_px_per_mm": {"p50": 13.43, "min": 10.499, "max": 38.594},
"probe_roi_px": {"u": 250.0, "v": 250.0, "radius": 20.0},
"frames": entries,
"null": {
"name": "reference_null",
"seed": 1,
"png": "frames/reference_null.png",
"sha256": _sha256(null_path),
},
}
manifest_path = root / flow.RENDER_MANIFEST_FILENAME
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
return manifest_path
def _synthetic_job(root: Path) -> dict:
settings = {
"axes": ["x", "y"],
"step_mm": 0.10,
"max_mm": 0.10,
"radius_mm": 0.5,
"resolution": (500, 500),
"process_resolution": (250, 250),
"tracker": dict(TRACKER),
}
return flow.build_track_job(settings, root)
@pytest.fixture(scope="module")
def scene(tmp_path_factory: pytest.TempPathFactory) -> tuple[Path, dict]:
root = tmp_path_factory.mktemp("scene")
manifest_path = _write_scene(root)
job = _synthetic_job(root)
strategy = root / flow.STRATEGY_FILENAME
strategy.write_text(
json.dumps({"axes": {"x": {"direction_px": [1.0, 0.0]}, "y": {"direction_px": [0.0, 1.0]}}}),
encoding="utf-8",
)
report, code = flow.run_track_job(job, root, progress=None)
assert code == flow.EXIT_OK, report
return root, report
def test_run_track_job_report_matches_the_contract(scene):
root, report = scene
assert report["schema"] == flow.FLOW_REPORT_SCHEMA
assert (root / flow.FLOW_REPORT_FILENAME).is_file()
tracker = report["tracker"]
assert tracker["algorithm"] == "dis"
assert tracker["dis_preset"] == "medium"
assert tracker["dis_mode"] == 1
assert tracker["finest_scale"] == 1
assert tracker["patch_px"] == 8
assert report["known_answer"]["passed"] is True
assert report["known_answer"]["cases"]
assert report["reference_self"] == {"max_abs_flow_px": 0.0}
diagnostic = report["reference_texture_known_answer"]
assert diagnostic["available"] is True
assert diagnostic["gating"] is False
assert diagnostic["scale"] == "render"
assert [(case["dx"], case["dy"]) for case in diagnostic["cases"]] == [(2, 0), (0, 2)]
for case in diagnostic["cases"]:
assert set(case) >= {"dx", "dy", "measured_u", "measured_v", "tol", "passed"}
assert [scale["name"] for scale in report["scales"]] == ["render", "process"]
def test_run_track_job_measures_response_and_floor(scene):
_, report = scene
by_name = {scale["name"]: scale for scale in report["scales"]}
render = by_name["render"]
assert render["width"] == 500 and render["resample"] is False
assert render["px_per_mm"] == pytest.approx(13.43)
assert render["mm_per_px"] == pytest.approx(1.0 / 13.43)
assert render["px_per_mm_source"] == "render_manifest.probe_px_per_mm.p50"
assert render["probe_px_per_mm_manifest"] == {"p50": 13.43, "min": 10.499, "max": 38.594}
assert render["null"]["floor_source"] == "reference_null"
assert 0.0 < render["null"]["floor_px"] < 0.5
frames = {frame["name"]: frame for frame in render["frames"]}
assert set(frames) == {"001_x_+0.01mm", "002_x_+0.10mm", "003_y_+0.10mm"}
max_step = frames["002_x_+0.10mm"]
assert max_step["roi"] == {"u": 250.0, "v": 250.0, "radius": 20.0, "pixels": int(
flow.region_masks((500, 500), (250.0, 250.0), 20.0)[0].sum()
)}
# 1.343 px of applied shift at 13.43 px/mm -> 0.1 mm of response.
assert max_step["response_proj_px"] == pytest.approx(1.343, abs=0.2)
assert max_step["response_mm"] == pytest.approx(0.10, abs=0.015)
assert max_step["forward_backward_error"] < 20.0
# The 0.01 mm step is 0.134 px: it must still come out positive but small.
small = frames["001_x_+0.01mm"]
assert small["response_proj_px"] == pytest.approx(0.1343, abs=0.2)
assert small["response_proj_px"] < max_step["response_proj_px"]
# y response is orthogonal to x.
assert frames["003_y_+0.10mm"]["response_proj_px"] == pytest.approx(1.343, abs=0.2)
# The ROI is fixed at the reference centroid for every frame in a scale.
for frame in render["frames"]:
assert frame["roi"]["u"] == 250.0 and frame["roi"]["v"] == 250.0
assert set(frame["flow"]) == {
"mean_u", "mean_v", "mean_mag", "p50_mag", "p95_mag", "max_mag", "coverage",
}
process = by_name["process"]
assert process["width"] == 250 and process["resample"] is True
assert process["px_per_mm"] == pytest.approx(6.715)
assert process["frames"][0]["roi"]["u"] == pytest.approx(124.75)
assert process["frames"][0]["roi"]["v"] == pytest.approx(124.75)
process_max = {frame["name"]: frame for frame in process["frames"]}["002_x_+0.10mm"]
assert process_max["response_mm"] == pytest.approx(0.10, abs=0.015)
# The synthetic manifest carries 3 displaced frames while the job settings
# imply 2; the manifest wins and the mismatch is reported honestly.
assert any("displaced frames" in warning for warning in report["warnings"])
def test_run_track_job_writes_float32_flow_arrays(scene):
root, report = scene
reference = np.load(root / flow.FLOW_DIRNAME / "render" / "000_reference.npy")
assert reference.shape == (500, 500, 2)
assert reference.dtype == np.float32
assert float(np.abs(reference).max()) == 0.0
null = np.load(root / flow.FLOW_DIRNAME / "render" / "reference_null.npy")
assert null.shape == (500, 500, 2) and float(np.abs(null).max()) > 0.0
frame = np.load(root / flow.FLOW_DIRNAME / "render" / "002_x_+0.10mm.npy")
assert frame.dtype == np.float32 and frame.shape == (500, 500, 2)
small = np.load(root / flow.FLOW_DIRNAME / "process" / "003_y_+0.10mm.npy")
assert small.shape == (250, 250, 2)
assert report["response_direction_source"] == "strategy_probe.json"
def test_run_track_job_uses_manifest_centroids_when_strategy_is_absent(scene):
root, _ = scene
(root / flow.STRATEGY_FILENAME).unlink()
job = _synthetic_job(root)
report, code = flow.run_track_job(job, root, progress=None)
assert code == flow.EXIT_OK
assert report["response_direction_source"] == "render_manifest.frames[].centroid_px"
assert report["reference_self"]["max_abs_flow_px"] == 0.0
# Regenerate the strategy file for any later test that shares the scene.
(root / flow.STRATEGY_FILENAME).write_text(
json.dumps({"axes": {"x": {"direction_px": [1.0, 0.0]}, "y": {"direction_px": [0.0, 1.0]}}}),
encoding="utf-8",
)
def test_run_track_job_missing_manifest_is_exit_3(scene):
root, _ = scene
job = _synthetic_job(root)
job["artifacts"]["manifest"] = str(root / "nope.json")
report, code = flow.run_track_job(job, root, progress=None)
assert code == flow.EXIT_MISSING_ARTIFACT and report == {}
def test_run_track_job_missing_frame_is_exit_3(scene, tmp_path: Path):
root, _ = scene
manifest = json.loads((root / flow.RENDER_MANIFEST_FILENAME).read_text(encoding="utf-8"))
manifest["frames"][2]["png"] = "frames/does_not_exist.png"
broken = tmp_path / "broken"
broken.mkdir()
(broken / flow.RENDER_MANIFEST_FILENAME).write_text(json.dumps(manifest), encoding="utf-8")
job = _synthetic_job(broken)
report, code = flow.run_track_job(job, broken, progress=None)
assert code == flow.EXIT_MISSING_ARTIFACT and report == {}
def test_run_track_job_reference_self_failure_is_exit_1(scene, monkeypatch: pytest.MonkeyPatch):
root, _ = scene
job = _synthetic_job(root)
def _boom(estimator, reference, scale_name):
raise flow.FlowError(f"reference_self is not zero at scale {scale_name}: max |flow| = 0.5 px")
monkeypatch.setattr(flow, "_verify_reference_self", _boom)
report, code = flow.run_track_job(job, root, progress=None)
assert code == flow.EXIT_SCENE_ERROR
assert report == {}
def test_run_track_job_failed_known_answer_is_exit_1(scene, monkeypatch: pytest.MonkeyPatch):
root, _ = scene
job = _synthetic_job(root)
monkeypatch.setattr(flow, "FixedReferenceFlow", lambda config=None: _StubEstimator(0.0, 0.0))
report, code = flow.run_track_job(job, root, progress=None)
assert code == flow.EXIT_SCENE_ERROR
assert report["known_answer"]["passed"] is False
assert report["known_answer"]["cases"][0]["measured_u"] == 0.0
def test_run_track_job_without_cv2_is_exit_2(scene, monkeypatch: pytest.MonkeyPatch):
root, _ = scene
job = _synthetic_job(root)
monkeypatch.setattr(flow, "cv2", None)
report, code = flow.run_track_job(job, root, progress=None)
assert code == flow.EXIT_CONFIG_ERROR and report == {}
def test_run_track_job_rejects_a_bad_schema(scene):
root, _ = scene
job = _synthetic_job(root)
job["schema"] = "something-else"
report, code = flow.run_track_job(job, root, progress=None)
assert code == flow.EXIT_CONFIG_ERROR and report == {}
def test_roi_truncation_warns_and_truncates(scene, tmp_path: Path):
root, _ = scene
manifest = json.loads((root / flow.RENDER_MANIFEST_FILENAME).read_text(encoding="utf-8"))
manifest["probe_roi_px"] = {"u": 5.0, "v": 250.0, "radius": 20.0}
shifted = tmp_path / "truncated"
shifted.mkdir()
(shifted / flow.RENDER_MANIFEST_FILENAME).write_text(json.dumps(manifest), encoding="utf-8")
for name in ("frames",):
(shifted / name).mkdir(exist_ok=True)
for entry in manifest["frames"]:
source = root / entry["png"]
target = shifted / entry["png"]
target.write_bytes(source.read_bytes())
target = shifted / manifest["null"]["png"]
target.write_bytes((root / manifest["null"]["png"]).read_bytes())
job = _synthetic_job(shifted)
report, code = flow.run_track_job(job, shifted, progress=None)
assert code == flow.EXIT_OK
render = report["scales"][0]
assert render["frames"][0]["roi"]["pixels"] < flow.expected_disc_pixels(20.0)
assert any("truncated" in warning for warning in report["warnings"])
def test_px_per_mm_falls_back_and_is_labelled(tmp_path: Path):
root = tmp_path / "noscale"
manifest_path = _write_scene(root)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
del manifest["probe_px_per_mm"]
manifest["probe_roi_px"] = {"u": 250.0, "v": 250.0, "radius": 20.0}
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
report, code = flow.run_track_job(_synthetic_job(root), root, progress=None)
assert code == flow.EXIT_OK
scale = report["scales"][0]
# radius 20 px / 0.5 mm = 40 px/mm, labelled as the fallback it is.
assert scale["px_per_mm"] == pytest.approx(40.0)
assert scale["px_per_mm_source"] == (
"render_manifest.probe_roi_px.radius / settings.radius_mm"
)
assert scale["probe_px_per_mm_manifest"] is None
def _checker(size: int = 500, cell: float = 3.11) -> np.ndarray:
"""Periodic marker-like pattern: the real nx=80 cell is ~3.11 px at 500x500."""
rows, cols = np.mgrid[0:size, 0:size]
return (((cols // cell) + (rows // cell)) % 2 * 200 + 30).astype(np.uint8)
def test_reference_texture_diagnostic_reports_periodic_aliasing(tmp_path: Path):
"""The gate must still pass on a periodic scene, but say so honestly.
A 3.11 px checker has a 6.22 px period, so a 2 px shift aliases onto the
wrong period. The synthetic known-answer gate tests the estimator itself
and must still pass; the reference-texture diagnostic must report the
aliasing instead of hiding it.
"""
root = tmp_path / "periodic"
_write_scene(root, base=_checker())
report, code = flow.run_track_job(_synthetic_job(root), root, progress=None)
assert code == flow.EXIT_OK
assert report["known_answer"]["passed"] is True
diagnostic = report["reference_texture_known_answer"]
assert diagnostic["available"] is True
assert not all(case["passed"] for case in diagnostic["cases"])
aliased = [case for case in diagnostic["cases"] if not case["passed"]][0]
expected = aliased["dx"] if aliased["dx"] else aliased["dy"]
measured = aliased["measured_u"] if aliased["dx"] else aliased["measured_v"]
assert abs(measured - expected) > aliased["tol"]
warning = [w for w in report["warnings"] if "periodic" in w][0]
assert "applied" in warning and "measured" in warning and "marker period" in warning
def test_px_per_mm_prefers_probe_local_and_labels_mesh_wide_fallback(tmp_path: Path):
# Probe-local block with per-axis values and no summary key: the transverse
# (x, y) mean is used and the label says so.
root = tmp_path / "peraxis"
manifest_path = _write_scene(root)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["probe_px_per_mm"] = {"axes": {"x": 13.0, "y": 17.0, "z": 1.5}}
manifest["mesh_px_per_mm"] = {"p50": 13.43}
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
report, code = flow.run_track_job(_synthetic_job(root), root, progress=None)
assert code == flow.EXIT_OK
scale = report["scales"][0]
assert scale["px_per_mm"] == pytest.approx(15.0)
assert scale["px_per_mm_source"] == "render_manifest.probe_px_per_mm.axes(x,y mean)"
assert scale["probe_px_per_mm_manifest"] == {"axes": {"x": 13.0, "y": 17.0, "z": 1.5}}
# Only the mesh-wide statistic exists: use it, but label it as mesh-wide.
root2 = tmp_path / "meshonly"
manifest_path2 = _write_scene(root2)
manifest2 = json.loads(manifest_path2.read_text(encoding="utf-8"))
del manifest2["probe_px_per_mm"]
manifest2["mesh_px_per_mm"] = {"p50": 13.43}
manifest_path2.write_text(json.dumps(manifest2), encoding="utf-8")
report2, code2 = flow.run_track_job(_synthetic_job(root2), root2, progress=None)
assert code2 == flow.EXIT_OK
scale2 = report2["scales"][0]
assert scale2["px_per_mm"] == pytest.approx(13.43)
assert "MESH-WIDE" in scale2["px_per_mm_source"]
def test_missing_null_frame_warns_and_reports_no_floor(tmp_path: Path):
root = tmp_path / "nonull"
manifest_path = _write_scene(root)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
del manifest["null"]
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
report, code = flow.run_track_job(_synthetic_job(root), root, progress=None)
assert code == flow.EXIT_OK
assert report["scales"][0]["null"]["floor_px"] is None
assert report["scales"][0]["null"]["floor_source"] == "unavailable"
# The basis label is what keeps the render-seed floor from being read as a
# sensor-noise model after the reclassification.
assert report["scales"][0]["null"]["floor_basis"] == "render_seed_reproducibility"
assert any("declares no null frame" in warning for warning in report["warnings"])
assert not (root / flow.FLOW_DIRNAME / "render" / "reference_null.npy").exists()
def test_roi_outside_the_frame_fails_loudly(tmp_path: Path):
root = tmp_path / "offscreen"
manifest_path = _write_scene(root)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["probe_roi_px"] = {"u": -200.0, "v": -200.0, "radius": 5.0}
manifest_path.write_text(json.dumps(manifest), encoding="utf-8")
report, code = flow.run_track_job(_synthetic_job(root), root, progress=None)
assert code == flow.EXIT_SCENE_ERROR
assert report == {}
assert not (root / flow.FLOW_REPORT_FILENAME).exists()
+399
View File
@@ -0,0 +1,399 @@
"""Tests for the noise model: real idle calibration, injected noise, retention.
These cover the correction that matters most in this bench. The render-seed floor was
being used as a noise model, its ordering reverses under additive noise, and it
understates the real idle floor measured from hardware. Everything here needs only
numpy and cv2 — no Blender — so the noise path is testable on its own.
"""
from __future__ import annotations
import json
from pathlib import Path
from types import SimpleNamespace
import numpy as np
import pytest
from orisys_sensor_design import compare, floorcal, flow, sensitivity
cv2 = pytest.importorskip("cv2")
def _pattern(size: int = 96, cell: int = 8, seed: int = 3) -> np.ndarray:
rng = np.random.RandomState(seed)
grid = rng.rand(size // cell + 1, size // cell + 1) * 255.0
return cv2.resize(grid, (size, size), interpolation=cv2.INTER_NEAREST).astype(np.uint8)
def _write_frames(directory: Path, frames: list[np.ndarray]) -> Path:
directory.mkdir(parents=True, exist_ok=True)
for index, frame in enumerate(frames):
assert cv2.imwrite(str(directory / f"{index:04d}.png"), frame)
return directory
def _noisy_clip(directory: Path, sigma: float, count: int = 8, seed: int = 11) -> Path:
base = _pattern()
rng = np.random.RandomState(seed)
frames = []
for _ in range(count):
noisy = base.astype(np.float32) + rng.normal(0.0, sigma, base.shape)
frames.append(np.clip(noisy, 0, 255).astype(np.uint8))
return _write_frames(directory, frames)
# --------------------------------------------------------------------------- #
# Real idle calibration
# --------------------------------------------------------------------------- #
def test_idle_floor_grows_with_noise(tmp_path: Path) -> None:
quiet = floorcal.measure_idle_floor(_noisy_clip(tmp_path / "q", 1.0), max_frames=8)
loud = floorcal.measure_idle_floor(_noisy_clip(tmp_path / "l", 8.0), max_frames=8)
assert quiet.floor_px_short >= 0.0
assert loud.floor_px_short > quiet.floor_px_short
assert quiet.frames == 8
def test_idle_floor_reports_a_dominant_period(tmp_path: Path) -> None:
measured = floorcal.measure_idle_floor(_noisy_clip(tmp_path / "p", 2.0), max_frames=8)
assert measured.dominant_period_px > 0
assert measured.floor_per_period_short > 0
payload = measured.to_json()
assert payload["roi"]["pixels"] > 0
assert "short = consecutive frames" in payload["basis"]
def test_drift_shows_as_a_long_over_short_ratio(tmp_path: Path) -> None:
"""A drifting scene must show long > short; white noise must not."""
base = _pattern()
drifting = []
for index in range(8):
matrix = np.float32([[1, 0, 0.35 * index], [0, 1, 0]])
drifting.append(
cv2.warpAffine(base, matrix, (base.shape[1], base.shape[0]),
flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)
)
drift = floorcal.measure_idle_floor(_write_frames(tmp_path / "d", drifting), max_frames=8)
white = floorcal.measure_idle_floor(_noisy_clip(tmp_path / "w", 4.0), max_frames=8)
assert drift.drift_ratio > 1.0
assert white.drift_ratio < drift.drift_ratio
def test_measure_idle_floor_rejects_missing_and_tiny_sources(tmp_path: Path) -> None:
with pytest.raises(floorcal.FloorCalibrationError):
floorcal.measure_idle_floor(tmp_path / "nope.mp4")
single = _write_frames(tmp_path / "one", [_pattern()])
with pytest.raises(floorcal.FloorCalibrationError):
floorcal.measure_idle_floor(single)
def test_resolve_idle_source_tolerates_absence(tmp_path: Path) -> None:
assert floorcal.resolve_idle_source(tmp_path, None) is None
assert floorcal.resolve_idle_source(tmp_path, "missing.mp4") is None
present = tmp_path / "clip.mp4"
present.write_bytes(b"not really a video")
assert floorcal.resolve_idle_source(tmp_path, "clip.mp4") == present.resolve()
# --------------------------------------------------------------------------- #
# Injected noise
# --------------------------------------------------------------------------- #
def test_inject_noise_is_deterministic_and_zero_sigma_is_identity() -> None:
base = _pattern()
assert np.array_equal(flow.inject_noise(base, 0.0, 1), base)
first = flow.inject_noise(base, 5.0, 7)
second = flow.inject_noise(base, 5.0, 7)
assert np.array_equal(first, second)
assert not np.array_equal(first, flow.inject_noise(base, 5.0, 8))
assert first.dtype == np.uint8
def _axis_frames(size: int = 96) -> dict:
base = _pattern(size)
frames = {}
for axis, matrix in (
("x", np.float32([[1, 0, 0.8], [0, 1, 0]])),
("y", np.float32([[1, 0, 0], [0, 1, 0.8]])),
):
steps = []
for step_mm in (0.01, 0.05, 0.10):
shifted = cv2.warpAffine(
base, np.float32(matrix * (1, 1, step_mm / 0.10)), (size, size),
flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE,
)
steps.append((step_mm, shifted))
frames[axis] = steps
return {"base": base, "frames": frames}
def test_noise_model_pass_reports_gain_floor_and_samples() -> None:
data = _axis_frames()
reference = data["base"]
mask, _ = flow.region_masks(reference.shape, (48.0, 48.0), 20.0)
result = flow.noise_model_pass(
reference=reference,
axis_frames=data["frames"],
directions={"x": (1.0, 0.0), "y": (0.0, 1.0)},
roi_mask=mask,
config={"dis_preset": "medium", "dis_mode": 1},
sigmas=(2.0, 8.0),
draws=1,
)
assert result["sigmas"] == [2.0, 8.0]
for axis in ("x", "y"):
entries = result["axes"][axis]
assert [entry["sigma"] for entry in entries] == [2.0, 8.0]
for entry in entries:
assert entry["gain_px_per_mm"] is not None
assert entry["floor_px"] is not None and entry["floor_px"] > 0.0
assert entry["floor_samples"] == 1
assert entry["points"] == 3
# A larger sigma must not produce a quieter floor.
x_entries = {entry["sigma"]: entry for entry in result["axes"]["x"]}
assert x_entries[8.0]["floor_px"] >= x_entries[2.0]["floor_px"]
def test_noise_model_pass_is_deterministic() -> None:
data = _axis_frames()
mask, _ = flow.region_masks(data["base"].shape, (48.0, 48.0), 20.0)
kwargs = dict(
reference=data["base"],
axis_frames=data["frames"],
directions={"x": (1.0, 0.0)},
roi_mask=mask,
config={"dis_preset": "medium", "dis_mode": 1},
sigmas=(4.0,),
draws=1,
)
first = flow.noise_model_pass(**kwargs)
second = flow.noise_model_pass(**kwargs)
assert json.dumps(first, sort_keys=True) == json.dumps(second, sort_keys=True)
def test_resolve_noise_settings_defaults_and_overrides() -> None:
default = flow._resolve_noise_settings({})
assert default["enabled"] is True
assert default["scales"] == ["render"]
assert default["sigmas"] == list(flow.DEFAULT_NOISE_SIGMAS)
off = flow._resolve_noise_settings(
{"settings": {"noise": {"models": ["render_seed"], "sigmas": [4.0]}}}
)
assert off["enabled"] is False
bad = flow._resolve_noise_settings(
{"settings": {"noise": {"sigmas": [-1.0, 0.0, 999.0], "draws": 0}}}
)
assert bad["sigmas"] == list(flow.DEFAULT_NOISE_SIGMAS)
assert bad["draws"] == 1
def test_track_job_carries_the_noise_block(tmp_path: Path) -> None:
"""Regression: `build_track_job` omitted `noise`, so every --noise-* flag and the
design.yaml block were silently ignored and the pass ran on built-in defaults (which
happened to match, hiding it). The job must carry what the caller asked for."""
settings = SimpleNamespace(
resolution=(64, 64),
process_resolution=(32, 32),
axes=("x",),
step_mm=0.01,
max_mm=0.10,
radius_mm=0.5,
tracker={"dis_mode": 1, "dis_preset": "medium"},
noise={"sigmas": [3.0], "draws": 7, "scales": ["process"], "models": ["injected"]},
)
job = flow.build_track_job(settings, tmp_path)
assert job["settings"]["noise"] == {
"sigmas": [3.0],
"draws": 7,
"scales": ["process"],
"models": ["injected"],
}
resolved = flow._resolve_noise_settings(job)
assert resolved["draws"] == 7
assert resolved["sigmas"] == [3.0]
assert resolved["scales"] == ["process"]
def test_floor_basis_is_labelled_render_seed() -> None:
"""The reclassification must be machine-readable, not just prose."""
source = Path(flow.__file__).read_text(encoding="utf-8")
assert '"floor_basis": "render_seed_reproducibility"' in source
assert "NOT a sensor-noise model" in source
# --------------------------------------------------------------------------- #
# Retention and disagreement, in the sensitivity report
# --------------------------------------------------------------------------- #
def _scored_scale(gain: float = 10.0):
from orisys_sensor_design import sensitivity as sens
axis = sens.AxisScore(
axis="x",
direction_px=(1.0, 0.0),
direction_source="test",
floor_px=0.02,
floor_source="null_projection_p95",
points=(),
gain_px_per_mm=gain,
gain_mm_per_mm=None,
scale_px_per_mm=13.0,
gain_gate="no_gain",
snr=1.0,
spearman=1.0,
resolvable_step_mm=None,
saturation_mm=None,
cross_axis_leak=0.0,
score=1.0,
verdict="resolvable",
px_per_mm_axis=13.0,
)
return sens.ScaleScore(
name="render",
width=500,
height=500,
px_per_mm=13.0,
displacement_px_per_step=0.13,
cell_px=6.25,
cell_over_patch_px=0.78,
null_floor_px=0.02,
null_floor_reported_px=0.02,
axes=(axis,),
score=1.0,
verdict="resolvable",
)
def test_attach_noise_models_computes_retention_and_snr() -> None:
scale = _scored_scale(gain=10.0)
report = {
"scales": [
{
"name": "render",
"noise_models": {
"basis": "injected",
"draws": 1,
"sigmas": [4.0],
"axes": {
"x": [
{
"sigma": 4.0,
"gain_px_per_mm": 8.0,
"floor_px": 0.04,
"points": 10,
"floor_samples": 1,
}
]
},
},
}
]
}
attached = sensitivity.attach_noise_models([scale], report, max_mm=0.10)
models = attached[0].noise_models
assert models is not None
row = models["axes"]["x"][0]
assert row["response_retention"] == pytest.approx(0.8)
# snr under the same model: |gain| * max_mm / floor
assert row["snr_noise"] == pytest.approx(8.0 * 0.10 / 0.04)
def test_noise_disagreement_fires_only_past_the_threshold() -> None:
scale = _scored_scale(gain=10.0)
noisy = {
"axes": {"x": [{"sigma": 16.0, "gain_px_per_mm": 6.0, "floor_px": 0.05}]},
}
import dataclasses
loud = dataclasses.replace(scale, noise_models=noisy)
message = sensitivity.noise_disagreement(loud)
assert message is not None and "60%" in message
quiet = dataclasses.replace(
scale,
noise_models={"axes": {"x": [{"sigma": 16.0, "gain_px_per_mm": 9.5, "floor_px": 0.05}]}},
)
assert sensitivity.noise_disagreement(quiet) is None
assert sensitivity.noise_disagreement(scale) is None
def test_calibration_note_states_the_factor() -> None:
note = sensitivity.calibration_note(
{
"floor_per_period_short": 0.0089,
"floor_px_short": 0.2267,
"width": 1280,
"height": 720,
"dominant_period_px": 25.5,
"drift_ratio": 0.98,
}
)
assert "0.00890" in note
assert "white noise" in note
def test_noise_calibration_is_cached_and_skippable(tmp_path: Path) -> None:
frames = _noisy_clip(tmp_path / "footage", 3.0, count=6)
settings = SimpleNamespace(noise={"idle_footage": str(frames), "max_frames": 6})
first = sensitivity.noise_calibration(tmp_path, settings)
assert first is not None and first["source"] == "measured"
assert (tmp_path / sensitivity.IDLE_CALIBRATION_FILENAME).is_file()
second = sensitivity.noise_calibration(tmp_path, settings)
assert second is not None and second["source"] == "cached"
assert sensitivity.noise_calibration(tmp_path, settings, enabled=False) is None
unset = sensitivity.noise_calibration(tmp_path, SimpleNamespace(noise={}))
assert unset is not None and unset["source"] == "unavailable"
# --------------------------------------------------------------------------- #
# Figure selection: the duplicate-label regression
# --------------------------------------------------------------------------- #
def _twelve_runs() -> list[dict]:
runs = []
for nx in (31, 56, 80):
for radius in (0.5, 1.5):
for dis_mode in (1, 0):
runs.append(
{
"id": f"nx{nx}@r{radius}@dm{dis_mode}",
"nx": nx,
"radius_mm": radius,
"dis_mode": dis_mode,
"score": float(nx + radius + dis_mode),
}
)
return runs
def test_select_runs_is_unique_and_represents_every_dis_mode() -> None:
runs = _twelve_runs()
chosen = compare.select_runs(runs, limit=4, reference_radius=0.5)
ids = [run["id"] for run in chosen]
assert len(ids) == len(set(ids)), "a run must not appear twice"
assert len(ids) == 4
# Global best and worst are always present.
ranked = sorted(runs, key=lambda run: -run["score"])
assert ranked[0]["id"] in ids and ranked[-1]["id"] in ids
# And the shipped setting (dm1) is represented at the reference radius: the old
# best+worst-of-keys[:1] rule picked dm0 only and dropped it.
assert any(
run["id"] in ids and run["dis_mode"] == 1 and run["radius_mm"] == 0.5
for run in runs
)
def test_select_runs_never_duplicates_when_radii_differ() -> None:
"""The exact shape that produced two identical `nx=80 dm0` legend entries."""
runs = _twelve_runs()
chosen = compare.select_runs(runs, limit=6, reference_radius=0.5)
labels = [f"nx={r['nx']} r={r['radius_mm']:g} mm dm={r['dis_mode']}" for r in chosen]
assert len(labels) == len(set(labels))
+503
View File
@@ -0,0 +1,503 @@
"""Tests for the deterministic marker-pattern optimizer.
No Blender and no cv2: the per-candidate pipeline is injected, so these tests
exercise enumeration, ranking, winner selection, deterministic ordering, the
full-resolution re-confirmation and the dry-run path directly.
"""
from __future__ import annotations
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
from orisys_sensor_design import optimise as opt
def _paths(tmp_path: Path) -> SimpleNamespace:
revision = tmp_path / "rev"
revision.mkdir(exist_ok=True)
return SimpleNamespace(root=revision)
def _settings(**overrides: object) -> SimpleNamespace:
values = {
"resolution": [500, 500],
"process_resolution": [250, 250],
"step_mm": 0.01,
}
values.update(overrides)
return SimpleNamespace(**values)
class RecordingRunner:
"""Deterministic fake pipeline: score depends only on the candidate."""
def __init__(
self,
scores: dict[str, float] | None = None,
confirm_factor: float = 0.9,
scale_scores: dict[str, dict[str, float]] | None = None,
):
self.calls: list[tuple[str, tuple[int, int], str, Path]] = []
self.scores = scores or {}
self.confirm_factor = confirm_factor
self.scale_scores = scale_scores or {}
def __call__(self, run: opt.CandidateRun) -> dict:
self.calls.append((run.candidate.id, run.resolution, run.stage, run.directory))
run.directory.mkdir(parents=True, exist_ok=True)
score = self.scores.get(run.candidate.id, float(run.candidate.nx))
if run.stage == "confirm":
score = score * self.confirm_factor
scales = {
name: {
"score": value * (self.confirm_factor if run.stage == "confirm" else 1.0),
"verdict": "marginal",
}
for name, value in (self.scale_scores.get(run.candidate.id) or {}).items()
}
return {
"score": score,
"verdict": "marginal",
"cell_px": None,
"px_per_mm": 6.624,
"report": str(run.directory / "sensitivity_report.json"),
"texture_sha256": f"sha-{run.candidate.id}",
"scales": scales,
}
class ReportWritingRunner(RecordingRunner):
"""Runner that returns only a report path, like cli.py's real runner."""
def __call__(self, run: opt.CandidateRun) -> dict:
result = super().__call__(run)
result.pop("scales", None)
report_path = Path(result["report"])
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(
json.dumps(
{
"schema": "orisys-sensor-design/sensitivity-report-v1",
"scales": [
{"name": "render", "score": 30.0 + run.candidate.nx, "verdict": "resolvable"},
{"name": "process", "score": 40.0 - run.candidate.nx, "verdict": "marginal"},
],
}
),
encoding="utf-8",
)
return result
# --------------------------------------------------------------------------- #
# Enumeration
# --------------------------------------------------------------------------- #
def test_enumeration_is_sorted_and_square_by_default() -> None:
candidates = opt.enumerate_candidates([31, 15, 20], [7])
assert [candidate.id for candidate in candidates] == ["15@7", "20@7", "31@7"]
assert all(candidate.nx == candidate.ny for candidate in candidates)
def test_enumeration_is_independent_of_input_order() -> None:
forward = opt.enumerate_candidates([15, 20, 31], [7, 3])
backward = opt.enumerate_candidates([31, 15, 20], [3, 7])
assert forward == backward
assert [candidate.id for candidate in forward] == [
"15@3", "15@7", "20@3", "20@7", "31@3", "31@7"
]
def test_enumeration_deduplicates_and_honours_grid_y() -> None:
candidates = opt.enumerate_candidates([20, 20], [7, 7], grid_y=[20, 30])
assert [candidate.id for candidate in candidates] == ["20@7", "20x30@7"]
def test_enumeration_is_bounded_by_the_grid_and_seed_sets() -> None:
candidates = opt.enumerate_candidates(opt.DEFAULT_GRID, [7])
assert len(candidates) == len(opt.DEFAULT_GRID)
assert candidates[0].nx == 15
assert candidates[-1].nx == 80
# nx ~ 28-36 is inside the default grid: it brackets the DIS patch size.
assert any(28 <= candidate.nx <= 36 for candidate in candidates)
@pytest.mark.parametrize(
"grid,seeds",
[
([], [7]),
([20], []),
([0], [7]),
([-3], [7]),
([20], [-1]),
(["x"], [7]),
(None, [7]),
],
)
def test_invalid_search_bounds_raise(grid: object, seeds: object) -> None:
with pytest.raises(opt.OptimiseError):
opt.enumerate_candidates(grid, seeds) # type: ignore[arg-type]
# --------------------------------------------------------------------------- #
# Ranking
# --------------------------------------------------------------------------- #
def _entries(scores: dict[tuple[int, int, int], float | None]) -> list[dict]:
return [
{"candidate": opt.Candidate(nx, ny, seed), "score": score, "verdict": "marginal"}
for (nx, ny, seed), score in scores.items()
]
def test_ranking_is_stable_and_independent_of_input_order() -> None:
scores = {(31, 31, 7): 0.4, (20, 20, 7): 0.9, (36, 36, 7): 0.9, (48, 48, 7): 0.1}
ordered = _entries(dict(sorted(scores.items())))
shuffled = _entries(dict(reversed(list(scores.items()))))
first = [entry["candidate"].id for entry in opt.rank_entries(ordered)]
second = [entry["candidate"].id for entry in opt.rank_entries(shuffled)]
assert first == second == ["20@7", "36@7", "31@7", "48@7"]
def test_unscored_candidates_sort_last() -> None:
ranked = opt.rank_entries(
_entries({(31, 31, 7): 0.4, (20, 20, 7): None, (36, 36, 7): 0.9})
)
assert [entry["candidate"].id for entry in ranked] == ["36@7", "31@7", "20@7"]
def test_ties_break_on_candidate_order() -> None:
ranked = opt.rank_entries(_entries({(36, 36, 7): 0.5, (20, 20, 7): 0.5, (48, 48, 7): 0.5}))
assert [entry["candidate"].id for entry in ranked] == ["20@7", "36@7", "48@7"]
# --------------------------------------------------------------------------- #
# run_optimise: winner, confirmation, determinism, notes
# --------------------------------------------------------------------------- #
def _run(tmp_path: Path, runner: object, **kwargs: object) -> tuple[dict, int]:
kwargs.setdefault("top", None)
return opt.run_optimise(
_paths(tmp_path),
_settings(),
grid=[20, 31, 48],
seeds=[7],
search_resolution=(250, 250),
output_dir=tmp_path / "out",
progress=lambda message: None,
texture_vector_mm=(18.5, 16.4),
runner=runner, # type: ignore[arg-type]
**kwargs,
)
def test_winner_is_the_top_ranked_candidate_and_confirmed_at_full_resolution(
tmp_path: Path,
) -> None:
runner = RecordingRunner(scores={"20@7": 0.2, "31@7": 0.9, "48@7": 0.5})
report, code = _run(tmp_path, runner)
assert code == 0
assert report["ranked"][0] == "31@7"
assert report["winner"]["nx"] == 31 and report["winner"]["seed"] == 7
assert report["winner"]["score"] == pytest.approx(0.9)
assert report["winner"]["confirmed_at_resolution"] == [500, 500]
assert report["winner"]["confirmed_score"] == pytest.approx(0.9 * 0.9)
assert report["winner"]["report"]
search_calls = [call for call in runner.calls if call[2] == "search"]
confirm_calls = [call for call in runner.calls if call[2] == "confirm"]
assert len(search_calls) == 3
assert all(call[1] == (250, 250) for call in search_calls)
assert len(confirm_calls) == 1
assert confirm_calls[0][0] == "31@7"
assert confirm_calls[0][1] == (500, 500), "re-confirmation must use probe.resolution"
def test_report_never_claims_a_global_optimum(tmp_path: Path) -> None:
runner = RecordingRunner()
report, _ = _run(tmp_path, runner)
notes = report["notes"]
assert any("not a global optimum" in note for note in notes)
for note in notes:
if "global optimum" in note:
assert "not a global optimum" in note, note
assert report["search"]["objective"] == "sensitivity_report.overall.score"
# The grid rationale must say it was chosen around the DIS patch, not nx=80.
assert any("DIS patch" in note for note in notes)
def test_optimizer_notes_record_the_roi_confound(tmp_path: Path) -> None:
report, _ = _run(tmp_path, RecordingRunner())
joined = " ".join(report["notes"])
assert "confounded with the ROI size" in joined
assert "Only nx/ny/seed vary" in joined
assert "radius_mm, the ROI and the scale are held fixed" in joined
def test_notes_no_longer_assert_aliasing_for_the_shipped_pattern(tmp_path: Path) -> None:
report, _ = _run(tmp_path, RecordingRunner())
joined = " ".join(report["notes"])
# The disproved claim must be gone.
assert "aliases a sub-period shift" not in joined
assert "wrong period" not in joined
# What was actually measured must be there instead.
assert "does NOT alias" in joined
# The rationale must now reference the effective window and the camera-space
# cell, not the old millimetre-derived cell or the nominal patch alone.
assert "effective" in joined
assert "width/nx" in joined
assert "setFinestScale" in joined
def test_notes_state_that_best_is_scale_dependent(tmp_path: Path) -> None:
report, _ = _run(tmp_path, RecordingRunner())
joined = " ".join(report["notes"])
assert "'Best' is scale-dependent" in joined
assert "unweighted mean" in joined
assert "scale_scores" in joined
def test_notes_report_the_search_to_confirm_score_gap(tmp_path: Path) -> None:
report, _ = _run(tmp_path, RecordingRunner())
joined = " ".join(report["notes"])
assert "score gap between the search and the confirmation" in joined
assert "ratio" in joined
def test_unset_search_resolution_defaults_to_settings_resolution(tmp_path: Path) -> None:
runner = RecordingRunner()
report, code = opt.run_optimise(
_paths(tmp_path),
_settings(resolution=[500, 500]),
grid=[20, 31],
seeds=[7],
top=None,
output_dir=tmp_path / "out",
progress=lambda message: None,
texture_vector_mm=(18.5, 16.4),
runner=runner,
)
assert code == 0
assert report["search"]["render_resolution"] == [500, 500]
search_resolutions = {call[1] for call in runner.calls if call[2] == "search"}
assert search_resolutions == {(500, 500)}, "search must default to settings.resolution"
# The confirmation still uses settings.resolution too.
assert report["winner"]["confirmed_at_resolution"] == [500, 500]
def test_unset_search_resolution_falls_back_when_settings_carry_none(
tmp_path: Path,
) -> None:
runner = RecordingRunner()
report, code = opt.run_optimise(
_paths(tmp_path),
SimpleNamespace(step_mm=0.01), # no resolution at all
grid=[20],
seeds=[7],
top=None,
output_dir=tmp_path / "out",
progress=lambda message: None,
runner=runner,
)
assert code == 0
assert report["search"]["render_resolution"] == list(opt.DEFAULT_SEARCH_RESOLUTION)
def test_winner_is_confirmed_at_settings_resolution_by_default(tmp_path: Path) -> None:
report, _ = _run(
tmp_path, RecordingRunner(scores={"20@7": 0.2, "31@7": 0.9, "48@7": 0.5})
)
# `_run` passes search_resolution=(250,250) explicitly; the confirmation target
# still comes from settings.resolution and must stay 500x500.
assert report["winner"]["confirmed_at_resolution"] == [500, 500]
assert report["search"]["render_resolution"] == [250, 250]
def test_candidate_rows_carry_per_scale_scores_and_ranks(tmp_path: Path) -> None:
scale_scores = {
"20@7": {"render": 0.1, "process": 0.9},
"31@7": {"render": 0.3, "process": 0.5},
"48@7": {"render": 0.9, "process": 0.1},
}
runner = RecordingRunner(
scores={"20@7": 0.2, "31@7": 0.9, "48@7": 0.5},
scale_scores=scale_scores,
)
report, _ = _run(tmp_path, runner)
assert report["search"]["scales"] == ["process", "render"]
# Overall winner is the mean's winner ...
assert report["ranked"][0] == "31@7"
# ... but it is NOT the render-scale winner, which is the point of the dimension.
assert report["ranked_by_scale"]["render"] == ["48@7", "31@7", "20@7"]
assert report["ranked_by_scale"]["process"] == ["20@7", "31@7", "48@7"]
by_nx = {row["nx"]: row for row in report["candidates"]}
assert by_nx[48]["scale_scores"]["render"] == pytest.approx(0.9)
assert by_nx[48]["scale_ranks"]["render"] == 1
assert by_nx[31]["scale_ranks"]["render"] == 2
assert by_nx[20]["scale_ranks"]["process"] == 1
assert by_nx[20]["scale_verdicts"]["render"] == "marginal"
assert by_nx[31]["scale_ranks"]["process"] == 2
assert report["winner"]["scale_scores"]["render"] == pytest.approx(0.3)
assert report["search"]["render_resolution"] == [250, 250]
def test_per_scale_scores_are_read_from_the_candidate_report_path(tmp_path: Path) -> None:
runner = ReportWritingRunner()
report, code = _run(tmp_path, runner)
assert code == 0
by_nx = {row["nx"]: row for row in report["candidates"]}
assert by_nx[31]["scale_scores"]["render"] == pytest.approx(61.0)
assert by_nx[31]["scale_scores"]["process"] == pytest.approx(9.0)
assert report["ranked_by_scale"]["render"][0] == "48@7"
assert report["ranked_by_scale"]["process"][0] == "20@7"
def test_candidate_table_is_sorted_and_carries_measured_fields(tmp_path: Path) -> None:
runner = RecordingRunner(scores={"20@7": 0.2, "31@7": 0.9, "48@7": 0.5})
report, _ = _run(tmp_path, runner)
rows = report["candidates"]
assert [row["nx"] for row in rows] == [31, 48, 20]
assert report["ranked"] == ["31@7", "48@7", "20@7"]
for row in rows:
assert set(row) >= {"nx", "ny", "seed", "score", "verdict", "cell_px",
"report", "texture_sha256", "scale_scores",
"scale_verdicts", "scale_ranks"}
assert report["search"]["candidates"] == 3
assert report["search"]["render_resolution"] == [250, 250]
def test_cell_px_is_the_camera_space_cell(tmp_path: Path) -> None:
"""The pattern grid is defined across the camera image, so cell = width / nx.
This replaces an earlier expectation of `(18.5/31) * px_per_mm`, which derived
the cell from the *physical* page cell times the probe-local scale. That is a
different (and ~2x smaller) number: the probe-local scale is 13.2 px/mm while
the camera-grid scale is 500/18.5 = 27.0 px/mm. Using the millimetre cell made
a 16.1 px cell look like 7.9 px and hid the effective-window rule.
"""
runner = RecordingRunner()
report, _ = _run(tmp_path, runner)
row = next(item for item in report["candidates"] if item["nx"] == 31)
assert row["cell_px"] == pytest.approx(500.0 / 31.0, rel=1e-9)
# And it must NOT be the millimetre-derived value.
assert row["cell_px"] != pytest.approx((18.5 / 31) * 6.624 * 2.0, rel=1e-3)
def test_two_runs_give_byte_identical_rankings(tmp_path: Path) -> None:
first, _ = _run(tmp_path, RecordingRunner(scores={"20@7": 0.2, "31@7": 0.9, "48@7": 0.5}))
second, _ = _run(tmp_path, RecordingRunner(scores={"20@7": 0.2, "31@7": 0.9, "48@7": 0.5}))
first.pop("generated_at")
second.pop("generated_at")
assert first["ranked"] == second["ranked"]
assert first["candidates"] == second["candidates"]
assert json.dumps(first["candidates"], sort_keys=True) == json.dumps(
second["candidates"], sort_keys=True
)
def test_top_truncates_ranked_but_keeps_the_winner(tmp_path: Path) -> None:
runner = RecordingRunner(scores={"20@7": 0.2, "31@7": 0.9, "48@7": 0.5})
report, _ = _run(tmp_path, runner, top=2)
assert report["ranked"] == ["31@7", "48@7"]
assert report["winner"]["nx"] == 31
def test_runner_failure_records_error_without_aborting(tmp_path: Path) -> None:
class Flaky(RecordingRunner):
def __call__(self, run: opt.CandidateRun) -> dict:
if run.candidate.nx == 31 and run.stage == "search":
raise RuntimeError("blender exploded")
return super().__call__(run)
runner = Flaky(scores={"20@7": 0.2, "48@7": 0.5})
report, code = _run(tmp_path, runner)
assert code == 0
assert report["winner"]["nx"] == 48, "a failed candidate must not win"
bad = next(item for item in report["candidates"] if item["nx"] == 31)
assert bad["score"] is None
assert "blender exploded" in bad["error"]
def test_all_candidates_failing_returns_nonzero(tmp_path: Path) -> None:
class Broken:
def __call__(self, run: opt.CandidateRun) -> dict:
raise RuntimeError("no pipeline")
report, code = _run(tmp_path, Broken())
assert code == 1
assert report["winner"] is None
assert all(row["score"] is None for row in report["candidates"])
def test_missing_runner_writes_a_degraded_report_and_returns_2(tmp_path: Path) -> None:
report, code = opt.run_optimise(
_paths(tmp_path),
_settings(),
grid=[20, 31],
seeds=[7],
search_resolution=(250, 250),
top=None,
output_dir=tmp_path / "out",
progress=lambda message: None,
runner=None,
)
assert code == 2
assert "runner" in report["error"]["message"]
assert (tmp_path / "out" / opt.OPTIMIZER_REPORT_FILENAME).is_file()
assert any("not a global optimum" in note for note in report["notes"])
# --------------------------------------------------------------------------- #
# Dry run
# --------------------------------------------------------------------------- #
def test_dry_run_enumerates_without_running_or_writing_candidate_dirs(
tmp_path: Path,
) -> None:
runner = RecordingRunner()
report, code = opt.run_optimise(
_paths(tmp_path),
_settings(),
grid=[15, 20, 31],
seeds=[7, 3],
search_resolution=(250, 250),
top=None,
dry_run=True,
output_dir=tmp_path / "out",
progress=lambda message: None,
runner=runner,
)
assert code == 0
assert runner.calls == [], "dry-run must not run the pipeline"
assert not (tmp_path / "out" / "candidates").exists()
assert report["search"]["dry_run"] is True
assert report["search"]["candidates"] == 6
assert [row["nx"] for row in report["candidates"]] == [15, 15, 20, 20, 31, 31]
assert report["ranked"] == []
assert report["ranked_by_scale"] == {}
assert report["winner"] is None
assert all(row["score"] is None for row in report["candidates"])
assert (tmp_path / "out" / opt.OPTIMIZER_REPORT_FILENAME).is_file()
assert any("dry-run" in note for note in report["notes"])
assert any("scale-dependent" in note for note in report["notes"])
def test_optimizer_report_schema_keys(tmp_path: Path) -> None:
report, _ = _run(tmp_path, RecordingRunner())
assert report["schema"] == opt.OPTIMIZER_REPORT_SCHEMA
assert set(report) >= {"schema", "generated_at", "search", "candidates", "ranked",
"ranked_by_scale", "winner", "notes"}
assert set(report["search"]) >= {"grid", "seeds", "render_resolution", "candidates",
"objective", "scales"}
assert set(report["winner"]) >= {"nx", "ny", "seed", "score",
"confirmed_at_resolution", "confirmed_score", "report"}
+4 -1
View File
@@ -328,7 +328,10 @@ pytestmark_gl = pytest.mark.skipif(
not (XYNOVA_EXPORT / "mesh.npz").is_file(), reason="export not built"
)
def test_preview_draws_the_mesh_and_the_housing_toggle_changes_it() -> None:
pytest.importorskip("PyQt5")
# Guard the submodule, not the top-level package: a partially installed
# PyQt5 can expose `PyQt5` (with `PyQt5.__file__ is None`) while every
# binding submodule is missing, which would otherwise fail instead of skip.
pytest.importorskip("PyQt5.QtWidgets")
from PyQt5.QtWidgets import QApplication
from orisys_sensor_design.preview.gl_view import (
+272
View File
@@ -0,0 +1,272 @@
"""Tests for `probe_plot`: the optical-flow figures.
matplotlib is an optional extra, so the whole module is skipped when it is absent —
but nothing here needs Blender or cv2. Every figure is built from synthetic arrays and
tiny JSON fixtures, which is deliberate: the figures are a view of measurements, so
they must be testable without making a measurement.
"""
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
import pytest
pytest.importorskip("matplotlib")
from orisys_sensor_design import probe_plot # noqa: E402
PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
def _flow(height: int = 60, width: int = 60, value: float = 0.4) -> np.ndarray:
flow = np.zeros((height, width, 2), dtype=np.float32)
flow[..., 0] = value
return flow
def _panel(**overrides) -> dict:
panel = {
"label": "nx=31, dis_mode=1",
"flow": _flow(),
"roi": {"u": 30.0, "v": 30.0, "radius": 6.6},
"cell_px": 16.13,
"effective_patch_px": 16.0,
"applied_px": 1.325,
"response_px": 0.886,
"direction_px": [1.0, 0.0],
"scale_note": "render 500x500 | patch 8 px, finest 1",
"note": "",
}
panel.update(overrides)
return panel
def _assert_png(path: Path) -> None:
assert path.is_file()
assert path.stat().st_size > 0
assert path.read_bytes()[:8] == PNG_MAGIC
def test_flow_comparison_writes_a_png(tmp_path: Path) -> None:
out = probe_plot.render_flow_comparison(
panels=[_panel(label="nx=31"), _panel(label="nx=80", cell_px=6.25, flow=_flow(value=0.9))],
out_path=tmp_path / "flow.png",
)
_assert_png(out)
def test_flow_comparison_annotates_a_missing_flow_array(tmp_path: Path) -> None:
"""A failed setting must appear as a named placeholder, not vanish."""
out = probe_plot.render_flow_comparison(
panels=[
_panel(label="ok"),
_panel(label="broken", flow=None, note="missing flow array 010_x.npy"),
],
out_path=tmp_path / "flow_missing.png",
)
_assert_png(out)
def test_flow_comparison_handles_empty_and_tiny_inputs(tmp_path: Path) -> None:
out = probe_plot.render_flow_comparison(panels=[], out_path=tmp_path / "empty.png")
_assert_png(out)
tiny = _flow(height=3, width=3, value=0.1)
out = probe_plot.render_flow_comparison(
panels=[_panel(flow=tiny, roi={"u": 1.0, "v": 1.0, "radius": 0.5})],
out_path=tmp_path / "tiny.png",
)
_assert_png(out)
def test_tuning_writes_both_panels(tmp_path: Path) -> None:
out = probe_plot.render_tuning(
series=[
{
"label": "render | dis_mode=1",
"points": [
{"nx": 31, "cell_px": 16.13, "effective_patch_px": 16.0, "score": 69.2},
{"nx": 80, "cell_px": 6.25, "effective_patch_px": 16.0, "score": 40.5},
],
},
{
"label": "render | dis_mode=0",
"points": [
{"nx": 31, "cell_px": 16.13, "effective_patch_px": 8.0, "score": 20.0},
{"nx": 80, "cell_px": 6.25, "effective_patch_px": 8.0, "score": 55.0},
],
},
],
out_path=tmp_path / "tuning.png",
patch_px=8.0,
)
_assert_png(out)
def test_tuning_with_no_points_still_writes_a_placeholder(tmp_path: Path) -> None:
out = probe_plot.render_tuning(series=[], out_path=tmp_path / "tuning_empty.png")
_assert_png(out)
def test_response_curves_writes_with_a_per_curve_floor(tmp_path: Path) -> None:
out = probe_plot.render_response_curves(
curves=[
{
"label": "nx=80 r=0.5 mm dm=1",
"verdict": "resolvable",
"score": 57.7,
"floor_px": 0.0155,
"points": [
{"step_mm": 0.01, "response_px": 0.08, "applied_px": 0.13},
{"step_mm": 0.10, "response_px": 0.89, "applied_px": 1.32},
],
},
{
"label": "nx=31 r=0.5 mm dm=1",
"verdict": "resolvable",
"score": 12.3,
# A different floor, so the two must get their own lines.
"floor_px": 0.0372,
"points": [
{"step_mm": 0.01, "response_px": 0.08, "applied_px": 0.13},
{"step_mm": 0.10, "response_px": 0.48, "applied_px": 1.32},
],
},
],
out_path=tmp_path / "response.png",
axis="x",
)
_assert_png(out)
def test_response_curves_adds_a_retention_panel(tmp_path: Path) -> None:
"""Retention is the panel that exposes a pattern collapsing under noise."""
out = probe_plot.render_response_curves(
curves=[
{
"label": "nx=80 r=0.5 mm dm=1",
"points": [{"step_mm": 0.10, "response_px": 0.90, "applied_px": 1.32}],
"floor_px": 0.0155,
"retention": [
{"sigma": 4.0, "response_retention": 0.99},
{"sigma": 16.0, "response_retention": 0.84},
],
},
{
"label": "nx=31 r=0.5 mm dm=1",
"points": [{"step_mm": 0.10, "response_px": 0.48, "applied_px": 1.32}],
"floor_px": 0.0372,
"retention": [
{"sigma": 4.0, "response_retention": 0.72},
{"sigma": 16.0, "response_retention": 0.59},
],
},
],
out_path=tmp_path / "retention.png",
axis="x",
)
_assert_png(out)
def test_response_curve_labels_must_be_unique_to_be_useful(tmp_path: Path) -> None:
"""Regression: the old label was `nx=.. dm..` and omitted the radius, so two runs
(nx80@r0.5@dm0 and nx80@r1.5@dm0) produced identical legend entries."""
runs = [
("nx=80 r=0.5 mm dm=0", 0.03),
("nx=80 r=1.5 mm dm=0", 0.09),
]
out = probe_plot.render_response_curves(
curves=[
{
"label": label,
"points": [{"step_mm": 0.10, "response_px": 0.9, "applied_px": 1.32}],
"floor_px": floor,
}
for label, floor in runs
],
out_path=tmp_path / "unique.png",
axis="x",
)
_assert_png(out)
assert len({label for label, _ in runs}) == 2
def test_response_curves_without_curves(tmp_path: Path) -> None:
out = probe_plot.render_response_curves(
curves=[], out_path=tmp_path / "response_empty.png"
)
_assert_png(out)
def _write_run(directory: Path, *, nx: int = 31, include_flow: bool = True) -> None:
directory.mkdir(parents=True, exist_ok=True)
frames_dir = directory / "frames"
frames_dir.mkdir(exist_ok=True)
frame_name = "000_reference"
(frames_dir / f"{frame_name}.png").write_bytes(PNG_MAGIC)
manifest = {
"width": 500,
"height": 500,
"probe_roi_px": {"u": 30.0, "v": 30.0, "radius": 6.6},
"frames": [
{"index": 0, "name": frame_name, "axis": None, "step_mm": 0.0,
"png": f"frames/{frame_name}.png", "centroid_px": [30.0, 30.0]},
{"index": 1, "name": "001_x_+0.10mm", "axis": "x", "step_mm": 0.10,
"png": "frames/001_x_+0.10mm.png", "centroid_px": [31.3, 30.0]},
],
}
(directory / "render_manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
flow_report = {
"tracker": {"patch_px": 8, "finest_scale": 1},
"scales": [
{"name": "render", "width": 500, "height": 500,
"frames": [{"name": "001_x_+0.10mm",
"roi": {"u": 30.0, "v": 30.0, "radius": 6.6, "pixels": 139}}]}
],
}
(directory / "flow_report.json").write_text(json.dumps(flow_report), encoding="utf-8")
sensitivity = {
"scales": [
{"name": "render", "cell_px": 500.0 / nx, "effective_patch_px": 16.0,
"axes": [{"axis": "x", "direction_px": [1.0, 0.0],
"points": [{"step_mm": 0.10, "response_px": 0.886,
"applied_px": 1.325}]}]}
]
}
(directory / "sensitivity_report.json").write_text(json.dumps(sensitivity), encoding="utf-8")
if include_flow:
flow_dir = directory / "flow" / "render"
flow_dir.mkdir(parents=True, exist_ok=True)
np.save(flow_dir / "001_x_+0.10mm.npy", _flow())
def test_panel_from_run_reads_the_artifacts(tmp_path: Path) -> None:
_write_run(tmp_path, nx=31)
panel = probe_plot.panel_from_run(label="nx=31", output_dir=tmp_path, axis="x", step_mm=0.10)
assert panel["label"] == "nx=31"
assert panel["flow"] is not None
assert panel["response_px"] == pytest.approx(0.886)
assert panel["applied_px"] == pytest.approx(1.325)
assert panel["cell_px"] == pytest.approx(500.0 / 31)
assert panel["effective_patch_px"] == pytest.approx(16.0)
assert "patch 8 px" in panel["scale_note"]
def test_panel_from_run_names_a_missing_flow_array(tmp_path: Path) -> None:
_write_run(tmp_path, include_flow=False)
panel = probe_plot.panel_from_run(label="broken", output_dir=tmp_path, axis="x", step_mm=0.10)
assert panel["flow"] is None
assert "missing flow array" in panel["note"]
def test_panel_from_run_survives_no_artifacts_at_all(tmp_path: Path) -> None:
empty = tmp_path / "nothing"
empty.mkdir()
panel = probe_plot.panel_from_run(label="empty", output_dir=empty, axis="x", step_mm=0.10)
assert panel["flow"] is None
assert panel["cell_px"] is None
# And the figure must still be drawable from it.
out = probe_plot.render_flow_comparison(panels=[panel], out_path=tmp_path / "none.png")
_assert_png(out)
+801
View File
@@ -0,0 +1,801 @@
"""Tests for the probe render stage: frame ordering, job I/O, manifest, refusal.
Host-side tests import only ``orisys_sensor_design`` (no bpy). The Blender-backed
tests shell out to ``blender -b <blend> -P blender/scripts/probe_render.py`` and
are skipped when no Blender executable can be found.
"""
from __future__ import annotations
import argparse
import dataclasses
import importlib.util
import json
import math
import os
import shutil
import struct
import subprocess
import sys
import types
import zlib
from pathlib import Path
import numpy as np
import pytest
from orisys_sensor_design import deform, render
from orisys_sensor_design.manifest import RevisionPaths, repo_root, sha256_file
MANIFEST_SCHEMA = "orisys-sensor-design/probe-render-manifest-v1"
#: Frozen revision facts (docs/probe-pipeline.md). The blend is the user's file; these
#: fail loudly if it changes, which is the point. The blend's own sha256 is intentionally
#: NOT pinned here: the user changes its render resolution as the bench is re-targeted
#: (500x500 -> 250x250), and a literal would call that legitimate edit a regression. What
#: the end-to-end test guards instead is that the pipeline never writes the blend.
MESH_PX_PER_MM = {"min": 10.4994, "p50": 13.4302, "max": 38.5943}
FRAME_COUNT = 31
REFERENCE_NAME = "000_reference"
REFERENCE_NULL_NAME = "reference_null"
def _repo() -> Path:
try:
return repo_root()
except FileNotFoundError:
return Path(__file__).resolve().parents[1]
REPO = _repo()
REVISION_DIR = REPO / "sensors" / "finger-xynova" / "r01"
SOURCE_BLEND = REVISION_DIR / "source" / "geometry.blend"
RENDER_SCRIPT = REPO / "blender" / "scripts" / "probe_render.py"
DEFORM_SCRIPT = REPO / "blender" / "scripts" / "probe_deform.py"
def _blender_executable() -> Path | None:
for variable in ("ORISYS_BLENDER", "BLENDER_EXE"):
value = os.environ.get(variable)
if value and Path(value).is_file():
return Path(value)
if os.name == "nt":
root = Path(r"C:\Program Files\Blender Foundation")
candidates = sorted(root.glob("Blender */blender.exe"), reverse=True)
if candidates:
return candidates[0]
which = shutil.which("blender")
return Path(which) if which else None
BLENDER = _blender_executable()
requires_blender = pytest.mark.skipif(
BLENDER is None, reason="no Blender executable (set ORISYS_BLENDER or install Blender)"
)
def _run_blender(script: Path, job: Path, timeout: int = 900) -> subprocess.CompletedProcess:
assert BLENDER is not None
return subprocess.run(
[str(BLENDER), "-b", str(SOURCE_BLEND), "-P", str(script), "--", "--job", str(job)],
capture_output=True,
text=True,
timeout=timeout,
)
def _png_size(path: Path) -> tuple[int, int]:
data = path.read_bytes()
assert data[:8] == b"\x89PNG\r\n\x1a\n", f"not a PNG: {path}"
width, height = struct.unpack(">II", data[16:24])
return int(width), int(height)
def _png_chunk(ctype: bytes, payload: bytes) -> bytes:
return (
struct.pack(">I", len(payload))
+ ctype
+ payload
+ struct.pack(">I", zlib.crc32(ctype + payload) & 0xFFFFFFFF)
)
def _paeth(a: int, b: int, c: int) -> int:
p = a + b - c
pa, pb, pc = abs(p - a), abs(p - b), abs(p - c)
if pa <= pb and pa <= pc:
return a
return b if pb <= pc else c
def _write_png(path: Path, pixels: np.ndarray, filter_type: int = 0) -> Path:
"""Encode an 8-bit RGBA PNG, applying one PNG filter per row.
Used to exercise the shipped decoder and the determinism gate with synthetic
pairs, including 1-LSB differences the old whole-file byte gate rejected.
"""
pixels = np.asarray(pixels, dtype=np.uint8)
height, width, channels = pixels.shape
assert channels == 4
stride = width * 4
raw = bytearray()
previous = np.zeros(stride, dtype=np.int16)
for y in range(height):
line = pixels[y].reshape(-1).astype(np.int16)
if filter_type == 0:
filtered = bytes(line.astype(np.uint8).tobytes())
else:
result = bytearray(stride)
for i in range(stride):
left = line[i - 4] if i >= 4 else 0
up = previous[i]
up_left = previous[i - 4] if i >= 4 else 0
if filter_type == 1:
predictor = left
elif filter_type == 2:
predictor = up
elif filter_type == 3:
predictor = (left + up) >> 1
elif filter_type == 4:
predictor = _paeth(left, up, up_left)
else:
raise ValueError(f"unsupported test filter {filter_type}")
result[i] = (line[i] - predictor) & 0xFF
filtered = bytes(result)
raw.append(filter_type)
raw += filtered
previous = line
data = (
b"\x89PNG\r\n\x1a\n"
+ _png_chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 6, 0, 0, 0))
+ _png_chunk(b"IDAT", zlib.compress(bytes(raw)))
+ _png_chunk(b"IEND", b"")
)
path.write_bytes(data)
return path
def _load_probe_render_module():
"""Import blender/scripts/probe_render.py with bpy stubbed out.
The module only touches bpy/mathutils at call time, so empty stub modules are
enough to unit-test its stdlib+numpy helpers (the determinism gate) without a
Blender process.
"""
stubs: dict[str, types.ModuleType] = {}
for name in ("bpy", "bpy_extras", "bpy_extras.object_utils", "mathutils"):
stubs[name] = sys.modules.setdefault(name, types.ModuleType(name))
stubs["bpy_extras.object_utils"].world_to_camera_view = object
stubs["bpy_extras"].object_utils = stubs["bpy_extras.object_utils"]
stubs["mathutils"].Vector = object
stubs["bpy"].data = None
spec = importlib.util.spec_from_file_location("probe_render_under_test", RENDER_SCRIPT)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
@pytest.fixture(scope="module")
def probe_render():
return _load_probe_render_module()
@pytest.fixture(scope="module")
def paths() -> RevisionPaths:
return RevisionPaths.from_revision_dir(REVISION_DIR)
@pytest.fixture(scope="module")
def settings(paths: RevisionPaths) -> deform.ProbeSettings:
return deform.resolve_probe(paths)
# ----------------------------------------------------------------------
# Host-side: frame ordering and job content
# ----------------------------------------------------------------------
def test_job_frames_follow_frame_plan_exactly(paths, settings, tmp_path):
job = render.build_render_job(paths, settings, tmp_path)
plan = deform.frame_plan(settings)
assert job["schema"] == render.RENDER_JOB_SCHEMA
assert len(job["frames"]) == FRAME_COUNT
assert job["frames"] == [frame.to_job_entry() for frame in plan]
assert [frame["name"] for frame in job["frames"]] == [frame.name for frame in plan]
assert job["frames"][0] == {
"index": 0,
"name": REFERENCE_NAME,
"axis": None,
"step_mm": 0.0,
}
assert job["frames"][-1]["name"] == "030_z_+0.10mm"
assert job["frames"][-1]["axis"] == "z"
# Axis-major, step-minor: x, then y, then z.
assert [frame["axis"] for frame in job["frames"][1:11]] == ["x"] * 10
assert [frame["axis"] for frame in job["frames"][11:21]] == ["y"] * 10
assert [frame["axis"] for frame in job["frames"][21:31]] == ["z"] * 10
assert job["frames"][1]["step_mm"] == pytest.approx(0.01)
assert job["frames"][10]["step_mm"] == pytest.approx(0.10)
def test_job_png_names_are_plan_names(paths, settings, tmp_path):
job = render.build_render_job(paths, settings, tmp_path)
for entry in job["frames"]:
assert render.frame_png_name(entry) == f"{entry['name']}.png"
assert render.frame_png_path(tmp_path, entry) == (
Path(tmp_path).resolve() / render.FRAMES_DIRNAME / f"{entry['name']}.png"
)
assert render.frame_png_name(REFERENCE_NULL_NAME) == "reference_null.png"
assert render.frame_png_name(deform.frame_plan(settings)[0]) == "000_reference.png"
def test_job_carries_the_hard_render_settings(paths, settings, tmp_path):
job = render.build_render_job(paths, settings, tmp_path)
block = job["settings"]
assert block["engine"] == "CYCLES"
assert block["samples"] == 16
assert block["denoise"] is False
assert block["view_transform"] == "Standard"
assert block["interpolate"] == "Closest"
assert block["seed"] == 0
assert (block["width"], block["height"]) == tuple(settings.resolution)
assert block["uv_map"] == "compute"
assert block["radius_mm"] == pytest.approx(0.5)
texture = Path(block["texture"])
assert texture.is_absolute() and texture.is_file()
assert job["objects"] == {"mesh": "sensing_surface", "camera": "cam_sensor"}
assert job["null_frame"] == {"name": REFERENCE_NULL_NAME, "seed": 1}
assert job["artifacts"]["report"] == str(
(Path(tmp_path).resolve() / render.MANIFEST_FILENAME)
)
assert job["artifacts"]["frames_npz"] == str(
deform.probe_artifact_paths(Path(tmp_path))["frames"]
)
assert job["source_blend"] == str(SOURCE_BLEND.resolve())
def test_job_seed_override_moves_the_null_seed(paths, settings, tmp_path):
job = render.build_render_job(paths, settings, tmp_path, seed=7, samples=4)
assert job["settings"]["seed"] == 7
assert job["settings"]["samples"] == 4
assert job["null_frame"]["seed"] == 8
def test_job_rejects_invalid_overrides(paths, settings, tmp_path):
for kwargs in (
{"samples": 0},
{"width": 0},
{"height": -1},
{"seed": 1.5},
{"seed": True},
{"uv_map": ""},
):
with pytest.raises(render.RenderConfigError):
render.build_render_job(paths, settings, tmp_path, **kwargs)
def test_write_render_job_round_trips(paths, settings, tmp_path):
job = render.build_render_job(paths, settings, tmp_path)
job_path = render.write_render_job(job, tmp_path)
assert job_path == render.render_job_path(tmp_path)
assert job_path.name == render.RENDER_JOB_FILENAME
assert json.loads(job_path.read_text(encoding="utf-8")) == job
def test_render_output_paths_include_frames(paths, settings, tmp_path):
plan = deform.frame_plan(settings)
paths_map = render.render_output_paths(tmp_path, plan)
for key in ("output_dir", "job", "manifest", "frames_npz", "frames_dir"):
assert key in paths_map
pngs = paths_map["frames"]
assert len(pngs) == FRAME_COUNT + 1
assert pngs[REFERENCE_NAME].name == "000_reference.png"
assert pngs[REFERENCE_NULL_NAME].name == "reference_null.png"
assert pngs[REFERENCE_NAME].parent == paths_map["frames_dir"]
# ----------------------------------------------------------------------
# Host-side: marker texture resolution
# ----------------------------------------------------------------------
def test_texture_resolves_relative_to_texture_output_dir(paths, settings):
expected = (
REPO / "build" / "textures" / "finger-xynova" / "r01" / "patch_texture_compute.png"
).resolve()
assert render.resolve_marker_texture(REPO, paths, settings) == expected
assert expected.is_file()
def test_texture_honours_explicit_texture_dir(paths, settings, tmp_path):
texture_dir = tmp_path / "textures"
texture_dir.mkdir()
texture = texture_dir / settings.texture
texture.write_bytes(b"\x89PNG\r\n\x1a\n")
resolved = render.marker_texture_path(
REPO, paths, settings.texture, texture_dir=texture_dir
)
assert resolved == texture.resolve()
def test_missing_texture_reports_exit_3(paths, settings, tmp_path):
texture_dir = tmp_path / "textures"
texture_dir.mkdir()
with pytest.raises(render.RenderArtifactError) as excinfo:
render.resolve_marker_texture(
REPO, paths, dataclasses.replace(settings, texture="absent.png"), texture_dir=texture_dir
)
assert excinfo.value.exit_code == 3
assert "absent.png" in str(excinfo.value)
def test_empty_texture_reports_exit_3(paths, settings, tmp_path):
texture_dir = tmp_path / "textures"
texture_dir.mkdir()
(texture_dir / settings.texture).write_bytes(b"")
with pytest.raises(render.RenderArtifactError) as excinfo:
render.resolve_marker_texture(REPO, paths, settings, texture_dir=texture_dir)
assert excinfo.value.exit_code == 3
assert "empty" in str(excinfo.value)
def test_build_render_job_requires_the_texture(paths, settings, tmp_path):
missing = dataclasses.replace(settings, texture="not_generated.png")
with pytest.raises(render.RenderArtifactError) as excinfo:
render.build_render_job(paths, missing, tmp_path)
assert excinfo.value.exit_code == 3
def test_require_artifact_on_empty_and_missing(tmp_path):
empty = tmp_path / "empty.bin"
empty.write_bytes(b"")
with pytest.raises(render.RenderArtifactError):
render.require_artifact(empty, "thing")
with pytest.raises(render.RenderArtifactError):
render.require_artifact(tmp_path / "nope.bin", "thing")
real = tmp_path / "real.bin"
real.write_bytes(b"x")
assert render.require_artifact(real, "thing") == real
# ----------------------------------------------------------------------
# Host-side: CLI overrides
# ----------------------------------------------------------------------
def test_parse_resolution_variants():
assert render.parse_resolution("640x480") == (640, 480)
assert render.parse_resolution("640,480") == (640, 480)
assert render.parse_resolution("640X480") == (640, 480)
assert render.parse_resolution([250, 250]) == (250, 250)
for bad in ("640", "640x", "0x100", "-4x5", "axb", None, 500):
with pytest.raises(render.RenderConfigError):
render.parse_resolution(bad)
def test_render_overrides_from_args():
args = argparse.Namespace(
resolution="640x480",
samples=8,
seed=3,
uv_map="produce",
texture=None,
width=None,
height=None,
)
assert render.render_overrides_from_args(args) == {
"samples": 8,
"seed": 3,
"uv_map": "produce",
"width": 640,
"height": 480,
}
args.width, args.height = 320, 240
overrides = render.render_overrides_from_args(args)
assert (overrides["width"], overrides["height"]) == (320, 240)
empty = render.render_overrides_from_args(argparse.Namespace())
assert empty == {}
# ----------------------------------------------------------------------
# Host-side: manifest loading
# ----------------------------------------------------------------------
def _write_manifest(directory: Path, payload: object) -> Path:
directory.mkdir(parents=True, exist_ok=True)
path = directory / render.MANIFEST_FILENAME
path.write_text(json.dumps(payload), encoding="utf-8")
return path
def test_load_render_manifest_accepts_a_valid_manifest(tmp_path):
payload = {
"schema": MANIFEST_SCHEMA,
"width": 500,
"height": 500,
"frames": [{"index": 0, "name": REFERENCE_NAME, "png": "frames/000_reference.png"}],
}
_write_manifest(tmp_path, payload)
assert render.load_render_manifest(tmp_path) == payload
def test_load_render_manifest_rejects_missing_empty_and_invalid(tmp_path):
with pytest.raises(render.RenderArtifactError) as excinfo:
render.load_render_manifest(tmp_path / "absent")
assert excinfo.value.exit_code == 3
empty_dir = tmp_path / "empty"
empty_dir.mkdir()
(empty_dir / render.MANIFEST_FILENAME).write_bytes(b"")
with pytest.raises(render.RenderArtifactError):
render.load_render_manifest(empty_dir)
bad_dir = tmp_path / "bad"
_write_manifest(bad_dir, {"schema": "wrong", "frames": []})
with pytest.raises(render.RenderArtifactError):
render.load_render_manifest(bad_dir)
no_frames = tmp_path / "noframes"
_write_manifest(no_frames, {"schema": MANIFEST_SCHEMA, "frames": []})
with pytest.raises(render.RenderArtifactError):
render.load_render_manifest(no_frames)
not_json = tmp_path / "notjson"
not_json.mkdir()
(not_json / render.MANIFEST_FILENAME).write_text("{", encoding="utf-8")
with pytest.raises(render.RenderArtifactError):
render.load_render_manifest(not_json)
def test_manifest_frame_entries_make_png_paths_absolute(tmp_path):
manifest = {
"frames": [
{"name": REFERENCE_NAME, "png": "frames/000_reference.png"},
{"name": "001_x_+0.01mm", "png": "frames/001_x_+0.01mm.png"},
]
}
entries = render.manifest_frame_entries(manifest, tmp_path)
assert entries[0]["png_path"] == (
Path(tmp_path).resolve() / "frames" / "000_reference.png"
)
assert entries[1]["png_path"].name == "001_x_+0.01mm.png"
with pytest.raises(render.RenderArtifactError):
render.manifest_frame_entries({"frames": [{"name": "x"}]}, tmp_path)
# ----------------------------------------------------------------------
# Determinism gate: decoded-pixel tolerance
#
# Regression for the real nx=36 defect: Cycles CPU thread-order rounding moved a
# boundary pixel by 1 LSB, the old whole-file byte comparison rejected candidate
# nx=36, and a content-dependent gate silently biases the pattern search.
# ----------------------------------------------------------------------
def _baseline(height: int = 250, width: int = 250) -> np.ndarray:
rng = np.random.default_rng(20260924)
return rng.integers(0, 256, size=(height, width, 4), dtype=np.uint8)
def _gate(probe_render, tmp_path: Path, before: np.ndarray, after: np.ndarray) -> dict:
return probe_render.compare_reference_renders(
_write_png(tmp_path / "a.png", before),
_write_png(tmp_path / "b.png", after),
)
def test_determinism_gate_accepts_identical_renders(probe_render, tmp_path):
image = _baseline()
result = _gate(probe_render, tmp_path, image, image.copy())
assert result["passed"] is True
assert result["max_abs_delta"] == 0
assert result["differing_pixels"] == 0
assert result["differing_fraction"] == 0.0
assert result["total_pixels"] == 250 * 250
def test_determinism_gate_accepts_one_lsb_on_a_couple_of_pixels(probe_render, tmp_path):
"""Exactly the nx=36 case: 2 pixels tip by one 8-bit step. Must PASS."""
before = _baseline()
after = before.copy()
after[17, 23, 1] = (int(after[17, 23, 1]) + 1) % 256
after[201, 88, 3] = (int(after[201, 88, 3]) - 1) % 256
result = _gate(probe_render, tmp_path, before, after)
assert result["passed"] is True
assert result["max_abs_delta"] == 1
assert result["max_abs_delta_tolerance"] == 1
assert result["differing_pixels"] == 2
assert result["differing_fraction"] == pytest.approx(2 / (250 * 250))
assert result["differing_fraction"] <= probe_render.DETERMINISM_MAX_DIFFERING_FRACTION
def test_determinism_gate_rejects_more_than_one_lsb(probe_render, tmp_path):
before = _baseline()
after = before.copy()
after[5, 5, 0] = int(after[5, 5, 0]) + 2
result = _gate(probe_render, tmp_path, before, after)
assert result["passed"] is False
assert result["max_abs_delta"] == 2
assert result["differing_pixels"] == 1
def test_determinism_gate_rejects_too_many_differing_pixels(probe_render, tmp_path):
"""1 LSB is fine per pixel, but content-dependent drift on >0.05% is not."""
before = _baseline(height=200, width=200)
after = before.copy()
total = 200 * 200
over_threshold = int(total * probe_render.DETERMINISM_MAX_DIFFERING_FRACTION) + 1
for index in range(over_threshold):
y, x = divmod(index, 200)
after[y, x, 0] = (int(after[y, x, 0]) + 1) % 256
result = _gate(probe_render, tmp_path, before, after)
assert result["passed"] is False
assert result["max_abs_delta"] == 1
assert result["differing_fraction"] > probe_render.DETERMINISM_MAX_DIFFERING_FRACTION
assert result["differing_pixels"] == over_threshold
def test_determinism_gate_accepts_the_exact_fraction_boundary(probe_render, tmp_path):
before = _baseline(height=200, width=200)
after = before.copy()
exactly = 20 # 20/40000 == 0.05%
assert 20 / (200 * 200) == probe_render.DETERMINISM_MAX_DIFFERING_FRACTION
for index in range(exactly):
y, x = divmod(index, 200)
after[y, x, 2] = (int(after[y, x, 2]) + 1) % 256
result = _gate(probe_render, tmp_path, before, after)
assert result["passed"] is True
assert result["differing_pixels"] == exactly
def test_determinism_gate_rejects_a_shape_mismatch(probe_render, tmp_path):
a = _write_png(tmp_path / "a.png", _baseline(height=250, width=250))
b = _write_png(tmp_path / "b.png", _baseline(height=249, width=250))
with pytest.raises(probe_render.SceneError):
probe_render.compare_reference_renders(a, b)
@pytest.mark.parametrize("filter_type", [0, 1, 2, 3, 4])
def test_decoder_round_trips_every_png_filter(probe_render, tmp_path, filter_type):
image = _baseline(height=13, width=9)
path = _write_png(tmp_path / f"f{filter_type}.png", image, filter_type=filter_type)
decoded = probe_render.decode_png_rgba8(path)
assert decoded.shape == image.shape
assert np.array_equal(decoded, image)
def test_decoder_rejects_non_rgba_png(probe_render, tmp_path):
width, height = 4, 2
raw = b"".join(b"\x00" + bytes(width * 3) for _ in range(height))
path = tmp_path / "rgb.png"
path.write_bytes(
b"\x89PNG\r\n\x1a\n"
+ _png_chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0))
+ _png_chunk(b"IDAT", zlib.compress(raw))
+ _png_chunk(b"IEND", b"")
)
with pytest.raises(probe_render.SceneError):
probe_render.decode_png_rgba8(path)
def test_strip_removes_only_volatile_text_chunks(probe_render, tmp_path):
image = _baseline(height=6, width=5)
path = _write_png(tmp_path / "with_meta.png", image)
chunks = path.read_bytes()
# Rebuild with volatile and non-volatile tEXt chunks interleaved.
extra = (
_png_chunk(b"tEXt", b"Date\x0001/01/2026")
+ _png_chunk(b"tEXt", b"cycles.ViewLayer.total_time\x001.23")
+ _png_chunk(b"tEXt", b"cycles.ViewLayer.samples\x0016")
+ _png_chunk(b"tEXt", b"Time\x0012:00:00")
)
ihdr_end = 8 + 12 + struct.unpack(">I", chunks[8:12])[0]
path.write_bytes(chunks[:ihdr_end] + extra + chunks[ihdr_end:])
dropped = probe_render.strip_volatile_text_chunks(path)
assert dropped == 3
remaining = path.read_bytes()
assert b"cycles.ViewLayer.samples\x0016" in remaining # deterministic, kept
assert b"Date\x00" not in remaining
assert b"Time\x0012" not in remaining
assert b"total_time" not in remaining
assert np.array_equal(probe_render.decode_png_rgba8(path), image)
# ----------------------------------------------------------------------
# Blender-backed: refusals and end to end
# ----------------------------------------------------------------------
@requires_blender
def test_render_refuses_a_non_standard_view_transform(paths, settings, tmp_path):
job = render.build_render_job(paths, settings, tmp_path)
job["settings"]["view_transform"] = "AgX"
# Proves the refusal runs before any artifact is loaded: this path is absent.
job["artifacts"]["frames_npz"] = str(tmp_path / "absent.npz")
job_path = render.write_render_job(job, tmp_path)
proc = _run_blender(RENDER_SCRIPT, job_path)
assert proc.returncode == 2, proc.stdout + proc.stderr
assert "view_transform" in (proc.stdout + proc.stderr)
assert not (tmp_path / render.FRAMES_DIRNAME).exists()
@requires_blender
@pytest.mark.parametrize("missing", ["frames_npz", "texture"])
def test_render_exits_3_when_a_required_artifact_is_missing(
paths, settings, tmp_path, missing, real_npz
):
out = tmp_path / missing
out.mkdir(parents=True, exist_ok=True)
job = render.build_render_job(paths, settings, out, samples=1)
shutil.copy2(real_npz, out / deform.FRAMES_NPZ_FILENAME)
absent = out / "absent.png"
if missing == "frames_npz":
job["artifacts"]["frames_npz"] = str(out / "absent.npz")
else:
job["settings"]["texture"] = str(absent)
job_path = render.write_render_job(job, out)
proc = _run_blender(RENDER_SCRIPT, job_path)
assert proc.returncode == 3, proc.stdout + proc.stderr
assert "not found" in (proc.stdout + proc.stderr)
@pytest.fixture(scope="module")
def real_npz(tmp_path_factory) -> Path:
"""The deform stage's contract artifact, produced by T1's script if absent."""
canonical = deform.probe_artifact_paths(deform.probe_output_dir(REPO, paths_for_deform()))[
"frames"
]
if canonical.is_file() and canonical.stat().st_size > 0:
return canonical
if BLENDER is None:
pytest.skip("probe_frames.npz absent and no Blender to build it")
scratch = tmp_path_factory.mktemp("t2_deform")
rev = RevisionPaths.from_revision_dir(REVISION_DIR)
probe = deform.resolve_probe(rev)
job = deform.build_deform_job(rev, probe, scratch)
job_path = deform.write_deform_job(job, scratch)
proc = _run_blender(DEFORM_SCRIPT, job_path)
assert proc.returncode == 0, proc.stdout + proc.stderr
npz = scratch / deform.FRAMES_NPZ_FILENAME
assert npz.is_file() and npz.stat().st_size > 0
return npz
def paths_for_deform() -> RevisionPaths:
return RevisionPaths.from_revision_dir(REVISION_DIR)
@requires_blender
def test_end_to_end_render_manifest(paths, settings, real_npz, tmp_path):
out = tmp_path / "render"
plan = deform.frame_plan(settings)
job = render.build_render_job(paths, settings, out, samples=4)
out.mkdir(parents=True, exist_ok=True)
shutil.copy2(real_npz, out / deform.FRAMES_NPZ_FILENAME)
job_path = render.write_render_job(job, out)
proc = _run_blender(RENDER_SCRIPT, job_path)
assert proc.returncode == 0, proc.stdout + proc.stderr
manifest_path = render.render_manifest_path(out)
assert manifest_path.is_file()
manifest = render.load_render_manifest(out)
# --- identity and settings that must never silently change ---
assert manifest["schema"] == MANIFEST_SCHEMA
assert manifest["uv_map"] == "compute"
width, height = tuple(settings.resolution)
assert (manifest["width"], manifest["height"]) == (width, height)
applied = manifest["settings_applied"]
assert applied["engine"] == "CYCLES"
assert applied["samples"] == 4
assert applied["denoise"] is False
assert applied["view_transform"] == "Standard"
assert applied["look"] == "None"
assert applied["exposure"] == 0.0
assert applied["interpolate"] == "Closest"
assert applied["seed"] == 0
assert manifest["reference_index"] == 0
assert manifest["reference_deterministic"] is True
determinism = manifest["reference_determinism"]
assert determinism["passed"] is True
assert determinism["max_abs_delta"] <= determinism["max_abs_delta_tolerance"] == 1
assert determinism["differing_fraction"] <= determinism["differing_fraction_tolerance"]
assert determinism["differing_fraction_tolerance"] == 0.0005
assert determinism["total_pixels"] == width * height
assert determinism["shape"] == [width, height]
# --- the source blend is untouched (opened read-only, never saved) ---
# The invariant is that the pipeline did not modify the blend, so the manifest's
# recorded hash must equal the hash on disk. It is deliberately NOT pinned to a
# literal: the blend is the user's file and they change its resolution (this repo
# moved 500x500 -> 250x250), which is a legitimate edit, not a regression.
assert manifest["source_blend"]["path"] == str(SOURCE_BLEND.resolve())
assert manifest["source_blend"]["sha256"].lower() == sha256_file(SOURCE_BLEND).lower()
texture = render.resolve_marker_texture(REPO, paths, settings)
assert manifest["texture"]["path"] == str(texture)
assert manifest["texture"]["sha256"] == sha256_file(texture)
# --- measured scales ---
mesh_scale = manifest["mesh_px_per_mm"]
assert (
mesh_scale["min"]
<= mesh_scale["p05"]
<= mesh_scale["p50"]
<= mesh_scale["p95"]
<= mesh_scale["max"]
)
# MESH_PX_PER_MM is measured at 500x500, and px/mm is linear in the render width, so
# scale it to the resolution the design declares rather than re-pinning it every time
# the bench is re-targeted. Halving the width must halve the scale.
factor = width / 500.0
assert mesh_scale["min"] == pytest.approx(MESH_PX_PER_MM["min"] * factor, abs=0.02)
assert mesh_scale["p50"] == pytest.approx(MESH_PX_PER_MM["p50"] * factor, abs=0.02)
assert mesh_scale["max"] == pytest.approx(MESH_PX_PER_MM["max"] * factor, abs=0.05)
assert mesh_scale["basis"] == "per-mesh-edge projected px / 3D mm, all 7341 edges"
probe_scale = manifest["probe_px_per_mm"]
axis_scales = [probe_scale["x"], probe_scale["y"], probe_scale["z"]]
assert probe_scale["p50"] == pytest.approx(sorted(axis_scales)[1])
# Normal motion is largely out of the image plane: a real sensor property.
assert probe_scale["z"] < probe_scale["x"]
assert probe_scale["z"] < probe_scale["y"]
assert probe_scale["basis"] == (
"probe-local: |centroid_px(step) - centroid_px(0)| / step_mm, "
"per axis at its largest step"
)
roi = manifest["probe_roi_px"]
assert roi["radius"] == pytest.approx(0.5 * probe_scale["p50"], rel=1e-12)
reference_centroid = manifest["frames"][0]["centroid_px"]
assert reference_centroid[0] == pytest.approx(roi["u"], abs=1e-9)
assert reference_centroid[1] == pytest.approx(roi["v"], abs=1e-9)
# --- every planned frame, in order, with matching bytes ---
assert len(manifest["frames"]) == FRAME_COUNT
assert [entry["name"] for entry in manifest["frames"]] == [frame.name for frame in plan]
assert [entry["axis"] for entry in manifest["frames"]] == [
frame.axis for frame in plan
]
for entry in manifest["frames"]:
png = out / entry["png"]
assert png.is_file() and png.stat().st_size > 0
assert entry["sha256"] == sha256_file(png)
assert _png_size(png) == (width, height)
assert (entry["width"], entry["height"]) == (width, height)
assert len(entry["centroid_px"]) == 2
assert all(isinstance(value, float) for value in entry["centroid_px"])
# frames/ holds exactly the plan plus the null frame, nothing else
written = {path.name for path in (out / render.FRAMES_DIRNAME).glob("*.png")}
assert written == {f"{frame.name}.png" for frame in plan} | {
f"{REFERENCE_NULL_NAME}.png"
}
# the per-frame centroids reproduce probe_px_per_mm
for axis in ("x", "y", "z"):
axis_entries = [entry for entry in manifest["frames"] if entry["axis"] == axis]
assert axis_entries
last = max(axis_entries, key=lambda entry: entry["step_mm"])
measured = math.dist(last["centroid_px"], reference_centroid) / last["step_mm"]
assert measured == pytest.approx(probe_scale[axis], rel=1e-9)
# --- null frame ---
null = manifest["null"]
assert null["name"] == REFERENCE_NULL_NAME
assert null["seed"] == 1
null_png = out / null["png"]
assert null_png.is_file() and null_png.stat().st_size > 0
assert null["sha256"] == sha256_file(null_png)
# The whole 0.1 mm sweep moves the centroid only ~1.3 px, so a distinct
# texture at the last frame must differ from the reference.
assert manifest["frames"][-1]["sha256"] != manifest["frames"][0]["sha256"]
+971
View File
@@ -0,0 +1,971 @@
"""Tests for the probe sensitivity scoring and report.
The strongest evidence is the synthetic case with a *known injected gain*: the
scorer must recover exactly the gain it was handed. The magnitudes used here are
the real measured probe-local scales (x 13.248, y 17.670, z 1.519 px/mm at
500x500; halved at the 250x250 process scale) so the gated and ungated paths are
both exercised at true magnitudes.
"""
from __future__ import annotations
import json
from dataclasses import replace
from pathlib import Path
from types import SimpleNamespace
import numpy as np
import pytest
from orisys_sensor_design import sensitivity as sens
H = W = 48
ROI = sens.Roi(u=24.0, v=24.0, radius=5.0)
PROCESS_H = PROCESS_W = 24
PROCESS_ROI = sens.Roi(
u=(ROI.u + 0.5) / 2.0 - 0.5,
v=(ROI.v + 0.5) / 2.0 - 0.5,
radius=ROI.radius / 2.0,
)
DIRECTION = (0.9995, -0.0310)
DIRECTION_UNIT = tuple(
float(value) / float(np.hypot(*DIRECTION)) for value in DIRECTION
)
STEPS = tuple(index * 0.01 for index in range(1, 11))
RENDER_PX_PER_MM = 13.2480
PROCESS_PX_PER_MM = 6.6240
#: Real measured per-axis probe-local scales (px per mm).
RENDER_LOCAL = {"x": 13.2480, "y": 17.6704, "z": 1.5193}
PROCESS_LOCAL = {"x": 6.6240, "y": 8.8352, "z": 0.75965}
#: Injecting gain == probe-local scale models a rigid probe: all applied
#: displacement shows up in the image, so gain_mm_per_mm ~ 1.
AXIS_GAIN = {"x": RENDER_LOCAL["x"], "y": RENDER_LOCAL["y"], "z": RENDER_LOCAL["z"]}
AXIS_SEED = {"x": 11, "y": 22, "z": 33}
# --------------------------------------------------------------------------- #
# Synthetic flow builders
# --------------------------------------------------------------------------- #
def _injected_flow(
gain_px_per_mm: float,
*,
seed: int,
floor_sigma: float = 0.0255,
perpendicular_px_per_mm: float = 0.0,
shape: tuple[int, int] = (H, W),
roi: sens.Roi = ROI,
) -> tuple[list[sens.FrameFlow], np.ndarray]:
"""Frames whose ROI-mean response is ``gain * step`` (plus noise)."""
height, width = shape
rng = np.random.default_rng(seed)
frames = [
sens.FrameFlow(
index=0,
name="000_reference",
axis=None,
step_mm=0.0,
roi=roi,
flow=np.zeros((height, width, 2), dtype=np.float64),
centroid_px=(roi.u, roi.v),
)
]
perpendicular = (-DIRECTION_UNIT[1], DIRECTION_UNIT[0])
for index, step in enumerate(STEPS, start=1):
signal = gain_px_per_mm * step
frame = np.zeros((height, width, 2), dtype=np.float64)
frame[..., 0] = signal * DIRECTION_UNIT[0]
frame[..., 1] = signal * DIRECTION_UNIT[1]
if perpendicular_px_per_mm:
leak = perpendicular_px_per_mm * step
frame[..., 0] += leak * perpendicular[0]
frame[..., 1] += leak * perpendicular[1]
frame += rng.normal(0.0, floor_sigma, (height, width, 2))
frames.append(
sens.FrameFlow(
index=index,
name=f"{index:03d}_frame",
axis="x",
step_mm=step,
roi=roi,
flow=frame,
centroid_px=(
roi.u + signal * DIRECTION_UNIT[0],
roi.v + signal * DIRECTION_UNIT[1],
),
)
)
null = rng.normal(0.0, floor_sigma, (height, width, 2))
return frames, null
def _axis_frames(
axis: str,
gain_px_per_mm: float,
*,
seed: int,
shape: tuple[int, int] = (H, W),
roi: sens.Roi = ROI,
perpendicular_px_per_mm: float = 0.0,
) -> tuple[list[sens.FrameFlow], np.ndarray]:
frames, null = _injected_flow(
gain_px_per_mm,
seed=seed,
shape=shape,
roi=roi,
perpendicular_px_per_mm=perpendicular_px_per_mm,
)
renamed: list[sens.FrameFlow] = []
for frame in frames:
if frame.step_mm == 0.0:
renamed.append(replace(frame, name="000_reference", axis=None))
else:
renamed.append(replace(frame, name=f"{axis}_{frame.step_mm:.2f}", axis=axis))
return renamed, null
def _score(
gain_px_per_mm: float = RENDER_LOCAL["x"],
*,
axis: str = "x",
seed: int = 0,
scale_px_per_mm: float | None = RENDER_LOCAL["x"],
null_flow: np.ndarray | None = None,
manifest_scale_factor: float = 1.0,
perpendicular_px_per_mm: float = 0.0,
**kwargs: object,
) -> sens.AxisScore:
frames, null = _axis_frames(
axis, gain_px_per_mm, seed=seed, perpendicular_px_per_mm=perpendicular_px_per_mm
)
return sens.score_axis(
frames,
axis,
px_per_mm=RENDER_PX_PER_MM,
null_flow=null if null_flow is None else null_flow,
scale_px_per_mm=scale_px_per_mm,
manifest_scale_factor=manifest_scale_factor,
**kwargs,
)
# --------------------------------------------------------------------------- #
# Synthetic known-gain recovery (primary correctness evidence)
# --------------------------------------------------------------------------- #
def test_known_gain_recovered_in_px_per_mm() -> None:
injected = RENDER_LOCAL["x"]
score = _score(injected)
assert score.gain_px_per_mm is not None
recovered = score.gain_px_per_mm
tolerance = 0.20 # 1.5 % of 13.248
assert abs(recovered - injected) < tolerance, (
f"injected gain {injected} px/mm, recovered {recovered:.6f} px/mm; "
f"tolerance {tolerance}"
)
assert 0.030 < score.floor_px < 0.070, score.floor_px
assert score.floor_source == "null_projection_p95"
assert score.direction_source == "centroid_regression"
assert score.direction_px[0] == pytest.approx(DIRECTION_UNIT[0], abs=5e-3)
assert score.direction_px[1] == pytest.approx(DIRECTION_UNIT[1], abs=5e-3)
expected_snr = abs(recovered) * 0.10 / score.floor_px
assert score.snr == pytest.approx(expected_snr, rel=1e-9)
assert score.gain_mm_per_mm == pytest.approx(recovered / RENDER_LOCAL["x"], rel=1e-9)
assert score.gain_gate == "derived"
assert score.verdict == "resolvable"
print(
f"\n[synthetic] injected={injected} px/mm recovered={recovered:.6f} px/mm "
f"tol={tolerance} floor={score.floor_px:.6f} px snr={score.snr:.4f} "
f"spearman={score.spearman:.4f} leak={score.cross_axis_leak:.4f} "
f"score={score.score:.4f}"
)
def test_known_gain_recovery_is_scale_agnostic() -> None:
frames, null = _injected_flow(4.0, seed=3, floor_sigma=0.01)
score = sens.score_axis(
frames, "x", px_per_mm=RENDER_PX_PER_MM, null_flow=null, scale_px_per_mm=4.0
)
assert score.gain_px_per_mm == pytest.approx(4.0, abs=0.05)
def test_gain_requires_three_points_above_floor_else_null() -> None:
score = _score(0.02)
assert score.gain_px_per_mm is None
assert score.gain_mm_per_mm is None
assert score.gain_gate == "no_gain"
assert score.resolvable_step_mm is None
assert score.verdict == "unresolvable"
# --------------------------------------------------------------------------- #
# The mm/mm gate
# --------------------------------------------------------------------------- #
def test_local_scale_gate_boundary_matches_the_measured_axes() -> None:
derived = _score(RENDER_LOCAL["z"], axis="z", scale_px_per_mm=RENDER_LOCAL["z"])
gated = _score(
PROCESS_LOCAL["z"], axis="z", scale_px_per_mm=PROCESS_LOCAL["z"]
)
# Render z = 1.519 px/mm clears the 1.0 threshold and keeps its ratio.
assert derived.gain_gate == "derived"
assert derived.gain_mm_per_mm is not None
# Process z = 0.760 px/mm is gated to null with no score inflation.
assert gated.gain_mm_per_mm is None, "sub-threshold local scale must not be divided"
assert gated.gain_gate == "below_min_local_scale"
assert gated.score < derived.score or gated.gain_px_per_mm is not None
def test_gate_does_not_inflate_the_score() -> None:
injected = RENDER_LOCAL["x"]
derived = _score(injected, scale_px_per_mm=RENDER_LOCAL["x"])
gated = _score(injected, scale_px_per_mm=0.4)
assert gated.gain_mm_per_mm is None
assert gated.gain_gate == "below_min_local_scale"
assert gated.gain_px_per_mm == pytest.approx(injected, abs=0.2)
assert gated.score == pytest.approx(derived.score, rel=1e-9)
assert gated.score < injected / 0.4
unknown = _score(injected, scale_px_per_mm=None)
assert unknown.gain_mm_per_mm is None
assert unknown.gain_gate == "scale_unknown"
def test_min_local_scale_constant_is_one() -> None:
assert sens.min_local_scale_px_per_mm == 1.0
assert sens.MIN_LOCAL_SCALE_PX_PER_MM == 1.0
# --------------------------------------------------------------------------- #
# applied_px must be per-axis, and per-scale
# --------------------------------------------------------------------------- #
def test_applied_px_uses_the_per_axis_scale() -> None:
frames, null = _axis_frames("z", AXIS_GAIN["z"], seed=AXIS_SEED["z"])
score = sens.score_axis(
frames,
"z",
px_per_mm=RENDER_PX_PER_MM,
null_flow=null,
scale_px_per_mm=RENDER_LOCAL["z"],
)
last = score.points[-1]
assert score.px_per_mm_axis is not None
assert last["applied_px"] == pytest.approx(0.10 * score.px_per_mm_axis, rel=1e-6)
# The isotropic scalar 13.248 would claim 1.3248 px; the true z motion is 0.152 px.
assert last["applied_px"] < 0.2
assert last["applied_px"] == pytest.approx(0.10 * RENDER_LOCAL["z"], abs=5e-3)
def test_px_per_mm_axis_and_applied_px_are_rescaled_to_the_scale() -> None:
frames, null = _axis_frames("z", AXIS_GAIN["z"], seed=AXIS_SEED["z"])
render = sens.score_axis(
frames,
"z",
px_per_mm=RENDER_PX_PER_MM,
null_flow=null,
scale_px_per_mm=RENDER_LOCAL["z"],
manifest_scale_factor=1.0,
)
process = sens.score_axis(
frames,
"z",
px_per_mm=PROCESS_PX_PER_MM,
null_flow=null,
scale_px_per_mm=PROCESS_LOCAL["z"],
manifest_scale_factor=0.5,
)
assert process.px_per_mm_axis == pytest.approx(render.px_per_mm_axis * 0.5, rel=1e-9)
assert process.points[-1]["applied_px"] == pytest.approx(
render.points[-1]["applied_px"] * 0.5, rel=1e-9
)
assert process.px_per_mm_axis == pytest.approx(PROCESS_LOCAL["z"], abs=5e-3)
# --------------------------------------------------------------------------- #
# Ambiguous (periodic-pattern aliasing) verdict
# --------------------------------------------------------------------------- #
def _find_axis(report: dict, axis: str, scale_name: str = "render") -> dict:
scale = next(item for item in report["scales"] if item["name"] == scale_name)
for entry in scale["axes"]:
if entry["axis"] == axis:
return entry
raise AssertionError(f"axis {axis!r} not in scale {scale_name!r}")
def _find_scale(report: dict, scale_name: str) -> dict:
return next(item for item in report["scales"] if item["name"] == scale_name)
def test_high_score_with_failed_reference_texture_known_answer_is_ambiguous(
tmp_path: Path,
) -> None:
report = _run_end_to_end(
tmp_path, pattern={"nx": 80, "ny": 80, "seed": 7}, known_answer_passed=False
)
x_axis = _find_axis(report, "x")
assert x_axis["summary"]["score"] > 0, "the score stays a measured number"
assert report["overall"]["score"] > 0
assert x_axis["summary"]["verdict"] == "ambiguous"
assert report["overall"]["verdict"] == "ambiguous"
assert report["overall"]["verdict"] != "resolvable"
aliasing = [w for w in report["warnings"] if "reference_texture_known_answer FAILED" in w]
assert aliasing, report["warnings"]
assert "measured" in aliasing[0] and "expected" in aliasing[0]
assert "8.17" in aliasing[0]
def test_passing_known_answer_does_not_force_ambiguous(tmp_path: Path) -> None:
report = _run_end_to_end(
tmp_path, pattern={"nx": 31, "ny": 31, "seed": 7}, known_answer_passed=True
)
assert _find_axis(report, "x")["summary"]["verdict"] == "resolvable"
assert report["overall"]["verdict"] != "ambiguous"
def test_ambiguous_outranks_unresolvable_and_marginal() -> None:
assert sens.VERDICT_SEVERITY["ambiguous"] < sens.VERDICT_SEVERITY["unresolvable"]
assert sens.VERDICT_SEVERITY["unresolvable"] < sens.VERDICT_SEVERITY["marginal"]
assert sens.VERDICT_SEVERITY["marginal"] < sens.VERDICT_SEVERITY["resolvable"]
# --------------------------------------------------------------------------- #
# Resolvable-step selection, leak, saturation, spearman
# --------------------------------------------------------------------------- #
def test_resolvable_step_is_first_step_above_floor() -> None:
score = _score()
above = [point for point in score.points if point["above_floor"]]
assert above, score.points
assert score.resolvable_step_mm == pytest.approx(above[0]["step_mm"])
assert score.resolvable_step_mm is not None and score.resolvable_step_mm > 0.0
def test_resolvable_step_none_when_nothing_clears_floor() -> None:
frames, _ = _axis_frames("x", 0.0, seed=0)
null = np.full((H, W, 2), 0.2)
score = sens.score_axis(
frames,
"x",
px_per_mm=RENDER_PX_PER_MM,
null_flow=null,
scale_px_per_mm=6.2,
direction_px=(1.0, 0.0),
)
assert score.floor_px == pytest.approx(0.2)
assert score.resolvable_step_mm is None
assert score.gain_px_per_mm is None
assert score.verdict == "unresolvable"
assert score.score == 0.0
def test_resolvable_step_skips_a_below_floor_first_step() -> None:
frames, null = _axis_frames("x", AXIS_GAIN["x"], seed=0)
first = frames[1]
frames[1] = replace(first, flow=np.zeros((H, W, 2)))
score = sens.score_axis(
frames,
"x",
px_per_mm=RENDER_PX_PER_MM,
null_flow=null,
scale_px_per_mm=RENDER_LOCAL["x"],
)
assert score.resolvable_step_mm == pytest.approx(0.02)
def test_leak_penalty_reduces_score() -> None:
clean = _score(seed=1, perpendicular_px_per_mm=0.0)
leaky = _score(seed=1, perpendicular_px_per_mm=RENDER_LOCAL["x"])
assert leaky.cross_axis_leak > clean.cross_axis_leak
assert leaky.gain_px_per_mm == pytest.approx(clean.gain_px_per_mm, abs=0.05)
assert leaky.score < clean.score
expected = leaky.snr * leaky.spearman * (1.0 - min(leaky.cross_axis_leak, 0.5))
assert leaky.score == pytest.approx(expected, rel=1e-9)
def test_leak_penalty_saturates_at_half() -> None:
frames, _ = _axis_frames("x", 0.0, seed=0)
perpendicular = (-DIRECTION_UNIT[1], DIRECTION_UNIT[0])
for index, step in enumerate(STEPS, start=1):
flow = np.zeros((H, W, 2))
flow[..., 0] = perpendicular[0] * step * 10.0
flow[..., 1] = perpendicular[1] * step * 10.0
frames[index] = replace(frames[index], flow=flow)
null = np.full((H, W, 2), 0.001)
score = sens.score_axis(
frames,
"x",
px_per_mm=RENDER_PX_PER_MM,
null_flow=null,
scale_px_per_mm=6.2,
direction_px=DIRECTION_UNIT,
)
assert score.cross_axis_leak == pytest.approx(1.0)
assert score.score == 0.0
def test_saturation_detected_at_first_decrease_after_a_rise() -> None:
frames, _ = _axis_frames("x", 0.0, seed=0)
peaks = (1, 2, 3, 4, 5, 4, 3, 2, 1, 1)
for index, (step, peak) in enumerate(zip(STEPS, peaks), start=1):
flow = np.zeros((H, W, 2))
flow[..., 0] = 0.20 * peak * DIRECTION_UNIT[0]
flow[..., 1] = 0.20 * peak * DIRECTION_UNIT[1]
frames[index] = replace(frames[index], flow=flow)
null = np.full((H, W, 2), 0.01)
score = sens.score_axis(
frames, "x", px_per_mm=RENDER_PX_PER_MM, null_flow=null, scale_px_per_mm=6.2
)
assert score.saturation_mm == pytest.approx(0.06)
def test_no_saturation_when_response_only_rises() -> None:
assert _score().saturation_mm is None
def test_spearman_is_clamped_to_zero_for_decreasing_response() -> None:
frames, _ = _axis_frames("x", 0.0, seed=0)
for index, step in enumerate(STEPS, start=1):
signal = 0.30 - (index - 1) * 0.02
flow = np.zeros((H, W, 2))
flow[..., 0] = signal * DIRECTION_UNIT[0]
flow[..., 1] = signal * DIRECTION_UNIT[1]
frames[index] = replace(frames[index], flow=flow)
null = np.full((H, W, 2), 0.01)
score = sens.score_axis(
frames, "x", px_per_mm=RENDER_PX_PER_MM, null_flow=null, scale_px_per_mm=6.2
)
assert score.spearman == 0.0
assert score.score == 0.0
def test_null_floor_is_the_p95_directional_projection() -> None:
frames, null = _axis_frames("x", AXIS_GAIN["x"], seed=5)
score = sens.score_axis(
frames,
"x",
px_per_mm=RENDER_PX_PER_MM,
null_flow=null,
scale_px_per_mm=RENDER_LOCAL["x"],
)
mask = ROI.mask(H, W)
direction = score.direction_px
projection = np.abs(null[..., 0] * direction[0] + null[..., 1] * direction[1])
assert score.floor_px == pytest.approx(float(np.percentile(projection[mask], 95.0)))
def test_cell_px_uses_the_physical_cell_not_the_camera() -> None:
frames, null = _axis_frames("x", AXIS_GAIN["x"], seed=0)
scale = sens.ScaleInput(
name="render",
width=W,
height=H,
px_per_mm=RENDER_PX_PER_MM,
frames=tuple(frames),
null_flow=null,
step_mm=0.01,
local_scale_px_per_mm={"x": RENDER_LOCAL["x"]},
local_scale_source="per_axis",
mesh_px_per_mm=13.4302,
)
scored = sens.score_scale(scale, cell_mm=[18.5 / 31, 16.4 / 31])
assert scored.cell_px == pytest.approx((18.5 / 31) * RENDER_PX_PER_MM)
assert scored.cell_over_patch_px == pytest.approx((18.5 / 31) * RENDER_PX_PER_MM / 8.0)
assert scored.displacement_px_per_step == pytest.approx(0.01 * RENDER_PX_PER_MM)
# --------------------------------------------------------------------------- #
# End-to-end report: schema, precedence, determinism, exit codes
# --------------------------------------------------------------------------- #
def _write_flow_scale(
flow_dir: Path, name: str, frames: list[sens.FrameFlow], null: np.ndarray
) -> None:
directory = flow_dir / name
directory.mkdir(parents=True, exist_ok=True)
for frame in frames:
if frame.step_mm == 0.0:
continue
np.save(directory / f"{frame.name}.npy", frame.flow.astype(np.float32))
np.save(directory / "reference_null.npy", null.astype(np.float32))
def _run_end_to_end(
tmp_path: Path,
*,
pattern: dict | None,
known_answer_passed: bool = True,
write_texture_job: bool = False,
settings_pattern: dict | None = None,
output_name: str = "out",
) -> dict:
output_dir = tmp_path / output_name
output_dir.mkdir(parents=True, exist_ok=True)
if pattern is not None and "cell_mm" not in pattern:
pattern = dict(pattern)
pattern["cell_mm"] = [18.5 / pattern["nx"], 16.4 / pattern["ny"]]
render_frames: dict[str, list[sens.FrameFlow]] = {}
process_frames: dict[str, list[sens.FrameFlow]] = {}
nulls: dict[str, tuple[np.ndarray, np.ndarray]] = {}
name_map: dict[tuple[str, float], str] = {}
manifest_frames: list[dict] = [
{
"index": 0,
"name": "000_reference",
"axis": None,
"step_mm": 0.0,
"centroid_px": [ROI.u, ROI.v],
}
]
index = 0
for axis in ("x", "y", "z"):
frames, null = _axis_frames(axis, AXIS_GAIN[axis], seed=AXIS_SEED[axis])
half_gain = AXIS_GAIN[axis] * (PROCESS_PX_PER_MM / RENDER_PX_PER_MM)
proc, proc_null = _axis_frames(
axis,
half_gain,
seed=AXIS_SEED[axis],
shape=(PROCESS_H, PROCESS_W),
roi=PROCESS_ROI,
)
render_frames[axis] = frames
process_frames[axis] = proc
nulls[axis] = (null, proc_null)
for frame in frames:
if frame.step_mm == 0.0:
continue
index += 1
name = f"{index:03d}_{axis}_+{frame.step_mm:.2f}mm"
name_map[(axis, frame.step_mm)] = name
manifest_frames.append(
{
"index": index,
"name": name,
"axis": axis,
"step_mm": frame.step_mm,
"centroid_px": list(frame.centroid_px),
}
)
def rename(frames: list[sens.FrameFlow], axis: str) -> list[sens.FrameFlow]:
renamed: list[sens.FrameFlow] = []
for frame in frames:
if frame.step_mm == 0.0:
continue
renamed.append(
replace(frame, name=name_map[(axis, frame.step_mm)], axis=axis)
)
return renamed
scale_specs = (
("render", W, H, RENDER_PX_PER_MM, ROI, render_frames, 0),
("process", PROCESS_W, PROCESS_H, PROCESS_PX_PER_MM, PROCESS_ROI, process_frames, 1),
)
report_frames_by_scale: dict[str, list[dict]] = {}
for name, width, height, px_per_mm, roi, frame_map, null_index in scale_specs:
report_frames: list[dict] = []
combined: list[sens.FrameFlow] = []
# Build the reference first so the flow directory has a consistent shape.
reference = sens.FrameFlow(
index=0,
name="000_reference",
axis=None,
step_mm=0.0,
roi=roi,
flow=np.zeros((height, width, 2)),
centroid_px=(ROI.u, ROI.v),
)
_write_flow_scale(output_dir / "flow", name, [reference], nulls["x"][null_index])
for axis in ("x", "y", "z"):
for frame in rename(frame_map[axis], axis):
combined.append(frame)
report_frames.append(
{
"index": frame.index,
"name": frame.name,
"axis": axis,
"step_mm": frame.step_mm,
"roi": {"u": roi.u, "v": roi.v, "radius": roi.radius},
"flow": {"mean_mag": 0.0},
"response_proj_px": 0.0,
"response_mm": 0.0,
}
)
# Per-axis nulls must share the scale's shape; write each axis's own null
# under a per-axis name and point every frame at the module's canonical
# reference_null by combining the three axes' nulls is not meaningful here.
# Instead: the null is scale-wide and identical for every axis, so use the x
# null (all axes share floor_sigma and seed-independent noise is fine).
_write_flow_scale(
output_dir / "flow",
name,
combined,
nulls["x"][null_index],
)
report_frames_by_scale[name] = report_frames
known_answer = {
"passed": known_answer_passed,
"gating": False,
"cases": [
{
"dx": 2,
"dy": 0,
"expected_u": 2.0,
"expected_v": 0.0,
"measured_u": 2.0 if known_answer_passed else 8.17,
"measured_v": 0.0,
}
],
}
flow_report = {
"schema": sens.FLOW_REPORT_SCHEMA,
"tracker": {"algorithm": "dis", "dis_preset": "medium", "dis_mode": 1},
"known_answer": {"passed": True},
"reference_texture_known_answer": known_answer,
"scales": [
{
"name": name,
"width": width,
"height": height,
"px_per_mm": px_per_mm,
"mm_per_px": 1.0 / px_per_mm,
"frames": report_frames_by_scale[name],
"null": {"floor_px": 0.05, "floor_source": "reference_null"},
}
for name, width, height, px_per_mm, _roi, _fm, _ni in scale_specs
],
}
(output_dir / "flow_report.json").write_text(json.dumps(flow_report), encoding="utf-8")
manifest = {
"schema": sens.RENDER_MANIFEST_SCHEMA,
"texture": {"path": str(output_dir / "marker.png"), "sha256": "abc123"},
"width": W,
"height": H,
"mesh_px_per_mm": {"min": 10.4994, "p05": 10.9457, "p50": 13.4302,
"p95": 22.8857, "max": 38.5943},
"probe_px_per_mm": {"p50": 13.2480, "x": 13.2480, "y": 17.6704, "z": 1.5193},
"probe_roi_px": {"u": ROI.u, "v": ROI.v, "radius": ROI.radius},
"reference_index": 0,
"reference_deterministic": True,
"frames": manifest_frames,
"null": {"name": "reference_null", "seed": 1},
}
(output_dir / "render_manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
if write_texture_job:
(output_dir / "_texture_job.json").write_text(
json.dumps(
{
"schema": "orisys-sensor-design/texture-job-v3",
"settings": {
"nx": 20,
"ny": 20,
"seed": 7,
"vector": {"width_mm": 18.5, "height_mm": 16.4},
},
}
),
encoding="utf-8",
)
settings = SimpleNamespace(
step_mm=0.01,
max_mm=0.10,
axes=["x", "y", "z"],
resolution=[500, 500],
process_resolution=[250, 250],
radius_mm=0.5,
falloff="smooth",
group="probe",
mesh="sensing_surface",
tracker={"dis_preset": "medium", "dis_mode": 1, "patch_px": 8},
pattern=settings_pattern,
)
report, code = sens.run_sensitivity(
settings, output_dir, pattern=pattern, progress=lambda message: None
)
assert code == 0, report.get("warnings")
assert (output_dir / sens.SENSITIVITY_REPORT_FILENAME).is_file()
return report
def test_report_schema_keys(tmp_path: Path) -> None:
report = _run_end_to_end(tmp_path, pattern={"nx": 31, "ny": 31, "seed": 7})
assert report["schema"] == sens.SENSITIVITY_REPORT_SCHEMA
for key in ("generated_at", "revision", "texture", "probe", "tracker", "scales",
"overall", "warnings", "notes"):
assert key in report, key
assert set(report["overall"]) >= {"score", "verdict", "best_scale", "best_axis"}
assert report["texture"]["grid"] == [31, 31]
assert report["texture"]["cell_mm"] == pytest.approx([18.5 / 31, 16.4 / 31])
assert [scale["name"] for scale in report["scales"]] == ["render", "process"]
for scale in report["scales"]:
for key in ("name", "px_per_mm", "displacement_px_per_step", "cell_px",
"cell_over_patch_px", "null_floor_px", "axes", "score", "verdict"):
assert key in scale, key
axis = _find_axis(report, "x")
assert set(axis) >= {"axis", "direction_px", "direction_source", "points", "summary"}
assert axis["direction_source"] in {
"centroid_regression",
"centroid_finite_difference",
"roi_mean_flow_at_max_step",
"provided",
}
assert set(axis["points"][0]) == {"step_mm", "applied_px", "response_px", "above_floor"}
for key in ("gain_px_per_mm", "gain_mm_per_mm", "scale_px_per_mm", "snr",
"spearman", "resolvable_step_mm", "saturation_mm",
"cross_axis_leak", "floor_px"):
assert key in axis["summary"], key
def test_report_is_deterministic_given_same_inputs(tmp_path: Path) -> None:
first = _run_end_to_end(tmp_path, pattern={"nx": 31, "ny": 31, "seed": 7})
second = _run_end_to_end(tmp_path, pattern={"nx": 31, "ny": 31, "seed": 7})
first.pop("generated_at")
second.pop("generated_at")
assert json.dumps(first, sort_keys=True) == json.dumps(second, sort_keys=True)
def test_pattern_precedence_explicit_beats_texture_job(tmp_path: Path) -> None:
report = _run_end_to_end(
tmp_path, pattern={"nx": 31, "ny": 31, "seed": 7}, write_texture_job=True
)
assert report["texture"]["grid"] == [31, 31]
assert "pattern argument" in " ".join(report["notes"])
def test_pattern_falls_back_to_texture_job(tmp_path: Path) -> None:
report = _run_end_to_end(tmp_path, pattern=None, write_texture_job=True)
assert report["texture"]["grid"] == [20, 20]
assert report["texture"]["cell_mm"] == pytest.approx([18.5 / 20, 16.4 / 20])
assert "_texture_job.json" in " ".join(report["notes"])
def test_pattern_unknown_is_null_not_a_guessed_80x80(tmp_path: Path) -> None:
report = _run_end_to_end(tmp_path, pattern=None, write_texture_job=False)
assert report["texture"]["grid"] is None
assert report["texture"]["cell_mm"] is None
assert report["scales"][0]["cell_px"] is None
assert any("unknown" in note for note in report["notes"])
def test_settings_pattern_is_used_when_no_argument(tmp_path: Path) -> None:
report = _run_end_to_end(
tmp_path,
pattern=None,
settings_pattern={"nx": 40, "ny": 40, "seed": 7, "cell_mm": [0.4625, 0.41]},
)
assert report["texture"]["grid"] == [40, 40]
assert report["texture"]["cell_mm"] == pytest.approx([0.4625, 0.41])
assert "settings.pattern" in " ".join(report["notes"])
def test_missing_artifact_returns_3_and_still_writes_a_report(tmp_path: Path) -> None:
output_dir = tmp_path / "empty"
output_dir.mkdir()
settings = SimpleNamespace(step_mm=0.01, radius_mm=0.5, falloff="smooth")
report, code = sens.run_sensitivity(settings, output_dir, progress=lambda m: None)
assert code == 3
path = output_dir / sens.SENSITIVITY_REPORT_FILENAME
assert path.is_file()
written = json.loads(path.read_text(encoding="utf-8"))
assert written["schema"] == sens.SENSITIVITY_REPORT_SCHEMA
assert written["warnings"]
assert written["overall"]["verdict"] == "unresolvable"
def test_degraded_report_on_bad_flow_report(tmp_path: Path) -> None:
output_dir = tmp_path / "bad"
output_dir.mkdir()
(output_dir / "flow_report.json").write_text("{not json", encoding="utf-8")
(output_dir / "render_manifest.json").write_text("{}", encoding="utf-8")
report, code = sens.run_sensitivity(SimpleNamespace(step_mm=0.01), output_dir, progress=lambda m: None)
assert code == 1
assert (output_dir / sens.SENSITIVITY_REPORT_FILENAME).is_file()
assert report["error"]["kind"] == "data"
def test_subpixel_and_cell_warnings_are_emitted(tmp_path: Path) -> None:
report = _run_end_to_end(tmp_path, pattern={"nx": 80, "ny": 80, "seed": 7})
joined = " ".join(report["warnings"])
assert "below one pixel" in joined
# The cell warning is now about representability, not a "DIS patch Nyquist limit"
# statement derived from the old millimetre-based cell.
assert "cannot be represented at all" in joined
# nx=80 at 500 px is a 6.25 px cell against a 16 px effective window (ratio 0.39),
# so the effective-window guidance must fire and name the ratio.
assert "effective DIS window" in joined
def test_cell_px_is_the_camera_space_cell_and_effective_window_is_reported(
tmp_path: Path,
) -> None:
"""The governing numbers must be camera-space, and consistent with each other."""
report = _run_end_to_end(tmp_path, pattern={"nx": 80, "ny": 80, "seed": 7})
scale = next(item for item in report["scales"] if item["name"] == "render")
assert scale["reference_width_px"] == scale["width"]
assert scale["cell_px"] == pytest.approx(scale["width"] / 80.0, rel=1e-9)
assert scale["cell_px_source"].startswith("render_width/nx")
# patch 8 px x 2**finest_scale; the fixture tracker config uses dis_mode=1.
assert scale["effective_patch_px"] == pytest.approx(16.0, rel=1e-9)
assert scale["cell_over_effective_patch_px"] == pytest.approx(
scale["cell_px"] / scale["effective_patch_px"], rel=1e-9
)
assert scale["cell_over_patch_px"] == pytest.approx(scale["cell_px"] / 8.0, rel=1e-9)
def test_nx31_is_near_the_effective_window_on_a_500px_render() -> None:
"""The regression that pins the corrected interpretation.
nx=31 on a 500 px render is a 16.13 px cell, which is 1.008x the 16 px effective
window of an 8 px patch at finest_scale 1 -- the measured render-scale optimum.
"""
win = sens.cell_metrics(width=500, nx=31, cell_mm=(0.5968, 0.529), px_per_mm=13.248,
finest_scale=1)
assert win["cell_px"] == pytest.approx(16.129, rel=1e-3)
assert win["cell_px_source"].startswith("render_width/nx")
assert win["effective_patch_px"] == pytest.approx(16.0, rel=1e-9)
assert win["cell_over_effective_patch_px"] == pytest.approx(1.008, rel=1e-2)
# The old, wrong definition is still recorded for comparison, and differs ~2x.
assert win["nominal_cell_px"] == pytest.approx(7.906, rel=1e-3)
finer = sens.cell_metrics(width=500, nx=80, cell_mm=(0.23125, 0.23125), px_per_mm=13.248,
finest_scale=1)
assert finer["cell_px"] == pytest.approx(6.25, rel=1e-9)
assert finer["cell_over_effective_patch_px"] == pytest.approx(0.3906, rel=1e-3)
# dis_mode=0 halves the effective window, so the same pattern sits at 2x the ratio.
coarse = sens.cell_metrics(width=500, nx=31, cell_mm=(0.5968, 0.529), px_per_mm=13.248,
finest_scale=0)
assert coarse["effective_patch_px"] == pytest.approx(8.0, rel=1e-9)
assert coarse["cell_over_effective_patch_px"] == pytest.approx(2.016, rel=1e-2)
# Without a grid the metric falls back and says so, rather than guessing.
fallback = sens.cell_metrics(width=500, nx=None, cell_mm=(0.5968, 0.529), px_per_mm=13.248)
assert fallback["cell_px_source"].startswith("cell_mm x probe-local")
assert fallback["cell_px"] == pytest.approx(7.906, rel=1e-3)
def test_step_size_and_estimator_floor_are_reported_without_conflation(
tmp_path: Path,
) -> None:
report = _run_end_to_end(tmp_path, pattern={"nx": 80, "ny": 80, "seed": 7})
axis = _find_axis(report, "x", "render")
# The step is sub-pixel but still clears the measured floor: 0.1325 px > ~0.046 px.
assert axis["summary"]["resolvable_step_mm"] == pytest.approx(0.01)
assert axis["summary"]["floor_px"] < 0.01 * RENDER_PX_PER_MM
joined = " ".join(report["warnings"])
assert "below one pixel" in joined
# "Below one pixel" must never be asserted as "below the estimator floor".
assert "below the estimator floor" not in joined
assert "estimator floor" in joined, "the floor must still be stated"
assert "per-pixel precision limit" in joined
# The old false claim must be gone: the ROI mean does resolve single steps here.
notes = " ".join(report["notes"])
assert "Only the ROI-aggregated response over the whole sweep is resolvable" not in notes
assert "smallest step clearing the measured floor" in notes
def test_roi_confound_and_attribution_notes_are_present(tmp_path: Path) -> None:
report = _run_end_to_end(tmp_path, pattern={"nx": 31, "ny": 31, "seed": 7})
joined = " ".join(report["notes"])
assert "confounded with the ROI size" in joined
assert "never the radius" in joined
assert "17%" in joined and "95%" in joined
assert "not noise, aliasing" in joined
assert "2.0000 px" in joined
def test_null_floor_is_the_projection_p95_on_that_scale_roi(tmp_path: Path) -> None:
"""Regression guard for the 15x floor error.
A projected p95 (``|dot(flow, d_hat)|`` over the ROI) can never exceed the p95
of ``|flow|`` over the *same* field and region, so that invariant bounds the
reported ``null_floor_px``. The exact-equality check pins the field *and* the
region: using the render-scale manifest ROI on the process-scale arrays (the
bug that inflated the process floor to 0.262662 px against a true 0.017082 px)
fails here on both scales.
"""
report = _run_end_to_end(tmp_path, pattern={"nx": 80, "ny": 80, "seed": 7})
output_dir = tmp_path / "out"
flow_report = json.loads((output_dir / "flow_report.json").read_text(encoding="utf-8"))
for scale in report["scales"]:
name = scale["name"]
entry = next(item for item in flow_report["scales"] if item["name"] == name)
raw = entry["frames"][0]["roi"]
roi = sens.Roi(u=float(raw["u"]), v=float(raw["v"]), radius=float(raw["radius"]))
null = np.load(output_dir / "flow" / name / "reference_null.npy").astype(np.float64)
mask = roi.mask(null.shape[0], null.shape[1])
assert mask.any(), name
if name != "render":
# The render-scale manifest ROI is a *different* region on this scale's
# grid; confirm the guard can tell them apart.
manifest_roi = sens.Roi(u=ROI.u, v=ROI.v, radius=ROI.radius)
assert not np.array_equal(
mask, manifest_roi.mask(null.shape[0], null.shape[1])
), name
magnitude = np.sqrt(null[..., 0] ** 2 + null[..., 1] ** 2)
magnitude_p95 = float(np.percentile(magnitude[mask], 95.0))
assert scale["null_floor_px"] <= magnitude_p95 + 1e-9, name
for axis in scale["axes"]:
direction = axis["direction_px"]
projection = np.abs(
null[..., 0] * direction[0] + null[..., 1] * direction[1]
)
expected = float(np.percentile(projection[mask], 95.0))
assert axis["summary"]["floor_px"] == pytest.approx(
expected, rel=1e-9, abs=1e-6
), (name, axis["axis"])
assert axis["summary"]["floor_px"] <= magnitude_p95 + 1e-9, (name, axis["axis"])
def test_address_scale_gate_and_rescale_end_to_end(tmp_path: Path) -> None:
report = _run_end_to_end(tmp_path, pattern={"nx": 31, "ny": 31, "seed": 7})
render_scale = _find_scale(report, "render")
process_scale = _find_scale(report, "process")
assert render_scale["mesh_px_per_mm"] == pytest.approx(13.4302)
assert render_scale["px_per_mm"] == pytest.approx(RENDER_PX_PER_MM)
assert process_scale["px_per_mm"] == pytest.approx(PROCESS_PX_PER_MM)
for axis in ("x", "y", "z"):
render_axis = _find_axis(report, axis, "render")
process_axis = _find_axis(report, axis, "process")
# applied_px is per-axis and per-scale, not the isotropic scalar.
assert render_axis["points"][-1]["applied_px"] == pytest.approx(
0.10 * render_axis["summary"]["px_per_mm_axis"], rel=1e-3
)
assert process_axis["points"][-1]["applied_px"] == pytest.approx(
0.10 * process_axis["summary"]["px_per_mm_axis"], rel=1e-3
)
# px_per_mm_axis is at the scale it appears in.
assert render_axis["summary"]["px_per_mm_axis"] == pytest.approx(
RENDER_LOCAL[axis], rel=0.05
)
assert process_axis["summary"]["px_per_mm_axis"] == pytest.approx(
PROCESS_LOCAL[axis], rel=0.05
)
# z is 0.152 px over the whole sweep, not 1.325 px.
assert _find_axis(report, "z", "render")["points"][-1]["applied_px"] == pytest.approx(
0.152, abs=0.01
)
# Render z (1.519) keeps its derived ratio; process z (0.760) is gated to null.
assert _find_axis(report, "z", "render")["summary"]["gain_gate"] == "derived"
assert _find_axis(report, "z", "render")["summary"]["gain_mm_per_mm"] is not None
assert _find_axis(report, "z", "process")["summary"]["gain_gate"] == "below_min_local_scale"
assert _find_axis(report, "z", "process")["summary"]["gain_mm_per_mm"] is None
assert any(
"min_local_scale_px_per_mm" in warning for warning in report["warnings"]
)
+309
View File
@@ -0,0 +1,309 @@
#!/usr/bin/env python3
"""Rebuild the UV/unroll map for a design export, using the SDK's own builder.
Why this exists: ``unroll_map_uv_s0.4.npz`` — ``map_x``, ``map_y``, ``valid_mask``, ``world_xyz`` —
is what decides which part of the sensing mesh the application treats as **sensing**; everything else
renders grey. Three facts make it worth rebuilding rather than trusting:
* the Blender export writes **no mask** (``blender/scripts/export.py`` writes geometry, optics,
intrinsics and pose only);
* the **SDK builds it** — ``orisys.unroll.precompute.build_uv_domain`` rasterises the mesh's authored
UV faces through the camera with a depth buffer, and ``build_or_load_domain`` builds-then-caches and
already knows how to spot a stale cache;
* the map **records its own parameters** (``out_width``/``out_height``/``flip_v``/``source_scale``/
``edge_band``/``edge_expand``/``projection_K``), so a rebuild can be tied to the same inputs.
So this is a thin wrapper: resolve the paths, take the parameters from an existing map or the
command line (never guess them), call the SDK, and report what came out.
python tools/build_uv_map.py \
--export-dir build/export/finger/r01/compute \
--params-from D:/orisys_ws/orisys-sdk/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz \
--output build/export/finger/r01/compute
Then compare it with what ships:
python D:/orisys_ws/orisys-app/tools/scene_assets/compare_uv_map.py \
--map build/export/finger/r01/compute/unroll_map_uv_s0.4.npz \
--against D:/orisys_ws/orisys-sdk/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz
"""
from __future__ import annotations
import argparse
import hashlib
import sys
from pathlib import Path
import numpy as np
REQUIRED_EXPORT_FILES = ("mesh.npz", "camera_intrinsics.npz", "world_to_cam.npy")
#: Parameters the map must state for a faithful rebuild. ``source_width``/``source_height`` are
#: deliberately *not* here: they are derived from the export's own calibration (`K`'s image size
#: times ``source_scale``), so a map built from a 1920x1080 camera is not rebuilt as if it were
#: 1280x720. Getting that wrong shrinks the island (measured: 3.5 % coverage instead of 40.8 %),
#: which is the whole reason this tool exists.
PARAM_KEYS = ("out_width", "out_height", "flip_v", "source_scale", "edge_band", "edge_expand")
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1 << 20), b""):
digest.update(chunk)
return digest.hexdigest()
def params_from_map(path: Path) -> dict:
"""Read the parameters a built map recorded about itself."""
with np.load(path, allow_pickle=False) as data:
found: dict = {}
for key in PARAM_KEYS:
if key in data.files:
value = np.asarray(data[key]).reshape(-1)[0].item()
found[key] = value
missing = [key for key in PARAM_KEYS if key not in found]
if missing:
raise SystemExit(
f"{path.name} does not record {missing}; it predates the self-describing metadata, so "
"pass the parameters explicitly instead"
)
found["edge_expand"] = float(found["edge_expand"])
found["edge_band"] = float(found["edge_band"])
found["source_scale"] = float(found["source_scale"])
found["flip_v"] = bool(found["flip_v"])
for key in ("out_width", "out_height"):
found[key] = int(found[key])
return found
def _warn_if_upsampled(source_width: int, source_height: int,
out_width: int, out_height: int) -> None:
"""Say it out loud when the output grid is finer than the source it samples."""
if source_width < out_width or source_height < out_height:
print(
f"WARNING: the source frame {source_width} x {source_height} is smaller than the "
f"{out_width} x {out_height} output grid, so the unrolled picture is upsampled "
"(several output pixels read one source pixel). Raise source_scale to at least "
f"{derive_source_scale(source_width, source_height, out_width, out_height) * 1.0:.3f} "
"of the *native* frame, or accept the softer picture."
)
def derive_source_scale(native_width: int, native_height: int,
out_width: int, out_height: int) -> float:
"""The smallest scale whose source grid still covers the output grid 1:1.
The remap *gathers* from the source frame into the ``out_width x out_height`` domain, so if
the source is smaller than the output in either axis, several output pixels read the same
source pixel and the unrolled picture is blocky. Requiring
min(native_width * s, native_height * s) >= max(out_width, out_height) (per axis)
gives ``s = max(out_w / native_w, out_h / native_h)``: coverage without upsampling, and — the
useful property — a source size that follows the *output* requirement, so two cameras of
different resolution produce the same output quality.
Above this the scale only buys interpolation smoothness and costs per-frame pixel work
(the live frame is resized by ``s`` before undistort/remap), so the derived value is the
sensible floor rather than the optimum: a profile may pin a larger one deliberately.
"""
if native_width <= 0 or native_height <= 0:
raise SystemExit("the export's camera_intrinsics.npz has a non-positive image size")
scale = max(out_width / float(native_width), out_height / float(native_height))
# Keep the cache name readable (it embeds the scale) and never exceed the native frame.
return min(1.0, round(scale + 1e-9, 3))
def params_from_profile(path: Path) -> dict:
"""Read the ``uv_map:`` block from a profile yaml.
Hand-parsed on purpose: the block is six scalars, and this must keep working with the
interpreter that runs the design tools rather than depending on PyYAML being installed.
Unknown keys and a missing block are reported rather than ignored.
"""
if not path.is_file():
raise SystemExit(f"no such profile: {path}")
found: dict = {}
inside = False
for raw in path.read_text(encoding="utf-8").splitlines():
line = raw.split("#", 1)[0].rstrip()
if not line.strip():
continue
if not line.startswith((" ", "\t")):
inside = line.strip() == "uv_map:"
continue
if not inside or ":" not in line:
continue
key, _, value = line.strip().partition(":")
value = value.strip()
if value.lower() in ("true", "false"):
found[key] = value.lower() == "true"
else:
try:
found[key] = float(value) if "." in value else int(value)
except ValueError:
found[key] = value
if not found:
raise SystemExit(f"{path} has no uv_map: block (add out_width/out_height/flip_v/"
"source_scale/edge_band/edge_expand)")
unknown = sorted(set(found) - set(PARAM_KEYS))
if unknown:
raise SystemExit(f"{path}: uv_map has keys this tool does not understand: {unknown}")
for key in ("out_width", "out_height"):
found[key] = int(found[key])
found["flip_v"] = bool(found["flip_v"])
for key in ("source_scale", "edge_band", "edge_expand"):
found[key] = float(found[key])
return found
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--export-dir", type=Path, required=True,
help="directory holding mesh.npz, camera_intrinsics.npz and world_to_cam.npy")
parser.add_argument("--profile", type=Path, default=None,
help="profile yaml carrying the uv_map: parameters (preferred)")
parser.add_argument("--params-from", type=Path, default=None,
help="an existing map to take the parameters from (cross-check)")
parser.add_argument("--output", type=Path, default=None,
help="directory to write into (default: the export dir)")
parser.add_argument("--force", action="store_true",
help="delete an existing map first, so the SDK must rebuild rather than load")
parser.add_argument("--edge-expand", type=float, default=None,
help="override; > 1 pins it, otherwise the SDK resolves it from distortion")
for key in ("out-width", "out-height", "source-width", "source-height"):
parser.add_argument(f"--{key}", type=int, default=None)
parser.add_argument("--flip-v", dest="flip_v", action="store_true", default=None)
parser.add_argument("--no-flip-v", dest="flip_v", action="store_false")
parser.add_argument("--source-scale", type=float, default=None)
parser.add_argument("--edge-band", type=float, default=None)
args = parser.parse_args(argv)
export_dir: Path = args.export_dir
missing = [name for name in REQUIRED_EXPORT_FILES if not (export_dir / name).is_file()]
if missing:
raise SystemExit(
f"{export_dir} is missing {missing}: this needs the payload "
"blender/scripts/export.py writes, not the source .blend"
)
params: dict = {}
if args.profile is not None:
params.update(params_from_profile(args.profile))
if args.params_from is not None:
if not args.params_from.is_file():
raise SystemExit(f"no such map to take parameters from: {args.params_from}")
recorded = params_from_map(args.params_from)
# A disagreement is the interesting case: the profile is the source of truth, but a map
# that was built with different numbers has to be visible, not resolved silently.
disagree = {
key: (recorded.get(key), params.get(key))
for key in params
if key in recorded and recorded[key] != params[key]
}
if disagree:
print(f"NOTE: {args.params_from.name} records different parameters than the profile "
f"(map, profile): {disagree}")
params.update({key: value for key, value in recorded.items() if key not in params})
overrides = {
"out_width": args.out_width, "out_height": args.out_height,
"flip_v": args.flip_v, "source_scale": args.source_scale, "edge_band": args.edge_band,
"edge_expand": args.edge_expand,
}
params.update({key: value for key, value in overrides.items() if value is not None})
absent = [key for key in PARAM_KEYS if key not in params]
if absent:
raise SystemExit(
f"no value for {absent}: pass --params-from an existing map or give them on the command "
"line. The importer never guesses what a source unit or an output size is."
)
try:
import orisys
from orisys.unroll.geometry import scale_camera_geometry, scaled_map_cache_path
from orisys.unroll.precompute import build_or_load_domain
except Exception as exc: # noqa: BLE001
raise SystemExit(
f"the SDK is not importable ({type(exc).__name__}: {exc}); install the orisys checkout "
"you intend to build with — the map must come from the same implementation that "
"built the shipped one"
)
out_dir: Path = args.output or export_dir
out_dir.mkdir(parents=True, exist_ok=True)
with np.load(export_dir / "camera_intrinsics.npz", allow_pickle=False) as handle:
K_native = np.asarray(handle["K"], dtype=np.float64)
native_width = int(np.asarray(handle["width"]).reshape(-1)[0])
native_height = int(np.asarray(handle["height"]).reshape(-1)[0])
# The scale is a decision, so it is reported as one: the profile (or the CLI) may pin it, and
# this says what the output grid would otherwise require.
derived_scale = derive_source_scale(
native_width, native_height, int(params["out_width"]), int(params["out_height"])
)
pinned_scale = "source_scale" in params
if not pinned_scale:
params["source_scale"] = derived_scale
effective_scale = float(params["source_scale"])
source_K, source_width, source_height = scale_camera_geometry(
K_native, native_width, native_height, effective_scale
)
if args.source_width is not None:
source_width = int(args.source_width)
if args.source_height is not None:
source_height = int(args.source_height)
_warn_if_upsampled(source_width, source_height,
int(params["out_width"]), int(params["out_height"]))
base = str(out_dir / "unroll_map_uv.npz")
target = Path(scaled_map_cache_path(base, float(params["source_scale"])))
if args.force and target.is_file():
target.unlink()
print(f"removed {target.name} (--force: rebuild rather than load)")
print(f"export {export_dir}")
print(f"target {target}")
print(f"SDK orisys {getattr(orisys, '__version__', '?')}")
print(f"parameters {params}")
print(f"native camera {native_width} x {native_height}, f={K_native[0, 0]:.1f}")
print(f"source frame {source_width} x {source_height} "
f"(scale {effective_scale}), f={source_K[0, 0]:.1f}")
print(f"scale {'pinned' if pinned_scale else 'derived'} {effective_scale}"
f" (output {params['out_width']} x {params['out_height']} needs >= {derived_scale})")
domain = build_or_load_domain(
str(export_dir / "mesh.npz"),
str(export_dir / "camera_intrinsics.npz"),
str(export_dir / "world_to_cam.npy"),
str(target),
source_K=source_K,
source_width=int(source_width),
source_height=int(source_height),
out_width=int(params["out_width"]),
out_height=int(params["out_height"]),
flip_v=bool(params["flip_v"]),
edge_band=float(params["edge_band"]),
edge_expand=float(params["edge_expand"]),
include_surface=True,
verbose=True,
source_scale=float(params["source_scale"]),
)
with np.load(target, allow_pickle=False) as written:
mask = np.asarray(written["valid_mask"]).astype(bool)
surface = "world_xyz" in written.files
print(f"\nwrote {target}")
print(f" shape {mask.shape[1]} x {mask.shape[0]} (W x H)")
print(f" coverage {mask.mean() * 100:.3f} % of the domain ({int(mask.sum())} pixels)")
print(f" surface map {'present' if surface else 'MISSING (the 3D UV->surface mapping needs it)'}")
print(f" sha256 {_sha256(target)}")
print("\ncompare it with the shipped map:")
print(" python D:/orisys_ws/orisys-app/tools/scene_assets/compare_uv_map.py "
f"--map \"{target}\" --against \"<shipped>.npz\"")
return 0
if __name__ == "__main__":
raise SystemExit(main())