fixed commetn from songxiao

This commit is contained in:
ykcheungab
2026-09-25 17:46:37 +08:00
parent 6effe74172
commit 12781854ca
56 changed files with 4935 additions and 419 deletions
+311 -3
View File
@@ -1,4 +1,4 @@
"""Display-only force baseline / deadband (value cards)."""
"""Display-only force baseline / deadband (value cards and curves)."""
from __future__ import annotations
@@ -6,6 +6,11 @@ import numpy as np
from orisys_app_shared.force.presentation import ForcePresentation, ZeroEstimate
#: The idle triplet from the customer's screenshot at run start: the numbers the
#: cards were showing because the estimate was not applied yet.
REPORTED_RAW_BIAS = (1118.0, 34.16, -186.1)
REPORTED_NEWTON_BIAS = (0.4, -0.25, 0.1)
def test_fit_zero_rejects_insufficient_samples() -> None:
presentation = ForcePresentation(enabled=True, min_samples=30)
@@ -22,7 +27,7 @@ def test_fit_zero_and_accept_deadband() -> None:
enabled=True,
min_samples=30,
baseline_seconds=0.0,
min_deadband=50.0,
min_deadband_raw=50.0,
deadband_percentile=99.0,
)
estimate = presentation.fit_zero(samples)
@@ -60,7 +65,7 @@ def test_observe_freezes_after_quiet_window() -> None:
enabled=True,
min_samples=10,
baseline_seconds=0.5,
min_deadband=10.0,
min_deadband_raw=10.0,
)
estimate: ZeroEstimate | None = None
for i in range(40):
@@ -80,8 +85,311 @@ def test_observe_freezes_after_quiet_window() -> None:
assert later is None
def test_the_estimate_is_kept_per_display_unit() -> None:
"""A newton baseline and a raw baseline are different scales, not one number."""
presentation = ForcePresentation(enabled=True, min_samples=5, baseline_seconds=1.0)
for i in range(12):
presentation.observe(
(1000.0 + i * 0.1, 0.0, 0.0), t_rel_s=i * 0.02, unit="raw"
)
assert presentation.unit == "raw"
assert presentation.ready
assert presentation.accept((1000.55, 0.0, 0.0), unit="raw").values[0] == 0.0
# The other unit has no estimate of its own and must show what was measured.
assert presentation.accept((1000.55, 0.0, 0.0), unit="N").values == (
1000.55,
0.0,
0.0,
)
presentation.unit = "N"
assert not presentation.ready, "the raw estimate made newtons ready"
presentation.unit = "raw"
# ...and it collects its own window, in its own scale.
for i in range(12):
presentation.observe((0.5 + i * 0.01, 0.0, 0.0), t_rel_s=i * 0.02, unit="N")
presentation.unit = "N"
assert presentation.ready
assert presentation.accept((0.555, 0.0, 0.0), unit="N").values[0] == 0.0
def test_the_newton_floor_is_not_the_raw_floor() -> None:
"""50 raw counts is a noise floor; 50 N would be a contact."""
presentation = ForcePresentation(enabled=True)
assert presentation.min_deadband_for("raw") == 50.0
assert presentation.min_deadband_for("N") == 0.0
rng = np.random.default_rng(3)
quiet = (0.5 + rng.normal(0.0, 0.002, size=(60, 3))).tolist()
estimate = presentation.fit_zero(quiet, unit="N")
assert estimate.quality == "ok"
assert max(estimate.deadbands) < 0.01, estimate.deadbands
def test_present_holds_a_resting_block_at_zero_and_keeps_an_excursion() -> None:
rng = np.random.default_rng(7)
bias = np.array([9555.0, -13368.0, -353.0])
quiet = (bias + rng.normal(0.0, 20.0, size=(80, 3))).tolist()
presentation = ForcePresentation(
enabled=True, min_samples=30, baseline_seconds=0.0, deadband_percentile=99.0
)
estimate = presentation.fit_zero(quiet)
assert estimate.quality == "ok"
presentation.apply_estimate(estimate)
block = np.asarray(
[bias, bias + 5.0, bias + 4000.0, [np.nan, np.nan, np.nan]], dtype=float
)
displayed, suppressed = presentation.present(block, unit="raw")
assert displayed[0].tolist() == [0.0, 0.0, 0.0], "a resting sample drifted"
assert suppressed[0].all()
assert displayed[1].tolist() == [0.0, 0.0, 0.0], "noise outside the deadband?"
# A real excursion survives, centred on the estimated offset.
assert 3900.0 < displayed[2][0] < 4100.0, displayed[2]
assert not suppressed[2].any()
# A gap is a gap: an unavailable axis must never become a zero.
assert np.isnan(displayed[3]).all()
assert not suppressed[3].any()
def test_present_is_a_display_copy_and_never_touches_the_input() -> None:
presentation = ForcePresentation(enabled=True, min_samples=5, baseline_seconds=1.0)
for i in range(12):
presentation.observe((100.0 + i * 0.01, 0.0, 0.0), t_rel_s=i * 0.02)
source = np.array([[100.055, 0.0, 0.0]])
displayed, _suppressed = presentation.present(source, unit="raw")
assert source.tolist() == [[100.055, 0.0, 0.0]], "present mutated the measurement"
assert displayed[0][0] == 0.0
def test_the_operator_floor_moves_the_frozen_deadband() -> None:
presentation = ForcePresentation(enabled=True, min_samples=5, baseline_seconds=1.0)
for i in range(12):
presentation.observe((100.0 + i * 0.01, 0.0, 0.0), t_rel_s=i * 0.02, unit="raw")
assert presentation.ready
presentation.set_min_deadband("raw", 500.0)
assert presentation.accept((300.0, 0.0, 0.0), unit="raw").values[0] == 0.0, (
"200 raw counts above the device's own band but inside the operator's floor"
)
# Back to auto: the floor is a floor, so the estimated band returns.
presentation.set_min_deadband("raw", 0.0)
assert presentation.min_deadband_for("raw") == 0.0
assert presentation.accept((300.0, 0.0, 0.0), unit="raw").values[0] != 0.0
def test_format_force_stable_digits() -> None:
presentation = ForcePresentation(enabled=True)
assert presentation.format_force(0.0, unit="raw") == "0"
assert presentation.format_force(1262.4, unit="raw") == "1262"
assert presentation.format_force(1.2345, unit="N") == "1.234"
# ------------------------------------------------------- the reported defect
def _quiet_until_zeroed(
presentation: ForcePresentation,
bias: tuple[float, float, float],
*,
unit: str,
limit: int = 29,
) -> int:
"""Feed quiet samples until ``accept`` reads zero; return the sample count.
Returns ``limit + 1`` (i.e. "never within the limit") if it did not zero.
"""
for index in range(limit):
presentation.observe(bias, t_rel_s=index / 30.0, flow_p95=0.1, unit=unit)
if presentation.accept(bias, unit=unit).values == (0.0, 0.0, 0.0):
return index + 1
return limit + 1
def test_a_resting_raw_channel_reads_zero_from_the_first_samples() -> None:
"""FN 1118 / FX 34.16 / FY -186.1 must be zero long before 30 samples / 3 s.
This is the reported defect: the cards kept the device's raw bias for the
whole startup window, so a session that started at rest looked like it was
under load.
"""
presentation = ForcePresentation(enabled=True, min_samples=30, baseline_seconds=3.0)
zeroed_at = _quiet_until_zeroed(presentation, REPORTED_RAW_BIAS, unit="raw")
assert zeroed_at <= 3, f"the raw bias was still on the cards after {zeroed_at} samples"
assert not presentation.ready, "zeroing should not have needed the whole quiet window"
assert presentation.display_ready, "the display policy was not applied while collecting"
assert presentation.display_quality == "provisional"
# ...and no invented zero: the offset is the one the quiet samples measured.
offsets = presentation.last_estimate.offsets
assert offsets[0] == REPORTED_RAW_BIAS[0]
assert offsets[1] == REPORTED_RAW_BIAS[1]
assert offsets[2] == REPORTED_RAW_BIAS[2]
def test_a_resting_newton_channel_reads_zero_from_the_first_samples() -> None:
"""The same aid in newtons — the estimate is per display unit."""
presentation = ForcePresentation(
enabled=True, unit="N", min_samples=30, baseline_seconds=3.0
)
zeroed_at = _quiet_until_zeroed(presentation, REPORTED_NEWTON_BIAS, unit="N")
assert zeroed_at <= 3, f"the newton bias survived {zeroed_at} samples"
assert not presentation.ready
assert presentation.display_quality == "provisional"
def test_a_noisy_channel_is_zeroed_from_the_first_samples_too() -> None:
"""The provisional band covers the samples it was fitted from.
With a handful of samples the p99 residual is essentially the largest one, so
the newest sample of the window is inside the band: the first tenths of a
second are zeroed, and the *first* sample is not claimed to be zero because
nothing has been estimated from it yet.
"""
rng = np.random.default_rng(11)
bias = np.array(REPORTED_RAW_BIAS)
presentation = ForcePresentation(enabled=True, min_samples=30, baseline_seconds=3.0)
for index in range(8):
sample = bias + rng.normal(0.0, 30.0, size=3)
presentation.observe(tuple(sample), t_rel_s=index / 30.0, flow_p95=0.1)
if index < 2:
# Nothing has been estimated from two samples: those frames honestly
# show the measurement (and are asserted to do so elsewhere).
continue
displayed = presentation.accept(tuple(sample), unit="raw")
assert displayed.values == (0.0, 0.0, 0.0), (
f"sample {index} of a resting noisy channel was drawn"
)
def test_a_contact_present_at_session_start_is_never_zeroed() -> None:
"""A press at run start must stay visible: no estimate may be invented for it.
The contact guard decides what feeds the estimate, so a window that has only
ever seen a contact has nothing to subtract and displays the measurement.
"""
presentation = ForcePresentation(enabled=True, min_samples=30, baseline_seconds=3.0)
pressed = (6000.0, 40.0, -180.0)
for index in range(20):
assert (
presentation.observe(
pressed, t_rel_s=index / 30.0, contact_found=True, flow_p95=0.1
)
is None
)
displayed = presentation.accept(pressed, unit="raw")
assert displayed.values[0] == pressed[0], "a contact at startup was zeroed away"
assert not displayed.suppressed[0]
assert not presentation.display_ready
assert presentation.display_quality == "none"
def test_a_contaminated_window_never_falls_back_to_the_raw_bias() -> None:
"""A window that closes badly keeps the estimate it already worked out.
Falling back to the raw offset is the reported defect. Both ways a startup
window can be spoiled — a contact, or too much flow — must leave the display
zeroed and say so through ``display_quality``.
"""
bias = REPORTED_RAW_BIAS
pressed = (6000.0, 40.0, -180.0)
for spoiled_by in ("contact", "flow"):
presentation = ForcePresentation(
enabled=True, min_samples=30, baseline_seconds=3.0
)
for index in range(3):
presentation.observe(bias, t_rel_s=index / 30.0, flow_p95=0.1)
assert presentation.display_ready and not presentation.ready
for index in range(90): # the window is spoiled before it can close
presentation.observe(
pressed,
t_rel_s=0.5 + index / 30.0,
contact_found=(spoiled_by == "contact"),
flow_p95=0.9 if spoiled_by == "flow" else 0.1,
)
estimate = presentation.observe(bias, t_rel_s=4.0, flow_p95=0.1)
assert estimate is not None, spoiled_by
assert estimate.quality == "contaminated", (spoiled_by, estimate)
assert not presentation.ready, spoiled_by
assert presentation.display_quality == "contaminated", spoiled_by
assert presentation.accept(bias, unit="raw").values == (0.0, 0.0, 0.0), spoiled_by
def test_a_window_that_closed_short_still_applies_what_it_learned() -> None:
"""A session shorter than the quiet window is not a reason to show the bias."""
presentation = ForcePresentation(enabled=True, min_samples=30, baseline_seconds=0.5)
for index in range(3):
presentation.observe(REPORTED_RAW_BIAS, t_rel_s=index / 30.0, flow_p95=0.1)
estimate = presentation.observe(REPORTED_RAW_BIAS, t_rel_s=0.6, flow_p95=0.1)
assert estimate is not None and estimate.quality == "insufficient"
assert presentation.display_quality == "insufficient"
assert presentation.accept(REPORTED_RAW_BIAS, unit="raw").values == (0.0, 0.0, 0.0)
def test_a_frozen_window_stops_chasing_the_data() -> None:
"""The provisional phase is a start, not a licence to re-zero a press."""
presentation = ForcePresentation(
enabled=True, min_samples=5, baseline_seconds=3.0, min_deadband_raw=50.0
)
for index in range(10):
presentation.observe(REPORTED_RAW_BIAS, t_rel_s=index / 30.0, flow_p95=0.1)
assert presentation.ready
assert presentation.display_quality == "ok"
# A held press after the window is a reading, not a new zero.
assert presentation.accept((5000.0, 34.16, -186.1), unit="raw").values[0] > 3000.0
def test_the_cards_and_the_curves_are_the_same_presentation() -> None:
"""What ``accept`` puts on a card is the row ``present`` draws on its curve.
This is what the extra "the estimate is ready" gate in the value cards broke:
the card kept the raw measurement while the curve was already drawn from the
applied estimate, so the two disagreed about the same sample.
"""
bias = REPORTED_RAW_BIAS
#: The samples the acquisition would flag as a contact: they must not feed
#: the idle estimate, or the display would zero the press away.
presses = {
3: (bias[0] + 4000.0, bias[1] + 10.0, bias[2] - 10.0),
5: (bias[0] - 4000.0, bias[1], bias[2]),
}
stream = [
bias,
bias,
bias,
presses[3],
bias,
presses[5],
bias,
]
presentation = ForcePresentation(enabled=True, min_samples=30, baseline_seconds=3.0)
for index, sample in enumerate(stream):
presentation.observe(
sample,
t_rel_s=index / 30.0,
flow_p95=0.1,
contact_found=index in presses,
)
card = presentation.accept(sample, unit="raw")
curve, suppressed = presentation.present([sample], unit="raw")
assert card.values == tuple(float(value) for value in curve[0]), index
assert card.suppressed == tuple(bool(flag) for flag in suppressed[0]), index
# The assertion is not vacuous: the excursion really is on both.
assert presentation.accept(stream[3], unit="raw").values[0] > 3000.0
def test_present_uses_the_provisional_estimate_before_the_window_closes() -> None:
"""The curves get the same early zero as the cards, not only after 3 s."""
presentation = ForcePresentation(enabled=True, min_samples=30, baseline_seconds=3.0)
block = np.asarray([REPORTED_RAW_BIAS] * 4, dtype=float)
raw, _suppressed = presentation.present(block, unit="raw")
assert raw[0].tolist() == list(REPORTED_RAW_BIAS), "nothing was learned yet"
for index in range(3):
presentation.observe(REPORTED_RAW_BIAS, t_rel_s=index / 30.0, flow_p95=0.1)
displayed, suppressed = presentation.present(block, unit="raw")
assert displayed.tolist() == [[0.0, 0.0, 0.0]] * 4
assert suppressed.all()
assert not presentation.ready, "the whole quiet window was not needed"
+224 -2
View File
@@ -9,6 +9,7 @@ stage owes, and a synthetic one can only prove the code agrees with itself.
from __future__ import annotations
import json
import shutil
import struct
import zipfile
from pathlib import Path
@@ -42,8 +43,11 @@ from orisys_app_shared.scene3d.glb import (
from orisys_app_shared.scene3d.legacy import (
LegacyFingerImporter,
build_binding,
crease_aware_mesh,
estimate_meters_per_source_unit,
load_legacy_domain,
normals_disagree_with_faces,
split_at_crease_edges,
split_at_uv_seams,
uv_correspondence_residual,
validate_rigid_transform,
@@ -72,9 +76,11 @@ def _identity() -> np.ndarray:
def _import_run(**kwargs) -> SceneAssets:
export_dir = kwargs.pop("export_dir", RUN_DIR)
map_path = kwargs.pop("map_path", RUN_MAP)
return LegacyFingerImporter().convert(
RUN_DIR,
RUN_MAP,
export_dir,
map_path,
transform_sensor_from_source=kwargs.pop("transform", _identity()),
meters_per_source_unit=kwargs.pop("units", 1e-3),
**kwargs,
@@ -97,6 +103,79 @@ def test_the_real_export_imports_and_validates():
assert assets.source_vertex_of_render.shape == (1032,)
def _write_housing(path: Path) -> Path:
"""A tiny tetrahedron standing in for a mounting frame."""
vertices = np.array(
[[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [0.0, 10.0, 0.0], [0.0, 0.0, 10.0]],
dtype=np.float32,
)
faces = np.array([[0, 1, 2], [0, 1, 3], [0, 2, 3], [1, 2, 3]], dtype=np.int32)
np.savez(path, vertices=vertices, faces=faces)
return path
@requires_run
def test_a_housing_becomes_its_own_part_without_touching_the_geometry_ids(tmp_path: Path):
"""The frame is display-only: it must not enter the identities the device verifies."""
plain = _import_run()
housing_path = _write_housing(tmp_path / "housing.npz")
with_housing = _import_run(housing_path=housing_path)
assert [part.part_id for part in plain.parts] == ["sensing_surface"]
assert [(part.part_id, part.role) for part in with_housing.parts] == [
("sensing_surface", "sensing"),
("housing", "housing"),
]
assert with_housing.geometry_id == plain.geometry_id, (
"adding a housing changed the sensing geometry id, which the device verifies"
)
assert with_housing.source_geometry_id == plain.source_geometry_id
assert with_housing.asset_hash != plain.asset_hash, (
"the package content changed, so the asset hash has to say so"
)
assert validate_assets(with_housing) == ()
# Units and the rigid transform are applied to the housing exactly as to the surface.
frame = with_housing.part("housing")
assert frame is not None
assert np.allclose(np.ptp(np.asarray(frame.positions_sensor_m), axis=0), 0.01, atol=1e-6)
@requires_run
def test_a_housing_in_the_export_directory_is_used_automatically(tmp_path: Path, monkeypatch):
"""``<legacy-dir>/housing.npz`` is picked up, so a re-export needs no new flag."""
export = tmp_path / "export"
export.mkdir()
shutil.copy(RUN_DIR / "mesh.npz", export / "mesh.npz")
assert len(_import_run(export_dir=export).parts) == 1
_write_housing(export / "housing.npz")
assert [part.role for part in _import_run(export_dir=export).parts] == [
"sensing",
"housing",
]
@requires_run
def test_a_requested_housing_that_is_missing_is_refused(tmp_path: Path):
"""A missing frame must not look like a package that simply has none."""
with pytest.raises(ThreeDError) as caught:
_import_run(housing_path=tmp_path / "not-there.npz")
assert caught.value.diagnostic.code == "geometry_mismatch"
@requires_run
def test_a_housing_with_an_out_of_range_triangle_is_refused(tmp_path: Path):
path = tmp_path / "housing.npz"
np.savez(
path,
vertices=np.zeros((3, 3), dtype=np.float32),
faces=np.array([[0, 1, 9]], dtype=np.int32),
)
with pytest.raises(ThreeDError) as caught:
_import_run(housing_path=path)
assert caught.value.diagnostic.code == "geometry_mismatch"
@requires_run
def test_the_uv_convention_is_measured_against_the_asset():
"""The one fact the importer must not guess: which pixel a UV addresses."""
@@ -235,6 +314,149 @@ def test_vertex_normals_are_unit_and_area_weighted():
assert np.isfinite(lonely[4]).all()
def _box(half: float = 1.0) -> tuple[np.ndarray, np.ndarray]:
"""A closed cube: every corner is a 90° crease, every edge a smooth pair."""
positions = np.array(
[
[-half, -half, -half], [half, -half, -half], [half, half, -half],
[-half, half, -half], [-half, -half, half], [half, -half, half],
[half, half, half], [-half, half, half],
],
dtype=np.float64,
)
triangles = np.array(
[
[0, 2, 1], [0, 3, 2], # -z
[4, 5, 6], [4, 6, 7], # +z
[0, 1, 5], [0, 5, 4], # -y
[2, 3, 7], [2, 7, 6], # +y
[1, 2, 6], [1, 6, 5], # +x
[3, 0, 4], [3, 4, 7], # -x
],
dtype=np.int64,
)
return positions, triangles
def test_crease_aware_normals_agree_with_every_face_of_a_box():
"""The defect this fixes: an averaged corner normal agrees with *no* face."""
positions, triangles = _box()
averaged = vertex_normals(positions, triangles)
crease = crease_aware_mesh(positions, triangles)
split_positions, split_triangles, split_normals = crease
assert len(split_triangles) == len(triangles), "splitting must not change the faces"
assert len(split_positions) == 24, "each of the 8 corners needs 3 normals"
def worst(normals, faces, verts):
a, b, c = verts[faces[:, 0]], verts[faces[:, 1]], verts[faces[:, 2]]
face = np.cross(b - a, c - a)
unit = face / np.linalg.norm(face, axis=1, keepdims=True)
incident = [[] for _ in range(len(verts))]
for index, triangle in enumerate(faces):
for k in range(3):
incident[int(triangle[k])].append(index)
return max(
float(np.degrees(np.arccos(np.clip(unit[incident[v]] @ normals[v], -1, 1))).min())
for v in range(len(verts))
if incident[v]
)
assert worst(averaged, triangles, positions) > 30.0, (
"the fixture must actually exhibit the defect"
)
assert worst(split_normals, split_triangles, split_positions) < 1e-6, (
"a split corner normal must be exactly a face normal on a cube"
)
def test_crease_splitting_keeps_the_smooth_parts_welded():
"""A cylinder's curved side must survive whole; only its rim split."""
ring = 24
angles = np.linspace(0.0, 2.0 * np.pi, ring, endpoint=False)
positions = np.vstack(
[
np.column_stack([np.cos(angles), np.sin(angles), np.zeros(ring)]),
np.column_stack([np.cos(angles), np.sin(angles), np.ones(ring)]),
]
)
triangles = []
for i in range(ring):
j = (i + 1) % ring
triangles.append([i, j, ring + i])
triangles.append([j, ring + j, ring + i])
triangles = np.asarray(triangles, dtype=np.int64)
split_positions, split_triangles, _ = split_at_crease_edges(positions, triangles)
# The side is a smooth ring (3.7° per step), so nothing on it may be duplicated;
# the two rims are boundaries, so the split only separates the *caps'* edges.
assert len(split_triangles) == len(triangles)
assert len(split_positions) == len(positions), (
"a smoothly curved side must stay welded"
)
def test_normals_disagree_with_faces_detects_the_defect():
positions, triangles = _box()
assert normals_disagree_with_faces(
positions, triangles, vertex_normals(positions, triangles)
)
_, _, crease = crease_aware_mesh(positions, triangles)
split_positions, split_triangles, _ = split_at_crease_edges(positions, triangles)
assert not normals_disagree_with_faces(split_positions, split_triangles, crease)
# A flat sheet has no crease at all.
flat = np.array([[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]])
assert not normals_disagree_with_faces(
flat, np.array([[0, 1, 2]]), np.array([[0.0, 0.0, 1.0]] * 3)
)
@requires_run
def test_the_shipped_housing_has_no_stale_shading():
"""The real housing: 134 of 2172 edges are creases, so plain averaging bands."""
import io
import zipfile
package = (
Path(__file__).resolve().parents[2]
/ "apps" / "studio" / "config" / "sensors" / "finger" / "scene"
/ "finger-display-1.0.0.oryscene"
)
if not package.is_file():
pytest.skip("the compiled finger package is not present")
with zipfile.ZipFile(package) as archive:
positions = np.load(io.BytesIO(archive.read("housing/positions.npy"))).astype(np.float64)
triangles = np.load(io.BytesIO(archive.read("housing/triangles.npy"))).astype(np.int64)
normals = np.load(io.BytesIO(archive.read("housing/normals.npy"))).astype(np.float64)
# Every stored normal agrees with an incident face, which is only possible
# because the source mesh's 726 vertices became 861 (one per side of a crease).
assert not normals_disagree_with_faces(positions, triangles, normals), (
"the shipped housing still carries normals averaged across its 90° corners"
)
# ...and the split is not a blanket unweld: the smooth vertices stay shared.
unique_positions = len(np.unique(positions, axis=0))
assert unique_positions == 726, (
f"{unique_positions} unique positions: the smooth surface was unwelded too"
)
assert len(positions) == 861, (
f"{len(positions)} vertices: the creases were not split into their own normals"
)
# The source mesh this was compiled from is the shape that used to be shipped,
# and plain averaging on it is exactly the defect.
source = Path(
r"D:\orisys_ws\orisys-sensor-design\build\export\finger\r01\compute\housing.npz"
)
if source.is_file():
with np.load(source, allow_pickle=False) as handle:
source_positions = np.asarray(handle["vertices"], dtype=np.float64)
source_triangles = np.asarray(handle["faces"], dtype=np.int64)
assert len(source_positions) == 726, len(source_positions)
assert normals_disagree_with_faces(
source_positions,
source_triangles,
vertex_normals(source_positions, source_triangles),
), "the detector does not fire on the geometry that actually banded"
# ------------------------------------------------------------------- binding
def _domain(shape_hw=(4, 5), flip_v=False, world=None):
valid = np.ones(shape_hw, dtype=bool)
+277 -3
View File
@@ -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):
+17 -5
View File
@@ -527,18 +527,30 @@ def _tool(name: str):
def _shipped_asset_dirs() -> tuple[Path, Path] | None:
"""The Blender export and its unroll map, when the repository carries them."""
base = (
"""The Blender export and its unroll map, when the repository carries them.
The map's name follows ``camera.source_scale`` (the SDK's ``scaled_map_cache_path``), so it is
derived from the bundle's own ``sensor.json`` rather than pinned: a scale change renames the
file, and a hard-coded name turns this acceptance test into a silent skip.
"""
bundle = (
Path(__file__).resolve().parents[2]
/ "apps"
/ "studio"
/ "config"
/ "sensors"
/ "finger"
/ "assets"
/ "30fps-0703"
)
mapping = base / "unroll_map_uv_s0.4.npz"
base = bundle / "assets" / "30fps-0703"
config_path = bundle / "sensor.json"
if not config_path.is_file():
return None
from orisys_app_shared.acquisition.geometry import map_path_for
scale = (json.loads(config_path.read_text(encoding="utf-8")).get("camera") or {}).get(
"source_scale"
)
mapping = map_path_for(base, scale)
if (base / "mesh.npz").is_file() and mapping.is_file():
return base, mapping
return None
+80
View File
@@ -410,6 +410,86 @@ def test_a_file_that_yields_no_frames_ends_the_session_with_an_error(monkeypatch
_restore(saved)
def test_a_paused_worker_stops_producing_but_keeps_the_device_open():
"""Pause is not stop: compute stops, the capture keeps running.
It lives in this file because the fake device that can answer the question (does the
source keep publishing while the loop takes no frame?) is the harness above.
"""
from orisys_app_shared.acquisition.sdk_worker import SdkAcquisitionWorker
CREATED.clear()
saved = _install()
worker = SdkAcquisitionWorker()
packets: list = []
lock = threading.Lock()
def sink(packet) -> None:
with lock:
packets.append(packet)
try:
worker.add_sink(sink)
worker.start(0, config_ref=BOX_CONFIG)
time.sleep(0.4)
before = len(packets)
assert before > 0, "the fake device produced nothing to pause"
assert not worker.paused
worker.pause()
assert worker.paused
time.sleep(0.4)
during = len(packets)
# The frames the source published across the pause: the device is demonstrably
# alive, which is what makes this different from ``stop``.
source_seq_at_pause = CREATED[-1].capture_source.status.last_seq
assert during == before, (
f"a paused worker emitted {during - before} packet(s): the computation was "
"supposed to stop"
)
worker.resume()
assert not worker.paused
time.sleep(0.4)
after = len(packets)
assert after > during, "the worker did not resume producing packets"
assert CREATED[-1].capture_source.status.last_seq > source_seq_at_pause, (
"the capture source stopped publishing while paused — the device was released "
"or the reader was starved, and neither is what pause means"
)
# Resuming continues from the newest frame rather than replaying the pause: the
# packet that follows the pause is dated after it, not before.
assert float(packets[after - 1].mono_t) > 0.0
stop_requests = getattr(worker, "_stop").is_set()
assert not stop_requests, "pausing must not set the stop flag"
worker.stop(wait=True)
finally:
_restore(saved)
def test_a_new_session_starts_unpaused():
"""Pausing is a statement about one session, not a sticky property of the device."""
from orisys_app_shared.acquisition.sdk_worker import SdkAcquisitionWorker
CREATED.clear()
saved = _install()
worker = SdkAcquisitionWorker()
try:
worker.add_sink(lambda _packet: None)
worker.start(0, config_ref=BOX_CONFIG)
time.sleep(0.3)
worker.pause()
assert worker.paused
worker.stop(wait=True)
assert not worker.paused, "stop cleared the pause flag"
worker.start(0, config_ref=BOX_CONFIG)
assert not worker.paused, "the next session started paused"
worker.stop(wait=True)
finally:
_restore(saved)
if __name__ == "__main__": # pragma: no cover - manual runner
failures = 0
for name, function in sorted(globals().items()):