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:
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user