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