阶段 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;
|
||||
* it copies what the frame needs and publishes it read-only — the GUI thread must
|
||||
never reach back into a live runtime;
|
||||
* it copies **nothing** when the three-dimensional view is not subscribed to, and
|
||||
returns ``None`` when the frame carries no usable field. A frame is never
|
||||
assembled from two different acquisitions, and a missing field is never filled
|
||||
in from the previous one.
|
||||
* it publishes **nothing** when the frame carries no usable field, or when the
|
||||
field is on a different grid than the scene's UV domain. A remap that is not
|
||||
configured is not guessed at: the adapter says which shapes it saw, so the
|
||||
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 typing import Any, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from orisys_app_shared.acquisition.models import SdkPacket
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
Diagnostic,
|
||||
ExpectedGeometry,
|
||||
FieldDescriptor,
|
||||
FrameIdentity,
|
||||
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:
|
||||
"""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(
|
||||
self, packet: SdkPacket, runtime: Any, identity: FrameIdentity
|
||||
) -> 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:
|
||||
"""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
|
||||
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(
|
||||
self, packet: SdkPacket, identity: FrameIdentity, domain: ExpectedGeometry
|
||||
) -> 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.
|
||||
|
||||
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``.
|
||||
|
||||
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
|
||||
previous un-consumed frame; a GUI timer calls :meth:`tick`, which picks up the
|
||||
newest composed frame and repaints. Display drops are counted here and never in
|
||||
the recording channel, and no CPU task is submitted while one is still running.
|
||||
display**. A producer calls :meth:`submit`, which validates cheaply and drops the
|
||||
previous un-consumed frame; a GUI timer calls :meth:`tick`, which hands the newest
|
||||
field frame to the composer, picks up the newest composed frame and asks the widget
|
||||
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 concurrent.futures import Executor
|
||||
from typing import Optional
|
||||
import threading
|
||||
from concurrent.futures import Executor, Future
|
||||
from typing import Any, Optional
|
||||
|
||||
from orisys_app_shared.scene3d.contracts import (
|
||||
Diagnostic,
|
||||
DisplaySettings,
|
||||
FrameIdentity,
|
||||
LatestFrameMailbox,
|
||||
OfferResult,
|
||||
RenderFrame,
|
||||
SceneAssets,
|
||||
SceneState,
|
||||
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:
|
||||
@@ -34,48 +52,41 @@ class ThreeDCoordinator:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
widget: Sensor3DWidget,
|
||||
composer: object,
|
||||
widget: Any,
|
||||
composer: Any,
|
||||
executor: Executor,
|
||||
max_fps: int = 30,
|
||||
) -> None:
|
||||
self._widget = widget
|
||||
self._composer = composer
|
||||
self._executor = executor
|
||||
self._max_fps = int(max_fps)
|
||||
self._max_fps = max(1, int(max_fps))
|
||||
self._incoming: LatestFrameMailbox[SurfaceFieldFrame] = LatestFrameMailbox()
|
||||
self._composed: LatestFrameMailbox[RenderFrame] = LatestFrameMailbox()
|
||||
self._lock = threading.Lock()
|
||||
self._state: SceneState = "disabled"
|
||||
self._generation = 0
|
||||
self._assets: Optional[SceneAssets] = None
|
||||
self._source_id = ""
|
||||
self._session_id: Optional[int] = None
|
||||
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:
|
||||
"""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")
|
||||
|
||||
# ------------------------------------------------------------------- state
|
||||
@property
|
||||
def state(self) -> SceneState:
|
||||
return self._state
|
||||
with self._lock:
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def generation(self) -> int:
|
||||
@@ -83,7 +94,261 @@ class ThreeDCoordinator:
|
||||
|
||||
@property
|
||||
def superseded_frames(self) -> int:
|
||||
"""Frames the display never showed. Not a recording drop."""
|
||||
return (
|
||||
self._incoming.superseded_frames + self._composed.superseded_frames
|
||||
"""Frames the display never showed. Never a recording drop."""
|
||||
return 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
|
||||
disconnect — so the screen cannot keep showing the previous acquisition's
|
||||
colours as if they were current. ``quality="display_only"`` and an
|
||||
``unavailable`` quantity are what tell the UI to hide the measurement bar.
|
||||
colours as if they were current. The composer owns the definition; this is the
|
||||
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()
|
||||
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(),
|
||||
)
|
||||
return build_rest_frame(assets, identity)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- dispatch
|
||||
|
||||
Reference in New Issue
Block a user