feat(studio-3d): 历史回放里的三维面板 —— 由「重算」通道喂它,并标注来源

- What: `VIEW_SHOWS_3D` 加入 `replay`;`app_window` 新增 `_shows_3d()`(可见性与布局共用一个判据)
  与 `_splitter_sizes_for()`(预设表里的 3D 槽是"二维布局"的描述,面板打开时从最宽的面板借
  `DEFAULT_3D_PANE_WIDTH=340`);`ThreeDPanel` 新增第三个来源标签(实时采集 / 回放:录像不含三维场 /
  重算通道:由录像重算,非记录值)与 `set_source_kind()`;`ThreeDCoordinator.show_rest()` 把参考模型
  放回屏幕并停止主张测量值(重算停止时调用,否则最后一帧会留在屏幕上);`Mesh3dPanel` 转发这两者,
  并新增"加载中"状态的修正:切换传感器再切回来时若该包已加载,直接显示已就绪的画面而不是一条永远
  不会被替换的"正在加载"。
- Why: 录像里没有三维场(raw_*.mp4 是原始相机图像,`depth.stored=false`,CSV 只有力值),所以纯回放只能
  显示参考模型;但「从视频重算力值」本来就把同一个运行路径指向录像的原始视频,而采集线程上的三维 sink
  早已注册——因此重算一开始面板就会收到真实的形变帧。这两件事必须由界面区分:一个是"没有测量",
  一个是"当前算法对这段录像会说什么",把后者当成记录值展示就是另一种 `display_illustrative`。
- Verification: apps/studio 全量通过(test_scene3d_view.py 32 项,含"回放里面板可见且标注正确""重算时
  标注切换""停止重算后清回参考模型""实时视图标为实时";test_scene3d_coordinator.py 15 项,含 show_rest
  与"停止时在飞的帧不会随后被显示");ui_check 570 项 0 失败(新增"已安装包必须被加载并标注来源");
  locale_check 0 缺失;仓库内工程包经 validate_scene 全部 ok。
- Not in this change: 逐帧保存展开场/深度(记录格式变更,设计文档明确否掉);重算通道本身(已存在,本次
  只是把它的帧接到面板上);示意位移(仍需 SDK lift + 已确认尺度)。
This commit is contained in:
ykcheungab
2026-09-14 17:13:37 +08:00
parent 2226bdde68
commit 981c250559
13 changed files with 515 additions and 27 deletions
@@ -278,6 +278,24 @@ class ThreeDCoordinator:
# the last field frame has to be composed again.
self._settings_dirty = True
def show_rest(self) -> None:
"""Put the reference model back on screen and stop claiming a measurement.
Needed by the replay view: while a recompute pass runs it feeds this
coordinator through the ordinary acquisition sink, and when the pass stops the
last recomputed frame would otherwise stay on screen — a picture of a
measurement that is no longer being made, which is exactly what the rest frame
exists to prevent. Idempotent, and safe before any scene is active.
"""
if self._closed or self._assets is None or not self._visible:
return
self._incoming.clear()
self._cancel_inflight()
self._last_field_frame = None
self._last_composed_identity = None
self._widget.present(self._composer.rest_frame(self._assets, self._identity(0)))
self._set_state("no_data")
def close(self) -> None:
"""Cancel work, stop the timer, clear both mailboxes. Idempotent."""
if self._closed:
@@ -81,6 +81,7 @@ class ThreeDPanel(QFrame):
self._assets: Optional[SceneAssets] = None
self._descriptor: Optional[FieldDescriptor] = None
self._state: SceneState = "disabled"
self._source_kind = "live"
self._syncing = False
root = QVBoxLayout(self)
@@ -184,6 +185,16 @@ class ThreeDPanel(QFrame):
self.legend_label.setMinimumWidth(1)
root.addWidget(self.legend_label)
#: Which *source* the field on screen came from. A separate line from the
#: scene state, because they answer different questions: the state says whether
#: anything is being measured, this says whether it was acquired live or
#: recomputed from a recording — a distinction the design document insists on.
self.source_label = QLabel()
self.source_label.setObjectName("panelMeta")
self.source_label.setWordWrap(True)
self.source_label.setMinimumWidth(1)
root.addWidget(self.source_label)
self.state_label = QLabel()
self.state_label.setObjectName("hintText")
self.state_label.setWordWrap(True)
@@ -246,6 +257,26 @@ class ThreeDPanel(QFrame):
self.state_label.setText(t(f"scene3d.state_{state}"))
self.state_label.setVisible(state != "live")
def set_source_kind(self, kind: str) -> None:
"""Say where the field comes from: ``live``, ``replay`` or ``recompute``.
Three states, and the difference between the last two is the point of the
whole replay story:
* ``live`` — acquired from the device now;
* ``replay`` — a recording is open and **no** 3D field is stored in it, so the
pane is showing the surface model only;
* ``recompute`` — a recompute pass is running over the recording's video, so
what is on screen is what *this* algorithm version says about that footage.
It is not the recorded measurement, and the label says so.
"""
kind = str(kind)
if kind not in ("live", "replay", "recompute"):
kind = "live"
self._source_kind = kind
self.source_label.setText(t(f"scene3d.source_{kind}"))
self.source_label.setToolTip(t(f"scene3d.source_{kind}_tip"))
def set_diagnostic(self, diagnostic: Optional[Diagnostic]) -> None:
"""One diagnostic line. The 2D views stay usable behind it."""
if diagnostic is None:
@@ -383,4 +414,6 @@ class ThreeDPanel(QFrame):
self.fps_label.setToolTip(t("scene3d.max_fps_tip"))
self.reset_button.setText(t("scene3d.reset_view"))
self.state_label.setText(t(f"scene3d.state_{self._state}"))
self.source_label.setText(t(f"scene3d.source_{self._source_kind}"))
self.source_label.setToolTip(t(f"scene3d.source_{self._source_kind}_tip"))
self.set_descriptor(self._descriptor)
@@ -310,6 +310,23 @@ class Scene3DView(QWidget):
self._camera.reset(self._assets.bounds_sensor_m)
self._widget.update()
def set_source_kind(self, kind: str) -> None:
"""Tell the pane where its field comes from (see ``ThreeDPanel``)."""
if self._panel is not None:
self._panel.set_source_kind(kind)
def show_reference_only(self) -> None:
"""Drop whatever measurement is on screen and show the surface model.
Used when a recompute pass over a recording stops: without this the last
recomputed frame would stay on screen while nothing is measuring it any more.
"""
if self._coordinator is not None:
self._coordinator.show_rest()
if self._panel is not None:
self._panel.set_descriptor(None)
self._panel.set_state("no_data")
# ------------------------------------------------------------------ state
@property
def available(self) -> bool: