fix(studio-3d): 审计三维改动 —— 修「第一个会话之后画面永久冻结」,并把面板/视图接口显式化

- What:
  1. **会话代际(真 bug)**:`ThreeDCoordinator.begin_session()` 新增——新采集会话开始时要显式宣告
     (清掉已锁定的 session_id、generation+1、清空 mailbox、把参考模型放回屏幕);`Scene3DView.begin_session()`、
     `Mesh3dPanel.begin_session()` 转发,`app_window._start_run()` 在启动 worker 后调用。
     另外 `submit()` 在"会话不符"时不再只计数,也给出 `stale_frame` 诊断(信息级),否则界面只是"不动"。
  2. **接口显式化(可维护性)**:`Mesh3dPanel.attach_view()` 成为唯一装配入口(真实视图与测试替身都走它,
     测试不再写 `_view`);`_view` 类型改为 `Optional["Scene3DView"]`(TYPE_CHECKING 导入,保持 GL 惰性);
     容器里 7 处 `hasattr(...)` 鸭子调用改为直接调用(方法改名会立刻报错,而不是静默不生效);
     `Scene3DView.describe()`/`render_totals()` 与 `Mesh3dPanel.describe()`/`render_totals()` 让面板自己回答
     "我是什么"——`app_window` 里那段走 5 个属性路径拼 `visualization3d` 的代码缩成一次调用 + 一个 try;
     `scene3d/panel.py` 对 coordinator/widget 的导入移到 TYPE_CHECKING;`scene3d/__init__.py` 写上各模块职责图
     (原文还写着"应用尚未导入本包")。
- Why: 用户要求全面回顾三维改动并按"实时/回放/接口清晰/性能"四条检查 bug。审计确实抓到一个严重缺陷:
  协调器记住第一个会话的 session_id 后,**之后每个会话的每一帧都被判为 stale 而拒绝**(实测 session 1 正常、
  session 2 stale=3 且 0 帧上屏),而状态仍显示 live —— 表现为"第一次运行之后三维就再也不动了"。
  这是"回放不太行"的直接原因之一(回放里的重算通道就是第二个会话)。顺带把"面板↔窗口"的隐式耦合收紧。
- Verification:
  · 回归用例:`test_scene3d_coordinator.py` +2(新会话在宣告前 stale 且带诊断、宣告后接受并回到 live;
    `begin_session` 在无场景/已关闭时安全)、`test_scene3d_view.py` +1(每次 _start_run 代际递增、第二个会话的帧被接受)。
  · 实测(真 GPU,apps/studio 配置里那个包):`audit_live_path.py` —— 三个连续会话 state=live、stale=0、
    每次都有 26260 个非背景像素、descriptor=flow_magnitude;
    `audit_replay_path.py` —— 回放时间轴正常(1.97 s 记录,播放 0.6 s 后位置 0.60 s,2D 有图,停止按钮生效,
    面板标签"回放:录像不含三维场"),随后**真实重算通道**跑完(59 个样本、2.6 s、正常报"视频已结束"),
    三维面板进入 state=live(62 帧上屏、2 帧显示丢弃、stale=0),标签切到"重算通道:由录像重算,非记录值"。
  · 性能(真 GPU,shipped 包):submit p50 0.26 / p95 0.38 ms,upload p50 0.18 ms,paint p50 0.90 ms,
    latency p50 33.08 ms(= 30 fps 上限);20k 顶点 CPU-only compose p50 5.97 ms(与已提交报告的 6.15 ms 一致)。
  · apps/studio 全量通过;ui_check 583 项 0 失败(连跑两次);locale_check 0 缺失。
  · 修掉 ui_check 自身一处错误断言:场景已加载但本机无 GL context 时,面板显示"无法创建 context"是**正确**行为,
    原断言却要求"不显示任何消息"(偶发失败的原因就是它,与产品无关)。
- Not in this change: 测试里仍直接读 `view._camera`/`widget._renderer`/`coordinator._composer` 等私有量(测试替身已收敛到
  `attach_view`,其余留待需要时再抽);`update_surface` 里每帧重算色标范围(`_valid_scalar_range`)可以做条件化,
  实测开销很小故未动;SDK 对极小合成视频的解码问题(640×480 mp4v)——现在会 10 秒后明确报错而不是空转。
