阶段 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)。
This commit is contained in:
@@ -11,6 +11,7 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
@@ -397,18 +398,51 @@ def test_the_contract_layer_imports_without_qt_or_opengl():
|
||||
)
|
||||
|
||||
|
||||
def test_the_cpu_skeleton_is_importable_and_says_which_stage_it_needs():
|
||||
from orisys_app_shared.scene3d import assets, composer, sampling
|
||||
def test_the_cpu_layer_is_importable_and_works_without_a_gpu():
|
||||
"""The acquisition thread imports this package; it must not drag Qt or GL in.
|
||||
|
||||
with pytest.raises(NotImplementedError) as sampler:
|
||||
sampling.SurfaceSampler().sample(_frame(), _binding())
|
||||
assert "stage 3" in str(sampler.value)
|
||||
with pytest.raises(NotImplementedError) as composer_error:
|
||||
composer.SceneComposer().rest_frame(_assets(), c.FrameIdentity("live-A", 1, 0, 0.0, 1))
|
||||
assert "stage 3" in str(composer_error.value)
|
||||
with pytest.raises(NotImplementedError) as loader:
|
||||
assets.PackagedAssetLoader().load(None, None) # type: ignore[arg-type]
|
||||
assert "stage 2" in str(loader.value)
|
||||
Stages 2 and 3 filled in the CPU pieces, so this checks they are reachable and
|
||||
usable here — the sampler and the composer run on a machine with no GPU, which
|
||||
is the whole reason the contracts layer is separate from the view.
|
||||
"""
|
||||
from orisys_app_shared.scene3d import assets, composer, sampling, synthetic
|
||||
|
||||
scene = synthetic.poc_scene()
|
||||
frame = _frame()
|
||||
fields = sampling.sample_field(frame, _binding())
|
||||
assert np.asarray(fields.scalar).shape == (4,)
|
||||
composed = composer.SceneComposer().compose(
|
||||
scene, c.SurfaceFieldFrame(
|
||||
identity=frame.identity,
|
||||
geometry_id=scene.geometry_id,
|
||||
field_domain_id=scene.field_domain_id,
|
||||
scalar=c.freeze(np.zeros(scene.shape_hw, dtype=np.float32), dtype=np.float32),
|
||||
scalar_descriptor=c.FieldDescriptor("flow_magnitude", "px", "approximate", False),
|
||||
valid=c.freeze(np.ones(scene.shape_hw, dtype=bool), dtype=np.bool_),
|
||||
),
|
||||
c.DisplaySettings(),
|
||||
)
|
||||
assert composed.scalar.shape == (9,)
|
||||
# The loading facade is real too (stage 2), and the compiler refuses what it
|
||||
# cannot yet do rather than writing something unprotected.
|
||||
assert callable(assets.PackagedAssetLoader().load)
|
||||
with pytest.raises(c.ThreeDError) as excinfo:
|
||||
assets.SceneCompiler().compile(
|
||||
scene,
|
||||
None,
|
||||
Path("unused.oryscene"),
|
||||
c.CompileOptions(
|
||||
asset_id="x",
|
||||
version="0",
|
||||
expected=synthetic.poc_expected(),
|
||||
transform_sensor_from_glb=np.eye(4),
|
||||
max_surface_vertices=0,
|
||||
max_housing_triangles=1000,
|
||||
signing_key_reference="build-key",
|
||||
encryption_key_id="customer-1",
|
||||
),
|
||||
)
|
||||
assert "stage 5" in excinfo.value.diagnostic.message
|
||||
|
||||
|
||||
def test_summarize_parts_counts_vertices_per_part():
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user