fix(studio-3d): 字段级来源与失效状态 —— 旧光流/算法失败不再冒充实时测量

- What:
  1. **采集侧声明每个派生数组的归属**(`acquisition/models.py`):`SdkPacket` 新增
     `flow_valid/flow_frame_id/depth_valid/depth_frame_id/forces_valid`(`None` = 未声明)。两个真实
     生产者都填写:`finger_worker`/`sdk_worker` 逐帧记录"该数组实际算出来的 frame_id",链失败时
     `*_valid=False` 并把上一次成功帧号带出来。光流与形变链分开判定(力读取失败不等于光流失败),
     但算法链失败时本帧的一切派生量都不可声明。
  2. **adapter 按声明收数据**(`scene3d/adapters.py`):`_field_validity()` 三态——未声明 → 拒绝并报
     `field_unverified`(没声明就不算数);声明为假 → `field_stale`(把 `invalid_reason` 带进诊断);
    声明帧号不是本帧 → `field_stale`(两个帧号都写进 details)。`_intersect_device_mask` 换成
     `_device_mask`:设备 mask 形状不符**拒绝该帧**(`field_domain_mismatch`,两个形状都写进诊断),
     既不拉伸也不静默忽略。发布的帧带 `computed_frame_id`,身份一路带到显示侧。
  3. **契约**(`scene3d/contracts.py`):`SceneState` 加 `"stale"`;诊断码加 `field_stale`/`field_unverified`
     (warning);`SurfaceFieldFrame.computed_frame_id` 与 `RenderFrame.computed_frame_id`(由 composer
     原样透传);`validate_surface_frame` 校验帧号非负且不晚于所属帧。新增 locale:
     `scene3d.state_stale`(中英各一条)。
  4. **协调器不再"边失败边报 live"**(`scene3d/coordinator.py`):新增 `_stale_since` 宽限状态机与
     `note_field_failure()`——字段被拒、帧被判 invalid、compose 抛错、或合成结果的 `computed_frame_id`
     落后于本帧,都立即把状态改为 `stale` 并保留最后一帧;宽限(`stale_grace_s`,默认 1.0 s)到期后
     回参考模型并转 `no_data`;收到好帧即回 `live` 并清掉宽限。顺带修掉"等待首帧时每个 tick 都把参考
     模型重新 present 一次"(`_rest_presented`,16 ms 一次的无谓上传)。
  5. **诊断真正到达界面**(`scene3d/view.py`):`submit_packet()` 在 adapter 拒绝时把
     `adapter.last_diagnostic` 交给 `note_field_failure()`;`tick()` 把协调器诊断的变化经已有
     `diagnosticRaised` 冒泡(协调器自身仍然零 Qt 依赖)。新增 `_snapshot_diagnostic()` 与 `_adapter`
     引用(后续"按能力禁用控件"也要问它)。
- Why: 审查报告 P1/R2:`FingerFrameAdapter.snapshot()` 从不读 `valid`/`stale_forces`/`invalid_reason`,
  只看到有限的 `flow` 就当作新数据上色(复现:`valid=False, stale_forces=True` + 有限 flow → 仍有有效
  着色点、diagnostic=None);compose 抛错只写 `last_diagnostic`,画面与 `state` 保持不变;
  `snapshot()` 返回 None 时诊断无人读取;mask 域不符时静默忽略会扩大可信范围。力读数失败不该机械地
  判死三维,但算法失败也不能因为旧数组是有限值就当成新数据——所以按**字段级来源**处理,并让失效有
  明确的、可见的状态而不是继续保持 live。
- Verification:
  · `test_scene3d_adapters.py` 23 通过(+6 新增):stale flow 被拒且带原因、未声明 flow → `field_unverified`、
  帧号不符 → `field_stale`(details 两个帧号)、新帧记录 `computed_frame_id`、box 侧"形变链失败仍用光流"、
  box 全部字段不可声明时拒绝;mask 域不符由"被忽略"改为"拒绝该帧"(原断言已改)。
  · `test_scene3d_coordinator.py` 30 通过(+6 新增):落后帧号的合成结果永不上屏且状态 stale、
  字段被拒 → 宽限内保留画面/宽限后回参考模型/好帧回 live、compose 抛错不再保持 live、invalid 帧也计入
  宽限。
  · `test_scene3d_view.py`(+3 新增,offscreen 可跑):adapter 拒绝 → 协调器 stale 且诊断经
  `diagnosticRaised` 回到面板、`scene3d.state_stale` 不是裸键;未声明字段 → `field_unverified`;
  新鲜字段照常上屏回到 live。
  · 全量:apps/studio 228 项 0 失败 14 跳过;shared 301 项 0 失败 1 跳过(`--junitxml` 计数)。
