Files
orisys-app/shared/tests/test_scene3d_live.py
T
ykcheungab ea1027e5a6 阶段 3:实时数据 —— 采样、合成、最新帧调度与适配器 - What: sampling.py 实现 SurfaceSampler(向量化四点双线性:无效 tap 不计入也不致无效、任一**正权** tap 无效即整顶点无效且值为 NaN、域/形状不符先拒;采样表只在加载时构建)与 remap_to_domain(按 unroll 映射 gather,空洞保持 NaN 且 valid=False,保留尾随轴以支持矢量场)。composer.py 实现 SceneComposer.compose(域校验 → 采样 → 位移 → **从形变后位置重算法线**;scalar 绝不成以 display_gain;approximate 位移产生 display_illustrative(info) 诊断、calibrated 不产生、无位移则着色并报 field_unavailable)与 build_rest_frame(参考帧:scalar 全 NaN、valid 全假、descriptor=unavailable/display_only),gl_backend.rest_surface 改为委托它,避免两处定义。coordinator.py 实现完整调度:容量 1 双 mailbox、generation/来源/会话三重过期拒绝并计数、**最多一次合成在飞行**、可见性挂起/恢复、设置变更(倍率变化重合成**屏幕上那一帧**)、close 幂等;submit 只做身份与形状的廉价校验,绝不等显示。adapters.py 实现 Finger/Box 适配器:|flow| 或 depth 响应、可选经 unroll 映射 remap、设备 valid_mask 优先、域不符时**发布空**并报出所见的两个形状;一律不造位移(未标定的毫米不如没有),并新增 display_illustrative 到诊断码表(info)。assets.py 从"桩类堆"改成门面:re-export 真正实现 + SceneCompiler.compile(外壳 GLB 与预算检查;加密请求明确拒绝并指向阶段 5)。测试:shared/tests/test_scene3d_live.py 25 项(采样/无效语义/倍率只影响位置/示意形变标注/法线重算/remap 空洞/参考帧/只读数组)、apps/studio/tests/test_scene3d_coordinator.py 13 项(状态机、过期、替换计数、单飞行、submit p99<1ms、切换场景丢弃在飞结果、隐藏挂起、倍率重合成、close 幂等)、test_scene3d_adapters.py 13 项;并把三处"桩仍未实现"的过时断言改成对已实现行为的断言。docs/architecture/studio-3d-stages.md 阶段 3 记为完成。 - Why: 阶段 3 决定"屏幕上的颜色和力值到底对应什么"。三条最容易错且肉眼看不出来的规则必须由代码而不是约定来保证:无效点不是 0(NaN 与 valid 分离,否则缺口会被画成一个测量值)、显示倍率只动位置不动测量(否则色标悄悄变了含义)、以及显示永远不能给采集施加背压(容量 1 mailbox + 单飞行合成 + 廉价 submit)。 - Verification: shared 全量通过(scene3d 相关 103 项:contracts 26、assets 33、package 19、live 25);studio 全量通过(scene3d 相关 54 项:stage1 24、skeleton 4、coordinator 13、adapters 13;GL 冒烟 10 项在真实 context 下另行通过);ui_check 554 项 0 失败。过程中修掉 3 个真实缺陷:倍率变化时用 incoming.peek() 取不到"屏幕上那一帧"(已被消费)导致改了倍率画面不动——改为保留 _last_field_frame;display_illustrative 不在码表里被默认成 error;assets.py 里与 package/legacy 同名的重复桩类(会误导集成者,已改为门面)。另修正两处我自己的错误断言(对称凸起顶点法线本就竖直;pytest.approx 不能直接 .all())。 - Not in this change: 手势与面板(阶段 4);示意位移的真实来源(需要 SDK lift + 已确认尺度,现阶段 adapter 一��� displacement=None);app_window 接线与记录隔离(阶段 4)。
2026-09-14 15:21:39 +08:00

