diff --git a/.gitignore b/.gitignore index cbe7c9c..dc6f5a3 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,14 @@ build/textures/**/_texture_job.json # Printability reports are regenerated output, not design input. Use # `orisys-design inspect --out ` 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 diff --git a/README.md b/README.md index 2e1e04a..4af2cd1 100644 --- a/README.md +++ b/README.md @@ -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///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///`. + +### 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 diff --git a/blender/scripts/probe_deform.py b/blender/scripts/probe_deform.py new file mode 100644 index 0000000..52ae11d --- /dev/null +++ b/blender/scripts/probe_deform.py @@ -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 + +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 "" + 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=" 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()) diff --git a/blender/scripts/probe_falloff.py b/blender/scripts/probe_falloff.py new file mode 100644 index 0000000..4d39762 --- /dev/null +++ b/blender/scripts/probe_falloff.py @@ -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)) diff --git a/blender/scripts/probe_render.py b/blender/scripts/probe_render.py new file mode 100644 index 0000000..0b6c12b --- /dev/null +++ b/blender/scripts/probe_render.py @@ -0,0 +1,1049 @@ +"""Blender headless probe rendering. + + blender -b sensors/finger-xynova/r01/source/geometry.blend \\ + -P blender/scripts/probe_render.py -- \\ + --job + +Reads the job written by ``orisys_sensor_design.render``, loads the +``probe_frames.npz`` produced by the deform stage, installs ONE emission +material bound to the marker texture over the configured UV map, and renders +every frame of the plan to ``frames/.png`` plus a second reference render +at a different seed (``frames/reference_null.png``) that the tracker uses to +measure its zero-input floor. + +THE .BLEND IS NEVER WRITTEN. No ``bpy.ops.wm.save_mainfile`` / +``save_as_mainfile`` call exists in this script; every scene change is in memory +only. + +WHY THE VIEW TRANSFORM IS FORCED AND CHECKED +-------------------------------------------- +The shipping .blend has ``view_transform = 'AgX'`` and ``cycles.samples = 1024`` +with denoising on. AgX is a filmic tonemapper: it silently rewrites every marker +pixel, which corrupts every flow number downstream. This script therefore forces +``view_transform='Standard'``, ``look='None'``, ``exposure=0.0``, +``use_denoising=False``, ``use_adaptive_sampling=False``, ``samples`` from the +job, a fixed ``cycles.seed``, ``interpolation='Closest'`` and dithering off, then +reads the resolved view transform back and REFUSES with exit code 2 if it is not +``Standard``. + +DETERMINISM +----------- +Cycles CPU floating-point accumulation order is not fixed, so a boundary pixel +can tip by one 8-bit step even with an identical scene and seed. That is thread +scheduling, not irreproducibility, and because it is content-dependent a strict +byte gate silently disqualifies some marker patterns and biases a pattern +search. + +So: after every render this script still strips ONLY the known-volatile ``tEXt`` +chunks (``Date``, ``Time``, ``RenderTime``, ``cycles.ViewLayer.*_time``) and +keeps every image-defining chunk byte-for-byte. ``reference_deterministic`` is +then decided from the DECODED PIXELS of two frame-0 renders in the same session: +identical shape, ``max |delta| <= 1`` per 8-bit channel, and a differing-pixel +fraction ``<= 0.05%``. The measured ``max_abs_delta`` and ``differing_fraction`` +are recorded in the manifest either way, and a tolerance breach still fails with +exit 1. Raw file bytes are never the comparison: PNG compression and metadata +are not the measurement. + +MEASUREMENTS +------------ +Two pixel scales are recorded, separately, because confusing them would corrupt +the scorer's units: + +* ``mesh_px_per_mm`` is mesh-wide: p05/p50/p95/min/max of (projected mesh edge + length in px) / (3D edge length in mm) over every mesh edge of the reference + frame, projected with ``bpy_extras.object_utils.world_to_camera_view``. For the + shipping revision that reproduces the contract's 13.430 / 10.499 / 38.594. +* ``probe_px_per_mm`` is probe-local and is the gain the scorer must use: + ``|centroid_px(step) - centroid_px(0)| / step_mm`` per axis (each axis using + its largest step), summarised by the median over axes. It is anisotropic: on + this sensor ``z`` (surface-normal motion) is much smaller than x/y because + normal motion is largely out of the image plane. That is recorded, not fixed. + +``probe_roi_px`` is the reference probe centroid projected to pixels (u=column, +v=row, origin top-left) plus ``radius_mm * probe_px_per_mm.p50``. Each frame +entry carries ``centroid_px`` for THAT frame's deformed probe centroid. + +Exit codes: 0 success, 1 scene/geometry error, 2 config/job error, +3 missing/empty required artifact. +""" + +# pyright: reportMissingImports=false + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import struct +import sys +import time +import zlib +from datetime import datetime, timezone +from pathlib import Path + +import bpy +import numpy as np +from bpy_extras.object_utils import world_to_camera_view +from mathutils import Vector + +JOB_SCHEMA = "orisys-sensor-design/probe-render-job-v1" +MANIFEST_SCHEMA = "orisys-sensor-design/probe-render-manifest-v1" +MANIFEST_FILENAME = "render_manifest.json" +FRAMES_DIRNAME = "frames" +NULL_FRAME_NAME = "reference_null" +REQUIRED_VIEW_TRANSFORM = "Standard" +DEFAULT_INTERPOLATE = "Closest" +DEFAULT_ENGINE = "CYCLES" +DEFAULT_SAMPLES = 16 +DEFAULT_SEED = 0 + +_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" +#: Volatile PNG text chunks Blender adds per render. They carry wall-clock or +#: render timings and are the ONLY reason two identical renders differ on disk. +#: The set is explicit on purpose: a new volatile chunk must fail the +#: determinism check loudly, not be silently averaged away. +_VOLATILE_TEXT_KEYS = frozenset({"Date", "Time", "RenderTime"}) +_VOLATILE_TEXT_PATTERN = re.compile(r"^cycles\.ViewLayer\..*_time$") + +#: Determinism gate. Cycles CPU thread-order rounding can move a boundary pixel +#: by one 8-bit step; anything beyond that, or on more than 0.05 % of pixels, is +#: real nondeterminism and must stop the run. +DETERMINISM_MAX_ABS_DELTA = 1 +DETERMINISM_MAX_DIFFERING_FRACTION = 0.0005 + +MESH_PX_PER_MM_BASIS = "per-mesh-edge projected px / 3D mm, all {count} edges" +PROBE_PX_PER_MM_BASIS = ( + "probe-local: |centroid_px(step) - centroid_px(0)| / step_mm, " + "per axis at its largest step" +) + +_REQUIRED_NPZ_KEYS = ( + "base_vertices", + "vertices", + "frame_names", + "frame_axes", + "frame_steps_mm", + "weights", + "origin", + "basis", + "probe_indices", +) + + +class ConfigError(ValueError): + """Invalid job/flags (exit code 2).""" + + +class SceneError(RuntimeError): + """Missing, inconsistent or unusable scene data (exit code 1).""" + + +class ArtifactError(RuntimeError): + """A required artifact is missing or empty (exit code 3).""" + + +# ---------------------------------------------------------------------- +# CLI +# ---------------------------------------------------------------------- +def _argv_after_double_dash(): + if "--" not in sys.argv: + return [] + return sys.argv[sys.argv.index("--") + 1 :] + + +def parse_args(argv): + parser = argparse.ArgumentParser(prog="probe_render.py") + parser.add_argument( + "--job", + type=Path, + required=True, + help="JSON render job written by orisys-design render", + ) + parser.add_argument("--width", type=int, default=None, help="Override render width") + parser.add_argument("--height", type=int, default=None, help="Override render height") + parser.add_argument("--samples", type=int, default=None, help="Override Cycles samples") + parser.add_argument("--seed", type=int, default=None, help="Override Cycles seed") + parser.add_argument("--uv-map", dest="uv_map", default=None, help="Override mesh UV map") + parser.add_argument( + "--texture", type=Path, default=None, help="Override marker texture PNG" + ) + return parser.parse_args(argv) + + +def _load_job(path): + if not path.is_file(): + raise ConfigError(f"Render job not found: {path}") + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ConfigError(f"Render job is not valid JSON: {path}: {exc}") from exc + if not isinstance(data, dict): + raise ConfigError(f"Render job must be a JSON object: {path}") + return data + + +def _as_int(value, label): + if isinstance(value, bool) or not isinstance(value, int): + raise ConfigError(f"{label}: must be an integer (got {value!r})") + return int(value) + + +def _as_positive_int(value, label): + number = _as_int(value, label) + if number <= 0: + raise ConfigError(f"{label}: must be a positive integer (got {value!r})") + return number + + +def _as_name(value, label): + if not isinstance(value, str) or not value.strip(): + raise ConfigError(f"{label}: must be a non-empty name (got {value!r})") + return value.strip() + + +def _utc_now(): + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +def _sha256(path): + digest = hashlib.sha256() + with Path(path).open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _require_non_empty(path, label): + candidate = Path(path) + if not candidate.is_file(): + raise ArtifactError(f"{label} not found: {candidate}") + if candidate.stat().st_size == 0: + raise ArtifactError(f"{label} is empty: {candidate}") + return candidate + + +# ---------------------------------------------------------------------- +# PNG metadata +# ---------------------------------------------------------------------- +def strip_volatile_text_chunks(path): + """Drop only the volatile ``tEXt`` chunks; keep every image chunk verbatim. + + Returns the number of dropped chunks. The file stays a valid PNG and its + IHDR/IDAT/IEND bytes are untouched, so the pixel data is exactly what Cycles + produced. + """ + data = Path(path).read_bytes() + if data[:8] != _PNG_SIGNATURE: + raise SceneError(f"renderer wrote a file that is not a PNG: {path}") + out = bytearray(_PNG_SIGNATURE) + pos = 8 + dropped = 0 + while pos + 8 <= len(data): + (length,) = struct.unpack(">I", data[pos : pos + 4]) + ctype = data[pos + 4 : pos + 8] + end = pos + 12 + length + if end > len(data): + raise SceneError(f"truncated PNG: {path}") + if ctype == b"tEXt": + key = data[pos + 8 : pos + 8 + length].split(b"\0", 1)[0].decode("latin-1") + if key in _VOLATILE_TEXT_KEYS or _VOLATILE_TEXT_PATTERN.match(key): + dropped += 1 + pos = end + continue + out += data[pos:end] + pos = end + if dropped: + Path(path).write_bytes(bytes(out)) + return dropped + + +# ---------------------------------------------------------------------- +# PNG pixel comparison (the determinism gate) +# ---------------------------------------------------------------------- +def png_dimensions(path): + """Return ``(width, height)`` from a PNG's IHDR, without decoding pixels.""" + data = Path(path).read_bytes() + if data[:8] != _PNG_SIGNATURE: + raise SceneError(f"not a PNG: {path}") + if data[12:16] != b"IHDR": + raise SceneError(f"PNG does not start with IHDR: {path}") + width, height = struct.unpack(">II", data[16:24]) + return int(width), int(height) + + +def decode_png_rgba8(path): + """Decode an 8-bit RGBA, non-interlaced PNG to a ``(H, W, 4)`` uint8 array. + + Only the format this pipeline writes is accepted; anything else fails loudly + rather than being compared under a false assumption. Decoding here (rather + than through Blender's image API) keeps the comparison exact: no colour + management, no float round-trip. + """ + data = Path(path).read_bytes() + if data[:8] != _PNG_SIGNATURE: + raise SceneError(f"not a PNG: {path}") + header = None + idat = bytearray() + pos = 8 + while pos + 8 <= len(data): + (length,) = struct.unpack(">I", data[pos : pos + 4]) + ctype = data[pos + 4 : pos + 8] + end = pos + 12 + length + if end > len(data): + raise SceneError(f"truncated PNG: {path}") + if ctype == b"IHDR": + header = struct.unpack(">IIBBBBB", data[pos + 8 : pos + 8 + length]) + elif ctype == b"IDAT": + idat += data[pos + 8 : pos + 8 + length] + elif ctype == b"IEND": + break + pos = end + if header is None: + raise SceneError(f"PNG has no IHDR: {path}") + width, height, depth, color_type, compression, filter_method, interlace = header + if (depth, color_type, interlace) != (8, 6, 0): + raise SceneError( + "pixel comparison expects an 8-bit RGBA non-interlaced PNG, got " + f"depth={depth} color_type={color_type} interlace={interlace}: {path}" + ) + if compression != 0 or filter_method != 0: + raise SceneError(f"unsupported PNG compression/filter method: {path}") + try: + raw = zlib.decompress(bytes(idat)) + except zlib.error as exc: + raise SceneError(f"cannot decompress PNG pixel data: {path}: {exc}") from exc + stride = width * 4 + if len(raw) != height * (stride + 1): + raise SceneError( + f"PNG pixel data is {len(raw)} bytes, expected {height * (stride + 1)}: {path}" + ) + + pixels = np.empty((height, width, 4), dtype=np.uint8) + previous = bytearray(stride) + cursor = 0 + for y in range(height): + filter_type = raw[cursor] + cursor += 1 + line = bytearray(raw[cursor : cursor + stride]) + cursor += stride + if filter_type == 0: + pass + elif filter_type == 1: + for i in range(4, stride): + line[i] = (line[i] + line[i - 4]) & 0xFF + elif filter_type == 2: + for i in range(stride): + line[i] = (line[i] + previous[i]) & 0xFF + elif filter_type == 3: + for i in range(stride): + left = line[i - 4] if i >= 4 else 0 + line[i] = (line[i] + ((left + previous[i]) >> 1)) & 0xFF + elif filter_type == 4: + for i in range(stride): + a = line[i - 4] if i >= 4 else 0 + b = previous[i] + c = previous[i - 4] if i >= 4 else 0 + p = a + b - c + pa = abs(p - a) + pb = abs(p - b) + pc = abs(p - c) + if pa <= pb and pa <= pc: + pred = a + elif pb <= pc: + pred = b + else: + pred = c + line[i] = (line[i] + pred) & 0xFF + else: + raise SceneError(f"unknown PNG filter type {filter_type}: {path}") + pixels[y] = np.frombuffer(bytes(line), dtype=np.uint8).reshape(width, 4) + previous = line + return pixels + + +def compare_reference_renders(path_a, path_b): + """Measure how far two frame-0 renders differ, in decoded pixels. + + Returns a result dict with ``passed``, ``max_abs_delta``, + ``differing_pixels``, ``total_pixels``, ``differing_fraction`` and the + tolerances applied. A byte-identical pair short-circuits to zero deltas; any + other pair is decoded and compared channel by channel. Shape mismatch, or any + unreadable/unsupported PNG, raises :class:`SceneError`. + """ + bytes_a = Path(path_a).read_bytes() + bytes_b = Path(path_b).read_bytes() + shape_a = png_dimensions(path_a) + shape_b = png_dimensions(path_b) + if shape_a != shape_b: + raise SceneError( + f"frame-0 renders have different shapes: {shape_a} vs {shape_b} " + f"({path_a}, {path_b})" + ) + height, width = shape_a[1], shape_a[0] + total_pixels = height * width + + if bytes_a == bytes_b: + max_abs_delta = 0 + differing_pixels = 0 + else: + pixels_a = decode_png_rgba8(path_a) + pixels_b = decode_png_rgba8(path_b) + if pixels_a.shape != pixels_b.shape: + raise SceneError( + f"decoded frame-0 renders have different shapes: " + f"{pixels_a.shape} vs {pixels_b.shape}" + ) + delta = np.abs(pixels_a.astype(np.int16) - pixels_b.astype(np.int16)) + max_abs_delta = int(delta.max()) if delta.size else 0 + differing_pixels = int((delta.max(axis=2) > 0).sum()) + + differing_fraction = differing_pixels / total_pixels if total_pixels else 0.0 + passed = ( + max_abs_delta <= DETERMINISM_MAX_ABS_DELTA + and differing_fraction <= DETERMINISM_MAX_DIFFERING_FRACTION + ) + return { + "passed": bool(passed), + "max_abs_delta": max_abs_delta, + "differing_pixels": differing_pixels, + "total_pixels": int(total_pixels), + "differing_fraction": float(differing_fraction), + "max_abs_delta_tolerance": DETERMINISM_MAX_ABS_DELTA, + "differing_fraction_tolerance": DETERMINISM_MAX_DIFFERING_FRACTION, + "shape": [int(width), int(height)], + } + + +# ---------------------------------------------------------------------- +# Scene lookup +# ---------------------------------------------------------------------- +def _require_object(name, object_type, label): + obj = bpy.data.objects.get(name) + if obj is None: + raise SceneError(f"Scene has no {label} named {name!r}") + if obj.type != object_type: + raise SceneError( + f"Object {name!r} is {obj.type}, expected {object_type} for the {label}" + ) + return obj + + +def _require_uv_layer(obj, name): + for layer in obj.data.uv_layers: + if layer.name == name: + if len(layer.data) == 0: + raise SceneError(f"UV map {name!r} on {obj.name!r} has no loop data") + return layer + available = ", ".join(sorted(layer.name for layer in obj.data.uv_layers)) or "none" + raise SceneError( + f"Mesh {obj.name!r} has no UV map named {name!r} (available: {available})" + ) + + +class Projector: + """World coordinates -> render pixels (u=column, v=row, origin top-left).""" + + def __init__(self, scene, camera, width, height): + self.scene = scene + self.camera = camera + self.width = float(width) + self.height = float(height) + + def project(self, world_co): + u, v, depth = world_to_camera_view( + self.scene, self.camera, Vector((float(world_co[0]), float(world_co[1]), float(world_co[2]))) + ) + return (u * self.width, (1.0 - v) * self.height, depth) + + def project_many(self, world_coords): + return np.array([self.project(p)[:2] for p in world_coords], dtype=np.float64) + + +def _mesh_px_per_mm_stats(reference_coords, edges, projector, matrix_world): + """Mesh-wide sampling scale: projected-edge px / 3D-edge mm over every edge. + + This describes how the whole surface is sampled by the camera. It is NOT the + gain that converts the probe's measured flow into millimetres; see + :func:`_probe_px_per_mm` for that. Measured on the reference frame from the + actual projection; never assumed. + """ + if len(edges) == 0: + raise SceneError("Mesh has no edges; cannot measure the projection scale") + world = np.array( + [(matrix_world @ Vector(tuple(c))).to_tuple() for c in reference_coords], + dtype=np.float64, + ) + px = projector.project_many(world) + d3 = np.linalg.norm(reference_coords[edges[:, 0]] - reference_coords[edges[:, 1]], axis=1) + d2 = np.linalg.norm(px[edges[:, 0]] - px[edges[:, 1]], axis=1) + usable = d3 > 0.0 + if not np.any(usable): + raise SceneError("Every mesh edge has zero length; cannot measure the projection scale") + ratios = d2[usable] / d3[usable] + stats = { + "min": float(ratios.min()), + "p05": float(np.percentile(ratios, 5)), + "p50": float(np.percentile(ratios, 50)), + "p95": float(np.percentile(ratios, 95)), + "max": float(ratios.max()), + } + stats["basis"] = MESH_PX_PER_MM_BASIS.format(count=int(len(edges))) + if not all(np.isfinite(value) for key, value in stats.items() if key != "basis"): + raise SceneError(f"Non-finite projection scale measured: {stats}") + return stats + + +def _probe_px_per_mm(frames, centroids): + """Probe-local scale per axis: |centroid_px(step) - centroid_px(0)| / step_mm. + + This is the gain the scorer must use to convert the probe's measured flow to + millimetres. It is anisotropic; on this sensor ``z`` (surface-normal motion) + is much smaller than x/y because normal motion is largely out of the image + plane. That is a real property, recorded rather than corrected. + + Each axis uses its largest step, which is the most numerically robust finite + difference of the same locally-linear map (all 6 probe vertices move by the + same vector, so the centroid tracks it exactly). + """ + reference = np.asarray(centroids[0], dtype=np.float64) + per_axis: dict[str, float] = {} + for axis in ("x", "y", "z"): + positions = [i for i, frame in enumerate(frames) if frame["axis"] == axis] + if not positions: + continue + best = max(positions, key=lambda i: frames[i]["step_mm"]) + step_mm = float(frames[best]["step_mm"]) + if step_mm <= 0.0: + raise SceneError(f"axis {axis!r} has no positive step; cannot measure its scale") + delta = np.asarray(centroids[best], dtype=np.float64) - reference + per_axis[axis] = float(np.linalg.norm(delta) / step_mm) + if not per_axis: + raise SceneError("Frame plan has no axis frames; cannot measure the probe-local scale") + + stats: dict[str, object] = {"p50": float(np.median(list(per_axis.values())))} + for axis in ("x", "y", "z"): + if axis in per_axis: + stats[axis] = per_axis[axis] + stats["basis"] = PROBE_PX_PER_MM_BASIS + if not all(np.isfinite(value) for value in per_axis.values()): + raise SceneError(f"Non-finite probe-local scale measured: {per_axis}") + return stats + + +# ---------------------------------------------------------------------- +# Config resolution +# ---------------------------------------------------------------------- +def _resolve_config(job, args): + schema = job.get("schema") + if schema != JOB_SCHEMA: + raise ConfigError(f"Job schema is {schema!r}, expected {JOB_SCHEMA!r}") + + settings = job.get("settings") + if not isinstance(settings, dict): + raise ConfigError("Job 'settings' must be a mapping") + objects = job.get("objects") + if not isinstance(objects, dict): + raise ConfigError("Job 'objects' must be a mapping") + artifacts = job.get("artifacts") + if not isinstance(artifacts, dict): + raise ConfigError("Job 'artifacts' must be a mapping") + + output_dir = job.get("output_dir") + if not isinstance(output_dir, str) or not output_dir: + raise ConfigError("Job 'output_dir' must be a non-empty path") + frames_npz = artifacts.get("frames_npz") + if not isinstance(frames_npz, str) or not frames_npz: + raise ConfigError("Job 'artifacts.frames_npz' must be a non-empty path") + + raw_frames = job.get("frames") + if not isinstance(raw_frames, list) or not raw_frames: + raise ConfigError("Job 'frames' must be a non-empty list") + frames = [] + for position, entry in enumerate(raw_frames): + if not isinstance(entry, dict): + raise ConfigError(f"Job frames[{position}] must be a mapping") + frame = { + "index": _as_int(entry.get("index"), f"frames[{position}].index"), + "name": _as_name(entry.get("name"), f"frames[{position}].name"), + "axis": entry.get("axis"), + "step_mm": entry.get("step_mm"), + } + if frame["axis"] is not None: + frame["axis"] = _as_name(frame["axis"], f"frames[{position}].axis") + if not isinstance(frame["step_mm"], (int, float)) or isinstance(frame["step_mm"], bool): + raise ConfigError( + f"frames[{position}].step_mm must be a number (got {frame['step_mm']!r})" + ) + frame["step_mm"] = float(frame["step_mm"]) + frames.append(frame) + + names = [frame["name"] for frame in frames] + if len(set(names)) != len(names): + raise ConfigError("Job 'frames' contains duplicate names") + if frames[0]["index"] != 0: + raise ConfigError(f"Job frames[0].index must be 0 (got {frames[0]['index']})") + + null_raw = job.get("null_frame") or {} + if not isinstance(null_raw, dict): + raise ConfigError("Job 'null_frame' must be a mapping") + null_name = _as_name(null_raw.get("name", NULL_FRAME_NAME), "null_frame.name") + + # --- the hard, non-negotiable render settings --- + requested_view_transform = _as_name( + settings.get("view_transform", REQUIRED_VIEW_TRANSFORM), "settings.view_transform" + ) + if requested_view_transform != REQUIRED_VIEW_TRANSFORM: + raise ConfigError( + "settings.view_transform must be " + f"{REQUIRED_VIEW_TRANSFORM!r} (got {requested_view_transform!r}); AgX/Filmic " + "silently rewrites marker pixel values and invalidates the measurement" + ) + + config = { + "output_dir": Path(output_dir).resolve(), + "frames_npz": Path(frames_npz).resolve(), + "source_blend": Path(str(job.get("source_blend", ""))).resolve(), + "mesh": _as_name(objects.get("mesh"), "objects.mesh"), + "camera": _as_name(objects.get("camera"), "objects.camera"), + "frames": frames, + "null_name": null_name, + "null_seed": _as_int( + null_raw.get("seed", _as_int(settings.get("seed", DEFAULT_SEED), "settings.seed") + 1), + "null_frame.seed", + ), + "uv_map": _as_name(args.uv_map or settings.get("uv_map"), "settings.uv_map"), + "texture": Path(args.texture or settings.get("texture") or "").expanduser().resolve(), + "width": _as_positive_int( + args.width if args.width is not None else settings.get("width"), "settings.width" + ), + "height": _as_positive_int( + args.height if args.height is not None else settings.get("height"), "settings.height" + ), + "engine": _as_name(settings.get("engine", DEFAULT_ENGINE), "settings.engine"), + "samples": _as_positive_int( + args.samples if args.samples is not None else settings.get("samples", DEFAULT_SAMPLES), + "settings.samples", + ), + "denoise": settings.get("denoise", False), + "view_transform": requested_view_transform, + "interpolate": _as_name( + settings.get("interpolate", DEFAULT_INTERPOLATE), "settings.interpolate" + ), + "seed": _as_int( + args.seed if args.seed is not None else settings.get("seed", DEFAULT_SEED), + "settings.seed", + ), + "radius_mm": settings.get("radius_mm"), + } + if not isinstance(config["denoise"], bool): + raise ConfigError(f"settings.denoise must be true or false (got {config['denoise']!r})") + if not isinstance(config["radius_mm"], (int, float)) or isinstance(config["radius_mm"], bool): + raise ConfigError( + "settings.radius_mm must be a positive number (got " + f"{config['radius_mm']!r}); probe_roi_px.radius = radius_mm * px_per_mm" + ) + config["radius_mm"] = float(config["radius_mm"]) + if config["radius_mm"] <= 0.0: + raise ConfigError(f"settings.radius_mm must be > 0 (got {config['radius_mm']!r})") + if args.texture is None and (not str(config["texture"]) or str(config["texture"]) == "."): + raise ConfigError("settings.texture must be a non-empty path") + return config + + +# ---------------------------------------------------------------------- +# Scene preparation +# ---------------------------------------------------------------------- +def _apply_scene_settings(scene, config): + """Force the contract's render settings and refuse a non-Standard transform.""" + try: + scene.view_settings.view_transform = REQUIRED_VIEW_TRANSFORM + except TypeError as exc: + raise ConfigError( + f"This Blender build cannot resolve view transform {REQUIRED_VIEW_TRANSFORM!r}: {exc}" + ) from exc + try: + scene.view_settings.look = "None" + except TypeError: + # Blender 4.x renames looks; the transform is what matters and 'None' is + # valid there. A build without 'None' keeps its default look, recorded + # as applied in the manifest. + pass + scene.view_settings.exposure = 0.0 + resolved_view_transform = scene.view_settings.view_transform + if resolved_view_transform != REQUIRED_VIEW_TRANSFORM: + raise ConfigError( + "resolved view transform is " + f"{resolved_view_transform!r}, expected {REQUIRED_VIEW_TRANSFORM!r}; refusing to " + "render marker values through a filmic transform" + ) + + scene.render.engine = config["engine"] + scene.render.resolution_x = config["width"] + scene.render.resolution_y = config["height"] + scene.render.resolution_percentage = 100 + scene.render.film_transparent = False + scene.render.use_file_extension = True + # 8-bit output must not be dithered: dithering is random and makes the + # saved bytes irreproducible. + scene.render.dither_intensity = 0.0 + scene.render.image_settings.file_format = "PNG" + scene.render.image_settings.color_mode = "RGBA" + # Stamp metadata is pure noise for a measurement; volatile cycles timing + # chunks are stripped after each render regardless. + for attribute in dir(scene.render): + if attribute.startswith("use_stamp_"): + try: + setattr(scene.render, attribute, False) + except (AttributeError, TypeError): + pass + + cycles = getattr(scene, "cycles", None) + if cycles is not None: + cycles.samples = config["samples"] + # Denoising and adaptive sampling both change pixel values and would + # make the sweep depend on a stochastic estimator. + cycles.use_denoising = False + cycles.use_adaptive_sampling = False + cycles.seed = config["seed"] + if hasattr(cycles, "use_animated_seed"): + cycles.use_animated_seed = False + elif config["engine"] == "CYCLES": + raise SceneError("Scene has no Cycles settings while the engine is CYCLES") + + return { + "engine": config["engine"], + "samples": config["samples"], + "denoise": False, + "view_transform": resolved_view_transform, + "look": scene.view_settings.look, + "exposure": float(scene.view_settings.exposure), + "interpolate": config["interpolate"], + "seed": config["seed"], + } + + +def _install_marker_material(mesh_obj, image, uv_map, interpolate): + """Install ONE emission material driven by the marker image over ``uv_map``.""" + material = bpy.data.materials.new("probe_marker_emission") + material.use_nodes = True + node_tree = material.node_tree + for node in list(node_tree.nodes): + node_tree.nodes.remove(node) + output = node_tree.nodes.new("ShaderNodeOutputMaterial") + emission = node_tree.nodes.new("ShaderNodeEmission") + texture = node_tree.nodes.new("ShaderNodeTexImage") + uv_node = node_tree.nodes.new("ShaderNodeUVMap") + uv_node.uv_map = uv_map + texture.image = image + try: + texture.interpolation = interpolate + except TypeError as exc: + raise ConfigError(f"settings.interpolate {interpolate!r} is not a valid mode") from exc + emission.inputs["Strength"].default_value = 1.0 + node_tree.links.new(uv_node.outputs["UV"], texture.inputs["Vector"]) + node_tree.links.new(texture.outputs["Color"], emission.inputs["Color"]) + node_tree.links.new(emission.outputs["Emission"], output.inputs["Surface"]) + + mesh = mesh_obj.data + mesh.materials.clear() + mesh.materials.append(material) + for polygon in mesh.polygons: + polygon.material_index = 0 + return material + + +def _write_vertices(mesh, coords): + flat = np.ascontiguousarray(coords, dtype=np.float32).ravel() + mesh.vertices.foreach_set("co", flat) + mesh.update() + + +def _render_to(scene, path): + Path(path).parent.mkdir(parents=True, exist_ok=True) + scene.render.filepath = str(path) + bpy.ops.render.render(write_still=True) + strip_volatile_text_chunks(path) + return Path(path) + + +# ---------------------------------------------------------------------- +# Main +# ---------------------------------------------------------------------- +def main(): + args = parse_args(_argv_after_double_dash()) + try: + job = _load_job(args.job) + config = _resolve_config(job, args) + except (ConfigError, json.JSONDecodeError, OSError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 + + scene = bpy.context.scene + try: + settings_applied = _apply_scene_settings(scene, config) + except (ConfigError, SceneError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 2 if isinstance(exc, ConfigError) else 1 + + try: + npz_path = _require_non_empty(config["frames_npz"], "probe_frames.npz") + texture_path = _require_non_empty(config["texture"], "marker texture") + mesh_obj = _require_object(config["mesh"], "MESH", "mesh") + camera_obj = _require_object(config["camera"], "CAMERA", "camera") + _require_uv_layer(mesh_obj, config["uv_map"]) + except (ArtifactError, SceneError) as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 3 if isinstance(exc, ArtifactError) else 1 + + started = time.time() + mesh = mesh_obj.data + try: + with np.load(str(npz_path), allow_pickle=False) as data: + missing = [key for key in _REQUIRED_NPZ_KEYS if key not in data.files] + if missing: + raise SceneError( + f"{npz_path} is missing key(s) {', '.join(missing)} " + f"(present: {', '.join(sorted(data.files))})" + ) + base_vertices = np.asarray(data["base_vertices"], dtype=np.float64) + vertices = np.asarray(data["vertices"], dtype=np.float64) + npz_names = [str(name) for name in np.asarray(data["frame_names"]).tolist()] + probe_indices = np.asarray(data["probe_indices"], dtype=np.int64).ravel() + except SceneError: + raise + except Exception as exc: # noqa: BLE001 - surface any npz read failure + print(f"ERROR: cannot read {npz_path}: {exc}", file=sys.stderr) + return 3 + + plan_names = [frame["name"] for frame in config["frames"]] + try: + if vertices.ndim != 3 or vertices.shape[2] != 3: + raise SceneError(f"probe_frames.npz 'vertices' must be (F, V, 3), got {vertices.shape}") + if base_vertices.ndim != 2 or base_vertices.shape[1] != 3: + raise SceneError( + f"probe_frames.npz 'base_vertices' must be (V, 3), got {base_vertices.shape}" + ) + if len(vertices) != len(npz_names): + raise SceneError( + f"probe_frames.npz has {len(vertices)} frames but {len(npz_names)} frame names" + ) + if len(npz_names) != len(plan_names): + raise SceneError( + f"probe_frames.npz has {len(npz_names)} frames but the job plan has " + f"{len(plan_names)}" + ) + mismatched = [ + (index, plan, npz) + for index, (plan, npz) in enumerate(zip(plan_names, npz_names)) + if plan != npz + ] + if mismatched: + index, plan, npz = mismatched[0] + raise SceneError( + f"frame order disagrees at index {index}: job plan {plan!r} vs " + f"probe_frames.npz {npz!r}" + ) + if vertices.shape[1] != len(mesh.vertices): + raise SceneError( + f"probe_frames.npz has {vertices.shape[1]} vertices but mesh {mesh_obj.name!r} " + f"has {len(mesh.vertices)}" + ) + if base_vertices.shape[0] != len(mesh.vertices): + raise SceneError( + f"probe_frames.npz 'base_vertices' has {base_vertices.shape[0]} rows but mesh " + f"{mesh_obj.name!r} has {len(mesh.vertices)} vertices" + ) + if probe_indices.size == 0: + raise SceneError("probe_frames.npz 'probe_indices' is empty") + if probe_indices.min() < 0 or probe_indices.max() >= len(mesh.vertices): + raise SceneError( + f"probe_frames.npz 'probe_indices' out of range 0..{len(mesh.vertices) - 1}" + ) + except SceneError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + try: + image = bpy.data.images.load(str(texture_path), check_existing=False) + except RuntimeError as exc: + print(f"ERROR: cannot load marker texture {texture_path}: {exc}", file=sys.stderr) + return 3 + if image.size[0] <= 0 or image.size[1] <= 0: + print(f"ERROR: marker texture has no pixels: {texture_path}", file=sys.stderr) + return 3 + image.colorspace_settings.name = "sRGB" + + output_dir = config["output_dir"] + frames_dir = output_dir / FRAMES_DIRNAME + frames_dir.mkdir(parents=True, exist_ok=True) + _install_marker_material(mesh_obj, image, config["uv_map"], config["interpolate"]) + + projector = Projector(scene, camera_obj, config["width"], config["height"]) + matrix_world = mesh_obj.matrix_world + edges = np.array( + [tuple(edge.vertices) for edge in mesh.edges], dtype=np.int64 + ).reshape(-1, 2) + + def project_local(local_co): + return projector.project(matrix_world @ Vector(tuple(float(x) for x in local_co))) + + try: + mesh_px_per_mm = _mesh_px_per_mm_stats(vertices[0], edges, projector, matrix_world) + # Every probe vertex moves by the same vector, so the centroid of each + # frame's probe members is exactly origin + applied displacement. + centroids = [ + (float(project_local(vertices[i][probe_indices].mean(axis=0))[0]), + float(project_local(vertices[i][probe_indices].mean(axis=0))[1])) + for i in range(len(config["frames"])) + ] + probe_px_per_mm = _probe_px_per_mm(config["frames"], centroids) + except SceneError as exc: + print(f"ERROR: {exc}", file=sys.stderr) + return 1 + + roi_u, roi_v = centroids[0] + roi_radius = config["radius_mm"] * float(probe_px_per_mm["p50"]) + + # --- frames, in plan order --- + frame_entries = [] + determinism = None + reference_png_sha = None + for position, frame in enumerate(config["frames"]): + centroid = centroids[position] + _write_vertices(mesh, vertices[position]) + png = _render_to(scene, frames_dir / f"{frame['name']}.png") + digest = _sha256(png) + if position == 0: + reference_png_sha = digest + # Same session, same seed, same scene: the DECODED PIXELS must agree + # within one 8-bit step on at most 0.05% of pixels. Raw file bytes are + # not the comparison; Cycles thread ordering can flip a boundary pixel. + repeat = _render_to(scene, output_dir / "checks" / f"{frame['name']}_repeat.png") + determinism = compare_reference_renders(png, repeat) + if not determinism["passed"]: + print( + "ERROR: frame 0 rendered twice with the same seed is not reproducible: " + f"max_abs_delta={determinism['max_abs_delta']} " + f"(tolerance {determinism['max_abs_delta_tolerance']}), " + f"differing_fraction={determinism['differing_fraction']:.8f} " + f"(tolerance {determinism['differing_fraction_tolerance']}), " + f"differing_pixels={determinism['differing_pixels']}/" + f"{determinism['total_pixels']}; every later number would be invalid", + file=sys.stderr, + ) + return 1 + frame_entries.append( + { + "index": frame["index"], + "name": frame["name"], + "axis": frame["axis"], + "step_mm": frame["step_mm"], + "png": f"{FRAMES_DIRNAME}/{frame['name']}.png", + "sha256": digest, + "width": config["width"], + "height": config["height"], + "centroid_px": [centroid[0], centroid[1]], + } + ) + print( + f"[{position + 1}/{len(config['frames'])}] {frame['name']} -> {png.name} " + f"({png.stat().st_size} bytes, centroid {centroid[0]:.4f},{centroid[1]:.4f})", + flush=True, + ) + + if reference_png_sha is None: + print("ERROR: the job plan has no reference frame", file=sys.stderr) + return 1 + if abs(centroids[0][0] - roi_u) > 1e-9 or abs(centroids[0][1] - roi_v) > 1e-9: + print( + f"ERROR: reference centroid_px {centroids[0]} does not match probe_roi_px " + f"centre ({roi_u}, {roi_v})", + file=sys.stderr, + ) + return 1 + + # --- null frame: the reference again at a different seed --- + _write_vertices(mesh, vertices[0]) + cycles = getattr(scene, "cycles", None) + if cycles is not None: + cycles.seed = config["null_seed"] + null_png = _render_to(scene, frames_dir / f"{config['null_name']}.png") + null_sha = _sha256(null_png) + print( + f"null frame {config['null_name']} seed={config['null_seed']} -> {null_png.name} " + f"({null_png.stat().st_size} bytes)", + flush=True, + ) + + if config["source_blend"].is_file(): + source_sha = _sha256(config["source_blend"]) + else: + source_sha = "" + manifest = { + "schema": MANIFEST_SCHEMA, + "generated_at": _utc_now(), + "source_blend": { + "path": str(config["source_blend"]), + "sha256": source_sha, + }, + "texture": { + "path": str(texture_path), + "sha256": _sha256(texture_path), + }, + "uv_map": config["uv_map"], + "width": config["width"], + "height": config["height"], + "settings_applied": settings_applied, + "mesh_px_per_mm": mesh_px_per_mm, + "probe_px_per_mm": probe_px_per_mm, + "probe_roi_px": { + "u": float(roi_u), + "v": float(roi_v), + "radius": float(roi_radius), + }, + "reference_index": 0, + "reference_deterministic": bool(determinism["passed"]), + "reference_determinism": determinism, + "frames": frame_entries, + "null": { + "name": config["null_name"], + "seed": config["null_seed"], + "png": f"{FRAMES_DIRNAME}/{config['null_name']}.png", + "sha256": null_sha, + }, + } + manifest_path = output_dir / MANIFEST_FILENAME + manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + + print( + f"Render complete: {len(frame_entries)} frames + 1 null in " + f"{time.time() - started:.1f}s" + ) + print( + " mesh_px_per_mm min/p05/p50/p95/max " + f"{mesh_px_per_mm['min']:.4f} / {mesh_px_per_mm['p05']:.4f} / " + f"{mesh_px_per_mm['p50']:.4f} / {mesh_px_per_mm['p95']:.4f} / " + f"{mesh_px_per_mm['max']:.4f}" + ) + print( + " probe_px_per_mm p50/x/y/z " + f"{probe_px_per_mm['p50']:.4f} / {float(probe_px_per_mm.get('x', float('nan'))):.4f} / " + f"{float(probe_px_per_mm.get('y', float('nan'))):.4f} / " + f"{float(probe_px_per_mm.get('z', float('nan'))):.4f}" + ) + print(f" probe_roi_px u/v/radius {roi_u:.4f} / {roi_v:.4f} / {roi_radius:.4f}") + print( + f" reference_deterministic {determinism['passed']} " + f"(max_abs_delta {determinism['max_abs_delta']}/" + f"{determinism['max_abs_delta_tolerance']}, differing_fraction " + f"{determinism['differing_fraction']:.8f}/" + f"{determinism['differing_fraction_tolerance']})" + ) + print(f" reference sha256 {reference_png_sha}") + print(f" manifest {manifest_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/blender/scripts/test_probe_falloff.py b/blender/scripts/test_probe_falloff.py new file mode 100644 index 0000000..3d6b16a --- /dev/null +++ b/blender/scripts/test_probe_falloff.py @@ -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=" 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) diff --git a/build/textures/finger-xynova/r01/patch_texture_produce.png b/build/textures/finger-xynova/r01/patch_texture_produce.png index 5affb76..7a65e49 100644 Binary files a/build/textures/finger-xynova/r01/patch_texture_produce.png and b/build/textures/finger-xynova/r01/patch_texture_produce.png differ diff --git a/docs/probe-findings.md b/docs/probe-findings.md new file mode 100644 index 0000000..a221ef2 --- /dev/null +++ b/docs/probe-findings.md @@ -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. diff --git a/docs/probe-method.md b/docs/probe-method.md new file mode 100644 index 0000000..5a7c81a --- /dev/null +++ b/docs/probe-method.md @@ -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///`. The sensitivity +stage reads `render_manifest.json`, `flow_report.json` and `flow//*.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 , 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 ... below 2 px` — the cell is at or under the + DIS patch Nyquist limit. +- `no step is resolvable on axis ... at ` — 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 [--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// # score only +orisys-design optimise sensors// --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. diff --git a/docs/probe-pipeline.md b/docs/probe-pipeline.md new file mode 100644 index 0000000..c77a65b --- /dev/null +++ b/docs/probe-pipeline.md @@ -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/// +``` + +resolved the same way `texture.py` resolves `build/textures/...` (see +`texture_revision_rel`; reuse it, do not re-invent it). + +## Frame plan + +Positive-only sweep, axes in the order `probe.axes` (default `x, y, z`), +steps `step_mm`, `2*step_mm`, ... up to and including `max_mm`. + +| index | name | axis | step_mm | +|---|---|---|---| +| 0 | `000_reference` | `null` | 0.0 | +| 1 | `001_x_+0.01mm` | `x` | 0.01 | +| ... | ... | ... | ... | +| 10 | `010_x_+0.10mm` | `x` | 0.10 | +| 11..20 | `0NN_y_+0.0Xmm` | `y` | ... | +| 21..30 | `0NN_z_+0.0Xmm` | `z` | ... | + +`F = 1 + len(axes) * round(max_mm / step_mm)` = **31** for the default settings. +PNG for frame `i` is `frames/.png`. + +The displacement for frame `i` is the frame's unit axis of the **probe-average +basis** (frozen below) scaled by `step_mm`. All 6 probe vertices move by exactly +that vector; every other vertex moves by `weight * vector`. + +## The probe-average basis + +Built from the area-independent average of the 6 probe vertex normals, then made +orthonormal so the three sweeps are exactly orthogonal and comparable: + +``` +z = normalize(sum(normal_i for i in probe)) # surface normal +x = normalize(cross(Vector((0,0,1)), z)) # fall back to (1,0,0) if degenerate +y = cross(z, x) +origin = mean(co_i for i in probe) +``` + +Measured for Xynova (orthonormal to 1e-10): + +``` +z = (-0.000027, -0.061749, 0.998092) +x = ( 1.000000, -0.000431, 0.000000) +y = ( 0.000430, 0.998092, 0.061749) +origin = (74.76961, 16.21550, 120.80202) +``` + +An axis vector for displacement is `{'x': x, 'y': y, 'z': z}[axis] * step_mm` +in the **mesh object's local space**. Since +`sensing_surface.matrix_world.to_scale() == (1,1,1)` and +`meters_per_source_unit == 0.001`, local units are mm and the vector is in mm. + +## Proportional falloff + +We deliberately do **not** use `bpy.ops.transform.translate(use_proportional_edit=True)`: +that operator is viewport-modal and its `proportional_size` is in screen pixels +relative to a 3D-view region, which does not exist under `blender -b` and would not +be reproducible. The falloff is computed explicitly, in mm, in a bpy-free module. + +Semantics match Blender's proportional editing: the falloff distance is measured +from the **original** positions of the selected (probe) vertices, so the weight set +is constant across all frames. + +``` +t = d / radius_mm # d = min distance to any probe vertex +smooth: w = 1 - (3t^2 - 2t^3) # Blender's "Smooth" curve; 1 at t=0, 0 at t>=1 +linear: w = 1 - t +sharp: w = (1 - t)^2 +constant: w = 1 +w = 0 whenever t >= 1, and w = 1 for probe members +``` + +## `design.yaml` `probe:` block + +```yaml +probe: + mesh: sensing_surface + camera: cam_sensor + group: probe + uv_map: compute + texture: patch_texture_compute.png + radius_mm: 0.5 + falloff: smooth + step_mm: 0.01 + max_mm: 0.10 + axes: [x, y, z] + resolution: [500, 500] + process_resolution: [250, 250] + tracker: + algorithm: dis + dis_preset: medium + dis_mode: 1 + render: + engine: CYCLES + samples: 16 + denoise: false + view_transform: Standard + interpolate: Closest + seed: 0 +``` + +Built-in fallbacks live in `deform.DEFAULT_PROBE` and are merged +`DEFAULT_PROBE <- design.yaml probe <- CLI overrides`, exactly like +`texture.merge_texture_patch`. Unknown keys are an error. Validation returns one +message per problem and raises `ProbeConfigError`. + +## Job files (stdlib JSON; Blender never reads YAML) + +### `_deform_job.json` + +```json +{ + "schema": "orisys-sensor-design/probe-deform-job-v1", + "revision_dir": "", + "source_blend": "", + "output_dir": "", + "objects": {"mesh": "sensing_surface", "group": "probe"}, + "settings": { + "radius_mm": 0.5, + "falloff": "smooth", + "step_mm": 0.01, + "max_mm": 0.10, + "axes": ["x", "y", "z"] + }, + "frames": [ + {"index": 0, "name": "000_reference", "axis": null, "step_mm": 0.0}, + {"index": 1, "name": "001_x_+0.01mm", "axis": "x", "step_mm": 0.01} + ], + "artifacts": {"frames": "/probe_frames.npz", "report": "/deform_report.json"} +} +``` + +### `_render_job.json` + +```json +{ + "schema": "orisys-sensor-design/probe-render-job-v1", + "revision_dir": "", + "source_blend": "", + "output_dir": "", + "objects": {"mesh": "sensing_surface", "camera": "cam_sensor"}, + "settings": { + "uv_map": "compute", + "texture": "", + "width": 500, "height": 500, + "radius_mm": 0.5, + "engine": "CYCLES", "samples": 16, + "denoise": false, "view_transform": "Standard", + "interpolate": "Closest", "seed": 0 + }, + "frames": [""], + "null_frame": {"name": "reference_null", "seed": 1}, + "artifacts": {"frames_npz": "/probe_frames.npz", "report": "/render_manifest.json"} +} +``` + +`null_frame` is a second render of the reference at a different Cycles seed. It is +never passed to the tracker: it exists only to measure the estimator's zero-input +floor. + +### `_track_job.json` + +```json +{ + "schema": "orisys-sensor-design/probe-track-job-v1", + "output_dir": "", + "settings": { + "axes": ["x", "y", "z"], + "step_mm": 0.01, "max_mm": 0.10, + "radius_mm": 0.5, + "process_resolution": [250, 250], + "tracker": {"algorithm": "dis", "dis_preset": "medium", "dis_mode": 1} + }, + "scales": [ + {"name": "render", "width": 500, "height": 500, "resample": false}, + {"name": "process", "width": 250, "height": 250, "resample": true} + ], + "artifacts": {"manifest": "/render_manifest.json", "strategy": "/strategy_probe.json"} +} +``` + +## `probe_frames.npz` + +The contract between the deformation and rendering workstreams. + +| Key | Shape / dtype | Meaning | +|---|---|---| +| `base_vertices` | `(V, 3) float64` | original object-local coords, all V vertices | +| `vertices` | `(F, V, 3) float64` | deformed coords per frame; index 0 = reference | +| `frame_names` | `(F,) ", + "source_blend": {"path": "", "sha256": "..."}, + "mesh": "sensing_surface", + "group": "probe", + "probe_indices": [215, 216, 218, 219, 327, 329], + "origin": [74.76961, 16.2155, 120.80202], + "basis": [[1.0, -0.000431, 0.0], [0.00043, 0.998092, 0.061749], + [-0.000027, -0.061749, 0.998092]], + "basis_orthonormal": true, + "normal_angle_max_deg": 2.553, + "flatness_mm": 0.007611, + "radius_mm": 0.5, + "falloff": "smooth", + "vertex_count": 2508, + "edge_p50_mm": 0.628501, + "weights": {"min": 0.0, "max": 1.0, "members_at_one": 6, + "partial": 5, "unaffected": 2497}, + "affect_bands": {"1.0": 6, "0.3-0.5": 1, "0.05-0.1": 4}, + "frames": [{"index": 0, "name": "000_reference", "axis": null, + "step_mm": 0.0, "max_delta_mm": 0.0, "moved_vertices": 0}], + "warnings": ["radius 0.5 mm reaches only 5 vertices outside the probe group ..."] +} +``` + +`moved_vertices` is the number of vertices whose displacement is non-zero **in that +frame**: `0` for the reference, and `6 + partial` (= 11 on Xynova) for any non-zero +step. It is not the probe member count, and it is not the same for every frame — +reporting 6 on the reference would contradict `max_delta_mm: 0.0`. The `weights` +block remains the authority for how many vertices are partially weighted. + +`warnings` is the honesty channel. Emit one whenever +`affected (partial) < 4 * len(probe_indices)`, or `radius_mm < edge_p50_mm`. + +## `render_manifest.json` + +```json +{ + "schema": "orisys-sensor-design/probe-render-manifest-v1", + "generated_at": "", + "source_blend": {"path": "", "sha256": "..."}, + "texture": {"path": "", "sha256": "..."}, + "uv_map": "compute", + "width": 500, "height": 500, + "settings_applied": {"engine": "CYCLES", "samples": 16, "denoise": false, + "view_transform": "Standard", "look": "None", + "exposure": 0.0, "interpolate": "Closest", "seed": 0}, + "mesh_px_per_mm": {"min": 10.499, "p05": 10.946, "p50": 13.430, + "p95": 22.886, "max": 38.594, + "basis": "per-mesh-edge projected px / 3D mm, all 7341 edges"}, + "probe_px_per_mm": {"p50": 13.248, "x": 13.248, "y": 17.670, "z": 1.519, + "basis": "probe-local: |centroid_px(step) - centroid_px(0)| / step_mm, per axis at its largest step"}, + "probe_roi_px": {"u": 0.0, "v": 0.0, "radius": 0.0}, + "reference_index": 0, + "reference_deterministic": true, + "frames": [{"index": 0, "name": "000_reference", "axis": null, "step_mm": 0.0, + "png": "frames/000_reference.png", "sha256": "...", + "width": 500, "height": 500, + "centroid_px": [249.61, 214.38]}], + "null": {"name": "reference_null", "seed": 1, + "png": "frames/reference_null.png", "sha256": "..."} +} +``` + +`reference_deterministic` reports the result of rendering frame 0 twice in the same +session with the same seed and comparing the saved bytes. It must be `true`; if it +is not, the render is not reproducible and the CLI must fail. + +**Compare decoded pixels, not file bytes, and allow a 1-LSB tolerance.** This was +established the hard way: with literal whole-file byte equality the check rejected +candidate `nx=36` on the real run, and PNG chunk analysis showed why — the two files +had **identical chunk structure** (the only `tEXt` chunk was the deterministic +`cycles.ViewLayer.samples`) yet their decompressed pixel data differed in **2 bytes of +250,250**, at two scanline offsets. That is Cycles CPU thread-order floating-point +nondeterminism tipping a boundary pixel by one 8-bit step, not a metadata artefact and +not a defect in the render. + +A gate that fails on a 1-LSB difference is not measuring reproducibility; it is +measuring thread scheduling, and because it is **content-dependent** it silently +disqualifies some patterns and biases a pattern search. So: + +- Strip only the known-volatile `tEXt` chunks as before (`Date`, `Time`, `RenderTime`, + `cycles.ViewLayer.*_time`); keep image chunks byte-for-byte. +- Decode both PNGs and require the same shape, `max |delta| <= 1` per 8-bit channel, + and a differing-pixel fraction at or below **0.05%**. Record the measured + `max_abs_delta` and `differing_fraction` in the manifest either way. +- Fail loudly when a tolerance is exceeded, so real nondeterminism still stops the run. +- Never compare raw file bytes: PNG compression and metadata are not the measurement. + + +`probe_roi_px` is the probe centroid projected to pixels plus the radius in pixels +that corresponds to `radius_mm` (`radius = radius_mm * probe_px_per_mm.p50`), all +measured from the actual projection (not assumed). The tracker uses it to build its +ROI, and the ROI is **fixed at the reference centroid** for every frame — a moving +ROI would bias the ROI mean as the probe moves, which is exactly the signal we are +trying to measure. + +### Two different pixel scales, and why they must not be confused + +- `mesh_px_per_mm` is a **mesh-wide** statistic: for every mesh edge, its projected + length in pixels divided by its 3D length in mm, summarised over all 7341 edges. + It describes how the whole surface is sampled in the camera. It is **not** the + gain to convert the probe's measured flow into millimetres. +- `probe_px_per_mm` is the **probe-local** scale, and it is the one the scorer must + use: `|centroid_px(step) - centroid_px(0)| / step_mm`, per axis, i.e. how many + image pixels the probe actually moves per millimetre applied. It is anisotropic, + and `z` (motion along the surface normal) is expected to be **much smaller** than + x and y because normal motion is largely out of the image plane. That is a real + physical property of the sensor, not a defect. + +Because the `z` scale can approach zero, dividing a measured response by it is +ill-conditioned. The scorer therefore reports gain in **px per mm** as the primary +quantity and only derives mm per mm when the axis scale clears +`sensitivity.min_local_scale_px_per_mm` (default **1.0**; a module constant in +`sensitivity.py`, deliberately not a `design.yaml` key so it cannot be tuned into +silence). Never divide by a scale below that; report `null` and warn instead. A large +"mm/mm" gain produced by dividing by a near-zero scale would be an artefact, not a +sensitivity. + +The default of 1.0 is validated by real data rather than chosen by feel. Measured +per-axis probe scales: **x 13.248, y 17.670, z 1.519 px/mm at the render scale**, and +**x 6.624, y 8.835, z 0.760 px/mm at the process scale**. So 1.0 discriminates exactly +where it should on this revision: render-scale `z` (1.519) is admitted and yields a +derived `gain_mm_per_mm` of 0.445 backed by 7 steps above the floor at `snr` 4.2, +while process-scale `z` (0.760) is correctly gated to `null`. + +Note what this implies about the earlier worry that `z` would be unmeasurable: at the +**process** scale a 0.1 mm normal displacement is only 0.076 px against a 0.19 px +floor, so it genuinely is unmeasurable there; at the **render** scale it is 0.152 px +against a 0.028 px floor, so it is measurable from about `0.04 mm` upward. The gate is +a guard against dividing by a degenerate scale, **not** a substitute for the measured +`snr`, `points_above_floor` and `resolvable_step_mm`, which are the quantities that +decide whether an axis is useful. Report all of them; do not suppress a number that +the data supports. + +### What actually limits the measurement (measured, not assumed) + +Do **not** attribute the response deficit to aliasing, noise, or ROI dilution. The +render-scale deficit is **spatial resolution of the measurement region**, established +by these measurements on the real frames: + +- The real `x +0.10 mm` flow radial profile is essentially uniform inside the ROI + (`-0.9162`, `-0.9096`, `-0.8725` px at r 0-2, 2-4, 4-6.6), with `|flow|` p5..p100 of + `0.81..0.93` px. So it *is* a coherent translation, not noise and not a diluted + average: the motion is simply shrunk to about `0.67x` of the applied `1.3248 px`. +- Injecting a synthetic **local rigid translation** of `1.3248 px` into the reference + and measuring it over the same ROI recovers only **17%** at a patch radius of + 6.6 px (equal to the ROI), then **33%** at r 8, **51%** at r 10, **63%** at r 13, + **81%** at r 20 and **95%** at r 40. +- Therefore the attenuation is caused by the moving probe patch plus its falloff taper + spanning only about **13 px, i.e. 1-2 DIS patches (8 px)**, so DIS regularizes + toward the static background. At the process scale the same region is about 6.5 px, + under one patch, and the response collapses while the forward-backward error grows + from 1.6 to 8.8 (x) and 15.8 (y). + +Two consequences the report must state rather than bury: + +1. **The measured gain is confounded with the ROI size.** It grows strongly with + `radius_mm` because a larger radius gives DIS more moving texture to lock onto. + Relative ranking of marker patterns is valid **only** while `radius_mm`, the ROI and + the scale are held fixed across candidates — which is why `run_optimise` varies + `nx`/`ny`/`seed` and never the radius. +2. **"Below one pixel" and "below the estimator floor" are different statements and + must not be conflated.** The measured zero-input floor is **0.0292 px at the render + scale**, so a `0.01 mm` step (`0.1325 px`) is about **4.5x above the floor**: the + ROI-averaged response genuinely resolves single steps at 500x500, with monotone + per-step responses (`0.0810, 0.1316, 0.1971, ... 0.8859` px). The earlier + expectation that the sub-pixel step would be lost in the floor is **not** supported + by the measurement; what is lost is *per-pixel* precision, not the ROI mean. Report + the floor and the step in pixels and let the numbers speak. + + + + +Each `frames[]` entry also carries `centroid_px`: the probe centroid projected to +pixels **for that frame's deformation**, as full-precision floats (not rounded). +This is the authoritative source for the image-space displacement direction the +sensitivity stage needs. It is a small number of pixels (1.34 px across the whole +0.1 mm sweep at 500x500), which is why it is recorded as a float and why the scorer +regresses over all steps rather than differencing a single pair. + +## `flow_report.json` + +```json +{ + "schema": "orisys-sensor-design/flow-report-v1", + "generated_at": "", + "tracker": {"algorithm": "dis", "dis_preset": "medium", "dis_mode": 1, + "finest_scale": 1, "source": "design.yaml probe.tracker"}, + "known_answer": {"passed": true, "cases": [ + {"dx": 2, "dy": 0, "measured_u": 2.0, "measured_v": 0.0, "tol": 0.25}, + {"dx": 0, "dy": 2, "measured_u": 0.0, "measured_v": 2.0, "tol": 0.25}]}, + "reference_texture_known_answer": { + "passed": false, "gating": false, + "note": "a known shift applied to the real rendered marker; a periodic " + "pattern legitimately fails this when its cell approaches the DIS " + "patch, so it is a diagnostic, not a plumbing gate", + "cases": [{"dx": 2, "dy": 0, "measured_u": 8.17, "expected_u": 2.0}]}, + "reference_self": {"max_abs_flow_px": 0.0}, + "scales": [{ + "name": "render", "width": 500, "height": 500, "resample": false, + "px_per_mm": 13.43, "mm_per_px": 0.07446, + "frames": [{ + "index": 1, "name": "001_x_+0.01mm", "axis": "x", "step_mm": 0.01, + "roi": {"u": 249.6, "v": 214.4, "radius": 6.7, "pixels": 141}, + "flow": {"mean_u": 0.02, "mean_v": -0.01, + "mean_mag": 0.03, "p50_mag": 0.02, "p95_mag": 0.08, "max_mag": 0.31, + "coverage": 0.41}, + "response_proj_px": 0.02, + "response_mm": 0.0015, + "forward_backward_error": 1.87} + ], + "null": {"floor_px": 0.05, "floor_source": "reference_null"} + }] +} +``` + +Per-frame flow arrays are also written to `flow//.npy` as +`float32 (H, W, 2)` so the sensitivity stage and any later inspection can reuse them +without re-running DIS. + +`response_mm` and `response_proj_px` are informational. **Pixels are authoritative** +(`response_proj_px`); `response_mm` is derived through a single isotropic scalar +scale (`px_per_mm`, the probe-local p50) and therefore *understates or overstates* +the true conversion on an anisotropic axis — notably `z`. The scorer must use +`response_proj_px` plus the per-axis local scale from `render_manifest.json` and must +not consume `response_mm`. Each scale records `px_per_mm_source` naming the exact +manifest field it came from, so the provenance of the scalar is auditable. + +## `sensitivity_report.json` + +```json +{ + "schema": "orisys-sensor-design/sensitivity-report-v1", + "generated_at": "", + "revision": "sensors/finger-xynova/r01", + "texture": {"path": "", "sha256": "...", "grid": [80, 80], + "cell_mm": [0.23125, 0.205]}, + "probe": {"group": "probe", "origin": [...], "basis": [...], + "radius_mm": 0.5, "falloff": "smooth", "affected_vertices": 5}, + "tracker": {"dis_preset": "medium", "dis_mode": 1, "patch_px": 8}, + "scales": [{ + "name": "render", "px_per_mm": 13.43, + "displacement_px_per_step": 0.1343, + "cell_px": 3.11, + "cell_over_patch_px": 0.389, + "null_floor_px": 0.05, + "null_floor_basis": "render_seed_reproducibility", + "axes": [{ + "axis": "x", "direction_px": [0.999, -0.031], + "direction_source": "centroid_regression", + "points": [{"step_mm": 0.01, "applied_px": 0.134, "response_px": 0.02, + "above_floor": false}], + "summary": {"gain_px_per_mm": 6.2, "gain_mm_per_mm": 0.46, + "scale_px_per_mm": 13.42, "snr": 0.42, "spearman": 0.95, + "resolvable_step_mm": null, "saturation_mm": null, + "cross_axis_leak": 0.18} + }], + "score": 0.11, + "verdict": "unresolvable" + }], + "overall": {"score": 0.11, "verdict": "unresolvable", + "best_scale": "render", "best_axis": "z"}, + "warnings": ["0.01 mm is 0.134 px at 500x500; a single step is below the estimator floor"], + "notes": [] +} +``` + +### Scoring (every input measured; no assumed constants) + +For one scale and one axis: + +1. `d_hat_px` — unit image-space direction of the applied displacement, from + `render_manifest.json`'s per-frame `centroid_px`. Regress `centroid_px` on + `step_mm` over every frame of that axis and normalize the slope vector + `(du/dstep, dv/dstep)`; this is preferred over a single 0 -> `+max_mm` finite + difference because the whole sweep is only ~1.3 px. Fall back to the two-point + finite difference when the regression is degenerate, and to the ROI-mean flow at + the `+max_mm` frame when `centroid_px` is absent. Record which was used as + `direction_source` per axis. +2. `response_i` = mean over ROI pixels of `dot(flow_i, d_hat_px)`, in px. Sign kept. +3. `floor_px` = p95 of `|response|` measured on `reference_null`, projected on the + same `d_hat_px`. Reported per axis. +4. `gain_px_per_mm` = OLS slope of `response_px` vs `applied_mm`, over the steps with + `|response_i| > floor_px`. Requires >= 3 such points, else `null`. **This is the + primary sensitivity**: measured pixels of probe response per millimetre applied. +5. `scale_px_per_mm` = `probe_px_per_mm[axis]`. Then `gain_mm_per_mm` = + `gain_px_per_mm / scale_px_per_mm`, reported **only** when + `scale_px_per_mm >= min_local_scale_px_per_mm` (default 1.0); otherwise `null` + plus a warning. Never divide by a near-zero scale: for the `z` axis that would + manufacture an enormous gain no camera could observe. +6. `snr` = `|gain_px_per_mm| * max_mm / max(floor_px, eps)` — both terms in px, so it + is dimensionally consistent and comparable across patterns and axes. +7. `spearman` = Spearman rank correlation of `response_i` vs `applied_mm` over all + steps; clamped to `[0, 1]`. +8. `cross_axis_leak` = mean `|dot(flow, d_perp_px)|` / mean `|dot(flow, d_hat_px)|` + over the ROI and steps, clamped to `[0, 1]`. +9. `score` = `snr * spearman * (1 - min(leak, 0.5))`. +10. `resolvable_step_mm` = the smallest `step_mm` with `|response_i| > floor_px`, else + `null`. +11. `saturation_mm` = the first step where `response` stops increasing + (monotonically decreasing after a rise), else `null`. + +`verdict`, in precedence order: + +- **`ambiguous`** — the tracker's `reference_texture_known_answer` failed for this + scale: a known shift applied to the *actual rendered marker* was not recovered. + For a **periodic** marker this is expected once the cell approaches the DIS patch + size — DIS locks onto the period and reports the shift modulo that period — and it + means the measured direction and magnitude are not trustworthy, so no score can + promote this pattern. This takes precedence over everything below. +- `resolvable` — `gain_px_per_mm` is not null and `snr >= 3`. +- `marginal` — `gain_px_per_mm` is not null and `snr < 3`. +- `unresolvable` — `gain_px_per_mm` is null. + +`overall.score` = mean of `score` over scales and axes. `overall.verdict` = +`resolvable` only if every scale and axis is `resolvable`; `ambiguous` if any scale is +`ambiguous`; otherwise the worst remaining verdict. + +Measured evidence for why `ambiguous` must exist (estimator, 500x500, probe scale +13.43 px/mm, DIS medium, `dis_mode=1`): a **2 px** known shift measured +**8.17 px** on a 3.11 px checker cell (aliased onto the 6.22 px period), **0.0 px** on +a 1 px cell, **-19.8 px** on 3.11 px grid lines, but **exactly 2.000 px** on a +12.4 px cell and **exactly 2.000 px** on a broadband random texture. A periodic +pattern aliases a sub-period shift onto the wrong period; a broadband one does not. +That is the whole reason the anisotropy and periodicity of the marker matter, and it +is measured, not assumed. + +## The floor is not a noise model + +`flow_report.json`'s `scales[].null.floor_px` is measured as `reference` vs a re-render at +**render seed + 1**: Cycles antialiasing jitter, ~5 grey levels RMS on half the pixels and +scaling with edge contrast. It is labelled +`floor_basis: "render_seed_reproducibility"` and **must not be used to rank patterns**. +Measured on this bench: + +- its ordering **reverses** under additive noise (render-seed: nx56 < nx80 < nx31; + σ=2: nx31 < nx56 < nx80); +- it **understates** the real idle floor, coarse patterns most. + +The calibrated replacement is: + +- **`design.yaml` `probe.noise`**: `idle_footage` (real no-contact recording), + `roi_fraction`, `max_frames`, `sigmas`, `draws`, `seed`, `models`, `scales`. +- **`floorcal.measure_idle_floor`**: runs the *same* `FixedReferenceFlow` on idle footage + and reports `floor_px_short` (consecutive frames, noise), `floor_px_long` (vs frame 0, + noise + drift), `dominant_period_px`, `floor_per_period_short|long` and `drift_ratio`. + A ratio near 1 means the clip is idle and the floor is frame-independent white noise. + On `data/dd02-0703/190.mp4` (1280x720): **0.2267 px**, period **25.5 px**, + **0.0089 floor per period**, ratio **0.98**. +- **`flow.noise_model_pass`**: additive white Gaussian applied to **both** the reference + and the displaced frames, so the signal and the floor come from one model. Written to + each scale as `noise_models` (`basis`, `draws`, `sigmas`, per-axis + `gain_px_per_mm`, `floor_px`, `floor_samples`). +- **`sensitivity.attach_noise_models`**: adds `response_retention` + (`gain(σ)/gain(clean)`) and `snr_noise` (same-model floor) per axis, and emits a + `model_disagreement` note when the response moves more than 15%. +- **`sensitivity.noise_calibration`**: top-level `noise_calibration` block with the real + numbers, cached as `idle_floor.json` keyed by the source's size and mtime. Skip with + `--no-calibration`. + +Schema: `flow-report-v2` and `sensitivity-report-v3`. All additions are additive; a report +without `noise_model` is still valid and means the render-seed model only. + +### Consequence for the ranking + +Under the calibrated model at the render scale, `radius_mm=0.5`, `dis_mode=1`, nx=80 has +the **highest clean response on both axes** (x 9.10 vs 4.74 px/mm; y 11.69 vs 10.90) and +the **best retention** (99%/84% at σ=4/16 against nx=31's 72%/59%). The earlier +"nx=31 wins" and "the cell must match the effective window" conclusions were artefacts of +the render-seed floor and are retracted; the window ratio remains a useful diagnostic, not +a validated law. The process scale and the `radius_mm=1.5` block are not yet re-ranked. + +## Out-of-scope / next hardware ask + +The idle clip gives only one pattern, so it validates the floor's **magnitude** and +**mechanism**, not its pattern trend. A deliberate no-contact recording at the shipped +configuration would let the trend itself be measured, and is the natural next ask. + +## `optimizer_report.json` + +```json +{ + "schema": "orisys-sensor-design/optimizer-report-v1", + "generated_at": "", + "search": {"grid": [15, 20, 24, 28, 31, 36, 42, 48, 56, 64, 80], + "seeds": [7], "render_resolution": [250, 250], + "candidates": 11, "objective": "sensitivity_report.overall.score"}, + "candidates": [{"nx": 31, "ny": 31, "seed": 7, "score": 0.44, + "verdict": "marginal", "cell_px": 8.01, + "report": "", "texture_sha256": "..."}], + "ranked": ["31@7", "36@7"], + "winner": {"nx": 31, "ny": 31, "seed": 7, "score": 0.44, + "confirmed_at_resolution": [500, 500], "confirmed_score": 0.39, + "report": ""}, + "notes": ["Ranked by the measured score under a fixed estimator and camera; this is not a global optimum."] +} +``` + +The optimizer searches `(nx, ny, seed)`. Candidates are enumerated in a +**deterministic sorted order** so two runs produce byte-identical rankings. Each +candidate regenerates the marker texture, then runs deform -> render -> track -> +sensitivity at the search resolution. Only the winner is re-run at the full +`probe.resolution` and its confirmed score recorded. + +### The search resolution must be the resolution you care about + +A reduced search resolution **answers a different question** and must not be used to +rank patterns. Measured on the real run, searching at 250x250 and confirming at +500x500 produced a winner that the real resolution disagrees with: + +| pattern | search score @250 | render score @500 | overall | +|---|---|---|---| +| `nx=48` (search winner) | **8.94** | 20.95 | 15.39 | +| `nx=80` (shipped) | **1.77** (ranked 10th of 10) | **36.77** | **22.22** | + +At 250x250 the `nx=80` cell is 1.53 px, below the DIS-patch Nyquist limit, so it +scored near the bottom; at 500x500 the same cell is 3.06 px and it scored *top*. The +shipped pattern is therefore the best of the grid at the real resolution and one of +the worst at the processed one — and a 250-based search excludes it. That is physics +(the two scales genuinely resolve different cells), not a bug, but it means the +default `search_resolution` must be `probe.resolution`, not a reduced size. Rendering +31 frames at 500x500 costs about 14 s, so a full-resolution search over the default +grid is only a few minutes and there is no reason to trade correctness for it. + +Two further consequences for the report: + +- Record **`search_resolution` and `confirm_resolution`**, and mark the winner's + `confirmed_score`, so a reader can see whether the search and the confirm measured + the same thing. A large gap between them means the search resolution was wrong. +- Rank and report candidates **per scale** as well as by the mean. "Best" is + scale-dependent here: `nx=80` wins the render scale while `nx=48` balances both. + `overall.score` is the mean over scales, which is a deliberate modelling choice, not + a neutral one, and the notes must say so. + + +## CLI surface + +``` +orisys-design deform [--output-dir DIR] [--radius-mm F] + [--falloff MODE] [--step-mm F] [--max-mm F] + [--group NAME] [--mesh NAME] [--axes x,y,z] +orisys-design render [--output-dir DIR] [--resolution WxH] + [--samples N] [--uv-map NAME] [--texture PATH] [--seed N] +orisys-design track [--output-dir DIR] + [--process-resolution WxH] [--no-process-scale] +orisys-design sensitivity [--output-dir DIR] [--skip-deform] + [--skip-render] [--skip-track] [--radius-mm F] [...] +orisys-design optimise [--output-dir DIR] [--grid LIST] + [--seeds LIST] [--search-resolution N] [--top N] [--dry-run] +``` + +Default `--output-dir` is `build/probe//`. + +**All argparse wiring lives in `cli.py`, owned by the Lead.** The feature modules +expose only plain entry points and must not add their own argument helpers or +duplicate `cmd_*` functions: + +- `deform.py`: `resolve_probe`, `probe_overrides_from_args`, `frame_plan`, + `build_deform_job`, `write_deform_job`, `resolve_probe_output_dir` +- `render.py`: `resolve_marker_texture`, `build_render_job`, `write_render_job`, + `render_manifest_path` +- `flow.py`: `build_track_job`, `write_track_job`, `run_track_job` +- `sensitivity.py`: `run_sensitivity(settings, output_dir, *, manifest_path=None, + flow_report_path=None, progress=print)` — **scoring only**; it must not re-run the + deform/render/track stages, because `cli.py` already chains them (and `--skip-*` + exists precisely to reuse their artifacts) +- `optimise.py`: `run_optimise(paths, settings, *, grid, seeds, search_resolution, + top, dry_run=False, runner=None, progress=print)`. The per-candidate `runner` is an + injected callable; `cli.py` supplies one that runs the real stages, and tests supply + a fake. Do not resolve stage functions by `inspect` or by guessing module layout. + + + +Exit codes, matching the existing convention: + +| Code | Meaning | +|---|---| +| 0 | success | +| 1 | scene / metadata / geometry error | +| 2 | config error, or a missing optional dependency | +| 3 | a required artifact is missing or empty | +| 4 | (reserved; not used by this pipeline) | + +## Failure modes that must fail loudly + +- Probe group missing, empty, or not on the requested mesh. +- The basis is not orthonormal within 1e-6. +- A displacement vector cannot be expressed in the basis. +- The marker texture PNG is missing or empty. +- `view_transform` is not `Standard` (AgX or Filmic silently destroys marker values). +- Frame count/order in `frames/` disagrees with the plan, or a PNG is missing/empty. +- `reference_deterministic` is false. +- The tracker's known-answer test fails. +- `cv2` is unavailable -> exit 2 with `python -m pip install -e .[probe]`. +- The probe ROI falls outside the frame at the process scale -> warn and truncate, do + not silently report a full-ROI number. + +## Non-goals + +Large-deformation robustness beyond `max_mm`; signed/bidirectional sweeps; per-vertex +normal indentation; refraction through `shell`/`rubber-1`; wiring the SDK processed +image pipeline (`resolution_stitch`, `source_scale`) into the estimator; absolute +force calibration. Sensitivity here is a **relative ranking under a fixed estimator +and camera**, not a force measurement. diff --git a/pyproject.toml b/pyproject.toml index 936b1ac..ee976ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/sensors/finger-xynova/r01/design.yaml b/sensors/finger-xynova/r01/design.yaml index 466a576..a0fd9e7 100644 --- a/sensors/finger-xynova/r01/design.yaml +++ b/sensors/finger-xynova/r01/design.yaml @@ -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 diff --git a/sensors/finger-xynova/r01/source/geometry.blend b/sensors/finger-xynova/r01/source/geometry.blend index 01b3e2c..fe2cecf 100644 Binary files a/sensors/finger-xynova/r01/source/geometry.blend and b/sensors/finger-xynova/r01/source/geometry.blend differ diff --git a/sensors/finger/r01/profiles/compute.yaml b/sensors/finger/r01/profiles/compute.yaml index e8eee80..c367302 100644 --- a/sensors/finger/r01/profiles/compute.yaml +++ b/sensors/finger/r01/profiles/compute.yaml @@ -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 ; 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 --map require_sensing_surface_only: true diff --git a/sensors/finger/r01/source/geometry.blend b/sensors/finger/r01/source/geometry.blend index 662656d..7b997ae 100644 Binary files a/sensors/finger/r01/source/geometry.blend and b/sensors/finger/r01/source/geometry.blend differ diff --git a/src/orisys_sensor_design/cli.py b/src/orisys_sensor_design/cli.py index e73177d..63b4a64 100644 --- a/src/orisys_sensor_design/cli.py +++ b/src/orisys_sensor_design/cli.py @@ -770,6 +770,811 @@ def cmd_optics(revision_dir: Path, args: argparse.Namespace) -> int: return 0 +# ---------------------------------------------------------------------- +# Probe sensitivity bench (`deform`, `render`, `track`, `sensitivity`, `optimise`) +# +# Contract: docs/probe-pipeline.md. Stages are factored into `_run_*_stage` +# helpers so `sensitivity` can chain them in-process without duplicating the +# job/Blender plumbing that the individual commands also use. +# ---------------------------------------------------------------------- +def _require_artifact(label: str, path: Path) -> bool: + """Print one error line when a required artifact is missing or empty.""" + if path.is_file() and path.stat().st_size > 0: + return True + print( + f"ERROR: the {label} artifact is missing or empty: {path}", + file=sys.stderr, + ) + return False + + +def _probe_context(revision_dir: Path, args: argparse.Namespace): + """Resolve (root, paths, settings, output_dir) for a probe command.""" + from orisys_sensor_design.deform import ( + probe_overrides_from_args, + resolve_probe, + resolve_probe_output_dir, + ) + + root = repo_root(revision_dir) + paths = RevisionPaths.from_revision_dir(revision_dir) + overrides = probe_overrides_from_args(args) + settings = resolve_probe(paths, overrides) + output_dir = resolve_probe_output_dir(root, paths, getattr(args, "output_dir", None)) + return root, paths, settings, output_dir + + +def _apply_render_overrides(settings, args: argparse.Namespace): + """Return `settings` with render-time CLI overrides applied.""" + import dataclasses + + resolution = getattr(args, "resolution", None) + samples = getattr(args, "samples", None) + seed = getattr(args, "seed", None) + uv_map = getattr(args, "uv_map", None) + texture = getattr(args, "texture", None) + + render = dict(getattr(settings, "render", {}) or {}) + if samples is not None: + render["samples"] = int(samples) + if seed is not None: + render["seed"] = int(seed) + + changes: dict = {"render": render} + if resolution is not None: + changes["resolution"] = [int(resolution[0]), int(resolution[1])] + if uv_map is not None: + changes["uv_map"] = str(uv_map) + if texture is not None: + changes["texture"] = str(texture) + return dataclasses.replace(settings, **changes) + + +def _run_deform_stage(paths, settings, output_dir: Path) -> int: + """Write the deform job, run Blender headless, and verify its artifacts.""" + from orisys_sensor_design.deform import ( + DEFORM_REPORT_FILENAME, + FRAMES_NPZ_FILENAME, + build_deform_job, + frame_plan, + write_deform_job, + ) + + output_dir.mkdir(parents=True, exist_ok=True) + job = build_deform_job(paths, settings, output_dir) + job_path = write_deform_job(job, output_dir) + print(f"Wrote deform job: {job_path}") + + code = _run_blender_script( + "probe_deform.py", paths.source_blend, ["--job", str(job_path)] + ) + if code != 0: + return code + + frames = frame_plan(settings) + npz_path = output_dir / FRAMES_NPZ_FILENAME + report_path = output_dir / DEFORM_REPORT_FILENAME + ok = _require_artifact("deformed frames (probe_frames.npz)", npz_path) + ok = _require_artifact("deformation report", report_path) and ok + if not ok: + return 3 + + print( + f"OK: {len(frames)} frame(s) deformed with radius " + f"{settings.radius_mm:g} mm, falloff {settings.falloff}" + ) + print(f" {npz_path}") + print(f" {report_path}") + warnings = _deform_warnings(report_path) + for message in warnings: + print(f" WARNING: {message}") + return 0 + + +def _deform_warnings(report_path: Path) -> list[str]: + """Read the deform report's warnings so the CLI surfaces them, not hides them.""" + try: + data = json.loads(report_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return [] + warnings = data.get("warnings") + if not isinstance(warnings, list): + return [] + return [str(item) for item in warnings] + + +def _run_render_stage(paths, settings, output_dir: Path) -> int: + """Write the render job, run Blender headless, and verify its artifacts.""" + from orisys_sensor_design.deform import FRAMES_NPZ_FILENAME + from orisys_sensor_design.render import ( + MANIFEST_FILENAME, + RenderArtifactError, + build_render_job, + render_manifest_path, + write_render_job, + ) + + npz_path = output_dir / FRAMES_NPZ_FILENAME + if not _require_artifact( + "deformed frames (run `orisys-design deform` first)", npz_path + ): + return 3 + + output_dir.mkdir(parents=True, exist_ok=True) + # build_render_job resolves and validates the marker texture itself, so a + # missing artwork fails here before Blender is ever started. + try: + job = build_render_job(paths, settings, output_dir) + except RenderArtifactError as exc: + print( + f"ERROR: {exc}\n" + "Generate the marker artwork first:\n" + " orisys-design texture ", + file=sys.stderr, + ) + return 3 + job_path = write_render_job(job, output_dir) + print(f"Wrote render job: {job_path}") + + code = _run_blender_script( + "probe_render.py", paths.source_blend, ["--job", str(job_path)] + ) + if code != 0: + return code + + manifest_path = render_manifest_path(output_dir) + if not _require_artifact("render manifest", manifest_path): + return 3 + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + if manifest.get("reference_deterministic") is not True: + print( + "ERROR: reference_deterministic is not true; re-rendering the same " + "frame produced different bytes, so the measurement is not " + "reproducible and every later number is untrustworthy.", + file=sys.stderr, + ) + return 1 + + frames = manifest.get("frames") or [] + print( + f"OK: rendered {len(frames)} frame(s) at " + f"{manifest.get('width')}x{manifest.get('height')} plus one null frame" + ) + scale = manifest.get("probe_px_per_mm") or {} + if scale.get("p50") is not None: + per_mm = float(scale["p50"]) + print( + f" probe scale p50 {per_mm:.3f} px/mm -> " + f"{settings.step_mm:g} mm is {settings.step_mm * per_mm:.4f} px" + ) + print(f" {manifest_path.name}: {MANIFEST_FILENAME}") + return 0 + + +def _apply_scale_overrides(settings, args: argparse.Namespace): + """Return `settings` with the estimator-scale CLI overrides applied. + + `--no-process-scale` is deliberately *not* folded into the settings: the second + scale is a property of the tracking job (`build_track_job(include_process_scale=)`), + not of the resolved settings, so it is passed to the stage separately. + """ + import dataclasses + + process_resolution = getattr(args, "process_resolution", None) + if process_resolution is None: + return settings + return dataclasses.replace( + settings, + process_resolution=[int(process_resolution[0]), int(process_resolution[1])], + ) + + +def _include_process_scale(args: argparse.Namespace) -> bool: + """Whether the tracking job should evaluate the second (process) scale.""" + return not bool(getattr(args, "no_process_scale", False)) + + +def _apply_noise_overrides(settings, args: argparse.Namespace): + """Return `settings` with the injected-noise CLI overrides applied. + + One draw gives a floor with no spread, which makes the noise-aware objective noisy + across candidates. `--noise-draws` is how a ranking gets confirmed properly. + """ + import dataclasses + + draws = getattr(args, "noise_draws", None) + sigmas = getattr(args, "noise_sigmas", None) + scales = getattr(args, "noise_scales", None) + if draws is None and sigmas is None and scales is None: + return settings + noise = dict(getattr(settings, "noise", {}) or {}) + if draws is not None: + noise["draws"] = int(draws) + if sigmas is not None: + noise["sigmas"] = [float(value) for value in sigmas] + if scales is not None: + noise["scales"] = [str(value) for value in scales] + return dataclasses.replace(settings, noise=noise) + + +def _run_track_stage( + settings, + output_dir: Path, + include_process_scale: bool = True, + rebuild_job: bool = False, +) -> int: + """Run the DIS fixed-reference estimator and write the flow report. + + An existing ``_track_job.json`` in ``output_dir`` is **reused** rather than + rebuilt, because rebuilding resolves the tracker settings from ``design.yaml`` and + would silently change the estimator: re-running ``track --output-dir`` on a + ``dis_mode=0`` variant used to overwrite it with a ``dis_mode=1`` report, because + the variant's setting lives in its job file and not in ``design.yaml``. Pass + ``--rebuild-job`` to force a rebuild. + """ + from orisys_sensor_design.flow import ( + TRACK_JOB_FILENAME, + build_track_job, + run_track_job, + write_track_job, + ) + + output_dir.mkdir(parents=True, exist_ok=True) + existing = output_dir / TRACK_JOB_FILENAME + job: dict | None = None + if existing.is_file() and not rebuild_job: + try: + loaded = json.loads(existing.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + loaded = None + if isinstance(loaded, dict): + job = loaded + tracker = (job.get("settings") or {}).get("tracker") or {} + print( + f"Reusing existing track job {existing} " + f"(dis_mode={tracker.get('dis_mode')}, " + f"dis_preset={tracker.get('dis_preset')}); " + "pass --rebuild-job to regenerate it from design.yaml" + ) + + if job is None: + job = build_track_job( + settings, output_dir, include_process_scale=include_process_scale + ) + print(f"Wrote track job: {write_track_job(job, output_dir)}") + + report, code = run_track_job(job, output_dir) + if code != 0: + return code + + known = report.get("known_answer") or {} + self_flow = report.get("reference_self") or {} + print( + f"OK: tracked {len(report.get('scales') or [])} scale(s); " + f"known-answer {'passed' if known.get('passed') else 'FAILED'}; " + f"reference-vs-reference max |flow| = " + f"{self_flow.get('max_abs_flow_px')} px" + ) + return 0 + + +def cmd_deform(revision_dir: Path, args: argparse.Namespace) -> int: + """Deform the sensing surface through the declared x/y/z sweep.""" + root = repo_root(revision_dir) + paths = RevisionPaths.from_revision_dir(revision_dir) + errors = validate_revision_metadata(paths) + if errors: + for message in errors: + print(f"ERROR: {message}", file=sys.stderr) + return 1 + + def run() -> int: + _root, paths, settings, output_dir = _probe_context(revision_dir, args) + return _run_deform_stage(paths, settings, output_dir) + + return _probe_stage(run) + + +def cmd_render(revision_dir: Path, args: argparse.Namespace) -> int: + """Render what the sensor camera sees for every deformed frame.""" + root = repo_root(revision_dir) + paths = RevisionPaths.from_revision_dir(revision_dir) + errors = validate_revision_metadata(paths) + if errors: + for message in errors: + print(f"ERROR: {message}", file=sys.stderr) + return 1 + + def run() -> int: + _root, paths, settings, output_dir = _probe_context(revision_dir, args) + settings = _apply_render_overrides(settings, args) + return _run_render_stage(paths, settings, output_dir) + + return _probe_stage(run) + + +def cmd_track(revision_dir: Path, args: argparse.Namespace) -> int: + """Estimate DIS optical flow against the zero-displacement reference.""" + root = repo_root(revision_dir) + paths = RevisionPaths.from_revision_dir(revision_dir) + errors = validate_revision_metadata(paths) + if errors: + for message in errors: + print(f"ERROR: {message}", file=sys.stderr) + return 1 + + try: + import cv2 # noqa: F401 + except ImportError: + print( + "ERROR: the flow estimator needs OpenCV.\n" + "Install it with: python -m pip install -e .[probe]", + file=sys.stderr, + ) + return 2 + + def run() -> int: + _root, paths, settings, output_dir = _probe_context(revision_dir, args) + settings = _apply_scale_overrides(settings, args) + settings = _apply_noise_overrides(settings, args) + return _run_track_stage( + settings, + output_dir, + _include_process_scale(args), + rebuild_job=bool(getattr(args, "rebuild_job", False)), + ) + + return _probe_stage(run) + + +def cmd_sensitivity(revision_dir: Path, args: argparse.Namespace) -> int: + """Run the probe bench end to end and write the sensitivity report.""" + root = repo_root(revision_dir) + paths = RevisionPaths.from_revision_dir(revision_dir) + errors = validate_revision_metadata(paths) + if errors: + for message in errors: + print(f"ERROR: {message}", file=sys.stderr) + return 1 + + try: + _root, paths, settings, output_dir = _probe_context(revision_dir, args) + settings = _apply_render_overrides(settings, args) + except Exception as exc: + code = _probe_error_code(exc) + if code is None: + raise + return code + + if not args.skip_deform: + code = _run_deform_stage(paths, settings, output_dir) + if code != 0: + return code + if not args.skip_render: + code = _run_render_stage(paths, settings, output_dir) + if code != 0: + return code + if not args.skip_track: + try: + import cv2 # noqa: F401 + except ImportError: + print( + "ERROR: the flow estimator needs OpenCV.\n" + "Install it with: python -m pip install -e .[probe]", + file=sys.stderr, + ) + return 2 + code = _run_track_stage(settings, output_dir) + if code != 0: + return code + + from orisys_sensor_design.sensitivity import run_sensitivity + + report, code = run_sensitivity( + settings, + output_dir, + calibration_enabled=not bool(getattr(args, "no_calibration", False)), + ) + overall = report.get("overall") or {} + if overall: + print( + f"Sensitivity: overall={overall.get('verdict')} " + f"score={_fmt(overall.get('score'))} " + f"(best scale {overall.get('best_scale')}, best axis " + f"{overall.get('best_axis')})" + ) + for message in report.get("warnings") or []: + print(f" WARNING: {message}") + for message in report.get("notes") or []: + print(f" note: {message}") + print(f" {output_dir / 'sensitivity_report.json'}") + return code + + +def cmd_optimise(revision_dir: Path, args: argparse.Namespace) -> int: + """Search patch-pattern parameters for the best measured sensitivity.""" + root = repo_root(revision_dir) + paths = RevisionPaths.from_revision_dir(revision_dir) + errors = validate_revision_metadata(paths) + if errors: + for message in errors: + print(f"ERROR: {message}", file=sys.stderr) + return 1 + + def run() -> int: + _root, paths, settings, output_dir = _probe_context(revision_dir, args) + from orisys_sensor_design.optimise import run_optimise + + runner = None if args.dry_run else _optimise_runner(paths, settings, args) + # Only forward options the user actually set: passing grid=None would + # override run_optimise's own defaults with None and fail validation. + kwargs: dict = {"dry_run": args.dry_run, "runner": runner, "output_dir": output_dir} + if args.grid is not None: + kwargs["grid"] = args.grid + if args.seeds is not None: + kwargs["seeds"] = args.seeds + if args.search_resolution is not None: + kwargs["search_resolution"] = args.search_resolution + if args.top is not None: + kwargs["top"] = args.top + report, code = run_optimise(paths, settings, **kwargs) + candidates = report.get("candidates") or [] + error = report.get("error") or {} + message = error.get("message") if isinstance(error, dict) else error + if message: + print(f"ERROR: {message}", file=sys.stderr) + print( + f"Optimiser: {len(candidates)} candidate(s), " + f"objective {report.get('search', {}).get('objective')}" + ) + winner = report.get("winner") + if winner: + print( + f" winner nx={winner.get('nx')} ny={winner.get('ny')} " + f"seed={winner.get('seed')} score={_fmt(winner.get('score'))} " + f"(confirmed at {winner.get('confirmed_at_resolution')}: " + f"{_fmt(winner.get('confirmed_score'))})" + ) + for message in report.get("notes") or []: + print(f" note: {message}") + print(f" {output_dir / 'optimizer_report.json'}") + return code + + return _probe_stage(run) + + +def _optimise_runner(paths, settings, args): + """Build the per-candidate runner that `run_optimise` calls once per grid. + + The runner owns the whole candidate pipeline: regenerate the marker texture for + this grid and seed, then deform -> render -> track -> score, all inside the + candidate directory that `run_optimise` created. It raises on failure and + `run_optimise` records the error and ranks that candidate last, so one bad grid + cannot abort the whole search. + """ + import dataclasses + + from orisys_sensor_design.texture import ( + build_texture_job, + resolve_texture_settings, + write_texture_job, + ) + + texture_size = getattr(args, "texture_size", None) + + def runner(run): + candidate = run.candidate + directory = Path(run.directory) + directory.mkdir(parents=True, exist_ok=True) + + # 1) This candidate's marker artwork. The raster is reduced for the search + # because the pattern is defined by nx/ny over the UV page, not by the + # raster: a smaller raster samples the same pattern and rasterizes far + # faster, which is what makes a grid search affordable at all. + overrides: dict = { + "nx": int(candidate.nx), + "ny": int(candidate.ny), + "seed": candidate.seed, + } + if texture_size is not None: + overrides["width"] = int(texture_size[0]) + overrides["height"] = int(texture_size[1]) + texture_settings = resolve_texture_settings(paths, overrides) + + texture_dir = directory / "textures" + texture_dir.mkdir(parents=True, exist_ok=True) + texture_job_path = write_texture_job( + build_texture_job(paths, texture_settings, texture_dir), texture_dir + ) + code = _run_blender_script( + "random_patch_dd02c.py", paths.source_blend, ["--job", str(texture_job_path)] + ) + if code != 0: + raise RuntimeError( + f"texture generation failed for candidate {candidate.id} (exit {code})" + ) + + # The probe bench reads one UV map; use the one the probe settings name. + target = next( + (item for item in texture_settings.outputs if item.uv_map == settings.uv_map), + texture_settings.outputs[0], + ) + texture_png = target.artifact_paths(texture_dir)["png"] + if not texture_png.is_file() or texture_png.stat().st_size == 0: + raise RuntimeError( + f"marker texture missing for candidate {candidate.id}: {texture_png}" + ) + + # 2) Per-candidate probe settings: this artwork, this resolution. + width = max(1, int(run.resolution[0])) + height = max(1, int(run.resolution[1])) + pattern = { + "nx": int(candidate.nx), + "ny": int(candidate.ny), + "seed": candidate.seed, + "cell_mm": [ + texture_settings.vector_width_mm / int(candidate.nx), + texture_settings.vector_height_mm / int(candidate.ny), + ], + } + probe = dataclasses.replace( + settings, + texture=str(texture_png), + resolution=[width, height], + process_resolution=[max(1, width // 2), max(1, height // 2)], + ) + + # 3) The stages, into this candidate's own directory. + for label, stage in ( + ("deform", lambda: _run_deform_stage(paths, probe, directory)), + ("render", lambda: _run_render_stage(paths, probe, directory)), + ("track", lambda: _run_track_stage(probe, directory)), + ): + code = stage() + if code != 0: + raise RuntimeError( + f"{label} stage failed for candidate {candidate.id} (exit {code})" + ) + + from orisys_sensor_design.sensitivity import run_sensitivity + + report, code = run_sensitivity(probe, directory, pattern=pattern) + if code != 0: + raise RuntimeError( + f"sensitivity scoring failed for candidate {candidate.id} (exit {code})" + ) + overall = report.get("overall") or {} + + px_per_mm = None + for scale in report.get("scales") or []: + if scale.get("name") == "render" and scale.get("px_per_mm") is not None: + px_per_mm = float(scale["px_per_mm"]) + break + + return { + "score": overall.get("score"), + "verdict": overall.get("verdict"), + "report": str(directory / "sensitivity_report.json"), + "texture_sha256": _file_sha256(texture_png), + "px_per_mm": px_per_mm, + } + + return runner + + +def _file_sha256(path: Path) -> str | None: + """sha256 of a file, or None when it cannot be read.""" + import hashlib + + try: + digest = hashlib.sha256() + with open(path, "rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + except OSError: + return None + + +# Exit code per probe exception class name, matched along the exception's MRO so a +# subclass lands on its parent's code. Matching by name keeps cli.py importable when +# a probe module is absent, which is the state a half-built checkout is in. +_PROBE_ERROR_CODES = { + "ProbeConfigError": 2, + "RenderConfigError": 2, + "SensitivityConfigError": 2, + "OptimiseError": 2, + "CompareError": 2, + "RenderArtifactError": 3, + "MissingArtifactError": 3, + "SensitivityDataError": 1, +} + + +def _compare_stage_runner(paths, args): + """Adapt the probe stages to `compare.run_compare`'s `(kind, **kwargs)` protocol.""" + from orisys_sensor_design.compare import CompareError + from orisys_sensor_design.texture import ( + build_texture_job, + resolve_texture_settings, + write_texture_job, + ) + + texture_size = getattr(args, "texture_size", None) + + def runner(kind: str, *, settings=None, output_dir=None, **kwargs) -> int: + output_dir = Path(output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + if kind == "texture": + overrides: dict = { + "nx": int(kwargs["nx"]), + "ny": int(kwargs["ny"]), + "seed": kwargs.get("seed"), + } + if texture_size is not None: + overrides["width"] = int(texture_size[0]) + overrides["height"] = int(texture_size[1]) + texture_settings = resolve_texture_settings(paths, overrides) + job_path = write_texture_job( + build_texture_job(paths, texture_settings, output_dir), output_dir + ) + return _run_blender_script( + "random_patch_dd02c.py", paths.source_blend, ["--job", str(job_path)] + ) + if kind == "deform": + return _run_deform_stage(paths, settings, output_dir) + if kind == "render": + return _run_render_stage(paths, settings, output_dir) + if kind == "track": + # compare builds a job per matrix cell, so the estimator setting is an + # input here, not something to inherit from whatever is on disk. + return _run_track_stage( + settings, output_dir, _include_process_scale(args), rebuild_job=True + ) + if kind == "sensitivity": + from orisys_sensor_design.sensitivity import run_sensitivity + + _report, code = run_sensitivity(settings, output_dir) + return code + raise CompareError(f"unknown comparison stage {kind!r}") + + return runner + + +def cmd_compare(revision_dir: Path, args: argparse.Namespace) -> int: + """Run one pattern under several estimator settings and plot the comparison.""" + root = repo_root(revision_dir) + paths = RevisionPaths.from_revision_dir(revision_dir) + errors = validate_revision_metadata(paths) + if errors: + for message in errors: + print(f"ERROR: {message}", file=sys.stderr) + return 1 + + def run() -> int: + from orisys_sensor_design.compare import ( + DEFAULT_MAX_RUNS, + FILENAME, + build_figures, + run_compare, + ) + + _root, paths, settings, output_dir = _probe_context(revision_dir, args) + settings = _apply_scale_overrides(settings, args) + settings = _apply_render_overrides(settings, args) + if not args.dry_run: + try: + import cv2 # noqa: F401 + except ImportError: + print( + "ERROR: the settings comparison needs OpenCV for the estimator.\n" + "Install it with: python -m pip install -e .[probe]", + file=sys.stderr, + ) + return 2 + + # Only forward options the user actually set: passing None would override + # run_compare's own defaults with None and fail validation. + kwargs: dict = { + "stage_runner": _compare_stage_runner(paths, args), + "resolution": args.resolution, + "max_runs": (args.max_runs if args.max_runs is not None else DEFAULT_MAX_RUNS), + "dry_run": args.dry_run, + "output_dir": output_dir, + "progress": print, + } + if args.grid is not None: + kwargs["grid"] = args.grid + if args.seeds is not None: + kwargs["seeds"] = args.seeds + if args.dis_modes is not None: + kwargs["dis_modes"] = args.dis_modes + if args.radii is not None: + kwargs["radii"] = args.radii + report, code = run_compare(paths, settings, **kwargs) + + search = report.get("search") or {} + print( + f"Comparison: {search.get('runs')} run(s), grid {search.get('grid')}, " + f"dis_modes {search.get('dis_modes')}, radii {search.get('radius_mm')}" + ) + if args.dry_run: + for entry in report.get("runs") or []: + print(f" {entry}") + print(f" {output_dir / 'comparison' / FILENAME} (dry-run: not written)") + return 0 + + for label in ("best_per_dis_mode", "best_per_radius"): + best = report.get(label) or {} + if best: + print(f" {label}:") + for key, value in sorted(best.items()): + print( + f" {key}: {value.get('id')} score=" + f"{_fmt(value.get('score'))} verdict={value.get('verdict')}" + ) + + if args.no_plot: + print(" (--no-plot: no figures written)") + else: + try: + import matplotlib # noqa: F401 + except ImportError: + print( + "ERROR: plotting needs matplotlib.\n" + "Install it with: python -m pip install -e .[viz]\n" + "Or re-run with --no-plot.", + file=sys.stderr, + ) + return 2 + figures = build_figures( + report, + output_dir / "comparison", + axis=args.axis, + step_mm=args.show_step_mm, + scale=args.scale, + ) + for name, path in figures.items(): + print(f" figure [{name}]: {path}") + + for message in report.get("notes") or []: + print(f" note: {message}") + print(f" {output_dir / 'comparison' / FILENAME}") + return code + + return _probe_stage(run) + + +def _probe_error_code(exc: Exception) -> int | None: + """Map a probe-stage exception to an exit code, or None to re-raise.""" + for klass in type(exc).__mro__: + code = _PROBE_ERROR_CODES.get(klass.__name__) + if code is not None: + for message in str(exc).splitlines(): + print(f"ERROR: {message}", file=sys.stderr) + return code + if isinstance(exc, ImportError): + print( + f"ERROR: {exc}\n" + "The probe bench needs its optional dependencies. Install them with:\n" + " python -m pip install -e .[probe]", + file=sys.stderr, + ) + return 2 + return None + + +def _probe_stage(action, *args, **kwargs) -> int: + """Run one probe stage and convert config/dependency failures to exit codes.""" + try: + return action(*args, **kwargs) + except Exception as exc: + code = _probe_error_code(exc) + if code is None: + raise + return code + + def cmd_manifest(revision_dir: Path, out: Path | None, profile: str) -> int: root = repo_root(revision_dir) paths = RevisionPaths.from_revision_dir(revision_dir) @@ -791,6 +1596,70 @@ def cmd_manifest(revision_dir: Path, out: Path | None, profile: str) -> int: return 0 +def _positive_int_pair(raw: str) -> tuple[int, int]: + """Parse a ``WxH`` argument (for example ``500x500``) into two ints.""" + text = str(raw).strip().lower().replace("*", "x").replace(",", "x") + parts = [part for part in text.split("x") if part] + if len(parts) != 2: + raise argparse.ArgumentTypeError(f"expected WxH (for example 500x500), got {raw!r}") + try: + width, height = int(parts[0]), int(parts[1]) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"expected WxH integers, got {raw!r}") from exc + if width < 1 or height < 1: + raise argparse.ArgumentTypeError(f"W and H must be positive, got {raw!r}") + return width, height + + +def _int_list(raw: str) -> list[int]: + """Parse a comma-separated integer list (candidate grids and seeds).""" + parts = [part.strip() for part in str(raw).replace(" ", "").split(",")] + values = [part for part in parts if part] + if not values: + raise argparse.ArgumentTypeError(f"expected a non-empty integer list, got {raw!r}") + try: + return [int(part) for part in values] + except ValueError as exc: + raise argparse.ArgumentTypeError(f"expected integers, got {raw!r}") from exc + + +def _float_list(raw: str) -> list[float]: + """Parse a comma-separated float list (for example falloff radii).""" + parts = [part.strip() for part in str(raw).replace(" ", "").split(",")] + values = [part for part in parts if part] + if not values: + raise argparse.ArgumentTypeError(f"expected a non-empty number list, got {raw!r}") + try: + return [float(part) for part in values] + except ValueError as exc: + raise argparse.ArgumentTypeError(f"expected numbers, got {raw!r}") from exc + + +def _str_list(raw: str) -> list[str]: + """Parse a comma-separated string list (for example estimator scale names).""" + parts = [part.strip() for part in str(raw).split(",")] + values = [part for part in parts if part] + if not values: + raise argparse.ArgumentTypeError(f"expected a non-empty list, got {raw!r}") + return values + + +def _axes_arg(raw: str) -> list[str]: + """Parse a comma-separated axis list; only x, y and z are meaningful.""" + parts = [part.strip().lower() for part in str(raw).split(",")] + values = [part for part in parts if part] + if not values: + raise argparse.ArgumentTypeError(f"expected a non-empty axis list, got {raw!r}") + unknown = sorted(set(values) - {"x", "y", "z"}) + if unknown: + raise argparse.ArgumentTypeError( + f"unknown axis/axes {', '.join(unknown)} (allowed: x, y, z)" + ) + if len(set(values)) != len(values): + raise argparse.ArgumentTypeError(f"duplicate axis in {raw!r}") + return values + + def main(argv: list[str] | None = None) -> None: parser = argparse.ArgumentParser(prog="orisys-design") parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}") @@ -1016,6 +1885,245 @@ def main(argv: list[str] | None = None) -> None: help="Displacements above this are flagged as implausible (default: 5)", ) + def _add_probe_deform_options(subparser: argparse.ArgumentParser) -> None: + subparser.add_argument( + "--output-dir", type=Path, default=None, + help="Probe artifact directory (default: build/probe//)", + ) + subparser.add_argument("--mesh", default=None, help="Sensing surface object name") + subparser.add_argument( + "--group", default=None, help="Vertex group to deform (default: probe)", + ) + subparser.add_argument( + "--radius-mm", dest="radius_mm", type=float, default=None, + help="Proportional falloff radius in mm (default: probe.radius_mm)", + ) + subparser.add_argument( + "--falloff", default=None, + choices=["smooth", "linear", "sharp", "constant"], + help="Proportional falloff curve (default: probe.falloff)", + ) + subparser.add_argument( + "--step-mm", dest="step_mm", type=float, default=None, + help="Displacement step in mm (default: probe.step_mm)", + ) + subparser.add_argument( + "--max-mm", dest="max_mm", type=float, default=None, + help="Maximum displacement in mm (default: probe.max_mm)", + ) + subparser.add_argument( + "--axes", type=_axes_arg, default=None, + help="Comma-separated sweep axes (default: probe.axes)", + ) + + def _add_probe_render_options(subparser: argparse.ArgumentParser) -> None: + subparser.add_argument( + "--resolution", type=_positive_int_pair, default=None, + metavar="WxH", help="Frame resolution (default: probe.resolution)", + ) + subparser.add_argument( + "--samples", type=int, default=None, + help="Cycles samples per frame (default: probe.render.samples)", + ) + subparser.add_argument( + "--seed", type=int, default=None, + help="Cycles seed (default: probe.render.seed)", + ) + subparser.add_argument( + "--uv-map", dest="uv_map", default=None, + help="Mesh UV map the marker texture is bound to (default: probe.uv_map)", + ) + subparser.add_argument( + "--texture", default=None, + help="Marker PNG, absolute or relative to the texture output dir", + ) + + def _add_probe_scale_options(subparser: argparse.ArgumentParser) -> None: + subparser.add_argument( + "--process-resolution", dest="process_resolution", + type=_positive_int_pair, default=None, metavar="WxH", + help="Second estimator scale (default: probe.process_resolution)", + ) + subparser.add_argument( + "--no-process-scale", dest="no_process_scale", + action="store_true", default=None, + help="Evaluate only the render resolution", + ) + + deform_p = sub.add_parser( + "deform", + help="Deform the sensing surface through a known x/y/z displacement sweep", + ) + deform_p.add_argument("revision_dir", type=Path) + _add_probe_deform_options(deform_p) + + render_p = sub.add_parser( + "render", help="Render what the sensor camera sees for every deformed frame", + ) + render_p.add_argument("revision_dir", type=Path) + render_p.add_argument( + "--output-dir", type=Path, default=None, + help="Probe artifact directory (default: build/probe//)", + ) + _add_probe_render_options(render_p) + + track_p = sub.add_parser( + "track", help="Measure DIS optical flow against the zero-displacement frame", + ) + track_p.add_argument("revision_dir", type=Path) + track_p.add_argument( + "--output-dir", type=Path, default=None, + help="Probe artifact directory (default: build/probe//)", + ) + _add_probe_scale_options(track_p) + track_p.add_argument( + "--noise-draws", dest="noise_draws", type=int, default=None, + help="Independent noise realisations per sigma (default: probe.noise.draws). " + "Raise this to confirm a ranking: one draw leaves the floor with no spread", + ) + track_p.add_argument( + "--noise-sigmas", dest="noise_sigmas", type=_float_list, default=None, + help="Comma-separated injected-noise sigmas in grey levels " + "(default: probe.noise.sigmas)", + ) + track_p.add_argument( + "--noise-scales", dest="noise_scales", type=_str_list, default=None, + help="Comma-separated estimator scales to evaluate under noise " + "(default: probe.noise.scales)", + ) + track_p.add_argument( + "--rebuild-job", dest="rebuild_job", action="store_true", + help="Regenerate _track_job.json from design.yaml instead of reusing the one " + "already in the output directory (reuse is the default so a re-run cannot " + "silently change the estimator)", + ) + + sensitivity_p = sub.add_parser( + "sensitivity", + help="Run the probe bench end to end and report pattern sensitivity", + ) + sensitivity_p.add_argument("revision_dir", type=Path) + _add_probe_deform_options(sensitivity_p) + _add_probe_render_options(sensitivity_p) + _add_probe_scale_options(sensitivity_p) + sensitivity_p.add_argument( + "--skip-deform", dest="skip_deform", action="store_true", + help="Reuse an existing probe_frames.npz", + ) + sensitivity_p.add_argument( + "--skip-render", dest="skip_render", action="store_true", + help="Reuse already rendered frames", + ) + sensitivity_p.add_argument( + "--skip-track", dest="skip_track", action="store_true", + help="Reuse an existing flow report", + ) + sensitivity_p.add_argument( + "--no-calibration", dest="no_calibration", action="store_true", + help="Skip measuring the real idle floor from probe.noise.idle_footage " + "(cached in the output directory, so this is usually a one-off cost)", + ) + + optimise_p = sub.add_parser( + "optimise", help="Search patch-pattern parameters for measured sensitivity", + ) + optimise_p.add_argument("revision_dir", type=Path) + optimise_p.add_argument( + "--output-dir", type=Path, default=None, + help="Probe artifact directory (default: build/probe//)", + ) + optimise_p.add_argument( + "--grid", type=_int_list, default=None, + help="Comma-separated patch grid sizes to try (default: a range around " + "the DIS patch size)", + ) + optimise_p.add_argument( + "--seeds", type=_int_list, default=None, + help="Comma-separated colour seeds to try (default: 7)", + ) + optimise_p.add_argument( + "--search-resolution", dest="search_resolution", + type=_positive_int_pair, default=None, metavar="WxH", + help="Render resolution used during the search (default: 250x250)", + ) + optimise_p.add_argument( + "--top", type=int, default=None, + help="Re-confirm this many top candidates at the full resolution (default: 1)", + ) + optimise_p.add_argument( + "--texture-size", dest="texture_size", + type=_positive_int_pair, default=None, metavar="WxH", + help="Marker raster size during the search (default: texture_patch's own " + "size; the pattern itself is set by nx/ny, not by the raster)", + ) + optimise_p.add_argument( + "--dry-run", dest="dry_run", action="store_true", + help="List the candidates and exit without rendering anything", + ) + + compare_p = sub.add_parser( + "compare", + help="Run one pattern under several estimator settings and plot the flow", + ) + compare_p.add_argument("revision_dir", type=Path) + compare_p.add_argument( + "--output-dir", type=Path, default=None, + help="Probe artifact directory (default: build/probe//)", + ) + compare_p.add_argument( + "--grid", type=_int_list, default=None, + help="Comma-separated patch grid sizes to compare (default: 31,56,80)", + ) + compare_p.add_argument( + "--seeds", type=_int_list, default=None, + help="Comma-separated colour seeds (default: 7)", + ) + compare_p.add_argument( + "--dis-modes", dest="dis_modes", type=_int_list, default=None, + help="Comma-separated OpenCV finest pyramid levels (default: 1,0). Only " + "track+sensitivity re-run per value; the frames are reused.", + ) + compare_p.add_argument( + "--radii", type=_float_list, default=None, + help="Comma-separated proportional falloff radii (default: 0.5,1.5). Named " + "--radii, not --radius-mm, so it cannot be mistaken for the scalar " + "probe override of the same meaning.", + ) + compare_p.add_argument( + "--at", dest="resolution", type=_positive_int_pair, default=None, metavar="WxH", + help="Render resolution for the sweep (default: probe.resolution)", + ) + compare_p.add_argument( + "--max-runs", dest="max_runs", type=int, default=None, + help="Refuse a matrix larger than this (default: 24)", + ) + compare_p.add_argument( + "--axis", default="x", choices=["x", "y", "z"], + help="Sweep axis shown in the figures (default: x)", + ) + compare_p.add_argument( + "--show-step-mm", dest="show_step_mm", type=float, default=0.10, + help="Displacement shown in the flow figure, in mm (default: 0.10). This is a " + "figure option only; the deformation step comes from probe.step_mm, and " + "a colliding --step-mm would silently override it.", + ) + compare_p.add_argument( + "--scale", default="render", help="Estimator scale shown (default: render)", + ) + compare_p.add_argument( + "--texture-size", dest="texture_size", type=_positive_int_pair, + default=None, metavar="WxH", + help="Marker raster size during the sweep (default: texture_patch's own size)", + ) + compare_p.add_argument( + "--no-plot", dest="no_plot", action="store_true", + help="Run the sweep and write the JSON report without any figure", + ) + compare_p.add_argument( + "--dry-run", dest="dry_run", action="store_true", + help="Enumerate the matrix and exit without running anything", + ) + args = parser.parse_args(argv) if args.command == "validate": code = cmd_validate(args.revision_dir, args.blender) @@ -1029,6 +2137,18 @@ def main(argv: list[str] | None = None) -> None: code = cmd_preview(args.revision_dir, args) elif args.command == "optics": code = cmd_optics(args.revision_dir, args) + elif args.command == "deform": + code = cmd_deform(args.revision_dir, args) + elif args.command == "render": + code = cmd_render(args.revision_dir, args) + elif args.command == "track": + code = cmd_track(args.revision_dir, args) + elif args.command == "sensitivity": + code = cmd_sensitivity(args.revision_dir, args) + elif args.command == "optimise": + code = cmd_optimise(args.revision_dir, args) + elif args.command == "compare": + code = cmd_compare(args.revision_dir, args) elif args.command == "manifest": code = cmd_manifest(args.revision_dir, args.out, args.profile) else: diff --git a/src/orisys_sensor_design/compare.py b/src/orisys_sensor_design/compare.py new file mode 100644 index 0000000..c8dacdf --- /dev/null +++ b/src/orisys_sensor_design/compare.py @@ -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/.png``), not to + its ``frames/`` subdirectory, so the manifest's own directory is the base. Passing + the subdirectory produced ``frames/frames/.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", +] diff --git a/src/orisys_sensor_design/deform.py b/src/orisys_sensor_design/deform.py new file mode 100644 index 0000000..b4cbe2b --- /dev/null +++ b/src/orisys_sensor_design/deform.py @@ -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///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 ``/`` 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//.""" + 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 diff --git a/src/orisys_sensor_design/floorcal.py b/src/orisys_sensor_design/floorcal.py new file mode 100644 index 0000000..8999add --- /dev/null +++ b/src/orisys_sensor_design/floorcal.py @@ -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", +] diff --git a/src/orisys_sensor_design/flow.py b/src/orisys_sensor_design/flow.py new file mode 100644 index 0000000..dae171f --- /dev/null +++ b/src/orisys_sensor_design/flow.py @@ -0,0 +1,2055 @@ +"""Host-side DIS optical-flow estimator for the probe sensitivity bench. + +This module is the ``flow.py`` stage of ``docs/probe-pipeline.md``: it measures +sub-pixel image displacement between the zero-displacement reference render and +each deformed render, and it gates its own trustworthiness before any number is +reported. + +Estimator (mirrored, not invented) +---------------------------------- +The runtime tracker ``orisys-sdk/src/orisys/tracker.py`` builds +``cv2.DISOpticalFlow_create(preset)`` and calls +``setFinestScale(config_tracker["dis_mode"])``. The fixed-reference variant in +``orisys-sdk/tests/DIS_no_integrate.py`` (``FixedRefDIS``) calls +``calc(reference, frame, None)``: no queue, no warm start, no integration. That +is exactly what this bench needs -- a displacement measured against the *fixed* +zero-displacement reference -- so :class:`FixedReferenceFlow` mirrors +``FixedRefDIS`` line for line, and :func:`dis_preset_from_name` / +:func:`load_tracker_config` mirror its helpers (including the same +``setdefault`` values). + +Why the quality gate exists +--------------------------- +At 500x500 the probe-local scale measured by the render stage is 13.248 px/mm on +x (17.67 on y, 1.52 on z), so 0.01 mm is about 0.13 px -- a fraction of a pixel +and far smaller than the 8 px DIS patch. A number that small is only +trustworthy if the estimator itself is proven, so two hard checks run before any +frame is reported: + +* ``known_answer`` -- a synthetic image shifted by a known *integer* number of + pixels must be recovered within :data:`DEFAULT_KNOWN_ANSWER_TOL_PX`. If the + estimator cannot do that, every downstream number is worthless and + :func:`run_track_job` fails loudly (exit 1). +* ``reference_self`` -- the reference compared against itself must produce + *exactly* zero flow. Anything else means the plumbing is wrong (for example + the reference was re-read or re-resampled instead of reused). + +Neither condition is allowed to produce exit code 0. + +What the real render measured +----------------------------- +On ``sensors/finger-xynova/r01`` (nx=80 random-cell marker, DIS medium +``dis_mode=1``): + +* ``reference_self`` = 0.0 px exactly and the broadband gate passes at both + scales (2 px -> 2.0000 px at 500x500, 1 px -> 1.0003 px at 250x250). +* Zero-input floor (reference vs ``reference_null``, p95 |flow| over the ROI): + **0.0292 px** at 500x500 and 0.0171 px at 250x250 -- about 4x *below* one + 0.01 mm step (0.1325 px), so a single 0.01 mm step is not floor-limited in the + ROI aggregate. +* The marker field is a random cell field, **not** periodic: a global 2 px shift + is recovered exactly, so it does not alias. A strongly periodic marker + (checker or line grid) does alias a sub-period shift onto the wrong period + (measured: 2 px applied -> 8.17 px on a 3.11 px checker cell, -19.8 px on 3.11 + px line grid, 0.0 px on a 1 px checker). +* The response deficit at the render scale is a *spatial resolution* limit, not + noise and not aliasing. The probe patch plus its falloff taper moves over + roughly 13 px, while the DIS patch is 8 px, so DIS reports a coherent but + shrunk translation: for 0.10 mm on x it reports 0.886 px against 1.325 px of + projected probe-centroid displacement (~67%), uniformly across the ROI + (radial bands 0.916/0.910/0.873 px from inside out) with |flow| spanning only + 0.81-0.93 px. A synthetic *local* rigid translation of radius equal to the + ROI (6.6 px) recovers only 17% of the same shift, r=13 px recovers 63%, and + r=40 px recovers 95% -- DIS needs a moving region many patches wide to report + the full displacement. +* ``forward_backward_error`` (intensity levels) is reported on every frame and + is the coherence indicator: it grows with step size wherever the flow field + stops explaining the image change. + +Outputs +------- +:func:`run_track_job` consumes a ``_track_job.json`` plus the renderer's +``render_manifest.json`` and writes ``flow//.npy`` (float32 +``(H, W, 2)``) and ``flow_report.json`` per the frozen contract. It is host +side only: numpy + OpenCV, no bpy, no Blender. +""" + +from __future__ import annotations + +import json +import math +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, Mapping, Sequence + +import numpy as np + +try: # pragma: no cover - exercised through the monkeypatched-cv2 test + import cv2 +except ImportError as exc: # pragma: no cover + cv2 = None # type: ignore[assignment] + _CV2_IMPORT_ERROR: Exception | None = exc +else: + _CV2_IMPORT_ERROR = None + +from orisys_sensor_design.manifest import load_yaml + +#: Schema strings and file names fixed by docs/probe-pipeline.md. +TRACK_JOB_SCHEMA = "orisys-sensor-design/probe-track-job-v1" +TRACK_JOB_FILENAME = "_track_job.json" +FLOW_REPORT_SCHEMA = "orisys-sensor-design/flow-report-v2" +FLOW_REPORT_FILENAME = "flow_report.json" +FLOW_DIRNAME = "flow" +RENDER_MANIFEST_SCHEMA = "orisys-sensor-design/probe-render-manifest-v1" +RENDER_MANIFEST_FILENAME = "render_manifest.json" +STRATEGY_FILENAME = "strategy_probe.json" + +#: Exit codes (docs/probe-pipeline.md "Exit codes"). +EXIT_OK = 0 +EXIT_SCENE_ERROR = 1 +EXIT_CONFIG_ERROR = 2 +EXIT_MISSING_ARTIFACT = 3 + +#: Tracker defaults, identical to DIS_no_integrate.load_tracker_config. +TRACKER_DEFAULTS: dict[str, Any] = { + "algorithm": "dis", + "dis_preset": "medium", + "dis_mode": 3, + "lower_threshold": 12.0, + "upper_threshold": 18.0, + "loop_closure_interval": 1, +} + +#: Known-answer gate: a 2 px shift at the native render scale (and the same +#: physical shift at any coarser scale) must come back within this many pixels. +DEFAULT_KNOWN_ANSWER_TOL_PX = 0.25 +KNOWN_ANSWER_SHIFTS_PX: tuple[tuple[int, int], ...] = ((2, 0), (0, 2)) + +#: Control region is the annulus (radius, control_scale * radius]. +DEFAULT_CONTROL_SCALE = 2.0 + +#: OpenCV DIS defaults, only used to report the estimator configuration. +DEFAULT_DIS_PATCH_PX = 8 + +_CV2_HINT = ( + "OpenCV (cv2) is required for the probe optical-flow estimator; " + "install it with: python -m pip install -e .[probe]" +) + + +class FlowError(ValueError): + """Raised for estimator configuration and measurement problems. + + ``report`` optionally carries the partial ``known_answer`` block measured + just before a gate failure, so a caller can audit what was seen. + """ + + def __init__(self, message: str, *, report: Any = None) -> None: + super().__init__(message) + self.report = report + + +def _require_cv2() -> None: + """Raise a clear ImportError when OpenCV is unavailable.""" + if cv2 is None: + raise ImportError(f"{_CV2_HINT} (original error: {_CV2_IMPORT_ERROR})") + + +class TrackerConfig(dict): + """Tracker settings as a dict with attribute access. + + The SDK tracker consumes ``config_tracker["dis_preset"]`` style mappings, so + a dict subclass keeps this module drop-in compatible while still giving the + host code a named type. + """ + + def __getattr__(self, name: str) -> Any: + try: + return self[name] + except KeyError as exc: # pragma: no cover - defensive + raise AttributeError(name) from exc + + def to_dict(self) -> dict[str, Any]: + return {key: value for key, value in self.items()} + + +def dis_preset_from_name(name: str | None) -> int: + """Map ``ultrafast``/``fast``/``medium`` to the OpenCV preset constant. + + Faithful port of ``DIS_no_integrate.dis_preset_from_name``: anything that is + not ``ultrafast`` or ``fast`` (including ``None`` and unknown names) resolves + to ``MEDIUM``. + """ + _require_cv2() + preset = (name or "medium").lower() + if preset == "ultrafast": + return cv2.DISOPTICAL_FLOW_PRESET_ULTRAFAST + if preset == "fast": + return cv2.DISOPTICAL_FLOW_PRESET_FAST + return cv2.DISOPTICAL_FLOW_PRESET_MEDIUM + + +def _coerce_tracker_block(data: Mapping[str, Any]) -> Mapping[str, Any]: + """Pick the tracker mapping out of a design dict, job dict, or tracker dict.""" + tracker = data.get("tracker") + if isinstance(tracker, Mapping): + return tracker + probe = data.get("probe") + if isinstance(probe, Mapping): + nested = probe.get("tracker") + if isinstance(nested, Mapping): + return nested + return data + + +def load_tracker_config(source: Mapping[str, Any] | str | Path) -> TrackerConfig: + """Load tracker settings from a mapping, JSON file, or YAML file. + + Defaults are applied with :func:`dict.setdefault` exactly like + ``DIS_no_integrate.load_tracker_config``, so an explicit ``dis_mode: 1`` in + ``design.yaml probe.tracker`` wins over the built-in ``3``. + + Accepts a mapping (used as-is), a path to a JSON/YAML file, or a full + ``design.yaml`` mapping/file (``tracker`` or ``probe.tracker`` is picked + out). The SDK's config-stem lookup (``orisys.configs.load_config``) is not + available to this repo; give an explicit path or mapping instead. + """ + if isinstance(source, (str, Path)): + path = Path(source) + if not path.is_file(): + raise FlowError(f"tracker config not found: {path}") + try: + if path.suffix.lower() == ".json": + data = json.loads(path.read_text(encoding="utf-8")) + else: + data = load_yaml(path) + except FlowError: + raise + except Exception as exc: + raise FlowError(f"cannot read tracker config {path}: {exc}") from exc + elif isinstance(source, Mapping): + data = source + else: + raise FlowError( + "tracker config must be a mapping or a JSON/YAML path, got " + f"{type(source).__name__}" + ) + if not isinstance(data, Mapping): + raise FlowError(f"tracker config must be a mapping, got {type(data).__name__}") + + block = _coerce_tracker_block(data) + config = TrackerConfig(block) + for key, value in TRACKER_DEFAULTS.items(): + config.setdefault(key, value) + return _validate_tracker_config(config) + + +def _validate_tracker_config(config: TrackerConfig) -> TrackerConfig: + """Return ``config`` after checking the values the estimator actually uses.""" + algorithm = config.get("algorithm") + if not isinstance(algorithm, str) or algorithm.lower() != "dis": + raise FlowError( + f"probe.tracker.algorithm must be 'dis' (got {algorithm!r}); the probe " + "estimator only implements the DIS fixed-reference pipeline" + ) + config["algorithm"] = "dis" + + preset = config.get("dis_preset") + if preset is not None and not isinstance(preset, str): + raise FlowError(f"probe.tracker.dis_preset must be a string (got {preset!r})") + + mode = config.get("dis_mode") + if isinstance(mode, bool) or not isinstance(mode, (int, float)): + raise FlowError(f"probe.tracker.dis_mode must be an integer (got {mode!r})") + mode_int = int(mode) + if mode_int < 0: + raise FlowError(f"probe.tracker.dis_mode must be >= 0 (got {mode_int})") + config["dis_mode"] = mode_int + + for key in ("lower_threshold", "upper_threshold"): + value = config.get(key) + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise FlowError(f"probe.tracker.{key} must be a number (got {value!r})") + config[key] = float(value) + + interval = config.get("loop_closure_interval") + if isinstance(interval, bool) or not isinstance(interval, int) or interval < 1: + raise FlowError( + f"probe.tracker.loop_closure_interval must be an integer >= 1 " + f"(got {interval!r})" + ) + return config + + +def resample(gray: np.ndarray, size: Sequence[int]) -> np.ndarray: + """Resize a 2-D grayscale image to ``(width, height)`` with ``INTER_AREA``. + + ``INTER_AREA`` is the correct downsampling filter: it averages the source + pixels that fall inside each target pixel instead of point-sampling one of + them (``INTER_NEAREST`` would destroy the marker texture at 250x250). + """ + _require_cv2() + width, height = int(size[0]), int(size[1]) + if width <= 0 or height <= 0: + raise FlowError(f"resample size must be positive, got {size!r}") + image = np.asarray(gray) + if image.ndim != 2: + raise FlowError(f"resample expects a 2-D grayscale image, got {image.shape}") + if image.shape == (height, width): + return image.copy() + return cv2.resize(image, (width, height), interpolation=cv2.INTER_AREA) + + +def load_gray(path: str | Path) -> np.ndarray: + """Read a PNG/JPEG as a single-channel ``uint8`` image.""" + _require_cv2() + image = cv2.imread(str(path), cv2.IMREAD_GRAYSCALE) + if image is None: + raise FlowError(f"cannot read image: {path}") + if image.ndim != 2: + raise FlowError(f"expected a single-channel image from {path}, got {image.shape}") + return image + + +class FixedReferenceFlow: + """DIS flow against one fixed reference frame: no queue, no integration. + + Mirrors ``DIS_no_integrate.FixedRefDIS`` exactly: ``DISOpticalFlow_create`` + with the configured preset, ``setFinestScale(dis_mode)`` and + ``calc(reference, frame, None)``. ``prev`` is always ``None`` so no + incremental displacement is ever accumulated into the measurement. + """ + + def __init__(self, config: Mapping[str, Any] | None = None) -> None: + _require_cv2() + if config is None: + resolved = load_tracker_config({}) + elif isinstance(config, TrackerConfig): + resolved = config + else: + resolved = load_tracker_config(config) + self.config: TrackerConfig = resolved + preset = dis_preset_from_name(resolved.get("dis_preset")) + self._dis = cv2.DISOpticalFlow_create(preset) + self._dis.setFinestScale(int(resolved.get("dis_mode", 3))) + self._reference: np.ndarray | None = None + + # -- introspection used by the report --------------------------------- + @property + def patch_px(self) -> int: + """DIS patch size in pixels (8 by default; measured, not assumed).""" + getter = getattr(self._dis, "getPatchSize", None) + if callable(getter): + try: + return int(getter()) + except Exception: # pragma: no cover - defensive + pass + return DEFAULT_DIS_PATCH_PX + + @property + def finest_scale(self) -> int: + return int(self.config.get("dis_mode", 3)) + + @property + def preset_name(self) -> str: + return str(self.config.get("dis_preset") or "medium").lower() + + @property + def reference(self) -> np.ndarray | None: + return self._reference + + # -- estimator surface ------------------------------------------------- + def reset(self) -> None: + self._reference = None + + def set_reference(self, gray: np.ndarray) -> np.ndarray: + """Store ``gray`` as the fixed reference and return the copy used.""" + image = np.asarray(gray) + if image.ndim != 2: + raise FlowError(f"reference must be 2-D grayscale, got {image.shape}") + self._reference = image.copy() + return self._reference + + def compute(self, gray: np.ndarray) -> np.ndarray: + """Flow from the fixed reference to ``gray`` as ``(H, W, 2) float32``. + + If no reference has been set, ``gray`` becomes the reference and the + flow is exactly zero -- the same first-frame behaviour as + ``FixedRefDIS.track``. + """ + _require_cv2() + image = np.asarray(gray) + if image.ndim != 2: + raise FlowError(f"frame must be 2-D grayscale, got {image.shape}") + if self._reference is None: + self.set_reference(image) + return np.zeros((*image.shape[:2], 2), dtype=np.float32) + if image.shape != self._reference.shape: + raise FlowError( + "frame size does not match the fixed reference: " + f"{image.shape} vs {self._reference.shape}" + ) + flow = self._dis.calc(self._reference, image, None) + return np.asarray(flow, dtype=np.float32) + + +def region_masks( + shape: Sequence[int], + centre_uv: Sequence[float], + radius_px: float, + control_scale: float = DEFAULT_CONTROL_SCALE, +) -> tuple[np.ndarray, np.ndarray]: + """Return ``(roi_mask, control_mask)`` boolean masks for one ROI. + + ``centre_uv`` is ``(u, v)`` = ``(column, row)`` in the same pixel-index + convention as the arrays (pixel centres at integer coordinates). The ROI is + the closed disc ``d <= radius_px``; the control region is the annulus + ``radius_px < d <= control_scale * radius_px``, so the two are disjoint by + construction. Pixels outside the frame are simply absent, which lets a + partly off-frame ROI be truncated rather than silently wrapped. + """ + height, width = int(shape[0]), int(shape[1]) + if height <= 0 or width <= 0: + raise FlowError(f"region_masks needs a positive shape, got {tuple(shape)!r}") + u, v = float(centre_uv[0]), float(centre_uv[1]) + radius = float(radius_px) + if not math.isfinite(radius) or radius <= 0.0: + raise FlowError(f"ROI radius must be a positive finite number, got {radius_px!r}") + scale = float(control_scale) + if not math.isfinite(scale) or scale <= 1.0: + raise FlowError( + f"control_scale must be > 1 so the control ring excludes the ROI, got " + f"{control_scale!r}" + ) + if not (math.isfinite(u) and math.isfinite(v)): + raise FlowError(f"ROI centre must be finite, got {tuple(centre_uv)!r}") + + rows, cols = np.mgrid[0:height, 0:width] + dist2 = (cols - u) ** 2 + (rows - v) ** 2 + roi = dist2 <= radius * radius + control = (dist2 > radius * radius) & (dist2 <= (scale * radius) ** 2) + return roi, control + + +def expected_disc_pixels(radius_px: float) -> float: + """Continuous area of the ROI disc, used to detect truncation.""" + return math.pi * float(radius_px) ** 2 + + +def flow_stats(flow: np.ndarray, mask: np.ndarray | None = None) -> dict[str, float]: + """Summary statistics of ``flow`` over ``mask`` (all pixels when ``None``). + + ``coverage`` is the fraction of masked pixels with a finite, non-zero flow + magnitude. OpenCV DIS leaves exactly zero where it has no support, so this + is the honest fraction of the ROI that actually carries a measurement. + """ + flow = np.asarray(flow) + if flow.ndim != 3 or flow.shape[2] != 2: + raise FlowError(f"flow must be (H, W, 2), got {flow.shape}") + if mask is None: + selected = np.ones(flow.shape[:2], dtype=bool) + else: + selected = np.asarray(mask, dtype=bool) + if selected.shape != flow.shape[:2]: + raise FlowError( + f"mask shape {selected.shape} does not match flow {flow.shape[:2]}" + ) + count = int(selected.sum()) + if count == 0: + raise FlowError( + "flow_stats mask selects no pixels; the ROI is outside the frame " + "(refusing to report a number)" + ) + components = flow[selected].astype(np.float64) + magnitude = np.linalg.norm(components, axis=1) + if not np.isfinite(components).all(): + raise FlowError("flow contains non-finite values inside the ROI") + return { + "mean_u": float(components[:, 0].mean()), + "mean_v": float(components[:, 1].mean()), + "mean_mag": float(magnitude.mean()), + "p50_mag": float(np.percentile(magnitude, 50)), + "p95_mag": float(np.percentile(magnitude, 95)), + "max_mag": float(magnitude.max()), + "coverage": float(np.count_nonzero(magnitude > 0.0) / count), + } + + +def _mesh_grid(height: int, width: int) -> np.ndarray: + """Pixel-centre meshgrid in the SDK's layout: ``(H, W, 2)`` as ``(x, y)``.""" + x = np.linspace(0, width - 1, width, dtype=np.float32) + y = np.linspace(0, height - 1, height, dtype=np.float32) + return np.array(np.meshgrid(x, y)).transpose((1, 2, 0)) + + +def forward_backward_error( + ref: np.ndarray, + cur: np.ndarray, + flow: np.ndarray, + mask: np.ndarray | None = None, +) -> float: + """Mean absolute intensity residual after warping ``cur`` back by ``flow``. + + Faithful port of ``Tracker.__getForwardBackwardError`` + (``orisys-sdk/src/orisys/tracker.py``): build the pixel meshgrid, add the + flow, ``cv2.remap`` the current frame with ``INTER_CUBIC`` and + ``BORDER_REPLICATE``, subtract the reference, and return the mean absolute + difference over the mask (over all pixels when the mask is ``None`` or + empty, exactly like the SDK). Units are 8-bit intensity levels. + """ + _require_cv2() + ref = np.asarray(ref) + cur = np.asarray(cur) + flow = np.asarray(flow, dtype=np.float32) + if ref.shape != cur.shape: + raise FlowError(f"frame shapes differ: ref {ref.shape} vs cur {cur.shape}") + if flow.ndim != 3 or flow.shape[2] != 2 or flow.shape[:2] != ref.shape[:2]: + raise FlowError( + f"flow shape {flow.shape} does not match frames {ref.shape}" + ) + grid = _mesh_grid(ref.shape[0], ref.shape[1]) + flow_map = grid + flow + warped = cv2.remap( + cur, + flow_map[:, :, 0], + flow_map[:, :, 1], + interpolation=cv2.INTER_CUBIC, + borderMode=cv2.BORDER_REPLICATE, + ) + diff_abs = np.abs(warped.astype(np.float32) - ref.astype(np.float32)) + if mask is not None: + selected = np.asarray(mask, dtype=bool) + if selected.shape != diff_abs.shape: + raise FlowError( + f"mask shape {selected.shape} does not match frames {diff_abs.shape}" + ) + if np.any(selected): + return float(diff_abs[selected].mean()) + return float(np.mean(diff_abs)) + + +def _intrinsics_matrix(K_like: Any) -> tuple[np.ndarray, tuple[int, int] | None]: + """Return ``(K, (native_width, native_height) or None)``.""" + native: tuple[int, int] | None = None + if isinstance(K_like, Mapping) or hasattr(K_like, "files"): + try: + matrix = np.asarray(K_like["K"], dtype=np.float64) + except Exception as exc: + raise FlowError(f"intrinsics have no usable 'K': {exc}") from exc + try: + native = (int(np.asarray(K_like["width"]).ravel()[0]), + int(np.asarray(K_like["height"]).ravel()[0])) + except Exception: + native = None + else: + matrix = np.asarray(K_like, dtype=np.float64) + matrix = matrix.reshape(3, 3) + if native is None: + # Blender's sensor convention puts the principal point at the image + # centre, so cx/cy are half the native resolution. + native = (int(round(2.0 * matrix[0, 2])), int(round(2.0 * matrix[1, 2]))) + if native[0] <= 0 or native[1] <= 0: + raise FlowError(f"intrinsics native size is not usable: {native!r}") + return matrix, native + + +def project_point( + K_like: Any, + world_to_cam: Any, + point: Sequence[float], + size: Sequence[int] | None = None, +) -> tuple[float, float]: + """Project a 3-D point to ``(u, v)`` pixel coordinates. + + ``K_like`` may be a 3x3 matrix, a mapping with ``K``/``width``/``height``, or + an ``np.load`` archive with those keys. ``world_to_cam`` is a 4x4 + world-to-camera matrix. Without ``size`` the result is in the intrinsics' + native pixel frame (origin bottom-left, Blender's convention); with + ``size=(width, height)`` it is rescaled to that render size and flipped to + image row order (origin top-left), with pixel centres at integer indices. + """ + matrix, native = _intrinsics_matrix(K_like) + transform = np.asarray(world_to_cam, dtype=np.float64).reshape(4, 4) + point3 = np.asarray(point, dtype=np.float64).reshape(3) + camera = transform @ np.append(point3, 1.0) + if camera[2] == 0.0: + raise FlowError(f"point {tuple(point3)} projects to zero depth") + u_native = matrix[0, 0] * (camera[0] / camera[2]) + matrix[0, 2] + v_native = matrix[1, 1] * (camera[1] / camera[2]) + matrix[1, 2] + if size is None: + return float(u_native), float(v_native) + width, height = int(size[0]), int(size[1]) + if width <= 0 or height <= 0: + raise FlowError(f"project_point size must be positive, got {tuple(size)!r}") + scale_x = width / float(native[0]) + scale_y = height / float(native[1]) + u = u_native * scale_x - 0.5 + v = (float(native[1]) - v_native) * scale_y - 0.5 + return float(u), float(v) + + +def px_per_mm_at( + K_like: Any, + world_to_cam: Any, + point_mm: Sequence[float], + axis_mm: Sequence[float], + size: Sequence[int] | None = None, +) -> float: + """Local image scale in px/mm, by finite difference along ``axis_mm``. + + Only used as a fallback: the render manifest already measures + ``probe_px_per_mm`` from the real projection and is preferred. + """ + start = np.asarray(point_mm, dtype=np.float64).reshape(3) + axis = np.asarray(axis_mm, dtype=np.float64).reshape(3) + length = float(np.linalg.norm(axis)) + if length <= 0.0: + raise FlowError("axis_mm must be a non-zero vector") + p0 = np.asarray(project_point(K_like, world_to_cam, start, size)) + p1 = np.asarray(project_point(K_like, world_to_cam, start + axis, size)) + return float(np.linalg.norm(p1 - p0) / length) + + +# --------------------------------------------------------------------------- +# Known-answer gate +# --------------------------------------------------------------------------- + + +def synthetic_texture(height: int, width: int, seed: int = 0) -> np.ndarray: + """Deterministic, DIS-trackable ``uint8`` test image. + + Multi-scale random blobs plus fine grain and a mild blur: DIS needs + gradients at several pyramid levels to estimate an integer shift, and this + gives them without depending on any render. + """ + _require_cv2() + if height <= 0 or width <= 0: + raise FlowError(f"synthetic_texture needs a positive size, got {(height, width)!r}") + rng = np.random.default_rng(seed) + coarse = rng.integers(0, 256, size=(max(2, height // 8), max(2, width // 8))).astype( + np.float32 + ) + image = cv2.resize(coarse, (width, height), interpolation=cv2.INTER_LINEAR) * 0.7 + image += rng.normal(0.0, 12.0, (height, width)).astype(np.float32) + image = cv2.GaussianBlur(image, (0, 0), 1.2) + return np.clip(np.round(image), 0, 255).astype(np.uint8) + + +def known_answer_shift(ref_gray: np.ndarray, dx: float, dy: float) -> np.ndarray: + """Translate ``ref_gray`` by ``(dx, dy)`` pixels (``BORDER_REPLICATE``).""" + _require_cv2() + image = np.asarray(ref_gray) + if image.ndim != 2: + raise FlowError(f"known_answer_shift expects 2-D input, got {image.shape}") + matrix = np.float32([[1.0, 0.0, float(dx)], [0.0, 1.0, float(dy)]]) + return cv2.warpAffine( + image, + matrix, + (image.shape[1], image.shape[0]), + flags=cv2.INTER_LINEAR, + borderMode=cv2.BORDER_REPLICATE, + ) + + +def _interior_mask(shape: Sequence[int], border: int) -> np.ndarray: + """All pixels at least ``border`` pixels from every edge.""" + height, width = int(shape[0]), int(shape[1]) + border = max(1, int(border)) + mask = np.zeros((height, width), dtype=bool) + if 2 * border >= height or 2 * border >= width: + return np.ones((height, width), dtype=bool) + mask[border : height - border, border : width - border] = True + return mask + + +def _pixel_number(value: float) -> float | int: + """Whole-pixel shifts stay JSON integers (2, not 2.0).""" + number = float(value) + return int(number) if number == int(number) else number + + +def measure_known_answer( + estimator: FixedReferenceFlow, + ref_gray: np.ndarray, + dx: float, + dy: float, + *, + tol: float = DEFAULT_KNOWN_ANSWER_TOL_PX, + expect: Sequence[float] | None = None, +) -> dict[str, float]: + """Apply ``(dx, dy)``, measure it, and gate the result against ``expect``. + + ``expect`` defaults to the applied shift; pass a different value to prove + the gate rejects a wrong answer (used by the tests). The measurement is the + mean flow over the interior of the frame, excluding a DIS-patch-wide border + where ``BORDER_REPLICATE`` makes the flow ill-defined. Raises + :class:`FlowError` when the measured shift is outside ``tol``. + """ + _require_cv2() + reference = np.asarray(ref_gray) + if reference.ndim != 2: + raise FlowError(f"known-answer reference must be 2-D, got {reference.shape}") + expected = (float(dx), float(dy)) if expect is None else ( + float(expect[0]), + float(expect[1]), + ) + shifted = known_answer_shift(reference, dx, dy) + border = max(4, int(getattr(estimator, "patch_px", DEFAULT_DIS_PATCH_PX)) // 2 + 1) + mask = _interior_mask(reference.shape, border) + estimator.set_reference(reference) + flow = estimator.compute(shifted) + measured_u = float(flow[:, :, 0][mask].mean()) + measured_v = float(flow[:, :, 1][mask].mean()) + case = { + "dx": _pixel_number(dx), + "dy": _pixel_number(dy), + "measured_u": measured_u, + "measured_v": measured_v, + "tol": float(tol), + } + errors = (measured_u - expected[0], measured_v - expected[1]) + if abs(errors[0]) > tol or abs(errors[1]) > tol: + raise FlowError( + "known-answer gate FAILED: applied " + f"({float(dx):+.4f}, {float(dy):+.4f}) px, expected the estimator to " + f"report ({expected[0]:+.4f}, {expected[1]:+.4f}) px within {tol} px, " + f"but it reported ({measured_u:+.4f}, {measured_v:+.4f}) px " + f"(errors {errors[0]:+.4f}, {errors[1]:+.4f} px). The estimator " + "cannot even recover a known integer shift; every downstream number " + "is invalid.", + report=case, + ) + return case + + +def _scaled_shift(dx: float, dy: float, factor: float) -> tuple[int, int]: + """Scale a pixel shift to a coarser scale, keeping it a non-zero integer. + + A zero component must stay zero: only a component that is non-zero at the + native scale is lifted to at least one pixel. + """ + scaled_x = int(round(float(dx) * factor)) + scaled_y = int(round(float(dy) * factor)) + if float(dx) != 0.0 and scaled_x == 0: + scaled_x = 1 + if float(dy) != 0.0 and scaled_y == 0: + scaled_y = 1 + return scaled_x, scaled_y + + +def evaluate_known_answer( + scales: Sequence[Mapping[str, Any]], + config: Mapping[str, Any] | None = None, + *, + tol: float = DEFAULT_KNOWN_ANSWER_TOL_PX, + base_shifts: Sequence[Sequence[int]] = KNOWN_ANSWER_SHIFTS_PX, + progress: Callable[[str], None] | None = None, +) -> tuple[dict[str, Any], list[str]]: + """Gate every scale with an integer shift and return ``(block, problems)``. + + The shift is ``2 px`` at the first (native) scale and is scaled with the + resolution for coarser scales, so each scale is gated with a shift that is an + integer number of *its* pixels. Returns the contract's ``known_answer`` + block plus a list of human-readable problems (empty when every case passed). + """ + _require_cv2() + cases: list[dict[str, Any]] = [] + problems: list[str] = [] + if not scales: + raise FlowError("no scales to validate the estimator against") + reference_width = int(scales[0]["width"]) + for scale in scales: + width = int(scale["width"]) + height = int(scale["height"]) + factor = width / float(reference_width) + shifts = [_scaled_shift(dx, dy, factor) for dx, dy in base_shifts] + reference = synthetic_texture(height, width, seed=0) + for dx, dy in shifts: + estimator = FixedReferenceFlow(config) + try: + case = measure_known_answer(estimator, reference, dx, dy, tol=tol) + except FlowError as exc: + if exc.report is not None: + cases.append(dict(exc.report)) + problems.append(f"scale {scale['name']}: {exc}") + if progress is not None: + progress(f" known-answer FAIL {scale['name']} ({dx},{dy}): {exc}") + continue + cases.append(case) + if progress is not None: + progress( + f" known-answer {scale['name']} applied ({dx},{dy}) px -> " + f"measured ({case['measured_u']:+.4f}, {case['measured_v']:+.4f}) px " + f"tol {tol}" + ) + block = {"passed": not problems, "tol": float(tol), "cases": cases} + return block, problems + + +def validate_estimator( + config: Mapping[str, Any] | None = None, + *, + scales: Sequence[Mapping[str, Any]] | None = None, + tol: float = DEFAULT_KNOWN_ANSWER_TOL_PX, +) -> dict[str, Any]: + """Return the contract's ``known_answer`` block, raising on any failure. + + This is the loud public gate: it must never return when the estimator cannot + recover a known integer shift. + """ + if scales is None: + scales = [{"name": "render", "width": 500, "height": 500, "resample": False}] + block, problems = evaluate_known_answer(scales, config, tol=tol) + if problems: + raise FlowError( + "known-answer gate FAILED; refusing to report any displacement:\n" + + "\n".join(f" - {problem}" for problem in problems), + report=block, + ) + return block + + +# --------------------------------------------------------------------------- +# Job and result I/O +# --------------------------------------------------------------------------- + + +def _setting(settings: Any, key: str, default: Any = None) -> Any: + """Read ``key`` from a mapping or an attribute object (e.g. ProbeSettings).""" + if settings is None: + return default + if isinstance(settings, Mapping): + value = settings.get(key, default) + else: + value = getattr(settings, key, default) + return default if value is None else value + + +def _tracker_dict(settings: Any) -> dict[str, Any]: + tracker = _setting(settings, "tracker", {}) or {} + if isinstance(tracker, TrackerConfig): + return tracker.to_dict() + if isinstance(tracker, Mapping): + return dict(tracker) + raise FlowError(f"settings.tracker must be a mapping, got {type(tracker).__name__}") + + +def build_track_job( + settings: Any, + output_dir: str | Path, + *, + include_process_scale: bool = True, + manifest_path: str | Path | None = None, + strategy_path: str | Path | None = None, +) -> dict[str, Any]: + """Build the ``_track_job.json`` dict for a resolved ``deform.ProbeSettings``. + + ``settings`` is duck-typed (mapping or object with attributes), so this does + not import ``deform`` and cannot create an import cycle. ``resolution`` + becomes the native ``render`` scale; ``process_resolution`` becomes the + resampled ``process`` scale when it differs and + ``include_process_scale`` is set. + """ + out = Path(output_dir).expanduser().resolve() + resolution = tuple(int(v) for v in _setting(settings, "resolution", (500, 500))) + if len(resolution) != 2: + raise FlowError(f"settings.resolution must be (width, height), got {resolution!r}") + scales: list[dict[str, Any]] = [ + { + "name": "render", + "width": resolution[0], + "height": resolution[1], + "resample": False, + } + ] + if include_process_scale: + process = tuple(int(v) for v in _setting(settings, "process_resolution", (0, 0))) + if len(process) != 2: + raise FlowError( + f"settings.process_resolution must be (width, height), got {process!r}" + ) + if process[0] > 0 and process[1] > 0 and process != resolution: + scales.append( + { + "name": "process", + "width": process[0], + "height": process[1], + "resample": True, + } + ) + + axes = [str(axis) for axis in _setting(settings, "axes", ("x", "y", "z"))] + noise_block = _setting(settings, "noise", None) or {} + return { + "schema": TRACK_JOB_SCHEMA, + "output_dir": str(out), + "settings": { + "axes": axes, + "step_mm": float(_setting(settings, "step_mm", 0.01)), + "max_mm": float(_setting(settings, "max_mm", 0.10)), + "radius_mm": float(_setting(settings, "radius_mm", 0.5)), + "process_resolution": [int(v) for v in _setting(settings, "process_resolution", (0, 0))], + "tracker": _tracker_dict(settings), + # Must be carried: without it _resolve_noise_settings falls back to its own + # defaults, so design.yaml's sigmas/draws/scales and every --noise-* flag + # were silently ignored (the defaults happened to match, which hid it). + "noise": dict(noise_block) if isinstance(noise_block, Mapping) else {}, + }, + "scales": scales, + "artifacts": { + "manifest": str( + Path(manifest_path).expanduser().resolve() + if manifest_path is not None + else out / RENDER_MANIFEST_FILENAME + ), + "strategy": str( + Path(strategy_path).expanduser().resolve() + if strategy_path is not None + else out / STRATEGY_FILENAME + ), + }, + } + + +def write_track_job(job: Mapping[str, Any], output_dir: str | Path) -> Path: + """Write ``_track_job.json`` into ``output_dir``; return its path.""" + out = Path(output_dir).expanduser() + out.mkdir(parents=True, exist_ok=True) + path = out / TRACK_JOB_FILENAME + path.write_text(json.dumps(job, indent=2) + "\n", encoding="utf-8") + return path + + +def _read_json(path: Path) -> Any: + return json.loads(path.read_text(encoding="utf-8")) + + +def _resolve_artifact(job: Mapping[str, Any], key: str, output_dir: Path, default_name: str) -> Path: + artifacts = job.get("artifacts") + raw = None + if isinstance(artifacts, Mapping): + raw = artifacts.get(key) + if raw is None: + return output_dir / default_name + return Path(str(raw)).expanduser().resolve() + + +def _load_render_manifest(path: Path) -> dict[str, Any] | None: + """Return the parsed manifest, or ``None`` when it is missing or empty.""" + if not path.is_file(): + return None + text = path.read_text(encoding="utf-8").strip() + if not text: + return None + data = json.loads(text) + if not isinstance(data, Mapping): + raise FlowError(f"{path}: render manifest must be a JSON object") + return dict(data) + + +def _px_block_scalar(block: Any) -> float | None: + """Best single px/mm scalars out of a manifest scale block. + + Accepts ``{"p50": ...}``, a bare number, ``{"x":..,"y":..,"z":..}`` or + ``{"axes": {...}}``. For a per-axis block with no summary key the mean of the + transverse ``x``/``y`` axes is used (``z`` is nearly along the view axis, so + folding it in would misstate the in-plane scale); the source label records + that this is what happened. + """ + if isinstance(block, Mapping): + for key in ("p50", "median", "mean", "min"): + value = block.get(key) + if isinstance(value, (int, float)) and not isinstance(value, bool) and float(value) > 0.0: + return float(value) + axes = block.get("axes") + if not isinstance(axes, Mapping): + axes = {axis: block[axis] for axis in ("x", "y", "z") if axis in block} + transverse = [ + float(axes[axis]) + for axis in ("x", "y") + if isinstance(axes.get(axis), (int, float)) + and not isinstance(axes.get(axis), bool) + and float(axes[axis]) > 0.0 + ] + if transverse: + return sum(transverse) / len(transverse) + every = [ + float(value) + for value in axes.values() + if isinstance(value, (int, float)) and not isinstance(value, bool) and float(value) > 0.0 + ] + if every: + return sum(every) / len(every) + elif isinstance(block, (int, float)) and not isinstance(block, bool) and float(block) > 0.0: + return float(block) + return None + + +def _px_source_suffix(block: Any) -> str: + """Which field inside a px/mm block supplied the scalar.""" + if not isinstance(block, Mapping): + return "" + for key in ("p50", "median", "mean", "min"): + value = block.get(key) + if isinstance(value, (int, float)) and not isinstance(value, bool) and float(value) > 0.0: + return f".{key}" + axes = block.get("axes") + if not isinstance(axes, Mapping): + axes = {axis: block[axis] for axis in ("x", "y", "z") if axis in block} + if any(isinstance(axes.get(axis), (int, float)) for axis in ("x", "y")): + return ".axes(x,y mean)" + return ".axes mean" if axes else "" + + +def _resolve_px_per_mm( + manifest: Mapping[str, Any], radius_mm: float +) -> tuple[float | None, str]: + """Return ``(px_per_mm_at_native_render_size, source_label)``. + + The render stage is the only place holding the camera matrices and the + deformed vertices together, so this consumes its measured value instead of + reprojecting anything here. ``probe_px_per_mm`` is the probe-local block; + ``mesh_px_per_mm`` is a mesh-wide statistic, so a fallback to it is labelled + loudly and never mistaken for the local scale. + """ + for key, prefix in ( + ("probe_px_per_mm", "render_manifest.probe_px_per_mm"), + ("mesh_px_per_mm", "render_manifest.mesh_px_per_mm"), + ): + block = manifest.get(key) + scalar = _px_block_scalar(block) + if scalar is not None: + suffix = _px_source_suffix(block) + label = f"{prefix}{suffix}" + if key == "mesh_px_per_mm": + label += " (MESH-WIDE fallback, not probe-local)" + return scalar, label + roi = manifest.get("probe_roi_px") + if isinstance(roi, Mapping) and radius_mm > 0.0: + radius = roi.get("radius") + if isinstance(radius, (int, float)) and float(radius) > 0.0: + return ( + float(radius) / float(radius_mm), + "render_manifest.probe_roi_px.radius / settings.radius_mm", + ) + return None, "unavailable" + + +def _resolve_roi( + manifest: Mapping[str, Any], + scale: Mapping[str, Any], + radius_mm: float, + px_per_mm: float | None, +) -> tuple[tuple[float, float], float, list[str]]: + """Return ``((u, v), radius_px, warnings)`` for one scale.""" + warnings: list[str] = [] + native_width = int(manifest.get("width") or scale["width"]) + native_height = int(manifest.get("height") or scale["height"]) + roi = manifest.get("probe_roi_px") + if isinstance(roi, Mapping) and all( + isinstance(roi.get(key), (int, float)) for key in ("u", "v", "radius") + ): + u = float(roi["u"]) + v = float(roi["v"]) + radius = float(roi["radius"]) + else: + u = native_width / 2.0 + v = native_height / 2.0 + radius = 0.0 + if px_per_mm is not None and radius_mm > 0.0: + radius = radius_mm * px_per_mm + warnings.append( + "render_manifest.json has no usable probe_roi_px; the ROI falls back " + f"to the image centre ({u:.1f}, {v:.1f}) with radius {radius:.2f} px" + ) + if radius <= 0.0: + raise FlowError( + "cannot build a probe ROI: probe_roi_px is unusable and the fallback " + "radius (radius_mm * px_per_mm) is zero" + ) + factor_x = int(scale["width"]) / float(native_width) + factor_y = int(scale["height"]) / float(native_height) + if abs(factor_x - factor_y) > 1e-9: + warnings.append( + f"scale {scale['name']} is not an isotropic resize of the render " + f"({factor_x:.4f} x, {factor_y:.4f} y); the ROI is scaled per axis" + ) + u_scaled = (u + 0.5) * factor_x - 0.5 + v_scaled = (v + 0.5) * factor_y - 0.5 + radius_scaled = radius * (factor_x + factor_y) / 2.0 + return (u_scaled, v_scaled), radius_scaled, warnings + + +def _roi_truncation(roi_mask: np.ndarray, centre: Sequence[float], radius_px: float) -> list[str]: + """Warnings when an ROI is partly outside the frame.""" + expected = expected_disc_pixels(radius_px) + actual = int(roi_mask.sum()) + if actual == 0: + raise FlowError( + f"the probe ROI at ({centre[0]:.2f}, {centre[1]:.2f}) radius " + f"{radius_px:.2f} px lies entirely outside the frame; refusing to " + "report a number" + ) + if expected > 0 and actual < 0.9 * expected: + return [ + f"probe ROI is truncated: {actual} px inside the frame vs " + f"{expected:.0f} px for the full disc (centre " + f"({centre[0]:.2f}, {centre[1]:.2f}), radius {radius_px:.2f} px)" + ] + return [] + + +# -- response direction ------------------------------------------------------ + + +def _unit(vector: Sequence[float]) -> tuple[float, float] | None: + u, v = float(vector[0]), float(vector[1]) + norm = math.hypot(u, v) + if not math.isfinite(norm) or norm <= 0.0: + return None + return (u / norm, v / norm) + + +def load_strategy_directions(path: str | Path) -> dict[str, tuple[float, float]]: + """Read per-axis image-space unit directions from ``strategy_probe.json``. + + ``docs/probe-pipeline.md`` lists ``strategy_probe.json`` as a track-job + artifact but does not freeze its schema, so this reader accepts the plausible + shapes and returns ``{}`` for anything it cannot use: + + * ``{"axes": {"x": {"direction_px": [u, v]}, ...}}`` + * ``{"axes": [{"axis": "x", "direction_px": [u, v]}, ...]}`` + * ``{"directions_px": {"x": [u, v]}}`` / ``{"directions": {"x": [u, v]}}`` + * ``{"frames": [{"index", "axis", "step_mm", "centroid_px": [u, v]}, ...]}`` + (direction from the reference centroid to the largest-step centroid) + """ + path = Path(path) + if not path.is_file(): + return {} + try: + data = _read_json(path) + except Exception: + return {} + if not isinstance(data, Mapping): + return {} + + directions: dict[str, tuple[float, float]] = {} + + def add(axis: Any, vector: Any) -> None: + if isinstance(axis, str) and isinstance(vector, (list, tuple)) and len(vector) >= 2: + unit = _unit(vector) + if unit is not None: + directions[axis] = unit + + axes_block = data.get("axes") + if isinstance(axes_block, Mapping): + for axis, entry in axes_block.items(): + if isinstance(entry, Mapping): + for key in ("direction_px", "unit_px", "direction"): + if key in entry: + add(axis, entry[key]) + break + elif isinstance(axes_block, list): + for entry in axes_block: + if isinstance(entry, Mapping): + axis = entry.get("axis") + for key in ("direction_px", "unit_px", "direction"): + if key in entry: + add(axis, entry[key]) + break + for key in ("directions_px", "directions"): + block = data.get(key) + if isinstance(block, Mapping): + for axis, vector in block.items(): + add(axis, vector) + + frames = data.get("frames") + if isinstance(frames, list): + centroids: dict[str, dict[float, Sequence[float]]] = {} + for entry in frames: + if not isinstance(entry, Mapping): + continue + axis = entry.get("axis") + if not isinstance(axis, str): + continue + point = None + for key in ("centroid_px", "projected_centroid", "centroid", "uv_px", "centre_px"): + if key in entry: + point = entry[key] + break + if point is None: + continue + try: + step = float(entry.get("step_mm", 0.0)) + except (TypeError, ValueError): + continue + centroids.setdefault(axis, {})[step] = point + for axis, by_step in centroids.items(): + if 0.0 not in by_step or len(by_step) < 2: + continue + base = np.asarray(by_step[0.0], dtype=np.float64) + top = np.asarray(by_step[max(by_step)], dtype=np.float64) + add(axis, (top - base).tolist()) + + return directions + + +def _max_step_frames(manifest: Mapping[str, Any]) -> dict[str, Mapping[str, Any]]: + """Largest-step frame for each axis, from the manifest's frame list.""" + best: dict[str, Mapping[str, Any]] = {} + for frame in manifest.get("frames") or []: + if not isinstance(frame, Mapping): + continue + axis = frame.get("axis") + if not isinstance(axis, str): + continue + try: + step = float(frame.get("step_mm", 0.0)) + except (TypeError, ValueError): + continue + if axis not in best or step > float(best[axis].get("step_mm", 0.0)): + best[axis] = frame + return best + + +_CENTROID_KEYS = ("centroid_px", "projected_centroid", "centroid", "uv_px", "centre_px") + + +def _entry_centroid(entry: Mapping[str, Any]) -> Sequence[float] | None: + for key in _CENTROID_KEYS: + value = entry.get(key) + if isinstance(value, (list, tuple)) and len(value) >= 2: + try: + return (float(value[0]), float(value[1])) + except (TypeError, ValueError): + return None + return None + + +def directions_from_manifest(manifest: Mapping[str, Any]) -> dict[str, tuple[float, float]]: + """Per-axis image-space directions from ``frames[].centroid_px``. + + The render manifest carries the projected probe centroid per frame, so the + unit direction of an axis is the normalised finite difference between the + largest-step frame of that axis and the reference frame -- the same + construction ``docs/probe-pipeline.md`` specifies for ``d_hat_px``. Returns + ``{}`` when the manifest predates ``centroid_px`` or has no reference. + """ + frames = [frame for frame in manifest.get("frames") or [] if isinstance(frame, Mapping)] + reference_index = int(manifest.get("reference_index", 0) or 0) + base: Sequence[float] | None = None + best: dict[str, tuple[float, Sequence[float]]] = {} + for frame in frames: + if int(frame.get("index", -1)) == reference_index: + base = _entry_centroid(frame) + continue + axis = frame.get("axis") + if not isinstance(axis, str): + continue + point = _entry_centroid(frame) + if point is None: + continue + try: + step = float(frame.get("step_mm", 0.0)) + except (TypeError, ValueError): + continue + if axis not in best or step > best[axis][0]: + best[axis] = (step, point) + if base is None: + return {} + directions: dict[str, tuple[float, float]] = {} + for axis, (_, point) in best.items(): + unit = _unit((point[0] - base[0], point[1] - base[1])) + if unit is not None: + directions[axis] = unit + return directions + + +# --------------------------------------------------------------------------- +# run_track_job +# --------------------------------------------------------------------------- + + +def _relative_png(manifest_path: Path, entry: Mapping[str, Any]) -> Path | None: + png = entry.get("png") + if not isinstance(png, str) or not png: + return None + path = Path(png) + if path.is_absolute(): + return path + return (manifest_path.parent / path).resolve() + + +def _check_png(path: Path | None) -> str | None: + """Return a problem string when a frame image is missing or empty.""" + if path is None: + return "frame entry has no 'png' path" + if not path.is_file(): + return f"frame image is missing: {path}" + try: + if path.stat().st_size == 0: + return f"frame image is empty: {path}" + except OSError as exc: # pragma: no cover - defensive + return f"cannot stat frame image {path}: {exc}" + return None + + +def _verify_reference_self(estimator: FixedReferenceFlow, reference: np.ndarray, scale_name: str) -> float: + """Reference-vs-reference must be *exactly* zero flow. + + Raises :class:`FlowError` otherwise: a non-zero result means the reference is + not actually being reused, and every displacement in this scale would be + measured against the wrong thing. + """ + estimator.set_reference(reference) + flow = estimator.compute(reference) + max_abs = float(np.abs(flow).max()) + if max_abs != 0.0: + raise FlowError( + f"reference_self is not zero at scale {scale_name}: max |flow| = " + f"{max_abs:.6g} px. The fixed reference is not being reused, so every " + "displacement at this scale is invalid." + ) + return max_abs + + +def _resolve_noise_settings(job: Mapping[str, Any]) -> dict[str, Any]: + """Read the injected-noise settings from a track job, with safe defaults.""" + settings = job.get("settings") if isinstance(job, Mapping) else None + block = {} + if isinstance(settings, Mapping): + candidate = settings.get("noise") + if isinstance(candidate, Mapping): + block = candidate + raw_sigmas = block.get("sigmas") + sigmas: list[float] = [] + if isinstance(raw_sigmas, Sequence) and not isinstance(raw_sigmas, (str, bytes)): + for value in raw_sigmas: + try: + sigma = float(value) + except (TypeError, ValueError): + continue + if 0.0 < sigma <= MAX_NOISE_SIGMA: + sigmas.append(sigma) + if not sigmas and block.get("enabled", True) is not False: + sigmas = list(DEFAULT_NOISE_SIGMAS) + + models = block.get("models") + if isinstance(models, str): + models = [models] + if not isinstance(models, Sequence) or isinstance(models, (str, bytes)): + models = ["render_seed", "injected"] + + scales = block.get("scales") + if isinstance(scales, str): + scales = [scales] + if not isinstance(scales, Sequence) or isinstance(scales, (str, bytes)): + scales = ["render"] + + try: + draws = int(block.get("draws", DEFAULT_NOISE_DRAWS)) + except (TypeError, ValueError): + draws = DEFAULT_NOISE_DRAWS + try: + seed = int(block.get("seed", 0)) + except (TypeError, ValueError): + seed = 0 + + return { + "enabled": bool(sigmas) and "injected" in [str(m) for m in models], + "sigmas": sigmas, + "draws": max(1, draws), + "seed": seed, + "scales": [str(value) for value in scales], + "models": [str(value) for value in models], + } + + +def run_track_job( + job: Mapping[str, Any], + output_dir: str | Path, + progress: Callable[[str], None] | None = print, +) -> tuple[dict[str, Any], int]: + """Run the flow stage for one ``_track_job.json``. + + Returns ``(flow_report, exit_code)`` with 0 success, 1 scene/data error + (including a failed known-answer or reference-self gate), 2 config error, and + 3 a required artifact that is missing or empty. ``flow_report.json`` is only + written on success: a failed run must not leave a report that downstream + stages could mistake for a measurement. + """ + def say(message: str) -> None: + if progress is not None: + progress(message) + + if not isinstance(job, Mapping): + say("track job must be a mapping") + return {}, EXIT_CONFIG_ERROR + out = Path(output_dir).expanduser().resolve() + job_output = job.get("output_dir") + if isinstance(job_output, str) and job_output: + declared = Path(job_output).expanduser().resolve() + if declared != out: + say(f"warning: job output_dir {declared} differs from --output-dir {out}; using the latter") + + try: + _require_cv2() + except ImportError as exc: + say(str(exc)) + return {}, EXIT_CONFIG_ERROR + + schema = job.get("schema") + if schema is not None and schema != TRACK_JOB_SCHEMA: + say(f"track job schema {schema!r} is not {TRACK_JOB_SCHEMA!r}") + return {}, EXIT_CONFIG_ERROR + + settings = job.get("settings") if isinstance(job.get("settings"), Mapping) else {} + warnings: list[str] = [] + try: + config = load_tracker_config(settings.get("tracker") or {}) + except FlowError as exc: + say(f"tracker config error: {exc}") + return {}, EXIT_CONFIG_ERROR + + axes = [str(axis) for axis in settings.get("axes", ["x", "y", "z"])] + radius_mm = float(settings.get("radius_mm", 0.5) or 0.0) + scales = job.get("scales") + if not isinstance(scales, list) or not scales: + say("track job has no scales") + return {}, EXIT_CONFIG_ERROR + for scale in scales: + if not isinstance(scale, Mapping) or not all( + isinstance(scale.get(key), (int, float)) and scale.get(key) > 0 + for key in ("width", "height") + ): + say(f"track job scale is invalid: {scale!r}") + return {}, EXIT_CONFIG_ERROR + if not isinstance(scale.get("name"), str) or not scale["name"]: + say(f"track job scale has no name: {scale!r}") + return {}, EXIT_CONFIG_ERROR + + manifest_path = _resolve_artifact(job, "manifest", out, RENDER_MANIFEST_FILENAME) + try: + manifest = _load_render_manifest(manifest_path) + except FlowError as exc: + say(f"render manifest error: {exc}") + return {}, EXIT_SCENE_ERROR + except Exception as exc: + say(f"cannot parse render manifest {manifest_path}: {exc}") + return {}, EXIT_SCENE_ERROR + if manifest is None: + say(f"render manifest is missing or empty: {manifest_path}") + return {}, EXIT_MISSING_ARTIFACT + if manifest.get("schema") not in (None, RENDER_MANIFEST_SCHEMA): + say( + f"render manifest schema {manifest.get('schema')!r} is not " + f"{RENDER_MANIFEST_SCHEMA!r}" + ) + return {}, EXIT_SCENE_ERROR + + frames = manifest.get("frames") + if not isinstance(frames, list) or not frames: + say(f"render manifest has no frames: {manifest_path}") + return {}, EXIT_MISSING_ARTIFACT + reference_index = int(manifest.get("reference_index", 0) or 0) + reference_entries = [ + frame for frame in frames if isinstance(frame, Mapping) and int(frame.get("index", -1)) == reference_index + ] + if not reference_entries: + say(f"render manifest has no frame with reference_index {reference_index}") + return {}, EXIT_SCENE_ERROR + reference_entry = reference_entries[0] + frame_entries = [ + frame + for frame in frames + if isinstance(frame, Mapping) and int(frame.get("index", -1)) != reference_index + ] + + step_mm = float(settings.get("step_mm", 0.0) or 0.0) + max_mm = float(settings.get("max_mm", 0.0) or 0.0) + if step_mm > 0.0 and max_mm > 0.0 and axes: + expected_frames = len(axes) * int(round(max_mm / step_mm)) + if expected_frames != len(frame_entries): + warnings.append( + f"render manifest lists {len(frame_entries)} displaced frames but the " + f"job settings imply {expected_frames} (axes {axes}, step {step_mm} mm, " + f"max {max_mm} mm); the manifest is authoritative for order" + ) + + png_paths: dict[tuple[str, int], Path] = {} + for scale in scales: + for frame in [reference_entry, *frame_entries]: + path = _relative_png(manifest_path, frame) + problem = _check_png(path) + if problem: + say(f"{problem} (frame {frame.get('name')!r}, scale {scale['name']})") + return {}, EXIT_MISSING_ARTIFACT + png_paths[(scale["name"], int(frame["index"]))] = path # type: ignore[arg-type] + + null_entry = manifest.get("null") + null_path: Path | None = None + if isinstance(null_entry, Mapping): + null_path = _relative_png(manifest_path, null_entry) + problem = _check_png(null_path) + if problem: + say(f"declared null frame is unusable: {problem}") + return {}, EXIT_MISSING_ARTIFACT + else: + warnings.append( + "render manifest declares no null frame; the zero-input floor is " + "unavailable and no null floor is reported" + ) + + strategy_path = _resolve_artifact(job, "strategy", out, STRATEGY_FILENAME) + try: + directions = load_strategy_directions(strategy_path) + except Exception as exc: # pragma: no cover - defensive + directions = {} + warnings.append(f"could not read {strategy_path.name}: {exc}") + direction_source = "strategy_probe.json" + if not directions: + directions = directions_from_manifest(manifest) + direction_source = "render_manifest.frames[].centroid_px" + if not directions: + direction_source = "flow_max_step" + warnings.append( + f"{strategy_path.name} is missing or has no usable per-axis directions and " + "render_manifest.json has no per-frame centroid_px; response_proj_px is " + "projected onto the ROI-mean flow direction of each axis's largest step " + "instead (direction_source='flow_max_step')" + ) + unknown_axes = sorted( + {str(frame.get("axis")) for frame in frame_entries if frame.get("axis") is not None} + - set(axes) + ) + if unknown_axes: + warnings.append(f"render manifest uses axes not in the job settings: {unknown_axes}") + + say( + f"tracker: algorithm=dis preset={config.get('dis_preset')} " + f"dis_mode={config.get('dis_mode')} ..." + ) + try: + known_answer, problems = evaluate_known_answer(scales, config, progress=progress) + except FlowError as exc: + say(f"known-answer gate could not run: {exc}") + return {}, EXIT_SCENE_ERROR + if problems: + say("known-answer gate FAILED; the estimator cannot recover a known integer shift:") + for problem in problems: + say(f" - {problem}") + say("refusing to report any displacement") + return {"schema": FLOW_REPORT_SCHEMA, "known_answer": known_answer}, EXIT_SCENE_ERROR + say(f"known-answer gate passed ({len(known_answer['cases'])} cases, tol {known_answer['tol']} px)") + + estimator_probe = FixedReferenceFlow(config) + patch_px = estimator_probe.patch_px + del estimator_probe + + if bool(scales[0].get("resample", False)): + say("the first track scale must be the native render scale (resample=false)") + return {}, EXIT_CONFIG_ERROR + reference_native = load_gray( + png_paths[(str(scales[0]["name"]), int(reference_entry["index"]))] + ) + + # Diagnostic (never a gate): run the same known-answer shift on the rendered + # reference texture. A periodic marker grid aliases a sub-period shift onto + # the wrong period, so this can legitimately fail even though the estimator + # itself is sound; it is reported so nobody reads a single-step response as + # unambiguous. + reference_texture_known_answer: dict[str, Any] | None = None + try: + cases: list[dict[str, Any]] = [] + failed = False + for dx, dy in KNOWN_ANSWER_SHIFTS_PX: + estimator = FixedReferenceFlow(config) + try: + case = measure_known_answer(estimator, reference_native, dx, dy) + case["passed"] = True + except FlowError as exc: + failed = True + case = dict(exc.report or {}) + case["passed"] = False + cases.append(case) + reference_texture_known_answer = { + "available": True, + "gating": False, + "scale": str(scales[0]["name"]), + "cases": cases, + "note": ( + "diagnostic only, NOT a gate. It applies a GLOBAL 2 px shift, which " + "is exactly recoverable on a non-periodic texture but is not the same " + "as the local probe deformation, so passing certifies only that the " + "marker field is not strongly periodic. A strongly periodic marker " + "(checker or line grid) aliases a sub-period shift onto the wrong " + "period; a random-cell field does not (the shipped nx=80 field " + "recovered 2.0000 px). A failure here means this scale's direction " + "and magnitude are untrustworthy; a pass does not certify the local " + "gain." + ), + } + if failed: + failed_cases = [case for case in cases if not case["passed"]] + detail = "; ".join( + "applied ({dx}, {dy}) px -> measured ({mu:+.2f}, {mv:+.2f}) px".format( + dx=case.get("dx"), dy=case.get("dy"), + mu=float(case.get("measured_u", 0.0)), + mv=float(case.get("measured_v", 0.0)), + ) + for case in failed_cases + ) + warnings.append( + "the rendered reference texture aliased a known shift: " + f"{detail} (tol {failed_cases[0].get('tol')} px). DIS locked onto the " + "marker period instead of the applied shift -- this happens for " + "strongly periodic markers (checker or line grid) whose period is " + "comparable to the DIS patch, while the shipped random-cell field " + "recovers 2.0000 px exactly. Treat this scale's direction and " + "magnitude as ambiguous." + ) + except Exception as exc: # pragma: no cover - defensive + reference_texture_known_answer = {"available": False, "error": str(exc)} + warnings.append(f"reference-texture known-answer diagnostic could not run: {exc}") + + scale_reports: list[dict[str, Any]] = [] + reference_self_max = 0.0 + noise_settings = _resolve_noise_settings(job) + if noise_settings["enabled"]: + say( + f"injected noise model: sigma={noise_settings['sigmas']} " + f"draws={noise_settings['draws']} scales={noise_settings['scales']}" + ) + try: + for scale in scales: + name = str(scale["name"]) + size = (int(scale["width"]), int(scale["height"])) + resample_scale = bool(scale.get("resample", False)) + reference = ( + resample(reference_native, size) if resample_scale else reference_native + ) + estimator = FixedReferenceFlow(config) + reference_self_max = max( + reference_self_max, _verify_reference_self(estimator, reference, name) + ) + native_px_per_mm, px_per_mm_source = _resolve_px_per_mm(manifest, radius_mm) + if native_px_per_mm is None: + px_per_mm: float | None = None + warnings.append( + f"render manifest has no usable probe_px_per_mm; scale {name} " + "reports px_per_mm=null and response_mm=null" + ) + else: + # The manifest measures px/mm at the native render size; the same + # image scale at a resampled size scales with the resolution. + factor_px = ( + int(scale["width"]) / float(manifest.get("width") or scale["width"]) + + int(scale["height"]) / float(manifest.get("height") or scale["height"]) + ) / 2.0 + px_per_mm = native_px_per_mm * factor_px + centre, radius_px, roi_warnings = _resolve_roi( + manifest, scale, radius_mm, native_px_per_mm + ) + warnings.extend(roi_warnings) + roi_mask, _control_mask = region_masks(reference.shape, centre, radius_px) + warnings.extend(_roi_truncation(roi_mask, centre, radius_px)) + roi_pixels = int(roi_mask.sum()) + say( + f"scale {name}: {size[0]}x{size[1]} resample={resample_scale} " + f"roi=({centre[0]:.2f}, {centre[1]:.2f}) r={radius_px:.2f} px " + f"pixels={roi_pixels} px_per_mm=" + f"{'n/a' if px_per_mm is None else format(px_per_mm, '.4f')}" + ) + if not directions: + for axis, frame in _max_step_frames(manifest).items(): + path = _relative_png(manifest_path, frame) + if path is None or not path.is_file(): + continue + gray = resample(load_gray(path), size) if resample_scale else load_gray(path) + flow = estimator.compute(gray) + unit = _unit( + ( + float(flow[:, :, 0][roi_mask].mean()), + float(flow[:, :, 1][roi_mask].mean()), + ) + ) + if unit is not None: + directions[axis] = unit + direction_source = "flow_max_step" + missing_axes = sorted( + { + str(frame.get("axis")) + for frame in frame_entries + if frame.get("axis") is not None + } + - set(directions) + ) + if missing_axes: + warnings.append( + f"no response direction for axes {missing_axes}; their " + "response_proj_px is reported as 0.0" + ) + + scale_out: dict[str, Any] = { + "name": name, + "width": size[0], + "height": size[1], + "resample": resample_scale, + "px_per_mm": None if px_per_mm is None else float(px_per_mm), + "mm_per_px": None if px_per_mm is None else float(1.0 / px_per_mm), + "px_per_mm_source": px_per_mm_source, + "probe_px_per_mm_manifest": manifest.get("probe_px_per_mm"), + "frames": [], + "null": None, + } + + scale_output_dir = out / FLOW_DIRNAME / name + scale_output_dir.mkdir(parents=True, exist_ok=True) + + def _save(flow_array: np.ndarray, frame_name: str) -> None: + np.save(scale_output_dir / f"{frame_name}.npy", flow_array.astype(np.float32)) + + _save(estimator.compute(reference), str(reference_entry.get("name", "reference"))) + if null_path is not None: + null_gray = resample(load_gray(null_path), size) if resample_scale else load_gray(null_path) + null_flow = estimator.compute(null_gray) + _save(null_flow, str(null_entry.get("name", "reference_null"))) + null_stats = flow_stats(null_flow, roi_mask) + scale_out["null"] = { + "floor_px": float(null_stats["p95_mag"]), + "floor_source": str(null_entry.get("name", "reference_null")), + # Reclassified: this is a render reproducibility floor, NOT a + # sensor-noise model. See noise_model_pass for the calibrated one. + "floor_basis": "render_seed_reproducibility", + "floor_warning": ( + "measured as reference vs a re-render at seed+1 (Cycles AA " + "jitter). Do not rank patterns by gain/this-floor: its ordering " + "reverses under additive noise and it understates the measured " + "real idle floor, coarse patterns most." + ), + } + say( + f" [{name}] render-seed reproducibility floor (p95 |flow| over ROI, " + f"NOT a sensor-noise model): {null_stats['p95_mag']:.4f} px " + f"(mean {null_stats['mean_mag']:.4f}, max {null_stats['max_mag']:.4f})" + ) + else: + scale_out["null"] = { + "floor_px": None, + "floor_source": "unavailable", + "floor_basis": "render_seed_reproducibility", + } + + axis_frames: dict[str, list[tuple[float, np.ndarray]]] = {} + for frame in frame_entries: + index = int(frame["index"]) + gray = ( + resample(load_gray(png_paths[(name, index)]), size) + if resample_scale + else load_gray(png_paths[(name, index)]) + ) + axis_name = str(frame.get("axis")) if frame.get("axis") is not None else None + step_value = float(frame.get("step_mm", 0.0) or 0.0) + if axis_name is not None and step_value > 0.0: + axis_frames.setdefault(axis_name, []).append((step_value, gray)) + flow = estimator.compute(gray) + _save(flow, str(frame.get("name", f"frame_{index:03d}"))) + stats = flow_stats(flow, roi_mask) + axis = frame.get("axis") + direction = directions.get(str(axis)) if axis is not None else None + if direction is None: + response_px = 0.0 + else: + response_px = float( + flow[:, :, 0][roi_mask].mean() * direction[0] + + flow[:, :, 1][roi_mask].mean() * direction[1] + ) + response_mm = None if px_per_mm is None else float(response_px / px_per_mm) + scale_out["frames"].append( + { + "index": index, + "name": str(frame.get("name", f"frame_{index:03d}")), + "axis": axis, + "step_mm": float(frame.get("step_mm", 0.0) or 0.0), + "roi": { + "u": float(centre[0]), + "v": float(centre[1]), + "radius": float(radius_px), + "pixels": roi_pixels, + }, + "flow": { + "mean_u": stats["mean_u"], + "mean_v": stats["mean_v"], + "mean_mag": stats["mean_mag"], + "p50_mag": stats["p50_mag"], + "p95_mag": stats["p95_mag"], + "max_mag": stats["max_mag"], + "coverage": stats["coverage"], + }, + "response_proj_px": response_px, + "response_mm": response_mm, + "forward_backward_error": forward_backward_error( + reference, gray, flow, roi_mask + ), + } + ) + say( + f" [{name}] {scale_out['frames'][-1]['name']}: response=" + f"{response_px:+.4f} px ({'n/a' if response_mm is None else format(response_mm, '+.5f')} mm) " + f"fbe={scale_out['frames'][-1]['forward_backward_error']:.3f} " + f"coverage={stats['coverage']:.2f}" + ) + # Re-check the fixed reference after the whole sweep: with a stateless + # fixed-reference DIS this is still exactly zero, and if it is not, + # the estimator accumulated state and every frame above is suspect. + drifted = float(np.abs(estimator.compute(reference)).max()) + if drifted != 0.0: + raise FlowError( + f"reference_self drifted to {drifted:.6g} px after processing " + f"scale {name}; the estimator is not stateless against its fixed " + "reference (an accumulating tracker is in the loop)" + ) + + # Signal and noise measured under one stated model. The render-seed floor + # above is kept only as a reproducibility diagnostic. + if noise_settings.get("enabled") and name in noise_settings["scales"]: + scale_out["noise_models"] = noise_model_pass( + reference=reference, + axis_frames=axis_frames, + directions=directions, + roi_mask=roi_mask, + config=config, + sigmas=noise_settings["sigmas"], + draws=noise_settings["draws"], + seed0=noise_settings["seed"], + progress=say, + ) + scale_reports.append(scale_out) + except FlowError as exc: + say(f"flow measurement error: {exc}") + return {}, EXIT_SCENE_ERROR + + if reference_self_max != 0.0: + say( + f"reference_self is not exactly zero (max |flow| = {reference_self_max:.6g} px); " + "refusing to report a result" + ) + return {}, EXIT_SCENE_ERROR + + floor_values = [ + scale["null"]["floor_px"] + for scale in scale_reports + if isinstance(scale.get("null"), Mapping) and scale["null"].get("floor_px") is not None + ] + if floor_values: + native_px_per_mm = scale_reports[0].get("px_per_mm") + step_line = ( + f"; 0.01 mm = {0.01 * float(native_px_per_mm):.4f} px at the native scale" + if native_px_per_mm + else "" + ) + warnings.append( + "render-seed reproducibility floor (reference vs a re-render at seed+1, " + f"p95 |flow| over the ROI): min {min(floor_values):.4f} px, max " + f"{max(floor_values):.4f} px across {len(floor_values)} scale(s){step_line}. " + "This is Cycles AA jitter, NOT a sensor-noise model, and it must not be used " + "to rank patterns." + ) + + injected_scales = [ + scale for scale in scale_reports if isinstance(scale.get("noise_models"), Mapping) + ] + if injected_scales: + sigmas = sorted( + { + float(entry["sigma"]) + for scale in injected_scales + for entries in (scale["noise_models"].get("axes") or {}).values() + for entry in entries + } + ) + warnings.append( + "injected-noise model measured signal and floor under one model at " + f"sigma={sigmas} grey levels (additive white Gaussian, " + f"{noise_settings['draws']} draw(s)); this is the noise-aware evidence, and " + "the render-seed floor above is kept only as a reproducibility diagnostic" + ) + + report: dict[str, Any] = { + "schema": FLOW_REPORT_SCHEMA, + "generated_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(), + "tracker": { + "algorithm": config.get("algorithm"), + "dis_preset": config.get("dis_preset"), + "dis_mode": config.get("dis_mode"), + "finest_scale": estimator_finest_scale(config), + "patch_px": int(patch_px), + "source": "design.yaml probe.tracker", + }, + "known_answer": known_answer, + "reference_self": {"max_abs_flow_px": float(reference_self_max)}, + "reference_texture_known_answer": reference_texture_known_answer, + "scales": scale_reports, + "response_direction_source": direction_source, + "warnings": warnings, + } + out.mkdir(parents=True, exist_ok=True) + (out / FLOW_REPORT_FILENAME).write_text( + json.dumps(report, indent=2) + "\n", encoding="utf-8" + ) + say(f"wrote {out / FLOW_REPORT_FILENAME}") + return report, EXIT_OK + + +def estimator_finest_scale(config: Mapping[str, Any] | None) -> int: + """``setFinestScale`` value reported for ``config`` (defaults to 3).""" + if config is None: + return int(TRACKER_DEFAULTS["dis_mode"]) + return int(config.get("dis_mode", TRACKER_DEFAULTS["dis_mode"])) + + +# --------------------------------------------------------------------------- # +# Injected noise model +# +# Why this exists: `floor_px` above is `reference` vs a re-render at seed+1, which is +# Cycles antialiasing jitter -- edge-correlated, ~5 grey levels RMS, and NOT a sensor +# noise model. Ranking patterns by `gain / that_floor` is unsound: measured on this +# bench the ordering reverses under additive noise, and the render-seed floor +# understates the real idle floor (measured on dd02 footage by floorcal) by 2-6x, +# understating the *coarse* patterns most. So the signal and the floor must both be +# measured under the same, stated model. +# --------------------------------------------------------------------------- # + +DEFAULT_NOISE_SIGMAS: tuple[float, ...] = (4.0, 16.0) +DEFAULT_NOISE_DRAWS = 1 +#: Grey levels. Above this the injected noise swamps most patterns' contrast. +MAX_NOISE_SIGMA = 32.0 + + +def inject_noise(gray: np.ndarray, sigma: float, seed: int) -> np.ndarray: + """Add white Gaussian noise to an 8-bit frame; deterministic for a given seed.""" + if sigma <= 0.0: + return np.asarray(gray, dtype=np.uint8) + rng = np.random.RandomState(int(seed) & 0x7FFFFFFF) + base = np.asarray(gray, dtype=np.float32) + noisy = base + rng.normal(0.0, float(sigma), base.shape).astype(np.float32) + return np.clip(noisy, 0.0, 255.0).astype(np.uint8) + + +def _ols_slope(xs: Sequence[float], ys: Sequence[float]) -> float | None: + """Least-squares slope, or None when there are fewer than two usable points.""" + if len(xs) < 2: + return None + x = np.asarray(xs, dtype=float) + y = np.asarray(ys, dtype=float) + if not np.all(np.isfinite(x)) or not np.all(np.isfinite(y)): + return None + denominator = float(((x - x.mean()) ** 2).sum()) + if denominator <= 0.0: + return None + return float(((x - x.mean()) * (y - y.mean())).sum() / denominator) + + +def noise_model_pass( + *, + reference: np.ndarray, + axis_frames: Mapping[str, Sequence[tuple[float, np.ndarray]]], + directions: Mapping[str, Sequence[float]], + roi_mask: np.ndarray, + config: Mapping[str, Any] | None, + sigmas: Sequence[float] = DEFAULT_NOISE_SIGMAS, + draws: int = DEFAULT_NOISE_DRAWS, + seed0: int = 0, + progress: Callable[[str], None] | None = None, +) -> dict[str, Any]: + """Measure per-axis response, gain and floor under injected pixel noise. + + For each sigma and draw: two independent noisy copies of the static reference give + the **floor** (pure noise-induced flow, projected on the axis direction), and the + noisy reference versus each noisy displaced frame gives the **response** per step, + whose OLS slope is the gain. Signal and floor therefore come from one model. + + ``response_retention`` is reported by the caller as ``gain(sigma) / gain(clean)``. + """ + out: dict[str, Any] = { + "basis": "additive white Gaussian in grey levels, applied to reference and frames", + "draws": int(max(1, draws)), + "sigmas": [float(value) for value in sigmas], + "axes": {}, + } + if not axis_frames: + return out + + for sigma in sigmas: + sigma = float(sigma) + if sigma <= 0.0 or sigma > MAX_NOISE_SIGMA: + continue + per_axis: dict[str, list[dict[str, float]]] = {axis: [] for axis in axis_frames} + floors: dict[str, list[float]] = {axis: [] for axis in axis_frames} + for draw in range(int(max(1, draws))): + seed_a = seed0 + int(sigma * 1000) + draw * 2 + noisy_a = inject_noise(reference, sigma, seed_a) + noisy_b = inject_noise(reference, sigma, seed_a + 1) + # Floor: two independent realisations of the same static scene. + floor_estimator = FixedReferenceFlow(config) + floor_estimator.set_reference(noisy_a) + floor_flow = floor_estimator.compute(noisy_b) + for axis, unit in directions.items(): + if axis not in floors or unit is None: + continue + projected = ( + floor_flow[:, :, 0] * float(unit[0]) + floor_flow[:, :, 1] * float(unit[1]) + ) + # ABSOLUTE value: the floor is the magnitude of the noise-induced + # response along the axis. A signed p95 can come out negative (the + # noise field is centred near zero), which produced negative floors and + # a divide-by-tiny snr of ~1e7 downstream. + floors[axis].append(float(np.percentile(np.abs(projected[roi_mask]), 95))) + # Signal: noisy reference versus each noisy displaced frame. + signal_estimator = FixedReferenceFlow(config) + signal_estimator.set_reference(noisy_a) + for axis, frames in axis_frames.items(): + unit = directions.get(axis) + if unit is None: + continue + for step_mm, gray in frames: + noisy_frame = inject_noise(gray, sigma, seed_a + 2 + int(step_mm * 1e6) % 977) + flow = signal_estimator.compute(noisy_frame) + projected = ( + flow[:, :, 0] * float(unit[0]) + flow[:, :, 1] * float(unit[1]) + ) + per_axis[axis].append( + {"step_mm": float(step_mm), "response_px": float(projected[roi_mask].mean())} + ) + for axis, points in per_axis.items(): + if not points: + continue + steps = [point["step_mm"] for point in points] + responses = [point["response_px"] for point in points] + gain = _ols_slope(steps, responses) + floor_values = floors.get(axis) or [] + out["axes"].setdefault(axis, []).append( + { + "sigma": sigma, + "points": int(len(points)), + "gain_px_per_mm": gain, + "max_step_response_px": ( + float(np.mean([abs(value) for value in responses])) if responses else None + ), + "floor_px": float(np.median(floor_values)) if floor_values else None, + "floor_samples": int(len(floor_values)), + } + ) + if progress is not None: + gains = ", ".join( + f"{axis}: gain=" + + ( + "n/a" + if not out["axes"].get(axis) + else format(out["axes"][axis][-1]["gain_px_per_mm"] or 0.0, ".3f") + ) + for axis in sorted(axis_frames) + ) + progress(f" injected noise sigma={sigma:g}: {gains}") + return out + diff --git a/src/orisys_sensor_design/optimise.py b/src/orisys_sensor_design/optimise.py new file mode 100644 index 0000000..dde4b6a --- /dev/null +++ b/src/orisys_sensor_design/optimise.py @@ -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 diff --git a/src/orisys_sensor_design/probe_plot.py b/src/orisys_sensor_design/probe_plot.py new file mode 100644 index 0000000..c3541dc --- /dev/null +++ b/src/orisys_sensor_design/probe_plot.py @@ -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//*.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 `/frames/.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"), + } diff --git a/src/orisys_sensor_design/render.py b/src/orisys_sensor_design/render.py new file mode 100644 index 0000000..c9ce4ec --- /dev/null +++ b/src/orisys_sensor_design/render.py @@ -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 ``.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//`` (the same + revision naming ``texture_revision_rel`` produces); ``texture_dir`` + defaults to ``build/textures//``. 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//`` 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/.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", +] diff --git a/src/orisys_sensor_design/sensitivity.py b/src/orisys_sensor_design/sensitivity.py new file mode 100644 index 0000000..51a92aa --- /dev/null +++ b/src/orisys_sensor_design/sensitivity.py @@ -0,0 +1,2063 @@ +"""Probe sensitivity scoring and report. + +Reads the flow products produced by ``flow.py`` (``flow_report.json`` plus the +per-frame ``flow//.npy`` arrays) together with +``render_manifest.json``, scores every sweep axis per scale exactly as +``docs/probe-pipeline.md`` specifies, and writes ``sensitivity_report.json``. + +Pure Python + numpy: no ``cv2`` and no ``bpy`` import anywhere in this module, so +the scoring is unit-testable against synthetic flow arrays with a known injected +gain. + +Honesty about resolution is the point of the report. At the real 500x500 render +the probe scale is ~13.43 px/mm, so a 0.01 mm step is ~0.134 px: far below the +DIS estimator floor. The report therefore records ``displacement_px_per_step``, +``cell_px`` and ``cell_over_patch_px`` per scale and warns loudly instead of +printing fake precision. +""" + +from __future__ import annotations + +import json +import math +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Mapping, Sequence + +import numpy as np + +from orisys_sensor_design.manifest import RevisionPaths, load_yaml, repo_root, sha256_file +from orisys_sensor_design.texture import texture_revision_rel + +SENSITIVITY_REPORT_SCHEMA = "orisys-sensor-design/sensitivity-report-v2" +SENSITIVITY_REPORT_FILENAME = "sensitivity_report.json" +FLOW_REPORT_SCHEMA = "orisys-sensor-design/flow-report-v1" +RENDER_MANIFEST_SCHEMA = "orisys-sensor-design/probe-render-manifest-v1" +DEFORM_REPORT_SCHEMA = "orisys-sensor-design/probe-deform-report-v1" + +#: DIS patch size in pixels at the finest scale (``dis_preset: medium``). +DIS_PATCH_PX = 8.0 +PROBE_RELATIVE_ROOT = Path("build") / "probe" +NULL_FRAME_NAME = "reference_null" +EPS = 1e-12 + +#: Minimum probe-local pixel scale before ``gain_mm_per_mm`` is derived. +#: +#: Gain is primary in **px per mm** (``gain_px_per_mm``). The derived +#: ``gain_mm_per_mm`` divides by the probe-local, per-axis scale +#: (``render_manifest.probe_px_per_mm[axis]``); for an axis whose motion is nearly +#: along the view direction that scale approaches zero and the division would +#: manufacture a gain no camera could observe. Below this threshold the derived +#: value is ``null`` plus a warning -- structurally impossible to divide by ~0. +#: +#: Default 1.0, justified by the real run: the per-axis probe-local scale is +#: 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. 1.0 discriminates exactly right -- the +#: render z axis clears it and yields a legitimate mm/mm gain, while the process +#: z axis is gated to null. Deliberately a module constant, not a ``design.yaml`` +#: key (contract). +min_local_scale_px_per_mm = 1.0 +MIN_LOCAL_SCALE_PX_PER_MM = min_local_scale_px_per_mm + +#: Verdict ordering for the "worst axis wins" aggregation. ``ambiguous`` is the +#: weakest claim of all (the measurement is untrustworthy), so it has the lowest +#: severity; ``min`` over severities therefore implements the contract precedence +#: ``ambiguous > unresolvable > marginal > resolvable``. +VERDICT_SEVERITY = {"ambiguous": -1, "unresolvable": 0, "marginal": 1, "resolvable": 2} +RESOLVABLE_SNR = 3.0 + +__all__ = [ + "SENSITIVITY_REPORT_SCHEMA", + "SENSITIVITY_REPORT_FILENAME", + "DIS_PATCH_PX", + "min_local_scale_px_per_mm", + "MIN_LOCAL_SCALE_PX_PER_MM", + "SensitivityError", + "SensitivityConfigError", + "SensitivityDataError", + "MissingArtifactError", + "Roi", + "FrameFlow", + "AxisScore", + "ScaleScore", + "ScaleInput", + "score_axis", + "score_scale", + "build_report", + "collect_report", + "run_sensitivity", + "write_sensitivity_report", + "load_scale_inputs", + "default_probe_output_dir", + "resolve_probe_output_dir", + "resolution_pair", +] + + +# --------------------------------------------------------------------------- # +# Errors +# --------------------------------------------------------------------------- # + + +class SensitivityError(Exception): + """Base error for the sensitivity stage.""" + + +class SensitivityConfigError(SensitivityError): + """Invalid settings, an unsupported resource, or a bad scale definition.""" + + +class SensitivityDataError(SensitivityError): + """An input artifact is present but cannot be interpreted.""" + + +class MissingArtifactError(SensitivityError): + """A required input artifact is missing or empty (exit code 3).""" + + +# --------------------------------------------------------------------------- # +# Small numeric helpers +# --------------------------------------------------------------------------- # + + +def _utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat() + + +def _r(value: Any, nd: int = 6) -> Any: + """Round for a stable, byte-comparable JSON report (None stays None).""" + if value is None: + return None + if isinstance(value, (int, np.integer)) and not isinstance(value, bool): + return int(value) + try: + number = float(value) + except (TypeError, ValueError): + return value + if not math.isfinite(number): + return None + return round(number, nd) + + +def _clamp(value: float, low: float, high: float) -> float: + return max(low, min(high, value)) + + +def _norm(vec: np.ndarray) -> float: + return float(math.sqrt(float(vec[0]) * float(vec[0]) + float(vec[1]) * float(vec[1]))) + + +def _unit(vec: np.ndarray) -> tuple[float, float] | None: + length = _norm(vec) + if length <= EPS: + return None + return (float(vec[0]) / length, float(vec[1]) / length) + + +def _rank(values: Sequence[float]) -> np.ndarray: + """Average ranks (1-based), ties share the mean rank. Deterministic.""" + array = np.asarray(values, dtype=np.float64) + order = sorted(range(len(array)), key=lambda i: (float(array[i]), i)) + ranks = np.empty(len(array), dtype=np.float64) + index = 0 + while index < len(order): + stop = index + while stop + 1 < len(order) and float(array[order[stop + 1]]) == float(array[order[index]]): + stop += 1 + average = (index + stop) / 2.0 + 1.0 + for position in range(index, stop + 1): + ranks[order[position]] = average + index = stop + 1 + return ranks + + +def spearman_rank(xs: Sequence[float], ys: Sequence[float]) -> float: + """Spearman rank correlation, clamped to ``[0, 1]`` (contract step 6).""" + if len(xs) != len(ys) or len(xs) < 2: + return 0.0 + rx = _rank(xs) + ry = _rank(ys) + if float(np.std(rx)) <= EPS or float(np.std(ry)) <= EPS: + return 0.0 + mx = rx - rx.mean() + my = ry - ry.mean() + denominator = float(np.sqrt(float(np.sum(mx * mx)) * float(np.sum(my * my)))) + if denominator <= EPS: + return 0.0 + return _clamp(float(np.sum(mx * my)) / denominator, 0.0, 1.0) + + +def ols_slope(xs: Sequence[float], ys: Sequence[float]) -> float | None: + """Ordinary-least-squares slope of ``ys`` on ``xs`` (contract step 4).""" + if len(xs) != len(ys) or len(xs) < 2: + return None + x = np.asarray(xs, dtype=np.float64) + y = np.asarray(ys, dtype=np.float64) + xm = x - x.mean() + denominator = float(np.sum(xm * xm)) + if denominator <= EPS: + return None + return float(np.sum(xm * (y - y.mean())) / denominator) + + +# --------------------------------------------------------------------------- # +# Input model +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True) +class Roi: + """Circular ROI in image pixels.""" + + u: float + v: float + radius: float + + def mask(self, height: int, width: int) -> np.ndarray: + if not (self.radius > 0.0): + raise SensitivityConfigError( + f"ROI radius must be positive (got {self.radius!r})" + ) + dx = np.arange(width, dtype=np.float64)[None, :] - float(self.u) + dy = np.arange(height, dtype=np.float64)[:, None] - float(self.v) + return (dx * dx + dy * dy) <= float(self.radius) * float(self.radius) + + def touches_border(self, height: int, width: int) -> bool: + return ( + self.u - self.radius < 0.0 + or self.v - self.radius < 0.0 + or self.u + self.radius > width - 1 + or self.v + self.radius > height - 1 + ) + + +@dataclass(frozen=True) +class FrameFlow: + """One frame's ROI and its dense flow field, shape ``(H, W, 2)``.""" + + index: int + name: str + axis: str | None + step_mm: float + roi: Roi | None + flow: np.ndarray | None + centroid_px: tuple[float, float] | None = None + + +@dataclass(frozen=True) +class ScaleInput: + """Everything needed to score one scale.""" + + name: str + width: int + height: int + px_per_mm: float + frames: tuple[FrameFlow, ...] + null_flow: np.ndarray | None = None + null_floor_px_reported: float | None = None + step_mm: float | None = None + #: Probe-local, per-axis projected scale (px per mm) for this scale, from + #: ``render_manifest.probe_px_per_mm`` rescaled to this scale's pixel grid. + local_scale_px_per_mm: Mapping[str, float] | None = None + #: ``"per_axis"`` / ``"p50_fallback"`` / ``"unavailable"``. + local_scale_source: str = "unavailable" + #: ``px_per_mm / manifest_reference``: converts a render-scale measurement + #: (``render_manifest.frames[].centroid_px``) into this scale's pixel grid. + manifest_scale_factor: float = 1.0 + #: Mesh-wide pixel scale, recorded for context only -- never a gain divisor. + mesh_px_per_mm: float | None = None + #: ``flow_report.json``'s ``reference_texture_known_answer`` for this scale. + known_answer: Mapping[str, Any] | None = None + + +# --------------------------------------------------------------------------- # +# Scoring (contract "Scoring" section, steps 1-10) +# --------------------------------------------------------------------------- # + + +@dataclass(frozen=True) +class AxisScore: + axis: str + direction_px: tuple[float, float] + direction_source: str + floor_px: float + floor_source: str + points: tuple[dict[str, Any], ...] + gain_px_per_mm: float | None + gain_mm_per_mm: float | None + scale_px_per_mm: float | None + gain_gate: str + snr: float + spearman: float + resolvable_step_mm: float | None + saturation_mm: float | None + cross_axis_leak: float + score: float + verdict: str + px_per_mm_axis: float | None = None + ambiguous: bool = False + + @property + def points_above_floor(self) -> int: + return sum(1 for point in self.points if point["above_floor"]) + + def to_json(self) -> dict[str, Any]: + return { + "axis": self.axis, + "direction_px": [_r(self.direction_px[0]), _r(self.direction_px[1])], + "direction_source": self.direction_source, + "points": [dict(point) for point in self.points], + "summary": { + "gain_px_per_mm": _r(self.gain_px_per_mm), + "gain_mm_per_mm": _r(self.gain_mm_per_mm), + "scale_px_per_mm": _r(self.scale_px_per_mm), + "gain_gate": self.gain_gate, + "ambiguous": bool(self.ambiguous), + "snr": _r(self.snr), + "spearman": _r(self.spearman), + "resolvable_step_mm": _r(self.resolvable_step_mm), + "saturation_mm": _r(self.saturation_mm), + "cross_axis_leak": _r(self.cross_axis_leak), + "score": _r(self.score), + "verdict": self.verdict, + "floor_px": _r(self.floor_px), + "floor_source": self.floor_source, + "px_per_mm_axis": _r(self.px_per_mm_axis), + "points_above_floor": self.points_above_floor, + "points_total": len(self.points), + }, + } + + +@dataclass(frozen=True) +class ScaleScore: + name: str + width: int + height: int + px_per_mm: float + displacement_px_per_step: float + cell_px: float | None + cell_over_patch_px: float | None + null_floor_px: float | None + null_floor_reported_px: float | None + axes: tuple[AxisScore, ...] + score: float + verdict: str + roi_truncated: bool = False + mesh_px_per_mm: float | None = None + local_scale_source: str = "unavailable" + ambiguous: bool = False + known_answer: Mapping[str, Any] | None = None + #: Camera-space marker cell, ``width / nx``. The pattern grid is defined across + #: the camera image, so this is the cell the estimator actually sees; the + #: millimetre cell is physical and position-dependent on a curved surface. + cell_px_source: str | None = None + #: ``patch_px * 2**finest_scale`` -- the correlation window in *input* pixels. + #: ``dis_mode=1`` means ``setFinestScale(1)``, so DIS computes at half resolution + #: and an 8 px patch spans 16 px of the input image. + effective_patch_px: float | None = None + cell_over_effective_patch_px: float | None = None + #: The previous (mm-cell x probe-local-scale) value, kept for continuity. + nominal_cell_px: float | None = None + reference_width_px: int | None = None + #: Per-axis signal and floor measured under the injected noise model, with the + #: clean gain's retention. This is the noise-aware evidence; `floor_px` above comes + #: from the render seed and is kept only as a reproducibility diagnostic. + noise_models: Mapping[str, Any] | None = None + + def axis(self, name: str) -> AxisScore | None: + for entry in self.axes: + if entry.axis == name: + return entry + return None + + def to_json(self) -> dict[str, Any]: + return { + "name": self.name, + "width": self.width, + "height": self.height, + "px_per_mm": _r(self.px_per_mm), + "mesh_px_per_mm": _r(self.mesh_px_per_mm), + "mm_per_px": _r(1.0 / self.px_per_mm if self.px_per_mm else None), + "displacement_px_per_step": _r(self.displacement_px_per_step), + "cell_px": _r(self.cell_px), + "cell_px_source": self.cell_px_source, + "nominal_cell_px": _r(self.nominal_cell_px), + "reference_width_px": self.reference_width_px, + "effective_patch_px": _r(self.effective_patch_px), + "cell_over_patch_px": _r(self.cell_over_patch_px), + "cell_over_effective_patch_px": _r(self.cell_over_effective_patch_px), + "noise_models": self.noise_models, + "null_floor_px": _r(self.null_floor_px), + "null_floor_reported_px": _r(self.null_floor_reported_px), + "roi_truncated": bool(self.roi_truncated), + "local_scale_source": self.local_scale_source, + "ambiguous": bool(self.ambiguous), + "axes": [entry.to_json() for entry in self.axes], + "score": _r(self.score), + "verdict": self.verdict, + } + + +def _mean_dot(flow: np.ndarray, direction: tuple[float, float], mask: np.ndarray) -> float: + projection = flow[..., 0] * direction[0] + flow[..., 1] * direction[1] + return float(np.mean(projection[mask])) + + +def _mean_abs_dot(flow: np.ndarray, direction: tuple[float, float], mask: np.ndarray) -> float: + projection = np.abs(flow[..., 0] * direction[0] + flow[..., 1] * direction[1]) + return float(np.mean(projection[mask])) + + +def _mean_vector(flow: np.ndarray, mask: np.ndarray) -> np.ndarray: + return np.array( + [float(np.mean(flow[..., 0][mask])), float(np.mean(flow[..., 1][mask]))], + dtype=np.float64, + ) + + +def _resolve_direction( + frames: Sequence[FrameFlow], + axis_frames: Sequence[FrameFlow], + explicit: Sequence[float] | None, +) -> tuple[tuple[float, float], str, float | None]: + """Contract step 1: unit image-space direction of the applied displacement. + + Preference order, so every reported direction is *measured*: + + 1. ``"provided"`` -- an explicitly supplied measurement; + 2. ``"centroid_regression"`` -- OLS slope of ``centroid_px`` on ``step_mm`` + over every frame of this axis (the reference is the step-0 point), which is + preferred because the whole sweep is only ~1.3 px at 500x500 and a two-point + difference there is a rounding argument; + 3. ``"centroid_finite_difference"`` -- the two-point difference between the + step-0 reference and the ``+max_mm`` frame, when the regression is + degenerate (fewer than two distinct steps, or a zero slope); + 4. ``"roi_mean_flow_at_max_step"`` -- the ROI-mean flow vector at ``+max_mm``, + used only when ``centroid_px`` is absent from the manifest entirely. + + Returns ``(direction, source, px_per_mm_axis)``. ``px_per_mm_axis`` is the + *measured projected scale* for this axis (slope magnitude, px per mm) when the + camera projection supplied one: the contract scores gain against the single + per-scale ``px_per_mm``, so recording this makes a per-axis projection + difference (e.g. the nearly view-axis z sweep) visible instead of hidden. + + A per-frame ``roi.u/v`` is deliberately never used: the contract fixes the ROI + at the reference centroid so the ROI mean is not bias-corrected as the probe + moves. + """ + if explicit is not None: + unit = _unit(np.asarray(explicit, dtype=np.float64)) + if unit is None: + raise SensitivityConfigError("explicit displacement direction is degenerate") + return unit, "provided", None + + reference = next( + (frame for frame in frames if frame.step_mm == 0.0 and frame.centroid_px is not None), + None, + ) + + # 2. Regress centroid_px on step_mm over every frame of the axis. + samples: dict[float, tuple[float, float]] = {} + if reference is not None: + samples[0.0] = reference.centroid_px + for frame in axis_frames: + if frame.centroid_px is not None: + samples.setdefault(float(frame.step_mm), frame.centroid_px) + if len(samples) >= 2: + steps = sorted(samples) + us = [samples[step][0] for step in steps] + vs = [samples[step][1] for step in steps] + slope_u = ols_slope(steps, us) + slope_v = ols_slope(steps, vs) + if slope_u is not None and slope_v is not None: + slope = np.array([slope_u, slope_v], dtype=np.float64) + unit = _unit(slope) + if unit is not None: + return unit, "centroid_regression", _norm(slope) + + # 3. Two-point finite difference, step 0 -> +max_mm. + far = max( + (frame for frame in axis_frames if frame.centroid_px is not None), + key=lambda frame: frame.step_mm, + default=None, + ) + if reference is not None and far is not None: + difference = np.asarray(far.centroid_px, dtype=np.float64) - np.asarray( + reference.centroid_px, dtype=np.float64 + ) + unit = _unit(difference) + if unit is not None: + magnitude = _norm(difference) / far.step_mm if far.step_mm > 0.0 else None + return unit, "centroid_finite_difference", magnitude + + # 4. centroid_px absent entirely: use the estimator's own displacement at +max_mm. + far = max(axis_frames, key=lambda frame: frame.step_mm) + if far.flow is not None and far.roi is not None: + mask = far.roi.mask(int(far.flow.shape[0]), int(far.flow.shape[1])) + unit = _unit(_mean_vector(far.flow, mask)) + if unit is not None: + return unit, "roi_mean_flow_at_max_step", None + + return (1.0, 0.0), "unmeasurable_fallback_x", None + + +def _validate_frame(frame: FrameFlow) -> None: + if frame.flow is None: + raise SensitivityDataError(f"frame {frame.name!r} has no flow array") + array = np.asarray(frame.flow) + if array.ndim != 3 or array.shape[2] != 2: + raise SensitivityDataError( + f"frame {frame.name!r} flow must have shape (H, W, 2), got {array.shape}" + ) + if frame.roi is None: + raise SensitivityDataError(f"frame {frame.name!r} has no ROI") + + +def _known_answer_failed(known_answer: Mapping[str, Any] | None) -> bool: + """True only when ``reference_texture_known_answer`` explicitly failed. + + A missing block is *not* a failure; the contract triggers ``ambiguous`` on + ``passed == false`` only. + """ + if not isinstance(known_answer, Mapping) or "passed" not in known_answer: + return False + return not bool(known_answer.get("passed")) + + +def _known_answer_detail(known_answer: Mapping[str, Any] | None) -> str: + """Summarise the measured-vs-expected shifts for the aliasing warning.""" + if not isinstance(known_answer, Mapping): + return "no detail recorded" + cases = known_answer.get("cases") + if not isinstance(cases, list) or not cases: + return "no cases recorded" + parts: list[str] = [] + for case in cases[:4]: + if not isinstance(case, Mapping): + continue + expected_u = case.get("expected_u", case.get("dx")) + expected_v = case.get("expected_v", case.get("dy")) + measured_u = case.get("measured_u") + measured_v = case.get("measured_v") + parts.append( + f"expected (u,v)=({_r(expected_u)},{_r(expected_v)}) " + f"measured (u,v)=({_r(measured_u)},{_r(measured_v)})" + ) + return "; ".join(parts) if parts else "no cases recorded" + + +def score_axis( + frames: Sequence[FrameFlow], + axis: str, + *, + px_per_mm: float, + null_flow: np.ndarray | None = None, + null_floor_px: float | None = None, + direction_px: Sequence[float] | None = None, + scale_px_per_mm: float | None = None, + min_scale_px_per_mm: float = min_local_scale_px_per_mm, + ambiguous: bool = False, + manifest_scale_factor: float = 1.0, +) -> AxisScore: + """Score one scale/axis pair. Pure: no filesystem, no global state. + + ``frames`` is the full per-scale frame list (it may include the step-0 + reference so the centroid regression is available); only the positive steps of + ``axis`` are scored, in increasing ``step_mm`` order. + + ``px_per_mm`` is the scale's mesh-wide scalar (used only to express the + sub-pixel applied displacement and the marker cell in pixels). + ``scale_px_per_mm`` is the **probe-local** per-axis scale: the only legitimate + divisor for the derived ``gain_mm_per_mm``, and only when it clears + ``min_scale_px_per_mm``. + """ + if not (px_per_mm > 0.0): + raise SensitivityConfigError(f"px_per_mm must be positive (got {px_per_mm!r})") + + axis_frames = sorted( + (frame for frame in frames if frame.axis == axis and frame.step_mm > 0.0), + key=lambda frame: frame.step_mm, + ) + if not axis_frames: + raise SensitivityConfigError(f"scale has no positive steps for axis {axis!r}") + for frame in axis_frames: + _validate_frame(frame) + + direction, direction_source, px_per_mm_axis = _resolve_direction( + frames, axis_frames, direction_px + ) + # `render_manifest.frames[].centroid_px` is recorded at the render resolution. + # Rescale the measured slope to *this* scale's pixel grid so every per-axis + # number inside one `scales[]` entry means the same thing (and so `applied_px` + # is per-axis rather than the isotropic scalar). + if px_per_mm_axis is not None: + px_per_mm_axis = px_per_mm_axis * float(manifest_scale_factor) + applied_px_per_mm = px_per_mm_axis if px_per_mm_axis is not None else px_per_mm + + # Contract step 3: floor from the null frame, per axis, in px. + measured_floor: float | None = None + if null_flow is not None: + array = np.asarray(null_flow) + if array.ndim != 3 or array.shape[2] != 2: + raise SensitivityDataError( + f"null flow must have shape (H, W, 2), got {array.shape}" + ) + reference = next((f for f in frames if f.step_mm == 0.0 and f.roi is not None), None) + roi = reference.roi if reference is not None else axis_frames[0].roi + mask = roi.mask(int(array.shape[0]), int(array.shape[1])) + projection = np.abs(array[..., 0] * direction[0] + array[..., 1] * direction[1]) + values = projection[mask] + measured_floor = float(np.percentile(values, 95.0)) if values.size else 0.0 + if measured_floor is not None: + floor_px = measured_floor + floor_source = "null_projection_p95" + elif null_floor_px is not None: + # The flow report's scalar is p95 of |flow| magnitude (no direction): a + # different quantity, used only when the null array is unavailable. + floor_px = float(null_floor_px) + floor_source = "reported_magnitude_p95_fallback" + else: + floor_px = 0.0 + floor_source = "unavailable_zero" + floor_px = max(0.0, floor_px) + + applied_mm = np.array([frame.step_mm for frame in axis_frames], dtype=np.float64) + response_px = np.array( + [ + _mean_dot(np.asarray(frame.flow), direction, frame.roi.mask( + int(np.asarray(frame.flow).shape[0]), int(np.asarray(frame.flow).shape[1]) + )) + for frame in axis_frames + ], + dtype=np.float64, + ) + above = np.abs(response_px) > floor_px + + # Contract step 4: primary gain in px of response per mm applied. + selected = np.nonzero(above)[0] + gain_px_per_mm = ( + ols_slope(applied_mm[selected], response_px[selected]) + if selected.size >= 3 + else None + ) + + # Contract step 5: derive mm/mm only when the probe-local scale is usable. + if gain_px_per_mm is None: + gain_mm_per_mm = None + gain_gate = "no_gain" + elif scale_px_per_mm is None: + gain_mm_per_mm = None + gain_gate = "scale_unknown" + elif scale_px_per_mm < min_scale_px_per_mm: + gain_mm_per_mm = None + gain_gate = "below_min_local_scale" + else: + gain_mm_per_mm = gain_px_per_mm / scale_px_per_mm + gain_gate = "derived" + + # Contract step 6: snr, both terms in px. + max_mm = float(applied_mm.max()) + snr = ( + abs(gain_px_per_mm) * max_mm / max(floor_px, EPS) + if gain_px_per_mm is not None + else 0.0 + ) + + # Contract step 7: Spearman over all steps, clamped to [0, 1]. + spearman = spearman_rank(applied_mm, response_px) + + # Contract step 8: cross-axis leak over the ROI and steps, clamped [0, 1]. + # A zero in-plane response with non-zero perpendicular motion is an infinite + # ratio, which clamps to 1 rather than being reported as no leak. + perpendicular = (-direction[1], direction[0]) + numerator = 0.0 + denominator = 0.0 + for frame in axis_frames: + flow = np.asarray(frame.flow) + mask = frame.roi.mask(int(flow.shape[0]), int(flow.shape[1])) + numerator += _mean_abs_dot(flow, perpendicular, mask) + denominator += _mean_abs_dot(flow, direction, mask) + if denominator > EPS: + leak = _clamp(numerator / denominator, 0.0, 1.0) + elif numerator > EPS: + leak = 1.0 + else: + leak = 0.0 + + # Contract step 9: score. + score = snr * spearman * (1.0 - min(leak, 0.5)) + + # Contract step 10: smallest resolvable step, else null. + resolvable_step = float(applied_mm[selected][0]) if selected.size else None + + # Contract step 11: first decrease after a rise, else null. + saturation: float | None = None + rose = False + for index in range(1, len(response_px)): + if response_px[index] > response_px[index - 1]: + rose = True + elif response_px[index] < response_px[index - 1] and rose: + saturation = float(applied_mm[index]) + break + + # Contract verdict, in precedence order: an untrustworthy (aliased) scale is + # `ambiguous` before any score can promote it; then the px/mm gain thresholds. + if ambiguous: + verdict = "ambiguous" + elif gain_px_per_mm is not None and snr >= RESOLVABLE_SNR: + verdict = "resolvable" + elif gain_px_per_mm is not None: + verdict = "marginal" + else: + verdict = "unresolvable" + + points = tuple( + { + "step_mm": _r(applied_mm[index]), + # Per-axis applied displacement in px: the isotropic scalar would + # overstate z (1.519 px/mm) by ~8.7x at the render scale. + "applied_px": _r(applied_mm[index] * applied_px_per_mm), + "response_px": _r(response_px[index]), + "above_floor": bool(above[index]), + } + for index in range(len(axis_frames)) + ) + + return AxisScore( + axis=axis, + direction_px=direction, + direction_source=direction_source, + floor_px=floor_px, + floor_source=floor_source, + points=points, + gain_px_per_mm=gain_px_per_mm, + gain_mm_per_mm=gain_mm_per_mm, + scale_px_per_mm=scale_px_per_mm, + gain_gate=gain_gate, + snr=snr, + spearman=spearman, + resolvable_step_mm=resolvable_step, + saturation_mm=saturation, + cross_axis_leak=leak, + score=score, + verdict=verdict, + px_per_mm_axis=px_per_mm_axis, + ambiguous=bool(ambiguous), + ) + + +def cell_metrics( + *, + width: int, + nx: int | None = None, + cell_mm: Sequence[float] | None = None, + px_per_mm: float | None = None, + patch_px: float = DIS_PATCH_PX, + finest_scale: int = 0, +) -> dict[str, Any]: + """Marker-cell metrics for one scale, pure and frame-independent. + + The pattern grid is authored **across the camera image**, so the cell in pixels + is ``width / nx`` and needs no millimetre conversion. Deriving it instead from + the physical page cell times the probe-local ``px_per_mm`` understated every cell + by about a factor of two -- the probe-local scale is ~13.2 px/mm while the + camera-grid scale is ``width / vector_width_mm`` ~27.0 px/mm -- which made a + 16.1 px cell look like 7.9 px and hid the rule that actually governs sensitivity. + + ``effective_patch_px`` is ``patch_px * 2**finest_scale``: OpenCV's + ``setFinestScale`` selects the finest **pyramid level** the flow is computed on + (zero is the original resolution) and the result is bilinearly upscaled, so an + 8 px patch at ``finest_scale=1`` spans 16 px of the input image. That product, + not the nominal patch, is the window the marker cell must match. + + ``nominal_cell_px`` keeps the old millimetre-derived value so an older report can + be compared; it is never the governing cell. + """ + effective_patch_px: float | None = None + if patch_px > 0.0: + effective_patch_px = float(patch_px) * float(2 ** max(0, int(finest_scale))) + + cell_px: float | None = None + cell_px_source: str | None = None + nominal_cell_px: float | None = None + if nx: + cell_px = float(width) / float(nx) + cell_px_source = "render_width/nx (camera-defined grid)" + if cell_mm and len(cell_mm) >= 1 and cell_mm[0] is not None and px_per_mm: + nominal_cell_px = float(cell_mm[0]) * float(px_per_mm) + if cell_px is None: + cell_px = nominal_cell_px + cell_px_source = "cell_mm x probe-local px_per_mm (fallback: no grid)" + + cell_over_patch: float | None = None + cell_over_effective_patch: float | None = None + if cell_px is not None and patch_px > 0.0: + cell_over_patch = cell_px / patch_px + if effective_patch_px: + cell_over_effective_patch = cell_px / effective_patch_px + + return { + "cell_px": cell_px, + "cell_px_source": cell_px_source, + "nominal_cell_px": nominal_cell_px, + "reference_width_px": int(width), + "effective_patch_px": effective_patch_px, + "cell_over_patch_px": cell_over_patch, + "cell_over_effective_patch_px": cell_over_effective_patch, + } + + +def score_scale( + scale: ScaleInput, + *, + patch_px: float = DIS_PATCH_PX, + finest_scale: int = 0, + grid: Sequence[int] | None = None, + cell_mm: Sequence[float] | None = None, + directions_px: Mapping[str, Sequence[float]] | None = None, + local_scale_px_per_mm: Mapping[str, float] | None = None, + min_scale_px_per_mm: float = min_local_scale_px_per_mm, + ambiguous: bool | None = None, +) -> ScaleScore: + """Score every axis of one scale and aggregate it (pure function). + + ``local_scale_px_per_mm`` overrides ``scale.local_scale_px_per_mm``: the + probe-local, per-axis pixel scale that gates ``gain_mm_per_mm``. + ``ambiguous`` overrides the ``reference_texture_known_answer`` verdict derived + from ``scale.known_answer``. + + ``grid`` is the marker grid ``(nx, ny)``. The pattern is defined across the + camera image, so ``nx`` gives the camera-space cell ``width / nx`` directly; the + millimetre cell is a physical measurement whose scale varies across a curved + surface and must not be used as the pixel cell. + """ + if not (scale.px_per_mm > 0.0): + raise SensitivityConfigError( + f"scale {scale.name!r}: px_per_mm must be positive (got {scale.px_per_mm!r})" + ) + + if ambiguous is None: + ambiguous = _known_answer_failed(scale.known_answer) + + scales_by_axis = dict( + local_scale_px_per_mm + if local_scale_px_per_mm is not None + else (scale.local_scale_px_per_mm or {}) + ) + + axes: list[AxisScore] = [] + seen: list[str] = [] + for frame in scale.frames: + if frame.axis and frame.axis not in seen and any( + other.axis == frame.axis and other.step_mm > 0.0 for other in scale.frames + ): + seen.append(frame.axis) + if not seen: + raise SensitivityConfigError(f"scale {scale.name!r} has no sweep axes") + + for axis in seen: + axes.append( + score_axis( + scale.frames, + axis, + px_per_mm=scale.px_per_mm, + null_flow=scale.null_flow, + null_floor_px=scale.null_floor_px_reported, + direction_px=(directions_px or {}).get(axis), + scale_px_per_mm=scales_by_axis.get(axis), + min_scale_px_per_mm=min_scale_px_per_mm, + ambiguous=bool(ambiguous), + manifest_scale_factor=float(scale.manifest_scale_factor), + ) + ) + + score = float(np.mean([axis.score for axis in axes])) + verdict = min((axis.verdict for axis in axes), key=lambda name: VERDICT_SEVERITY[name]) + + step_mm = scale.step_mm + if step_mm is None: + positive = [frame.step_mm for frame in scale.frames if frame.step_mm > 0.0] + step_mm = min(positive) if positive else 0.0 + + # --- marker cell, in the space the pattern is actually defined in ----------- + metrics = cell_metrics( + width=scale.width, + nx=(int(grid[0]) if grid and len(grid) >= 1 and grid[0] else None), + cell_mm=cell_mm, + px_per_mm=scale.px_per_mm, + patch_px=patch_px, + finest_scale=finest_scale, + ) + + floors = [axis.floor_px for axis in axes] + roi_truncated = any( + frame.roi is not None and frame.roi.touches_border(scale.height, scale.width) + for frame in scale.frames + ) + + return ScaleScore( + name=scale.name, + width=scale.width, + height=scale.height, + px_per_mm=scale.px_per_mm, + displacement_px_per_step=float(step_mm) * scale.px_per_mm, + cell_px=metrics["cell_px"], + cell_px_source=metrics["cell_px_source"], + nominal_cell_px=metrics["nominal_cell_px"], + reference_width_px=metrics["reference_width_px"], + effective_patch_px=metrics["effective_patch_px"], + cell_over_patch_px=metrics["cell_over_patch_px"], + cell_over_effective_patch_px=metrics["cell_over_effective_patch_px"], + null_floor_px=max(floors) if floors else None, + null_floor_reported_px=scale.null_floor_px_reported, + axes=tuple(axes), + score=score, + verdict=verdict, + roi_truncated=roi_truncated, + mesh_px_per_mm=scale.mesh_px_per_mm, + local_scale_source=scale.local_scale_source, + ambiguous=bool(ambiguous), + known_answer=scale.known_answer, + ) + + +# --------------------------------------------------------------------------- # +# Report assembly +# --------------------------------------------------------------------------- # + + +def _resolution_warnings(scales: Sequence[ScaleScore], step_mm: float) -> list[str]: + warnings: list[str] = [] + for scale in scales: + if scale.ambiguous: + warnings.append( + f"reference_texture_known_answer FAILED at {scale.name}: " + f"{_known_answer_detail(scale.known_answer)}. A known shift applied to " + "the actual rendered marker was not recovered, which is what a periodic " + "pattern does once its cell approaches the DIS patch (the shift aliases " + "onto the period). The measured direction and magnitude are " + "untrustworthy, so every axis is forced to 'ambiguous' regardless of " + "how good the score looks" + ) + if scale.roi_truncated: + warnings.append( + f"the probe ROI is not fully inside the {scale.width}x{scale.height} " + f"{scale.name} frame; it was truncated and the score uses only the " + "pixels actually inside" + ) + if scale.cell_px is not None and scale.cell_px < 2.0: + warnings.append( + f"marker cell is {scale.cell_px:.3g} px at {scale.name} " + f"(camera-space: {scale.reference_width_px} / nx); below 2 px a cell " + "cannot be represented at all, so neither the sensor nor the estimator " + "can use it" + ) + if ( + scale.cell_over_effective_patch_px is not None + and scale.effective_patch_px + and not (0.4 <= scale.cell_over_effective_patch_px <= 2.5) + ): + warnings.append( + f"marker cell is {scale.cell_px:.3g} px at {scale.name} against an " + f"effective DIS window of {scale.effective_patch_px:.3g} px " + f"(patch {DIS_PATCH_PX:g} px x 2**finest_scale), a ratio of " + f"{scale.cell_over_effective_patch_px:.2f}. The measured render-scale " + "optimum sits near a ratio of about 1 (cell matching the level-1 " + "window); a ratio well below 1 means the marker is finer than the " + "window can use and sensitivity is being thrown away" + ) + if scale.cell_px is not None and scale.nominal_cell_px not in (None, scale.cell_px): + warnings.append( + f"the millimetre-derived cell ({scale.nominal_cell_px:.3g} px at the " + f"probe-local scale) differs from the camera-space cell " + f"({scale.cell_px:.3g} px); the camera-space value governs because the " + "pattern grid is defined across the camera image" + ) + if scale.displacement_px_per_step < 1.0: + # "Below one pixel" and "below the estimator floor" are different + # statements: report the step and the floor in px and let the reader + # compare them rather than asserting the step is unmeasurable. + per_axis = "; ".join( + f"{axis.axis}: floor {axis.floor_px:.4g} px, " + + ( + f"smallest resolvable step {axis.resolvable_step_mm:g} mm" + if axis.resolvable_step_mm is not None + else "no step clears the floor" + ) + for axis in scale.axes + ) + warnings.append( + f"step_mm={step_mm:g} mm is {scale.displacement_px_per_step:.4g} px at " + f"{scale.name} ({scale.px_per_mm:.4g} px/mm), i.e. below one pixel. That " + "is a per-pixel precision limit, which is a different statement from the " + "estimator floor; the measured zero-input floor and the step in pixels " + f"are: {per_axis}" + ) + if scale.local_scale_source == "p50_fallback": + warnings.append( + f"render_manifest.probe_px_per_mm carries no per-axis keys at " + f"{scale.name}; the probe-local p50 was applied to every axis, so " + "gain_mm_per_mm is not axis-specific (gain_px_per_mm is unaffected)" + ) + for axis in scale.axes: + peak = max((abs(point["response_px"]) for point in axis.points), default=0.0) + if axis.floor_source == "reported_magnitude_p95_fallback": + warnings.append( + f"the null flow array was unavailable at {scale.name}: axis " + f"{axis.axis} falls back to the reported |flow| magnitude p95, which " + "is not the contract's directional projection" + ) + if axis.direction_source == "unmeasurable_fallback_x": + warnings.append( + f"no displacement direction could be measured on axis {axis.axis} at " + f"{scale.name}; the report falls back to +x and the axis is not " + "interpretable" + ) + if axis.gain_gate == "below_min_local_scale": + warnings.append( + f"axis {axis.axis} at {scale.name} has a probe-local scale of " + f"{axis.scale_px_per_mm:.4g} px/mm, below " + f"min_local_scale_px_per_mm={min_local_scale_px_per_mm:g}: " + "gain_mm_per_mm is null rather than dividing by a near-zero scale " + f"(gain_px_per_mm={_r(axis.gain_px_per_mm)} px/mm is still reported)" + ) + elif axis.gain_gate == "scale_unknown": + warnings.append( + f"axis {axis.axis} at {scale.name} has no measured probe-local " + "pixel scale (render_manifest.probe_px_per_mm); gain_mm_per_mm is null" + ) + if axis.resolvable_step_mm is None: + warnings.append( + f"no step is resolvable on axis {axis.axis} at {scale.name}: every " + f"|response| is within the {axis.floor_px:.4g} px estimator floor" + ) + if peak <= axis.floor_px: + warnings.append( + f"the null floor ({axis.floor_px:.4g} px) exceeds the full " + f"{step_mm * len(axis.points):.4g} mm response on axis {axis.axis} at " + f"{scale.name} (peak |response| {peak:.4g} px)" + ) + return warnings + + +def _axis_scale_notes(scales: Sequence[ScaleScore]) -> list[str]: + """Cross-check the manifest's probe-local scale against the measured one.""" + notes: list[str] = [] + for scale in scales: + for axis in scale.axes: + if axis.px_per_mm_axis is None or axis.scale_px_per_mm is None: + continue + if not (axis.scale_px_per_mm > 0.0): + continue + ratio = axis.px_per_mm_axis / axis.scale_px_per_mm + if ratio > 1.25 or ratio < 0.8: + notes.append( + f"axis {axis.axis} at {scale.name}: the measured centroid slope is " + f"{axis.px_per_mm_axis:.4g} px/mm while " + f"render_manifest.probe_px_per_mm[{axis.axis}] is " + f"{axis.scale_px_per_mm:.4g} px/mm (x{ratio:.3g}); the derived " + "gain_mm_per_mm divides by the manifest value per the contract" + ) + return notes + + +def _resolution_notes(scales: Sequence[ScaleScore], step_mm: float) -> list[str]: + notes = [ + "Sensitivity is a relative ranking measured under a fixed estimator (DIS) and " + "camera; it is not a force calibration. Compare patterns, not absolute values.", + ] + for scale in scales: + if scale.displacement_px_per_step > 0.0: + mm_per_px = 1.0 / scale.px_per_mm + sweep_px = scale.displacement_px_per_step * max( + (len(axis.points) for axis in scale.axes), default=0 + ) + smallest = ", ".join( + f"{axis.axis} {axis.resolvable_step_mm:g} mm" + if axis.resolvable_step_mm is not None + else f"{axis.axis} none" + for axis in scale.axes + ) + notes.append( + f"at {scale.name} ({scale.width}x{scale.height}) 1 px = {mm_per_px:.5g} mm; " + f"step_mm={step_mm:g} mm = {scale.displacement_px_per_step:.4g} px and the " + f"full sweep is {sweep_px:.4g} px. The per-pixel motion is sub-pixel, but " + "the ROI mean is what is measured, so compare each axis's floor_px with " + "the step in px rather than assuming the step is unmeasurable: the " + f"smallest step clearing the measured floor is {smallest}." + ) + break + # Measured limits (docs/probe-pipeline.md, "What actually limits the + # measurement"): the deficit is measurement-region resolution, not noise, + # aliasing or a non-uniform flow, and it is confounded with the ROI size. + notes.append( + "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 therefore valid only while radius_mm, the ROI and the scale are held fixed; " + "run_optimise varies nx/ny/seed and never the radius for exactly this reason." + ) + notes.append( + "The render-scale deficit is spatial resolution of the measurement region, 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 only " + "synthetic periodic textures alias onto the DIS period. The coherent motion is " + "measured at about 0.67x of the applied displacement." + ) + return notes + + +def build_report( + *, + revision: str, + texture: Mapping[str, Any], + probe: Mapping[str, Any], + tracker: Mapping[str, Any], + scales: Sequence[ScaleScore], + step_mm: float, + warnings: Sequence[str] | None = None, + notes: Sequence[str] | None = None, + noise_calibration: Mapping[str, Any] | None = None, +) -> dict[str, Any]: + """Assemble ``sensitivity_report.json`` from scored scales.""" + if not scales: + raise SensitivityConfigError("no scales were scored") + + collected_warnings = list(warnings or []) + _resolution_warnings(scales, step_mm) + collected_notes = list(notes or []) + _resolution_notes(scales, step_mm) + + axis_pairs = [ + (scale, axis) for scale in scales for axis in scale.axes + ] + overall_score = float(np.mean([axis.score for _, axis in axis_pairs])) + overall_verdict = min( + (axis.verdict for _, axis in axis_pairs), + key=lambda name: VERDICT_SEVERITY[name], + ) + best_scale = max(scales, key=lambda scale: scale.score) + best_axis_pair = max(axis_pairs, key=lambda pair: pair[1].score) + + report: dict[str, Any] = { + "schema": SENSITIVITY_REPORT_SCHEMA, + "generated_at": _utc_now(), + "revision": revision, + "texture": dict(texture), + "probe": dict(probe), + "tracker": dict(tracker), + "scales": [scale.to_json() for scale in scales], + "overall": { + "score": _r(overall_score), + "verdict": overall_verdict, + "best_scale": best_scale.name, + "best_axis": best_axis_pair[1].axis, + }, + "warnings": collected_warnings, + "notes": collected_notes, + } + if noise_calibration is not None: + report["noise_calibration"] = dict(noise_calibration) + return report + + +def write_sensitivity_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 + + +# --------------------------------------------------------------------------- # +# Artifact loading +# --------------------------------------------------------------------------- # + + +def _load_json(path: Path) -> dict[str, Any]: + path = Path(path) + if not path.is_file() or path.stat().st_size == 0: + raise MissingArtifactError(f"required artifact missing or empty: {path}") + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise SensitivityDataError(f"could not read {path}: {exc}") from exc + if not isinstance(data, dict): + raise SensitivityDataError(f"expected a JSON object in {path}") + return data + + +def _roi_from_mapping(raw: Any) -> Roi | None: + if not isinstance(raw, Mapping): + return None + if raw.get("u") is None or raw.get("v") is None: + return None + radius = raw.get("radius") + if radius is None: + return None + return Roi(u=float(raw["u"]), v=float(raw["v"]), radius=float(radius)) + + +def _centroid_from_mapping(raw: Mapping[str, Any]) -> tuple[float, float] | None: + for key in ("centroid_px", "probe_centroid_px", "projected_centroid_px"): + value = raw.get(key) + if isinstance(value, (list, tuple)) and len(value) >= 2: + return (float(value[0]), float(value[1])) + return None + + +def _scale_px_per_mm( + entry: Mapping[str, Any], manifest: Mapping[str, Any], width: int +) -> float: + value = entry.get("px_per_mm") + if value is not None: + return float(value) + mm_per_px = entry.get("mm_per_px") + if mm_per_px is not None and float(mm_per_px) > 0.0: + return 1.0 / float(mm_per_px) + manifest_scale = manifest.get("probe_px_per_mm") or {} + p50 = manifest_scale.get("p50") if isinstance(manifest_scale, Mapping) else None + manifest_width = int(manifest.get("width") or 0) + if p50 is not None and manifest_width > 0 and width > 0: + return float(p50) * width / manifest_width + raise SensitivityConfigError( + f"scale {entry.get('name')!r} carries no px_per_mm and the render manifest " + "cannot supply one" + ) + + +def _local_scale_table( + manifest: Mapping[str, Any], px_per_mm: float +) -> tuple[dict[str, float], float | None, str, float]: + """Probe-local per-axis px/mm for one scale, plus the mesh-wide context value. + + ``render_manifest.probe_px_per_mm`` is measured at the manifest's render + resolution; rescale it to this scale's pixel grid with the ratio of this + scale's ``px_per_mm`` to the manifest's probe-local p50. Falls back to the + probe-local p50 for every axis (recorded as ``"p50_fallback"``) only when the + manifest predates the per-axis keys; never invents a probe-local scale from the + mesh-wide statistic. + + Also returns that ratio as ``manifest_scale_factor`` so any render-scale + measurement can be converted into this scale's pixel grid. + """ + mesh_block = manifest.get("mesh_px_per_mm") + mesh_p50 = ( + float(mesh_block["p50"]) + if isinstance(mesh_block, Mapping) and mesh_block.get("p50") is not None + else None + ) + probe_block = manifest.get("probe_px_per_mm") + probe_p50 = ( + float(probe_block["p50"]) + if isinstance(probe_block, Mapping) and probe_block.get("p50") is not None + else None + ) + reference = probe_p50 if probe_p50 else mesh_p50 + factor = (px_per_mm / reference) if reference else 1.0 + + local: dict[str, float] = {} + source = "unavailable" + if isinstance(probe_block, Mapping): + per_axis = {axis: probe_block.get(axis) for axis in ("x", "y", "z")} + if all(value is not None for value in per_axis.values()): + local = {axis: float(value) * factor for axis, value in per_axis.items()} + source = "per_axis" + elif probe_p50 is not None: + local = {axis: probe_p50 * factor for axis in ("x", "y", "z")} + source = "p50_fallback" + return local, (mesh_p50 * factor if mesh_p50 else None), source, factor + + +def load_scale_inputs( + flow_report: Mapping[str, Any], + flow_dir: Path, + manifest: Mapping[str, Any] | None = None, +) -> list[ScaleInput]: + """Load every scale's frames, null floor and dense flow arrays from disk.""" + manifest = manifest or {} + flow_dir = Path(flow_dir) + scales: list[ScaleInput] = [] + entries = flow_report.get("scales") + if not isinstance(entries, list) or not entries: + raise SensitivityDataError("flow_report.json has no 'scales' entries") + + # The periodic-pattern aliasing diagnostic lives top-level in the flow report + # (per-scale overrides are honoured too). + top_known_answer = flow_report.get("reference_texture_known_answer") + + manifest_roi = _roi_from_mapping(manifest.get("probe_roi_px")) + + # `render_manifest.json.frames[].centroid_px` is the authoritative source for + # the image-space displacement direction (contract scoring item 1). Index it by + # frame name so the flow report's frames pick it up. + manifest_frames: dict[str, Mapping[str, Any]] = {} + raw_manifest_frames = manifest.get("frames") + if isinstance(raw_manifest_frames, list): + for raw in raw_manifest_frames: + if isinstance(raw, Mapping) and raw.get("name"): + manifest_frames[str(raw["name"])] = raw + manifest_reference = next( + ( + raw + for raw in manifest_frames.values() + if float(raw.get("step_mm") or 0.0) == 0.0 + ), + None, + ) + for entry in entries: + if not isinstance(entry, Mapping): + raise SensitivityDataError("flow_report.json scale entry is not an object") + name = str(entry.get("name") or "") + if not name: + raise SensitivityDataError("flow_report.json scale entry has no name") + width = int(entry.get("width") or manifest.get("width") or 0) + height = int(entry.get("height") or manifest.get("height") or 0) + px_per_mm = _scale_px_per_mm(entry, manifest, width) + + raw_frames = entry.get("frames") + if not isinstance(raw_frames, list) or not raw_frames: + raise SensitivityDataError(f"scale {name!r} has no frames") + frames: list[FrameFlow] = [] + width = width or None + height = height or None + for raw in raw_frames: + if not isinstance(raw, Mapping): + raise SensitivityDataError(f"scale {name!r} frame entry is not an object") + frame_name = str(raw.get("name") or "") + flow_path = flow_dir / name / f"{frame_name}.npy" + if not flow_path.is_file() or flow_path.stat().st_size == 0: + raise MissingArtifactError(f"required flow array missing or empty: {flow_path}") + try: + flow = np.load(flow_path, allow_pickle=False).astype(np.float64) + except (OSError, ValueError) as exc: + raise SensitivityDataError(f"could not read {flow_path}: {exc}") from exc + if flow.ndim != 3 or flow.shape[2] != 2: + raise SensitivityDataError( + f"{flow_path} must be (H, W, 2), got {flow.shape}" + ) + roi = _roi_from_mapping(raw.get("roi")) or manifest_roi + if roi is None: + raise SensitivityDataError( + f"frame {frame_name!r} at scale {name!r} has no ROI and the " + "manifest carries no probe_roi_px" + ) + width = int(width or flow.shape[1]) + height = int(height or flow.shape[0]) + centroid = _centroid_from_mapping(raw) + if centroid is None and frame_name in manifest_frames: + centroid = _centroid_from_mapping(manifest_frames[frame_name]) + frames.append( + FrameFlow( + index=int(raw.get("index", len(frames))), + name=frame_name, + axis=(None if raw.get("axis") is None else str(raw["axis"])), + step_mm=float(raw.get("step_mm") or 0.0), + roi=roi, + flow=flow, + centroid_px=centroid, + ) + ) + + null_flow = None + for candidate in (NULL_FRAME_NAME, "null", "reference_null"): + null_path = flow_dir / name / f"{candidate}.npy" + if null_path.is_file() and null_path.stat().st_size > 0: + null_flow = np.load(null_path, allow_pickle=False).astype(np.float64) + break + + # The step-0 reference carries no measurable response but does carry the + # projected centroid the direction regression needs. The contract's frame + # plan does not ask the tracker for reference flow, so synthesize it from + # the manifest when the flow report omits it. Its ROI is the *scale's* ROI: + # a single render-scale manifest ROI would mask the wrong pixels at the + # process scale. + if not any(frame.step_mm == 0.0 for frame in frames): + shape = null_flow.shape if null_flow is not None else frames[0].flow.shape + reference_centroid = ( + _centroid_from_mapping(manifest_reference) + if manifest_reference is not None + else None + ) + if reference_centroid is None and manifest_roi is not None: + reference_centroid = (float(manifest_roi.u), float(manifest_roi.v)) + frames.insert( + 0, + FrameFlow( + index=0, + name=( + str(manifest_reference.get("name")) + if manifest_reference is not None + else "000_reference" + ), + axis=None, + step_mm=0.0, + roi=frames[0].roi, + flow=np.zeros(shape, dtype=np.float64), + centroid_px=reference_centroid, + ), + ) + + null_block = entry.get("null") or {} + step_values = [frame.step_mm for frame in frames if frame.step_mm > 0.0] + local_scales, mesh_scale, local_source, scale_factor = _local_scale_table( + manifest, px_per_mm + ) + known_answer = entry.get("reference_texture_known_answer") + if not isinstance(known_answer, Mapping): + known_answer = top_known_answer if isinstance(top_known_answer, Mapping) else None + scales.append( + ScaleInput( + name=name, + width=int(width or frames[0].flow.shape[1]), + height=int(height or frames[0].flow.shape[0]), + px_per_mm=px_per_mm, + frames=tuple(frames), + null_flow=null_flow, + null_floor_px_reported=( + None + if not isinstance(null_block, Mapping) or null_block.get("floor_px") is None + else float(null_block["floor_px"]) + ), + step_mm=min(step_values) if step_values else None, + local_scale_px_per_mm=local_scales or None, + local_scale_source=local_source, + manifest_scale_factor=scale_factor, + mesh_px_per_mm=mesh_scale, + known_answer=known_answer, + ) + ) + return scales + + +# --------------------------------------------------------------------------- # +# Settings adapters +# --------------------------------------------------------------------------- # + + +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 _as_mapping(value: Any) -> dict[str, Any]: + if value is None: + return {} + if isinstance(value, Mapping): + return dict(value) + result: dict[str, Any] = {} + for key in ( + "algorithm", + "dis_preset", + "dis_mode", + "tracker", + "finest_scale", + "patch_px", + "name", + "value", + ): + if hasattr(value, key): + result[key] = getattr(value, key) + return result + + +def resolution_pair(value: Any) -> tuple[int, int] | None: + """Accept ``(w, h)``, ``[w, h]`` or ``"WxH"`` and return an int pair.""" + if value is None: + return None + if isinstance(value, str) and "x" in value.lower(): + left, _, right = value.lower().partition("x") + return (int(left), int(right)) + if isinstance(value, (list, tuple)) and len(value) >= 2: + return (int(value[0]), int(value[1])) + return None + + +def _revision_dir_from_output_dir(output_dir: Path) -> Path | None: + """Infer ``/sensors//`` from a ``build/probe`` path.""" + resolved = Path(output_dir).resolve() + for parent in [resolved, *resolved.parents]: + if parent.name != "build": + continue + root = parent.parent + try: + relative = resolved.relative_to(parent / "probe") + except ValueError: + return None + candidate = root / "sensors" / relative + return candidate if candidate.is_dir() else None + return None + + +def _revision_label(settings: Any, output_dir: Path) -> str: + revision_dir = _settings_value(settings, "revision_dir", None) + if revision_dir: + revision_dir = Path(revision_dir).resolve() + try: + root = repo_root(revision_dir) + return f"sensors/{texture_revision_rel(root, RevisionPaths.from_revision_dir(revision_dir)).as_posix()}" + except (FileNotFoundError, OSError, ValueError): + return str(revision_dir).replace("\\", "/") + inferred = _revision_dir_from_output_dir(output_dir) + if inferred is not None: + try: + root = repo_root(inferred) + return f"sensors/{texture_revision_rel(root, RevisionPaths.from_revision_dir(inferred)).as_posix()}" + except (FileNotFoundError, OSError, ValueError): + pass + label = _settings_value(settings, "revision", None) + return str(label) if label else "" + + +def _pattern_parts( + raw: Mapping[str, Any], source: str +) -> tuple[tuple[int, int], tuple[float, float] | None, int | None, str] | None: + """Normalise a pattern mapping into ``(grid, cell_mm, seed, source)``. + + ``cell_mm`` is the *physical* marker cell ``[width_mm, height_mm]``. A raw + ``vector``/``vector_width_mm`` page size is converted with ``nx``/``ny``; + nothing is ever derived from the camera. + """ + nx = raw.get("nx") + ny = raw.get("ny") + if nx is None or ny is None: + return None + grid = (int(nx), int(ny)) + if grid[0] < 1 or grid[1] < 1: + return None + + cell = raw.get("cell_mm") + cell_mm: tuple[float, float] | None = None + if isinstance(cell, (list, tuple)) and len(cell) >= 2 and cell[0] and cell[1]: + cell_mm = (float(cell[0]), float(cell[1])) + else: + vector = raw.get("vector") if isinstance(raw.get("vector"), Mapping) else {} + width_mm = raw.get("vector_width_mm", vector.get("width_mm")) + height_mm = raw.get("vector_height_mm", vector.get("height_mm")) + if width_mm and height_mm: + cell_mm = (float(width_mm) / grid[0], float(height_mm) / grid[1]) + + seed = raw.get("seed") + return grid, cell_mm, (None if seed is None else int(seed)), source + + +def _pattern_from_texture_job(output_dir: Path) -> Mapping[str, Any] | None: + """Read ``settings.nx/ny/seed/vector`` from ``_texture_job.json`` if present.""" + path = Path(output_dir) / "_texture_job.json" + if not path.is_file() or path.stat().st_size == 0: + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + if not isinstance(data, Mapping): + return None + block = data.get("settings") + return block if isinstance(block, Mapping) else None + + +def _resolve_pattern( + settings: Any, + output_dir: Path, + explicit: Mapping[str, Any] | None, +) -> tuple[tuple[int, int] | None, tuple[float, float] | None, int | None, str]: + """Resolve the measured pattern grid with the pinned precedence. + + 1. the explicit ``pattern`` kwarg (cli.py always passes it for real runs); + 2. ``getattr(settings, "pattern", None)``; + 3. ``_texture_job.json`` inside ``output_dir``; + 4. otherwise ``(None, None, None, "unavailable")`` -- never a guessed 80x80. + """ + sources: list[tuple[Mapping[str, Any] | None, str]] = [ + (explicit if isinstance(explicit, Mapping) else None, "pattern argument"), + ( + _settings_value(settings, "pattern", None) + if isinstance(_settings_value(settings, "pattern", None), Mapping) + else None, + "settings.pattern", + ), + (_pattern_from_texture_job(output_dir), "_texture_job.json"), + ] + for raw, source in sources: + if raw is None: + continue + parts = _pattern_parts(raw, source) + if parts is not None: + return parts + return None, None, None, "unavailable" + + +def _pattern_note( + grid: tuple[int, int] | None, + cell_mm: tuple[float, float] | None, + seed: int | None, + source: str, +) -> str: + if grid is None: + return ( + "marker pattern grid is unknown (no pattern argument, no settings.pattern, no " + "_texture_job.json in the output directory); texture.grid and texture.cell_mm " + "are null rather than a guessed 80x80" + ) + note = f"measured marker pattern {grid[0]}x{grid[1]}" + if seed is not None: + note += f" seed {seed}" + note += f" (source: {source})" + if cell_mm is not None: + note += f"; physical cell {cell_mm[0]:.6g} x {cell_mm[1]:.6g} mm" + return note + + +def _collect_report( + settings: Any, + output_dir: Path, + manifest_path: Path, + flow_report_path: Path, + *, + pattern: Mapping[str, Any] | None = None, + progress: Any = None, + calibration_enabled: bool = True, +) -> dict[str, Any]: + output_dir = Path(output_dir).resolve() + manifest = _load_json(manifest_path) + flow_report = _load_json(flow_report_path) + + deform_report: dict[str, Any] = {} + deform_path = output_dir / "deform_report.json" + if deform_path.is_file() and deform_path.stat().st_size > 0: + try: + deform_report = json.loads(deform_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + deform_report = {} + + revision = _revision_label(settings, output_dir) + grid, cell_mm, pattern_seed, pattern_source = _resolve_pattern( + settings, output_dir, pattern + ) + + texture_record = manifest.get("texture") or {} + texture_path = texture_record.get("path") if isinstance(texture_record, Mapping) else None + if not texture_path: + configured = _settings_value(settings, "texture", None) + texture_path = str(configured) if configured else None + texture_sha = texture_record.get("sha256") if isinstance(texture_record, Mapping) else None + if not texture_sha and texture_path and Path(texture_path).is_file(): + texture_sha = sha256_file(Path(texture_path)) + + texture_meta = { + "path": texture_path, + "sha256": texture_sha, + "grid": list(grid) if grid else None, + "cell_mm": list(cell_mm) if cell_mm else None, + "seed": pattern_seed, + } + + tracker_settings = _as_mapping(_settings_value(settings, "tracker", None)) + reported_tracker = flow_report.get("tracker") or {} + if isinstance(reported_tracker, Mapping): + for key, value in reported_tracker.items(): + tracker_settings.setdefault(key, value) + tracker_meta = { + "dis_preset": tracker_settings.get("dis_preset"), + "dis_mode": tracker_settings.get("dis_mode"), + "patch_px": float(tracker_settings.get("patch_px", DIS_PATCH_PX)), + # dis_mode *is* the OpenCV finest_scale: DIS computes flow down to this + # pyramid level and bilinearly upscales, so level 1 works at half resolution. + "finest_scale": int(tracker_settings.get("dis_mode") or 0), + } + + probe_meta = { + "group": _settings_value(settings, "group", None) or deform_report.get("group"), + "origin": deform_report.get("origin"), + "basis": deform_report.get("basis"), + "radius_mm": _settings_value(settings, "radius_mm", None) + if _settings_value(settings, "radius_mm", None) is not None + else deform_report.get("radius_mm"), + "falloff": _settings_value(settings, "falloff", None) + if _settings_value(settings, "falloff", None) is not None + else deform_report.get("falloff"), + "affected_vertices": ( + (deform_report.get("weights") or {}).get("partial") + if isinstance(deform_report.get("weights"), Mapping) + else None + ), + } + + scales = load_scale_inputs(flow_report, output_dir / "flow", manifest) + scored = [ + score_scale( + scale, + patch_px=tracker_meta["patch_px"], + finest_scale=tracker_meta["finest_scale"], + grid=grid, + cell_mm=cell_mm, + ) + for scale in scales + ] + + step_values = [frame.step_mm for scale in scales for frame in scale.frames if frame.step_mm > 0.0] + settings_step = _settings_value(settings, "step_mm", None) + step_mm = float(settings_step) if settings_step else (min(step_values) if step_values else 0.0) + + scored = attach_noise_models(scored, flow_report, max_mm=float(settings_step or step_mm)) + calibration = noise_calibration( + output_dir, settings, progress=progress, enabled=calibration_enabled + ) + + extra_notes: list[str] = [] + if any(scale.noise_models for scale in scored): + extra_notes.append(NOISE_MODEL_NOTE) + if calibration and calibration.get("floor_per_period_short"): + extra_notes.append(calibration_note(calibration)) + for scale in scored: + disagreement = noise_disagreement(scale) + if disagreement: + extra_notes.append(disagreement) + + return build_report( + revision=revision, + texture=texture_meta, + probe=probe_meta, + tracker=tracker_meta, + scales=scored, + step_mm=step_mm, + notes=[_pattern_note(grid, cell_mm, pattern_seed, pattern_source), *extra_notes], + noise_calibration=calibration, + ) + + +NOISE_MODEL_NOTE = ( + "signal and floor were measured under the same injected-noise model; " + "`noise_models` carries gain_px_per_mm, floor_px and response_retention per sigma. " + "The render-seed `floor_px` is a reproducibility diagnostic only and must not rank " + "patterns: its ordering reverses under additive noise and it understates the measured " + "real idle floor, coarse patterns most." +) + + +def noise_disagreement(scale: Any) -> str | None: + """Name the axes whose injected-noise gain ordering contradicts the clean gain.""" + models = getattr(scale, "noise_models", None) + if not isinstance(models, Mapping): + return None + clean = {axis.axis: axis.gain_px_per_mm for axis in scale.axes} + worst: list[str] = [] + for axis, entries in (models.get("axes") or {}).items(): + if not isinstance(entries, Sequence) or not entries: + continue + best = max(entries, key=lambda entry: float(entry.get("sigma") or 0.0)) + gain = best.get("gain_px_per_mm") + reference = clean.get(axis) + if gain is None or reference is None or reference == 0.0: + continue + retention = float(gain) / float(reference) + if not (0.85 <= retention <= 1.15): + worst.append(f"{axis} {100.0 * retention:.0f}%") + if not worst: + return None + return ( + f"scale {scale.name}: injected noise changes the response by more than 15% on " + f"{', '.join(worst)}; a ranking that ignores this is not measuring detectability" + ) + + +def calibration_note(calibration: Mapping[str, Any]) -> str: + """State the real idle floor beside the simulated one, and the factor between them.""" + real = float(calibration["floor_per_period_short"]) + return ( + f"real idle calibration: floor {float(calibration['floor_px_short']):.4f} px " + f"at {calibration['width']}x{calibration['height']}, dominant period " + f"{float(calibration['dominant_period_px']):.2f} px, i.e. " + f"{real:.5f} floor per period, with short/long baseline ratio " + f"{float(calibration['drift_ratio']):.2f} (near 1 means white noise, not drift). " + "Compare that with each run's floor/cell; a simulated floor well below it is " + "optimistic and inflates snr." + ) + + +def attach_noise_models( + scored: Sequence[ScaleScore], + flow_report: Mapping[str, Any], + *, + max_mm: float, +) -> list[ScaleScore]: + """Attach per-axis injected-noise gain, floor and retention to each scale. + + Retention is ``gain(sigma) / gain(clean)`` using the clean OLS gain already scored, + so the same denominator appears in every row and the ratio is comparable across + patterns. The noise-aware snr uses the floor measured *under that sigma*. + """ + import dataclasses + + flow_scales = { + str(scale.get("name")): scale + for scale in (flow_report.get("scales") or []) + if isinstance(scale, Mapping) + } + out: list[ScaleScore] = [] + for scale in scored: + source = flow_scales.get(scale.name) + models = (source or {}).get("noise_models") if isinstance(source, Mapping) else None + if not isinstance(models, Mapping): + out.append(scale) + continue + clean = {axis.axis: axis.gain_px_per_mm for axis in scale.axes} + axes_out: dict[str, list[dict[str, Any]]] = {} + for axis, entries in (models.get("axes") or {}).items(): + rows = [] + reference = clean.get(str(axis)) + for entry in entries or []: + gain = entry.get("gain_px_per_mm") + floor = entry.get("floor_px") + retention = ( + float(gain) / float(reference) + if gain is not None and reference not in (None, 0.0) + else None + ) + snr = ( + abs(float(gain)) * float(max_mm) / max(float(floor), 1e-9) + if gain is not None and floor not in (None, 0.0) + else None + ) + rows.append( + { + "sigma": entry.get("sigma"), + "gain_px_per_mm": gain, + "floor_px": floor, + "response_retention": retention, + "snr_noise": snr, + "points": entry.get("points"), + "floor_samples": entry.get("floor_samples"), + } + ) + axes_out[str(axis)] = rows + out.append( + dataclasses.replace( + scale, + noise_models={ + "basis": models.get("basis"), + "draws": models.get("draws"), + "sigmas": models.get("sigmas"), + "max_mm": float(max_mm), + "axes": axes_out, + }, + ) + ) + return out + + +IDLE_CALIBRATION_FILENAME = "idle_floor.json" + + +def noise_calibration( + output_dir: Path, + settings: Any, + *, + progress: Callable[[str], None] | None = None, + enabled: bool = True, +) -> dict[str, Any] | None: + """Measure (or reuse) the real idle floor for the configured footage. + + Cached next to the run because the floor is a property of the recording, not of this + run: measuring it costs ``2 x frames`` DIS calls, so it is written once and reused + while the source's size and mtime are unchanged. + """ + if not enabled: + return None + from orisys_sensor_design import floorcal + + block = _settings_value(settings, "noise", None) + configured = None + if isinstance(block, Mapping): + configured = block.get("idle_footage") + if not configured: + return {"source": "unavailable", "reason": "probe.noise.idle_footage is not set"} + try: + root = repo_root_for(output_dir) + source = floorcal.resolve_idle_source(root, str(configured)) + except Exception: + source = None + if source is None: + return { + "source": "unavailable", + "reason": f"idle footage not found: {configured}", + } + + cache = Path(output_dir) / IDLE_CALIBRATION_FILENAME + stat = source.stat() + fingerprint = {"path": str(source), "size": int(stat.st_size), "mtime": float(stat.st_mtime)} + if cache.is_file(): + try: + cached = json.loads(cache.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + cached = None + if isinstance(cached, Mapping) and cached.get("fingerprint") == fingerprint: + payload = dict(cached) + payload["source"] = "cached" + return payload + + try: + kwargs: dict[str, Any] = {} + if isinstance(block, Mapping): + kwargs["roi_fraction"] = float(block.get("roi_fraction", floorcal.DEFAULT_ROI_FRACTION)) + kwargs["max_frames"] = int(block.get("max_frames", floorcal.DEFAULT_MAX_FRAMES)) + if progress is not None: + progress(f"measuring the real idle floor from {source}") + measured = floorcal.measure_idle_floor(source, **kwargs) + payload = measured.to_json() + payload["source"] = "measured" + payload["fingerprint"] = fingerprint + cache.parent.mkdir(parents=True, exist_ok=True) + cache.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + return payload + except Exception as exc: # calibration must never fail a scoring run + return {"source": "unavailable", "reason": f"{type(exc).__name__}: {exc}"} + + +def repo_root_for(output_dir: Path) -> Path: + """Find the repository root from anywhere inside it, without importing manifest.""" + from orisys_sensor_design.manifest import repo_root + + return repo_root(Path(output_dir)) + + +def _error_report(revision: str, message: str, kind: str) -> dict[str, Any]: + """A minimal, honest report so a failure still leaves an artifact behind.""" + return { + "schema": SENSITIVITY_REPORT_SCHEMA, + "generated_at": _utc_now(), + "revision": revision, + "texture": {"path": None, "sha256": None, "grid": None, "cell_mm": None, "seed": None}, + "probe": { + "group": None, + "origin": None, + "basis": None, + "radius_mm": None, + "falloff": None, + "affected_vertices": None, + }, + "tracker": {"dis_preset": None, "dis_mode": None, "patch_px": DIS_PATCH_PX}, + "scales": [], + "overall": {"score": None, "verdict": "unresolvable", "best_scale": None, "best_axis": None}, + "warnings": [message], + "notes": [], + "error": {"kind": kind, "message": message}, + } + + +def run_sensitivity( + settings: Any, + output_dir: Path, + *, + pattern: Mapping[str, Any] | None = None, + manifest_path: Path | None = None, + flow_report_path: Path | None = None, + progress: Any = print, + calibration_enabled: bool = True, +) -> tuple[dict[str, Any], int]: + """Score the artifacts under ``output_dir`` and write the report. + + ``settings`` is the resolved ``deform.ProbeSettings`` (duck-typed: any object + or mapping with the documented keys works). Returns ``(report, exit_code)`` + with 0 ok / 1 data error / 2 config / 3 missing or empty artifact. The + report is always written before a non-zero code is returned, so a downstream + failure never loses measurements. + + ``pattern`` labels the marker grid actually measured: + ``{"nx": int, "ny": int, "seed": int | None, "cell_mm": [w_mm, h_mm]}``, + where ``cell_mm`` is the *physical* cell. Precedence (recorded in ``notes``): + ``pattern`` -> ``settings.pattern`` -> ``_texture_job.json`` in + ``output_dir`` -> null. A missing grid is reported as null, never guessed. + """ + output_dir = Path(output_dir) + manifest_path = Path(manifest_path) if manifest_path is not None else output_dir / "render_manifest.json" + flow_report_path = ( + Path(flow_report_path) if flow_report_path is not None else output_dir / "flow_report.json" + ) + report_path = output_dir / SENSITIVITY_REPORT_FILENAME + revision = _revision_label(settings, output_dir) + + try: + report = _collect_report( + settings, + output_dir, + manifest_path, + flow_report_path, + pattern=pattern, + progress=progress, + calibration_enabled=calibration_enabled, + ) + code = 0 + except MissingArtifactError as exc: + report = _error_report(revision, str(exc), "missing_artifact") + code = 3 + except SensitivityConfigError as exc: + report = _error_report(revision, str(exc), "config") + code = 2 + except SensitivityDataError as exc: + report = _error_report(revision, str(exc), "data") + code = 1 + except (OSError, ValueError, KeyError, TypeError) as exc: + report = _error_report(revision, f"could not score: {exc}", "data") + code = 1 + + write_sensitivity_report(report, report_path) + overall = report.get("overall", {}) + if code == 0: + progress( + f"sensitivity: overall={overall.get('score')} " + f"verdict={overall.get('verdict')} " + f"best={overall.get('best_scale')}/{overall.get('best_axis')}" + ) + for scale in report.get("scales", []): + progress( + f" [{scale['name']}] {scale['width']}x{scale['height']} " + f"px_per_mm={scale['px_per_mm']} cell_px={scale['cell_px']} " + f"score={scale['score']} verdict={scale['verdict']}" + ) + progress(f"Wrote {report_path}") + else: + progress(f"ERROR: {report['warnings'][0] if report.get('warnings') else 'sensitivity failed'}") + progress(f"Wrote {report_path} (degraded)") + return report, code + + +# --------------------------------------------------------------------------- # +# Output directory helpers +# --------------------------------------------------------------------------- # + + +def default_probe_output_dir(root: Path, paths: RevisionPaths) -> Path: + """Default probe output directory: ``build/probe//``.""" + return (Path(root) / PROBE_RELATIVE_ROOT / texture_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; ``deform.py`` wins when it exists.""" + if output_dir is not None: + return Path(output_dir).expanduser().resolve() + try: + from orisys_sensor_design import deform + + resolver = getattr(deform, "resolve_probe_output_dir", None) + if callable(resolver): + return Path(resolver(root, paths, None)) + except ImportError: + pass + return default_probe_output_dir(root, paths) + + +def load_probe_block(paths: RevisionPaths) -> dict[str, Any]: + """Read the raw ``probe:`` block from design.yaml.""" + design = load_yaml(paths.design_yaml) + raw = design.get("probe") + if raw is None: + return {} + if not isinstance(raw, Mapping): + raise SensitivityConfigError(f"{paths.design_yaml}: 'probe' must be a mapping") + return dict(raw) diff --git a/tests/test_compare.py b/tests/test_compare.py new file mode 100644 index 0000000..33ef181 --- /dev/null +++ b/tests/test_compare.py @@ -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 == {} diff --git a/tests/test_deform.py b/tests/test_deform.py new file mode 100644 index 0000000..5a1ba58 --- /dev/null +++ b/tests/test_deform.py @@ -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// 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"], + }, + } diff --git a/tests/test_flow.py b/tests/test_flow.py new file mode 100644 index 0000000..b2894b1 --- /dev/null +++ b/tests/test_flow.py @@ -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() diff --git a/tests/test_noise_model.py b/tests/test_noise_model.py new file mode 100644 index 0000000..3ccae06 --- /dev/null +++ b/tests/test_noise_model.py @@ -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)) diff --git a/tests/test_optimise.py b/tests/test_optimise.py new file mode 100644 index 0000000..4227713 --- /dev/null +++ b/tests/test_optimise.py @@ -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"} diff --git a/tests/test_preview.py b/tests/test_preview.py index 8b55379..6354bc3 100644 --- a/tests/test_preview.py +++ b/tests/test_preview.py @@ -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 ( diff --git a/tests/test_probe_plot.py b/tests/test_probe_plot.py new file mode 100644 index 0000000..edefff5 --- /dev/null +++ b/tests/test_probe_plot.py @@ -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) diff --git a/tests/test_render.py b/tests/test_render.py new file mode 100644 index 0000000..32fae4e --- /dev/null +++ b/tests/test_render.py @@ -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 -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"] diff --git a/tests/test_sensitivity.py b/tests/test_sensitivity.py new file mode 100644 index 0000000..4027474 --- /dev/null +++ b/tests/test_sensitivity.py @@ -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"] + ) diff --git a/tools/build_uv_map.py b/tools/build_uv_map.py new file mode 100644 index 0000000..d1be694 --- /dev/null +++ b/tools/build_uv_map.py @@ -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 \".npz\"") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())