ef487098c9
- What:
1. **显示限速真的生效**(R5):`ThreeDCoordinator.tick()` 用注入的单调时钟与
`display_period_s() = 1/max_fps` 限制**合成启动**频率(窗口定时器仍是 16 ms,但"显示帧率"是给操作者的
承诺,不是定时器的描述);声明边界(activate/begin_session/show_rest/隐藏)把预算清零,所以按下开始后的
第一帧立刻合成。生产者侧改为**按需**跳过:`Scene3DView.submit_packet()` 在
`coordinator.has_pending_frame()`(mailbox 里已有一帧等待)时不再构造快照——那是 O(H×W) 的拷贝,
而 mailbox 只保留最新一帧,两次拷贝里必然丢一个;被跳过的数量计入新的 `skipped_producer`,
与"显示跳帧"分开。**不丢任何本该显示的帧**(等待中的那一帧仍在)。
2. **真实统计**:`RenderFrame.compose_ms`(composer 在构造帧之前测量并写入,不再读会被下一任务改写的
`last_stats`);`GLRenderer` 的绘制计数改名为 `paint_calls`,`RenderStats.displayed_frames` 由
**widget 按帧身份**统计(相机旋转/色标改动只是重画,不再是"多显示了一帧");`render_totals()` 增
`compose_ms`/`latency_ms`(合成开始→被接受的单调耗时)/`paint_calls`/`skipped_producer`,并且
`shown`/`compose_ms` 改从 widget 读(无 GL 的机器也能报告,之前只有画过才有数)。
3. **无位移时复用资产几何**(R5 建议):`SceneComposer` 缓存 `asset_hash → (positions, normals)`;
变形时照旧从形变后的位置重算法线,不变形时直接用资产自己的(只读、零拷贝)数组。20k 顶点实测:
compose p50 **6.15 ms → 1.03 ms**(法线段 4.9 ms → 0.0001 ms,剩余是采样 1.01 ms);
真实发布包(1032 顶点)compose p50 **0.50 ms → 0.17 ms**。
4. **保护包密钥注入**(R4):新增 `scene3d/keys.py`——从 preference 读取
`scene3d_verify_key`(验签公钥 PEM)/`scene3d_content_key_file` + `scene3d_content_key_id`
(内容密钥,经 `protect.load_key_file`)/`scene3d_require_signature`(发布模式,拒绝无签名包),
组装成 `SceneKeys` 交给 `SceneAssetLoader` → `load_package` → `PackagedAssetLoader.load(keys=…,
verify_key_pem=…)`;`AppWindow._sync_3d_source()` 解析它并 `Mesh3dPanel.set_keys()`。
缺失/不可读的密钥是**状态而不是崩溃**(`describe()` 里带原因),密钥材料永不进日志;
`Mesh3dPanel.describe()` 的 scene 块记录 `keys`(布尔 + 路径)。
- Why: 审查报告 R5(`max_fps` 只被存储与显示、`draw()` 硬编码 `compose_ms=0.0`、`_displayed_frames` 把
重画也算新帧、`SceneComposer.last_stats` 被显示链路完全未读)与 R4(底层支持签名/加密包,Studio 加载
入口却从不传验签公钥与 KeyProvider,合法保护包打不开,只能发明文包或关掉校验)。R5 的另一半是
"当前只有曲面着色、没有 displacement 时,composer 仍每帧重算法线并重新打包位置"——缓存后 20k 顶点
降低到六分之一。
- Verification:
· `test_scene3d_coordinator.py` +6:15 fps 的假时钟下 1.32 s 内只启动约 20 次合成(60 fps 对照 ≥55 次)、
`display_period_s()` 跟随设置、结果延迟是真实单调差值、`has_pending_frame()` 随 mailbox 变化;
`test_scene3d_live.py` +3:无位移时发布帧直接**就是**资产自己的(只读)数组、force_deform 但仍无位移时
给出 `field_unavailable` 诊断、有标定位移时仍重算、`compose_ms` 属于该帧且不被下一次合成改写;
`test_scene3d_view.py` +2:等待中的帧让生产者跳过第二个快照且取出后立即恢复、`render_totals()` 的
compose 成本与延迟是真实值;新增 `test_scene3d_protected_package.py` 7 项:Studio 入口打开签名包、
用 preference 里的内容密钥打开加密包、缺验签公钥 → `asset_auth_failed`、密钥文件缺失/无 key id →
状态而非崩溃、发布模式拒绝无签名包而开发模式接受、`SceneAssetLoader` 把密钥交给 worker、面板携带密钥。
· 全量:shared+apps 同一次运行 **588 项 0 失败 15 跳过**;真 GL 冒烟(`QT_QPA_PLATFORM=windows`)
**14 项 0 失败**;ui_check 587 项 0 失败;locale_check 0 缺失。
· 顺带修掉两个**既有的顺序依赖**真 GL 用例(不是本次引入:在上一提交上单跑同样失败):
`test_the_producer_side_never_waits_for_the_gpu` 的收尾没有排空 mailbox(留下的一帧会被下一个测试
上传),`test_an_automatic_colour_scale_follows_the_frame` 在渲染之前就断言"范围来自本帧",
且用"不同颜色数"当自动量程的代理(对可见面积敏感)。现在前者排空并断言排空完成,后者先渲染再断言,
判据改为**平均亮度**(固定 0..1000 把 3..7 压进 LUT 最暗的一小段,自动量程铺满整条色带)。
· 性能报告(`.repro/perf-cpu-*-after-9.json`,`bench_scene.py --cpu-only`):合成 p50
合成 poc 0.016 ms / 20k 顶点 1.03 ms / 真实包 0.17 ms;对比已提交的 `perf-report-windows-dev.json`
(20k 6.15 ms、真实包 0.50 ms)。
- Not in this change: **画布布局第二批**(2D/3D 页签容器、参数抽屉、窄窗口回退、诊断抽屉)与曲线量程开关
仍未做;阶段 9 的门禁部分(`soak_scene.py --minutes 120` GUI 模式、Jetson Orin Nano 与 QCS6490 上的
`probe_gl/bench_scene/soak_scene` 报告)需要长时间运行与板卡,本轮未跑,结论不写"通过";
签名私钥仍只在构建机(本提交只引入"读"的一半)。
465 lines
20 KiB
Python
465 lines
20 KiB
Python
"""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
|
||
|
||
|
||
# --------------------------------------------- stage 9: reuse and real numbers
|
||
def test_colouring_in_place_reuses_the_assets_own_geometry():
|
||
"""No displacement to draw means the picture *is* the asset: nothing to recompute.
|
||
|
||
Normals were recomputed from the same untouched positions on every frame, and the
|
||
measured cost was the bulk of a 20k-vertex compose. The reused arrays are the
|
||
asset's own (frozen) ones, so nothing is copied either.
|
||
"""
|
||
scene = synthetic.poc_scene()
|
||
part = scene.sensing_part()
|
||
composer = SceneComposer()
|
||
frame = _frame(scene, np.zeros((3, 3), dtype=np.float32))
|
||
composed = composer.compose(scene, frame, DisplaySettings())
|
||
assert composed.positions_sensor_m is part.positions_sensor_m
|
||
assert composed.normals_sensor is part.normals_sensor
|
||
# A deform request with nothing to deform is the same case, and says so.
|
||
composed = composer.compose(
|
||
scene, frame, DisplaySettings(deform_enabled=True, display_gain=5.0)
|
||
)
|
||
assert composed.positions_sensor_m is part.positions_sensor_m
|
||
assert any(item.code == "field_unavailable" for item in composed.diagnostics)
|
||
|
||
|
||
def test_a_calibrated_displacement_still_recomputes_positions_and_normals():
|
||
"""The cache is for the colour-in-place case only, never for a deformed surface."""
|
||
scene = synthetic.poc_scene()
|
||
part = scene.sensing_part()
|
||
displacement = np.zeros((3, 3, 3), dtype=np.float32)
|
||
displacement[1, 1, 2] = 0.5
|
||
frame = _frame(
|
||
scene,
|
||
np.ones((3, 3), dtype=np.float32),
|
||
displacement=displacement,
|
||
quality="calibrated",
|
||
)
|
||
composed = SceneComposer().compose(
|
||
scene, frame, DisplaySettings(deform_enabled=True, display_gain=2.0)
|
||
)
|
||
assert composed.positions_sensor_m is not part.positions_sensor_m
|
||
assert composed.normals_sensor is not part.normals_sensor
|
||
assert not np.allclose(
|
||
np.asarray(composed.positions_sensor_m), np.asarray(part.positions_sensor_m)
|
||
)
|
||
|
||
|
||
def test_the_composed_frame_carries_its_own_cost():
|
||
"""The panel's "compose ms" reads this, not the composer's shared counter."""
|
||
scene = synthetic.poc_scene()
|
||
composer = SceneComposer()
|
||
frame = _frame(scene, np.zeros((3, 3), dtype=np.float32))
|
||
composed = composer.compose(scene, frame, DisplaySettings())
|
||
assert composed.compose_ms > 0.0
|
||
assert composed.compose_ms == pytest.approx(composer.last_stats.total_ms)
|
||
# A second compose rewrites ``last_stats``; the first frame's number must not move.
|
||
first = composed.compose_ms
|
||
composer.compose(scene, frame, DisplaySettings())
|
||
assert composed.compose_ms == first
|