- Not in this change: `docs/architecture/studio-3d-contracts.pyi` 与新契约的同步放在阶段 6 的文档提交里
  一起做(本阶段契约改动较多,避免每提交改一次文档);设备几何身份(R3)、资产加载请求号(R7)、
  二维后端统一、限速与真实统计均未动;真机上"链失败时 VRAW 是否也过期"这一条仍需实测(若证明链失败
  不影响 VRAW,`flow_valid` 放宽为只看读取结果是一行改动,已写进计划的风险项)。
This commit is contained in:
ykcheungab
2026-09-14 21:03:07 +08:00
parent d06e116c15
commit 7d1fca4c93
15 changed files with 721 additions and 38 deletions
+125 -3
View File
@@ -32,12 +32,24 @@ from orisys_studio.scene3d.adapters import (
@dataclass
class FakePacket:
"""The parts of ``SdkPacket`` the adapters look at."""
"""The parts of ``SdkPacket`` the adapters look at.
The provenance flags default to *stated fresh*: that is what both production
workers now write, and it keeps the older tests about grids and units about grids
and units. A test that wants a stale or unstated field says so — that is the
behaviour under test.
"""
flow: Optional[np.ndarray] = None
depth: Optional[np.ndarray] = None
valid_mask: Optional[np.ndarray] = None
frame_id: int = 1
invalid_reason: str = ""
flow_valid: Optional[bool] = True
flow_frame_id: Optional[int] = None
depth_valid: Optional[bool] = True
depth_frame_id: Optional[int] = None
forces_valid: Optional[bool] = True
def _identity(generation: int = 1) -> FrameIdentity:
@@ -242,7 +254,8 @@ def test_the_finger_adapter_does_not_colour_outside_the_uv_island(scene):
)
def test_a_device_mask_from_another_grid_is_ignored_not_stretched(scene):
def test_a_device_mask_from_another_grid_refuses_the_frame(scene):
"""Neither stretching it nor ignoring it is honest — so the frame is refused."""
adapter = FingerFrameAdapter(
geometry_id=scene.geometry_id,
field_domain_id=scene.field_domain_id,
@@ -253,8 +266,117 @@ def test_a_device_mask_from_another_grid_is_ignored_not_stretched(scene):
frame = adapter.snapshot(
FakePacket(flow=flow, valid_mask=wrong), runtime=None, identity=_identity()
)
assert frame is None, "a mask from another grid must not widen the trusted area"
item = adapter.last_diagnostic
assert item is not None and item.code == "field_domain_mismatch"
assert item.details["mask"] == "7x7"
assert item.details["domain"] == f"{scene.shape_hw[0]}x{scene.shape_hw[1]}"
# ------------------------------------------------- field provenance (stage 6, R2)
def _finger(scene) -> FingerFrameAdapter:
return FingerFrameAdapter(
geometry_id=scene.geometry_id,
field_domain_id=scene.field_domain_id,
shape_hw=scene.shape_hw,
)
def test_a_stale_flow_is_refused_instead_of_coloured(scene):
"""R2: the array is still finite, but it is not this frame's measurement.
This is the audit's reproduction: a packet with ``valid=False``/``stale_forces``
and a filled ``flow`` used to be coloured as valid data with no diagnostic.
"""
adapter = _finger(scene)
flow = np.full((*scene.shape_hw, 2), 4.0, dtype=np.float32)
frame = adapter.snapshot(
FakePacket(
flow=flow,
flow_valid=False,
invalid_reason="compute: ValueError: boom",
),
runtime=None,
identity=_identity(),
)
assert frame is None, "a failed chain's leftover flow was coloured as current"
item = adapter.last_diagnostic
assert item is not None and item.code == "field_stale"
assert "boom" in item.message
def test_an_unstated_flow_is_unverified_not_fresh(scene):
"""No statement is not the same as "it is mine"."""
adapter = _finger(scene)
flow = np.full((*scene.shape_hw, 2), 1.0, dtype=np.float32)
frame = adapter.snapshot(
FakePacket(flow=flow, flow_valid=None), runtime=None, identity=_identity()
)
assert frame is None
item = adapter.last_diagnostic
assert item is not None and item.code == "field_unverified"
def test_flow_computed_for_another_frame_is_refused(scene):
adapter = _finger(scene)
flow = np.full((*scene.shape_hw, 2), 2.0, dtype=np.float32)
frame = adapter.snapshot(
FakePacket(flow=flow, flow_frame_id=3),
runtime=None,
identity=_identity(), # frame_id == 1
)
assert frame is None
item = adapter.last_diagnostic
assert item is not None and item.code == "field_stale"
assert item.details["computed"] == "3" and item.details["frame"] == "1"
def test_a_fresh_flow_records_the_frame_it_belongs_to(scene):
"""The identity travels with the frame, so the display side can re-check it."""
adapter = _finger(scene)
flow = np.full((*scene.shape_hw, 2), 3.0, dtype=np.float32)
frame = adapter.snapshot(
FakePacket(flow=flow, flow_frame_id=1), runtime=None, identity=_identity()
)
assert frame is not None
assert np.asarray(frame.valid).all()
assert frame.computed_frame_id == 1
assert validate_surface_frame(frame) == ()
def test_the_box_adapter_skips_a_stale_depth_and_uses_the_flow(scene):
"""A failed deformation chain does not have to cost the flow as well."""
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.5, dtype=np.float32)
flow = np.zeros((*scene.shape_hw, 2), dtype=np.float32)
flow[..., 0] = 1.0
frame = adapter.snapshot(
FakePacket(depth=depth, depth_valid=False, 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_box_adapter_refuses_when_no_field_is_its_own(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.5, dtype=np.float32)
frame = adapter.snapshot(
FakePacket(depth=depth, depth_valid=False),
_identity(),
ExpectedGeometry("box", "rev", scene.geometry_id, scene.field_domain_id, scene.shape_hw),
)
assert frame is None
item = adapter.last_diagnostic
assert item is not None and item.code == "field_stale"
def _run_map():
+150 -1
View File
@@ -80,7 +80,7 @@ class CountingComposer(SceneComposer):
def _frame(scene, *, generation=1, source="live-A", session=1, frame_id=1, value=1.0,
domain=None, valid=True):
domain=None, valid=True, computed=None):
scalar = np.full(scene.shape_hw, value, dtype=np.float32)
mask = np.full(scene.shape_hw, valid, dtype=bool)
return SurfaceFieldFrame(
@@ -90,6 +90,7 @@ def _frame(scene, *, generation=1, source="live-A", session=1, frame_id=1, value
scalar=freeze(scalar, dtype=np.float32),
scalar_descriptor=FieldDescriptor("flow_magnitude", "px", "approximate", False),
valid=freeze(mask, dtype=np.bool_),
computed_frame_id=computed,
)
@@ -640,3 +641,151 @@ def test_a_result_from_an_older_request_is_dropped_on_arrival(scene):
"a result for a superseded request was presented"
)
coordinator.close()
# --------------------------------------------- a field that is not this frame's (R2)
class Clock:
"""A monotonic clock the test moves by hand."""
def __init__(self, now: float = 0.0) -> None:
self.now = float(now)
def __call__(self) -> float:
return self.now
def advance(self, seconds: float) -> None:
self.now += float(seconds)
class FailingComposer(SceneComposer):
"""A composer that can be switched to raising, like a broken chain."""
def __init__(self) -> None:
super().__init__()
self.fail = False
def compose(self, assets, frame, settings):
if self.fail:
raise RuntimeError("synthetic compose error")
return super().compose(assets, frame, settings)
def _stale_diagnostic():
from orisys_app_shared.scene3d.contracts import diagnostic
return diagnostic("field_stale", "this frame's flow is not its own measurement")
def test_a_stale_array_is_never_presented_as_the_current_instant(scene):
"""R2: a finite array from an earlier frame is not this frame's measurement."""
widget = FakeWidget()
coordinator = ThreeDCoordinator(widget, SceneComposer(), ImmediateExecutor())
coordinator.set_visible(True)
coordinator.activate(scene, "live-A")
presented = len(widget.frames)
assert coordinator.submit(_frame(scene, frame_id=5, computed=2, value=6.0)) == "accepted"
coordinator.tick()
coordinator.tick()
assert not any(frame.valid.any() for frame in widget.frames[presented:]), (
"a stale array was painted as the current instant"
)
assert all(frame.identity.frame_id != 5 for frame in widget.frames[presented:])
assert coordinator.state == "stale"
assert coordinator.stale_frames >= 1
assert coordinator.last_diagnostic is not None
assert coordinator.last_diagnostic.code == "field_stale"
coordinator.close()
def test_a_refused_field_expires_the_measurement_after_the_grace(scene):
clock = Clock()
widget = FakeWidget()
coordinator = ThreeDCoordinator(
widget, SceneComposer(), ImmediateExecutor(), clock=clock, stale_grace_s=1.0
)
coordinator.set_visible(True)
coordinator.activate(scene, "live-A")
coordinator.submit(_frame(scene, value=8.0))
coordinator.tick()
coordinator.tick()
assert coordinator.state == "live"
assert np.allclose(np.asarray(widget.frames[-1].scalar), 8.0)
# The producer cannot build a field any more: the picture stays for the grace, but
# the pane must stop calling it live immediately.
coordinator.note_field_failure(_stale_diagnostic())
assert coordinator.state == "stale"
coordinator.tick()
assert np.allclose(np.asarray(widget.frames[-1].scalar), 8.0), (
"a single refused frame blanked the model"
)
assert coordinator.state == "stale"
clock.advance(1.5)
coordinator.tick()
assert not widget.frames[-1].valid.any(), "the expired measurement is still on screen"
assert widget.frames[-1].scalar_descriptor.quantity == "unavailable"
assert coordinator.state == "no_data"
coordinator.close()
def test_a_good_frame_after_a_failure_goes_back_to_live(scene):
clock = Clock()
widget = FakeWidget()
coordinator = ThreeDCoordinator(
widget, SceneComposer(), ImmediateExecutor(), clock=clock, stale_grace_s=5.0
)
coordinator.set_visible(True)
coordinator.activate(scene, "live-A")
coordinator.note_field_failure(_stale_diagnostic())
assert coordinator.state == "stale"
coordinator.submit(_frame(scene, frame_id=7, value=2.0))
coordinator.tick()
coordinator.tick()
assert coordinator.state == "live"
assert np.allclose(np.asarray(widget.frames[-1].scalar), 2.0)
coordinator.close()
def test_a_failed_compose_does_not_leave_the_state_live(scene):
"""R2: the error used to be recorded while the picture and "live" stayed."""
composer = FailingComposer()
widget = FakeWidget()
coordinator = ThreeDCoordinator(widget, composer, ImmediateExecutor())
coordinator.set_visible(True)
coordinator.activate(scene, "live-A")
coordinator.submit(_frame(scene, frame_id=1, value=1.0))
coordinator.tick()
coordinator.tick()
assert coordinator.state == "live"
composer.fail = True
coordinator.submit(_frame(scene, frame_id=2, value=2.0))
coordinator.tick()
coordinator.tick()
assert coordinator.state == "stale", "a failed compose kept reporting live"
assert coordinator.last_diagnostic is not None
assert coordinator.last_diagnostic.code == "field_unavailable"
assert "synthetic compose error" in coordinator.last_diagnostic.message
coordinator.close()
def test_an_invalid_frame_starts_the_stale_grace_too(scene):
"""A domain mismatch is "no measurement is arriving", not "everything is fine"."""
clock = Clock()
widget = FakeWidget()
coordinator = ThreeDCoordinator(
widget, SceneComposer(), ImmediateExecutor(), clock=clock, stale_grace_s=1.0
)
coordinator.set_visible(True)
coordinator.activate(scene, "live-A")
assert coordinator.submit(_frame(scene, domain="other-domain")) == "invalid"
assert coordinator.state == "stale"
assert coordinator.invalid_frames == 1
clock.advance(2.0)
coordinator.tick()
assert coordinator.state == "no_data"
coordinator.close()
+15 -1
View File
@@ -381,7 +381,12 @@ def test_the_probe_reports_this_machine_truthfully():
# ------------------------------------------------- stage 4: the assembled view
class _Packet:
"""The parts of an ``SdkPacket`` the finger adapter and the identity need."""
"""The parts of an ``SdkPacket`` the finger adapter and the identity need.
The provenance flags are part of the contract now: a producer has to say that the
array it hands over belongs to this frame, and the adapter refuses one that does
not say (see ``adapters._field_validity``).
"""
def __init__(self, frame_id: int, shape_hw: tuple[int, int], value: float = 0.5) -> None:
self.session_id = 1
@@ -389,6 +394,15 @@ class _Packet:
self.mono_t = float(frame_id) / 30.0
self.flow = np.full((*shape_hw, 2), float(value), dtype=np.float32)
self.raw_frame = np.zeros((4, 4, 3), dtype=np.uint8)
self.valid = True
self.invalid_reason = ""
self.flow_valid = True
self.flow_frame_id = int(frame_id)
self.depth = None
self.depth_valid = None
self.depth_frame_id = None
self.forces_valid = True
self.valid_mask = None
@pytest.fixture(scope="module")
+87
View File
@@ -22,6 +22,7 @@ from __future__ import annotations
import itertools
import shutil
import time
from dataclasses import replace
from pathlib import Path
@@ -610,6 +611,89 @@ def test_a_finger_scene_uses_the_finger_adapter(tmp_path, write_poc, qt_app):
view.close()
# ------------------------------------------- stage 6: a field that is not this frame's
def _stale_scene_view(qt_app):
"""A real view with the synthetic scene, or a skip when GL is not buildable."""
from orisys_studio.scene3d.view import Scene3DView
view = Scene3DView()
if not view.available:
view.close()
pytest.skip("no GL binding here")
if not view.use_scene(synthetic.poc_scene(), source_id="poc-live"):
view.close()
pytest.skip("the synthetic scene was refused")
view.set_enabled(True)
return view
def test_a_packet_that_cannot_claim_its_field_marks_the_measurement_stale(qt_app):
"""R2 through the view: the refusal used to be dropped on the floor.
``snapshot()`` returning ``None`` left the pane claiming ``live`` about a picture
nothing was producing. Now the reason travels to the coordinator *and* back out
to the pane.
"""
view = _stale_scene_view(qt_app)
try:
packet = ui_check._fake_packet(1)
packet.flow_valid = False
packet.invalid_reason = "compute: RuntimeError: boom"
view.submit_packet(packet)
coordinator = view.coordinator
assert coordinator is not None
assert coordinator.state == "stale", "a refused field kept reporting live"
assert coordinator.last_diagnostic is not None
assert coordinator.last_diagnostic.code == "field_stale"
view.tick()
assert view.last_diagnostic is not None
assert view.last_diagnostic.code == "field_stale", (
"the pane never learned why the measurement stopped"
)
assert view.panel is not None
assert view.panel.state_label.text() not in ("", "scene3d.state_stale"), (
"the stale state has no operator-facing sentence"
)
finally:
view.close()
def test_an_unstated_field_is_refused_rather_than_assumed(qt_app):
view = _stale_scene_view(qt_app)
try:
packet = ui_check._fake_packet(1)
packet.flow_valid = None
view.submit_packet(packet)
coordinator = view.coordinator
assert coordinator is not None
assert coordinator.state == "stale"
assert coordinator.last_diagnostic is not None
assert coordinator.last_diagnostic.code == "field_unverified"
finally:
view.close()
def test_a_fresh_field_reaches_the_screen_and_clears_the_stale_state(qt_app):
view = _stale_scene_view(qt_app)
try:
shape = tuple(view.assets.shape_hw)
packet = ui_check._fake_packet(1)
packet.flow = np.full((*shape, 2), 0.5, dtype=np.float32)
view.submit_packet(packet)
assert view._snapshot_diagnostic() is None, "a stated, fresh field was refused"
coordinator = view.coordinator
for _ in range(60):
view.tick()
if coordinator is not None and coordinator.state == "live":
break
time.sleep(0.01)
assert coordinator is not None and coordinator.state == "live", coordinator.state
finally:
view.close()
# ------------------------------------------------------- stage 5: the record block
def test_a_recording_says_what_the_3d_pane_was(
make_window, next_record_root, write_poc, qt_app, monkeypatch
@@ -763,6 +847,9 @@ def test_every_run_gets_a_new_generation(make_window, qt_app):
packet = ui_check._fake_packet(1)
packet.flow = np.full((*shape, 2), 0.5, dtype=np.float32)
view.submit_packet(packet)
assert view._snapshot_diagnostic() is None, (
"the adapter refused the second session's packet"
)
assert view.coordinator.stale_frames == 0, (
"the second session's frames were refused as stale"
)