"""Tests for `probe_plot`: the optical-flow figures. matplotlib is an optional extra, so the whole module is skipped when it is absent — but nothing here needs Blender or cv2. Every figure is built from synthetic arrays and tiny JSON fixtures, which is deliberate: the figures are a view of measurements, so they must be testable without making a measurement. """ from __future__ import annotations import json from pathlib import Path import numpy as np import pytest pytest.importorskip("matplotlib") from orisys_sensor_design import probe_plot # noqa: E402 PNG_MAGIC = b"\x89PNG\r\n\x1a\n" def _flow(height: int = 60, width: int = 60, value: float = 0.4) -> np.ndarray: flow = np.zeros((height, width, 2), dtype=np.float32) flow[..., 0] = value return flow def _panel(**overrides) -> dict: panel = { "label": "nx=31, dis_mode=1", "flow": _flow(), "roi": {"u": 30.0, "v": 30.0, "radius": 6.6}, "cell_px": 16.13, "effective_patch_px": 16.0, "applied_px": 1.325, "response_px": 0.886, "direction_px": [1.0, 0.0], "scale_note": "render 500x500 | patch 8 px, finest 1", "note": "", } panel.update(overrides) return panel def _assert_png(path: Path) -> None: assert path.is_file() assert path.stat().st_size > 0 assert path.read_bytes()[:8] == PNG_MAGIC def test_flow_comparison_writes_a_png(tmp_path: Path) -> None: out = probe_plot.render_flow_comparison( panels=[_panel(label="nx=31"), _panel(label="nx=80", cell_px=6.25, flow=_flow(value=0.9))], out_path=tmp_path / "flow.png", ) _assert_png(out) def test_flow_comparison_annotates_a_missing_flow_array(tmp_path: Path) -> None: """A failed setting must appear as a named placeholder, not vanish.""" out = probe_plot.render_flow_comparison( panels=[ _panel(label="ok"), _panel(label="broken", flow=None, note="missing flow array 010_x.npy"), ], out_path=tmp_path / "flow_missing.png", ) _assert_png(out) def test_flow_comparison_handles_empty_and_tiny_inputs(tmp_path: Path) -> None: out = probe_plot.render_flow_comparison(panels=[], out_path=tmp_path / "empty.png") _assert_png(out) tiny = _flow(height=3, width=3, value=0.1) out = probe_plot.render_flow_comparison( panels=[_panel(flow=tiny, roi={"u": 1.0, "v": 1.0, "radius": 0.5})], out_path=tmp_path / "tiny.png", ) _assert_png(out) def test_tuning_writes_both_panels(tmp_path: Path) -> None: out = probe_plot.render_tuning( series=[ { "label": "render | dis_mode=1", "points": [ {"nx": 31, "cell_px": 16.13, "effective_patch_px": 16.0, "score": 69.2}, {"nx": 80, "cell_px": 6.25, "effective_patch_px": 16.0, "score": 40.5}, ], }, { "label": "render | dis_mode=0", "points": [ {"nx": 31, "cell_px": 16.13, "effective_patch_px": 8.0, "score": 20.0}, {"nx": 80, "cell_px": 6.25, "effective_patch_px": 8.0, "score": 55.0}, ], }, ], out_path=tmp_path / "tuning.png", patch_px=8.0, ) _assert_png(out) def test_tuning_with_no_points_still_writes_a_placeholder(tmp_path: Path) -> None: out = probe_plot.render_tuning(series=[], out_path=tmp_path / "tuning_empty.png") _assert_png(out) def test_response_curves_writes_with_a_per_curve_floor(tmp_path: Path) -> None: out = probe_plot.render_response_curves( curves=[ { "label": "nx=80 r=0.5 mm dm=1", "verdict": "resolvable", "score": 57.7, "floor_px": 0.0155, "points": [ {"step_mm": 0.01, "response_px": 0.08, "applied_px": 0.13}, {"step_mm": 0.10, "response_px": 0.89, "applied_px": 1.32}, ], }, { "label": "nx=31 r=0.5 mm dm=1", "verdict": "resolvable", "score": 12.3, # A different floor, so the two must get their own lines. "floor_px": 0.0372, "points": [ {"step_mm": 0.01, "response_px": 0.08, "applied_px": 0.13}, {"step_mm": 0.10, "response_px": 0.48, "applied_px": 1.32}, ], }, ], out_path=tmp_path / "response.png", axis="x", ) _assert_png(out) def test_response_curves_adds_a_retention_panel(tmp_path: Path) -> None: """Retention is the panel that exposes a pattern collapsing under noise.""" out = probe_plot.render_response_curves( curves=[ { "label": "nx=80 r=0.5 mm dm=1", "points": [{"step_mm": 0.10, "response_px": 0.90, "applied_px": 1.32}], "floor_px": 0.0155, "retention": [ {"sigma": 4.0, "response_retention": 0.99}, {"sigma": 16.0, "response_retention": 0.84}, ], }, { "label": "nx=31 r=0.5 mm dm=1", "points": [{"step_mm": 0.10, "response_px": 0.48, "applied_px": 1.32}], "floor_px": 0.0372, "retention": [ {"sigma": 4.0, "response_retention": 0.72}, {"sigma": 16.0, "response_retention": 0.59}, ], }, ], out_path=tmp_path / "retention.png", axis="x", ) _assert_png(out) def test_response_curve_labels_must_be_unique_to_be_useful(tmp_path: Path) -> None: """Regression: the old label was `nx=.. dm..` and omitted the radius, so two runs (nx80@r0.5@dm0 and nx80@r1.5@dm0) produced identical legend entries.""" runs = [ ("nx=80 r=0.5 mm dm=0", 0.03), ("nx=80 r=1.5 mm dm=0", 0.09), ] out = probe_plot.render_response_curves( curves=[ { "label": label, "points": [{"step_mm": 0.10, "response_px": 0.9, "applied_px": 1.32}], "floor_px": floor, } for label, floor in runs ], out_path=tmp_path / "unique.png", axis="x", ) _assert_png(out) assert len({label for label, _ in runs}) == 2 def test_response_curves_without_curves(tmp_path: Path) -> None: out = probe_plot.render_response_curves( curves=[], out_path=tmp_path / "response_empty.png" ) _assert_png(out) def _write_run(directory: Path, *, nx: int = 31, include_flow: bool = True) -> None: directory.mkdir(parents=True, exist_ok=True) frames_dir = directory / "frames" frames_dir.mkdir(exist_ok=True) frame_name = "000_reference" (frames_dir / f"{frame_name}.png").write_bytes(PNG_MAGIC) manifest = { "width": 500, "height": 500, "probe_roi_px": {"u": 30.0, "v": 30.0, "radius": 6.6}, "frames": [ {"index": 0, "name": frame_name, "axis": None, "step_mm": 0.0, "png": f"frames/{frame_name}.png", "centroid_px": [30.0, 30.0]}, {"index": 1, "name": "001_x_+0.10mm", "axis": "x", "step_mm": 0.10, "png": "frames/001_x_+0.10mm.png", "centroid_px": [31.3, 30.0]}, ], } (directory / "render_manifest.json").write_text(json.dumps(manifest), encoding="utf-8") flow_report = { "tracker": {"patch_px": 8, "finest_scale": 1}, "scales": [ {"name": "render", "width": 500, "height": 500, "frames": [{"name": "001_x_+0.10mm", "roi": {"u": 30.0, "v": 30.0, "radius": 6.6, "pixels": 139}}]} ], } (directory / "flow_report.json").write_text(json.dumps(flow_report), encoding="utf-8") sensitivity = { "scales": [ {"name": "render", "cell_px": 500.0 / nx, "effective_patch_px": 16.0, "axes": [{"axis": "x", "direction_px": [1.0, 0.0], "points": [{"step_mm": 0.10, "response_px": 0.886, "applied_px": 1.325}]}]} ] } (directory / "sensitivity_report.json").write_text(json.dumps(sensitivity), encoding="utf-8") if include_flow: flow_dir = directory / "flow" / "render" flow_dir.mkdir(parents=True, exist_ok=True) np.save(flow_dir / "001_x_+0.10mm.npy", _flow()) def test_panel_from_run_reads_the_artifacts(tmp_path: Path) -> None: _write_run(tmp_path, nx=31) panel = probe_plot.panel_from_run(label="nx=31", output_dir=tmp_path, axis="x", step_mm=0.10) assert panel["label"] == "nx=31" assert panel["flow"] is not None assert panel["response_px"] == pytest.approx(0.886) assert panel["applied_px"] == pytest.approx(1.325) assert panel["cell_px"] == pytest.approx(500.0 / 31) assert panel["effective_patch_px"] == pytest.approx(16.0) assert "patch 8 px" in panel["scale_note"] def test_panel_from_run_names_a_missing_flow_array(tmp_path: Path) -> None: _write_run(tmp_path, include_flow=False) panel = probe_plot.panel_from_run(label="broken", output_dir=tmp_path, axis="x", step_mm=0.10) assert panel["flow"] is None assert "missing flow array" in panel["note"] def test_panel_from_run_survives_no_artifacts_at_all(tmp_path: Path) -> None: empty = tmp_path / "nothing" empty.mkdir() panel = probe_plot.panel_from_run(label="empty", output_dir=empty, axis="x", step_mm=0.10) assert panel["flow"] is None assert panel["cell_px"] is None # And the figure must still be drawable from it. out = probe_plot.render_flow_comparison(panels=[panel], out_path=tmp_path / "none.png") _assert_png(out)