fixed commetn from songxiao
This commit is contained in:
@@ -13,8 +13,10 @@ 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
|
||||
|
||||
@@ -38,6 +40,106 @@ BUNDLE = (
|
||||
)
|
||||
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():
|
||||
@@ -50,6 +152,178 @@ def _require_package() -> None:
|
||||
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"
|
||||
@@ -67,7 +341,7 @@ def test_the_device_description_comes_from_the_files_the_runtime_loads():
|
||||
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 == "unroll_map_uv_s0.4.npz"
|
||||
assert Path(device.detail["map"]).name == _bundle_map_name()
|
||||
assert device.detail["shape_hw"] == "300x280"
|
||||
|
||||
|
||||
@@ -84,7 +358,7 @@ def test_a_same_size_different_mapping_is_refused(tmp_path):
|
||||
_require_package()
|
||||
fake = tmp_path / "finger"
|
||||
shutil.copytree(BUNDLE, fake)
|
||||
map_path = fake / "assets" / "30fps-0703" / "unroll_map_uv_s0.4.npz"
|
||||
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.
|
||||
@@ -149,7 +423,7 @@ def test_a_missing_map_names_the_file_it_looked_for(tmp_path):
|
||||
with pytest.raises(ThreeDError) as excinfo:
|
||||
describe_sensor_geometry("finger", fake / "sensor.json")
|
||||
assert excinfo.value.diagnostic.code == "asset_rejected"
|
||||
assert "unroll_map_uv_s0.4.npz" in excinfo.value.diagnostic.message
|
||||
assert _bundle_map_name() in excinfo.value.diagnostic.message
|
||||
|
||||
|
||||
def test_a_bundle_without_an_assets_dir_is_unverified(tmp_path):
|
||||
|
||||
Reference in New Issue
Block a user