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
@@ -6,8 +6,25 @@ use, and PyOpenGL decides that at its first import — so the import happens ins
:func:`~orisys_studio.scene3d.gl_backend.GLDispatch.bind_current_context`, once a
real context exists (design document §11.1).
Nothing in the application imports this package yet: the skeleton is inert until
stage 4 wires the panel into ``app_window``.
Who owns what, so a change stays local (stages 4–5 of ``studio-3d-stages.md``):
``view.py``
the assembly. ``Scene3DView`` builds the widget, camera, renderer, composer and
coordinator, owns the tick, and is the **only** module the application talks to
for anything but the pane's chrome.
``coordinator.py``
identity and scheduling. It decides *which* frames are current; it knows nothing
about Qt, GL or the file system.
``adapters.py`` / ``asset_source.py`` / ``prefs.py``
the three boundaries: acquisition arrays in, packages in, preferences in.
``widget.py`` / ``gl_backend.py`` / ``camera.py``
the drawing: Qt context, GL objects, view maths. No acquisition, no policy.
``panel.py``
controls and status text. It reads and writes :class:`DisplaySettings` and never
reaches for a frame.
``ui/mesh3d_panel.py`` (in the ``ui`` package)
the pane's frame in the window: which view, which asset, which state — the place
the window learns "there is no 3D here, and why".
"""
__all__: list[str] = []
@@ -164,6 +164,35 @@ class ThreeDCoordinator:
# Come back on the newest frame rather than replaying what was dropped.
self._set_state("ready" if self._assets is not None else "disabled")
def begin_session(self) -> None:
"""A new acquisition session starts: accept its frames, not the old ones.
``session_id`` is part of a frame's identity and a frame from another session is
refused — that is what keeps a leftover picture from being read as the current
measurement. But a *new* session is not a stale one, and something has to say
so: the application calls this when a run starts (live, or a recompute pass over
a recording). Without it the coordinator latched onto the first session it ever
saw and silently refused **every later session's frames** while still reporting
state ``live`` — which is exactly "the 3D view stopped working after the first
run". A new session is a new generation, so anything already in flight belongs
to the past and is dropped here as well.
"""
if self._closed:
return
self._cancel_inflight()
self._incoming.clear()
self._composed.clear()
self._session_id = None
self._generation += 1
self._last_composed_identity = None
self._last_field_frame = None
self._settings_dirty = False
if self._assets is not None:
# The reference model, not the last measurement: the new session has not
# produced anything yet, and an old picture would be a claim about it.
self._widget.present(self._composer.rest_frame(self._assets, self._identity(0)))
self._set_state("ready" if self._visible else "suspended")
# ---------------------------------------------------------------- producers
def submit(self, frame: SurfaceFieldFrame) -> OfferResult:
"""Offer a field frame from a producer thread. Never blocks on the GUI."""
@@ -186,7 +215,16 @@ class ThreeDCoordinator:
)
return "stale"
if session_id is not None and identity.session_id != session_id:
# Counted *and* reported: a pane that silently refuses a whole session
# looks like a frozen picture, and "a frame from another session was
# dropped" is the sentence that explains it.
self._stale_frames += 1
self._last_diagnostic = diagnostic(
"stale_frame",
"a frame from another acquisition session was dropped",
frame=str(identity.session_id),
scene=str(session_id),
)
return "stale"
problem = self._reject_reason(frame, assets)
if problem is not None:
@@ -18,7 +18,7 @@ Two rules it exists to enforce in the interface:
from __future__ import annotations
from typing import Optional
from typing import TYPE_CHECKING, Optional
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import (
@@ -44,9 +44,11 @@ from orisys_app_shared.scene3d.contracts import (
SceneAssets,
SceneState,
)
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
from orisys_studio.scene3d.prefs import FPS_CHOICES
from orisys_studio.scene3d.widget import Sensor3DWidget
if TYPE_CHECKING: # the panel talks to these two by interface, not by import
from orisys_studio.scene3d.coordinator import ThreeDCoordinator
from orisys_studio.scene3d.widget import Sensor3DWidget
#: Width of one control in the pane. Chosen against
#: ``MIN_PANEL_WIDTHS["mesh3d"]`` (240), not against how it looks on a wide screen:
@@ -315,6 +315,16 @@ class Scene3DView(QWidget):
if self._panel is not None:
self._panel.set_source_kind(kind)
def begin_session(self) -> None:
"""The application is starting an acquisition session for this pane.
Called from the run path, before the first frame of the session arrives: the
coordinator has to know that a new ``session_id`` is legitimate rather than a
leftover (see ``ThreeDCoordinator.begin_session``).
"""
if self._coordinator is not None:
self._coordinator.begin_session()
def show_reference_only(self) -> None:
"""Drop whatever measurement is on screen and show the surface model.
@@ -374,6 +384,81 @@ class Scene3DView(QWidget):
def render_stats(self) -> Optional[RenderStats]:
return self._last_stats
def describe(self) -> dict:
"""What the pane is, for a recording's ``visualization3d`` block.
One method instead of the caller walking ``assets``, ``gl_widget``,
``capabilities()``, ``settings()`` and ``last_descriptor``: the pane owns the
answer to "what was on screen", and the window only has to ask. Everything here
is display state, so it can be recorded without touching the measurement.
"""
assets = self._assets
if assets is None:
return {}
widget = self._widget
capabilities = widget.capabilities() if widget is not None else None
settings = self.settings()
scale = settings.color_scale
descriptor = self._last_descriptor
return {
"scene": {
"asset_id": str(getattr(assets, "asset_id", "")),
"asset_version": str(getattr(assets, "asset_version", "")),
"asset_hash": str(getattr(assets, "asset_hash", "")),
"geometry_id": str(getattr(assets, "geometry_id", "")),
"field_domain_id": str(getattr(assets, "field_domain_id", "")),
"platform_lock": (
assets.platform_lock.to_dict()
if getattr(assets, "platform_lock", None) is not None
else None
),
},
"gl": (
{
"profile": str(capabilities.profile),
"vendor": str(capabilities.vendor),
"renderer": str(capabilities.renderer),
"gl_version": str(capabilities.gl_version),
"glsl_version": str(capabilities.glsl_version),
"software_renderer": bool(capabilities.software_renderer),
}
if capabilities is not None
else {}
),
"display": {
"deform_enabled": bool(settings.deform_enabled),
"display_gain": float(settings.display_gain),
"housing_visible": bool(settings.housing_visible),
"max_fps": int(settings.max_fps),
"scale_mode": str(scale.mode) if scale is not None else "none",
"scale_lower": float(scale.lower) if scale is not None else None,
"scale_upper": float(scale.upper) if scale is not None else None,
},
"stream": (
{
"quantity": str(descriptor.quantity),
"unit": str(descriptor.unit),
"quality": str(descriptor.quality),
"signed": bool(descriptor.signed),
}
if descriptor is not None
else {}
),
}
def render_totals(self) -> dict:
"""Display-side counters: shown frames and display drops, never recording ones."""
coordinator = self._coordinator
stats = self._last_stats
return {
"shown": int(getattr(stats, "displayed_frames", 0) or 0),
"dropped_display": int(
coordinator.superseded_frames if coordinator is not None else 0
),
"stale": int(getattr(coordinator, "stale_frames", 0) or 0),
"invalid": int(getattr(coordinator, "invalid_frames", 0) or 0),
}
def close(self) -> None:
"""Release the GPU resources, stop the worker. Idempotent."""
if self._coordinator is not None: