319 lines
9.7 KiB
Python
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()
|