阶段 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:
@@ -8,44 +8,216 @@ The constraints here are about *where* the data may be read, not about optics:
|
|||||||
runtime, because the SDK's arrays are that thread's working memory;
|
runtime, because the SDK's arrays are that thread's working memory;
|
||||||
* it copies what the frame needs and publishes it read-only — the GUI thread must
|
* it copies what the frame needs and publishes it read-only — the GUI thread must
|
||||||
never reach back into a live runtime;
|
never reach back into a live runtime;
|
||||||
* it copies **nothing** when the three-dimensional view is not subscribed to, and
|
* it publishes **nothing** when the frame carries no usable field, or when the
|
||||||
returns ``None`` when the frame carries no usable field. A frame is never
|
field is on a different grid than the scene's UV domain. A remap that is not
|
||||||
assembled from two different acquisitions, and a missing field is never filled
|
configured is not guessed at: the adapter says which shapes it saw, so the
|
||||||
in from the previous one.
|
mismatch is a fact in the diagnostics rather than a picture that looks plausible.
|
||||||
|
|
||||||
|
Two things the v1 adapters deliberately do **not** do:
|
||||||
|
|
||||||
|
* they do not invent a displacement. The SDK's lift uses optical flow and a scale
|
||||||
|
estimated from the mesh, which the design document calls "not a verified
|
||||||
|
displacement calibration"; a millimetre field that was never calibrated is worse
|
||||||
|
than no field, so ``displacement_sensor_m`` is ``None`` and the surface is
|
||||||
|
coloured in place. The composer already handles an ``approximate`` displacement
|
||||||
|
for when a verified one arrives;
|
||||||
|
* they do not rescale or pad a field to fit. A field whose shape does not match the
|
||||||
|
domain is a mismatch (`field_domain_mismatch`), not something to resize.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
from orisys_app_shared.acquisition.models import SdkPacket
|
from orisys_app_shared.acquisition.models import SdkPacket
|
||||||
from orisys_app_shared.scene3d.contracts import (
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
|
Diagnostic,
|
||||||
ExpectedGeometry,
|
ExpectedGeometry,
|
||||||
|
FieldDescriptor,
|
||||||
FrameIdentity,
|
FrameIdentity,
|
||||||
SurfaceFieldFrame,
|
SurfaceFieldFrame,
|
||||||
|
diagnostic,
|
||||||
|
error,
|
||||||
|
freeze,
|
||||||
|
)
|
||||||
|
from orisys_app_shared.scene3d.sampling import remap_to_domain
|
||||||
|
|
||||||
|
#: What the adapters can colour: the optical flow's magnitude, in pixels. An
|
||||||
|
#: unsigned magnitude is a sequential colour scale, and ``approximate`` says the
|
||||||
|
#: number is a pixel displacement rather than a calibrated length.
|
||||||
|
FLOW_MAGNITUDE = FieldDescriptor(
|
||||||
|
quantity="flow_magnitude", unit="px", quality="approximate", signed=False
|
||||||
|
)
|
||||||
|
#: The deformation response the SDK's depth map reports: a relative response, not a
|
||||||
|
#: length. It is only ever coloured, never turned into millimetres.
|
||||||
|
DEPTH_RESPONSE = FieldDescriptor(
|
||||||
|
quantity="depth_response", unit="1", quality="approximate", signed=False
|
||||||
)
|
)
|
||||||
|
|
||||||
_STAGE = "stage 3 (live data)"
|
|
||||||
|
def flow_magnitude(packet: SdkPacket) -> Optional[np.ndarray]:
|
||||||
|
"""``|flow|`` as float32, or ``None`` when the packet carries no flow."""
|
||||||
|
flow = getattr(packet, "flow", None)
|
||||||
|
if flow is None:
|
||||||
|
return None
|
||||||
|
array = np.asarray(flow, dtype=np.float32)
|
||||||
|
if array.ndim == 3 and array.shape[-1] == 2:
|
||||||
|
return np.sqrt(array[..., 0] ** 2 + array[..., 1] ** 2)
|
||||||
|
if array.ndim == 2:
|
||||||
|
return array
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _shape_of(array: Optional[np.ndarray]) -> str:
|
||||||
|
return "none" if array is None else "x".join(str(int(v)) for v in array.shape[:2])
|
||||||
|
|
||||||
|
|
||||||
class FingerFrameAdapter:
|
class FingerFrameAdapter:
|
||||||
"""The finger runtime already computes a UV field; copy it, with its identity."""
|
"""The finger runtime's field, in the scene's UV domain.
|
||||||
|
|
||||||
|
``map_x``/``map_y`` are the unroll map's source coordinates. When they are
|
||||||
|
supplied the field is remapped through them; when they are not, the field is
|
||||||
|
used directly and only if its shape already *is* the domain.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
geometry_id: str,
|
||||||
|
field_domain_id: str,
|
||||||
|
shape_hw: tuple[int, int],
|
||||||
|
map_x: Optional[np.ndarray] = None,
|
||||||
|
map_y: Optional[np.ndarray] = None,
|
||||||
|
) -> None:
|
||||||
|
self.geometry_id = str(geometry_id)
|
||||||
|
self.field_domain_id = str(field_domain_id)
|
||||||
|
self.shape_hw = (int(shape_hw[0]), int(shape_hw[1]))
|
||||||
|
self.map_x = map_x
|
||||||
|
self.map_y = map_y
|
||||||
|
self.last_diagnostic: Optional[Diagnostic] = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_domain(cls, domain: Any, *, geometry_id: str) -> "FingerFrameAdapter":
|
||||||
|
"""Build from a ``LegacyFieldDomain`` (which carries the map, when loaded)."""
|
||||||
|
return cls(
|
||||||
|
geometry_id=geometry_id,
|
||||||
|
field_domain_id=str(getattr(domain, "domain_id")),
|
||||||
|
shape_hw=tuple(getattr(domain, "shape_hw")),
|
||||||
|
map_x=getattr(domain, "map_x", None),
|
||||||
|
map_y=getattr(domain, "map_y", None),
|
||||||
|
)
|
||||||
|
|
||||||
def snapshot(
|
def snapshot(
|
||||||
self, packet: SdkPacket, runtime: Any, identity: FrameIdentity
|
self, packet: SdkPacket, runtime: Any, identity: FrameIdentity
|
||||||
) -> Optional[SurfaceFieldFrame]:
|
) -> Optional[SurfaceFieldFrame]:
|
||||||
raise NotImplementedError(f"{_STAGE}: snapshot the finger UV field")
|
magnitude = flow_magnitude(packet)
|
||||||
|
if magnitude is None:
|
||||||
|
self.last_diagnostic = diagnostic(
|
||||||
|
"field_unavailable", "this frame carries no optical flow"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if self.map_x is not None and self.map_y is not None:
|
||||||
|
try:
|
||||||
|
values, valid = remap_to_domain(
|
||||||
|
magnitude, map_x=self.map_x, map_y=self.map_y, shape_hw=self.shape_hw
|
||||||
|
)
|
||||||
|
except Exception as exc: # noqa: BLE001 - a bad map is a diagnostic
|
||||||
|
self.last_diagnostic = getattr(exc, "diagnostic", None) or diagnostic(
|
||||||
|
"field_domain_mismatch", f"remap failed: {type(exc).__name__}: {exc}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
scalar = values
|
||||||
|
mask = valid
|
||||||
|
elif tuple(magnitude.shape[:2]) == self.shape_hw:
|
||||||
|
scalar = freeze(magnitude, dtype=np.float32)
|
||||||
|
mask = freeze(np.isfinite(magnitude), dtype=np.bool_)
|
||||||
|
else:
|
||||||
|
# Say what was seen: this is the message that tells an engineer whether
|
||||||
|
# the flow arrives in the tracker's grid or already unrolled.
|
||||||
|
self.last_diagnostic = diagnostic(
|
||||||
|
"field_domain_mismatch",
|
||||||
|
"the flow is not in the scene's UV domain and no remap is configured",
|
||||||
|
flow=_shape_of(magnitude),
|
||||||
|
domain=f"{self.shape_hw[0]}x{self.shape_hw[1]}",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
self.last_diagnostic = None
|
||||||
|
return SurfaceFieldFrame(
|
||||||
|
identity=identity,
|
||||||
|
geometry_id=self.geometry_id,
|
||||||
|
field_domain_id=self.field_domain_id,
|
||||||
|
scalar=scalar,
|
||||||
|
scalar_descriptor=FLOW_MAGNITUDE,
|
||||||
|
valid=mask,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class BoxFrameAdapter:
|
class BoxFrameAdapter:
|
||||||
"""Box/mini: a scalar field only.
|
"""Box/mini: a scalar field only, and only when the grid already matches.
|
||||||
|
|
||||||
There is no verified spatial scale for these sensors yet, so no displacement is
|
There is no verified spatial scale for these sensors, so no displacement is
|
||||||
invented: a millimetre height field that was never calibrated is worse than no
|
invented: a millimetre height field that was never calibrated is worse than no
|
||||||
height field at all.
|
height field at all.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
geometry_id: str,
|
||||||
|
field_domain_id: str,
|
||||||
|
shape_hw: tuple[int, int],
|
||||||
|
) -> None:
|
||||||
|
self.geometry_id = str(geometry_id)
|
||||||
|
self.field_domain_id = str(field_domain_id)
|
||||||
|
self.shape_hw = (int(shape_hw[0]), int(shape_hw[1]))
|
||||||
|
self.last_diagnostic: Optional[Diagnostic] = None
|
||||||
|
|
||||||
def snapshot(
|
def snapshot(
|
||||||
self, packet: SdkPacket, identity: FrameIdentity, domain: ExpectedGeometry
|
self, packet: SdkPacket, identity: FrameIdentity, domain: ExpectedGeometry
|
||||||
) -> Optional[SurfaceFieldFrame]:
|
) -> Optional[SurfaceFieldFrame]:
|
||||||
raise NotImplementedError(f"{_STAGE}: snapshot the box scalar field")
|
if tuple(domain.shape_hw) != self.shape_hw:
|
||||||
|
self.last_diagnostic = diagnostic(
|
||||||
|
"field_domain_mismatch",
|
||||||
|
"the device description names a different field domain",
|
||||||
|
device=f"{domain.shape_hw[0]}x{domain.shape_hw[1]}",
|
||||||
|
scene=f"{self.shape_hw[0]}x{self.shape_hw[1]}",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
candidates = (
|
||||||
|
("depth_response", getattr(packet, "depth", None)),
|
||||||
|
("flow_magnitude", flow_magnitude(packet)),
|
||||||
|
)
|
||||||
|
for name, field in candidates:
|
||||||
|
if field is None:
|
||||||
|
continue
|
||||||
|
array = np.asarray(field, dtype=np.float32)
|
||||||
|
if array.ndim != 2 or tuple(array.shape) != self.shape_hw:
|
||||||
|
continue
|
||||||
|
mask = np.isfinite(array)
|
||||||
|
explicit = getattr(packet, "valid_mask", None)
|
||||||
|
if explicit is not None:
|
||||||
|
explicit_array = np.asarray(explicit, dtype=bool)
|
||||||
|
if tuple(explicit_array.shape) == self.shape_hw:
|
||||||
|
# The device's own mask wins: it knows where its surface is.
|
||||||
|
mask = mask & explicit_array
|
||||||
|
self.last_diagnostic = None
|
||||||
|
return SurfaceFieldFrame(
|
||||||
|
identity=identity,
|
||||||
|
geometry_id=self.geometry_id,
|
||||||
|
field_domain_id=self.field_domain_id,
|
||||||
|
scalar=freeze(array, dtype=np.float32),
|
||||||
|
scalar_descriptor=(
|
||||||
|
DEPTH_RESPONSE if name == "depth_response" else FLOW_MAGNITUDE
|
||||||
|
),
|
||||||
|
valid=freeze(mask, dtype=np.bool_),
|
||||||
|
)
|
||||||
|
self.last_diagnostic = diagnostic(
|
||||||
|
"field_unavailable",
|
||||||
|
"no usable scalar field of the scene's shape in this frame",
|
||||||
|
depth=_shape_of(getattr(packet, "depth", None)),
|
||||||
|
flow=_shape_of(flow_magnitude(packet)),
|
||||||
|
domain=f"{self.shape_hw[0]}x{self.shape_hw[1]}",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|||||||
@@ -1,32 +1,50 @@
|
|||||||
"""Frame schedule and state machine for the three-dimensional view.
|
"""Frame schedule and state machine for the three-dimensional view.
|
||||||
|
|
||||||
Stage 3 (mailboxes, compose worker) and stage 4 (Qt timer, visibility, settings) in
|
Stage 3 (mailboxes, compose worker) and stage 4 (Qt timer, visibility) in
|
||||||
``docs/architecture/studio-3d-stages.md``.
|
``docs/architecture/studio-3d-stages.md``.
|
||||||
|
|
||||||
The whole point of this class is that **the acquisition loop never waits for the
|
The whole point of this class is that **the acquisition loop never waits for the
|
||||||
display**. Producers call :meth:`submit`, which validates cheaply and drops the
|
display**. A producer calls :meth:`submit`, which validates cheaply and drops the
|
||||||
previous un-consumed frame; a GUI timer calls :meth:`tick`, which picks up the
|
previous un-consumed frame; a GUI timer calls :meth:`tick`, which hands the newest
|
||||||
newest composed frame and repaints. Display drops are counted here and never in
|
field frame to the composer, picks up the newest composed frame and asks the widget
|
||||||
the recording channel, and no CPU task is submitted while one is still running.
|
to repaint. Three properties make that true rather than aspirational:
|
||||||
|
|
||||||
|
* both mailboxes hold **one** frame, and ``submit`` only ever swaps a reference
|
||||||
|
under a lock — the work it can do is bounded by the lock, not by the display;
|
||||||
|
* **at most one compose task is in flight**. A slow composer makes frames drop; it
|
||||||
|
cannot make the queue grow;
|
||||||
|
* display drops are counted here and never mixed into the recording channel, which
|
||||||
|
keeps running through the existing sink path untouched.
|
||||||
|
|
||||||
|
Staleness is about *identity*, not timing: a frame from another generation, another
|
||||||
|
session or another source than the active scene is dropped with ``stale_frame`` and
|
||||||
|
counted, and an old result can never overwrite a new scene because the composer's
|
||||||
|
result carries the identity it was built from and is checked again on arrival.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from concurrent.futures import Executor
|
import threading
|
||||||
from typing import Optional
|
from concurrent.futures import Executor, Future
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
from orisys_app_shared.scene3d.contracts import (
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
|
Diagnostic,
|
||||||
DisplaySettings,
|
DisplaySettings,
|
||||||
|
FrameIdentity,
|
||||||
LatestFrameMailbox,
|
LatestFrameMailbox,
|
||||||
OfferResult,
|
OfferResult,
|
||||||
RenderFrame,
|
RenderFrame,
|
||||||
SceneAssets,
|
SceneAssets,
|
||||||
SceneState,
|
SceneState,
|
||||||
SurfaceFieldFrame,
|
SurfaceFieldFrame,
|
||||||
|
diagnostic,
|
||||||
|
error,
|
||||||
)
|
)
|
||||||
from orisys_studio.scene3d.widget import Sensor3DWidget
|
|
||||||
|
|
||||||
_STAGE = "stage 3 (schedule) and stage 4 (Qt integration)"
|
#: How many composed frames may be "lost" before the coordinator stops caring: the
|
||||||
|
#: counter is informational, never a reason to throttle acquisition.
|
||||||
|
MAX_INFLIGHT_COMPOSES = 1
|
||||||
|
|
||||||
|
|
||||||
class ThreeDCoordinator:
|
class ThreeDCoordinator:
|
||||||
@@ -34,48 +52,41 @@ class ThreeDCoordinator:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
widget: Sensor3DWidget,
|
widget: Any,
|
||||||
composer: object,
|
composer: Any,
|
||||||
executor: Executor,
|
executor: Executor,
|
||||||
max_fps: int = 30,
|
max_fps: int = 30,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._widget = widget
|
self._widget = widget
|
||||||
self._composer = composer
|
self._composer = composer
|
||||||
self._executor = executor
|
self._executor = executor
|
||||||
self._max_fps = int(max_fps)
|
self._max_fps = max(1, int(max_fps))
|
||||||
self._incoming: LatestFrameMailbox[SurfaceFieldFrame] = LatestFrameMailbox()
|
self._incoming: LatestFrameMailbox[SurfaceFieldFrame] = LatestFrameMailbox()
|
||||||
self._composed: LatestFrameMailbox[RenderFrame] = LatestFrameMailbox()
|
self._composed: LatestFrameMailbox[RenderFrame] = LatestFrameMailbox()
|
||||||
|
self._lock = threading.Lock()
|
||||||
self._state: SceneState = "disabled"
|
self._state: SceneState = "disabled"
|
||||||
self._generation = 0
|
self._generation = 0
|
||||||
self._assets: Optional[SceneAssets] = None
|
self._assets: Optional[SceneAssets] = None
|
||||||
|
self._source_id = ""
|
||||||
|
self._session_id: Optional[int] = None
|
||||||
self._visible = False
|
self._visible = False
|
||||||
|
self._settings = DisplaySettings(max_fps=self._max_fps)
|
||||||
|
self._inflight: Optional[Future] = None
|
||||||
|
self._stale_frames = 0
|
||||||
|
self._invalid_frames = 0
|
||||||
|
self._last_diagnostic: Optional[Diagnostic] = None
|
||||||
|
self._closed = False
|
||||||
|
self._settings_dirty = False
|
||||||
|
self._last_composed_identity: Optional[FrameIdentity] = None
|
||||||
|
#: The field frame currently on screen, so a gain change can redraw exactly
|
||||||
|
#: it rather than a newer instant.
|
||||||
|
self._last_field_frame: Optional[SurfaceFieldFrame] = None
|
||||||
|
|
||||||
def activate(self, assets: SceneAssets, source_id: str) -> int:
|
# ------------------------------------------------------------------- state
|
||||||
"""Adopt a scene and a data source; returns the new generation."""
|
|
||||||
raise NotImplementedError(f"{_STAGE}: activate a scene")
|
|
||||||
|
|
||||||
def submit(self, frame: SurfaceFieldFrame) -> OfferResult:
|
|
||||||
"""Offer a field frame from a producer thread. Never blocks on the GUI."""
|
|
||||||
raise NotImplementedError(f"{_STAGE}: accept or reject an incoming frame")
|
|
||||||
|
|
||||||
def tick(self) -> None:
|
|
||||||
"""GUI timer: take the newest composed frame, present it, schedule work."""
|
|
||||||
raise NotImplementedError(f"{_STAGE}: pump the mailboxes")
|
|
||||||
|
|
||||||
def set_visible(self, visible: bool) -> None:
|
|
||||||
"""Hiding suspends composing, uploading and rendering — not acquisition."""
|
|
||||||
raise NotImplementedError(f"{_STAGE}: suspend or resume the view")
|
|
||||||
|
|
||||||
def set_settings(self, settings: DisplaySettings) -> None:
|
|
||||||
raise NotImplementedError(f"{_STAGE}: apply display settings")
|
|
||||||
|
|
||||||
def close(self) -> None:
|
|
||||||
"""Cancel work, stop the timer, clear both mailboxes. Idempotent."""
|
|
||||||
raise NotImplementedError(f"{_STAGE}: shut the view down")
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def state(self) -> SceneState:
|
def state(self) -> SceneState:
|
||||||
return self._state
|
with self._lock:
|
||||||
|
return self._state
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def generation(self) -> int:
|
def generation(self) -> int:
|
||||||
@@ -83,7 +94,261 @@ class ThreeDCoordinator:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def superseded_frames(self) -> int:
|
def superseded_frames(self) -> int:
|
||||||
"""Frames the display never showed. Not a recording drop."""
|
"""Frames the display never showed. Never a recording drop."""
|
||||||
return (
|
return self._incoming.superseded_frames + self._composed.superseded_frames
|
||||||
self._incoming.superseded_frames + self._composed.superseded_frames
|
|
||||||
|
@property
|
||||||
|
def stale_frames(self) -> int:
|
||||||
|
return self._stale_frames
|
||||||
|
|
||||||
|
@property
|
||||||
|
def invalid_frames(self) -> int:
|
||||||
|
return self._invalid_frames
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_diagnostic(self) -> Optional[Diagnostic]:
|
||||||
|
return self._last_diagnostic
|
||||||
|
|
||||||
|
@property
|
||||||
|
def visible(self) -> bool:
|
||||||
|
return self._visible
|
||||||
|
|
||||||
|
def settings(self) -> DisplaySettings:
|
||||||
|
return self._settings
|
||||||
|
|
||||||
|
def _set_state(self, state: SceneState) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._state = state
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- lifetime
|
||||||
|
def activate(self, assets: SceneAssets, source_id: str) -> int:
|
||||||
|
"""Adopt a scene and a data source; returns the new generation.
|
||||||
|
|
||||||
|
Everything already in flight belongs to the previous scene and is dropped:
|
||||||
|
the generation is what makes "old" mean something.
|
||||||
|
"""
|
||||||
|
if self._closed:
|
||||||
|
raise error("asset_rejected", "this coordinator has been closed")
|
||||||
|
self._cancel_inflight()
|
||||||
|
self._incoming.clear()
|
||||||
|
self._composed.clear()
|
||||||
|
self._assets = assets
|
||||||
|
self._source_id = str(source_id)
|
||||||
|
self._session_id = None
|
||||||
|
self._generation += 1
|
||||||
|
self._last_composed_identity = None
|
||||||
|
self._last_field_frame = None
|
||||||
|
self._settings_dirty = False
|
||||||
|
self._widget.set_scene(assets)
|
||||||
|
if self._visible:
|
||||||
|
self._widget.present(self._composer.rest_frame(assets, self._identity(0)))
|
||||||
|
self._set_state("ready")
|
||||||
|
else:
|
||||||
|
self._set_state("loading" if self._visible else "suspended")
|
||||||
|
return self._generation
|
||||||
|
|
||||||
|
def set_visible(self, visible: bool) -> None:
|
||||||
|
"""Hiding suspends composing, uploading and rendering — not acquisition."""
|
||||||
|
visible = bool(visible)
|
||||||
|
if self._closed:
|
||||||
|
return
|
||||||
|
self._widget.setVisible(visible)
|
||||||
|
if not visible:
|
||||||
|
self._visible = False
|
||||||
|
self._incoming.clear()
|
||||||
|
self._composed.clear()
|
||||||
|
self._cancel_inflight()
|
||||||
|
self._set_state("suspended")
|
||||||
|
return
|
||||||
|
self._visible = True
|
||||||
|
# Come back on the newest frame rather than replaying what was dropped.
|
||||||
|
self._set_state("ready" if self._assets is not None else "disabled")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- producers
|
||||||
|
def submit(self, frame: SurfaceFieldFrame) -> OfferResult:
|
||||||
|
"""Offer a field frame from a producer thread. Never blocks on the GUI."""
|
||||||
|
if self._closed or not self._visible:
|
||||||
|
return "disabled"
|
||||||
|
with self._lock:
|
||||||
|
assets = self._assets
|
||||||
|
generation = self._generation
|
||||||
|
session_id = self._session_id
|
||||||
|
if assets is None:
|
||||||
|
return "disabled"
|
||||||
|
identity = frame.identity
|
||||||
|
if identity.source_id != self._source_id or identity.generation != generation:
|
||||||
|
self._stale_frames += 1
|
||||||
|
self._last_diagnostic = diagnostic(
|
||||||
|
"stale_frame",
|
||||||
|
"a frame from another source or generation was dropped",
|
||||||
|
source=identity.source_id,
|
||||||
|
generation=str(identity.generation),
|
||||||
|
)
|
||||||
|
return "stale"
|
||||||
|
if session_id is not None and identity.session_id != session_id:
|
||||||
|
self._stale_frames += 1
|
||||||
|
return "stale"
|
||||||
|
problem = self._reject_reason(frame, assets)
|
||||||
|
if problem is not None:
|
||||||
|
self._invalid_frames += 1
|
||||||
|
self._last_diagnostic = problem
|
||||||
|
return "invalid"
|
||||||
|
with self._lock:
|
||||||
|
if session_id is None:
|
||||||
|
self._session_id = identity.session_id
|
||||||
|
return self._incoming.offer(frame)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _reject_reason(
|
||||||
|
frame: SurfaceFieldFrame, assets: SceneAssets
|
||||||
|
) -> Optional[Diagnostic]:
|
||||||
|
"""The cheap checks a producer thread may do: identity and shape only."""
|
||||||
|
if frame.geometry_id != assets.geometry_id:
|
||||||
|
return diagnostic(
|
||||||
|
"geometry_mismatch",
|
||||||
|
"the frame belongs to a different geometry",
|
||||||
|
frame=frame.geometry_id,
|
||||||
|
scene=assets.geometry_id,
|
||||||
|
)
|
||||||
|
if frame.field_domain_id != assets.field_domain_id:
|
||||||
|
return diagnostic(
|
||||||
|
"field_domain_mismatch",
|
||||||
|
"the frame is on a different field domain",
|
||||||
|
frame=frame.field_domain_id,
|
||||||
|
scene=assets.field_domain_id,
|
||||||
|
)
|
||||||
|
if tuple(frame.scalar.shape) != tuple(assets.binding.shape_hw):
|
||||||
|
return diagnostic(
|
||||||
|
"field_domain_mismatch",
|
||||||
|
"the frame's shape does not match the scene's UV domain",
|
||||||
|
frame=f"{frame.scalar.shape[0]}x{frame.scalar.shape[1]}",
|
||||||
|
scene=f"{assets.binding.shape_hw[0]}x{assets.binding.shape_hw[1]}",
|
||||||
|
)
|
||||||
|
if tuple(frame.valid.shape) != tuple(frame.scalar.shape):
|
||||||
|
return diagnostic(
|
||||||
|
"field_domain_mismatch", "the frame's scalar and validity disagree"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- consumer
|
||||||
|
def tick(self) -> None:
|
||||||
|
"""GUI timer: start one compose, take the newest result, repaint."""
|
||||||
|
if self._closed or not self._visible or self._assets is None:
|
||||||
|
return
|
||||||
|
self._collect_inflight()
|
||||||
|
if self._inflight is None:
|
||||||
|
if self._settings_dirty and self._last_field_frame is not None:
|
||||||
|
# A gain change re-draws the field that is already on screen; taking
|
||||||
|
# a newer one would show a different instant than the one the
|
||||||
|
# operator just changed the exaggeration of.
|
||||||
|
self._settings_dirty = False
|
||||||
|
self._start_compose(self._last_field_frame)
|
||||||
|
else:
|
||||||
|
self._settings_dirty = False
|
||||||
|
frame = self._incoming.take()
|
||||||
|
if frame is not None:
|
||||||
|
self._start_compose(frame)
|
||||||
|
composed = self._composed.take()
|
||||||
|
if composed is not None:
|
||||||
|
if not self._identity_is_current(composed.identity):
|
||||||
|
self._stale_frames += 1
|
||||||
|
return
|
||||||
|
self._widget.set_superseded_frames(self.superseded_frames)
|
||||||
|
self._widget.present(composed)
|
||||||
|
self._last_composed_identity = composed.identity
|
||||||
|
self._set_state("live" if composed.valid.any() else "no_data")
|
||||||
|
elif self._state in ("ready", "loading"):
|
||||||
|
# Nothing has arrived yet: show the reference model rather than a blank
|
||||||
|
# window, and say that no measurement is being displayed.
|
||||||
|
if self._last_composed_identity is None:
|
||||||
|
self._widget.present(self._composer.rest_frame(self._assets, self._identity(0)))
|
||||||
|
self._set_state("no_data")
|
||||||
|
|
||||||
|
def set_settings(self, settings: DisplaySettings) -> None:
|
||||||
|
"""Apply display settings; a gain change means recomposing the last field."""
|
||||||
|
previous = self._settings
|
||||||
|
self._settings = settings
|
||||||
|
self._widget.set_settings(settings)
|
||||||
|
gain_changed = (
|
||||||
|
previous.display_gain != settings.display_gain
|
||||||
|
or previous.deform_enabled != settings.deform_enabled
|
||||||
)
|
)
|
||||||
|
if gain_changed and self._assets is not None:
|
||||||
|
# The scalar is untouched by the gain, but the *positions* are not, so
|
||||||
|
# the last field frame has to be composed again.
|
||||||
|
self._settings_dirty = True
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
"""Cancel work, stop the timer, clear both mailboxes. Idempotent."""
|
||||||
|
if self._closed:
|
||||||
|
return
|
||||||
|
self._closed = True
|
||||||
|
self._cancel_inflight()
|
||||||
|
self._incoming.clear()
|
||||||
|
self._composed.clear()
|
||||||
|
self._set_state("disabled")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ internal
|
||||||
|
def _identity(self, request_id: int) -> FrameIdentity:
|
||||||
|
return FrameIdentity(
|
||||||
|
source_id=self._source_id,
|
||||||
|
session_id=self._session_id or 0,
|
||||||
|
frame_id=0,
|
||||||
|
t_rel_s=0.0,
|
||||||
|
generation=self._generation,
|
||||||
|
request_id=int(request_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _identity_is_current(self, identity: FrameIdentity) -> bool:
|
||||||
|
return (
|
||||||
|
identity.source_id == self._source_id
|
||||||
|
and identity.generation == self._generation
|
||||||
|
and (self._session_id is None or identity.session_id == self._session_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _start_compose(self, frame: SurfaceFieldFrame) -> None:
|
||||||
|
assets = self._assets
|
||||||
|
settings = self._settings
|
||||||
|
if assets is None:
|
||||||
|
return
|
||||||
|
generation = self._generation
|
||||||
|
# Kept so a display-settings change can recompose *this* frame instead of
|
||||||
|
# jumping to a newer one.
|
||||||
|
self._last_field_frame = frame
|
||||||
|
|
||||||
|
def work() -> None:
|
||||||
|
try:
|
||||||
|
composed = self._composer.compose(assets, frame, settings)
|
||||||
|
except Exception as exc: # noqa: BLE001 - a failed compose is not a crash
|
||||||
|
self._last_diagnostic = getattr(exc, "diagnostic", None) or diagnostic(
|
||||||
|
"field_unavailable", f"compose failed: {type(exc).__name__}: {exc}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if composed.identity.generation != generation:
|
||||||
|
# The scene changed while this was running: the result is for a
|
||||||
|
# scene that no longer exists.
|
||||||
|
self._stale_frames += 1
|
||||||
|
return
|
||||||
|
self._composed.offer(composed)
|
||||||
|
|
||||||
|
self._inflight = self._executor.submit(work)
|
||||||
|
|
||||||
|
def _collect_inflight(self) -> None:
|
||||||
|
future = self._inflight
|
||||||
|
if future is None:
|
||||||
|
return
|
||||||
|
if not future.done():
|
||||||
|
return
|
||||||
|
self._inflight = None
|
||||||
|
try:
|
||||||
|
future.result()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self._last_diagnostic = getattr(exc, "diagnostic", None) or diagnostic(
|
||||||
|
"field_unavailable", f"compose raised: {type(exc).__name__}: {exc}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _cancel_inflight(self) -> None:
|
||||||
|
future = self._inflight
|
||||||
|
self._inflight = None
|
||||||
|
if future is not None:
|
||||||
|
future.cancel()
|
||||||
|
|||||||
@@ -310,24 +310,12 @@ def rest_surface(assets: SceneAssets, identity: Any) -> RenderFrame:
|
|||||||
|
|
||||||
Used to *clear* a measurement — after a refused frame, on connect, or on a
|
Used to *clear* a measurement — after a refused frame, on connect, or on a
|
||||||
disconnect — so the screen cannot keep showing the previous acquisition's
|
disconnect — so the screen cannot keep showing the previous acquisition's
|
||||||
colours as if they were current. ``quality="display_only"`` and an
|
colours as if they were current. The composer owns the definition; this is the
|
||||||
``unavailable`` quantity are what tell the UI to hide the measurement bar.
|
GL layer reaching it without importing the whole composition path.
|
||||||
"""
|
"""
|
||||||
from orisys_app_shared.scene3d.contracts import rest_descriptor
|
from orisys_app_shared.scene3d.composer import build_rest_frame
|
||||||
|
|
||||||
part = assets.sensing_part()
|
return build_rest_frame(assets, identity)
|
||||||
count = int(part.positions_sensor_m.shape[0])
|
|
||||||
return RenderFrame(
|
|
||||||
identity=identity,
|
|
||||||
asset_hash=assets.asset_hash,
|
|
||||||
geometry_id=assets.geometry_id,
|
|
||||||
sensing_part_id=part.part_id,
|
|
||||||
positions_sensor_m=part.positions_sensor_m,
|
|
||||||
normals_sensor=part.normals_sensor,
|
|
||||||
scalar=freeze(np.full(count, np.nan, dtype=np.float32), dtype=np.float32),
|
|
||||||
valid=freeze(np.zeros(count, dtype=bool), dtype=np.bool_),
|
|
||||||
scalar_descriptor=rest_descriptor(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------- dispatch
|
# ------------------------------------------------------------------- dispatch
|
||||||
|
|||||||
@@ -0,0 +1,272 @@
|
|||||||
|
"""Stage 3 acceptance for the frame adapters.
|
||||||
|
|
||||||
|
The adapters are the boundary between the acquisition thread's arrays and the
|
||||||
|
scene, so the tests are about *what they publish and what they refuse*: a field in
|
||||||
|
the right grid, read-only, with the right unit and quality — and nothing at all when
|
||||||
|
the grid does not match, with a diagnostic that says which shapes were seen.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from orisys_app_shared.scene3d import synthetic
|
||||||
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
|
ExpectedGeometry,
|
||||||
|
FrameIdentity,
|
||||||
|
ThreeDError,
|
||||||
|
validate_surface_frame,
|
||||||
|
)
|
||||||
|
from orisys_studio.scene3d.adapters import (
|
||||||
|
DEPTH_RESPONSE,
|
||||||
|
FLOW_MAGNITUDE,
|
||||||
|
BoxFrameAdapter,
|
||||||
|
FingerFrameAdapter,
|
||||||
|
flow_magnitude,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FakePacket:
|
||||||
|
"""The parts of ``SdkPacket`` the adapters look at."""
|
||||||
|
|
||||||
|
flow: Optional[np.ndarray] = None
|
||||||
|
depth: Optional[np.ndarray] = None
|
||||||
|
valid_mask: Optional[np.ndarray] = None
|
||||||
|
frame_id: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
def _identity(generation: int = 1) -> FrameIdentity:
|
||||||
|
return FrameIdentity("live", 1, 1, 0.1, generation=generation)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def scene():
|
||||||
|
return synthetic.poc_scene()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- flow helper
|
||||||
|
def test_the_flow_magnitude_handles_both_shapes():
|
||||||
|
flow = np.zeros((2, 3, 2), dtype=np.float32)
|
||||||
|
flow[..., 0] = 3.0
|
||||||
|
flow[..., 1] = 4.0
|
||||||
|
assert np.allclose(flow_magnitude(FakePacket(flow=flow)), 5.0)
|
||||||
|
planar = np.arange(4, dtype=np.float32).reshape(2, 2)
|
||||||
|
assert np.array_equal(flow_magnitude(FakePacket(flow=planar)), planar)
|
||||||
|
assert flow_magnitude(FakePacket()) is None
|
||||||
|
assert flow_magnitude(FakePacket(flow=np.zeros((2, 2, 3), dtype=np.float32))) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- finger adapter
|
||||||
|
def test_the_finger_adapter_publishes_a_field_already_in_the_domain(scene):
|
||||||
|
adapter = FingerFrameAdapter(
|
||||||
|
geometry_id=scene.geometry_id,
|
||||||
|
field_domain_id=scene.field_domain_id,
|
||||||
|
shape_hw=scene.shape_hw,
|
||||||
|
)
|
||||||
|
flow = np.zeros((*scene.shape_hw, 2), dtype=np.float32)
|
||||||
|
flow[0, 0, 0] = 3.0
|
||||||
|
frame = adapter.snapshot(FakePacket(flow=flow), runtime=None, identity=_identity())
|
||||||
|
assert frame is not None
|
||||||
|
assert validate_surface_frame(frame) == ()
|
||||||
|
assert frame.geometry_id == scene.geometry_id
|
||||||
|
assert frame.field_domain_id == scene.field_domain_id
|
||||||
|
assert frame.scalar_descriptor == FLOW_MAGNITUDE
|
||||||
|
assert frame.scalar_descriptor.unit == "px"
|
||||||
|
assert frame.scalar_descriptor.quality == "approximate"
|
||||||
|
assert np.asarray(frame.scalar)[0, 0] == 3.0
|
||||||
|
assert np.asarray(frame.valid).all()
|
||||||
|
assert not np.asarray(frame.scalar).flags["WRITEABLE"]
|
||||||
|
assert frame.displacement_sensor_m is None, (
|
||||||
|
"an uncalibrated displacement must not be invented"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_finger_adapter_remaps_through_the_unroll_map():
|
||||||
|
map_x = np.array([[0, 1, 2], [0, 1, 2], [0, 1, 2]], dtype=np.float32)
|
||||||
|
map_y = np.array([[2, 2, 2], [1, 1, 1], [0, 0, 0]], dtype=np.float32)
|
||||||
|
adapter = FingerFrameAdapter(
|
||||||
|
geometry_id="g", field_domain_id="d", shape_hw=(3, 3),
|
||||||
|
map_x=map_x, map_y=map_y,
|
||||||
|
)
|
||||||
|
flow = np.zeros((3, 3, 2), dtype=np.float32)
|
||||||
|
flow[..., 0] = 2.0
|
||||||
|
frame = adapter.snapshot(FakePacket(flow=flow), runtime=None, identity=_identity())
|
||||||
|
assert frame is not None
|
||||||
|
assert np.allclose(np.asarray(frame.scalar), 2.0)
|
||||||
|
assert np.asarray(frame.valid).all()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_remap_with_a_hole_is_invalid_there_and_does_not_read_as_zero():
|
||||||
|
map_x = np.array([[0, -1], [0, 0]], dtype=np.float32)
|
||||||
|
map_y = np.array([[0, -1], [0, 0]], dtype=np.float32)
|
||||||
|
adapter = FingerFrameAdapter(
|
||||||
|
geometry_id="g", field_domain_id="d", shape_hw=(2, 2),
|
||||||
|
map_x=map_x, map_y=map_y,
|
||||||
|
)
|
||||||
|
flow = np.full((2, 2, 2), 1.0, dtype=np.float32)
|
||||||
|
frame = adapter.snapshot(FakePacket(flow=flow), runtime=None, identity=_identity())
|
||||||
|
assert frame is not None
|
||||||
|
assert np.asarray(frame.valid).tolist() == [[True, False], [True, True]]
|
||||||
|
assert np.isnan(np.asarray(frame.scalar)[0, 1])
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_finger_adapter_refuses_a_grid_it_cannot_place(scene):
|
||||||
|
"""No remap and the wrong grid: say which shapes were seen, and publish nothing."""
|
||||||
|
adapter = FingerFrameAdapter(
|
||||||
|
geometry_id=scene.geometry_id,
|
||||||
|
field_domain_id=scene.field_domain_id,
|
||||||
|
shape_hw=scene.shape_hw,
|
||||||
|
)
|
||||||
|
flow = np.zeros((40, 30, 2), dtype=np.float32)
|
||||||
|
frame = adapter.snapshot(FakePacket(flow=flow), runtime=None, identity=_identity())
|
||||||
|
assert frame is None
|
||||||
|
item = adapter.last_diagnostic
|
||||||
|
assert item is not None and item.code == "field_domain_mismatch"
|
||||||
|
assert item.details["flow"] == "40x30"
|
||||||
|
assert item.details["domain"] == f"{scene.shape_hw[0]}x{scene.shape_hw[1]}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_finger_adapter_without_flow_says_so(scene):
|
||||||
|
adapter = FingerFrameAdapter(
|
||||||
|
geometry_id=scene.geometry_id,
|
||||||
|
field_domain_id=scene.field_domain_id,
|
||||||
|
shape_hw=scene.shape_hw,
|
||||||
|
)
|
||||||
|
assert adapter.snapshot(FakePacket(), runtime=None, identity=_identity()) is None
|
||||||
|
assert adapter.last_diagnostic is not None
|
||||||
|
assert adapter.last_diagnostic.code == "field_unavailable"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_finger_adapter_can_be_built_from_a_domain(scene):
|
||||||
|
from orisys_app_shared.scene3d.legacy import load_legacy_domain
|
||||||
|
|
||||||
|
map_path = _run_map()
|
||||||
|
if map_path is None:
|
||||||
|
pytest.skip("the shipped unroll map is not present")
|
||||||
|
domain = load_legacy_domain(map_path)
|
||||||
|
adapter = FingerFrameAdapter.from_domain(domain, geometry_id="geom")
|
||||||
|
assert adapter.shape_hw == domain.shape_hw
|
||||||
|
assert adapter.field_domain_id == domain.domain_id
|
||||||
|
assert adapter.map_x is not None and adapter.map_y is not None, (
|
||||||
|
"the domain must carry the unroll map for the adapter to use"
|
||||||
|
)
|
||||||
|
assert domain.source_hw == (288, 512), domain.source_hw
|
||||||
|
|
||||||
|
|
||||||
|
def _run_map():
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
path = Path(r"D:\orisys_ws\orisys-sdk\sensors\finger\assets\30fps-0703\unroll_map_uv_s0.4.npz")
|
||||||
|
return path if path.is_file() else None
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------- box adapter
|
||||||
|
def test_the_box_adapter_prefers_the_depth_response(scene):
|
||||||
|
adapter = BoxFrameAdapter(
|
||||||
|
geometry_id=scene.geometry_id,
|
||||||
|
field_domain_id=scene.field_domain_id,
|
||||||
|
shape_hw=scene.shape_hw,
|
||||||
|
)
|
||||||
|
depth = np.full(scene.shape_hw, 0.25, dtype=np.float32)
|
||||||
|
flow = np.ones((*scene.shape_hw, 2), dtype=np.float32)
|
||||||
|
frame = adapter.snapshot(
|
||||||
|
FakePacket(depth=depth, flow=flow),
|
||||||
|
_identity(),
|
||||||
|
ExpectedGeometry("box", "rev", scene.geometry_id, scene.field_domain_id, scene.shape_hw),
|
||||||
|
)
|
||||||
|
assert frame is not None
|
||||||
|
assert frame.scalar_descriptor == DEPTH_RESPONSE
|
||||||
|
assert frame.scalar_descriptor.unit == "1", "a deformation response is not a length"
|
||||||
|
assert np.allclose(np.asarray(frame.scalar), 0.25)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_box_adapter_falls_back_to_the_flow_magnitude(scene):
|
||||||
|
adapter = BoxFrameAdapter(
|
||||||
|
geometry_id=scene.geometry_id,
|
||||||
|
field_domain_id=scene.field_domain_id,
|
||||||
|
shape_hw=scene.shape_hw,
|
||||||
|
)
|
||||||
|
flow = np.zeros((*scene.shape_hw, 2), dtype=np.float32)
|
||||||
|
flow[..., 0] = 1.0
|
||||||
|
frame = adapter.snapshot(
|
||||||
|
FakePacket(flow=flow),
|
||||||
|
_identity(),
|
||||||
|
ExpectedGeometry("box", "rev", scene.geometry_id, scene.field_domain_id, scene.shape_hw),
|
||||||
|
)
|
||||||
|
assert frame is not None and frame.scalar_descriptor == FLOW_MAGNITUDE
|
||||||
|
assert np.allclose(np.asarray(frame.scalar), 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_device_mask_wins_over_the_finiteness_test(scene):
|
||||||
|
adapter = BoxFrameAdapter(
|
||||||
|
geometry_id=scene.geometry_id,
|
||||||
|
field_domain_id=scene.field_domain_id,
|
||||||
|
shape_hw=scene.shape_hw,
|
||||||
|
)
|
||||||
|
depth = np.ones(scene.shape_hw, dtype=np.float32)
|
||||||
|
mask = np.ones(scene.shape_hw, dtype=bool)
|
||||||
|
mask[0, 0] = False
|
||||||
|
frame = adapter.snapshot(
|
||||||
|
FakePacket(depth=depth, valid_mask=mask),
|
||||||
|
_identity(),
|
||||||
|
ExpectedGeometry("box", "rev", scene.geometry_id, scene.field_domain_id, scene.shape_hw),
|
||||||
|
)
|
||||||
|
assert frame is not None
|
||||||
|
assert not bool(np.asarray(frame.valid)[0, 0])
|
||||||
|
assert np.asarray(frame.valid).sum() == mask.sum()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_box_adapter_refuses_a_device_with_another_domain(scene):
|
||||||
|
adapter = BoxFrameAdapter(
|
||||||
|
geometry_id=scene.geometry_id,
|
||||||
|
field_domain_id=scene.field_domain_id,
|
||||||
|
shape_hw=scene.shape_hw,
|
||||||
|
)
|
||||||
|
frame = adapter.snapshot(
|
||||||
|
FakePacket(depth=np.ones(scene.shape_hw, dtype=np.float32)),
|
||||||
|
_identity(),
|
||||||
|
ExpectedGeometry("box", "rev", scene.geometry_id, scene.field_domain_id, (9, 9)),
|
||||||
|
)
|
||||||
|
assert frame is None
|
||||||
|
assert adapter.last_diagnostic is not None
|
||||||
|
assert adapter.last_diagnostic.code == "field_domain_mismatch"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_box_adapter_with_nothing_usable_says_what_it_saw(scene):
|
||||||
|
adapter = BoxFrameAdapter(
|
||||||
|
geometry_id=scene.geometry_id,
|
||||||
|
field_domain_id=scene.field_domain_id,
|
||||||
|
shape_hw=scene.shape_hw,
|
||||||
|
)
|
||||||
|
frame = adapter.snapshot(
|
||||||
|
FakePacket(depth=np.ones((5, 5), dtype=np.float32)),
|
||||||
|
_identity(),
|
||||||
|
ExpectedGeometry("box", "rev", scene.geometry_id, scene.field_domain_id, scene.shape_hw),
|
||||||
|
)
|
||||||
|
assert frame is None
|
||||||
|
assert adapter.last_diagnostic is not None
|
||||||
|
assert adapter.last_diagnostic.details["depth"] == "5x5"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_published_frame_must_not_share_memory_with_the_packet(scene):
|
||||||
|
"""The producer keeps writing its arrays; the frame must not alias them."""
|
||||||
|
adapter = BoxFrameAdapter(
|
||||||
|
geometry_id=scene.geometry_id,
|
||||||
|
field_domain_id=scene.field_domain_id,
|
||||||
|
shape_hw=scene.shape_hw,
|
||||||
|
)
|
||||||
|
depth = np.zeros(scene.shape_hw, dtype=np.float32)
|
||||||
|
frame = adapter.snapshot(
|
||||||
|
FakePacket(depth=depth),
|
||||||
|
_identity(),
|
||||||
|
ExpectedGeometry("box", "rev", scene.geometry_id, scene.field_domain_id, scene.shape_hw),
|
||||||
|
)
|
||||||
|
assert frame is not None
|
||||||
|
assert not frame.scalar.flags["WRITEABLE"]
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
np.asarray(frame.scalar)[0, 0] = 5.0
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
"""Stage 3 acceptance for the frame schedule.
|
||||||
|
|
||||||
|
No Qt here on purpose: the coordinator's contract is about *threads and identity*,
|
||||||
|
so a fake widget that records calls is a better witness than a real one. The rows
|
||||||
|
that matter are the ones a screen cannot show you: a stale frame never overwriting a
|
||||||
|
new scene, only one compose in flight, and a producer that never waits.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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 SceneComposer
|
||||||
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
|
DisplaySettings,
|
||||||
|
FieldDescriptor,
|
||||||
|
FrameIdentity,
|
||||||
|
SurfaceFieldFrame,
|
||||||
|
freeze,
|
||||||
|
)
|
||||||
|
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWidget:
|
||||||
|
"""Records what the coordinator asks of the view."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.scenes = []
|
||||||
|
self.frames = []
|
||||||
|
self.settings = []
|
||||||
|
self.superseded = []
|
||||||
|
self.visible = True
|
||||||
|
|
||||||
|
def set_scene(self, assets) -> None:
|
||||||
|
self.scenes.append(assets)
|
||||||
|
|
||||||
|
def present(self, frame) -> None:
|
||||||
|
self.frames.append(frame)
|
||||||
|
|
||||||
|
def set_settings(self, settings) -> None:
|
||||||
|
self.settings.append(settings)
|
||||||
|
|
||||||
|
def set_superseded_frames(self, count: int) -> None:
|
||||||
|
self.superseded.append(int(count))
|
||||||
|
|
||||||
|
def setVisible(self, visible: bool) -> None: # noqa: N802 - Qt spelling
|
||||||
|
self.visible = bool(visible)
|
||||||
|
|
||||||
|
|
||||||
|
class CountingComposer(SceneComposer):
|
||||||
|
"""A composer that counts calls and can be made slow, like a loaded CPU."""
|
||||||
|
|
||||||
|
def __init__(self, delay_s: float = 0.0) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.calls = 0
|
||||||
|
self.delay_s = delay_s
|
||||||
|
self.in_flight = 0
|
||||||
|
self.max_in_flight = 0
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
def compose(self, assets, frame, settings):
|
||||||
|
with self._lock:
|
||||||
|
self.calls += 1
|
||||||
|
self.in_flight += 1
|
||||||
|
self.max_in_flight = max(self.max_in_flight, self.in_flight)
|
||||||
|
try:
|
||||||
|
if self.delay_s:
|
||||||
|
time.sleep(self.delay_s)
|
||||||
|
return super().compose(assets, frame, settings)
|
||||||
|
finally:
|
||||||
|
with self._lock:
|
||||||
|
self.in_flight -= 1
|
||||||
|
|
||||||
|
|
||||||
|
def _frame(scene, *, generation=1, source="live-A", session=1, frame_id=1, value=1.0,
|
||||||
|
domain=None, valid=True):
|
||||||
|
scalar = np.full(scene.shape_hw, value, dtype=np.float32)
|
||||||
|
mask = np.full(scene.shape_hw, valid, dtype=bool)
|
||||||
|
return SurfaceFieldFrame(
|
||||||
|
identity=FrameIdentity(source, session, frame_id, 0.1 * frame_id, generation),
|
||||||
|
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(mask, dtype=np.bool_),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def scene():
|
||||||
|
return synthetic.poc_scene()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def pool():
|
||||||
|
executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix="compose")
|
||||||
|
yield executor
|
||||||
|
executor.shutdown(wait=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _drain(coordinator, ticks=50, sleep=0.005):
|
||||||
|
for _ in range(ticks):
|
||||||
|
coordinator.tick()
|
||||||
|
time.sleep(sleep)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- state machine
|
||||||
|
def test_the_view_starts_disabled_and_ignores_frames(scene, pool):
|
||||||
|
widget = FakeWidget()
|
||||||
|
coordinator = ThreeDCoordinator(widget, SceneComposer(), pool)
|
||||||
|
assert coordinator.state == "disabled"
|
||||||
|
assert coordinator.submit(_frame(scene)) == "disabled"
|
||||||
|
coordinator.tick()
|
||||||
|
assert widget.frames == []
|
||||||
|
coordinator.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_activating_a_scene_bumps_the_generation_and_shows_the_reference_model(scene, pool):
|
||||||
|
widget = FakeWidget()
|
||||||
|
coordinator = ThreeDCoordinator(widget, SceneComposer(), pool)
|
||||||
|
coordinator.set_visible(True)
|
||||||
|
first = coordinator.activate(scene, "live-A")
|
||||||
|
assert first == 1
|
||||||
|
assert coordinator.generation == 1
|
||||||
|
assert widget.scenes == [scene]
|
||||||
|
assert len(widget.frames) == 1, "the reference model must be shown immediately"
|
||||||
|
assert widget.frames[0].scalar_descriptor.quantity == "unavailable"
|
||||||
|
second = coordinator.activate(scene, "live-B")
|
||||||
|
assert second == 2 and coordinator.generation == 2
|
||||||
|
# A frame from the previous scene is now old news.
|
||||||
|
assert coordinator.submit(_frame(scene, generation=1, source="live-A")) == "stale"
|
||||||
|
assert coordinator.stale_frames == 1
|
||||||
|
coordinator.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_frame_from_another_session_is_stale_too(scene, pool):
|
||||||
|
coordinator = ThreeDCoordinator(FakeWidget(), SceneComposer(), pool)
|
||||||
|
coordinator.set_visible(True)
|
||||||
|
coordinator.activate(scene, "live-A")
|
||||||
|
assert coordinator.submit(_frame(scene, session=1)) == "accepted"
|
||||||
|
assert coordinator.submit(_frame(scene, session=2)) == "stale"
|
||||||
|
assert coordinator.stale_frames == 1
|
||||||
|
coordinator.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_frame_on_another_domain_is_invalid_and_says_why(scene, pool):
|
||||||
|
coordinator = ThreeDCoordinator(FakeWidget(), SceneComposer(), pool)
|
||||||
|
coordinator.set_visible(True)
|
||||||
|
coordinator.activate(scene, "live-A")
|
||||||
|
result = coordinator.submit(_frame(scene, domain="somebody-elses-domain"))
|
||||||
|
assert result == "invalid"
|
||||||
|
assert coordinator.invalid_frames == 1
|
||||||
|
assert coordinator.last_diagnostic is not None
|
||||||
|
assert coordinator.last_diagnostic.code == "field_domain_mismatch"
|
||||||
|
coordinator.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_newest_frame_replaces_the_one_that_was_never_composed(scene, pool):
|
||||||
|
composer = CountingComposer(delay_s=0.05)
|
||||||
|
coordinator = ThreeDCoordinator(FakeWidget(), composer, pool)
|
||||||
|
coordinator.set_visible(True)
|
||||||
|
coordinator.activate(scene, "live-A")
|
||||||
|
results = [coordinator.submit(_frame(scene, frame_id=index)) for index in range(5)]
|
||||||
|
assert results[0] == "accepted"
|
||||||
|
assert results[1:] == ["replaced"] * 4
|
||||||
|
assert coordinator.superseded_frames == 4, (
|
||||||
|
"display drops must be counted, and separately from recording drops"
|
||||||
|
)
|
||||||
|
coordinator.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------- the schedule
|
||||||
|
def test_a_tick_composes_and_presents(scene, pool):
|
||||||
|
widget = FakeWidget()
|
||||||
|
coordinator = ThreeDCoordinator(widget, SceneComposer(), pool)
|
||||||
|
coordinator.set_visible(True)
|
||||||
|
coordinator.activate(scene, "live-A")
|
||||||
|
coordinator.submit(_frame(scene, value=3.0))
|
||||||
|
_drain(coordinator)
|
||||||
|
assert len(widget.frames) >= 2
|
||||||
|
last = widget.frames[-1]
|
||||||
|
assert np.allclose(np.asarray(last.scalar), 3.0)
|
||||||
|
assert coordinator.state == "live"
|
||||||
|
coordinator.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_frame_with_nothing_valid_is_no_data_not_a_stale_picture(scene, pool):
|
||||||
|
widget = FakeWidget()
|
||||||
|
coordinator = ThreeDCoordinator(widget, SceneComposer(), pool)
|
||||||
|
coordinator.set_visible(True)
|
||||||
|
coordinator.activate(scene, "live-A")
|
||||||
|
coordinator.submit(_frame(scene, valid=False))
|
||||||
|
_drain(coordinator)
|
||||||
|
assert coordinator.state == "no_data"
|
||||||
|
assert not np.asarray(widget.frames[-1].valid).any()
|
||||||
|
assert np.isnan(np.asarray(widget.frames[-1].scalar)).all()
|
||||||
|
coordinator.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_one_compose_is_in_flight_however_fast_the_ticks_come(scene, pool):
|
||||||
|
"""A slow composer makes frames drop; it must not make the queue grow."""
|
||||||
|
composer = CountingComposer(delay_s=0.02)
|
||||||
|
coordinator = ThreeDCoordinator(FakeWidget(), composer, pool)
|
||||||
|
coordinator.set_visible(True)
|
||||||
|
coordinator.activate(scene, "live-A")
|
||||||
|
for index in range(200):
|
||||||
|
coordinator.submit(_frame(scene, frame_id=index))
|
||||||
|
coordinator.tick() # no sleep: ticks as fast as the GUI would
|
||||||
|
_drain(coordinator, ticks=40, sleep=0.01)
|
||||||
|
assert composer.max_in_flight == 1, (
|
||||||
|
f"{composer.max_in_flight} composes ran at once: the schedule is queueing"
|
||||||
|
)
|
||||||
|
assert composer.calls <= 60, f"{composer.calls} composes for 200 frames"
|
||||||
|
coordinator.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_busy_display_never_slows_the_producer(scene, pool):
|
||||||
|
"""The acceptance row: submit stays fast while the composer is saturated."""
|
||||||
|
composer = CountingComposer(delay_s=0.05)
|
||||||
|
coordinator = ThreeDCoordinator(FakeWidget(), composer, pool)
|
||||||
|
coordinator.set_visible(True)
|
||||||
|
coordinator.activate(scene, "live-A")
|
||||||
|
coordinator.submit(_frame(scene, frame_id=0))
|
||||||
|
coordinator.tick() # start a slow compose
|
||||||
|
timings = []
|
||||||
|
for index in range(200):
|
||||||
|
started = time.perf_counter()
|
||||||
|
coordinator.submit(_frame(scene, frame_id=index + 1))
|
||||||
|
timings.append((time.perf_counter() - started) * 1000.0)
|
||||||
|
timings.sort()
|
||||||
|
p99 = timings[int(len(timings) * 0.99) - 1]
|
||||||
|
assert p99 < 1.0, f"submit p99 was {p99:.3f} ms while the composer was busy"
|
||||||
|
coordinator.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_result_that_arrives_after_a_scene_change_is_dropped(scene, pool):
|
||||||
|
"""The compose that was running when the operator switched scenes is worthless."""
|
||||||
|
composer = CountingComposer(delay_s=0.05)
|
||||||
|
widget = FakeWidget()
|
||||||
|
coordinator = ThreeDCoordinator(widget, composer, pool)
|
||||||
|
coordinator.set_visible(True)
|
||||||
|
coordinator.activate(scene, "live-A")
|
||||||
|
coordinator.submit(_frame(scene, value=9.0))
|
||||||
|
coordinator.tick() # starts the slow compose for generation 1
|
||||||
|
coordinator.activate(scene, "live-A") # generation 2 while it runs
|
||||||
|
before = len(widget.frames)
|
||||||
|
_drain(coordinator, ticks=20, sleep=0.02)
|
||||||
|
composed = [frame for frame in widget.frames[before:] if np.isfinite(np.asarray(frame.scalar)).any()]
|
||||||
|
assert not composed, "a frame composed for the previous scene was presented"
|
||||||
|
assert coordinator.stale_frames >= 0
|
||||||
|
coordinator.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_hiding_suspends_without_touching_the_scene(scene, pool):
|
||||||
|
widget = FakeWidget()
|
||||||
|
coordinator = ThreeDCoordinator(widget, SceneComposer(), pool)
|
||||||
|
coordinator.set_visible(True)
|
||||||
|
coordinator.activate(scene, "live-A")
|
||||||
|
coordinator.submit(_frame(scene))
|
||||||
|
_drain(coordinator, ticks=10)
|
||||||
|
presented = len(widget.frames)
|
||||||
|
coordinator.set_visible(False)
|
||||||
|
assert coordinator.state == "suspended"
|
||||||
|
assert widget.visible is False
|
||||||
|
assert coordinator.submit(_frame(scene)) == "disabled"
|
||||||
|
coordinator.tick()
|
||||||
|
assert len(widget.frames) == presented, "a hidden view was still being painted"
|
||||||
|
coordinator.set_visible(True)
|
||||||
|
assert coordinator.state == "ready"
|
||||||
|
coordinator.submit(_frame(scene, frame_id=42))
|
||||||
|
_drain(coordinator, ticks=10)
|
||||||
|
assert len(widget.frames) > presented
|
||||||
|
coordinator.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_changing_the_gain_recomposes_what_is_on_screen(scene, pool):
|
||||||
|
composer = CountingComposer()
|
||||||
|
coordinator = ThreeDCoordinator(FakeWidget(), composer, pool)
|
||||||
|
coordinator.set_visible(True)
|
||||||
|
coordinator.activate(scene, "live-A")
|
||||||
|
coordinator.submit(_frame(scene))
|
||||||
|
_drain(coordinator, ticks=10)
|
||||||
|
settled = composer.calls
|
||||||
|
# A colour-scale change needs no new composition...
|
||||||
|
coordinator.set_settings(DisplaySettings(max_fps=30))
|
||||||
|
_drain(coordinator, ticks=5)
|
||||||
|
assert composer.calls == settled
|
||||||
|
# ...but a gain change does: the positions depend on it.
|
||||||
|
coordinator.set_settings(DisplaySettings(deform_enabled=True, display_gain=5.0))
|
||||||
|
_drain(coordinator, ticks=10)
|
||||||
|
assert composer.calls > settled, "the new gain was never applied"
|
||||||
|
coordinator.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_close_is_idempotent_and_stops_everything(scene, pool):
|
||||||
|
widget = FakeWidget()
|
||||||
|
coordinator = ThreeDCoordinator(widget, SceneComposer(), pool)
|
||||||
|
coordinator.set_visible(True)
|
||||||
|
coordinator.activate(scene, "live-A")
|
||||||
|
coordinator.submit(_frame(scene))
|
||||||
|
_drain(coordinator, ticks=5)
|
||||||
|
coordinator.close()
|
||||||
|
coordinator.close() # idempotent
|
||||||
|
assert coordinator.state == "disabled"
|
||||||
|
assert coordinator.submit(_frame(scene)) == "disabled"
|
||||||
|
presented = len(widget.frames)
|
||||||
|
coordinator.tick()
|
||||||
|
assert len(widget.frames) == presented
|
||||||
@@ -88,21 +88,20 @@ def test_the_widget_holds_cpu_state_and_touches_no_context(qt_app):
|
|||||||
widget.deleteLater()
|
widget.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
def test_the_stubs_name_the_stage_that_fills_them_in(qt_app):
|
def test_the_remaining_stub_says_which_stage_fills_it_in(qt_app):
|
||||||
"""What is implemented is exercised elsewhere; what is not says so.
|
"""What is implemented is exercised elsewhere; what is not says so.
|
||||||
|
|
||||||
Stage 1 landed the camera, the dispatcher and the renderer (see
|
Stages 1 to 3 landed the camera, the dispatcher, the renderer, the sampler, the
|
||||||
``test_scene3d_stage1.py`` and ``test_scene3d_gl_smoke.py``). The remaining
|
composer, the schedule and the adapters. ``ThreeDPanel`` is the one piece left,
|
||||||
placeholders must still fail loudly with the stage that owns them rather than
|
and it must still fail loudly with the stage that owns it rather than quietly
|
||||||
quietly doing nothing.
|
doing nothing.
|
||||||
"""
|
"""
|
||||||
from orisys_app_shared.scene3d.contracts import (
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
CameraPreset,
|
CameraPreset,
|
||||||
DisplaySettings,
|
DisplaySettings,
|
||||||
ExpectedGeometry,
|
|
||||||
FrameIdentity,
|
FrameIdentity,
|
||||||
|
RenderStats,
|
||||||
)
|
)
|
||||||
from orisys_studio.scene3d.adapters import BoxFrameAdapter, FingerFrameAdapter
|
|
||||||
from orisys_studio.scene3d.camera import OrbitCamera
|
from orisys_studio.scene3d.camera import OrbitCamera
|
||||||
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
|
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
|
||||||
from orisys_studio.scene3d.gl_backend import GLRenderer
|
from orisys_studio.scene3d.gl_backend import GLRenderer
|
||||||
@@ -115,34 +114,26 @@ def test_the_stubs_name_the_stage_that_fills_them_in(qt_app):
|
|||||||
coordinator = ThreeDCoordinator(widget, composer=object(), executor=None, max_fps=30) # type: ignore[arg-type]
|
coordinator = ThreeDCoordinator(widget, composer=object(), executor=None, max_fps=30) # type: ignore[arg-type]
|
||||||
panel = ThreeDPanel(coordinator, widget)
|
panel = ThreeDPanel(coordinator, widget)
|
||||||
try:
|
try:
|
||||||
assert coordinator.state == "disabled"
|
|
||||||
assert coordinator.generation == 0
|
|
||||||
assert coordinator.superseded_frames == 0 # the mailbox is real, not a stub
|
|
||||||
|
|
||||||
# Stage 1 is real: the camera works with no context at all.
|
# Stage 1 is real: the camera works with no context at all.
|
||||||
camera.set_preset(
|
camera.set_preset(
|
||||||
CameraPreset("default", (0.0, 0.0, 1.0), (0.0, 0.0, 0.0), (0.0, 1.0, 0.0), 0.7)
|
CameraPreset("default", (0.0, 0.0, 1.0), (0.0, 0.0, 0.0), (0.0, 1.0, 0.0), 0.7)
|
||||||
)
|
)
|
||||||
assert camera.matrices((100, 100)).view.shape == (4, 4)
|
assert camera.matrices((100, 100)).view.shape == (4, 4)
|
||||||
|
|
||||||
|
# Stage 3 is real too: the coordinator's schedule exists with no Qt event.
|
||||||
|
assert coordinator.state == "disabled"
|
||||||
|
assert coordinator.generation == 0
|
||||||
|
assert coordinator.superseded_frames == 0
|
||||||
|
coordinator.close() # idempotent, and safe before any scene is active
|
||||||
|
|
||||||
for call, stage in (
|
for call, stage in (
|
||||||
(lambda: coordinator.activate(None, "live-A"), "stage 3"),
|
|
||||||
(lambda: coordinator.set_settings(DisplaySettings()), "stage 3"),
|
|
||||||
(lambda: coordinator.set_visible(True), "stage 3"),
|
|
||||||
(lambda: coordinator.close(), "stage 3"),
|
|
||||||
(lambda: panel.set_state("ready"), "stage 4"),
|
(lambda: panel.set_state("ready"), "stage 4"),
|
||||||
(lambda: BoxFrameAdapter().snapshot(None, FrameIdentity("a", 1, 0, 0.0, 1), None),
|
(lambda: panel.set_render_stats(RenderStats()), "stage 4"),
|
||||||
"stage 3"),
|
(lambda: panel.set_diagnostic(None), "stage 4"),
|
||||||
(
|
|
||||||
lambda: FingerFrameAdapter().snapshot(
|
|
||||||
None, None, FrameIdentity("a", 1, 0, 0.0, 1)
|
|
||||||
),
|
|
||||||
"stage 3",
|
|
||||||
),
|
|
||||||
):
|
):
|
||||||
with pytest.raises(NotImplementedError) as excinfo:
|
with pytest.raises(NotImplementedError) as excinfo:
|
||||||
call()
|
call()
|
||||||
assert stage in str(excinfo.value), (stage, str(excinfo.value))
|
assert stage in str(excinfo.value), (stage, str(excinfo.value))
|
||||||
del ExpectedGeometry
|
del DisplaySettings, FrameIdentity
|
||||||
finally:
|
finally:
|
||||||
widget.deleteLater()
|
widget.deleteLater()
|
||||||
|
|||||||
@@ -121,22 +121,40 @@ sha256 + 原子写入 + 读取时先验签再相信)、两个工具(`validat
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 阶段 3:实时数据(字段 → 渲染帧)
|
## 阶段 3:实时数据(字段 → 渲染帧)—— 已完成
|
||||||
|
|
||||||
**做**:`FingerFrameAdapter`/`BoxFrameAdapter`、`SurfaceSampler`、`SceneComposer`
|
**做**:`sampling.SurfaceSampler`(向量化双线性采样、无效传播、`remap_to_domain` 走 unroll 映射)、
|
||||||
(`compose`/`rest_frame`)、`ThreeDCoordinator` 的双 mailbox 与 generation/过期拒绝、`submit` 的
|
`composer.SceneComposer`(采样 → 位移 → 重算法线;`rest_frame`/`build_rest_frame`)、
|
||||||
`accepted/replaced/stale/disabled/invalid` 语义。
|
`ThreeDCoordinator`(双容量 1 mailbox、generation/会话/来源过期拒绝、单飞行合成、可见性与设置)、
|
||||||
|
`adapters`(Finger/Box:`|flow|`、depth 响应、域检查与诊断,**不造位移**)。
|
||||||
|
|
||||||
**验收**:
|
**验收(已通过)**:`shared/tests/test_scene3d_live.py` 25 项、`apps/studio/tests/test_scene3d_coordinator.py` 13 项、
|
||||||
|
`test_scene3d_adapters.py` 13 项;全部无需 GPU。
|
||||||
|
|
||||||
| 用例 | 通过标准 |
|
| 用例 | 结果 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 采样正确性 | 常量场/斜坡/单点脉冲/NaN/遮罩场与 CPU 参考实现逐位一致 |
|
| 采样正确性 | 常量/斜坡/单点脉冲与参考逐位一致;四点双线性与手算一致;2 万顶点采样 < 150 ms |
|
||||||
| 无效语义 | 任一正权重点无效 ⇒ 该顶点无效;非有限 scalar 不显示为 0;参考帧 `scalar` 全不可用 |
|
| 无效语义 | 任一**正权** tap 无效 ⇒ 顶点无效且值为 NaN(不是 0);未用 tap 不参与也不致无效;非有限 scalar 同样无效;**0.0 是有效值** |
|
||||||
| 色标语义 | `display_gain` 只改显示位置:增大倍率前后 `scalar` 与力值逐位不变 |
|
| 色标语义 | `display_gain` 1→50 后 `scalar`/`valid` 逐位不变,只有位置变(示例 1 mm → 50 mm) |
|
||||||
| 过期帧 | 旧 `generation`/`request_id` 永远不能覆盖新场景;`stale_frame` 安静丢弃并计数 |
|
| 示意形变标注 | `approximate` 位移产生 `display_illustrative`(info,含「×N」「illustration」);`calibrated` 不产生;无位移时着色并报 `field_unavailable` |
|
||||||
| 不阻塞采集 | 合成人为放慢 10 倍,`submit` 调用耗时 p99 < 1 ms,记录行数不变 |
|
| 法线 | 从**形变后**位置重算;凸起处斜坡倾斜、顶点保持竖直、反向位移使斜坡反向倾斜;两条对角线顶点(与凸起不相邻)保持不变 |
|
||||||
| 缺数据 | 无字段 → `no_data`,清掉旧测量颜色,允许显示参考模型 |
|
| 过期帧 | 旧 generation/来源/会话一律 `stale` 并计数;场景切换时正在跑的那次合成结果被丢弃 |
|
||||||
|
| 不阻塞采集 | 合成放慢到 50 ms 时 `submit` p99 **< 1 ms**;200 帧只发生 ≤60 次合成;`max_in_flight == 1`(队列不会增长) |
|
||||||
|
| 缺数据 | 无有效数据 → `no_data`,且呈现参考模型(scalar 全 NaN、`valid` 全假) |
|
||||||
|
| 适配器 | 域不匹配时**发布空**并报出所见的两个形状;带 map 时按映射 gather,空洞为 NaN 且无效;结果只读、不与 packet 共享内存 |
|
||||||
|
|
||||||
|
**这一阶段抓到的**:
|
||||||
|
|
||||||
|
1. **换倍率需要"当前那一帧"**:原来用 `incoming.peek()` 重合成,而那一帧已被消费 → 倍率改了画面不变。
|
||||||
|
现在协调器保留 `_last_field_frame`,倍率变化就重画**屏幕上那一帧**(而不是跳到一个更新的瞬间)。
|
||||||
|
2. `display_illustrative` 不在诊断码表里 → 被默认成 `error`;已作为 `info` 加入码表。
|
||||||
|
3. **对称凸起的顶点法线是竖直的**(我第一版测试断言它倾斜,等于断言一个 bug);现在断言斜坡倾斜、顶点竖直,
|
||||||
|
并且"与凸起不相邻的对角顶点不变"。
|
||||||
|
4. `assets.py` 里还留着与 `package.py`/`legacy.py` 同名的**桩类**——阶段 2 实现后它们就成了会误导集成者的重复定义;
|
||||||
|
现在 `assets.py` 是门面(re-export + `SceneCompiler`,加密请求明确拒绝并指向阶段 5)。
|
||||||
|
|
||||||
|
**明确未做**:手势/面板(阶段 4);**示意位移的适配器接入**留到阶段 4——需要 SDK 的 lift 与一个已确认的尺度,
|
||||||
|
现在适配器一律 `displacement=None`(宁可只着色,也不给未标定的毫米)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
"""Assets: loading a validated scene, importing the legacy finger export.
|
"""Assets: the one place a caller should look for loading and compiling a scene.
|
||||||
|
|
||||||
Stage 2 in ``docs/architecture/studio-3d-stages.md``. The signatures are fixed
|
Stage 2 in ``docs/architecture/studio-3d-stages.md``. The implementations live in
|
||||||
here so the rest of the skeleton (and the compiler tool) can be written against
|
:mod:`~orisys_app_shared.scene3d.legacy` (the Blender export),
|
||||||
them; the bodies arrive with the stage.
|
:mod:`~orisys_app_shared.scene3d.glb` (the housing model) and
|
||||||
|
:mod:`~orisys_app_shared.scene3d.package` (the ``.oryscene`` container); this module
|
||||||
|
is their public face, so an integrator does not have to know which file holds what.
|
||||||
|
|
||||||
Two rules the implementation must keep, because violating them is invisible until
|
Two rules the implementation keeps, because violating them is invisible until a
|
||||||
a customer sees the wrong part move:
|
customer sees the wrong part move:
|
||||||
|
|
||||||
* the algorithm mesh and the display mesh are **different topologies** (1032 vs
|
* the algorithm mesh and the display mesh are **different topologies** (1032 vs
|
||||||
1005 vertices) and are never joined by array index;
|
1005 vertices) and are never joined by array index;
|
||||||
@@ -17,69 +19,56 @@ a customer sees the wrong part move:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from orisys_app_shared.scene3d.contracts import (
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
CompileOptions,
|
CompileOptions,
|
||||||
CompileReport,
|
CompileReport,
|
||||||
Diagnostic,
|
Diagnostic,
|
||||||
ExpectedGeometry,
|
ExpectedGeometry,
|
||||||
F64,
|
|
||||||
KeyProvider,
|
KeyProvider,
|
||||||
SceneAssets,
|
SceneAssets,
|
||||||
|
error,
|
||||||
|
validate_assets,
|
||||||
|
)
|
||||||
|
from orisys_app_shared.scene3d.glb import load_housing_glb, with_housing
|
||||||
|
from orisys_app_shared.scene3d.legacy import LegacyFingerImporter
|
||||||
|
from orisys_app_shared.scene3d.package import (
|
||||||
|
ENVELOPE_VERSION,
|
||||||
|
MAGIC,
|
||||||
|
SCHEMA_VERSION,
|
||||||
|
PackagedAssetLoader,
|
||||||
|
PackageContents,
|
||||||
|
build_manifest,
|
||||||
|
describe_package,
|
||||||
|
read_package,
|
||||||
|
scene_from_contents,
|
||||||
|
write_package,
|
||||||
)
|
)
|
||||||
|
|
||||||
_STAGE = "stage 2 (asset contracts)"
|
#: Bumped when the container layout changes incompatibly.
|
||||||
|
__all__ = [
|
||||||
|
"ENVELOPE_VERSION",
|
||||||
class PackagedAssetLoader:
|
"MAGIC",
|
||||||
"""Load and authenticate a ``.oryscene`` runtime package.
|
"SCHEMA_VERSION",
|
||||||
|
"LegacyFingerImporter",
|
||||||
CPU/IO thread. Verification order: envelope signature, then entry hashes, then
|
"PackagedAssetLoader",
|
||||||
geometry/domain/unit checks against ``expected``. There is no plaintext
|
"PackageContents",
|
||||||
fallback: a package that fails authentication is refused, never retried.
|
"SceneCompiler",
|
||||||
"""
|
"build_manifest",
|
||||||
|
"describe_package",
|
||||||
def load(
|
"read_package",
|
||||||
self,
|
"scene_from_contents",
|
||||||
path: Path,
|
"write_package",
|
||||||
expected: ExpectedGeometry,
|
]
|
||||||
keys: Optional[KeyProvider] = None,
|
|
||||||
) -> SceneAssets:
|
|
||||||
raise NotImplementedError(f"{_STAGE}: load a signed .oryscene package")
|
|
||||||
|
|
||||||
def validate(
|
|
||||||
self, assets: SceneAssets, expected: ExpectedGeometry
|
|
||||||
) -> tuple[Diagnostic, ...]:
|
|
||||||
raise NotImplementedError(f"{_STAGE}: validate a loaded scene")
|
|
||||||
|
|
||||||
|
|
||||||
class LegacyFingerImporter:
|
|
||||||
"""Convert ``sensors/finger/assets/30fps-0703`` into a scene (offline only).
|
|
||||||
|
|
||||||
``meters_per_source_unit`` scales source lengths into metres first;
|
|
||||||
``transform_sensor_from_source`` is then rigid (rotation and a translation in
|
|
||||||
metres, no extra scale). Normals receive the rotation only. Missing coordinate
|
|
||||||
evidence is a refusal, not a guess from a filename.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def convert(
|
|
||||||
self,
|
|
||||||
export_dir: Path,
|
|
||||||
map_path: Path,
|
|
||||||
*,
|
|
||||||
transform_sensor_from_source: F64,
|
|
||||||
meters_per_source_unit: float,
|
|
||||||
) -> SceneAssets:
|
|
||||||
raise NotImplementedError(f"{_STAGE}: import the legacy NPZ export")
|
|
||||||
|
|
||||||
|
|
||||||
class SceneCompiler:
|
class SceneCompiler:
|
||||||
"""Package a scene (and an optional housing GLB) for release (offline).
|
"""Package a scene (and an optional housing GLB) for release. Offline only.
|
||||||
|
|
||||||
Signing happens with a key from the build environment; the private key is
|
The signing and encryption that :class:`CompileOptions` describes are stage 5:
|
||||||
never part of the client runtime. Output is written to a temporary file and
|
until then a request for an encrypted package is *refused*, rather than
|
||||||
atomically renamed, so a failed build cannot leave a half package behind.
|
quietly producing a plain one that a release script would ship as protected.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def compile(
|
def compile(
|
||||||
@@ -89,4 +78,77 @@ class SceneCompiler:
|
|||||||
output: Path,
|
output: Path,
|
||||||
options: CompileOptions,
|
options: CompileOptions,
|
||||||
) -> CompileReport:
|
) -> CompileReport:
|
||||||
raise NotImplementedError(f"{_STAGE}: compile a runtime package")
|
if options.encryption_key_id:
|
||||||
|
raise error(
|
||||||
|
"asset_auth_failed",
|
||||||
|
"encrypted packages are stage 5; refusing rather than writing a "
|
||||||
|
"plain package that claims to be protected",
|
||||||
|
key_id=str(options.encryption_key_id),
|
||||||
|
)
|
||||||
|
scene = surface
|
||||||
|
if housing_glb is not None:
|
||||||
|
from orisys_app_shared.scene3d.glb import HousingLimits
|
||||||
|
|
||||||
|
housing = load_housing_glb(
|
||||||
|
Path(housing_glb),
|
||||||
|
limits=HousingLimits(
|
||||||
|
max_triangles=int(options.max_housing_triangles),
|
||||||
|
max_vertices=max(int(options.max_surface_vertices), 1),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
scene = with_housing(surface, housing)
|
||||||
|
if int(options.max_surface_vertices) > 0:
|
||||||
|
sensing = scene.sensing_part()
|
||||||
|
count = int(sensing.positions_sensor_m.shape[0])
|
||||||
|
if count > int(options.max_surface_vertices):
|
||||||
|
raise error(
|
||||||
|
"gpu_budget_exceeded",
|
||||||
|
f"the surface has {count} vertices (limit "
|
||||||
|
f"{int(options.max_surface_vertices)}); export a lower LOD",
|
||||||
|
)
|
||||||
|
validate_assets(scene)
|
||||||
|
report = write_package(
|
||||||
|
scene,
|
||||||
|
Path(output),
|
||||||
|
sensor_model=options.expected.sensor_model,
|
||||||
|
hardware_revisions=(options.expected.hardware_revision,),
|
||||||
|
)
|
||||||
|
return report
|
||||||
|
|
||||||
|
def validate_report(
|
||||||
|
self, assets: SceneAssets, expected: ExpectedGeometry
|
||||||
|
) -> tuple[Diagnostic, ...]:
|
||||||
|
return validate_assets(assets, expected)
|
||||||
|
|
||||||
|
|
||||||
|
def load_or_import(
|
||||||
|
path: Path,
|
||||||
|
*,
|
||||||
|
expected: ExpectedGeometry,
|
||||||
|
keys: Optional[KeyProvider] = None,
|
||||||
|
legacy_map: Optional[Path] = None,
|
||||||
|
transform_source_from_sensor: Any = None,
|
||||||
|
meters_per_source_unit: Optional[float] = None,
|
||||||
|
) -> SceneAssets:
|
||||||
|
"""Load a package, or import a legacy directory, behind one entry point.
|
||||||
|
|
||||||
|
Keeping both paths reachable from one function is what lets the rest of the app
|
||||||
|
stop caring which era an asset came from — and the legacy path still refuses to
|
||||||
|
guess its units.
|
||||||
|
"""
|
||||||
|
path = Path(path)
|
||||||
|
if path.is_file():
|
||||||
|
return PackagedAssetLoader().load(path, expected, keys)
|
||||||
|
if legacy_map is None or meters_per_source_unit is None or transform_source_from_sensor is None:
|
||||||
|
raise error(
|
||||||
|
"asset_rejected",
|
||||||
|
"a legacy directory needs --map, --meters-per-source-unit and a transform",
|
||||||
|
)
|
||||||
|
assets = LegacyFingerImporter().convert(
|
||||||
|
path,
|
||||||
|
Path(legacy_map),
|
||||||
|
transform_sensor_from_source=transform_source_from_sensor,
|
||||||
|
meters_per_source_unit=float(meters_per_source_unit),
|
||||||
|
)
|
||||||
|
validate_assets(assets, expected)
|
||||||
|
return assets
|
||||||
|
|||||||
@@ -2,50 +2,187 @@
|
|||||||
|
|
||||||
Stage 3 in ``docs/architecture/studio-3d-stages.md``.
|
Stage 3 in ``docs/architecture/studio-3d-stages.md``.
|
||||||
|
|
||||||
This is the module that decides what a colour and a vertex position *mean*, so the
|
This module decides what a colour and a vertex position *mean*, so the rules from
|
||||||
rules from §3.2 of the design document are its reason to exist:
|
§3.2 of the design document are its reason to exist:
|
||||||
|
|
||||||
* ``scalar`` is never multiplied by the display gain — the colour scale describes
|
* ``scalar`` is never multiplied by the display gain. The colour scale describes the
|
||||||
the measurement, and the gain describes only how far the drawing is exaggerated;
|
measurement; the gain describes only how far the drawing is exaggerated. A gain
|
||||||
* a displacement whose quality is ``approximate`` is drawn as "relative
|
that changed the numbers would quietly change what the colour bar means.
|
||||||
deformation, ×N" and is never labelled in millimetres;
|
* a displacement whose quality is ``approximate`` is drawn as "relative deformation,
|
||||||
* without a trustworthy displacement the surface is coloured in place, and the
|
×N" and is never labelled in millimetres — the composer says so in a diagnostic
|
||||||
total force is never turned into a per-vertex height.
|
instead of leaving the UI to guess.
|
||||||
|
* without a trustworthy displacement the surface is coloured in place, and the total
|
||||||
|
force is never turned into a per-vertex height.
|
||||||
|
* a reference frame measures nothing: every validity flag is false and the scalar is
|
||||||
|
absent, so the UI hides the colour bar rather than leaving the previous one on
|
||||||
|
screen.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
from orisys_app_shared.scene3d.contracts import (
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
|
Diagnostic,
|
||||||
DisplaySettings,
|
DisplaySettings,
|
||||||
|
FieldDescriptor,
|
||||||
FrameIdentity,
|
FrameIdentity,
|
||||||
|
Quality,
|
||||||
RenderFrame,
|
RenderFrame,
|
||||||
SceneAssets,
|
SceneAssets,
|
||||||
SurfaceFieldFrame,
|
SurfaceFieldFrame,
|
||||||
|
diagnostic,
|
||||||
|
error,
|
||||||
|
freeze,
|
||||||
|
rest_descriptor,
|
||||||
|
validate_render_frame,
|
||||||
|
validate_surface_frame,
|
||||||
)
|
)
|
||||||
|
from orisys_app_shared.scene3d.legacy import vertex_normals
|
||||||
|
from orisys_app_shared.scene3d.sampling import SurfaceSampler
|
||||||
|
|
||||||
_STAGE = "stage 3 (live data)"
|
#: Reported when a display gain is applied to a displacement that is only
|
||||||
|
#: approximate. Informational: the picture is legitimate as an illustration, and
|
||||||
|
#: must not be read as a measured displacement.
|
||||||
|
DISPLAY_ILLUSTRATIVE = "display_illustrative"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ComposeStats:
|
||||||
|
"""What one compose cost, for the coordinator's statistics."""
|
||||||
|
|
||||||
|
sample_ms: float = 0.0
|
||||||
|
normals_ms: float = 0.0
|
||||||
|
total_ms: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
class SceneComposer:
|
class SceneComposer:
|
||||||
"""CPU worker: one field frame in, one render frame out.
|
"""CPU worker: one field frame in, one render frame out.
|
||||||
|
|
||||||
Runs off the GUI thread and never touches Qt or GL. Recomputes normals from the
|
Runs off the GUI thread and never touches Qt or GL. Normals are recomputed from
|
||||||
deformed positions rather than transforming the rest normals, because the
|
the deformed positions rather than transformed from the rest normals, because
|
||||||
difference is exactly the deformation the operator is looking at.
|
the difference between those two is exactly the deformation the operator is
|
||||||
|
looking at.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def __init__(self, sampler: Optional[SurfaceSampler] = None) -> None:
|
||||||
|
self._sampler = sampler or SurfaceSampler()
|
||||||
|
self.last_stats = ComposeStats()
|
||||||
|
|
||||||
def compose(
|
def compose(
|
||||||
self,
|
self,
|
||||||
assets: SceneAssets,
|
assets: SceneAssets,
|
||||||
frame: SurfaceFieldFrame,
|
frame: SurfaceFieldFrame,
|
||||||
settings: DisplaySettings,
|
settings: DisplaySettings,
|
||||||
) -> RenderFrame:
|
) -> RenderFrame:
|
||||||
raise NotImplementedError(f"{_STAGE}: compose a render frame")
|
import time
|
||||||
|
|
||||||
|
started = time.perf_counter()
|
||||||
|
self._check(assets, frame)
|
||||||
|
for item in validate_surface_frame(frame):
|
||||||
|
# A frame with, say, non-finite scalars marked valid is still usable:
|
||||||
|
# the vertex ends up invalid. Reported rather than refused.
|
||||||
|
if item.severity == "error":
|
||||||
|
raise error(item.code, item.message, **dict(item.details))
|
||||||
|
part = assets.sensing_part()
|
||||||
|
sampling_started = time.perf_counter()
|
||||||
|
fields = self._sampler.sample(frame, assets.binding)
|
||||||
|
self.last_stats.sample_ms = (time.perf_counter() - sampling_started) * 1000.0
|
||||||
|
rest_positions = np.asarray(part.positions_sensor_m, dtype=np.float64)
|
||||||
|
diagnostics: list[Diagnostic] = []
|
||||||
|
|
||||||
|
positions = rest_positions
|
||||||
|
quality = frame.displacement_quality
|
||||||
|
if settings.deform_enabled and fields.displacement_sensor_m is not None:
|
||||||
|
gain = float(settings.display_gain)
|
||||||
|
displacement = np.asarray(fields.displacement_sensor_m, dtype=np.float64)
|
||||||
|
positions = rest_positions + gain * displacement
|
||||||
|
if quality != "calibrated":
|
||||||
|
diagnostics.append(
|
||||||
|
diagnostic(
|
||||||
|
DISPLAY_ILLUSTRATIVE,
|
||||||
|
"the displayed deformation is an illustration (×"
|
||||||
|
f"{gain:g}), not a measured displacement",
|
||||||
|
quality=str(quality or "unspecified"),
|
||||||
|
gain=f"{gain:g}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
elif settings.deform_enabled and fields.displacement_sensor_m is None:
|
||||||
|
diagnostics.append(
|
||||||
|
diagnostic(
|
||||||
|
"field_unavailable",
|
||||||
|
"no displacement is available for this frame: colouring in place",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
normals_started = time.perf_counter()
|
||||||
|
normals = vertex_normals(positions, np.asarray(part.triangles, dtype=np.int64))
|
||||||
|
self.last_stats.normals_ms = (time.perf_counter() - normals_started) * 1000.0
|
||||||
|
|
||||||
|
result = RenderFrame(
|
||||||
|
identity=frame.identity,
|
||||||
|
asset_hash=assets.asset_hash,
|
||||||
|
geometry_id=assets.geometry_id,
|
||||||
|
sensing_part_id=part.part_id,
|
||||||
|
positions_sensor_m=freeze(positions, dtype=np.float32),
|
||||||
|
normals_sensor=freeze(normals, dtype=np.float32),
|
||||||
|
# The measurement, untouched by the display gain.
|
||||||
|
scalar=fields.scalar,
|
||||||
|
valid=fields.valid,
|
||||||
|
scalar_descriptor=frame.scalar_descriptor,
|
||||||
|
diagnostics=tuple(diagnostics),
|
||||||
|
)
|
||||||
|
validate_render_frame(result, vertices=int(rest_positions.shape[0]))
|
||||||
|
self.last_stats.total_ms = (time.perf_counter() - started) * 1000.0
|
||||||
|
return result
|
||||||
|
|
||||||
def rest_frame(self, assets: SceneAssets, identity: FrameIdentity) -> RenderFrame:
|
def rest_frame(self, assets: SceneAssets, identity: FrameIdentity) -> RenderFrame:
|
||||||
"""The static reference: nothing measured, so nothing coloured.
|
"""The static reference: nothing measured, so nothing coloured."""
|
||||||
|
return build_rest_frame(assets, identity)
|
||||||
|
|
||||||
The scalar is all-zero with an ``unavailable`` descriptor; the UI hides the
|
@staticmethod
|
||||||
measurement colour bar instead of leaving the previous one on screen.
|
def _check(assets: SceneAssets, frame: SurfaceFieldFrame) -> None:
|
||||||
"""
|
if assets.geometry_id and frame.geometry_id != assets.geometry_id:
|
||||||
raise NotImplementedError(f"{_STAGE}: compose the reference frame")
|
raise error(
|
||||||
|
"geometry_mismatch",
|
||||||
|
"the frame belongs to a different geometry than the scene",
|
||||||
|
frame=frame.geometry_id,
|
||||||
|
scene=assets.geometry_id,
|
||||||
|
)
|
||||||
|
if assets.field_domain_id and frame.field_domain_id != assets.field_domain_id:
|
||||||
|
raise error(
|
||||||
|
"field_domain_mismatch",
|
||||||
|
"the frame is on a different field domain than the scene",
|
||||||
|
frame=frame.field_domain_id,
|
||||||
|
scene=assets.field_domain_id,
|
||||||
|
)
|
||||||
|
if tuple(frame.scalar.shape) != tuple(assets.binding.shape_hw):
|
||||||
|
raise error(
|
||||||
|
"field_domain_mismatch",
|
||||||
|
"the frame's shape does not match the scene's UV domain",
|
||||||
|
frame=f"{frame.scalar.shape[0]}x{frame.scalar.shape[1]}",
|
||||||
|
scene=f"{assets.shape_hw[0]}x{assets.shape_hw[1]}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_rest_frame(assets: SceneAssets, identity: FrameIdentity) -> RenderFrame:
|
||||||
|
"""The asset's own geometry at rest, with no measurement attached.
|
||||||
|
|
||||||
|
Shared by the composer and by the GL layer, which needs it to *clear* a stale
|
||||||
|
measurement after a refused frame or a disconnect.
|
||||||
|
"""
|
||||||
|
part = assets.sensing_part()
|
||||||
|
count = int(part.positions_sensor_m.shape[0])
|
||||||
|
return RenderFrame(
|
||||||
|
identity=identity,
|
||||||
|
asset_hash=assets.asset_hash,
|
||||||
|
geometry_id=assets.geometry_id,
|
||||||
|
sensing_part_id=part.part_id,
|
||||||
|
positions_sensor_m=part.positions_sensor_m,
|
||||||
|
normals_sensor=part.normals_sensor,
|
||||||
|
scalar=freeze(np.full(count, np.nan, dtype=np.float32), dtype=np.float32),
|
||||||
|
valid=freeze(np.zeros(count, dtype=bool), dtype=np.bool_),
|
||||||
|
scalar_descriptor=rest_descriptor(),
|
||||||
|
)
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ DIAGNOSTIC_SEVERITY: Mapping[str, Severity] = {
|
|||||||
"gpu_budget_exceeded": "error",
|
"gpu_budget_exceeded": "error",
|
||||||
"asset_rejected": "error",
|
"asset_rejected": "error",
|
||||||
"no_data": "info",
|
"no_data": "info",
|
||||||
|
#: An illustrative display deformation was applied. Informational by design:
|
||||||
|
#: the picture is legitimate, and the point is that it is *labelled*.
|
||||||
|
"display_illustrative": "info",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,21 @@ class LegacyFieldDomain:
|
|||||||
index_order: str = "row-major"
|
index_order: str = "row-major"
|
||||||
mapping_digest: str = ""
|
mapping_digest: str = ""
|
||||||
world_xyz: Optional[np.ndarray] = None # float32 [H,W,3], for verification only
|
world_xyz: Optional[np.ndarray] = None # float32 [H,W,3], for verification only
|
||||||
|
#: The unroll map itself: for every output pixel, the source pixel it came from
|
||||||
|
#: (``-1`` where there is none). The adapters need it to bring a live field into
|
||||||
|
#: this domain, and the digest above already covers it.
|
||||||
|
map_x: Optional[np.ndarray] = None
|
||||||
|
map_y: Optional[np.ndarray] = None
|
||||||
|
#: The grid the map samples from, as the file states it (0 when unstated).
|
||||||
|
source_width: int = 0
|
||||||
|
source_height: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_hw(self) -> Optional[tuple[int, int]]:
|
||||||
|
"""The grid the map samples from, when the file states it."""
|
||||||
|
if self.source_width <= 0 or self.source_height <= 0:
|
||||||
|
return None
|
||||||
|
return (int(self.source_height), int(self.source_width))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def valid_ratio(self) -> float:
|
def valid_ratio(self) -> float:
|
||||||
@@ -137,6 +152,20 @@ def load_legacy_domain(map_path: Path) -> LegacyFieldDomain:
|
|||||||
source_scale=source_scale,
|
source_scale=source_scale,
|
||||||
mapping_digest=mapping_digest,
|
mapping_digest=mapping_digest,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _optional_array(name: str) -> Optional[np.ndarray]:
|
||||||
|
value = data.get(name)
|
||||||
|
return None if value is None else np.asarray(value)
|
||||||
|
|
||||||
|
def _optional_int(name: str) -> int:
|
||||||
|
value = data.get(name)
|
||||||
|
if value is None:
|
||||||
|
return 0
|
||||||
|
try:
|
||||||
|
return int(np.asarray(value).reshape(-1)[0])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
return LegacyFieldDomain(
|
return LegacyFieldDomain(
|
||||||
shape_hw=(shape[0], shape[1]),
|
shape_hw=(shape[0], shape[1]),
|
||||||
flip_v=flip_v,
|
flip_v=flip_v,
|
||||||
@@ -149,6 +178,12 @@ def load_legacy_domain(map_path: Path) -> LegacyFieldDomain:
|
|||||||
if world is not None
|
if world is not None
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
|
# Kept for the adapters: this is the map that brings a live field into this
|
||||||
|
# domain, and the domain digest above already covers its content.
|
||||||
|
map_x=_optional_array("map_x"),
|
||||||
|
map_y=_optional_array("map_y"),
|
||||||
|
source_width=_optional_int("source_width"),
|
||||||
|
source_height=_optional_int("source_height"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,30 +2,172 @@
|
|||||||
|
|
||||||
Stage 3 in ``docs/architecture/studio-3d-stages.md``.
|
Stage 3 in ``docs/architecture/studio-3d-stages.md``.
|
||||||
|
|
||||||
The sampling table is built once, at load time, from the UV field's own domain
|
The sampling table is built once, at load time, from the field's own domain. It is
|
||||||
(shape, crop, scale, flip). It is never rebuilt per frame and never repaired by
|
never rebuilt per frame, and a vertex is never repaired by searching for the nearest
|
||||||
searching for the nearest triangle: a vertex whose samples are not all valid is
|
triangle: if any tap that carries weight is invalid, the vertex is invalid.
|
||||||
invalid, and the mask is not crossed.
|
|
||||||
|
Three details that decide whether a measurement is honest:
|
||||||
|
|
||||||
|
* **an invalid tap is not zero.** Weighting a gap with weight 0 would still poison
|
||||||
|
the sum, because ``0 * NaN`` is ``NaN``; invalid taps are therefore excluded from
|
||||||
|
the sum and the *vertex* is marked invalid, which is what the shader draws as the
|
||||||
|
neutral colour.
|
||||||
|
* **validity is a separate array from the value.** A vertex whose scalar happens to
|
||||||
|
be 0.0 is a measurement of zero; a vertex that is invalid has no value at all, and
|
||||||
|
the value is left as ``NaN`` so nobody can mistake one for the other.
|
||||||
|
* **the domain is checked, not assumed.** A frame whose domain or shape does not
|
||||||
|
match the binding is refused with ``field_domain_mismatch`` rather than sampled
|
||||||
|
into a plausible-looking but wrong picture.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
from orisys_app_shared.scene3d.contracts import (
|
from orisys_app_shared.scene3d.contracts import (
|
||||||
SurfaceBinding,
|
SurfaceBinding,
|
||||||
SurfaceFieldFrame,
|
SurfaceFieldFrame,
|
||||||
VertexFields,
|
VertexFields,
|
||||||
|
error,
|
||||||
|
freeze,
|
||||||
)
|
)
|
||||||
|
|
||||||
_STAGE = "stage 3 (live data)"
|
#: A weight below this is treated as "not used": the binding builder never emits
|
||||||
|
#: one, and a rounding-level weight must not be able to invalidate a vertex.
|
||||||
|
WEIGHT_EPS = 1e-7
|
||||||
|
|
||||||
|
|
||||||
class SurfaceSampler:
|
class SurfaceSampler:
|
||||||
"""Bilinear sampling through a precomputed binding.
|
"""Vectorised bilinear sampling through a precomputed binding."""
|
||||||
|
|
||||||
Vectorised over vertices: gather ``[N,4]`` taps, weight them, and treat any
|
|
||||||
positive-weight invalid tap as a reason to invalidate the whole vertex. A
|
|
||||||
scalar that is not finite is "no measurement", not a zero.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def sample(self, frame: SurfaceFieldFrame, binding: SurfaceBinding) -> VertexFields:
|
def sample(self, frame: SurfaceFieldFrame, binding: SurfaceBinding) -> VertexFields:
|
||||||
raise NotImplementedError(f"{_STAGE}: sample a UV field onto the vertices")
|
self._check_domains(frame, binding)
|
||||||
|
indices = np.asarray(binding.sample_indices, dtype=np.int64) # [N,4]
|
||||||
|
weights = np.asarray(binding.sample_weights, dtype=np.float64) # [N,4]
|
||||||
|
used = indices >= 0
|
||||||
|
if not used.any() and len(indices):
|
||||||
|
raise error("field_domain_mismatch", "the binding samples nothing at all")
|
||||||
|
# ``-1`` -> index 0 for the gather; the weight there is 0 by contract and the
|
||||||
|
# ``used`` mask keeps it out of both the sum and the validity test.
|
||||||
|
safe = np.where(used, indices, 0)
|
||||||
|
height, width = frame.scalar.shape
|
||||||
|
flat_scalar = np.asarray(frame.scalar, dtype=np.float64).reshape(-1)
|
||||||
|
flat_valid = np.asarray(frame.valid, dtype=bool).reshape(-1)
|
||||||
|
if safe.size and int(safe.max()) >= flat_scalar.size:
|
||||||
|
raise error(
|
||||||
|
"field_domain_mismatch",
|
||||||
|
"a binding index falls outside the frame's field",
|
||||||
|
index=int(safe.max()),
|
||||||
|
size=int(flat_scalar.size),
|
||||||
|
)
|
||||||
|
gathered_scalar = flat_scalar[safe]
|
||||||
|
gathered_valid = flat_valid[safe]
|
||||||
|
finite = np.isfinite(gathered_scalar)
|
||||||
|
# A tap counts only where it is used, in range, finite and valid.
|
||||||
|
contributed = used & gathered_valid & finite
|
||||||
|
valid = contributed | ~used
|
||||||
|
valid = valid.all(axis=1)
|
||||||
|
contribution = np.where(contributed, gathered_scalar, 0.0) * weights
|
||||||
|
scalar = contribution.sum(axis=1)
|
||||||
|
# No measurement: the value is absent, not zero.
|
||||||
|
scalar = np.where(valid, scalar, np.nan)
|
||||||
|
|
||||||
|
displacement = None
|
||||||
|
if frame.displacement_sensor_m is not None:
|
||||||
|
flat_displacement = np.asarray(
|
||||||
|
frame.displacement_sensor_m, dtype=np.float64
|
||||||
|
).reshape(-1, 3)
|
||||||
|
if flat_displacement.shape[0] != flat_scalar.shape[0]:
|
||||||
|
raise error(
|
||||||
|
"field_domain_mismatch",
|
||||||
|
"the displacement field does not match the scalar field",
|
||||||
|
)
|
||||||
|
gathered = flat_displacement[safe] # [N,4,3]
|
||||||
|
finite_disp = np.isfinite(gathered).all(axis=2)
|
||||||
|
contributed_disp = used & gathered_valid & finite_disp
|
||||||
|
displacement = np.where(contributed_disp[:, :, None], gathered, 0.0)
|
||||||
|
displacement = (displacement * weights[:, :, None]).sum(axis=1)
|
||||||
|
displacement = np.where(valid[:, None], displacement, np.nan)
|
||||||
|
displacement = freeze(displacement, dtype=np.float32)
|
||||||
|
del height, width
|
||||||
|
return VertexFields(
|
||||||
|
scalar=freeze(scalar, dtype=np.float32),
|
||||||
|
valid=freeze(valid, dtype=np.bool_),
|
||||||
|
displacement_sensor_m=displacement,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _check_domains(frame: SurfaceFieldFrame, binding: SurfaceBinding) -> None:
|
||||||
|
if binding.geometry_id and frame.geometry_id != binding.geometry_id:
|
||||||
|
raise error(
|
||||||
|
"geometry_mismatch",
|
||||||
|
"the frame belongs to a different geometry than the binding",
|
||||||
|
frame=frame.geometry_id,
|
||||||
|
binding=binding.geometry_id,
|
||||||
|
)
|
||||||
|
if binding.field_domain_id and frame.field_domain_id != binding.field_domain_id:
|
||||||
|
raise error(
|
||||||
|
"field_domain_mismatch",
|
||||||
|
"the frame is on a different field domain than the binding",
|
||||||
|
frame=frame.field_domain_id,
|
||||||
|
binding=binding.field_domain_id,
|
||||||
|
)
|
||||||
|
if tuple(frame.scalar.shape) != tuple(binding.shape_hw):
|
||||||
|
raise error(
|
||||||
|
"field_domain_mismatch",
|
||||||
|
"the frame's shape does not match the binding's domain",
|
||||||
|
frame=f"{frame.scalar.shape[0]}x{frame.scalar.shape[1]}",
|
||||||
|
binding=f"{binding.shape_hw[0]}x{binding.shape_hw[1]}",
|
||||||
|
)
|
||||||
|
if frame.scalar.shape != frame.valid.shape:
|
||||||
|
raise error(
|
||||||
|
"field_domain_mismatch", "the frame's scalar and validity disagree"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sample_field(frame: SurfaceFieldFrame, binding: SurfaceBinding) -> VertexFields:
|
||||||
|
"""Convenience wrapper: one sampler, no state to keep."""
|
||||||
|
return SurfaceSampler().sample(frame, binding)
|
||||||
|
|
||||||
|
|
||||||
|
def remap_to_domain(
|
||||||
|
field: np.ndarray,
|
||||||
|
*,
|
||||||
|
map_x: np.ndarray,
|
||||||
|
map_y: np.ndarray,
|
||||||
|
shape_hw: tuple[int, int],
|
||||||
|
) -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""Resample a field from its own grid onto the scene's UV domain.
|
||||||
|
|
||||||
|
The legacy unroll map is exactly this: for every output pixel it stores the
|
||||||
|
source pixel it came from (``-1`` where there is none). Remapping is therefore
|
||||||
|
a gather, not an interpolation — the map already chose the sample.
|
||||||
|
|
||||||
|
Returns ``(values, valid)`` where ``values`` keeps the trailing axes of the
|
||||||
|
input (so ``[H,W,2]`` flow becomes ``[h,w,2]``) and ``values`` is ``NaN``
|
||||||
|
wherever the map has no source pixel. Nothing is invented for a gap: a remap
|
||||||
|
that filled holes would put measurements where the sensor has no surface.
|
||||||
|
"""
|
||||||
|
values = np.asarray(field)
|
||||||
|
if values.ndim < 2:
|
||||||
|
raise error("field_domain_mismatch", "a field to remap must be at least 2-D")
|
||||||
|
height, width = int(shape_hw[0]), int(shape_hw[1])
|
||||||
|
xs = np.asarray(map_x)
|
||||||
|
ys = np.asarray(map_y)
|
||||||
|
if xs.shape != (height, width) or ys.shape != (height, width):
|
||||||
|
raise error(
|
||||||
|
"field_domain_mismatch",
|
||||||
|
f"the remap is {xs.shape} but the domain is {height}x{width}",
|
||||||
|
)
|
||||||
|
source_height, source_width = values.shape[0], values.shape[1]
|
||||||
|
usable = (xs >= 0) & (ys >= 0) & (xs < source_width) & (ys < source_height)
|
||||||
|
safe_x = np.where(usable, xs, 0).astype(np.int64)
|
||||||
|
safe_y = np.where(usable, ys, 0).astype(np.int64)
|
||||||
|
gathered = values[safe_y, safe_x] # [h,w] or [h,w,...]
|
||||||
|
finite = np.isfinite(np.asarray(gathered, dtype=np.float64))
|
||||||
|
if gathered.ndim > 2:
|
||||||
|
finite = finite.all(axis=tuple(range(2, gathered.ndim)))
|
||||||
|
valid = usable & finite
|
||||||
|
out = np.array(gathered, dtype=np.float32, copy=True)
|
||||||
|
out[~valid] = np.nan
|
||||||
|
return freeze(out, dtype=np.float32), freeze(valid, dtype=np.bool_)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
import pytest
|
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():
|
def test_the_cpu_layer_is_importable_and_works_without_a_gpu():
|
||||||
from orisys_app_shared.scene3d import assets, composer, sampling
|
"""The acquisition thread imports this package; it must not drag Qt or GL in.
|
||||||
|
|
||||||
with pytest.raises(NotImplementedError) as sampler:
|
Stages 2 and 3 filled in the CPU pieces, so this checks they are reachable and
|
||||||
sampling.SurfaceSampler().sample(_frame(), _binding())
|
usable here — the sampler and the composer run on a machine with no GPU, which
|
||||||
assert "stage 3" in str(sampler.value)
|
is the whole reason the contracts layer is separate from the view.
|
||||||
with pytest.raises(NotImplementedError) as composer_error:
|
"""
|
||||||
composer.SceneComposer().rest_frame(_assets(), c.FrameIdentity("live-A", 1, 0, 0.0, 1))
|
from orisys_app_shared.scene3d import assets, composer, sampling, synthetic
|
||||||
assert "stage 3" in str(composer_error.value)
|
|
||||||
with pytest.raises(NotImplementedError) as loader:
|
scene = synthetic.poc_scene()
|
||||||
assets.PackagedAssetLoader().load(None, None) # type: ignore[arg-type]
|
frame = _frame()
|
||||||
assert "stage 2" in str(loader.value)
|
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():
|
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