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