406 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Stage 3 acceptance: sampling, composition, and the frame schedule.
The three rows that matter most are the ones that are easy to get subtly wrong and
impossible to notice on screen: an invalid tap must invalidate the *vertex* rather
than read as zero, the display gain must not touch the measurement, and a producer
must never be able to make the display queue grow.
"""
from __future__ import annotations
import threading
import time
from concurrent.futures import ThreadPoolExecutor
import numpy as np
import pytest
from orisys_app_shared.scene3d import synthetic
from orisys_app_shared.scene3d.composer import DISPLAY_ILLUSTRATIVE, SceneComposer
from orisys_app_shared.scene3d.contracts import (
ColorScale,
DisplaySettings,
FieldDescriptor,
FrameIdentity,
Quality,
RenderFrame,
SurfaceFieldFrame,
ThreeDError,
freeze,
rest_descriptor,
)
from orisys_app_shared.scene3d.sampling import (
SurfaceSampler,
remap_to_domain,
sample_field,
)
# --------------------------------------------------------------------- helpers
def _frame(scene, scalar, valid=None, *, displacement=None, quality=None,
identity=None, domain=None, shape=None):
scalar = np.asarray(scalar, dtype=np.float32)
if valid is None:
valid = np.ones(scalar.shape, dtype=bool)
frame = SurfaceFieldFrame(
identity=identity or FrameIdentity("live", 1, 1, 0.1, generation=1),
geometry_id=scene.geometry_id,
field_domain_id=domain or scene.field_domain_id,
scalar=freeze(scalar, dtype=np.float32),
scalar_descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False),
valid=freeze(np.asarray(valid, dtype=bool), dtype=np.bool_),
displacement_sensor_m=(
freeze(np.asarray(displacement, dtype=np.float32), dtype=np.float32)
if displacement is not None
else None
),
displacement_quality=quality,
)
if shape is not None:
object.__setattr__(frame, "scalar", freeze(np.asarray(shape, dtype=np.float32), dtype=np.float32))
return frame
def _binding(shape=(3, 3), indices=None, weights=None):
"""The identity binding of the PoC scene, optionally overridden."""
scene = synthetic.poc_scene()
binding = scene.binding
if indices is None and weights is None and shape == (3, 3):
return binding
from orisys_app_shared.scene3d.contracts import SurfaceBinding
count = int(indices.shape[0]) if indices is not None else shape[0] * shape[1]
return SurfaceBinding(
part_id="sensing_surface",
geometry_id=scene.geometry_id,
field_domain_id=scene.field_domain_id,
shape_hw=shape,
sample_indices=freeze(np.asarray(indices, dtype=np.int32), dtype=np.int32),
sample_weights=freeze(np.asarray(weights, dtype=np.float32), dtype=np.float32),
)
# --------------------------------------------------------------------- sampling
def test_a_constant_field_samples_to_the_same_constant():
scene = synthetic.poc_scene()
fields = sample_field(_frame(scene, np.full((3, 3), 7.5)), scene.binding)
assert np.allclose(np.asarray(fields.scalar), 7.5)
assert np.asarray(fields.valid).all()
def test_a_ramp_field_samples_exactly_like_the_reference():
"""Every vertex samples its own cell, so the sampled value is the cell value."""
scene = synthetic.poc_scene()
ramp = np.arange(9, dtype=np.float32).reshape(3, 3)
fields = sample_field(_frame(scene, ramp), scene.binding)
assert np.array_equal(np.asarray(fields.scalar), ramp.reshape(-1))
def test_a_single_sample_spike_reaches_exactly_one_vertex():
scene = synthetic.poc_scene()
field = np.zeros((3, 3), dtype=np.float32)
field[1, 1] = 5.0
fields = sample_field(_frame(scene, field), scene.binding)
scalar = np.asarray(fields.scalar)
assert int((scalar > 0).sum()) == 1
assert scalar[4] == 5.0
def test_bilinear_sampling_averages_the_four_taps():
"""A vertex between pixels gets the weighted average, computed by hand."""
scene = synthetic.poc_scene()
indices = np.array([[0, 1, 3, 4]], dtype=np.int32)
weights = np.array([[0.25, 0.25, 0.25, 0.25]], dtype=np.float32)
binding = _binding(indices=indices, weights=weights)
field = np.array([[1.0, 2.0, 0.0], [3.0, 4.0, 0.0], [0.0, 0.0, 0.0]], dtype=np.float32)
fields = sample_field(_frame(scene, field), binding)
assert np.asarray(fields.scalar)[0] == pytest.approx((1 + 2 + 3 + 4) / 4)
assert bool(np.asarray(fields.valid)[0])
def test_one_invalid_tap_invalidates_the_vertex_and_is_not_read_as_zero():
"""A gap is not a measurement of zero: the value must be absent."""
scene = synthetic.poc_scene()
indices = np.array([[0, 1, 3, 4]], dtype=np.int32)
weights = np.array([[0.25, 0.25, 0.25, 0.25]], dtype=np.float32)
binding = _binding(indices=indices, weights=weights)
field = np.array([[1.0, 2.0, 0.0], [3.0, 4.0, 0.0], [0.0, 0.0, 0.0]], dtype=np.float32)
valid = np.ones((3, 3), dtype=bool)
valid[1, 1] = False # index 4 is a positive-weight tap
fields = sample_field(_frame(scene, field, valid), binding)
assert not bool(np.asarray(fields.valid)[0])
assert np.isnan(np.asarray(fields.scalar)[0]), (
"an invalid vertex must carry no value at all, not a sum with a zero in it"
)
def test_an_unused_tap_is_neither_a_contribution_nor_a_reason_to_invalidate():
scene = synthetic.poc_scene()
indices = np.array([[4, -1, -1, -1]], dtype=np.int32)
weights = np.array([[1.0, 0.0, 0.0, 0.0]], dtype=np.float32)
binding = _binding(indices=indices, weights=weights)
field = np.zeros((3, 3), dtype=np.float32)
field[1, 1] = 3.0
valid = np.ones((3, 3), dtype=bool)
valid[0, 0] = False # unused, so it must not matter
fields = sample_field(_frame(scene, field, valid), binding)
assert bool(np.asarray(fields.valid)[0])
assert np.asarray(fields.scalar)[0] == pytest.approx(3.0)
def test_a_non_finite_scalar_invalidates_rather_than_colouring_nan():
scene = synthetic.poc_scene()
field = np.full((3, 3), 1.0, dtype=np.float32)
field[2, 2] = np.nan
fields = sample_field(_frame(scene, field), scene.binding)
assert not bool(np.asarray(fields.valid)[8])
assert np.isnan(np.asarray(fields.scalar)[8])
def test_a_zero_measurement_is_valid_and_stays_zero():
"""Zero is a value. Only `valid` decides whether it means anything."""
scene = synthetic.poc_scene()
fields = sample_field(_frame(scene, np.zeros((3, 3), dtype=np.float32)), scene.binding)
assert np.asarray(fields.valid).all()
assert np.all(np.asarray(fields.scalar) == 0.0)
assert not np.isnan(np.asarray(fields.scalar)).any()
def test_the_sampler_refuses_a_frame_from_another_domain():
scene = synthetic.poc_scene()
frame = _frame(scene, np.zeros((3, 3), dtype=np.float32), domain="some-other-domain")
with pytest.raises(ThreeDError) as excinfo:
sample_field(frame, scene.binding)
assert excinfo.value.diagnostic.code == "field_domain_mismatch"
def test_the_sampler_refuses_a_frame_of_the_wrong_shape():
scene = synthetic.poc_scene()
frame = _frame(scene, np.zeros((4, 4), dtype=np.float32), shape=np.zeros((4, 4), dtype=np.float32))
with pytest.raises(ThreeDError) as excinfo:
sample_field(frame, scene.binding)
assert "shape does not match" in excinfo.value.diagnostic.message
def test_sampling_is_vectorised_enough_for_a_large_mesh():
"""A full-resolution binding is ~10^5 vertices; sampling must not crawl."""
scene = synthetic.poc_scene()
count = 20_000
rng = np.random.default_rng(7)
indices = rng.integers(0, 9, size=(count, 4)).astype(np.int32)
weights = rng.random((count, 4)).astype(np.float32)
weights /= weights.sum(axis=1, keepdims=True)
binding = _binding(indices=indices, weights=weights)
frame = _frame(scene, rng.random((3, 3)).astype(np.float32))
started = time.perf_counter()
fields = sample_field(frame, binding)
elapsed = (time.perf_counter() - started) * 1000.0
assert np.asarray(fields.scalar).shape == (count,)
assert elapsed < 150.0, f"sampling {count} vertices took {elapsed:.1f} ms"
# ----------------------------------------------------------------------- remap
def test_a_remap_gathers_the_source_pixels_the_map_names():
field = np.arange(12, dtype=np.float32).reshape(3, 4)
map_x = np.array([[0, 1], [2, 3]])
map_y = np.array([[0, 0], [2, 2]])
values, valid = remap_to_domain(field, map_x=map_x, map_y=map_y, shape_hw=(2, 2))
assert np.array_equal(np.asarray(values), [[0.0, 1.0], [10.0, 11.0]])
assert np.asarray(valid).all()
def test_a_remap_that_has_no_source_pixel_leaves_a_hole_not_a_zero():
field = np.full((3, 3), 5.0, dtype=np.float32)
map_x = np.array([[0, -1], [1, 1]])
map_y = np.array([[0, -1], [1, 1]])
values, valid = remap_to_domain(field, map_x=map_x, map_y=map_y, shape_hw=(2, 2))
assert np.asarray(valid).tolist() == [[True, False], [True, True]]
assert np.isnan(np.asarray(values)[0, 1]), "a hole must not read as zero"
assert np.asarray(values)[0, 0] == 5.0
def test_a_remap_keeps_a_trailing_axis_for_vector_fields():
flow = np.zeros((3, 3, 2), dtype=np.float32)
flow[..., 0] = 1.0
flow[..., 1] = 2.0
map_x = np.array([[0, 1]])
map_y = np.array([[1, 2]])
values, valid = remap_to_domain(flow, map_x=map_x, map_y=map_y, shape_hw=(1, 2))
assert np.asarray(values).shape == (1, 2, 2)
assert np.asarray(values)[0, 0].tolist() == [1.0, 2.0]
assert np.asarray(valid).all()
def test_a_remap_of_the_wrong_shape_is_refused():
field = np.zeros((3, 3), dtype=np.float32)
with pytest.raises(ThreeDError) as excinfo:
remap_to_domain(
field, map_x=np.zeros((2, 2)), map_y=np.zeros((2, 2)), shape_hw=(3, 3)
)
assert excinfo.value.diagnostic.code == "field_domain_mismatch"
def test_a_remap_leaves_the_result_read_only():
field = np.zeros((2, 2), dtype=np.float32)
values, valid = remap_to_domain(
field, map_x=np.zeros((2, 2)), map_y=np.zeros((2, 2)), shape_hw=(2, 2)
)
assert not np.asarray(values).flags["WRITEABLE"]
assert not np.asarray(valid).flags["WRITEABLE"]
# ------------------------------------------------------------------- composition
def test_the_gain_moves_positions_and_never_the_measurement():
"""The row the whole colour scale depends on."""
scene = synthetic.poc_scene()
count = 9
field = np.linspace(0.0, 1.0, count, dtype=np.float32).reshape(3, 3)
displacement = np.zeros((3, 3, 3), dtype=np.float32)
displacement[:, :, 2] = 0.001 # a millimetre of illustrative relief
frame = _frame(scene, field, displacement=displacement, quality="approximate")
composer = SceneComposer()
quiet = composer.compose(scene, frame, DisplaySettings(deform_enabled=False))
small = composer.compose(
scene, frame, DisplaySettings(deform_enabled=True, display_gain=1.0)
)
large = composer.compose(
scene, frame, DisplaySettings(deform_enabled=True, display_gain=50.0)
)
# The measurement is identical in all three.
assert np.array_equal(np.asarray(quiet.scalar), np.asarray(small.scalar))
assert np.array_equal(np.asarray(small.scalar), np.asarray(large.scalar))
assert np.array_equal(np.asarray(quiet.valid), np.asarray(large.valid))
# The positions are not.
rest = np.asarray(small.positions_sensor_m)
moved = np.asarray(large.positions_sensor_m)
rest_scene = np.asarray(scene.sensing_part().positions_sensor_m)
assert np.allclose((rest - rest_scene)[:, :2], 0.0, atol=1e-7), "the gain moved sideways"
assert np.allclose((rest - rest_scene)[:, 2], 0.001, atol=1e-7)
assert np.allclose((moved - rest_scene)[:, 2], 0.05, atol=1e-7)
def test_an_illustrative_deformation_says_so():
scene = synthetic.poc_scene()
displacement = np.zeros((3, 3, 3), dtype=np.float32)
displacement[:, :, 2] = 0.001
frame = _frame(scene, np.zeros((3, 3), dtype=np.float32), displacement=displacement,
quality="approximate")
composed = SceneComposer().compose(
scene, frame, DisplaySettings(deform_enabled=True, display_gain=3.0)
)
codes = [item.code for item in composed.diagnostics]
assert DISPLAY_ILLUSTRATIVE in codes, codes
note = next(item for item in composed.diagnostics if item.code == DISPLAY_ILLUSTRATIVE)
assert note.severity == "info"
assert "illustration" in note.message and "×3" in note.message
def test_a_calibrated_displacement_does_not_need_the_caveat():
scene = synthetic.poc_scene()
displacement = np.zeros((3, 3, 3), dtype=np.float32)
displacement[:, :, 2] = 0.0005
frame = _frame(scene, np.zeros((3, 3), dtype=np.float32), displacement=displacement,
quality="calibrated")
composed = SceneComposer().compose(
scene, frame, DisplaySettings(deform_enabled=True, display_gain=2.0)
)
assert DISPLAY_ILLUSTRATIVE not in [item.code for item in composed.diagnostics]
def test_deformation_without_a_displacement_colours_in_place_and_says_so():
scene = synthetic.poc_scene()
frame = _frame(scene, np.full((3, 3), 2.0, dtype=np.float32))
composed = SceneComposer().compose(
scene, frame, DisplaySettings(deform_enabled=True, display_gain=10.0)
)
assert np.allclose(
np.asarray(composed.positions_sensor_m),
np.asarray(scene.sensing_part().positions_sensor_m),
)
assert any(item.code == "field_unavailable" for item in composed.diagnostics)
def test_normals_are_recomputed_from_the_deformed_positions():
"""A bump must change the shading on its slopes."""
scene = synthetic.poc_scene()
displacement = np.zeros((3, 3, 3), dtype=np.float32)
# Only the centre vertex is lifted: the slopes around it have to tilt. (The
# apex itself stays vertical, which is the correct normal for a symmetric
# peak — asserting otherwise would be asserting a bug.)
displacement[1, 1, 2] = 0.004
frame = _frame(scene, np.zeros((3, 3), dtype=np.float32), displacement=displacement,
quality="approximate")
composed = SceneComposer().compose(
scene, frame, DisplaySettings(deform_enabled=True, display_gain=1.0)
)
normals = np.asarray(composed.normals_sensor)
assert np.allclose(np.linalg.norm(normals, axis=1), 1.0, atol=1e-5)
rest_normals = np.asarray(scene.sensing_part().normals_sensor)
assert not np.allclose(normals, rest_normals), "the normals ignored the deformation"
# Only the vertices whose triangles touch the lifted one change; the two
# diagonal corners (2 and 6) share no triangle with it and correctly stay flat.
neighbours = [0, 1, 3, 5, 7, 8]
untouched = [2, 6]
tilted = np.array([float(normals[index, 2]) for index in neighbours])
assert (tilted < 0.999).all(), f"the slopes did not tilt: {tilted}"
flat = np.array([float(normals[index, 2]) for index in untouched])
assert np.allclose(flat, 1.0, atol=1e-6), flat
assert normals[4, 2] == pytest.approx(1.0, abs=1e-3), (
"the apex of a symmetric bump is vertical"
)
# ...and the reverse bump tilts the other way.
upside_down = -displacement
flipped = SceneComposer().compose(
scene,
_frame(scene, np.zeros((3, 3), dtype=np.float32), displacement=upside_down,
quality="approximate"),
DisplaySettings(deform_enabled=True, display_gain=1.0),
)
flipped_normals = np.asarray(flipped.normals_sensor)
assert np.allclose(
flipped_normals[neighbours, 0], -normals[neighbours, 0], atol=1e-6
), "the reverse bump must tilt the slopes the other way"
def test_the_composer_refuses_a_frame_from_another_geometry():
scene = synthetic.poc_scene()
frame = _frame(scene, np.zeros((3, 3), dtype=np.float32))
object.__setattr__(frame, "geometry_id", "someone-elses")
with pytest.raises(ThreeDError) as excinfo:
SceneComposer().compose(scene, frame, DisplaySettings())
assert excinfo.value.diagnostic.code == "geometry_mismatch"
def test_a_reference_frame_measures_nothing():
scene = synthetic.poc_scene()
rest = SceneComposer().rest_frame(scene, FrameIdentity("live", 1, 0, 0.0, 1))
assert not np.asarray(rest.valid).any()
assert np.isnan(np.asarray(rest.scalar)).all()
assert rest.scalar_descriptor.quantity == "unavailable"
assert rest.scalar_descriptor.quality == "display_only"
assert np.allclose(
np.asarray(rest.positions_sensor_m),
np.asarray(scene.sensing_part().positions_sensor_m),
)
def test_the_composed_frame_carries_read_only_arrays():
scene = synthetic.poc_scene()
composed = SceneComposer().compose(
scene, _frame(scene, np.zeros((3, 3), dtype=np.float32)), DisplaySettings()
)
for name in ("positions_sensor_m", "normals_sensor", "scalar", "valid"):
value = getattr(composed, name)
assert not value.flags["WRITEABLE"], f"{name} is writable"
assert value.flags["C_CONTIGUOUS"]
def test_the_composer_does_not_rebuild_the_binding_or_the_indices():
"""Composition must be cheap enough to run per frame."""
scene = synthetic.poc_scene()
composer = SceneComposer()
frame = _frame(scene, np.zeros((3, 3), dtype=np.float32))
for _ in range(3):
composer.compose(scene, frame, DisplaySettings())
assert composer.last_stats.total_ms < 50.0, composer.last_stats