Files
orisys-sensor-design/tests/test_optics_plot.py
T
2026-09-23 10:01:08 +08:00

319 lines
9.7 KiB
Python

"""Tests for the two-panel refraction comparison figure and the sample field."""
from __future__ import annotations
import math
import sys
from pathlib import Path
import pytest
np = pytest.importorskip("numpy")
from orisys_sensor_design.optics import (
AIR_INDEX,
Interface,
OpticsLayer,
OpticalStack,
camera_rays,
sample_stack,
summarize,
quantify,
)
from orisys_sensor_design.optics_plot import (
DEFAULT_SANITY_MM,
figure_summary,
project_pinhole,
render_comparison,
)
REPO = Path(__file__).resolve().parents[1]
XYNOVA_EXPORT = REPO / "build" / "export" / "finger-xynova" / "r01" / "compute"
def _quad(z: float, half: float = 10.0, name: str = "q") -> Interface:
vertices = np.array(
[[-half, -half, z], [half, -half, z], [half, half, z], [-half, half, z]],
dtype=np.float64,
)
faces = np.array([[0, 1, 2], [0, 2, 3]], dtype=np.int64)
return Interface(name=name, vertices=vertices, faces=faces)
def _air_stack():
layer = OpticsLayer(name="air", outer="top", inner="bottom", ior=1.0, ior_source="t")
layers = [(layer, _quad(0.0, name="top"), _quad(-2.0, name="bottom"))]
marker = _quad(-10.0, half=60.0, name="marker")
return OpticalStack(marker_surface="marker", layers=(layer,)), layers, marker
def _rays(samples: int = 4) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
K = np.array([[100.0, 0.0, 50.0], [0.0, 100.0, 50.0], [0.0, 0.0, 1.0]])
# Place the camera 5 units in front of the stack: at the sensor origin it
# would sit exactly on the first interface and every ray would miss.
world_to_cam = np.eye(4)
world_to_cam[2, 3] = -5.0
return camera_rays(K, world_to_cam, 100, 100, samples)
# ----------------------------------------------------------- sample field
def test_sample_stack_returns_arrays_with_nan_for_unresolved() -> None:
stack, layers, marker = _air_stack()
origin, directions, pixel_u, pixel_v = _rays(4)
field = sample_stack(
stack=stack,
layers=layers,
marker=marker,
origin=origin,
directions=directions,
pitch_mm=0.25,
measured_thickness=[2.0],
pixel_u=pixel_u,
pixel_v=pixel_v,
image_size=(100, 100),
)
assert field.count == 16
assert field.error_mm.shape == (16,)
assert field.straight.shape == (16, 3)
# A plane-facing camera resolves every ray here.
assert field.resolved == 16
assert np.all(np.isfinite(field.error_mm))
assert field.grid_shape == (4, 4)
assert field.image_size == (100, 100)
assert np.allclose(field.error_mm, 0.0, atol=1e-9)
def test_sample_stack_marks_misses_as_nan() -> None:
stack, layers, marker = _air_stack()
# Rays pointing away from every surface must not become zeros.
directions = np.tile(np.array([0.0, 0.0, 1.0]), (5, 1))
field = sample_stack(
stack=stack,
layers=layers,
marker=marker,
origin=np.array([0.0, 0.0, 5.0]),
directions=directions,
pitch_mm=0.25,
measured_thickness=[2.0],
)
assert field.resolved == 0
assert np.all(np.isnan(field.error_mm))
assert field.status_counts().get("miss") == 5
def test_summarize_agrees_with_quantify() -> None:
"""The refactor must not change the JSON report's numbers."""
stack, layers, marker = _air_stack()
origin, directions, pixel_u, pixel_v = _rays(4)
kwargs = dict(
stack=stack,
layers=layers,
marker=marker,
origin=origin,
directions=directions,
pitch_mm=0.25,
measured_thickness=[2.0],
)
summary = summarize(sample_stack(**kwargs))
legacy = quantify(**kwargs)
assert summary == legacy
assert summary["statuses"]["ok"] == 16
def test_camera_rays_pixel_grid_matches_the_direction_count() -> None:
origin, directions, pixel_u, pixel_v = _rays(5)
assert directions.shape == (25, 3)
assert pixel_u.shape == (25,)
assert pixel_v.shape == (25,)
assert pixel_u.min() > 0.0 and pixel_u.max() < 100.0
assert origin.shape == (3,)
# ----------------------------------------------------------------- plotting
def test_project_pinhole_places_a_centred_point_at_the_principal_point() -> None:
K = np.array([[100.0, 0.0, 50.0], [0.0, 100.0, 50.0], [0.0, 0.0, 1.0]])
u, v, depth = project_pinhole(np.array([[0.0, 0.0, -5.0]]), K, np.eye(4))
assert u[0] == pytest.approx(50.0)
assert v[0] == pytest.approx(50.0)
assert depth[0] == pytest.approx(5.0)
matplotlib = pytest.importorskip("matplotlib")
def _render(tmp_path: Path, field, marker, *, sanity_mm: float = DEFAULT_SANITY_MM):
K = np.array([[100.0, 0.0, 50.0], [0.0, 100.0, 50.0], [0.0, 0.0, 1.0]])
return render_comparison(
samples=field,
marker=marker,
K=K,
world_to_cam=np.eye(4),
out_path=tmp_path / "figure.png",
sanity_mm=sanity_mm,
)
def test_render_comparison_writes_a_png(tmp_path: Path) -> None:
stack, layers, marker = _air_stack()
origin, directions, pixel_u, pixel_v = _rays(4)
field = sample_stack(
stack=stack,
layers=layers,
marker=marker,
origin=origin,
directions=directions,
pitch_mm=0.25,
measured_thickness=[2.0],
pixel_u=pixel_u,
pixel_v=pixel_v,
image_size=(100, 100),
)
path = _render(tmp_path, field, marker)
assert path.is_file() and path.stat().st_size > 0
assert path.read_bytes()[:8] == b"\x89PNG\r\n\x1a\n"
def test_render_comparison_survives_an_all_unresolved_field(tmp_path: Path) -> None:
stack, layers, marker = _air_stack()
directions = np.tile(np.array([0.0, 0.0, 1.0]), (4, 1))
field = sample_stack(
stack=stack,
layers=layers,
marker=marker,
origin=np.array([0.0, 0.0, 5.0]),
directions=directions,
pitch_mm=0.25,
measured_thickness=[2.0],
pixel_u=np.array([1.0, 2.0, 3.0, 4.0]),
pixel_v=np.array([1.0, 2.0, 3.0, 4.0]),
image_size=(100, 100),
)
path = _render(tmp_path, field, marker)
assert path.is_file() and path.stat().st_size > 0
def test_render_comparison_handles_an_all_zero_field(tmp_path: Path) -> None:
"""An air-only stack has zero displacement; the colour range must not collapse."""
stack, layers, marker = _air_stack()
origin, directions, pixel_u, pixel_v = _rays(4)
field = sample_stack(
stack=stack,
layers=layers,
marker=marker,
origin=origin,
directions=directions,
pitch_mm=0.25,
measured_thickness=[2.0],
pixel_u=pixel_u,
pixel_v=pixel_v,
image_size=(100, 100),
)
assert np.allclose(field.error_mm, 0.0, atol=1e-9)
assert _render(tmp_path, field, marker).is_file()
def test_figure_summary_counts_implausible_samples() -> None:
stack, layers, marker = _air_stack()
origin, directions, pixel_u, pixel_v = _rays(4)
field = sample_stack(
stack=stack,
layers=layers,
marker=marker,
origin=origin,
directions=directions,
pitch_mm=0.25,
measured_thickness=[2.0],
pixel_u=pixel_u,
pixel_v=pixel_v,
image_size=(100, 100),
)
counts = figure_summary(field, sanity_mm=5.0)
assert counts["resolved"] == 16
assert counts["implausible"] == 0 # air only: no displacement at all
assert counts["sanity_mm"] == 5.0
# ---------------------------------------------------------------------- CLI
@pytest.mark.skipif(
not (XYNOVA_EXPORT / "mesh.npz").is_file(), reason="export not built"
)
def test_cli_writes_both_the_report_and_the_figure(tmp_path: Path) -> None:
from orisys_sensor_design import cli
revision = REPO / "sensors" / "finger-xynova" / "r01"
args = type(
"Args",
(),
{
"profile": "compute",
"output_dir": XYNOVA_EXPORT,
"samples": 8,
"out": tmp_path / "report.json",
"plot": tmp_path / "figure.png",
"no_plot": False,
"sanity_mm": 5.0,
},
)()
assert cli.cmd_optics(revision, args) == 0
assert (tmp_path / "report.json").is_file()
assert (tmp_path / "figure.png").is_file()
@pytest.mark.skipif(
not (XYNOVA_EXPORT / "mesh.npz").is_file(), reason="export not built"
)
def test_cli_no_plot_skips_the_figure(tmp_path: Path) -> None:
from orisys_sensor_design import cli
revision = REPO / "sensors" / "finger-xynova" / "r01"
args = type(
"Args",
(),
{
"profile": "compute",
"output_dir": XYNOVA_EXPORT,
"samples": 8,
"out": tmp_path / "report.json",
"plot": tmp_path / "figure.png",
"no_plot": True,
"sanity_mm": 5.0,
},
)()
assert cli.cmd_optics(revision, args) == 0
assert (tmp_path / "report.json").is_file()
assert not (tmp_path / "figure.png").exists()
@pytest.mark.skipif(
not (XYNOVA_EXPORT / "mesh.npz").is_file(), reason="export not built"
)
def test_cli_without_matplotlib_exits_two_without_tracing(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""A missing plotting dependency must fail fast, before the ray trace."""
from orisys_sensor_design import cli
monkeypatch.setitem(sys.modules, "matplotlib", None)
revision = REPO / "sensors" / "finger-xynova" / "r01"
args = type(
"Args",
(),
{
"profile": "compute",
"output_dir": XYNOVA_EXPORT,
"samples": 8,
"out": tmp_path / "report.json",
"plot": tmp_path / "figure.png",
"no_plot": False,
"sanity_mm": 5.0,
},
)()
assert cli.cmd_optics(revision, args) == 2
assert not (tmp_path / "report.json").exists()