This commit is contained in:
ykcheungab
2026-09-14 19:58:33 +08:00
parent eb1dfbce8a
commit c9a28e7c3a
9 changed files with 429 additions and 132 deletions
+108 -8
View File
@@ -29,6 +29,8 @@ import numpy as np
import pytest
import ui_check
from PyQt5.QtCore import pyqtSignal
from PyQt5.QtWidgets import QWidget
from orisys_app_shared.scene3d import synthetic
from orisys_app_shared.scene3d.contracts import (
ColorScale,
@@ -283,18 +285,33 @@ def test_panel_controls_do_not_touch_the_algorithm_side(make_window):
# ------------------------------------------------------------------ record isolation
class _RecordingView:
class _RecordingView(QWidget):
"""A stand-in for the pane's view that records what it was handed.
Used to attach a *real* consumer to the real sink path on a machine with no GL:
what is being tested is whether the window's second sink can change the record,
not whether the adapter can compute a flow magnitude.
not whether the adapter can compute a flow magnitude. It implements the container's
whole contract (see ``Mesh3dPanel.attach_view``) rather than the one method a
particular test happens to call, so a change to that contract shows up here. It is
a real ``QWidget`` because the container puts it in its stack.
"""
def __init__(self) -> None:
diagnosticRaised = pyqtSignal(object)
enabledChanged = pyqtSignal(bool)
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.packets: list = []
self.coordinator = None
self.assets = None
self.available = True
self.reason = ""
self.source_kind = "live"
self.sessions = 0
self.reference_only = 0
self.closed = 0
# the sink the acquisition thread calls
def submit_packet(self, packet) -> None:
# Deliberately more work than the real one does per packet, so a leak into the
# record path would show up as a difference rather than as a timing artefact.
@@ -302,6 +319,37 @@ class _RecordingView:
frame = np.asarray(packet.raw_frame)
float(np.asarray(frame, dtype=np.float64).sum())
# the container's contract
def set_enabled(self, enabled: bool) -> None:
self.enabled_flag = bool(enabled)
def set_source_kind(self, kind: str) -> None:
self.source_kind = str(kind)
def begin_session(self) -> None:
self.sessions += 1
def show_reference_only(self) -> None:
self.reference_only += 1
def tick(self) -> None:
self.ticks = getattr(self, "ticks", 0) + 1
def close(self) -> None:
self.closed += 1
def settings(self):
return DisplaySettings()
def restore_settings(self, settings) -> None:
self.settings_value = settings
def describe(self) -> dict:
return {"scene": {"asset_id": "double"}, "gl": {}, "display": {}, "stream": {}}
def render_totals(self) -> dict:
return {"shown": 0, "dropped_display": 0, "stale": 0, "invalid": 0}
def render_stats(self):
return None
@@ -315,9 +363,9 @@ def _run_session(make_window, record_root, *, with_3d: bool):
view = None
if with_3d:
view = _RecordingView()
window.mesh3d_panel._view = view
else:
window.mesh3d_panel._view = None
window.mesh3d_panel.attach_view(view)
# With no pane the sink still runs (it is registered on the worker) and does
# nothing, which is the case the comparison below is about.
window.toolbar.btn_record.setChecked(True)
for index in range(96):
stuck.emit(ui_check._fake_packet(index, mono_t=index / 30.0))
@@ -366,7 +414,7 @@ def test_the_3d_sink_is_registered_and_inert_when_the_pane_is_off(make_window, n
assert window._on_packet_for_3d in stuck.sinks(), (
"the 3D sink must be registered with the worker, not attached per toggle"
)
window.mesh3d_panel._view = None
assert window.mesh3d_panel.view is None, "a box sensor must not build a view"
for index in range(10):
stuck.emit(ui_check._fake_packet(index))
assert window.session_store.count == 10
@@ -578,7 +626,7 @@ def test_a_recording_says_what_the_3d_pane_was(
# metadata assembly something to describe (an enabled pane with a scene).
view = _RecordingView()
view.assets = None
window.mesh3d_panel._view = view
window.mesh3d_panel.attach_view(view)
window.toolbar.btn_record.setChecked(True)
for index in range(20):
stuck.emit(ui_check._fake_packet(index, mono_t=index / 30.0))
@@ -673,6 +721,58 @@ def test_the_live_view_still_says_live(make_window, qt_app):
window.close()
def test_every_run_gets_a_new_generation(make_window, qt_app):
"""The window tells the pane when a session starts (see the audit's frozen-pane bug).
Without this the pane accepts the first run's frames and refuses every later run's,
so the picture stays on the first run's last frame forever.
"""
from orisys_app_shared.scene3d.contracts import FrameIdentity
from tests.test_ui_smoke import _show
import ui_check
window = _show(make_window(sensor_type="finger"), (1440, 880))
try:
view = window.mesh3d_panel.view
if view is None or view.coordinator is None:
pytest.skip("no 3D view on this platform")
assert _pump(qt_app, lambda: view.assets is not None, timeout_s=15.0)
stuck = ui_check._StuckWorker()
window._ensure_workers = lambda: None
window._box_worker = stuck
window._finger_worker = stuck
try:
generations = []
for _ in range(2):
window._start_run()
window._run_timer.stop()
generations.append(view.coordinator.generation)
window._stop_run(reason="user_stop")
assert generations[0] < generations[1], generations
# And the frames of the second session are accepted, not refused as stale.
identity = FrameIdentity(
source_id=view._source_id,
session_id=window._session_counter,
frame_id=1,
t_rel_s=0.0,
generation=view.coordinator.generation,
)
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.coordinator.stale_frames == 0, (
"the second session's frames were refused as stale"
)
del identity
finally:
stuck.release()
finally:
window.close()
def test_a_platform_locked_scene_is_refused_in_the_application(qt_app):
"""A lock violation is a *pane* failure: the view refuses the scene and says why."""
from orisys_app_shared.scene3d.contracts import PlatformInfo, PlatformLock