437 lines
19 KiB
Python
437 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"]
|