added optics test (undergoing)

This commit is contained in:
ykcheungab
2026-09-23 10:01:08 +08:00
parent f9662df08c
commit bc5a928852
11 changed files with 2099 additions and 9 deletions
+308
View File
@@ -0,0 +1,308 @@
"""Tests for the optical stack: validation, Snell's law, and the slab oracle."""
from __future__ import annotations
import math
from pathlib import Path
import pytest
np = pytest.importorskip("numpy")
from orisys_sensor_design.optics import (
AIR_INDEX,
Interface,
OpticsConfigError,
OpticsLayer,
OpticalStack,
intersect_mesh,
load_optics_block,
quantify,
refract,
resolve_optics,
surfaces_coincide,
trace_ray,
validate_optics_block,
)
from orisys_sensor_design.manifest import RevisionPaths, repo_root
REPO = repo_root()
def _block(**overrides) -> dict:
base = {
"marker_surface": "sensing_surface",
"layers": [
{
"name": "clear_rubber",
"outer": "rubber-2",
"inner": "rubber-1",
"ior": 1.43,
"ior_source": "assumed_silicone_literature",
}
],
}
base.update(overrides)
return base
def _quad(z: float, half: float = 10.0, name: str = "q") -> Interface:
"""A horizontal quad facing -Z, as two triangles."""
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)
# ------------------------------------------------------------- validation
def test_validate_accepts_a_complete_block() -> None:
assert validate_optics_block(_block()) == []
def test_validate_rejects_unknown_keys() -> None:
errors = validate_optics_block(_block(dpi=600))
assert any("unknown key" in message for message in errors)
def test_validate_requires_ior_source() -> None:
block = _block()
del block["layers"][0]["ior_source"]
errors = validate_optics_block(block)
assert any("ior_source" in message for message in errors)
def test_validate_rejects_an_index_at_or_below_one() -> None:
for value in (1.0, 0.9):
block = _block()
block["layers"][0]["ior"] = value
errors = validate_optics_block(block)
assert any("ior" in message for message in errors), value
def test_validate_rejects_duplicate_layer_names() -> None:
block = _block()
block["layers"].append(dict(block["layers"][0]))
errors = validate_optics_block(block)
assert any("duplicate" in message for message in errors)
def test_validate_rejects_empty_layers() -> None:
assert any("non-empty" in m for m in validate_optics_block(_block(layers=[])))
def test_validate_rejects_a_shared_interface_between_layers() -> None:
"""The exact mistake that made every ray miss: rubber-2 as exit and entry."""
block = _block(
layers=[
{
"name": "plastic_shell",
"outer": "shell",
"inner": "rubber-2",
"ior": 1.59,
"ior_source": "assumed",
},
{
"name": "clear_rubber",
"outer": "rubber-2",
"inner": "rubber-1",
"ior": 1.43,
"ior_source": "assumed",
},
]
)
errors = validate_optics_block(block)
assert any("share an interface" in message for message in errors)
def test_resolve_optics_returns_none_when_absent(tmp_path: Path) -> None:
revision = tmp_path / "sensors" / "finger" / "r99"
revision.mkdir(parents=True)
(revision / "design.yaml").write_text("sensor: finger\nrevision: r99\n", encoding="utf-8")
paths = RevisionPaths.from_revision_dir(revision)
assert load_optics_block(paths) is None
assert resolve_optics(paths) is None
def test_r01_design_yaml_declares_a_valid_stack() -> None:
paths = RevisionPaths.from_revision_dir(REPO / "sensors" / "finger-xynova" / "r01")
stack = resolve_optics(paths)
assert stack is not None
assert stack.marker_surface == "sensing_surface"
# The stack is camera > shell > rubber-1 > sensing_surface > rubber-2, so the
# two refracting solids are shell and rubber-1; rubber-2 sits behind the
# markers and does not bend camera light.
assert [layer.name for layer in stack.layers] == ["plastic_shell", "clear_rubber"]
assert [layer.solid for layer in stack.layers] == ["shell", "rubber-1"]
assert all(layer.is_solid for layer in stack.layers)
assert all(layer.ior_source for layer in stack.layers), (
"an assumed index must record its source"
)
# ------------------------------------------------------------------- optics
def test_refract_obeys_snells_law() -> None:
normal = np.array([0.0, 0.0, 1.0])
angle_in = math.radians(40.0)
direction = np.array([math.sin(angle_in), 0.0, -math.cos(angle_in)])
out = refract(direction, normal, 1.0, 1.5)
assert out is not None
angle_out = math.acos(abs(float(out[2])))
assert math.sin(angle_in) == pytest.approx(1.5 * math.sin(angle_out), rel=1e-9)
def test_refract_returns_none_under_total_internal_reflection() -> None:
normal = np.array([0.0, 0.0, 1.0])
steep = math.radians(70.0)
direction = np.array([math.sin(steep), 0.0, math.cos(steep)])
assert refract(direction, normal, 1.5, 1.0) is None
def test_intersect_mesh_finds_the_plane_and_reports_a_miss() -> None:
plane = _quad(0.0)
hit = intersect_mesh(np.array([0.0, 0.0, -5.0]), np.array([0.0, 0.0, 1.0]), plane)
assert hit is not None
distance, point, normal = hit
assert distance == pytest.approx(5.0)
assert point[2] == pytest.approx(0.0)
assert abs(float(normal[2])) == pytest.approx(1.0)
miss = intersect_mesh(np.array([0.0, 0.0, -5.0]), np.array([0.0, 1.0, 0.0]), plane)
assert miss is None
def test_surfaces_coincide_is_asymmetric_by_design() -> None:
dense = np.array([[x, 0.0, 0.0] for x in np.linspace(0.0, 1.0, 20)])
sparse = dense[::4]
# Every sparse point lies on the dense set, but not the reverse.
assert surfaces_coincide(sparse, dense, 1e-6) is True
assert surfaces_coincide(dense, sparse, 1e-6) is False
def test_slab_displacement_matches_the_closed_form() -> None:
"""The tracer against an independent analytic oracle, not against itself.
A plane-parallel slab of index ``n`` and thickness ``t`` shifts a ray of
incidence ``theta1`` *along the interface* by ``t * (tan(theta1) - tan(theta2))``
with ``sin(theta2) = sin(theta1)/n``. That in-plane shift is the one that moves
a marker's apparent position; the perpendicular gap between the incident and
exit rays is smaller by a factor of ``cos(theta1)``, and conflating the two
understates the error by 15% at 30 degrees.
"""
thickness = 2.0
index = 1.5
layer = OpticsLayer(
name="slab", outer="top", inner="bottom", ior=index, ior_source="test"
)
layers = [(layer, _quad(0.0, name="top"), _quad(-thickness, name="bottom"))]
# Wide enough that a 40 degree ray still lands on it after 15 units of travel.
marker = _quad(-10.0, half=60.0, name="marker")
for theta_deg in (10.0, 20.0, 30.0, 40.0):
theta = math.radians(theta_deg)
direction = np.array([math.sin(theta), 0.0, -math.cos(theta)])
origin = np.array([0.0, 0.0, 5.0])
straight = intersect_mesh(origin, direction, marker)
traced = trace_ray(origin, direction, layers, marker)
assert straight is not None and traced.status == "ok", theta_deg
_, straight_point, _ = straight
measured = float(np.linalg.norm(traced.point - straight_point))
theta_out = math.asin(math.sin(theta) / index)
expected = thickness * (math.tan(theta) - math.tan(theta_out))
assert measured == pytest.approx(expected, rel=1e-3), theta_deg
def test_index_one_reproduces_the_pinhole_result() -> None:
"""With no refraction the trace must agree with the straight ray exactly."""
layer = OpticsLayer(name="air", outer="top", inner="bottom", ior=1.0, ior_source="test")
layers = [(layer, _quad(0.0, name="top"), _quad(-2.0, name="bottom"))]
marker = _quad(-10.0, name="marker")
origin = np.array([0.0, 0.0, 5.0])
direction = np.array([0.3, 0.1, -1.0])
direction = direction / np.linalg.norm(direction)
straight = intersect_mesh(origin, direction, marker)
traced = trace_ray(origin, direction, layers, marker)
assert straight is not None and traced.status == "ok"
_, straight_point, _ = straight
assert np.allclose(traced.point, straight_point, atol=1e-9)
def test_marker_at_last_exit_uses_the_exit_point() -> None:
"""Markers printed on the inner face: the exit point is the marker hit."""
layer = OpticsLayer(name="rubber", outer="top", inner="bottom", ior=1.43, ior_source="t")
layers = [(layer, _quad(0.0, name="top"), _quad(-1.0, name="bottom"))]
marker = _quad(-1.0, name="marker")
origin = np.array([0.0, 0.0, 5.0])
direction = np.array([0.1, 0.0, -1.0])
direction = direction / np.linalg.norm(direction)
without = trace_ray(origin, direction, layers, marker)
with_flag = trace_ray(origin, direction, layers, marker, marker_at_last_exit=True)
assert with_flag.status == "ok"
assert with_flag.point[2] == pytest.approx(-1.0, abs=1e-6)
# Without the flag the ray sits on the marker already and finds nothing.
assert without.status == "miss"
def test_quantify_reports_zero_error_for_an_air_only_stack() -> None:
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, name="marker")
stack = OpticalStack(marker_surface="marker", layers=(layer,))
directions = np.array([[x, 0.0, -1.0] for x in (-0.2, 0.0, 0.2)])
directions = directions / np.linalg.norm(directions, axis=1, keepdims=True)
result = quantify(
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 result["statuses"]["ok"] == 3
assert result["error_mm"]["max"] == pytest.approx(0.0, abs=1e-9)
assert result["error_cells"]["max"] == pytest.approx(0.0, abs=1e-9)
assert result["layers"][0]["thickness_mm"] == pytest.approx(2.0)
def test_quantify_reports_measured_thickness_when_not_declared() -> None:
layer = OpticsLayer(name="slab", outer="top", inner="bottom", ior=1.5, ior_source="t")
layers = [(layer, _quad(0.0, name="top"), _quad(-2.0, name="bottom"))]
marker = _quad(-10.0, name="marker")
stack = OpticalStack(marker_surface="marker", layers=(layer,))
result = quantify(
stack=stack,
layers=layers,
marker=marker,
origin=np.array([0.0, 0.0, 5.0]),
directions=np.array([[0.0, 0.0, -1.0]]),
pitch_mm=0.25,
measured_thickness=[1.75],
)
assert result["layers"][0]["thickness_mm"] == pytest.approx(1.75)
assert result["total_thickness_mm"] == pytest.approx(1.75)
def test_load_optics_block_rejects_a_non_mapping(tmp_path: Path) -> None:
revision = tmp_path / "sensors" / "finger" / "r99"
revision.mkdir(parents=True)
(revision / "design.yaml").write_text(
"sensor: finger\nrevision: r99\noptics: 3\n", encoding="utf-8"
)
paths = RevisionPaths.from_revision_dir(revision)
with pytest.raises(OpticsConfigError, match="must be a mapping"):
load_optics_block(paths)
+318
View File
@@ -0,0 +1,318 @@
"""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()