Files
orisys-app/shared/tests/test_scene3d_device_geometry.py
2026-09-27 19:20:43 +08:00

436 lines
19 KiB
Python

"""Stage 6 acceptance for the *device's own* geometry description.
The review's R3 finding was that the internal-consistency check was never wired to the
acquisition side: the adapter was built from the asset, so "the frame matches the scene"
was a sentence the asset wrote about itself. These tests are about the other half — the
description the acquisition side derives from the files the runtime actually loads, and
what happens when those files and the package disagree.
The bundle used here is the shipped one (``apps/studio/config/sensors/finger``); the
tests that need it skip when it is absent, because the module is also imported by runs
that only have the SDK.
"""
from __future__ import annotations
import io
import json
import shutil
import zipfile
from dataclasses import replace
from pathlib import Path
import numpy as np
import pytest
from orisys_app_shared.acquisition.geometry import (
describe_sensor_geometry,
map_path_for,
)
from orisys_app_shared.scene3d.contracts import ThreeDError
from orisys_app_shared.scene3d.package import PackagedAssetLoader
BUNDLE = (
Path(__file__).resolve().parents[2]
/ "apps"
/ "studio"
/ "config"
/ "sensors"
/ "finger"
)
PACKAGE = BUNDLE / "scene" / "finger-display-1.0.0.oryscene"
#: The **runtime** SDK asset: the mesh the device actually runs, in source units.
SDK_ASSET_DIR = Path(r"D:\orisys_ws\orisys-sdk\sensors\finger\assets\30fps-0703")
SDK_MESH = SDK_ASSET_DIR / "mesh.npz"
#: The physical statement about that asset: one source unit is one millimetre.
#: Documented in ``tools/scene_assets/README.md`` and stated by the compiler's
#: ``--meters-per-source-unit``; it is the *design* export that is in other units.
SDK_METERS_PER_SOURCE_UNIT = 0.001
#: What the SDK asset measures when the file above is not on this machine (metres):
#: the same 15.81 x 15.0 x 7.5 mm envelope, kept as a documented constant so the
#: guard still runs — and can still fail — on a checkout without the SDK.
SENSOR_ENVELOPE_FALLBACK_M = np.array([0.01580961, 0.015, 0.0075])
#: How far the shipped package may be from the SDK asset's own envelope. The defect
#: this guards against is a 54.38x error, so one percent is three orders of magnitude
#: tighter and still leaves room for float32 storage and the export's own rounding.
ENVELOPE_RELATIVE_TOLERANCE = 0.01
def sensor_frame_envelope_m() -> np.ndarray:
"""The sensing surface's true envelope, in the sensor frame, in metres.
Derived from the **SDK asset mesh** — the file the device runs — and *not* from
the package: a guard that reads its expectation out of the thing it is checking
cannot fail. ``manifest.length_unit`` is ``m`` and ``coordinate_frame`` is
``sensor``, so the package's arrays are directly comparable with this.
"""
if not SDK_MESH.is_file():
return SENSOR_ENVELOPE_FALLBACK_M.copy()
with np.load(SDK_MESH, allow_pickle=False) as mesh:
extent = np.ptp(np.asarray(mesh["vertices"], dtype=np.float64), axis=0)
return extent * SDK_METERS_PER_SOURCE_UNIT
def _package_arrays(package: Path) -> tuple[dict, dict[str, np.ndarray]]:
"""The raw manifest and entries, read straight out of the zip.
Deliberately not through the loader: this guard is about the *written file*,
and a check that goes through code which might normalise or repair would not
see what actually shipped.
"""
with zipfile.ZipFile(package) as archive:
manifest = json.loads(archive.read("manifest.json"))
arrays = {
name: np.load(io.BytesIO(archive.read(name)))
for name in archive.namelist()
if name.endswith(".npy")
}
return manifest, arrays
def sensing_envelope_m(package: Path) -> np.ndarray:
_, arrays = _package_arrays(package)
return np.ptp(np.asarray(arrays["sensing/positions.npy"], dtype=np.float64), axis=0)
def assert_package_geometry_is_sensor_scale(package: Path) -> None:
"""The scale check: the shipped envelope must be the SDK asset's envelope.
Two independent statements inside the package have to be right, and the second
is the one that reaches the renderer's own framing:
* ``sensing/positions.npy``, in the metres ``manifest.length_unit`` claims;
* ``manifest.bounds_sensor_m``, which the loader trusts for the camera.
"""
expected = sensor_frame_envelope_m()
manifest, arrays = _package_arrays(package)
length_unit = str(manifest.get("length_unit") or "")
assert length_unit == "m", (
f"{package.name} declares length_unit={length_unit!r}; this guard compares "
"metres, so a different unit statement has to be converted first"
)
extent = np.ptp(np.asarray(arrays["sensing/positions.npy"], dtype=np.float64), axis=0)
np.testing.assert_allclose(
extent,
expected,
rtol=ENVELOPE_RELATIVE_TOLERANCE,
err_msg=(
f"{package.name}: the sensing envelope in the units the manifest claims "
"(m) does not match the SDK asset's own sensor-frame envelope. Measured "
f"{np.sort(extent)} m, the SDK asset states {np.sort(expected)} m "
f"(a factor of {float(np.sort(extent)[-1] / np.sort(expected)[-1]):.5f} on "
"the largest axis). This is the unit statement, not a formatting choice: "
"the design export is not in millimetres, and calling its units "
"millimetres compiles cleanly and is 54.38x too large."
),
)
bounds = np.asarray(manifest.get("bounds_sensor_m"), dtype=np.float64)
assert bounds.shape == (2, 3), f"{package.name}: bounds_sensor_m is {bounds.shape}"
np.testing.assert_allclose(
bounds[1] - bounds[0],
expected,
rtol=ENVELOPE_RELATIVE_TOLERANCE,
err_msg=(
f"{package.name}: manifest.bounds_sensor_m spans {bounds[1] - bounds[0]} m, "
f"but the SDK asset states {expected} m: the camera would frame the wrong box"
),
)
def _require_bundle() -> None:
if not (BUNDLE / "sensor.json").is_file():
pytest.skip("the shipped finger bundle is not present")
def _require_package() -> None:
_require_bundle()
if not PACKAGE.is_file():
pytest.skip("the compiled finger package is not present")
def _bundle_map_name() -> str:
"""The map filename this bundle's own config resolves to — derived, never hard-coded.
The filename follows ``camera.source_scale`` (``scaled_map_cache_path``), and the bundle moved
from ``0.4`` to ``0.42`` when the package was recompiled from the authoritative design export.
Deriving it here keeps these tests about the behaviour they name — the device description comes
from the files the runtime loads — instead of about one filename that any scale change renames.
"""
_require_bundle()
config = json.loads((BUNDLE / "sensor.json").read_text(encoding="utf-8"))
scale = config.get("camera", {}).get("source_scale")
return map_path_for(BUNDLE / "assets" / "30fps-0703", scale).name
def test_the_shipped_package_looks_at_the_sensing_side_of_the_surface():
"""The default camera must be in front of the plate, not behind it.
The shipped ``default`` preset used to be a fixed vector, which is only correct
if the export's frame happens to equal the sensor frame. The design export's does
not: that preset ended up 122.7° from the sensing surface's own normal — behind
the surface — where the frame hides essentially all of it. Both the package's
baked preset and the presets the loader derives are checked here.
"""
_require_package()
from orisys_app_shared.scene3d.contracts import DisplaySettings
from orisys_app_shared.scene3d.legacy import (
default_finger_presets,
sensing_surface_normal,
)
from orisys_app_shared.scene3d.package import read_package, scene_from_contents
scene = scene_from_contents(read_package(PACKAGE))
sensing = scene.sensing_part()
normal = sensing_surface_normal(
np.asarray(sensing.positions_sensor_m, dtype=np.float64),
np.asarray(sensing.triangles, dtype=np.int64),
)
frame = _package_arrays(PACKAGE)[0]
baked = next(item for item in frame["camera_presets"] if item["name"] == "default")
centre = np.asarray(scene.bounds_sensor_m, dtype=np.float64).mean(axis=0)
def angle(preset) -> float:
eye = np.asarray(preset.eye_sensor_m, dtype=np.float64)
offset = eye - centre
offset = offset / np.linalg.norm(offset)
return float(np.degrees(np.arccos(np.clip(offset @ normal, -1.0, 1.0))))
baked_angle = angle(
type(scene.camera_presets[0])(
name="default",
eye_sensor_m=tuple(baked["eye"]),
target_sensor_m=tuple(baked["target"]),
up_sensor=tuple(baked["up"]),
vertical_fov_rad=float(baked["fov"]),
)
)
assert baked_angle < 60.0, (
f"the baked default preset is {baked_angle:.1f}° from the sensing surface's "
"normal: it points at the back of the plate"
)
loaded_angle = angle(next(p for p in scene.camera_presets if p.name == "default"))
assert loaded_angle < 60.0, f"the loaded default preset is {loaded_angle:.1f}° off"
derived = default_finger_presets(
scene.bounds_sensor_m,
positions_sensor_m=np.asarray(sensing.positions_sensor_m, dtype=np.float64),
triangles=np.asarray(sensing.triangles, dtype=np.int64),
)
assert angle(derived[0]) < 40.0
assert all(abs(angle(preset)) <= 90.0 + 1e-6 for preset in derived)
# The housing stays hidden by default: the operator turns it on for inspection.
assert DisplaySettings().housing_visible is False
def test_a_preset_behind_the_surface_is_replaced_on_load(tmp_path):
"""The repair path, driven with a deliberately wrong preset.
A package whose ``default`` preset points at the back of the plate is loaded
through the real container, and the loader replaces the whole set with one
derived from the mesh. The point is that the guard is on the *consuming* side:
a package baked by an older compiler is corrected without being rebuilt.
"""
_require_package()
from dataclasses import replace
from orisys_app_shared.scene3d.legacy import sensing_surface_normal
from orisys_app_shared.scene3d.package import (
PackagedAssetLoader,
read_package,
scene_from_contents,
write_package,
)
scene = scene_from_contents(read_package(PACKAGE))
normal = sensing_surface_normal(
np.asarray(scene.sensing_part().positions_sensor_m, dtype=np.float64),
np.asarray(scene.sensing_part().triangles, dtype=np.int64),
)
centre = np.asarray(scene.bounds_sensor_m, dtype=np.float64).mean(axis=0)
backwards = tuple(centre - normal * 0.02)
broken = replace(
scene.camera_presets[0],
eye_sensor_m=backwards,
target_sensor_m=tuple(centre),
)
assert float(np.dot(np.asarray(backwards) - centre, normal)) < 0.0, "fixture is not behind"
path = tmp_path / "backwards.oryscene"
write_package(replace(scene, camera_presets=(broken,) + tuple(scene.camera_presets[1:])), path)
# The file keeps what was written...
assert _package_arrays(path)[0]["camera_presets"][0]["eye"] == list(backwards)
# ...and the loader does not present a view of the back of the sensor.
from orisys_app_shared.scene3d.contracts import ExpectedGeometry
expected = ExpectedGeometry(
"finger", "*", scene.geometry_id, scene.field_domain_id, scene.shape_hw,
scene.source_geometry_id,
)
loaded = PackagedAssetLoader().load(path, expected)
fixed = next(preset for preset in loaded.camera_presets if preset.name == "default")
offset = np.asarray(fixed.eye_sensor_m, dtype=np.float64) - centre
offset = offset / np.linalg.norm(offset)
angle = float(np.degrees(np.arccos(np.clip(offset @ normal, -1.0, 1.0))))
assert angle < 60.0, f"the loader kept a preset {angle:.1f}° off the surface normal"
def test_the_shipped_package_is_at_sensor_scale_not_design_scale():
"""The scale guard that was missing.
Every other check in this file is about *identity*: the geometry id, the domain
id and the source-geometry id are digests of unit-free data, so a package whose
geometry is 54.38x too large passes all of them — the ids are correct and only
the scale is wrong (measured: ``sensing/positions.npy`` at 0.8157 x 0.4079 x
0.8598 m in a package that claims millimetres-as-metres). This asserts the
package agrees with the SDK asset mesh's own physical envelope.
"""
_require_package()
assert_package_geometry_is_sensor_scale(PACKAGE)
def test_the_guard_itself_discriminates():
"""A guard that cannot fail is not a guard: 54.38x must be rejected."""
_require_package()
expected = sensor_frame_envelope_m()
too_large = expected * 54.381958120648946
with pytest.raises(AssertionError):
np.testing.assert_allclose(
too_large, expected, rtol=ENVELOPE_RELATIVE_TOLERANCE
)
assert float(np.max(too_large / expected)) == pytest.approx(54.38196, rel=1e-4)
def test_the_expected_envelope_comes_from_the_sdk_asset_not_the_package():
"""The expectation is external, so a wrong package cannot define it as right."""
if not SDK_MESH.is_file():
pytest.skip("the SDK asset mesh is not present on this machine")
with np.load(SDK_MESH, allow_pickle=False) as mesh:
extent_source_units = np.ptp(
np.asarray(mesh["vertices"], dtype=np.float64), axis=0
)
assert np.allclose(np.sort(extent_source_units), [7.5, 15.0, 15.81], atol=0.02), (
extent_source_units
)
assert np.allclose(
np.sort(sensor_frame_envelope_m()), [0.0075, 0.015, 0.01581], atol=2e-5
)
if PACKAGE.is_file():
# And it really is a different statement from the package's own numbers.
assert not np.allclose(
sensing_envelope_m(PACKAGE), extent_source_units
), "the SDK asset and the package are in the same units: this guard would be vacuous"
def test_the_map_name_follows_the_scale_the_sdk_would_use(tmp_path):
assert map_path_for(tmp_path, 0.4).name == "unroll_map_uv_s0.4.npz"
assert map_path_for(tmp_path, 1.0).name == "unroll_map_uv.npz"
assert map_path_for(tmp_path, None).name == "unroll_map_uv.npz"
def test_the_device_description_comes_from_the_files_the_runtime_loads():
_require_bundle()
device = describe_sensor_geometry("finger", BUNDLE / "sensor.json")
assert device.verified, device.detail
assert device.expected.sensor_model == "finger"
# The metre-scaled identity needs the operator's unit statement; a device that
# cannot make it must say nothing rather than echo the asset's id back.
assert device.expected.geometry_id == ""
assert device.expected.field_domain_id
assert device.expected.source_geometry_id
assert tuple(device.expected.shape_hw) == (300, 280)
assert Path(device.detail["map"]).name == _bundle_map_name()
assert device.detail["shape_hw"] == "300x280"
def test_the_shipped_package_passes_the_shipped_device_description():
_require_package()
device = describe_sensor_geometry("finger", BUNDLE / "sensor.json")
assets = PackagedAssetLoader().load(PACKAGE, device.expected)
assert assets.source_geometry_id == device.expected.source_geometry_id
assert assets.compatible_sensor_model == "finger"
def test_a_same_size_different_mapping_is_refused(tmp_path):
"""The risk the review named: 300x280 is not one mapping."""
_require_package()
fake = tmp_path / "finger"
shutil.copytree(BUNDLE, fake)
map_path = fake / "assets" / "30fps-0703" / _bundle_map_name()
with np.load(map_path, allow_pickle=False) as handle:
data = {key: handle[key] for key in handle.files}
# The same grid, the same valid area — a different mapping.
data["map_x"] = np.asarray(data["map_x"], dtype=np.float32).copy()
data["map_x"][0, 0] = float(data["map_x"][0, 0]) + 3.0
np.savez(map_path, **data)
shipped = describe_sensor_geometry("finger", BUNDLE / "sensor.json")
other = describe_sensor_geometry("finger", fake / "sensor.json")
assert tuple(other.expected.shape_hw) == tuple(shipped.expected.shape_hw)
assert other.expected.field_domain_id != shipped.expected.field_domain_id, (
"two different mappings of the same size produced the same domain id"
)
with pytest.raises(ThreeDError) as excinfo:
PackagedAssetLoader().load(PACKAGE, other.expected)
assert excinfo.value.diagnostic.code == "field_domain_mismatch"
def test_a_changed_mesh_behind_the_same_map_is_refused(tmp_path):
"""The other half: the map is identical, the surface behind it is not."""
_require_package()
device = describe_sensor_geometry("finger", BUNDLE / "sensor.json")
other_geometry = replace(device.expected, source_geometry_id="somebody-elses-surface")
with pytest.raises(ThreeDError) as excinfo:
PackagedAssetLoader().load(PACKAGE, other_geometry)
assert excinfo.value.diagnostic.code == "geometry_mismatch"
def test_a_bundle_that_cannot_be_described_is_unverified_not_a_match(tmp_path):
_require_bundle()
unknown = tmp_path / "nowhere"
device = describe_sensor_geometry("finger", unknown)
assert device.verified is False
assert device.expected.geometry_id == ""
assert device.expected.source_geometry_id == ""
assert "could not be located" in device.detail["reason"]
def test_a_bundle_whose_map_disagrees_with_the_config_is_refused(tmp_path):
"""``resolution_stitch`` and the map are two statements of one grid."""
_require_bundle()
fake = tmp_path / "finger"
shutil.copytree(BUNDLE, fake)
config = json.loads((fake / "sensor.json").read_text(encoding="utf-8"))
config["camera"]["resolution_stitch"] = [280, 299]
(fake / "sensor.json").write_text(
json.dumps(config, ensure_ascii=False), encoding="utf-8"
)
with pytest.raises(ThreeDError) as excinfo:
describe_sensor_geometry("finger", fake / "sensor.json")
assert excinfo.value.diagnostic.code == "field_domain_mismatch"
def test_a_missing_map_names_the_file_it_looked_for(tmp_path):
_require_bundle()
fake = tmp_path / "finger"
(fake / "assets" / "30fps-0703").mkdir(parents=True)
(fake / "manifest.json").write_text(
json.dumps({"assets_dir": "assets/30fps-0703"}), encoding="utf-8"
)
shutil.copy(BUNDLE / "sensor.json", fake / "sensor.json")
with pytest.raises(ThreeDError) as excinfo:
describe_sensor_geometry("finger", fake / "sensor.json")
assert excinfo.value.diagnostic.code == "asset_rejected"
assert _bundle_map_name() in excinfo.value.diagnostic.message
def test_a_bundle_without_an_assets_dir_is_unverified(tmp_path):
fake = tmp_path / "finger"
fake.mkdir()
(fake / "manifest.json").write_text(json.dumps({"name": "finger"}), encoding="utf-8")
(fake / "sensor.json").write_text(json.dumps({"camera": {}}), encoding="utf-8")
device = describe_sensor_geometry("finger", fake / "sensor.json")
assert device.verified is False
assert "assets_dir" in device.detail["reason"]