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