c377c14474
2. added unwarp map export in current cli, don't need to generate by precompute in sensor running
273 lines
9.7 KiB
Python
273 lines
9.7 KiB
Python
"""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)
|