Files
orisys-app/apps/autocal/tests/test_sensor_bundle.py
T

250 lines
8.7 KiB
Python

"""The sensor export bundle: unit derivation, validation, and the flat view.
The unit factor is the assertion that matters most. `export.py` writes vertices
in the sensor frame *without* converting the design unit, and the conversion
lives in `world_to_cam`'s linear block — so a consumer that assumes millimetres
is wrong by ~54x, and everything downstream (platform coordinates, press depths)
is wrong with it. These tests pin that down.
The bundle used here is the bench-confirmed one. When it is not present the
tests skip rather than fail: this suite has to run on machines that do not have
the sensor-design checkout.
"""
from __future__ import annotations
import numpy as np
import pytest
from orisys_autocal.sensor.export_bundle import (
BundleError,
housing_tail_origin_mm,
load_bundle,
mm_per_unit_from_pose,
)
from orisys_autocal.sensor.uv_flat import rasterise
#: The bench-confirmed export, and the figures measured from it.
BUNDLE = r"D:\orisys_ws\orisys-sensor-design\build\export\finger\r01\compute"
EXPECTED_VERTICES = 1032
EXPECTED_TRIANGLES = 1974
EXPECTED_MM_PER_UNIT = 0.018388452316735537
EXPECTED_EXTENT_MM = (15.0, 7.5, 15.81)
def _bundle():
if not __import__("pathlib").Path(BUNDLE).is_dir():
pytest.skip("the sensor-design export is not on this machine")
return load_bundle(
BUNDLE,
expected_vertices=EXPECTED_VERTICES,
expected_triangles=EXPECTED_TRIANGLES,
)
@pytest.fixture(scope="module")
def bundle():
return _bundle()
# ------------------------------------------------------------------- the scale
def test_the_unit_factor_is_derived_from_the_pose(bundle):
assert bundle.mm_per_unit == pytest.approx(EXPECTED_MM_PER_UNIT, rel=1e-9)
def test_the_physical_size_is_millimetres_not_mesh_units(bundle):
"""The failure this whole module exists to catch: a 54x unit error."""
extent = bundle.extent_mm
assert extent[0] == pytest.approx(EXPECTED_EXTENT_MM[0], abs=0.02)
assert extent[1] == pytest.approx(EXPECTED_EXTENT_MM[1], abs=0.02)
assert extent[2] == pytest.approx(EXPECTED_EXTENT_MM[2], abs=0.02)
def test_a_rigid_pose_means_one_millimetre_per_unit():
assert mm_per_unit_from_pose(np.eye(4)) == pytest.approx(1.0)
def test_a_uniformly_scaled_pose_reports_that_scale():
pose = np.eye(4)
pose[:3, :3] *= 0.5
assert mm_per_unit_from_pose(pose) == pytest.approx(0.5)
def test_a_non_uniform_pose_is_refused():
"""A per-axis scale cannot be undone by one factor; guessing is worse."""
pose = np.eye(4)
pose[0, 0] = 0.01
pose[1, 1] = 0.02
pose[2, 2] = 0.03
with pytest.raises(BundleError) as excinfo:
mm_per_unit_from_pose(pose)
assert "uniform" in str(excinfo.value)
def test_a_mirrored_pose_is_refused():
"""A reflection would put every point on the wrong side of the sensor."""
pose = np.eye(4)
pose[0, 0] = -1.0
with pytest.raises(BundleError) as excinfo:
mm_per_unit_from_pose(pose)
assert "mirror" in str(excinfo.value).lower()
def test_a_degenerate_pose_is_refused():
with pytest.raises(BundleError):
mm_per_unit_from_pose(np.zeros((4, 4)))
# ------------------------------------------------------------------- the mesh
def test_the_mesh_matches_the_design_profile(bundle):
assert bundle.vertices_mm.shape == (EXPECTED_VERTICES, 3)
assert bundle.faces.shape == (EXPECTED_TRIANGLES, 3)
assert bundle.uv_data.shape[0] == EXPECTED_TRIANGLES * 3
assert bundle.loop_to_vertex.shape[0] == EXPECTED_TRIANGLES * 3
def test_every_face_index_is_in_range(bundle):
assert int(bundle.faces.max()) < bundle.vertices_mm.shape[0]
assert int(bundle.faces.min()) >= 0
assert int(bundle.loop_to_vertex.max()) < bundle.vertices_mm.shape[0]
def test_the_surface_area_is_physically_plausible(bundle):
"""~356 mm^2 for a 15 x 7.5 x 15.8 mm patch: a unit error would show here."""
assert bundle.surface_area_mm2 == pytest.approx(355.77, abs=1.0)
def test_the_origin_is_the_unsensed_housing_tail():
"""The tail is the housing end the sensing patch does not cover."""
housing = np.array(
[
[-2.0, 0.0, 0.0],
[2.0, 0.0, 0.0],
[0.0, 4.0, 0.2],
[-1.0, 0.1, 20.0],
[1.0, 0.1, 20.2],
]
)
sensing = np.array([[0.0, 2.0, 5.0], [0.0, 2.0, 19.8]])
origin = housing_tail_origin_mm(housing, sensing)
assert origin[0] == pytest.approx(0.0)
assert origin[1] == pytest.approx(0.0)
assert origin[2] == pytest.approx(0.0)
def test_a_loaded_bundle_puts_the_tail_on_the_origin(bundle):
origin = housing_tail_origin_mm(bundle.housing_vertices_mm, bundle.vertices_mm)
assert origin == pytest.approx(np.zeros(3), abs=1e-6)
def test_the_stored_normals_are_coherent(bundle):
"""Mixed winding would make 'the normal' meaningless."""
normals = bundle.face_normals
centroid = bundle.vertices_mm.mean(axis=0)
centres = bundle.vertices_mm[bundle.faces].mean(axis=1)
outward = np.einsum("ij,ij->i", normals, centres - centroid)
assert float((outward > 0).mean()) > 0.95
def test_a_missing_file_is_named(tmp_dir):
"""A path that is not a directory at all says so, and names the path."""
absent = tmp_dir / "no-such-revision"
with pytest.raises(BundleError) as excinfo:
load_bundle(absent)
assert "not a directory" in str(excinfo.value)
assert absent.name in str(excinfo.value)
def test_an_incomplete_bundle_names_what_is_missing(tmp_dir):
(tmp_dir / "mesh.npz").write_bytes(b"")
with pytest.raises(BundleError) as excinfo:
load_bundle(tmp_dir)
message = str(excinfo.value)
assert "housing.npz" in message and "world_to_cam.npy" in message
def test_a_count_mismatch_is_refused(bundle):
with pytest.raises(BundleError) as excinfo:
load_bundle(BUNDLE, expected_vertices=999)
assert "999" in str(excinfo.value)
assert str(EXPECTED_VERTICES) in str(excinfo.value)
# --------------------------------------------------------------- the flat view
@pytest.fixture(scope="module")
def flat(bundle):
return rasterise(bundle, (256, 256))
def test_the_flat_image_is_not_blank(flat):
assert flat.image_rgb.shape == (256, 256, 3)
assert flat.image_rgb.dtype == np.uint8
assert not bool((flat.image_rgb == 0).all())
def test_a_reasonable_share_of_the_image_carries_surface(flat):
assert 0.5 < flat.valid_fraction < 0.9
def test_a_corner_pixel_is_an_island_gap(flat):
"""The atlas is a single island, so a corner is outside it."""
assert flat.sample(0, 0) is None
assert flat.sample(flat.width - 1, flat.height - 1) is None
def test_an_out_of_range_pixel_is_reported_not_crashed(flat):
assert flat.sample(-1, 5) is None
assert flat.sample(5, flat.height + 10) is None
def test_every_covered_pixel_lies_on_its_recorded_face(bundle, flat):
"""The lookup table must be the mesh, not an approximation of it."""
covered = np.argwhere(flat.face_index >= 0)
step = max(1, len(covered) // 300)
worst = 0.0
for row, column in covered[::step]:
face = int(flat.face_index[row, column])
triangle = bundle.vertices_mm[bundle.faces[face]]
normal = np.cross(triangle[1] - triangle[0], triangle[2] - triangle[0])
normal = normal / np.linalg.norm(normal)
distance = abs(float((flat.positions_mm[row, column] - triangle[0]) @ normal))
worst = max(worst, distance)
assert worst < 1e-9
def test_every_covered_pixel_reports_its_face_normal(bundle, flat):
covered = np.argwhere(flat.face_index >= 0)
step = max(1, len(covered) // 300)
for row, column in covered[::step]:
face = int(flat.face_index[row, column])
assert float(flat.normals[row, column] @ bundle.face_normals[face]) == pytest.approx(1.0)
def test_a_uv_corner_lands_on_a_pixel_near_its_vertex(bundle, flat):
"""The round trip: vertex -> its UV pixel -> looked-up 3D is within a pixel.
Most corners land exactly on their vertex; a few land one pixel off a seam,
which is what the tolerance is for.
"""
uv = bundle.uv_data.reshape(-1, 3, 2)
worst = 0.0
misses = 0
for index in range(0, len(uv), 97):
face, corner = divmod(index, 3)
x, y = flat.pixel_for_uv(float(uv[face, corner, 0]), float(uv[face, corner, 1]))
sample = flat.sample(x, y)
if sample is None:
# A corner exactly on an island boundary can miss; count it and keep
# the check meaningful by requiring that it stays rare.
misses += 1
continue
vertex = bundle.vertices_mm[bundle.loop_to_vertex[index]]
worst = max(worst, float(np.linalg.norm(sample[0] - vertex)))
assert worst < 0.1, "a looked-up point is more than a pixel from its vertex"
assert misses < len(range(0, len(uv), 97)) * 0.05