1. added blender deformation probe for marker size picking and optimization
2. added unwarp map export in current cli, don't need to generate by precompute in sensor running
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,668 @@
|
||||
"""Settings comparison sweep: run one marker pattern under several estimator settings.
|
||||
|
||||
The user-facing question this answers is *"is the winner real, or an artifact of the
|
||||
settings I happened to use?"* A single grid at a single ``dis_mode`` cannot tell you
|
||||
that, so this module runs a bounded matrix and reports the score for every cell of it,
|
||||
plus the figures in :mod:`orisys_sensor_design.probe_plot`.
|
||||
|
||||
What each setting actually invalidates, which is what makes the sweep affordable:
|
||||
|
||||
====================== ===========================================================
|
||||
setting invalidates
|
||||
====================== ===========================================================
|
||||
``nx`` the marker **texture** (regenerated once per grid)
|
||||
``radius_mm`` the **deformation and frames** (``deform`` + ``render``)
|
||||
``dis_mode`` only **track + sensitivity** -- the frames are reused
|
||||
====================== ===========================================================
|
||||
|
||||
The ``dis_mode`` case is the important one. Changing it does not change the rendered
|
||||
frames at all, so the variant is run in a sibling directory holding a rewritten
|
||||
``render_manifest.json`` whose ``png`` entries are made **absolute** and therefore
|
||||
point at the original frames. ``manifest_frame_entries`` resolves an absolute ``png``
|
||||
to itself, so the estimator reads the very same pixels. That makes the decisive
|
||||
experiment (does the optimum move when the effective window halves?) cost seconds
|
||||
instead of a re-render.
|
||||
|
||||
``stage_runner`` is injected so the module is testable without Blender: it is a
|
||||
callable ``(kind, **kwargs) -> int`` returning an exit code, where ``kind`` is one of
|
||||
``"texture"``, ``"deform"``, ``"render"``, ``"track"``, ``"sensitivity"``. ``cli.py``
|
||||
supplies one backed by the real stage helpers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping, Sequence
|
||||
|
||||
SCHEMA = "orisys-sensor-design/settings-comparison-v1"
|
||||
FILENAME = "settings_comparison.json"
|
||||
COMPARISON_DIRNAME = "comparison"
|
||||
DEFAULT_DIS_MODES = (1, 0)
|
||||
DEFAULT_RADII = (0.5, 1.5)
|
||||
DEFAULT_GRID = (31, 56, 80)
|
||||
DEFAULT_SEEDS = (7,)
|
||||
#: Refuse to start a matrix larger than this unless the caller raises the bound.
|
||||
DEFAULT_MAX_RUNS = 24
|
||||
#: The two optima the render scale measured, for the tuning figure's markers.
|
||||
NOMINAL_PATCH_PX = 8.0
|
||||
|
||||
StageRunner = Callable[..., int]
|
||||
|
||||
|
||||
class CompareError(Exception):
|
||||
"""Config or orchestration problem (exit code 2)."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SweepEntry:
|
||||
"""One cell of the matrix: a pattern, a radius and an estimator setting."""
|
||||
|
||||
nx: int
|
||||
ny: int
|
||||
seed: int
|
||||
radius_mm: float
|
||||
dis_mode: int
|
||||
|
||||
@property
|
||||
def frame_key(self) -> str:
|
||||
return f"nx{self.nx:03d}_r{self.radius_mm:g}"
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return f"nx{self.nx}@r{self.radius_mm:g}@dm{self.dis_mode}"
|
||||
|
||||
@property
|
||||
def dirname(self) -> str:
|
||||
"""Directory for this entry; only ``dis_mode`` differs within a frame set.
|
||||
|
||||
The ``dis_mode`` suffix is always present, including for ``dis_mode=0``: an
|
||||
earlier truthiness test (`if self.dis_mode:`) silently gave ``0`` the
|
||||
suffix-less name and made it collide with the frame-set directory instead of
|
||||
being a distinct variant.
|
||||
"""
|
||||
return f"{self.frame_key}_dm{self.dis_mode}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SweepOutcome:
|
||||
entry: SweepEntry
|
||||
directory: Path
|
||||
score: float | None = None
|
||||
verdict: str | None = None
|
||||
scale_scores: dict[str, float] = field(default_factory=dict)
|
||||
scale_verdicts: dict[str, str] = field(default_factory=dict)
|
||||
cell_px: float | None = None
|
||||
effective_patch_px: float | None = None
|
||||
error: str | None = None
|
||||
|
||||
def to_json(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.entry.id,
|
||||
"nx": self.entry.nx,
|
||||
"ny": self.entry.ny,
|
||||
"seed": self.entry.seed,
|
||||
"radius_mm": self.entry.radius_mm,
|
||||
"dis_mode": self.entry.dis_mode,
|
||||
"directory": str(self.directory),
|
||||
"score": self.score,
|
||||
"verdict": self.verdict,
|
||||
"scale_scores": dict(self.scale_scores),
|
||||
"scale_verdicts": dict(self.scale_verdicts),
|
||||
"cell_px": self.cell_px,
|
||||
"effective_patch_px": self.effective_patch_px,
|
||||
"error": self.error,
|
||||
}
|
||||
|
||||
|
||||
def enumerate_sweep(
|
||||
*,
|
||||
grid: Sequence[int],
|
||||
seeds: Sequence[int],
|
||||
dis_modes: Sequence[int],
|
||||
radii: Sequence[float],
|
||||
) -> list[SweepEntry]:
|
||||
"""Deterministically ordered matrix, sorted so two runs agree byte for byte."""
|
||||
entries: list[SweepEntry] = []
|
||||
for nx in sorted({int(value) for value in grid}):
|
||||
for seed in sorted({int(value) for value in seeds}):
|
||||
for radius in sorted({float(value) for value in radii}):
|
||||
for dis_mode in sorted({int(value) for value in dis_modes}, reverse=True):
|
||||
if nx < 1:
|
||||
raise CompareError(f"grid values must be >= 1, got {nx}")
|
||||
if radius <= 0.0:
|
||||
raise CompareError(f"radius_mm values must be > 0, got {radius}")
|
||||
if dis_mode < 0:
|
||||
raise CompareError(f"dis_mode values must be >= 0, got {dis_mode}")
|
||||
entries.append(
|
||||
SweepEntry(nx=nx, ny=nx, seed=seed, radius_mm=radius, dis_mode=dis_mode)
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _absolutise_manifest(manifest: Mapping[str, Any], run_dir: Path) -> dict[str, Any]:
|
||||
"""Return a manifest copy whose frame *and null* PNG paths are absolute.
|
||||
|
||||
``manifest_frame_entries`` resolves an absolute ``png`` to itself (pathlib ignores
|
||||
the left side when the right side is absolute), so a variant directory holding only
|
||||
this manifest reads the original rendered frames with no re-render.
|
||||
|
||||
Two details that each cost a debugging round:
|
||||
|
||||
* ``png`` is stored **relative to the run directory** (``frames/<name>.png``), not to
|
||||
its ``frames/`` subdirectory, so the manifest's own directory is the base. Passing
|
||||
the subdirectory produced ``frames/frames/<name>.png`` and every variant run failed
|
||||
with a missing-artifact exit 3.
|
||||
* the ``null`` block is a separate path that the tracker loads for the zero-input
|
||||
floor, so it has to be rewritten as well.
|
||||
"""
|
||||
def absolute(value: Any) -> Any:
|
||||
if not isinstance(value, str) or not value:
|
||||
return value
|
||||
candidate = Path(value)
|
||||
if candidate.is_absolute():
|
||||
return str(candidate)
|
||||
return str((run_dir / candidate).resolve())
|
||||
|
||||
clone = dict(manifest)
|
||||
frames = []
|
||||
for entry in manifest.get("frames") or []:
|
||||
item = dict(entry)
|
||||
if "png" in item:
|
||||
item["png"] = absolute(item.get("png"))
|
||||
frames.append(item)
|
||||
clone["frames"] = frames
|
||||
null = manifest.get("null")
|
||||
if isinstance(null, Mapping):
|
||||
null_clone = dict(null)
|
||||
if "png" in null_clone:
|
||||
null_clone["png"] = absolute(null_clone.get("png"))
|
||||
clone["null"] = null_clone
|
||||
return clone
|
||||
|
||||
|
||||
def _collect(outcome: SweepOutcome, report: Mapping[str, Any]) -> None:
|
||||
"""Copy the score fields out of a sensitivity report into the outcome."""
|
||||
overall = report.get("overall") or {}
|
||||
outcome.score = overall.get("score")
|
||||
outcome.verdict = overall.get("verdict")
|
||||
for scale in report.get("scales") or []:
|
||||
name = scale.get("name")
|
||||
if name is None:
|
||||
continue
|
||||
outcome.scale_scores[str(name)] = scale.get("score")
|
||||
outcome.scale_verdicts[str(name)] = scale.get("verdict")
|
||||
if scale.get("cell_px") is not None:
|
||||
outcome.cell_px = scale.get("cell_px")
|
||||
if scale.get("effective_patch_px") is not None:
|
||||
outcome.effective_patch_px = scale.get("effective_patch_px")
|
||||
|
||||
|
||||
def run_compare(
|
||||
paths: Any,
|
||||
settings: Any,
|
||||
*,
|
||||
stage_runner: StageRunner,
|
||||
grid: Sequence[int] = DEFAULT_GRID,
|
||||
seeds: Sequence[int] = DEFAULT_SEEDS,
|
||||
dis_modes: Sequence[int] = DEFAULT_DIS_MODES,
|
||||
radii: Sequence[float] = DEFAULT_RADII,
|
||||
resolution: Any = None,
|
||||
max_runs: int = DEFAULT_MAX_RUNS,
|
||||
dry_run: bool = False,
|
||||
output_dir: Path | None = None,
|
||||
progress: Callable[[str], None] = print,
|
||||
) -> tuple[dict[str, Any], int]:
|
||||
"""Run the settings matrix and return ``(report, exit_code)``."""
|
||||
import dataclasses
|
||||
|
||||
entries = enumerate_sweep(grid=grid, seeds=seeds, dis_modes=dis_modes, radii=radii)
|
||||
if len(entries) > int(max_runs):
|
||||
raise CompareError(
|
||||
f"the requested matrix has {len(entries)} runs but --max-runs is {int(max_runs)}; "
|
||||
"narrow --grid/--dis-modes/--radius-mm or raise --max-runs"
|
||||
)
|
||||
|
||||
base = Path(output_dir) if output_dir is not None else _default_output_dir(paths)
|
||||
base = base / COMPARISON_DIRNAME if base.name != COMPARISON_DIRNAME else base
|
||||
width = None
|
||||
if resolution:
|
||||
width = int(resolution[0])
|
||||
|
||||
report: dict[str, Any] = {
|
||||
"schema": SCHEMA,
|
||||
"revision": str(getattr(paths, "root", "")),
|
||||
"search": {
|
||||
"grid": sorted({int(v) for v in grid}),
|
||||
"seeds": sorted({int(v) for v in seeds}),
|
||||
"dis_modes": sorted({int(v) for v in dis_modes}, reverse=True),
|
||||
"radius_mm": sorted({float(v) for v in radii}),
|
||||
"runs": len(entries),
|
||||
"resolution": ([width, int(resolution[1])] if resolution else None),
|
||||
"dry_run": bool(dry_run),
|
||||
},
|
||||
"runs": [],
|
||||
"ranked": [],
|
||||
"best_per_dis_mode": {},
|
||||
"best_per_radius": {},
|
||||
"notes": [],
|
||||
}
|
||||
if dry_run:
|
||||
report["runs"] = [entry.id for entry in entries]
|
||||
report["notes"].append(
|
||||
"dry-run: enumerated the matrix and ran nothing; no score is reported."
|
||||
)
|
||||
return report, 0
|
||||
|
||||
outcomes: list[SweepOutcome] = []
|
||||
textures: dict[int, Path] = {}
|
||||
frame_dirs: dict[str, Path] = {}
|
||||
|
||||
for entry in entries:
|
||||
directory = base / entry.dirname
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
outcome = SweepOutcome(entry=entry, directory=directory)
|
||||
try:
|
||||
# 1) The marker artwork for this grid (cached: nx is the only input).
|
||||
if entry.nx not in textures:
|
||||
texture_dir = base / f"tex_nx{entry.nx:03d}"
|
||||
code = stage_runner(
|
||||
"texture", settings=settings, output_dir=texture_dir, nx=entry.nx,
|
||||
ny=entry.ny, seed=entry.seed,
|
||||
)
|
||||
if code != 0:
|
||||
raise CompareError(f"texture stage failed (exit {code})")
|
||||
textures[entry.nx] = texture_dir
|
||||
# The probe bench reads one UV map; the texture stage writes that stem
|
||||
# into the cached directory, so resolve it there.
|
||||
texture_png = textures[entry.nx] / Path(str(settings.texture)).name
|
||||
probe = _probe_for(settings, entry, resolution, texture_png, dataclasses)
|
||||
|
||||
# 2) The deformation and frames for this (nx, radius) (cached).
|
||||
if entry.frame_key not in frame_dirs:
|
||||
code = stage_runner("deform", settings=probe, output_dir=directory)
|
||||
if code != 0:
|
||||
raise CompareError(f"deform stage failed (exit {code})")
|
||||
code = stage_runner("render", settings=probe, output_dir=directory)
|
||||
if code != 0:
|
||||
raise CompareError(f"render stage failed (exit {code})")
|
||||
frame_dirs[entry.frame_key] = directory
|
||||
elif directory != frame_dirs[entry.frame_key]:
|
||||
# Same frames, different estimator setting: point at the originals.
|
||||
# manifest_frame_entries resolves an absolute `png` to itself, so the
|
||||
# estimator reads exactly the same pixels with no re-render.
|
||||
source = _load_json(frame_dirs[entry.frame_key] / "render_manifest.json")
|
||||
if not source:
|
||||
raise CompareError(
|
||||
f"no render_manifest.json in {frame_dirs[entry.frame_key]}"
|
||||
)
|
||||
rewritten = _absolutise_manifest(
|
||||
source, frame_dirs[entry.frame_key]
|
||||
)
|
||||
(directory / "render_manifest.json").write_text(
|
||||
json.dumps(rewritten, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
# 3) The estimator and the score (per dis_mode -- the cheap axis).
|
||||
code = stage_runner("track", settings=probe, output_dir=directory)
|
||||
if code != 0:
|
||||
raise CompareError(f"track stage failed (exit {code})")
|
||||
code = stage_runner("sensitivity", settings=probe, output_dir=directory)
|
||||
score_report = _load_json(directory / "sensitivity_report.json")
|
||||
if code != 0 and not score_report:
|
||||
raise CompareError(f"sensitivity stage failed (exit {code})")
|
||||
_collect(outcome, score_report)
|
||||
progress(
|
||||
f"{outcome.entry.id}: score="
|
||||
+ (f"{outcome.score:.4f}" if outcome.score is not None else "None")
|
||||
+ f" verdict={outcome.verdict} scales={outcome.scale_scores}"
|
||||
)
|
||||
except Exception as exc: # one bad cell must not lose the rest
|
||||
outcome.error = f"{type(exc).__name__}: {exc}"
|
||||
progress(f"{outcome.entry.id}: FAILED: {outcome.error}")
|
||||
outcomes.append(outcome)
|
||||
|
||||
report["runs"] = [outcome.to_json() for outcome in outcomes]
|
||||
|
||||
scored = [outcome for outcome in outcomes if outcome.score is not None]
|
||||
ranked = sorted(scored, key=lambda o: (-float(o.score), o.entry.nx, o.entry.dis_mode))
|
||||
report["ranked"] = [outcome.entry.id for outcome in ranked]
|
||||
for key_name, key_of in (
|
||||
("best_per_dis_mode", lambda o: str(o.entry.dis_mode)),
|
||||
("best_per_radius", lambda o: f"{o.entry.radius_mm:g}"),
|
||||
):
|
||||
best: dict[str, Any] = {}
|
||||
for outcome in ranked:
|
||||
best.setdefault(
|
||||
key_of(outcome),
|
||||
{"id": outcome.entry.id, "score": outcome.score, "verdict": outcome.verdict},
|
||||
)
|
||||
report[key_name] = best
|
||||
|
||||
report["notes"].append(
|
||||
"Each cell of the matrix used the same estimator and camera; only the listed "
|
||||
"settings vary. Scores are comparable within a dis_mode/radius block, and the "
|
||||
"per-setting bests are reported so an optimum that moves with a setting is "
|
||||
"visible rather than averaged away."
|
||||
)
|
||||
if len({outcome.entry.radius_mm for outcome in outcomes}) > 1:
|
||||
report["notes"].append(
|
||||
"radius_mm changes the measurement geometry as well as the deformation: the "
|
||||
"probe ROI scales with it, and the measured gain is confounded with ROI size, "
|
||||
"so compare radii for ranking stability rather than as absolute sensitivity."
|
||||
)
|
||||
if len({outcome.entry.dis_mode for outcome in outcomes}) > 1:
|
||||
report["notes"].append(
|
||||
"dis_mode is OpenCV's finest pyramid level, so the effective DIS window is "
|
||||
"patch_px * 2**dis_mode. If the winning nx moves when dis_mode changes, the "
|
||||
"optimum is governed by that window rather than by the pattern alone."
|
||||
)
|
||||
|
||||
failures = [outcome for outcome in outcomes if outcome.error]
|
||||
if failures:
|
||||
report["notes"].append(
|
||||
f"{len(failures)} run(s) failed and are recorded with an error: "
|
||||
+ ", ".join(outcome.entry.id for outcome in failures)
|
||||
)
|
||||
|
||||
(base / FILENAME).write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
||||
return report, (1 if failures and not scored else 0)
|
||||
|
||||
|
||||
def _default_output_dir(paths: Any) -> Path:
|
||||
for attribute in ("root", "revision_dir"):
|
||||
value = getattr(paths, attribute, None)
|
||||
if value:
|
||||
return Path(value)
|
||||
raise CompareError("cannot resolve an output directory from the revision paths")
|
||||
|
||||
|
||||
def _probe_for(
|
||||
settings: Any, entry: SweepEntry, resolution: Any, texture_png: Path, dataclasses_module
|
||||
) -> Any:
|
||||
"""Per-entry probe settings: this pattern, this radius, this tracker setting.
|
||||
|
||||
``pattern`` must follow the candidate grid, not the revision default. Leaving it
|
||||
alone made every run report ``pattern.nx`` from ``texture_patch`` (80), so
|
||||
``cell_px`` was 6.25 for *every* candidate and the tuning curve was meaningless.
|
||||
"""
|
||||
changes: dict[str, Any] = {
|
||||
"radius_mm": float(entry.radius_mm),
|
||||
"texture": str(texture_png),
|
||||
}
|
||||
if resolution:
|
||||
changes["resolution"] = [int(resolution[0]), int(resolution[1])]
|
||||
changes["tracker"] = dict(getattr(settings, "tracker", {}) or {})
|
||||
changes["tracker"]["dis_mode"] = int(entry.dis_mode)
|
||||
|
||||
pattern = getattr(settings, "pattern", None)
|
||||
if pattern is not None:
|
||||
try:
|
||||
changes["pattern"] = dataclasses_module.replace(
|
||||
pattern, nx=int(entry.nx), ny=int(entry.ny), seed=int(entry.seed)
|
||||
)
|
||||
except Exception: # a non-dataclass pattern must not break the sweep
|
||||
pass
|
||||
return dataclasses_module.replace(settings, **changes)
|
||||
|
||||
|
||||
def select_runs(
|
||||
scored: Sequence[Mapping[str, Any]],
|
||||
*,
|
||||
limit: int,
|
||||
reference_radius: float | None = None,
|
||||
) -> list[Mapping[str, Any]]:
|
||||
"""Choose the runs a figure shows, deterministically and without duplicates.
|
||||
|
||||
The previous rule took best **and** worst of ``keys[:1]``, which sorted to
|
||||
``dis_mode=0`` -- so dm0 was over-represented, one run appeared twice under
|
||||
different radii, and the shipped dm1 setting was squeezed out by the 4-curve cap.
|
||||
|
||||
This rule: the global best and worst, then the best at the reference radius for
|
||||
every ``dis_mode`` (so the shipped setting is always represented), then fill by
|
||||
rank. Deduplicated by run id throughout.
|
||||
"""
|
||||
ranked = sorted(
|
||||
scored, key=lambda run: -float(run.get("score") or 0.0)
|
||||
)
|
||||
if not ranked:
|
||||
return []
|
||||
chosen: list[Mapping[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def add(run: Mapping[str, Any] | None) -> None:
|
||||
if run is None or len(chosen) >= limit:
|
||||
return
|
||||
key = str(run.get("id"))
|
||||
if key in seen:
|
||||
return
|
||||
seen.add(key)
|
||||
chosen.append(run)
|
||||
|
||||
add(ranked[0])
|
||||
add(ranked[-1])
|
||||
radius = reference_radius
|
||||
if radius is None:
|
||||
radii = sorted({float(run.get("radius_mm") or 0.0) for run in ranked})
|
||||
radius = radii[0] if radii else None
|
||||
for dis_mode in sorted({int(run.get("dis_mode") or 0) for run in ranked}, reverse=True):
|
||||
block = [
|
||||
run for run in ranked
|
||||
if int(run.get("dis_mode") or 0) == dis_mode
|
||||
and float(run.get("radius_mm") or 0.0) == float(radius)
|
||||
]
|
||||
add(block[0] if block else None)
|
||||
for run in ranked:
|
||||
add(run)
|
||||
return chosen
|
||||
|
||||
|
||||
def build_figures(
|
||||
report: Mapping[str, Any],
|
||||
base_dir: Path,
|
||||
*,
|
||||
out_dir: Path | None = None,
|
||||
axis: str = "x",
|
||||
step_mm: float = 0.10,
|
||||
scale: str = "render",
|
||||
max_flow_panels: int = 4,
|
||||
max_response_curves: int = 6,
|
||||
dpi: int = 150,
|
||||
progress: Callable[[str], None] = print,
|
||||
) -> dict[str, Path]:
|
||||
"""Write the tuning, flow-comparison and response figures for a sweep report.
|
||||
|
||||
Imported lazily so the sweep itself never requires matplotlib. A plotting failure
|
||||
is reported but never discards the JSON report, which is already on disk.
|
||||
"""
|
||||
from orisys_sensor_design import probe_plot
|
||||
|
||||
base_dir = Path(base_dir)
|
||||
out_dir = Path(out_dir) if out_dir is not None else base_dir
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
runs = [run for run in report.get("runs") or [] if isinstance(run, Mapping)]
|
||||
scored = [run for run in runs if run.get("score") is not None]
|
||||
figures: dict[str, Path] = {}
|
||||
if not scored:
|
||||
progress("no scored runs; nothing to plot")
|
||||
return figures
|
||||
|
||||
# --- tuning: one series per (dis_mode, radius, scale) ----------------------
|
||||
series: list[dict[str, Any]] = []
|
||||
keys = sorted(
|
||||
{
|
||||
(int(run.get("dis_mode") or 0), float(run.get("radius_mm") or 0.0))
|
||||
for run in scored
|
||||
}
|
||||
)
|
||||
for dis_mode, radius in keys:
|
||||
rows = [
|
||||
run for run in scored
|
||||
if int(run.get("dis_mode") or 0) == dis_mode
|
||||
and float(run.get("radius_mm") or 0.0) == radius
|
||||
]
|
||||
for scale_name in sorted({s for run in rows for s in (run.get("scale_scores") or {})}):
|
||||
points = [
|
||||
{
|
||||
"nx": run.get("nx"),
|
||||
# The cell is a property of the pattern at the *render* width; the
|
||||
# same pattern spans proportionally fewer pixels at a smaller
|
||||
# estimator scale, which is why the process series falls off.
|
||||
"cell_px": run.get("cell_px"),
|
||||
"effective_patch_px": run.get("effective_patch_px"),
|
||||
"score": (run.get("scale_scores") or {}).get(scale_name),
|
||||
}
|
||||
for run in rows
|
||||
if (run.get("scale_scores") or {}).get(scale_name) is not None
|
||||
and run.get("cell_px")
|
||||
]
|
||||
if points:
|
||||
series.append(
|
||||
{
|
||||
"label": f"{scale_name} | dis_mode={dis_mode} | r={radius:g} mm",
|
||||
"points": points,
|
||||
}
|
||||
)
|
||||
try:
|
||||
figures["tuning"] = probe_plot.render_tuning(
|
||||
series=series,
|
||||
out_path=out_dir / "settings_tuning.png",
|
||||
title="Sensitivity vs camera-space cell, by setting",
|
||||
patch_px=NOMINAL_PATCH_PX,
|
||||
dpi=dpi,
|
||||
)
|
||||
except Exception as exc:
|
||||
progress(f"could not render the tuning figure: {exc}")
|
||||
|
||||
# --- the runs every figure shows, chosen by one documented rule ---------------
|
||||
reference_radius = min(
|
||||
(float(run.get("radius_mm") or 0.0) for run in scored), default=None
|
||||
)
|
||||
flow_runs = select_runs(scored, limit=max_flow_panels, reference_radius=reference_radius)
|
||||
# The response figure gets the whole reference-radius block instead of best/worst:
|
||||
# selection by score would drop the pattern that clean gain says is best, which is
|
||||
# exactly the comparison this figure exists to settle.
|
||||
block = sorted(
|
||||
(
|
||||
run for run in scored
|
||||
if float(run.get("radius_mm") or 0.0) == float(reference_radius)
|
||||
),
|
||||
key=lambda run: (-int(run.get("dis_mode") or 0), int(run.get("nx") or 0)),
|
||||
)
|
||||
response_runs = block[:max_response_curves] or select_runs(
|
||||
scored, limit=max_response_curves, reference_radius=reference_radius
|
||||
)
|
||||
|
||||
def run_label(run: Mapping[str, Any]) -> str:
|
||||
"""Unique per run: nx, radius AND dis_mode all appear, unlike the old label."""
|
||||
return (
|
||||
f"nx={run.get('nx')} r={float(run.get('radius_mm') or 0.0):g} mm "
|
||||
f"dm={run.get('dis_mode')}"
|
||||
)
|
||||
|
||||
panels = []
|
||||
for run in flow_runs:
|
||||
directory = Path(str(run.get("directory")))
|
||||
panel = probe_plot.panel_from_run(
|
||||
label=run_label(run),
|
||||
output_dir=directory,
|
||||
axis=axis,
|
||||
step_mm=step_mm,
|
||||
scale=scale,
|
||||
)
|
||||
panel["label"] = f"{run_label(run)} -- overall {float(run.get('score') or 0.0):.2f}"
|
||||
panels.append(panel)
|
||||
try:
|
||||
figures["flow"] = probe_plot.render_flow_comparison(
|
||||
panels=panels,
|
||||
out_path=out_dir / "settings_flow.png",
|
||||
title=(
|
||||
f"Measured optical flow, {scale} scale, axis {axis} at {step_mm:g} mm "
|
||||
"-- comparing settings"
|
||||
),
|
||||
dpi=dpi,
|
||||
)
|
||||
except Exception as exc:
|
||||
progress(f"could not render the flow-comparison figure: {exc}")
|
||||
|
||||
# --- response curves for the response-selected runs --------------------------
|
||||
curves = []
|
||||
for run in response_runs:
|
||||
report_path = Path(str(run.get("directory"))) / "sensitivity_report.json"
|
||||
block = _load_json(report_path)
|
||||
block_scale = next(
|
||||
(s for s in block.get("scales") or [] if s.get("name") == scale), None
|
||||
)
|
||||
axis_block = next(
|
||||
(a for a in (block_scale or {}).get("axes") or [] if a.get("axis") == axis), None
|
||||
)
|
||||
if axis_block:
|
||||
summary = axis_block.get("summary") or {}
|
||||
retention = []
|
||||
for entries in ((block_scale or {}).get("noise_models") or {}).get("axes", {}).get(axis, []) or []:
|
||||
if isinstance(entries, Mapping) and entries.get("response_retention") is not None:
|
||||
retention.append(
|
||||
{
|
||||
"sigma": entries.get("sigma"),
|
||||
"response_retention": entries.get("response_retention"),
|
||||
}
|
||||
)
|
||||
curves.append(
|
||||
{
|
||||
# Unique: nx, radius and dis_mode all appear. The old label was
|
||||
# `nx=.. dm..` and omitted the radius, so `nx80@r0.5@dm0` and
|
||||
# `nx80@r1.5@dm0` produced two identical legend entries.
|
||||
"label": run_label(run),
|
||||
"points": axis_block.get("points") or [],
|
||||
"verdict": summary.get("verdict"),
|
||||
"score": summary.get("score"),
|
||||
# This curve's own floor; a single global band was the maximum
|
||||
# across runs and belonged to no plotted curve.
|
||||
"floor_px": summary.get("floor_px"),
|
||||
"retention": retention,
|
||||
}
|
||||
)
|
||||
try:
|
||||
figures["response"] = probe_plot.render_response_curves(
|
||||
curves=curves,
|
||||
out_path=out_dir / "settings_response.png",
|
||||
axis=axis,
|
||||
title="Response vs applied displacement, by setting",
|
||||
dpi=dpi,
|
||||
)
|
||||
except Exception as exc:
|
||||
progress(f"could not render the response figure: {exc}")
|
||||
|
||||
report["figures"] = {
|
||||
"flow_ids": [str(run.get("id")) for run in flow_runs],
|
||||
"response_ids": [str(run.get("id")) for run in response_runs],
|
||||
}
|
||||
return figures
|
||||
|
||||
|
||||
__all__ = [
|
||||
"COMPARISON_DIRNAME",
|
||||
"CompareError",
|
||||
"DEFAULT_DIS_MODES",
|
||||
"DEFAULT_GRID",
|
||||
"DEFAULT_MAX_RUNS",
|
||||
"DEFAULT_RADII",
|
||||
"DEFAULT_SEEDS",
|
||||
"FILENAME",
|
||||
"SCHEMA",
|
||||
"SweepEntry",
|
||||
"SweepOutcome",
|
||||
"build_figures",
|
||||
"enumerate_sweep",
|
||||
"run_compare",
|
||||
"select_runs",
|
||||
]
|
||||
@@ -0,0 +1,770 @@
|
||||
"""Headless probe-deformation settings, frame plan, output paths, and job I/O.
|
||||
|
||||
The host CLI (``orisys-design deform``) reads the ``probe`` mapping in
|
||||
``design.yaml``, merges explicit CLI overrides, writes a stdlib-JSON job file,
|
||||
and invokes ``blender/scripts/probe_deform.py`` headlessly. Blender never reads
|
||||
YAML; every path handed to it is absolute.
|
||||
|
||||
This module is the **single ordering authority** for the sweep:
|
||||
``frame_plan()`` produces the reference frame at index 0 followed by axis-major,
|
||||
step-minor ascending positive steps. Every job carries that resolved list, so
|
||||
render and analysis cannot disagree about frame order.
|
||||
|
||||
The deformation itself lives in Blender: ``probe_deform.py`` writes
|
||||
``probe_frames.npz`` (the contract between the deformation and rendering
|
||||
workstreams) and ``deform_report.json``. It never writes the ``.blend``.
|
||||
|
||||
Proportional falloff is computed explicitly, in millimetres, by the bpy-free
|
||||
``blender/scripts/probe_falloff.py``: ``bpy.ops.transform.translate`` with
|
||||
proportional editing is viewport-modal and its radius is screen-space, which
|
||||
does not exist under ``blender -b``. Weights are derived from the probe
|
||||
members' ORIGINAL positions, so one weight vector is shared by every frame.
|
||||
|
||||
Contract: ``docs/probe-pipeline.md``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Sequence
|
||||
|
||||
from orisys_sensor_design.manifest import RevisionPaths, export_object_names, load_yaml
|
||||
from orisys_sensor_design.texture import (
|
||||
TextureConfigError,
|
||||
merge_texture_patch,
|
||||
texture_revision_rel,
|
||||
)
|
||||
|
||||
DEFORM_JOB_SCHEMA = "orisys-sensor-design/probe-deform-job-v1"
|
||||
DEFORM_JOB_FILENAME = "_deform_job.json"
|
||||
FRAMES_NPZ_FILENAME = "probe_frames.npz"
|
||||
DEFORM_REPORT_FILENAME = "deform_report.json"
|
||||
PROBE_RELATIVE_ROOT = Path("build") / "probe"
|
||||
|
||||
#: Sweep axes, and the basis rows they index (rows are x, y, z in that order).
|
||||
AXIS_NAMES = ("x", "y", "z")
|
||||
AXIS_ROWS = {"x": 0, "y": 1, "z": 2}
|
||||
FALLOFF_MODES = ("smooth", "linear", "sharp", "constant")
|
||||
|
||||
#: Built-in fallbacks. sensors/<sensor>/<revision>/design.yaml is the source of
|
||||
#: truth for a revision; these only apply when a key is absent there.
|
||||
DEFAULT_PROBE: dict[str, Any] = {
|
||||
"mesh": "sensing_surface",
|
||||
"camera": "cam_sensor",
|
||||
"group": "probe",
|
||||
"uv_map": "compute",
|
||||
"texture": "patch_texture_compute.png",
|
||||
"radius_mm": 0.5,
|
||||
"falloff": "smooth",
|
||||
"step_mm": 0.01,
|
||||
"max_mm": 0.10,
|
||||
"axes": ["x", "y", "z"],
|
||||
"resolution": [500, 500],
|
||||
"process_resolution": [250, 250],
|
||||
"tracker": {
|
||||
"algorithm": "dis",
|
||||
"dis_preset": "medium",
|
||||
"dis_mode": 1,
|
||||
},
|
||||
"render": {
|
||||
"engine": "CYCLES",
|
||||
"samples": 16,
|
||||
"denoise": False,
|
||||
"view_transform": "Standard",
|
||||
"interpolate": "Closest",
|
||||
"seed": 0,
|
||||
},
|
||||
# Real-noise calibration and the injected noise model. The render-seed floor is a
|
||||
# reproducibility diagnostic only; these keys anchor the score to something real.
|
||||
"noise": {
|
||||
# Idle (no-contact) footage, relative to the repo root or absolute. Absent =
|
||||
# skip calibration and warn, never fail.
|
||||
"idle_footage": None,
|
||||
"roi_fraction": 0.18,
|
||||
"max_frames": 120,
|
||||
# Injected additive white Gaussian noise, in grey levels. Kept small by
|
||||
# default because each sigma costs a full DIS pass per scale.
|
||||
"sigmas": [4.0, 16.0],
|
||||
"draws": 1,
|
||||
"seed": 0,
|
||||
"models": ["render_seed", "injected"],
|
||||
# The process scale is a resample of the render one, so evaluating only the
|
||||
# render scale by default keeps the sweep affordable.
|
||||
"scales": ["render"],
|
||||
},
|
||||
}
|
||||
|
||||
_PROBE_KEYS = frozenset(DEFAULT_PROBE)
|
||||
_TRACKER_KEYS = frozenset(DEFAULT_PROBE["tracker"])
|
||||
_RENDER_KEYS = frozenset(DEFAULT_PROBE["render"])
|
||||
_NOISE_KEYS = frozenset(DEFAULT_PROBE["noise"])
|
||||
_NESTED_KEYS = {
|
||||
"tracker": _TRACKER_KEYS,
|
||||
"render": _RENDER_KEYS,
|
||||
"noise": _NOISE_KEYS,
|
||||
}
|
||||
|
||||
_MAX_PROBE_EDGE = 32768
|
||||
|
||||
|
||||
class ProbeConfigError(ValueError):
|
||||
"""Raised when probe metadata or CLI overrides are invalid.
|
||||
|
||||
The message may span several lines; each line is one problem.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProbeFrame:
|
||||
"""One frame of the deformation/rendering sweep."""
|
||||
|
||||
index: int
|
||||
name: str
|
||||
axis: str | None
|
||||
step_mm: float
|
||||
|
||||
def to_job_entry(self) -> dict[str, Any]:
|
||||
"""Return the job entry shared by the deform and render jobs."""
|
||||
return {
|
||||
"index": self.index,
|
||||
"name": self.name,
|
||||
"axis": self.axis,
|
||||
"step_mm": self.step_mm,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PatternInfo(Mapping[str, Any]):
|
||||
"""The marker pattern grid that was in play for a probe run.
|
||||
|
||||
Resolved from the same ``texture_patch`` block ``texture.py`` reads (the
|
||||
defaults are reused, never duplicated). ``sensitivity_report.json`` needs it
|
||||
because the optimizer searches over ``nx``/``ny``/``seed``: a score without
|
||||
the grid that produced it cannot be compared with any other score.
|
||||
|
||||
It is a frozen dataclass (attribute access, equality) *and* a
|
||||
``collections.abc.Mapping``: the sensitivity stage duck-types
|
||||
``settings.pattern`` as a mapping and calls ``.get(...)`` on it, so both
|
||||
access styles are required for that fallback path to work.
|
||||
"""
|
||||
|
||||
nx: int
|
||||
ny: int
|
||||
seed: int | None
|
||||
vector_width_mm: float
|
||||
vector_height_mm: float
|
||||
|
||||
@property
|
||||
def cell_mm(self) -> tuple[float, float]:
|
||||
"""Pattern cell size on the vector page, in millimetres."""
|
||||
return (self.vector_width_mm / self.nx, self.vector_height_mm / self.ny)
|
||||
|
||||
def to_job_entry(self) -> dict[str, Any]:
|
||||
"""Return the JSON-serialisable pattern block recorded in job settings."""
|
||||
return dict(self)
|
||||
|
||||
def _entries(self) -> dict[str, Any]:
|
||||
return {
|
||||
"nx": self.nx,
|
||||
"ny": self.ny,
|
||||
"seed": self.seed,
|
||||
"vector_width_mm": self.vector_width_mm,
|
||||
"vector_height_mm": self.vector_height_mm,
|
||||
"cell_mm": [self.cell_mm[0], self.cell_mm[1]],
|
||||
}
|
||||
|
||||
def __getitem__(self, key: str) -> Any:
|
||||
return self._entries()[key]
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._entries())
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._entries())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProbeSettings:
|
||||
"""Resolved probe settings for one headless run.
|
||||
|
||||
``tracker`` and ``render`` are plain mappings matching the contract's
|
||||
``design.yaml`` ``probe.tracker`` / ``probe.render`` blocks; the render and
|
||||
analysis stages read them directly off this dataclass. ``pattern`` records
|
||||
the marker grid resolved from ``texture_patch``.
|
||||
"""
|
||||
|
||||
mesh: str
|
||||
camera: str
|
||||
group: str
|
||||
uv_map: str
|
||||
texture: str
|
||||
radius_mm: float
|
||||
falloff: str
|
||||
step_mm: float
|
||||
max_mm: float
|
||||
axes: tuple[str, ...]
|
||||
resolution: tuple[int, int]
|
||||
process_resolution: tuple[int, int]
|
||||
tracker: dict[str, Any]
|
||||
render: dict[str, Any]
|
||||
pattern: PatternInfo
|
||||
noise: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_job_settings(self) -> dict[str, Any]:
|
||||
"""Return the deform job's ``settings`` block (contract `_deform_job.json`)."""
|
||||
return {
|
||||
"radius_mm": self.radius_mm,
|
||||
"falloff": self.falloff,
|
||||
"step_mm": self.step_mm,
|
||||
"max_mm": self.max_mm,
|
||||
"axes": list(self.axes),
|
||||
"pattern": self.pattern.to_job_entry(),
|
||||
}
|
||||
|
||||
def to_render_settings(self, texture_path: Path | str) -> dict[str, Any]:
|
||||
"""Return the render job's ``settings`` block (contract `_render_job.json`)."""
|
||||
return {
|
||||
"uv_map": self.uv_map,
|
||||
"texture": str(Path(texture_path).resolve()),
|
||||
"width": self.resolution[0],
|
||||
"height": self.resolution[1],
|
||||
"engine": self.render["engine"],
|
||||
"samples": self.render["samples"],
|
||||
"denoise": self.render["denoise"],
|
||||
"view_transform": self.render["view_transform"],
|
||||
"interpolate": self.render["interpolate"],
|
||||
"seed": self.render["seed"],
|
||||
}
|
||||
|
||||
def to_track_settings(self) -> dict[str, Any]:
|
||||
"""Return the track job's ``settings`` block (contract `_track_job.json`)."""
|
||||
return {
|
||||
"axes": list(self.axes),
|
||||
"step_mm": self.step_mm,
|
||||
"max_mm": self.max_mm,
|
||||
"radius_mm": self.radius_mm,
|
||||
"process_resolution": list(self.process_resolution),
|
||||
"tracker": dict(self.tracker),
|
||||
# Additive: the render-seed floor is a diagnostic, and this block is what
|
||||
# lets the estimator measure signal and noise under the same model.
|
||||
"noise": dict(self.noise),
|
||||
}
|
||||
|
||||
|
||||
def _is_int(value: Any) -> bool:
|
||||
return isinstance(value, int) and not isinstance(value, bool)
|
||||
|
||||
|
||||
def _is_number(value: Any) -> bool:
|
||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||
|
||||
|
||||
def load_probe_block(paths: RevisionPaths) -> dict[str, Any]:
|
||||
"""Return the raw ``probe`` mapping from design.yaml ({} when absent)."""
|
||||
design = load_yaml(paths.design_yaml)
|
||||
raw = design.get("probe")
|
||||
if raw is None:
|
||||
return {}
|
||||
if not isinstance(raw, dict):
|
||||
raise ProbeConfigError(
|
||||
f"{paths.design_yaml}: 'probe' must be a mapping, got {type(raw).__name__}"
|
||||
)
|
||||
return raw
|
||||
|
||||
|
||||
def _check_known_keys(block: dict[str, Any], where: str, errors: list[str]) -> None:
|
||||
unknown = sorted(set(block) - _PROBE_KEYS)
|
||||
if unknown:
|
||||
allowed = ", ".join(sorted(_PROBE_KEYS))
|
||||
errors.append(f"{where}: unknown key(s) {', '.join(unknown)} (allowed: {allowed})")
|
||||
|
||||
|
||||
def merge_probe(
|
||||
paths: RevisionPaths | None,
|
||||
overrides: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Merge built-in defaults ← design.yaml ``probe`` ← CLI overrides.
|
||||
|
||||
Overrides with a value of ``None`` are ignored so an unset CLI flag never
|
||||
erases a configured value. Unknown keys at the top level or inside
|
||||
``tracker``/``render`` are an error.
|
||||
"""
|
||||
merged: dict[str, Any] = {
|
||||
key: (dict(value) if isinstance(value, dict) else value)
|
||||
for key, value in DEFAULT_PROBE.items()
|
||||
}
|
||||
|
||||
errors: list[str] = []
|
||||
blocks: list[tuple[dict[str, Any], str]] = []
|
||||
if paths is not None:
|
||||
blocks.append((load_probe_block(paths), f"{paths.design_yaml} probe"))
|
||||
blocks.append(
|
||||
(
|
||||
{key: value for key, value in (overrides or {}).items() if value is not None},
|
||||
"CLI overrides",
|
||||
)
|
||||
)
|
||||
|
||||
for block, where in blocks:
|
||||
if not block:
|
||||
continue
|
||||
_check_known_keys(block, where, errors)
|
||||
for key, value in block.items():
|
||||
if key not in _PROBE_KEYS:
|
||||
continue
|
||||
if key in _NESTED_KEYS:
|
||||
if not isinstance(value, dict):
|
||||
errors.append(f"{where}.{key}: must be a mapping")
|
||||
continue
|
||||
allowed_nested = _NESTED_KEYS[key]
|
||||
unknown_nested = sorted(set(value) - allowed_nested)
|
||||
if unknown_nested:
|
||||
errors.append(
|
||||
f"{where}.{key}: unknown key(s) {', '.join(unknown_nested)} "
|
||||
f"(allowed: {', '.join(sorted(allowed_nested))})"
|
||||
)
|
||||
for nested_key, nested_value in value.items():
|
||||
if nested_key in allowed_nested:
|
||||
merged[key][nested_key] = nested_value
|
||||
continue
|
||||
merged[key] = value
|
||||
|
||||
if errors:
|
||||
raise ProbeConfigError("\n".join(errors))
|
||||
return merged
|
||||
|
||||
|
||||
def _bare_png_name_problems(value: Any, label: str) -> list[str]:
|
||||
"""Return messages for a value that must be a bare ``.png`` file name."""
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return [f"{label}: must be a non-empty file name"]
|
||||
if (
|
||||
"/" in value
|
||||
or "\\" in value
|
||||
or Path(value).name != value
|
||||
or value in {".", ".."}
|
||||
):
|
||||
return [f"{label}: must be a bare file name without directories ({value!r})"]
|
||||
if Path(value).suffix.lower() != ".png":
|
||||
return [f"{label}: must end with .png ({value!r})"]
|
||||
return []
|
||||
|
||||
|
||||
def _resolution_problems(value: Any, label: str) -> list[str]:
|
||||
if not isinstance(value, (list, tuple)) or len(value) != 2:
|
||||
return [f"{label}: must be a [width, height] pair (got {value!r})"]
|
||||
problems: list[str] = []
|
||||
for axis, raw in zip(("width", "height"), value):
|
||||
if not _is_int(raw) or raw <= 0:
|
||||
problems.append(
|
||||
f"{label}.{axis}: must be a positive integer (got {raw!r})"
|
||||
)
|
||||
elif raw > _MAX_PROBE_EDGE:
|
||||
problems.append(
|
||||
f"{label}.{axis}: must be <= {_MAX_PROBE_EDGE} (got {raw})"
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
def _object_name_problems(value: Any, label: str) -> list[str]:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return [f"{label}: must be a non-empty object name"]
|
||||
return []
|
||||
|
||||
|
||||
def _axes_problems(value: Any) -> list[str]:
|
||||
if not isinstance(value, (list, tuple)) or not value:
|
||||
return ["axes: must be a non-empty list of axis names"]
|
||||
problems: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for axis in value:
|
||||
if not isinstance(axis, str) or axis.strip().lower() not in AXIS_NAMES:
|
||||
problems.append(
|
||||
f"axes: every entry must be one of {', '.join(AXIS_NAMES)} (got {axis!r})"
|
||||
)
|
||||
continue
|
||||
name = axis.strip().lower()
|
||||
if name in seen:
|
||||
problems.append(f"axes: duplicate axis {name!r}")
|
||||
seen.add(name)
|
||||
return problems
|
||||
|
||||
|
||||
def validate_probe(block: dict[str, Any], objects: dict[str, str]) -> list[str]:
|
||||
"""Return one message per invalid value in a probe mapping.
|
||||
|
||||
``block`` is mutated in place only to fill in the object-name defaults from
|
||||
design.yaml's ``objects`` mapping, mirroring ``validate_texture_patch``.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
_check_known_keys(block, "probe", errors)
|
||||
|
||||
for key, default in (
|
||||
("mesh", objects["sensing_surface"]),
|
||||
("camera", objects["cam_sensor"]),
|
||||
("group", "probe"),
|
||||
):
|
||||
value = block.get(key)
|
||||
if value is None:
|
||||
block[key] = default
|
||||
else:
|
||||
errors.extend(_object_name_problems(value, key))
|
||||
|
||||
uv_map = block.get("uv_map")
|
||||
if not isinstance(uv_map, str) or not uv_map.strip():
|
||||
errors.append(f"uv_map: must be a non-empty UV map name (got {uv_map!r})")
|
||||
|
||||
errors.extend(_bare_png_name_problems(block.get("texture"), "texture"))
|
||||
|
||||
radius = block.get("radius_mm")
|
||||
if not _is_number(radius) or float(radius) <= 0.0:
|
||||
errors.append(f"radius_mm: must be a positive number (got {radius!r})")
|
||||
|
||||
falloff = block.get("falloff")
|
||||
if not isinstance(falloff, str) or falloff.strip().lower() not in FALLOFF_MODES:
|
||||
errors.append(
|
||||
f"falloff: must be one of {', '.join(FALLOFF_MODES)} (got {falloff!r})"
|
||||
)
|
||||
|
||||
step = block.get("step_mm")
|
||||
step_ok = _is_number(step) and float(step) > 0.0
|
||||
if not step_ok:
|
||||
errors.append(f"step_mm: must be a positive number (got {step!r})")
|
||||
|
||||
max_mm = block.get("max_mm")
|
||||
max_ok = _is_number(max_mm) and float(max_mm) > 0.0
|
||||
if not max_ok:
|
||||
errors.append(f"max_mm: must be a positive number (got {max_mm!r})")
|
||||
|
||||
if step_ok and max_ok and float(step) > float(max_mm):
|
||||
errors.append(
|
||||
f"max_mm: must be >= step_mm (got max_mm={max_mm!r}, step_mm={step!r})"
|
||||
)
|
||||
|
||||
errors.extend(_axes_problems(block.get("axes")))
|
||||
|
||||
for key in ("resolution", "process_resolution"):
|
||||
errors.extend(_resolution_problems(block.get(key), key))
|
||||
|
||||
tracker = block.get("tracker")
|
||||
if not isinstance(tracker, dict):
|
||||
errors.append("tracker: must be a mapping")
|
||||
else:
|
||||
unknown = sorted(set(tracker) - _TRACKER_KEYS)
|
||||
if unknown:
|
||||
errors.append(
|
||||
f"tracker: unknown key(s) {', '.join(unknown)} "
|
||||
f"(allowed: {', '.join(sorted(_TRACKER_KEYS))})"
|
||||
)
|
||||
algorithm = tracker.get("algorithm")
|
||||
if not isinstance(algorithm, str) or not algorithm.strip():
|
||||
errors.append(
|
||||
f"tracker.algorithm: must be a non-empty name (got {algorithm!r})"
|
||||
)
|
||||
preset = tracker.get("dis_preset")
|
||||
if not isinstance(preset, str) or not preset.strip():
|
||||
errors.append(
|
||||
f"tracker.dis_preset: must be a non-empty name (got {preset!r})"
|
||||
)
|
||||
dis_mode = tracker.get("dis_mode")
|
||||
if not _is_int(dis_mode) or dis_mode < 0:
|
||||
errors.append(
|
||||
f"tracker.dis_mode: must be an integer >= 0 (got {dis_mode!r})"
|
||||
)
|
||||
|
||||
render = block.get("render")
|
||||
if not isinstance(render, dict):
|
||||
errors.append("render: must be a mapping")
|
||||
else:
|
||||
unknown = sorted(set(render) - _RENDER_KEYS)
|
||||
if unknown:
|
||||
errors.append(
|
||||
f"render: unknown key(s) {', '.join(unknown)} "
|
||||
f"(allowed: {', '.join(sorted(_RENDER_KEYS))})"
|
||||
)
|
||||
engine = render.get("engine")
|
||||
if not isinstance(engine, str) or not engine.strip():
|
||||
errors.append(f"render.engine: must be a non-empty name (got {engine!r})")
|
||||
samples = render.get("samples")
|
||||
if not _is_int(samples) or samples < 1:
|
||||
errors.append(
|
||||
f"render.samples: must be an integer >= 1 (got {samples!r})"
|
||||
)
|
||||
if not isinstance(render.get("denoise"), bool):
|
||||
errors.append(
|
||||
f"render.denoise: must be true or false (got {render.get('denoise')!r})"
|
||||
)
|
||||
view_transform = render.get("view_transform")
|
||||
if not isinstance(view_transform, str) or not view_transform.strip():
|
||||
errors.append(
|
||||
f"render.view_transform: must be a non-empty name (got {view_transform!r})"
|
||||
)
|
||||
elif view_transform.strip() != "Standard":
|
||||
# AgX/Filmic silently destroys marker values; the contract makes this
|
||||
# a loud failure rather than a warning.
|
||||
errors.append(
|
||||
"render.view_transform: must be 'Standard' "
|
||||
f"(got {view_transform!r}); AgX/Filmic distorts marker values"
|
||||
)
|
||||
interpolate = render.get("interpolate")
|
||||
if not isinstance(interpolate, str) or not interpolate.strip():
|
||||
errors.append(
|
||||
f"render.interpolate: must be a non-empty name (got {interpolate!r})"
|
||||
)
|
||||
if not _is_int(render.get("seed")):
|
||||
errors.append(f"render.seed: must be an integer (got {render.get('seed')!r})")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def resolve_pattern(paths: RevisionPaths) -> PatternInfo:
|
||||
"""Resolve the marker pattern grid from the shared ``texture_patch`` block.
|
||||
|
||||
Defaults are reused from ``texture.DEFAULT_TEXTURE_PATCH`` (via
|
||||
``texture.merge_texture_patch``); this module never re-declares them. Raises
|
||||
``ProbeConfigError`` so callers only need one config exception type.
|
||||
"""
|
||||
try:
|
||||
block = merge_texture_patch(paths)
|
||||
except TextureConfigError as exc:
|
||||
raise ProbeConfigError(str(exc)) from exc
|
||||
|
||||
errors: list[str] = []
|
||||
nx = block.get("nx")
|
||||
ny = block.get("ny")
|
||||
seed = block.get("seed")
|
||||
if not _is_int(nx) or nx < 1:
|
||||
errors.append(f"texture_patch.nx: must be an integer >= 1 (got {nx!r})")
|
||||
if not _is_int(ny) or ny < 1:
|
||||
errors.append(f"texture_patch.ny: must be an integer >= 1 (got {ny!r})")
|
||||
if seed is not None and not _is_int(seed):
|
||||
errors.append(f"texture_patch.seed: must be an integer or null (got {seed!r})")
|
||||
|
||||
vector = block.get("vector")
|
||||
width: Any = None
|
||||
height: Any = None
|
||||
if not isinstance(vector, dict):
|
||||
errors.append("texture_patch.vector: must be a mapping of page size in mm")
|
||||
else:
|
||||
width = vector.get("width_mm")
|
||||
height = vector.get("height_mm")
|
||||
if not _is_number(width) or float(width) <= 0.0:
|
||||
errors.append(
|
||||
f"texture_patch.vector.width_mm: must be a positive number (got {width!r})"
|
||||
)
|
||||
if not _is_number(height) or float(height) <= 0.0:
|
||||
errors.append(
|
||||
f"texture_patch.vector.height_mm: must be a positive number (got {height!r})"
|
||||
)
|
||||
|
||||
if errors:
|
||||
raise ProbeConfigError("\n".join(errors))
|
||||
return PatternInfo(
|
||||
nx=int(nx),
|
||||
ny=int(ny),
|
||||
seed=None if seed is None else int(seed),
|
||||
vector_width_mm=float(width),
|
||||
vector_height_mm=float(height),
|
||||
)
|
||||
|
||||
|
||||
def resolve_probe(
|
||||
paths: RevisionPaths,
|
||||
overrides: dict[str, Any] | None = None,
|
||||
) -> ProbeSettings:
|
||||
"""Merge and validate settings for a headless probe-deformation run."""
|
||||
block = merge_probe(paths, overrides)
|
||||
objects = export_object_names(paths)
|
||||
errors = validate_probe(block, objects)
|
||||
if errors:
|
||||
raise ProbeConfigError("\n".join(errors))
|
||||
|
||||
tracker = block["tracker"]
|
||||
render = block["render"]
|
||||
return ProbeSettings(
|
||||
mesh=str(block["mesh"]),
|
||||
camera=str(block["camera"]),
|
||||
group=str(block["group"]),
|
||||
uv_map=str(block["uv_map"]),
|
||||
texture=str(block["texture"]),
|
||||
radius_mm=float(block["radius_mm"]),
|
||||
falloff=str(block["falloff"]).strip().lower(),
|
||||
step_mm=float(block["step_mm"]),
|
||||
max_mm=float(block["max_mm"]),
|
||||
axes=tuple(str(axis).strip().lower() for axis in block["axes"]),
|
||||
resolution=(int(block["resolution"][0]), int(block["resolution"][1])),
|
||||
process_resolution=(
|
||||
int(block["process_resolution"][0]),
|
||||
int(block["process_resolution"][1]),
|
||||
),
|
||||
tracker={
|
||||
"algorithm": str(tracker["algorithm"]).strip().lower(),
|
||||
"dis_preset": str(tracker["dis_preset"]).strip().lower(),
|
||||
"dis_mode": int(tracker["dis_mode"]),
|
||||
},
|
||||
render={
|
||||
"engine": str(render["engine"]),
|
||||
"samples": int(render["samples"]),
|
||||
"denoise": bool(render["denoise"]),
|
||||
"view_transform": str(render["view_transform"]),
|
||||
"interpolate": str(render["interpolate"]),
|
||||
"seed": int(render["seed"]),
|
||||
},
|
||||
pattern=resolve_pattern(paths),
|
||||
noise=dict(block["noise"]),
|
||||
)
|
||||
|
||||
|
||||
def _step_decimals(step_mm: float) -> int:
|
||||
"""Decimals needed to name a step, never fewer than the contract's two.
|
||||
|
||||
``+0.01mm`` and ``+0.10mm`` in the frozen frame table are 2-decimal names;
|
||||
a finer configured step (for example 0.005) widens every name in that plan
|
||||
to 3 decimals instead of mixing widths or rounding into a duplicate name.
|
||||
"""
|
||||
text = repr(float(step_mm))
|
||||
if "e" in text or "E" in text:
|
||||
return 6
|
||||
decimals = len(text.split(".", 1)[1]) if "." in text else 0
|
||||
return max(2, decimals)
|
||||
|
||||
|
||||
def _frame_name(index: int, axis: str, step_mm: float, decimals: int) -> str:
|
||||
return f"{index:03d}_{axis}_+{step_mm:.{decimals}f}mm"
|
||||
|
||||
|
||||
def frame_plan(settings: ProbeSettings) -> list[ProbeFrame]:
|
||||
"""Return the resolved sweep: reference, then axis-major, step-minor.
|
||||
|
||||
``F = 1 + len(axes) * round(max_mm / step_mm)`` (31 for the defaults). The
|
||||
reference frame is index 0 with ``axis=None`` and ``step_mm=0.0``. Steps
|
||||
stop at the last multiple of ``step_mm`` that does not exceed ``max_mm``.
|
||||
"""
|
||||
if settings.step_mm <= 0.0:
|
||||
raise ProbeConfigError(f"step_mm must be > 0 (got {settings.step_mm!r})")
|
||||
if settings.max_mm <= 0.0:
|
||||
raise ProbeConfigError(f"max_mm must be > 0 (got {settings.max_mm!r})")
|
||||
if not settings.axes:
|
||||
raise ProbeConfigError("axes must not be empty")
|
||||
|
||||
count = int(round(settings.max_mm / settings.step_mm))
|
||||
while count > 0 and settings.step_mm * count > settings.max_mm + 1e-9:
|
||||
count -= 1
|
||||
if count < 1:
|
||||
raise ProbeConfigError(
|
||||
f"max_mm ({settings.max_mm}) must be at least step_mm ({settings.step_mm})"
|
||||
)
|
||||
|
||||
frames = [ProbeFrame(index=0, name="000_reference", axis=None, step_mm=0.0)]
|
||||
index = 1
|
||||
decimals = _step_decimals(settings.step_mm)
|
||||
for axis in settings.axes:
|
||||
for multiple in range(1, count + 1):
|
||||
step_mm = round(settings.step_mm * multiple, 12)
|
||||
frames.append(
|
||||
ProbeFrame(
|
||||
index=index,
|
||||
name=_frame_name(index, axis, step_mm, decimals),
|
||||
axis=axis,
|
||||
step_mm=step_mm,
|
||||
)
|
||||
)
|
||||
index += 1
|
||||
return frames
|
||||
|
||||
|
||||
def probe_revision_rel(root: Path, paths: RevisionPaths) -> Path:
|
||||
"""Return ``<sensor>/<revision>`` for the revision below the repo's sensors/.
|
||||
|
||||
Reuses ``texture.texture_revision_rel`` so probe output follows exactly the
|
||||
same on-disk revision naming as the texture stage.
|
||||
"""
|
||||
return texture_revision_rel(root, paths)
|
||||
|
||||
|
||||
def probe_output_dir(root: Path, paths: RevisionPaths) -> Path:
|
||||
"""Default output directory: build/probe/<sensor>/<revision>."""
|
||||
return (root / PROBE_RELATIVE_ROOT / probe_revision_rel(root, paths)).resolve()
|
||||
|
||||
|
||||
def resolve_probe_output_dir(
|
||||
root: Path,
|
||||
paths: RevisionPaths,
|
||||
output_dir: Path | None = None,
|
||||
) -> Path:
|
||||
"""Resolve the probe output directory (explicit --output-dir wins)."""
|
||||
if output_dir is None:
|
||||
return probe_output_dir(root, paths)
|
||||
return (Path(output_dir).expanduser()).resolve()
|
||||
|
||||
|
||||
def deform_job_path(output_dir: Path) -> Path:
|
||||
"""Return the job file path written beside the deform artifacts."""
|
||||
return Path(output_dir) / DEFORM_JOB_FILENAME
|
||||
|
||||
|
||||
def probe_artifact_paths(output_dir: Path) -> dict[str, Path]:
|
||||
"""Return the absolute ``probe_frames.npz`` / ``deform_report.json`` paths."""
|
||||
resolved_dir = Path(output_dir).resolve()
|
||||
return {
|
||||
"frames": resolved_dir / FRAMES_NPZ_FILENAME,
|
||||
"report": resolved_dir / DEFORM_REPORT_FILENAME,
|
||||
}
|
||||
|
||||
|
||||
def build_deform_job(
|
||||
paths: RevisionPaths,
|
||||
settings: ProbeSettings,
|
||||
output_dir: Path,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the JSON job dict that probe_deform.py consumes (stdlib JSON)."""
|
||||
resolved_dir = Path(output_dir).resolve()
|
||||
return {
|
||||
"schema": DEFORM_JOB_SCHEMA,
|
||||
"revision_dir": str(paths.root.resolve()),
|
||||
"source_blend": str(paths.source_blend.resolve()),
|
||||
"output_dir": str(resolved_dir),
|
||||
"objects": {
|
||||
"mesh": settings.mesh,
|
||||
"group": settings.group,
|
||||
},
|
||||
"settings": settings.to_job_settings(),
|
||||
"frames": [frame.to_job_entry() for frame in frame_plan(settings)],
|
||||
"artifacts": {
|
||||
key: str(path) for key, path in probe_artifact_paths(resolved_dir).items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def write_deform_job(job: dict[str, Any], output_dir: Path) -> Path:
|
||||
"""Write _deform_job.json into the output directory; return its path."""
|
||||
output_dir = Path(output_dir)
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
job_path = deform_job_path(output_dir)
|
||||
job_path.write_text(json.dumps(job, indent=2) + "\n", encoding="utf-8")
|
||||
return job_path
|
||||
|
||||
|
||||
def probe_overrides_from_args(args: Any) -> dict[str, Any]:
|
||||
"""Collect CLI override values from an argparse namespace (None = unset)."""
|
||||
overrides: dict[str, Any] = {
|
||||
"mesh": getattr(args, "mesh", None),
|
||||
"group": getattr(args, "group", None),
|
||||
"radius_mm": getattr(args, "radius_mm", None),
|
||||
"falloff": getattr(args, "falloff", None),
|
||||
"step_mm": getattr(args, "step_mm", None),
|
||||
"max_mm": getattr(args, "max_mm", None),
|
||||
"axes": None,
|
||||
}
|
||||
axes = getattr(args, "axes", None)
|
||||
if axes is not None:
|
||||
names: Sequence[Any]
|
||||
if isinstance(axes, str):
|
||||
names = [name.strip() for name in axes.split(",")]
|
||||
else:
|
||||
names = list(axes)
|
||||
overrides["axes"] = [name for name in names if name and str(name).strip()]
|
||||
return overrides
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Measure the true zero-input optical-flow floor from real idle sensor footage.
|
||||
|
||||
The probe bench's ``floor_px`` is measured as ``reference`` vs a re-render at
|
||||
``seed + 1``. That is Cycles antialiasing jitter: on the real data it differs by
|
||||
~5 grey levels RMS on half the pixels, scaling with edge contrast. It is **not** a
|
||||
sensor-noise model, and it is demonstrably the wrong thing to divide a signal by -- the
|
||||
ordering of patterns by that floor reverses under additive noise.
|
||||
|
||||
This module measures the real quantity instead. Given idle footage (no contact), it runs
|
||||
the *same* DIS fixed-reference estimator the bench uses and reports how much apparent
|
||||
flow appears when nothing is moving. Two baselines are measured on purpose:
|
||||
|
||||
* **long** -- frame 0 versus every later frame, which accumulates any slow drift
|
||||
(illumination, thermal, creep) as well as noise;
|
||||
* **short** -- each frame versus its successor, which is noise-dominated.
|
||||
|
||||
``drift_ratio = long / short`` therefore separates the two. On the dd02 idle clip it is
|
||||
~0.93 (short is slightly *larger*), i.e. the floor is frame-independent white noise and
|
||||
the clip is genuinely idle -- which is what makes it a usable calibration target.
|
||||
|
||||
Everything is expressed in pixels, and scale-invariantly as ``floor_per_period``: the
|
||||
floor divided by the dominant marker period, so a 1280x720 recording can be compared with
|
||||
a 500x500 render. That ratio is the number the bench should be calibrated against.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Mapping, Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
#: Default ROI radius as a fraction of the shorter image side.
|
||||
DEFAULT_ROI_FRACTION = 0.18
|
||||
DEFAULT_MAX_FRAMES = 120
|
||||
#: Marker-period search band, in pixels. Wide enough to cover a 4 px to 120 px pitch.
|
||||
DEFAULT_PERIOD_BAND = (4.0, 120.0)
|
||||
#: High-pass sigma used before the spectrum, in pixels: removes illumination gradients.
|
||||
DEFAULT_HIGHPASS_SIGMA = 12.0
|
||||
|
||||
IMAGE_SUFFIXES = (".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff")
|
||||
|
||||
|
||||
class FloorCalibrationError(RuntimeError):
|
||||
"""Idle footage could not be read, or held too few usable frames."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IdleFloor:
|
||||
"""The measured real zero-input floor for one recording."""
|
||||
|
||||
source: str
|
||||
frames: int
|
||||
width: int
|
||||
height: int
|
||||
roi_radius_px: float
|
||||
roi_pixels: int
|
||||
#: Median over frame pairs of the p95 |flow| inside the ROI.
|
||||
floor_px_long: float
|
||||
floor_px_short: float
|
||||
#: Peak of the radial power spectrum, in pixels of marker period.
|
||||
dominant_period_px: float
|
||||
floor_per_period_short: float
|
||||
floor_per_period_long: float
|
||||
drift_ratio: float
|
||||
|
||||
def to_json(self) -> dict[str, Any]:
|
||||
return {
|
||||
"source": self.source,
|
||||
"frames": int(self.frames),
|
||||
"width": int(self.width),
|
||||
"height": int(self.height),
|
||||
"roi": {
|
||||
"u": self.width / 2.0,
|
||||
"v": self.height / 2.0,
|
||||
"radius": float(self.roi_radius_px),
|
||||
"pixels": int(self.roi_pixels),
|
||||
},
|
||||
"floor_px_long": float(self.floor_px_long),
|
||||
"floor_px_short": float(self.floor_px_short),
|
||||
"dominant_period_px": float(self.dominant_period_px),
|
||||
"floor_per_period_short": float(self.floor_per_period_short),
|
||||
"floor_per_period_long": float(self.floor_per_period_long),
|
||||
"drift_ratio": float(self.drift_ratio),
|
||||
"basis": (
|
||||
"real idle footage: DIS fixed-reference p95 |flow| over the ROI; "
|
||||
"short = consecutive frames (noise), long = versus frame 0 (drift+noise)"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def load_gray_frames(
|
||||
source: str | Path,
|
||||
*,
|
||||
max_frames: int = DEFAULT_MAX_FRAMES,
|
||||
stride: int = 1,
|
||||
) -> list[np.ndarray]:
|
||||
"""Read up to ``max_frames`` grayscale frames from a video or an image directory."""
|
||||
source = Path(source)
|
||||
if source.is_dir():
|
||||
paths = sorted(
|
||||
path for path in source.iterdir()
|
||||
if path.suffix.lower() in IMAGE_SUFFIXES
|
||||
)[:: max(1, int(stride))][: int(max_frames)]
|
||||
frames = []
|
||||
for path in paths:
|
||||
image = _read_gray(path)
|
||||
if image is not None:
|
||||
frames.append(image)
|
||||
if not frames:
|
||||
raise FloorCalibrationError(f"no readable images in {source}")
|
||||
return frames
|
||||
|
||||
if not source.is_file():
|
||||
raise FloorCalibrationError(f"idle footage not found: {source}")
|
||||
|
||||
try:
|
||||
import cv2
|
||||
except ImportError as exc: # pragma: no cover - optional dependency path
|
||||
raise FloorCalibrationError(
|
||||
"reading idle footage needs OpenCV: python -m pip install -e .[probe]"
|
||||
) from exc
|
||||
|
||||
capture = cv2.VideoCapture(str(source))
|
||||
if not capture.isOpened():
|
||||
raise FloorCalibrationError(f"cannot open idle footage: {source}")
|
||||
frames = []
|
||||
index = 0
|
||||
try:
|
||||
while len(frames) < int(max_frames):
|
||||
ok, frame = capture.read()
|
||||
if not ok or frame is None:
|
||||
break
|
||||
if index % max(1, int(stride)) == 0:
|
||||
gray = frame if frame.ndim == 2 else cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
frames.append(np.asarray(gray))
|
||||
index += 1
|
||||
finally:
|
||||
capture.release()
|
||||
if len(frames) < 2:
|
||||
raise FloorCalibrationError(f"idle footage yielded {len(frames)} frame(s): {source}")
|
||||
return frames
|
||||
|
||||
|
||||
def _read_gray(path: Path) -> np.ndarray | None:
|
||||
try:
|
||||
import cv2
|
||||
|
||||
image = cv2.imread(str(path), cv2.IMREAD_GRAYSCALE)
|
||||
return None if image is None else image
|
||||
except ImportError: # pragma: no cover
|
||||
return None
|
||||
|
||||
|
||||
def dominant_period_px(
|
||||
gray: np.ndarray,
|
||||
*,
|
||||
band: Sequence[float] = DEFAULT_PERIOD_BAND,
|
||||
highpass_sigma: float = DEFAULT_HIGHPASS_SIGMA,
|
||||
) -> float | None:
|
||||
"""Return the dominant spatial period of an image, in pixels.
|
||||
|
||||
The image is high-passed first so illumination gradients do not dominate, then the
|
||||
radially averaged power spectrum is searched inside ``band``. Period is taken from the
|
||||
radial frequency directly (``1 / r`` with ``r`` in cycles per pixel), so no
|
||||
bin-to-period heuristics are involved.
|
||||
"""
|
||||
try:
|
||||
import cv2
|
||||
except ImportError: # pragma: no cover
|
||||
return None
|
||||
|
||||
array = np.asarray(gray, dtype=np.float32)
|
||||
height, width = array.shape[:2]
|
||||
if height < 16 or width < 16:
|
||||
return None
|
||||
flattened = array - cv2.GaussianBlur(array, (0, 0), float(highpass_sigma))
|
||||
window = np.outer(np.hanning(height), np.hanning(width)).astype(np.float32)
|
||||
spectrum = np.abs(np.fft.fftshift(np.fft.fft2(flattened * window))) ** 2
|
||||
|
||||
yy, xx = np.mgrid[0:height, 0:width]
|
||||
# Radial frequency in cycles per pixel, normalised per axis so the two halves of a
|
||||
# non-square image are comparable.
|
||||
radial = np.hypot((yy - height // 2) / float(height), (xx - width // 2) / float(width))
|
||||
low, high = float(band[0]), float(band[1])
|
||||
if low <= 0 or high <= low:
|
||||
return None
|
||||
r_lo, r_hi = 1.0 / high, 1.0 / low
|
||||
selected = (radial >= r_lo) & (radial <= r_hi)
|
||||
if not np.any(selected):
|
||||
return None
|
||||
|
||||
bins = np.clip((radial[selected] - r_lo) / (r_hi - r_lo) * 512.0, 0, 511).astype(np.int32)
|
||||
sums = np.bincount(bins, weights=spectrum[selected], minlength=512)
|
||||
counts = np.bincount(bins, minlength=512)
|
||||
profile = sums / np.maximum(counts, 1)
|
||||
peak = int(np.argmax(profile))
|
||||
r_peak = r_lo + (peak + 0.5) / 512.0 * (r_hi - r_lo)
|
||||
if r_peak <= 0:
|
||||
return None
|
||||
return float(1.0 / r_peak)
|
||||
|
||||
|
||||
def measure_idle_floor(
|
||||
source: str | Path,
|
||||
*,
|
||||
roi_fraction: float = DEFAULT_ROI_FRACTION,
|
||||
max_frames: int = DEFAULT_MAX_FRAMES,
|
||||
stride: int = 1,
|
||||
tracker: Mapping[str, Any] | None = None,
|
||||
period_band: Sequence[float] = DEFAULT_PERIOD_BAND,
|
||||
) -> IdleFloor:
|
||||
"""Measure the real zero-input flow floor on idle footage.
|
||||
|
||||
Runs the bench's own ``FixedReferenceFlow`` with the configured tracker settings, so
|
||||
the number is directly comparable with a simulated ``floor_px``.
|
||||
"""
|
||||
from orisys_sensor_design.flow import FixedReferenceFlow, region_masks
|
||||
|
||||
frames = load_gray_frames(source, max_frames=max_frames, stride=stride)
|
||||
if len(frames) < 2:
|
||||
# The video path already enforces this; the image-directory path must too, or a
|
||||
# single frame yields an empty short baseline and a silent NaN floor.
|
||||
raise FloorCalibrationError(
|
||||
f"idle footage yielded {len(frames)} frame(s); at least 2 are needed to "
|
||||
f"measure a floor: {source}"
|
||||
)
|
||||
height, width = frames[0].shape[:2]
|
||||
radius = float(roi_fraction) * float(min(height, width))
|
||||
mask, _ = region_masks((height, width), (width / 2.0, height / 2.0), radius)
|
||||
if not np.any(mask):
|
||||
raise FloorCalibrationError(
|
||||
f"the ROI (r={radius:.1f} px) is empty on a {width}x{height} frame"
|
||||
)
|
||||
|
||||
config = dict(tracker or {"dis_preset": "medium", "dis_mode": 1})
|
||||
|
||||
def p95_flow(reference: np.ndarray, moving: np.ndarray) -> float:
|
||||
estimator = FixedReferenceFlow(config)
|
||||
estimator.set_reference(reference)
|
||||
flow = estimator.compute(moving)
|
||||
magnitude = np.linalg.norm(np.asarray(flow, dtype=float), axis=-1)
|
||||
return float(np.percentile(magnitude[mask], 95))
|
||||
|
||||
long_values = [p95_flow(frames[0], frames[k]) for k in range(1, len(frames))]
|
||||
short_values = [p95_flow(frames[k], frames[k + 1]) for k in range(len(frames) - 1)]
|
||||
floor_long = float(np.median(long_values))
|
||||
floor_short = float(np.median(short_values))
|
||||
|
||||
period = dominant_period_px(frames[0], band=period_band) or float("nan")
|
||||
return IdleFloor(
|
||||
source=str(Path(source)),
|
||||
frames=len(frames),
|
||||
width=int(width),
|
||||
height=int(height),
|
||||
roi_radius_px=radius,
|
||||
roi_pixels=int(mask.sum()),
|
||||
floor_px_long=floor_long,
|
||||
floor_px_short=floor_short,
|
||||
dominant_period_px=period,
|
||||
floor_per_period_short=float(floor_short / period) if period else float("nan"),
|
||||
floor_per_period_long=float(floor_long / period) if period else float("nan"),
|
||||
drift_ratio=float(floor_long / floor_short) if floor_short else float("inf"),
|
||||
)
|
||||
|
||||
|
||||
def resolve_idle_source(root: Path, configured: str | None) -> Path | None:
|
||||
"""Resolve ``probe.noise.idle_footage`` against the repo root, tolerating absence."""
|
||||
if not configured:
|
||||
return None
|
||||
candidate = Path(str(configured)).expanduser()
|
||||
if not candidate.is_absolute():
|
||||
candidate = (Path(root) / candidate)
|
||||
candidate = candidate.resolve()
|
||||
return candidate if candidate.exists() else None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_HIGHPASS_SIGMA",
|
||||
"DEFAULT_MAX_FRAMES",
|
||||
"DEFAULT_PERIOD_BAND",
|
||||
"DEFAULT_ROI_FRACTION",
|
||||
"FloorCalibrationError",
|
||||
"IdleFloor",
|
||||
"dominant_period_px",
|
||||
"load_gray_frames",
|
||||
"measure_idle_floor",
|
||||
"resolve_idle_source",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -0,0 +1,636 @@
|
||||
"""Optical-flow figures for the probe bench: what the estimator actually measured.
|
||||
|
||||
Three figures, all built from artifacts the pipeline already wrote (rendered frames,
|
||||
``flow/<scale>/*.npy``, the sensitivity report) so a figure explains a measurement
|
||||
rather than adding one:
|
||||
|
||||
``render_flow_comparison``
|
||||
One column per setting. The top row is the rendered reference around the probe
|
||||
with the ROI, the marker cell grid and the estimator's **effective window** drawn
|
||||
on it, plus the applied-displacement arrow against the measured one. The bottom
|
||||
row is the measured ``|flow|`` field on a shared colour scale. This is the figure
|
||||
that shows *why* one pattern tracks and another does not.
|
||||
|
||||
``render_tuning``
|
||||
Score against the **camera-space** marker cell, one series per scale, with the
|
||||
nominal patch and the effective window marked. Because the pattern grid is defined
|
||||
across the camera image, the cell is ``width / nx``; an earlier millimetre-derived
|
||||
cell was about half that and made the measured optimum look like it contradicted
|
||||
``dis_mode``.
|
||||
|
||||
``render_response_curves``
|
||||
Response against applied displacement per pattern, with the estimator's zero-input
|
||||
floor as a band and the ideal 1:1 recovery as a diagonal, so damping, the
|
||||
resolvable threshold and any saturation are visible together.
|
||||
|
||||
matplotlib is imported lazily and only inside the functions, and ``Agg`` is selected
|
||||
before ``pyplot``, so the core pipeline keeps working without matplotlib and the
|
||||
figures still render headlessly — the same contract ``optics_plot.py`` keeps.
|
||||
|
||||
Every figure is defensive on purpose: a panel whose artifact is missing is replaced by
|
||||
a text panel naming the gap instead of being dropped or silently rescaled, because a
|
||||
comparison figure that quietly omits a failed setting is worse than no figure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
#: Default half-width of the crop drawn around the probe, in pixels.
|
||||
DEFAULT_CROP_PX = 26
|
||||
#: Cell/window ratios inside this band are where the measured optima sit.
|
||||
USABLE_RATIO_BAND = (0.4, 2.5)
|
||||
|
||||
_APPLIED_COLOR = "#1f77b4"
|
||||
_MEASURED_COLOR = "#d62728"
|
||||
_ROI_COLOR = "#2ca02c"
|
||||
_WINDOW_COLOR = "#9467bd"
|
||||
_CELL_COLOR = "#ff7f0e"
|
||||
|
||||
|
||||
def _pyplot():
|
||||
"""Import matplotlib lazily and force the headless Agg backend."""
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
return plt
|
||||
|
||||
|
||||
def _crop(array: np.ndarray, centre: Sequence[float], half: int) -> tuple[np.ndarray, tuple[int, int]]:
|
||||
"""Return a clamped crop around ``centre`` and its ``(x0, y0)`` origin."""
|
||||
height, width = array.shape[:2]
|
||||
cu, cv = float(centre[0]), float(centre[1])
|
||||
x0 = int(max(0, min(width - 1, round(cu) - half)))
|
||||
x1 = int(max(x0 + 1, min(width, round(cu) + half)))
|
||||
y0 = int(max(0, min(height - 1, round(cv) - half)))
|
||||
y1 = int(max(y0 + 1, min(height, round(cv) + half)))
|
||||
return array[y0:y1, x0:x1], (x0, y0)
|
||||
|
||||
|
||||
def _load_reference(panel: Mapping[str, Any]) -> np.ndarray | None:
|
||||
"""Load a panel's rendered reference frame as a 2-D grey array, or None."""
|
||||
path = panel.get("reference_png")
|
||||
if path is None:
|
||||
return None
|
||||
path = Path(path)
|
||||
if not path.is_file():
|
||||
return None
|
||||
try:
|
||||
import matplotlib.image as mpimg
|
||||
|
||||
image = mpimg.imread(path)
|
||||
except Exception:
|
||||
return None
|
||||
array = np.asarray(image, dtype=float)
|
||||
if array.ndim == 3:
|
||||
array = array[..., :3].mean(axis=2)
|
||||
return array
|
||||
|
||||
|
||||
def _text_panel(axes, title: str, lines: Sequence[str]) -> None:
|
||||
"""Draw a placeholder panel naming what is missing."""
|
||||
axes.set_title(title, fontsize=9, color="#b22222")
|
||||
axes.text(
|
||||
0.5,
|
||||
0.5,
|
||||
"\n".join(lines),
|
||||
ha="center",
|
||||
va="center",
|
||||
fontsize=8,
|
||||
color="#b22222",
|
||||
transform=axes.transAxes,
|
||||
wrap=True,
|
||||
)
|
||||
axes.set_xticks([])
|
||||
axes.set_yticks([])
|
||||
|
||||
|
||||
def render_flow_comparison(
|
||||
*,
|
||||
panels: Sequence[Mapping[str, Any]],
|
||||
out_path: Path,
|
||||
title: str = "Measured optical flow per setting",
|
||||
crop_px: int = DEFAULT_CROP_PX,
|
||||
quiver_stride: int = 4,
|
||||
dpi: int = 150,
|
||||
) -> Path:
|
||||
"""Write the flow-comparison figure and return its path.
|
||||
|
||||
Each panel supplies ``label``, ``flow`` (H,W,2 in px), optionally
|
||||
``reference_png``, ``roi`` ({u, v, radius}), ``cell_px``, ``effective_patch_px``,
|
||||
``applied_px``, ``response_px``, ``scale_note`` and ``note``.
|
||||
"""
|
||||
plt = _pyplot()
|
||||
out_path = Path(out_path)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
columns = max(1, len(panels))
|
||||
|
||||
magnitudes = []
|
||||
for panel in panels:
|
||||
flow = panel.get("flow")
|
||||
if flow is not None:
|
||||
magnitudes.append(float(np.nanmax(np.linalg.norm(np.asarray(flow, dtype=float), axis=-1))))
|
||||
vmax = max(magnitudes) if magnitudes else 1.0
|
||||
vmax = max(vmax, 1e-6)
|
||||
|
||||
figure, grid = plt.subplots(
|
||||
2, columns, figsize=(4.2 * columns, 6.6), dpi=dpi, squeeze=False,
|
||||
gridspec_kw={"height_ratios": [1.0, 1.15]},
|
||||
)
|
||||
figure.suptitle(title, fontsize=13)
|
||||
|
||||
for index, panel in enumerate(panels):
|
||||
top = grid[0][index]
|
||||
bottom = grid[1][index]
|
||||
label = str(panel.get("label") or f"setting {index + 1}")
|
||||
|
||||
flow = panel.get("flow")
|
||||
roi = panel.get("roi") or {}
|
||||
centre = (float(roi.get("u", 0.0)), float(roi.get("v", 0.0)))
|
||||
radius = float(roi.get("radius", 0.0))
|
||||
|
||||
if flow is None:
|
||||
_text_panel(top, label, ["no flow array", str(panel.get("note") or "")])
|
||||
_text_panel(bottom, "", [""])
|
||||
continue
|
||||
|
||||
flow = np.asarray(flow, dtype=float)
|
||||
magnitude = np.linalg.norm(flow, axis=-1)
|
||||
reference = _load_reference(panel)
|
||||
|
||||
# ------------------------------------------------------------- top row
|
||||
crop_half = int(crop_px)
|
||||
if reference is not None:
|
||||
crop, (x0, y0) = _crop(reference, centre, crop_half)
|
||||
top.imshow(crop, cmap="gray", origin="upper", vmin=0.0, vmax=1.0)
|
||||
else:
|
||||
x0 = int(round(centre[0])) - crop_half
|
||||
y0 = int(round(centre[1])) - crop_half
|
||||
top.imshow(
|
||||
np.zeros((2 * crop_half, 2 * crop_half)), cmap="gray", origin="upper",
|
||||
vmin=0.0, vmax=1.0,
|
||||
)
|
||||
top.text(
|
||||
0.5, 0.02, "rendered frame unavailable", ha="center", va="bottom",
|
||||
fontsize=7, color="#b22222", transform=top.transAxes,
|
||||
)
|
||||
|
||||
# Marker cell grid: drawn at the camera-space pitch so the cell and the
|
||||
# estimator window can be compared by eye.
|
||||
cell_px = panel.get("cell_px")
|
||||
if cell_px:
|
||||
step = float(cell_px)
|
||||
if step >= 2.0:
|
||||
span = range(int(np.floor((x0) / step)), int(np.ceil((x0 + 2 * crop_half) / step)) + 1)
|
||||
for k in span:
|
||||
top.axvline(k * step - x0, color=_CELL_COLOR, lw=0.4, alpha=0.45)
|
||||
top.axhline(k * step - y0, color=_CELL_COLOR, lw=0.4, alpha=0.45)
|
||||
|
||||
window = panel.get("effective_patch_px")
|
||||
if window:
|
||||
half = float(window) / 2.0
|
||||
top.add_patch(
|
||||
plt.Rectangle(
|
||||
(centre[0] - half - x0, centre[1] - half - y0),
|
||||
float(window), float(window),
|
||||
fill=False, edgecolor=_WINDOW_COLOR, lw=1.2, ls="--",
|
||||
)
|
||||
)
|
||||
if radius > 0:
|
||||
top.add_patch(
|
||||
plt.Circle(
|
||||
(centre[0] - x0, centre[1] - y0), radius,
|
||||
fill=False, edgecolor=_ROI_COLOR, lw=1.2,
|
||||
)
|
||||
)
|
||||
|
||||
# Applied vs measured displacement, both drawn from the ROI centre.
|
||||
applied = float(panel.get("applied_px") or 0.0)
|
||||
response = float(panel.get("response_px") or 0.0)
|
||||
direction = panel.get("direction_px")
|
||||
if direction is None:
|
||||
direction = (1.0, 0.0)
|
||||
direction = np.asarray(direction, dtype=float)
|
||||
norm = float(np.linalg.norm(direction)) or 1.0
|
||||
direction = direction / norm
|
||||
arrow = 12.0
|
||||
top.annotate(
|
||||
"", xy=(centre[0] - x0 + direction[0] * arrow, centre[1] - y0 + direction[1] * arrow),
|
||||
xytext=(centre[0] - x0, centre[1] - y0),
|
||||
arrowprops=dict(arrowstyle="->", color=_APPLIED_COLOR, lw=1.6),
|
||||
)
|
||||
if applied > 0:
|
||||
scale = arrow / applied
|
||||
top.annotate(
|
||||
"",
|
||||
xy=(centre[0] - x0 + direction[0] * response * scale,
|
||||
centre[1] - y0 + direction[1] * response * scale),
|
||||
xytext=(centre[0] - x0, centre[1] - y0),
|
||||
arrowprops=dict(arrowstyle="->", color=_MEASURED_COLOR, lw=1.6),
|
||||
)
|
||||
|
||||
top.set_title(label, fontsize=9)
|
||||
top.set_xticks([])
|
||||
top.set_yticks([])
|
||||
# The crop is a small square in a taller axes box; anchor it to the top so the
|
||||
# marker stays next to its caption instead of floating in whitespace.
|
||||
top.set_anchor("N")
|
||||
caption = panel.get("scale_note") or ""
|
||||
if applied > 0:
|
||||
caption += (
|
||||
f"\napplied {applied:.3f} px | measured {response:+.3f} px"
|
||||
f" | recovery {100.0 * response / applied:.1f}%"
|
||||
)
|
||||
if panel.get("note"):
|
||||
caption += f"\n{panel['note']}"
|
||||
top.set_xlabel(caption, fontsize=7, labelpad=6)
|
||||
|
||||
# ---------------------------------------------------------- bottom row
|
||||
crop_mag, _ = _crop(magnitude, centre, crop_half)
|
||||
image = bottom.imshow(
|
||||
crop_mag, cmap="magma", origin="upper", vmin=0.0, vmax=vmax
|
||||
)
|
||||
if radius > 0:
|
||||
bottom.add_patch(
|
||||
plt.Circle(
|
||||
(centre[0] - x0, centre[1] - y0), radius,
|
||||
fill=False, edgecolor=_ROI_COLOR, lw=1.2,
|
||||
)
|
||||
)
|
||||
roi_value = float(magnitude[
|
||||
max(0, int(round(centre[1])) - 1):int(round(centre[1])) + 2,
|
||||
max(0, int(round(centre[0])) - 1):int(round(centre[0])) + 2,
|
||||
].mean())
|
||||
bottom.set_title(
|
||||
f"|flow| px (ROI mean {roi_value:.3g}, vmax {vmax:.3g})", fontsize=9
|
||||
)
|
||||
bottom.set_xticks([])
|
||||
bottom.set_yticks([])
|
||||
if index == columns - 1:
|
||||
figure.colorbar(image, ax=bottom, fraction=0.046, pad=0.02)
|
||||
|
||||
figure.tight_layout(rect=(0, 0, 1, 0.96))
|
||||
figure.savefig(out_path)
|
||||
plt.close(figure)
|
||||
return out_path
|
||||
|
||||
|
||||
def render_tuning(
|
||||
*,
|
||||
series: Sequence[Mapping[str, Any]],
|
||||
out_path: Path,
|
||||
title: str = "Sensitivity against marker cell (camera space)",
|
||||
patch_px: float | None = None,
|
||||
dpi: int = 150,
|
||||
) -> Path:
|
||||
"""Write the tuning curve: score vs camera-space cell, and vs the window ratio.
|
||||
|
||||
``series`` entries are ``{"label": str, "points": [{"nx", "cell_px",
|
||||
"effective_patch_px", "score"}]}``. One series per setting combination, so an
|
||||
optimum that moves with a setting is visible rather than averaged away.
|
||||
"""
|
||||
plt = _pyplot()
|
||||
out_path = Path(out_path)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
usable = [
|
||||
dict(item, **entry)
|
||||
for item in series
|
||||
for entry in item.get("points") or []
|
||||
if entry.get("cell_px") and entry.get("score") is not None
|
||||
]
|
||||
figure, axes = plt.subplots(1, 2, figsize=(13.4, 5.4), dpi=dpi)
|
||||
figure.suptitle(title, fontsize=13)
|
||||
|
||||
if not usable:
|
||||
for ax in axes:
|
||||
_text_panel(ax, "no scored runs", ["nothing to plot"])
|
||||
figure.tight_layout(rect=(0, 0, 1, 0.94))
|
||||
figure.savefig(out_path)
|
||||
plt.close(figure)
|
||||
return out_path
|
||||
|
||||
markers = ["o", "s", "^", "D", "v", "P", "X", "*"]
|
||||
for position, item in enumerate(series):
|
||||
points = [
|
||||
entry for entry in (item.get("points") or [])
|
||||
if entry.get("cell_px") and entry.get("score") is not None
|
||||
]
|
||||
if not points:
|
||||
continue
|
||||
label = str(item.get("label"))
|
||||
marker = markers[position % len(markers)]
|
||||
points = sorted(points, key=lambda entry: float(entry["cell_px"]))
|
||||
axes[0].plot(
|
||||
[float(entry["cell_px"]) for entry in points],
|
||||
[float(entry["score"]) for entry in points],
|
||||
marker=marker, ls="-", label=label, ms=6, lw=1.2,
|
||||
)
|
||||
for entry in points:
|
||||
axes[0].annotate(
|
||||
str(entry.get("nx")),
|
||||
(float(entry["cell_px"]), float(entry["score"])),
|
||||
fontsize=7, xytext=(3, 3), textcoords="offset points",
|
||||
)
|
||||
ratio_points = [entry for entry in points if entry.get("effective_patch_px")]
|
||||
if ratio_points:
|
||||
axes[1].plot(
|
||||
[float(entry["cell_px"]) / float(entry["effective_patch_px"]) for entry in ratio_points],
|
||||
[float(entry["score"]) for entry in ratio_points],
|
||||
marker=marker, ls="-", label=label, ms=6, lw=1.2,
|
||||
)
|
||||
|
||||
if patch_px:
|
||||
axes[0].axvline(float(patch_px), color=_WINDOW_COLOR, ls=":", lw=1.1)
|
||||
# Label the nominal patch on the left and the windows on the right: when
|
||||
# dis_mode=0 the window *is* the nominal patch, so right-hand labels would
|
||||
# overprint each other into unreadable text.
|
||||
axes[0].annotate(
|
||||
f"nominal patch {float(patch_px):g} px", (float(patch_px), 0.98),
|
||||
xycoords=("data", "axes fraction"), fontsize=8, color=_WINDOW_COLOR,
|
||||
xytext=(-6, -12), textcoords="offset points", rotation=90,
|
||||
va="top", ha="right",
|
||||
)
|
||||
windows = sorted({float(entry["effective_patch_px"]) for entry in usable if entry.get("effective_patch_px")})
|
||||
for position, window in enumerate(windows):
|
||||
axes[0].axvline(window, color=_ROI_COLOR, ls="--", lw=1.1, alpha=0.8)
|
||||
axes[0].annotate(
|
||||
f"window {window:g} px", (window, 0.98),
|
||||
xycoords=("data", "axes fraction"), fontsize=8, color=_ROI_COLOR,
|
||||
xytext=(4 + 12 * position, -12), textcoords="offset points",
|
||||
rotation=90, va="top", ha="left",
|
||||
)
|
||||
|
||||
axes[0].set_xscale("log")
|
||||
axes[0].set_xlabel("marker cell (camera px) = render_width / nx")
|
||||
axes[0].set_ylabel("sensitivity score")
|
||||
axes[0].set_title("score vs camera-space cell", fontsize=10)
|
||||
axes[0].grid(alpha=0.25, which="both")
|
||||
axes[0].legend(fontsize=8)
|
||||
|
||||
low, high = USABLE_RATIO_BAND
|
||||
axes[1].axvspan(low, high, color=_ROI_COLOR, alpha=0.12)
|
||||
axes[1].axvline(1.0, color=_WINDOW_COLOR, ls="--", lw=1.1)
|
||||
axes[1].set_xscale("log")
|
||||
axes[1].set_xlabel("cell / effective window (1.0 = matched)")
|
||||
axes[1].set_ylabel("sensitivity score")
|
||||
axes[1].set_title("score vs window ratio (shaded = usable band)", fontsize=10)
|
||||
axes[1].grid(alpha=0.25, which="both")
|
||||
axes[1].legend(fontsize=8)
|
||||
|
||||
figure.tight_layout(rect=(0, 0, 1, 0.94))
|
||||
figure.savefig(out_path)
|
||||
plt.close(figure)
|
||||
return out_path
|
||||
|
||||
|
||||
def render_response_curves(
|
||||
*,
|
||||
curves: Sequence[Mapping[str, Any]],
|
||||
out_path: Path,
|
||||
axis: str = "x",
|
||||
title: str = "Response vs applied displacement",
|
||||
dpi: int = 150,
|
||||
) -> Path:
|
||||
"""Write response-vs-displacement curves, plus a noise-retention panel.
|
||||
|
||||
Each curve supplies ``label`` (callers must make it unique -- include the radius and
|
||||
the dis_mode, or two different runs collide in the legend), ``points``
|
||||
(``step_mm``, ``response_px``, ``applied_px``), optionally ``floor_px``, ``score``,
|
||||
``verdict`` and ``retention`` (a list of ``{"sigma", "response_retention"}``).
|
||||
|
||||
Two panels on purpose. The left one is **signal** against the ideal 1:1 line, with
|
||||
each curve's own noise floor drawn as a horizontal line: a bigger response with a
|
||||
bigger floor is the normal case, and a single global band hides that. The right one
|
||||
is **retention** -- ``gain(sigma)/gain(clean)`` -- which is where a pattern that
|
||||
looks strong on the left but collapses under noise becomes visible.
|
||||
"""
|
||||
plt = _pyplot()
|
||||
out_path = Path(out_path)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
has_retention = any(curve.get("retention") for curve in curves)
|
||||
if has_retention:
|
||||
figure, axes = plt.subplots(1, 2, figsize=(13.4, 5.6), dpi=dpi)
|
||||
signal_axes, retention_axes = axes[0], axes[1]
|
||||
else:
|
||||
figure, signal_axes = plt.subplots(figsize=(8.4, 5.6), dpi=dpi)
|
||||
retention_axes = None
|
||||
figure.suptitle(f"{title} (axis {axis})", fontsize=12)
|
||||
|
||||
plotted = 0
|
||||
for curve in curves:
|
||||
points = [p for p in (curve.get("points") or []) if p.get("step_mm")]
|
||||
if not points:
|
||||
continue
|
||||
points = sorted(points, key=lambda p: float(p["step_mm"]))
|
||||
xs = [float(p["step_mm"]) for p in points]
|
||||
ys = [float(p.get("response_px") or 0.0) for p in points]
|
||||
label = str(curve.get("label"))
|
||||
if curve.get("score") is not None:
|
||||
label += f" [axis score {float(curve['score']):.1f}]"
|
||||
if curve.get("verdict"):
|
||||
label += f" [{curve['verdict']}]"
|
||||
(line,) = signal_axes.plot(xs, ys, marker="o", ms=4, lw=1.3, label=label)
|
||||
# This curve's own floor, in its own colour.
|
||||
floor = curve.get("floor_px")
|
||||
if floor:
|
||||
signal_axes.axhline(
|
||||
float(floor), color=line.get_color(), ls=":", lw=1.0, alpha=0.75
|
||||
)
|
||||
# Stagger the labels: several patterns' floors are within a few hundredths
|
||||
# of a pixel, so a fixed offset overprints them into unreadable text.
|
||||
signal_axes.annotate(
|
||||
f"floor {float(floor):.4g} px",
|
||||
(0.995, float(floor)),
|
||||
xycoords=("axes fraction", "data"),
|
||||
xytext=(0, 6 + 9 * (plotted % 5)),
|
||||
textcoords="offset points",
|
||||
fontsize=7,
|
||||
color=line.get_color(),
|
||||
ha="right",
|
||||
va="bottom",
|
||||
)
|
||||
if plotted == 0:
|
||||
applied = [float(p.get("applied_px") or 0.0) for p in points]
|
||||
if any(applied):
|
||||
signal_axes.plot(xs, applied, ls="--", color="#555555", lw=1.1,
|
||||
label="applied (ideal 1:1)")
|
||||
plotted += 1
|
||||
|
||||
if retention_axes is not None and curve.get("retention"):
|
||||
entries = sorted(curve["retention"], key=lambda e: float(e.get("sigma") or 0.0))
|
||||
retention_axes.plot(
|
||||
[float(e["sigma"]) for e in entries],
|
||||
[float(e.get("response_retention") or 0.0) for e in entries],
|
||||
marker="s", ms=4, lw=1.3, color=line.get_color(),
|
||||
label=str(curve.get("label")),
|
||||
)
|
||||
|
||||
if not plotted:
|
||||
_text_panel(signal_axes, "no curves available", ["nothing to plot"])
|
||||
|
||||
signal_axes.set_xlabel("applied displacement (mm)")
|
||||
signal_axes.set_ylabel("measured response (px, projected on the applied direction)")
|
||||
signal_axes.set_title("signal (one axis, one scale)", fontsize=10)
|
||||
signal_axes.grid(alpha=0.25)
|
||||
if plotted:
|
||||
signal_axes.legend(fontsize=7)
|
||||
|
||||
if retention_axes is not None:
|
||||
retention_axes.axhline(1.0, color="#555555", ls="--", lw=1.1)
|
||||
retention_axes.axhspan(0.85, 1.15, color=_ROI_COLOR, alpha=0.12)
|
||||
retention_axes.set_xlabel("injected noise sigma (grey levels)")
|
||||
retention_axes.set_ylabel("gain(sigma) / gain(clean)")
|
||||
retention_axes.set_title(
|
||||
"response retention under noise (shaded = within 15%)", fontsize=10
|
||||
)
|
||||
retention_axes.grid(alpha=0.25)
|
||||
retention_axes.legend(fontsize=7)
|
||||
|
||||
figure.tight_layout(rect=(0, 0, 1, 0.94))
|
||||
figure.savefig(out_path)
|
||||
plt.close(figure)
|
||||
return out_path
|
||||
|
||||
|
||||
def panel_from_run(
|
||||
*,
|
||||
label: str,
|
||||
output_dir: Path,
|
||||
axis: str = "x",
|
||||
step_mm: float = 0.10,
|
||||
scale: str = "render",
|
||||
manifest: Mapping[str, Any] | None = None,
|
||||
flow_report: Mapping[str, Any] | None = None,
|
||||
sensitivity_report: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Assemble one comparison panel from a run's on-disk artifacts.
|
||||
|
||||
Any artifact that is missing yields a panel the figure draws as an annotated
|
||||
placeholder, so a failed setting is visible in the comparison rather than absent
|
||||
from it.
|
||||
"""
|
||||
import json
|
||||
|
||||
output_dir = Path(output_dir)
|
||||
note: list[str] = []
|
||||
|
||||
if manifest is None:
|
||||
path = output_dir / "render_manifest.json"
|
||||
manifest = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
|
||||
if flow_report is None:
|
||||
path = output_dir / "flow_report.json"
|
||||
flow_report = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
|
||||
if sensitivity_report is None:
|
||||
path = output_dir / "sensitivity_report.json"
|
||||
sensitivity_report = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
|
||||
|
||||
frames = [f for f in (manifest.get("frames") or []) if f.get("axis") == axis]
|
||||
entry = None
|
||||
if frames:
|
||||
entry = min(frames, key=lambda f: abs(float(f.get("step_mm", 0.0)) - float(step_mm)))
|
||||
flow = None
|
||||
if entry:
|
||||
flow_path = output_dir / "flow" / scale / f"{entry['name']}.npy"
|
||||
if flow_path.is_file():
|
||||
flow = np.load(flow_path)
|
||||
else:
|
||||
note.append(f"missing flow array {flow_path.name}")
|
||||
|
||||
roi = None
|
||||
scale_block = None
|
||||
for block in flow_report.get("scales") or []:
|
||||
if block.get("name") != scale:
|
||||
continue
|
||||
scale_block = block
|
||||
for frame in block.get("frames") or []:
|
||||
if entry and frame.get("name") == entry.get("name"):
|
||||
roi = frame.get("roi")
|
||||
if roi is None and block.get("frames"):
|
||||
roi = (block["frames"][0] or {}).get("roi")
|
||||
if roi is None:
|
||||
roi = manifest.get("probe_roi_px")
|
||||
if roi is not None:
|
||||
note.append("ROI fell back to the manifest's reference ROI")
|
||||
|
||||
sensitivity_scale = None
|
||||
for block in sensitivity_report.get("scales") or []:
|
||||
if block.get("name") == scale:
|
||||
sensitivity_scale = block
|
||||
cell_px = (sensitivity_scale or {}).get("cell_px")
|
||||
window = (sensitivity_scale or {}).get("effective_patch_px")
|
||||
direction = None
|
||||
response = None
|
||||
response_applied = None
|
||||
for axis_block in (sensitivity_scale or {}).get("axes") or []:
|
||||
if axis_block.get("axis") != axis:
|
||||
continue
|
||||
direction = axis_block.get("direction_px")
|
||||
for point in axis_block.get("points") or []:
|
||||
if entry and abs(float(point.get("step_mm", 0.0)) - float(entry.get("step_mm", 0.0))) < 1e-9:
|
||||
response = point.get("response_px")
|
||||
response_applied = point.get("applied_px")
|
||||
reference_png = None
|
||||
if entry:
|
||||
# Resolve through the manifest, never by guessing the layout: a dis_mode
|
||||
# variant directory holds no frames of its own and carries absolute `png`
|
||||
# paths into the rendered directory. Assuming `<output_dir>/frames/<name>.png`
|
||||
# made every variant panel read as "rendered frame unavailable".
|
||||
from orisys_sensor_design.render import (
|
||||
RenderArtifactError,
|
||||
manifest_frame_entries,
|
||||
)
|
||||
|
||||
try:
|
||||
entries = manifest_frame_entries(manifest, output_dir)
|
||||
except RenderArtifactError:
|
||||
entries = []
|
||||
for item in entries:
|
||||
path = item.get("png_path")
|
||||
if item.get("name") == entry.get("name") and path is not None:
|
||||
reference_png = path
|
||||
break
|
||||
if reference_png is None:
|
||||
for item in entries:
|
||||
if item.get("axis") is None and item.get("png_path") is not None:
|
||||
reference_png = item["png_path"]
|
||||
break
|
||||
if reference_png is None:
|
||||
candidate = output_dir / "frames" / f"{entry['name']}.png"
|
||||
reference_png = candidate if candidate.is_file() else None
|
||||
if reference_png is None or not Path(reference_png).is_file():
|
||||
note.append("rendered frame for this panel is missing")
|
||||
|
||||
tracker = flow_report.get("tracker") or {}
|
||||
scale_note = (
|
||||
f"{scale} {manifest.get('width')}x{manifest.get('height')}"
|
||||
f" | patch {tracker.get('patch_px')} px, finest {tracker.get('finest_scale')}"
|
||||
)
|
||||
if cell_px:
|
||||
scale_note += f" | cell {float(cell_px):.2f} px"
|
||||
if window:
|
||||
scale_note += f" | window {float(window):.2f} px"
|
||||
if cell_px:
|
||||
scale_note += f" (ratio {float(cell_px) / float(window):.2f})"
|
||||
|
||||
return {
|
||||
"label": label,
|
||||
"flow": flow,
|
||||
"reference_png": reference_png,
|
||||
"roi": roi,
|
||||
"cell_px": cell_px,
|
||||
"effective_patch_px": window,
|
||||
"applied_px": response_applied,
|
||||
"response_px": response,
|
||||
"direction_px": direction,
|
||||
"scale_note": scale_note,
|
||||
"note": "; ".join(note),
|
||||
"axis": axis,
|
||||
"step_mm": None if entry is None else entry.get("step_mm"),
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
"""Host-side probe-render job I/O, output paths, and marker-texture resolution.
|
||||
|
||||
The host CLI (``orisys-design render``) resolves :class:`deform.ProbeSettings`,
|
||||
writes a stdlib-JSON ``_render_job.json``, and invokes
|
||||
``blender/scripts/probe_render.py`` headlessly. Blender never reads YAML and
|
||||
never writes the ``.blend``; every path handed to it is absolute.
|
||||
|
||||
Frame ordering is **not** decided here: :func:`deform.frame_plan` is the single
|
||||
ordering authority, and this module calls it so the render job and the deform
|
||||
job cannot disagree. For the same reason the render stage consumes the
|
||||
``probe_frames.npz`` written by the deform stage instead of re-deriving any
|
||||
geometry or falloff weights.
|
||||
|
||||
Exit-code convention (see ``docs/probe-pipeline.md``):
|
||||
|
||||
* :class:`RenderConfigError` -> 2 (invalid job/config)
|
||||
* :class:`RenderArtifactError` -> 3 (required artifact missing or empty)
|
||||
|
||||
Both carry enough context to be printed as-is by the CLI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from orisys_sensor_design.deform import (
|
||||
ProbeConfigError,
|
||||
ProbeFrame,
|
||||
ProbeSettings,
|
||||
frame_plan,
|
||||
probe_artifact_paths,
|
||||
resolve_probe_output_dir,
|
||||
)
|
||||
from orisys_sensor_design.manifest import RevisionPaths, repo_root
|
||||
from orisys_sensor_design.texture import resolve_texture_output_dir
|
||||
|
||||
RENDER_JOB_SCHEMA = "orisys-sensor-design/probe-render-job-v1"
|
||||
RENDER_MANIFEST_SCHEMA = "orisys-sensor-design/probe-render-manifest-v1"
|
||||
|
||||
RENDER_JOB_FILENAME = "_render_job.json"
|
||||
MANIFEST_FILENAME = "render_manifest.json"
|
||||
FRAMES_DIRNAME = "frames"
|
||||
NULL_FRAME_NAME = "reference_null"
|
||||
PNG_SUFFIX = ".png"
|
||||
|
||||
#: The only view transform the contract accepts. AgX/Filmic are filmic
|
||||
#: tonemappers: they silently rewrite every marker pixel and invalidate the
|
||||
#: measurement, so the render stage refuses anything else.
|
||||
REQUIRED_VIEW_TRANSFORM = "Standard"
|
||||
|
||||
_MAX_PROBE_EDGE = 32768
|
||||
|
||||
#: `Frame` in this module is any of: a deform.ProbeFrame, the job dict entry, or
|
||||
#: a bare frame name. All three are accepted where a frame is named.
|
||||
FrameLike = ProbeFrame | Mapping[str, Any] | str
|
||||
|
||||
|
||||
class RenderConfigError(ValueError):
|
||||
"""Raised when render settings/overrides are invalid (exit code 2)."""
|
||||
|
||||
|
||||
class RenderArtifactError(RuntimeError):
|
||||
"""Raised when a required artifact is missing, empty or unusable (exit 3).
|
||||
|
||||
``exit_code`` lets the CLI map this straight onto the contract's exit-code
|
||||
table without re-parsing the message.
|
||||
"""
|
||||
|
||||
exit_code = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RenderPaths:
|
||||
"""Default on-disk locations for one probe render run."""
|
||||
|
||||
root: Path
|
||||
output_dir: Path
|
||||
texture_dir: Path
|
||||
job: Path
|
||||
manifest: Path
|
||||
frames_npz: Path
|
||||
frames_dir: Path
|
||||
texture: Path
|
||||
|
||||
def frame_png(self, frame: FrameLike) -> Path:
|
||||
"""Return the PNG path for one frame (or bare frame name)."""
|
||||
return self.frames_dir / frame_png_name(frame)
|
||||
|
||||
def as_dict(self) -> dict[str, Path]:
|
||||
"""Return the paths as a plain mapping (stable key order)."""
|
||||
return {
|
||||
"job": self.job,
|
||||
"manifest": self.manifest,
|
||||
"frames_npz": self.frames_npz,
|
||||
"frames_dir": self.frames_dir,
|
||||
"texture": self.texture,
|
||||
}
|
||||
|
||||
|
||||
def frame_name(frame: FrameLike) -> str:
|
||||
"""Return the frame name from a ProbeFrame, a job entry, or a bare name."""
|
||||
if isinstance(frame, str):
|
||||
if not frame:
|
||||
raise RenderConfigError("frame name must be a non-empty string")
|
||||
return frame
|
||||
if isinstance(frame, Mapping):
|
||||
name = frame.get("name")
|
||||
if not isinstance(name, str) or not name:
|
||||
raise RenderConfigError(f"frame entry has no usable 'name': {frame!r}")
|
||||
return name
|
||||
name = getattr(frame, "name", None)
|
||||
if not isinstance(name, str) or not name:
|
||||
raise RenderConfigError(f"frame has no usable 'name': {frame!r}")
|
||||
return name
|
||||
|
||||
|
||||
def frame_png_name(frame: FrameLike) -> str:
|
||||
"""Return ``<frame name>.png``, the contract's per-frame artifact name."""
|
||||
return f"{frame_name(frame)}{PNG_SUFFIX}"
|
||||
|
||||
|
||||
def probe_frames_path(output_dir: Path) -> Path:
|
||||
"""Return the absolute ``probe_frames.npz`` path beside the render artifacts."""
|
||||
return Path(probe_artifact_paths(Path(output_dir))["frames"])
|
||||
|
||||
|
||||
def render_job_path(output_dir: Path) -> Path:
|
||||
"""Return the ``_render_job.json`` path for an output directory."""
|
||||
return Path(output_dir).resolve() / RENDER_JOB_FILENAME
|
||||
|
||||
|
||||
def render_manifest_path(output_dir: Path) -> Path:
|
||||
"""Return the ``render_manifest.json`` path for an output directory."""
|
||||
return Path(output_dir).resolve() / MANIFEST_FILENAME
|
||||
|
||||
|
||||
def frames_dir(output_dir: Path) -> Path:
|
||||
"""Return the ``frames/`` directory holding the per-frame PNGs."""
|
||||
return Path(output_dir).resolve() / FRAMES_DIRNAME
|
||||
|
||||
|
||||
def frame_png_path(output_dir: Path, frame: FrameLike) -> Path:
|
||||
"""Return the absolute PNG path for one frame in an output directory."""
|
||||
return frames_dir(output_dir) / frame_png_name(frame)
|
||||
|
||||
|
||||
def render_output_paths(
|
||||
output_dir: Path,
|
||||
frames: Sequence[FrameLike] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return the absolute artifact paths for one render run.
|
||||
|
||||
Always contains ``output_dir``, ``job``, ``manifest``, ``frames_npz`` and
|
||||
``frames_dir``. When ``frames`` is given, an extra ``frames`` mapping of
|
||||
frame name (plus the null frame) to PNG path is included.
|
||||
"""
|
||||
resolved_dir = Path(output_dir).resolve()
|
||||
paths: dict[str, Any] = {
|
||||
"output_dir": resolved_dir,
|
||||
"job": render_job_path(resolved_dir),
|
||||
"manifest": render_manifest_path(resolved_dir),
|
||||
"frames_npz": probe_frames_path(resolved_dir),
|
||||
"frames_dir": frames_dir(resolved_dir),
|
||||
}
|
||||
if frames is not None:
|
||||
pngs = {frame_name(frame): frame_png_path(resolved_dir, frame) for frame in frames}
|
||||
pngs[NULL_FRAME_NAME] = frame_png_path(resolved_dir, NULL_FRAME_NAME)
|
||||
paths["frames"] = pngs
|
||||
return paths
|
||||
|
||||
|
||||
def default_render_paths(
|
||||
root: Path,
|
||||
paths: RevisionPaths,
|
||||
settings: ProbeSettings,
|
||||
output_dir: Path | None = None,
|
||||
texture_dir: Path | None = None,
|
||||
) -> RenderPaths:
|
||||
"""Resolve the default locations for a render run.
|
||||
|
||||
``output_dir`` defaults to ``build/probe/<sensor>/<revision>`` (the same
|
||||
revision naming ``texture_revision_rel`` produces); ``texture_dir``
|
||||
defaults to ``build/textures/<sensor>/<revision>``. The marker texture is
|
||||
resolved and must exist and be non-empty.
|
||||
"""
|
||||
resolved_dir = resolve_probe_output_dir(root, paths, output_dir)
|
||||
resolved_texture_dir = resolve_texture_output_dir(root, paths, texture_dir)
|
||||
return RenderPaths(
|
||||
root=Path(root).resolve(),
|
||||
output_dir=resolved_dir,
|
||||
texture_dir=resolved_texture_dir,
|
||||
job=render_job_path(resolved_dir),
|
||||
manifest=render_manifest_path(resolved_dir),
|
||||
frames_npz=probe_frames_path(resolved_dir),
|
||||
frames_dir=frames_dir(resolved_dir),
|
||||
texture=resolve_marker_texture(root, paths, settings, texture_dir=texture_dir),
|
||||
)
|
||||
|
||||
|
||||
def require_artifact(path: Path, label: str) -> Path:
|
||||
"""Return ``path`` if it is an existing non-empty file, else raise exit-3."""
|
||||
candidate = Path(path)
|
||||
if not candidate.is_file():
|
||||
raise RenderArtifactError(f"{label} not found: {candidate}")
|
||||
if candidate.stat().st_size == 0:
|
||||
raise RenderArtifactError(f"{label} is empty: {candidate}")
|
||||
return candidate
|
||||
|
||||
|
||||
def marker_texture_path(
|
||||
root: Path,
|
||||
paths: RevisionPaths,
|
||||
texture: str,
|
||||
texture_dir: Path | None = None,
|
||||
) -> Path:
|
||||
"""Resolve a configured marker-texture value to an absolute path.
|
||||
|
||||
The configured form is a bare file name resolved against the texture output
|
||||
directory (``build/textures/<sensor>/<revision>`` by default, or an explicit
|
||||
``texture_dir``). An absolute value is used as given.
|
||||
"""
|
||||
candidate = Path(texture).expanduser()
|
||||
if candidate.is_absolute():
|
||||
return candidate.resolve()
|
||||
base = resolve_texture_output_dir(root, paths, texture_dir)
|
||||
return (base / candidate).resolve()
|
||||
|
||||
|
||||
def resolve_marker_texture(
|
||||
root: Path,
|
||||
paths: RevisionPaths,
|
||||
settings: ProbeSettings,
|
||||
texture_dir: Path | None = None,
|
||||
) -> Path:
|
||||
"""Resolve ``settings.texture`` to an existing, non-empty PNG.
|
||||
|
||||
Raises :class:`RenderArtifactError` (exit code 3) when the marker texture is
|
||||
missing or empty; the contract's failure list requires that to fail loudly
|
||||
rather than render a blank surface.
|
||||
"""
|
||||
path = marker_texture_path(root, paths, settings.texture, texture_dir=texture_dir)
|
||||
return require_artifact(path, f"marker texture {settings.texture!r}")
|
||||
|
||||
|
||||
def _as_positive_int(value: Any, label: str) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
||||
raise RenderConfigError(f"{label}: must be a positive integer (got {value!r})")
|
||||
if value > _MAX_PROBE_EDGE:
|
||||
raise RenderConfigError(f"{label}: must be <= {_MAX_PROBE_EDGE} (got {value})")
|
||||
return int(value)
|
||||
|
||||
|
||||
def _as_int(value: Any, label: str) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise RenderConfigError(f"{label}: must be an integer (got {value!r})")
|
||||
return int(value)
|
||||
|
||||
|
||||
def parse_resolution(value: Any) -> tuple[int, int]:
|
||||
"""Parse ``"500x500"`` / ``"500,500"`` / ``[500, 500]`` into ``(w, h)``."""
|
||||
if isinstance(value, (list, tuple)):
|
||||
parts: list[Any] = list(value)
|
||||
elif isinstance(value, str):
|
||||
parts = [part for part in re.split(r"[xX,\s]+", value.strip()) if part]
|
||||
else:
|
||||
raise RenderConfigError(
|
||||
f"resolution: must be WxH, W,H or a [width, height] pair (got {value!r})"
|
||||
)
|
||||
if len(parts) != 2:
|
||||
raise RenderConfigError(
|
||||
f"resolution: must have exactly two values (got {value!r})"
|
||||
)
|
||||
try:
|
||||
width, height = (int(str(part)) for part in parts)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise RenderConfigError(
|
||||
f"resolution: width and height must be integers (got {value!r})"
|
||||
) from exc
|
||||
return _as_positive_int(width, "resolution.width"), _as_positive_int(
|
||||
height, "resolution.height"
|
||||
)
|
||||
|
||||
|
||||
def render_overrides_from_args(args: Any) -> dict[str, Any]:
|
||||
"""Collect CLI render override values from an argparse namespace.
|
||||
|
||||
Unset flags are ``None`` and are dropped, so they never erase a configured
|
||||
value. ``--resolution WxH`` is expanded into ``width``/``height``;
|
||||
explicit ``--width``/``--height`` win over it.
|
||||
"""
|
||||
overrides: dict[str, Any] = {
|
||||
"samples": getattr(args, "samples", None),
|
||||
"seed": getattr(args, "seed", None),
|
||||
"uv_map": getattr(args, "uv_map", None),
|
||||
"texture": getattr(args, "texture", None),
|
||||
}
|
||||
resolution = getattr(args, "resolution", None)
|
||||
if resolution is not None:
|
||||
width, height = parse_resolution(resolution)
|
||||
overrides["width"] = width
|
||||
overrides["height"] = height
|
||||
width = getattr(args, "width", None)
|
||||
height = getattr(args, "height", None)
|
||||
if width is not None:
|
||||
overrides["width"] = width
|
||||
if height is not None:
|
||||
overrides["height"] = height
|
||||
return {key: value for key, value in overrides.items() if value is not None}
|
||||
|
||||
|
||||
def _repo_root_for(paths: RevisionPaths) -> Path:
|
||||
try:
|
||||
return repo_root(paths.root)
|
||||
except FileNotFoundError:
|
||||
# A revision outside a repository layout still gets a deterministic
|
||||
# location; resolve_texture_output_dir then falls back the same way
|
||||
# texture.py already does.
|
||||
return Path(paths.root).resolve()
|
||||
|
||||
|
||||
def build_render_job(
|
||||
paths: RevisionPaths,
|
||||
settings: ProbeSettings,
|
||||
output_dir: Path,
|
||||
*,
|
||||
width: int | None = None,
|
||||
height: int | None = None,
|
||||
samples: int | None = None,
|
||||
seed: int | None = None,
|
||||
uv_map: str | None = None,
|
||||
texture: str | Path | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the JSON job dict that ``probe_render.py`` consumes.
|
||||
|
||||
The frame list comes from :func:`deform.frame_plan` and is stored verbatim
|
||||
so the Blender stage can assert it against ``probe_frames.npz``. The marker
|
||||
texture is resolved to an absolute, existing path; a missing/empty texture
|
||||
raises :class:`RenderArtifactError` (exit 3) before Blender is started.
|
||||
|
||||
``radius_mm`` is carried in ``settings`` in addition to the frozen keys:
|
||||
``render_manifest.json.probe_roi_px.radius`` is ``radius_mm`` x the measured
|
||||
px/mm, and the render job has no other way to learn the configured radius.
|
||||
"""
|
||||
resolved_dir = Path(output_dir).resolve()
|
||||
if texture is not None:
|
||||
texture_path = Path(texture).expanduser().resolve()
|
||||
require_artifact(texture_path, f"marker texture {str(texture)!r}")
|
||||
else:
|
||||
texture_path = resolve_marker_texture(
|
||||
_repo_root_for(paths), paths, settings
|
||||
)
|
||||
|
||||
settings_block = settings.to_render_settings(texture_path)
|
||||
if width is not None:
|
||||
settings_block["width"] = _as_positive_int(width, "width")
|
||||
if height is not None:
|
||||
settings_block["height"] = _as_positive_int(height, "height")
|
||||
if samples is not None:
|
||||
settings_block["samples"] = _as_positive_int(samples, "samples")
|
||||
if seed is not None:
|
||||
settings_block["seed"] = _as_int(seed, "seed")
|
||||
if uv_map is not None:
|
||||
if not isinstance(uv_map, str) or not uv_map.strip():
|
||||
raise RenderConfigError(f"uv_map: must be a non-empty name (got {uv_map!r})")
|
||||
settings_block["uv_map"] = uv_map.strip()
|
||||
settings_block["radius_mm"] = float(settings.radius_mm)
|
||||
|
||||
artifacts = probe_artifact_paths(resolved_dir)
|
||||
return {
|
||||
"schema": RENDER_JOB_SCHEMA,
|
||||
"revision_dir": str(Path(paths.root).resolve()),
|
||||
"source_blend": str(Path(paths.source_blend).resolve()),
|
||||
"output_dir": str(resolved_dir),
|
||||
"objects": {
|
||||
"mesh": settings.mesh,
|
||||
"camera": settings.camera,
|
||||
},
|
||||
"settings": settings_block,
|
||||
"frames": [frame.to_job_entry() for frame in frame_plan(settings)],
|
||||
"null_frame": {
|
||||
"name": NULL_FRAME_NAME,
|
||||
"seed": int(settings_block["seed"]) + 1,
|
||||
},
|
||||
"artifacts": {
|
||||
"frames_npz": str(artifacts["frames"]),
|
||||
"report": str(resolved_dir / MANIFEST_FILENAME),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def write_render_job(job: dict[str, Any], output_dir: Path) -> Path:
|
||||
"""Write ``_render_job.json`` into the output directory; return its path."""
|
||||
resolved_dir = Path(output_dir)
|
||||
resolved_dir.mkdir(parents=True, exist_ok=True)
|
||||
job_path = render_job_path(resolved_dir)
|
||||
job_path.write_text(json.dumps(job, indent=2) + "\n", encoding="utf-8")
|
||||
return job_path
|
||||
|
||||
|
||||
def load_render_manifest(output_dir: Path) -> dict[str, Any]:
|
||||
"""Load and minimally validate ``render_manifest.json``.
|
||||
|
||||
Raises :class:`RenderArtifactError` (exit 3) when the manifest is missing,
|
||||
empty, not JSON, not a mapping, carries the wrong schema, or lists no
|
||||
frames. Downstream stages should treat that as a missing artifact rather
|
||||
than guessing.
|
||||
"""
|
||||
path = render_manifest_path(output_dir)
|
||||
require_artifact(path, MANIFEST_FILENAME)
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise RenderArtifactError(f"{MANIFEST_FILENAME} is not valid JSON: {path}: {exc}") from exc
|
||||
if not isinstance(data, dict):
|
||||
raise RenderArtifactError(f"{MANIFEST_FILENAME} must be a JSON object: {path}")
|
||||
schema = data.get("schema")
|
||||
if schema != RENDER_MANIFEST_SCHEMA:
|
||||
raise RenderArtifactError(
|
||||
f"{MANIFEST_FILENAME} schema is {schema!r}, expected {RENDER_MANIFEST_SCHEMA!r}: {path}"
|
||||
)
|
||||
frames = data.get("frames")
|
||||
if not isinstance(frames, list) or not frames:
|
||||
raise RenderArtifactError(f"{MANIFEST_FILENAME} lists no frames: {path}")
|
||||
return data
|
||||
|
||||
|
||||
def manifest_frame_entries(
|
||||
manifest: Mapping[str, Any],
|
||||
output_dir: Path,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return the manifest's frame entries, with each PNG path made absolute.
|
||||
|
||||
``png`` is stored relative to the output directory (``frames/<name>.png``);
|
||||
a copy is returned with an added ``png_path`` absolute ``Path`` so callers
|
||||
do not have to re-derive the layout.
|
||||
"""
|
||||
resolved_dir = Path(output_dir).resolve()
|
||||
entries: list[dict[str, Any]] = []
|
||||
for entry in manifest.get("frames", []):
|
||||
if not isinstance(entry, Mapping):
|
||||
raise RenderArtifactError(f"manifest frame entry must be an object: {entry!r}")
|
||||
item = dict(entry)
|
||||
png = item.get("png")
|
||||
if not isinstance(png, str) or not png:
|
||||
raise RenderArtifactError(f"manifest frame entry has no 'png': {entry!r}")
|
||||
item["png_path"] = (resolved_dir / png).resolve()
|
||||
entries.append(item)
|
||||
return entries
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FRAMES_DIRNAME",
|
||||
"MANIFEST_FILENAME",
|
||||
"NULL_FRAME_NAME",
|
||||
"PNG_SUFFIX",
|
||||
"RENDER_JOB_FILENAME",
|
||||
"RENDER_JOB_SCHEMA",
|
||||
"RENDER_MANIFEST_SCHEMA",
|
||||
"REQUIRED_VIEW_TRANSFORM",
|
||||
"RenderArtifactError",
|
||||
"RenderConfigError",
|
||||
"RenderPaths",
|
||||
"ProbeConfigError",
|
||||
"build_render_job",
|
||||
"default_render_paths",
|
||||
"frame_name",
|
||||
"frame_png_name",
|
||||
"frame_png_path",
|
||||
"frames_dir",
|
||||
"load_render_manifest",
|
||||
"manifest_frame_entries",
|
||||
"marker_texture_path",
|
||||
"parse_resolution",
|
||||
"probe_frames_path",
|
||||
"render_job_path",
|
||||
"render_manifest_path",
|
||||
"render_output_paths",
|
||||
"render_overrides_from_args",
|
||||
"require_artifact",
|
||||
"resolve_marker_texture",
|
||||
"write_render_job",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user