删除深度重算引擎;修好回放视频每帧重开文件导致的无法播放,运输控制改到状态轨主按钮 - What: 1) 彻底删除深度重算引擎:shared/.../record/depth.py(DepthRecomputer/DepthResult)与 depth_backends.py(BoxReplay/FingerReplay)、其专属测试 test_depth_replay.py,record/__init__ 不再导出,playback.py::replay_warnings 不再报 depth_recomputed;apps/studio/replay.py 由 515 行瘦身为只做读盘+游标+视频帧+提示,回放视图的 2D 面板只提供「原始图像」;session.json 的 depth.replay 由 recompute 改为 none;删除 31 个只剩深度使用的文案键(depth_*/warn_state_*/warn_reset_*),warn_config_changed 与 warn_sdk_changed 改为描述"由视频重算";ui_check 深度段、studio 两个深度用例、test_playback 三例、drive_shared 的 test_depth_replay 与 reproduce.py 的深度段一并清理(shared 套件 150→134 项)。2) 无法播放的根因是 RecordingReader.video_frame_at 每帧都 cv2.VideoCapture 重开并 seek:小片段已 ~10 ms/帧,1080p H.264 每次 seek 需从关键帧解码(上百 ms),而播放每 33 ms 要一帧且全在 UI 线程上,视频因此看着不动;改为按分段缓存解码器、顺序帧用 read() 步进(实测 10 ms → 0.5 ms/帧)、跳转才 seek,新增 RecordingReader.close() 释放。运输控制按用户建议上移到状态轨主按钮:toolbar.set_run_role("playback") 使回放视图的主按钮变成「▶ 回放」/「⏸ 暂停」(实时视图仍是「▶ 运行」),点按以 1× 推进游标(墙钟计时,漏拍不变慢)、到末尾自动停,拖动游标/框选/滚轮/拖总览条经 ForcePlotsPanel.timeline_taken_over 自动暂停,空格键同效;曲线表头不再放第二个播放按钮;按钮样式在回放角色下不再套用"停止"的红色语义。 - Why: 用户要求先清掉深度重算引擎、并反馈"现在无法播放视频",建议把上方播放按钮在历史回放形式下改为回放。 - Verification: ui_check.py 554 项 0 失败(新增 seek<250ms/步进<60ms 的解码预算、回放按钮文字随播放/暂停切换、手动拖游标即暂停、切回实时视图按钮恢复运行、按深度视图已移除);test_ui_smoke.py 71 项 0 失败;drive_shared.py 134 项 0 失败(深度引擎用例随代码删除);locale_studio.py 262 键齐全、zh==en;reproduce.py 退出 0;.repro/probe_play.py 实测按「回放」后 0.75 s 内游标推进 0.689 s、面板 12/12 帧都在更新;两张复核图(回放视图的「▶ 回放」、实时视图的「▶ 运行」)。 - Not in this change: 录制文件格式与 CSV 列序不变(raw_*.mp4 与 forces.csv 继续一起写);「由视频重算」复核模式保留(它复用实时管线,是有状态、只能顺序前进的一遍)。

This commit is contained in:
ykcheungab
2026-09-14 09:31:21 +08:00
parent 9b3c5fe46c
commit da8165cf03
19 changed files with 354 additions and 2056 deletions
+51 -29
View File
@@ -246,13 +246,13 @@ class StudioMainWindow(QMainWindow):
# Playback drives the *chart cursor* (the playhead) instead of a slider
# under the panels: the two views share one layout, and the transport is
# a single button on the chart header.
# the rail's primary button — 「运行」while measuring, 「回放」while
# replaying (see ``_on_run_toggled`` / ``toolbar.set_run_role``).
self._play_timer = QTimer(self)
self._play_timer.setInterval(PLAYBACK_TICK_MS)
self._play_timer.timeout.connect(self._on_play_tick)
self._play_started_at = 0.0
self._play_from = 0.0
self.force_plots.play_toggled.connect(self._on_chart_play_toggled)
self.setStatusBar(_NoticeBar())
# The status bar carries **one-off notices** only (a recording finished, a
@@ -526,12 +526,13 @@ class StudioMainWindow(QMainWindow):
"device_sn": self._device_serial(info),
},
# Depth is deliberately NOT stored (decided with the team): it is a
# function of the frame and the sensor configuration, so replay
# recomputes it from raw_*.mp4. The hashes below are what a replay
# checks before trusting that recomputation.
# function of the frame and the sensor configuration, and the replay
# view shows the recorded frames only. Re-running the pipeline over
# raw_*.mp4 is the "recompute" mode's job; the hashes below are what
# such a pass (and any other consumer) checks before trusting it.
"depth": {
"stored": False,
"replay": "recompute",
"replay": "none",
"quantity": "relative deformation / algorithm response, not a calibrated length",
"method": "re-run the SDK over raw_*.mp4 with this config_ref",
"config_sha256": self._config_hash(config_ref),
@@ -616,6 +617,7 @@ class StudioMainWindow(QMainWindow):
self.replay.recording_changed.connect(self._on_recording_changed)
# Dragging the shared cursor on the charts scrubs the recording too.
self.force_plots.cursor_moved.connect(self._on_cursor_moved)
self.force_plots.timeline_taken_over.connect(self._pause_playback)
def _on_cursor_moved(self, t_rel_s: float) -> None:
if self._recompute_running:
@@ -693,11 +695,12 @@ class StudioMainWindow(QMainWindow):
segments = sorted(reader.segments()) if reader is not None else []
if segments:
self.toolbar.set_video_path(str(segments[0]))
self.force_plots.set_playing(self._play_timer.isActive())
self.toolbar.set_run_checked(self._play_timer.isActive())
# Loading a recording is what makes a recompute pass possible at all.
self.act_recompute.setEnabled(
self._view_preset == "replay" and self.replay.is_loaded()
)
self._update_run_enabled()
self._refresh_status_rail()
def _on_replay_position(self, position_s: float) -> None:
@@ -708,8 +711,8 @@ class StudioMainWindow(QMainWindow):
self._set_display_unit("raw" if self.force_plots.unit() == "N" else "N")
# ------------------------------------------------------------- playback
def _on_chart_play_toggled(self, playing: bool) -> None:
"""The transport button on the chart header, like an editor's play/pause."""
def _on_transport_toggled(self, playing: bool) -> None:
"""The rail's primary button in the replay view: play or pause."""
if self._recompute_active():
# A recompute pass is a *run*: play starts the pipeline over the
# video, pause stops it. It can only go forward (the chain is
@@ -719,7 +722,7 @@ class StudioMainWindow(QMainWindow):
self._start_run()
elif self._recompute_running:
self._stop_run(reason="user_stop")
self.force_plots.set_playing(bool(playing) and self._running)
self.toolbar.set_run_checked(bool(playing) and self._running)
return
if playing and self.replay.is_loaded():
self._start_playback()
@@ -729,7 +732,7 @@ class StudioMainWindow(QMainWindow):
def _start_playback(self) -> None:
duration = self.replay.duration_s()
if duration <= 0:
self.force_plots.set_playing(False)
self.toolbar.set_run_checked(False)
return
position = self.replay.position()
if position >= duration - 0.02:
@@ -739,12 +742,15 @@ class StudioMainWindow(QMainWindow):
self._play_from = float(position)
self._play_started_at = time.perf_counter()
self._play_timer.start()
self.force_plots.set_playing(True)
self.toolbar.set_run_checked(True)
self.toolbar.refresh_labels()
def _pause_playback(self) -> None:
if self._play_timer.isActive():
self._play_timer.stop()
self.force_plots.set_playing(False)
if self.toolbar.btn_run.isChecked():
self.toolbar.set_run_checked(False)
self.toolbar.refresh_labels()
def _on_play_tick(self) -> None:
"""Advance the playhead from the wall clock, not from the tick count."""
@@ -809,7 +815,9 @@ class StudioMainWindow(QMainWindow):
self._recompute_running = True
# Playing is what starts the pass: one path in, so the button and the
# menu cannot start it twice.
self.force_plots.set_playing(True)
self.toolbar.set_run_checked(True)
self.toolbar.refresh_labels()
self._on_transport_toggled(True)
return
if self._recompute_running:
self._stop_run(reason="user_stop")
@@ -831,18 +839,6 @@ class StudioMainWindow(QMainWindow):
else:
self.arrow_panel.show_frame(image)
def _replay_depth_scale_text(self) -> str:
"""The depth colour scale, latched per recording by the controller.
Kept for the offline depth engine (``ReplayController.depth_image``); the
replay view itself no longer offers a depth pane, because depth is not
stored with a recording.
"""
span = self.replay.depth_range()
if span is None:
return ""
return t("panels.depth_scale", low=f"{span[0]:.3f}", high=f"{span[1]:.3f}")
def _apply_replay_source(self, active: bool) -> None:
"""Point the charts at the recording (or back at the live session)."""
if active and self.replay.is_loaded():
@@ -1152,6 +1148,9 @@ class StudioMainWindow(QMainWindow):
# visible difference between the two views.
self._pause_playback()
self.toolbar.set_source_view("replay" if preset == "replay" else "live")
# The rail's primary button is this view's main action: acquire while
# measuring, play back while replaying.
self.toolbar.set_run_role("playback" if preset == "replay" else "acquire")
self._apply_panel_visibility()
if apply_sizes and preset in VIEW_SPLITTER_SIZES:
self.content_splitter.setSizes(list(VIEW_SPLITTER_SIZES[preset]))
@@ -1168,6 +1167,7 @@ class StudioMainWindow(QMainWindow):
self._on_recompute_toggled(False)
self.brand_bar.set_active_view(preset if preset != "custom" else None)
self._sync_menu_checks()
self._update_run_enabled()
self._refresh_status_rail()
if persist:
self._persist_prefs()
@@ -1176,6 +1176,22 @@ class StudioMainWindow(QMainWindow):
return self._view_preset
def _update_run_enabled(self) -> None:
if self.toolbar.run_role() == "playback":
# In the replay view the button is a transport: it needs a recording,
# not a runnable sensor. While it plays it stays enabled so it can be
# paused.
loaded = self.replay.is_loaded()
self.toolbar.set_run_tooltip_reason(
"" if loaded else t("toolbar.playback_disabled")
)
self.toolbar.set_run_enabled(loaded or self._play_timer.isActive())
self.toolbar.set_record_enabled(
self._running or self.recorder.is_recording
)
if not self._running:
self.toolbar.refresh_labels()
self._sync_shortcut_actions()
return
sensor_ok = self.sensor_picker.sensor_type() in RUNNABLE_TYPES
source_ok = True
reason = ""
@@ -1809,9 +1825,9 @@ class StudioMainWindow(QMainWindow):
def _on_play_shortcut(self) -> None:
"""Space plays or pauses the recording, if there is one to move through."""
if not self.force_plots.btn_play.isEnabled():
if self.toolbar.run_role() != "playback" or not self.toolbar.btn_run.isEnabled():
return
self.force_plots.btn_play.setChecked(not self.force_plots.btn_play.isChecked())
self.toolbar.btn_run.setChecked(not self.toolbar.btn_run.isChecked())
def _on_run_shortcut(self) -> None:
if not self.toolbar.btn_run.isEnabled():
@@ -1953,6 +1969,10 @@ class StudioMainWindow(QMainWindow):
self.arrow_panel.show_frame(_bgr_to_qimage(frame))
def _on_run_toggled(self, checked: bool) -> None:
if self.toolbar.run_role() == "playback":
# The primary button of the replay view is its transport.
self._on_transport_toggled(bool(checked))
return
if checked:
self._start_run()
else:
@@ -2072,7 +2092,9 @@ class StudioMainWindow(QMainWindow):
self._running = False
# A recompute pass is over: the transport button goes back to "play".
self._recompute_running = False
self.force_plots.set_playing(False)
if self.toolbar.run_role() == "playback" and self.toolbar.btn_run.isChecked():
self.toolbar.set_run_checked(False)
self.toolbar.refresh_labels()
self._set_phase("error" if reason == "failed" else "idle")
self.cal_panel.set_running(False)
self.toolbar.set_run_checked(False)
+47 -346
View File
@@ -1,32 +1,26 @@
"""Replay engine: one recording, one cursor, and the depth it can recompute.
"""Replay engine: one recording, one cursor, one frame at a time.
This is the headless half of the old replay window. It owns the recording
reader, the cursor and the asynchronous depth recomputation, and it *shows*
nothing: the main window renders the frames into the 2D panel, the curves read
the recording through :class:`~orisys_app_shared.force.source.RecordingCurveSource`
and the progress bar drives :meth:`set_position`.
This is the headless half of the replay view. It owns the recording reader and
the cursor and it *shows* nothing: the main window renders the frame into the 2D
panel, the curves read the recording through
:class:`~orisys_app_shared.force.source.RecordingCurveSource`, and the transport
on the chart header drives :meth:`set_position`.
Depth is recomputed on demand rather than read from disk (see
:mod:`orisys_app_shared.record.depth`), so the pane states plainly when it is
unavailable instead of showing a stand-in image.
There is no depth recomputation here: depth is not stored with a recording, the
replay view shows the recorded frames, and re-running the pipeline over a video
is the acquisition path's job (see the studio's "recompute" mode).
"""
from __future__ import annotations
import logging
import threading
from pathlib import Path
import numpy as np
from PyQt5.QtCore import QObject, QTimer, pyqtSignal
from PyQt5.QtCore import QObject, pyqtSignal
from PyQt5.QtGui import QImage
from orisys_app_shared.i18n import t
from orisys_app_shared.record import (
DepthRecomputer,
DepthResult,
RecordingReader,
)
from orisys_app_shared.record import RecordingReader
logger = logging.getLogger(__name__)
@@ -37,14 +31,25 @@ def _bgr_to_qimage(frame: np.ndarray) -> QImage:
return QImage(rgb.data, width, height, channels * width, QImage.Format_RGB888).copy()
def _sdk_version() -> str:
"""The SDK version in use, or "" when the SDK is not importable."""
try:
import orisys
return str(getattr(orisys, "__version__", "") or "")
except Exception: # noqa: BLE001 - a missing SDK is a warning, not a crash
logger.debug("Could not read the SDK version", exc_info=True)
return ""
class ReplayController(QObject):
"""Open one recording and answer "what is at t?" for every view."""
#: The cursor moved; the progress bar and the charts' cursor follow.
#: The cursor moved; the chart cursor and the 2D panel follow.
position_changed = pyqtSignal(float)
#: The frame under the cursor changed (video and/or depth).
#: The frame under the cursor changed.
frames_changed = pyqtSignal()
#: Warnings, depth state or the loaded recording changed.
#: The notes about this recording, or the loaded recording, changed.
status_changed = pyqtSignal()
#: A recording was opened (True) or the previous one was dropped (False).
recording_changed = pyqtSignal(bool)
@@ -52,51 +57,19 @@ class ReplayController(QObject):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self._reader: RecordingReader | None = None
self._recomputer: DepthRecomputer | None = None
self._unit = "raw"
self._position = 0.0
self._depth_ok = False
# A **daemon** thread, not a ThreadPoolExecutor: the SDK call can block
# indefinitely and an executor's non-daemon worker would then keep the
# whole process alive at exit. A blocked daemon thread cannot be
# cancelled either, so it is left to die with the process.
self._depth_thread: threading.Thread | None = None
self._depth_result = None
self._depth_error: BaseException | None = None
#: Which cursor position each depth answer belongs to. Recomputation is
#: asynchronous, so an answer can arrive after the cursor has moved; the
#: request it answers is remembered and compared, never assumed.
self._depth_request_seq = 0
self._depth_pending_seq: int | None = None
self._depth_pending_position: float | None = None
self._depth_result_seq: int | None = None
#: Which recording each answer belongs to (incremented on every open).
self._record_serial = 0
self._depth_pending_record: int | None = None
self._depth_result_record: int | None = None
self._depth_result_frame = -1
#: Processors whose owning request was still running when it was retired.
self._retired_recomputers: list = []
#: The frame cache: one video image per cursor position.
self._video_position: float | None = None
self._video_qimage: QImage | None = None
self._video_reason = ""
self._depth_qimage: QImage | None = None
self._depth_message = ""
self._warnings = ""
#: The depth colour scale, latched to the first recomputed frame of the
#: recording so two frames stay comparable (same rule as the live view).
self._depth_range: tuple[float, float] | None = None
self._depth_poll = QTimer(self)
self._depth_poll.setInterval(120)
self._depth_poll.timeout.connect(self._poll_depth)
# ------------------------------------------------------------- recording
def is_loaded(self) -> bool:
return self._reader is not None
def directory(self) -> Path | None:
def directory(self):
return self._reader.directory if self._reader is not None else None
def reader(self):
@@ -113,82 +86,45 @@ class ReplayController(QObject):
return RecordingCurveSource(self._reader)
def open_recording(self, directory: Path) -> bool:
"""Open a recording. Any depth answer still in flight is invalidated."""
# A running request belongs to the *previous* recording: its result must
# never be pasted into the new one, and its processor must be closed by
# the thread that is using it, not here.
self._retire_recomputer()
self._record_serial += 1
self._depth_request_seq += 1
self._depth_pending_seq = None
self._depth_pending_position = None
self._depth_error = None
def open_recording(self, directory) -> bool:
"""Open a recording directory."""
self._release_reader()
try:
self._reader = RecordingReader(Path(directory))
info = self._reader.info()
self._reader = RecordingReader(directory)
self._reader.info()
except Exception: # noqa: BLE001 - reported through the status text
logger.exception("Could not open recording %s", directory)
self._reader = None
self._recomputer = None
self._reset_frames()
self.recording_changed.emit(False)
self.status_changed.emit()
return False
self._recomputer = DepthRecomputer(self._reader)
self._depth_ok = False
self._depth_result = None
self._depth_result_seq = None
self._depth_range = None
self._reset_frames()
self._position = 0.0
_ = info
self.recording_changed.emit(True)
self._refresh_warnings()
self._refresh_depth_pane()
self.set_position(0.0)
return True
def close(self) -> None:
"""Drop the recording and hand every processor to its owning thread."""
self._depth_poll.stop()
for recomputer in list(self._retired_recomputers):
if not self._depth_busy():
self._retired_recomputers.remove(recomputer)
try:
recomputer.close()
except Exception: # noqa: BLE001
logger.exception("Could not close a retired recomputer")
self._retire_recomputer()
self._reader = None
"""Drop the recording (the decoder is released with it)."""
self._release_reader()
self._reset_frames()
self.recording_changed.emit(False)
self.status_changed.emit()
def _release_reader(self) -> None:
reader, self._reader = self._reader, None
if reader is not None:
try:
reader.close()
except Exception: # noqa: BLE001 - releasing is best effort
logger.debug("Could not close the recording reader", exc_info=True)
def _reset_frames(self) -> None:
self._video_position = None
self._video_qimage = None
self._video_reason = ""
self._depth_qimage = None
self._depth_message = ""
def _retire_recomputer(self) -> None:
"""Hand the current processor to its worker, or close it here if idle.
Closing a processor that a worker thread is still reading is a use-after-
free; only the thread that owns it may close it, so a busy one is marked
for the worker to close when it finishes.
"""
recomputer, self._recomputer = self._recomputer, None
if recomputer is None:
return
if self._depth_busy():
self._retired_recomputers.append(recomputer)
else:
try:
recomputer.close()
except Exception: # noqa: BLE001 - closing is best effort
logger.exception("Could not close the depth recomputer")
# ---------------------------------------------------------------- cursor
def position(self) -> float:
@@ -200,16 +136,12 @@ class ReplayController(QObject):
return float(self._reader.info().duration_s)
def set_position(self, t_rel_s: float) -> None:
"""Move the single cursor: video, depth and charts follow it."""
"""Move the single cursor: the video frame and the charts follow it."""
if self._reader is None:
return
duration = self.duration_s()
self._position = max(0.0, min(float(t_rel_s), duration if duration > 0 else 0.0))
self._refresh_video()
if self._depth_ok or self._depth_result is not None:
# The held depth belongs to the frame it was computed for. Moving the
# cursor must not keep showing it as if it were synchronised.
self._refresh_depth_pane()
self.position_changed.emit(self._position)
self.frames_changed.emit()
@@ -217,20 +149,15 @@ class ReplayController(QObject):
return self._unit
def set_unit(self, unit: str) -> str:
"""Show the recording in ``unit``; the bar and the charts share one.
"""Show the recording in ``unit``; the charts and the rail share one.
Opening a recording that carries no calibrated axis in newton mode would
draw three empty charts, so the caller picks the unit from what the
recording actually has and tells both views about it here.
recording actually has and tells every view about it here.
"""
self._unit = "N" if str(unit).upper().startswith("N") else "raw"
return self._unit
def toggle_unit(self) -> str:
self._unit = "N" if self._unit == "raw" else "raw"
self.status_changed.emit()
return self._unit
# ----------------------------------------------------------------- frames
def video_image(self) -> QImage | None:
self._refresh_video()
@@ -261,12 +188,7 @@ class ReplayController(QObject):
self._video_reason = ""
self._video_qimage = _bgr_to_qimage(image)
def depth_image(self) -> QImage | None:
return self._depth_qimage
def depth_message(self) -> str:
return self._depth_message
# ---------------------------------------------------------------- notes
def warnings_text(self) -> str:
return self._warnings
@@ -275,15 +197,12 @@ class ReplayController(QObject):
if self._reader is None:
return
info = self._reader.info()
_ok, sdk_version = DepthRecomputer.sdk_available()
# The reader compares the recorded config hash against the config file as
# it is now, which is what the depth recomputation would actually use.
codes = self._reader.replay_warnings(sdk_version=sdk_version)
codes = self._reader.replay_warnings(sdk_version=_sdk_version())
text = ""
if codes:
text = t(
"playback.warnings",
detail=", ".join(t(f"playback.warn_{c}") for c in codes),
detail=", ".join(t(f"playback.warn_{code}") for code in codes),
)
if info.decimated:
# The curves are a view, not every sample: say so rather than let the
@@ -295,221 +214,3 @@ class ReplayController(QObject):
)
text = f"{text} {note}" if text else note
self._warnings = text
# -------------------------------------------------------------------- depth
def request_depth(self) -> None:
"""Kick off a recomputation on a worker thread.
Opening a video with the SDK and stepping to a frame can block, so it
must never run on the UI thread — that is exactly how the replay would
freeze.
"""
if self._recomputer is None:
return
problem = self._recomputer.preflight()
if problem:
self._depth_ok = False
self._depth_qimage = None
self._depth_message = t(f"playback.depth_{problem}")
self.status_changed.emit()
return
if self._depth_thread is not None and self._depth_thread.is_alive():
self._depth_message = t("playback.depth_busy")
self.status_changed.emit()
return
self._depth_ok = False
self._depth_result = None
self._depth_result_seq = None
self._depth_error = None
self._depth_message = t("playback.depth_running")
self.status_changed.emit()
position = self._position
# Bind the answer to this request: the recording, the frame and a request
# sequence. A result that arrives after the cursor moved — or after
# another recording was opened — is discarded rather than pasted on.
recomputer = self._recomputer
record = self._record_serial
frame = self._current_frame()
self._depth_request_seq += 1
seq = self._depth_request_seq
self._depth_pending_seq = seq
self._depth_pending_position = position
self._depth_pending_record = record
def work() -> None:
# Runs off the UI thread and touches no Qt object. It captures the
# processor it was given: the controller may already be showing
# another recording by the time this returns.
try:
result = recomputer.depth_at(position)
except BaseException as exc: # noqa: BLE001 - reported via the poll
self._depth_error = (record, exc)
return
finally:
if recomputer in self._retired_recomputers:
# Retired while this request was running: the owning thread
# closes it, not the UI thread.
self._retired_recomputers.remove(recomputer)
try:
recomputer.close()
except Exception: # noqa: BLE001
logger.exception("Could not close a retired recomputer")
self._depth_result = result
self._depth_result_seq = seq
self._depth_result_record = record
self._depth_result_frame = getattr(frame, "frame_id", -1)
self._depth_thread = threading.Thread(
target=work, name="depth-replay", daemon=True
)
self._depth_thread.start()
self._depth_poll.start()
def depth_busy(self) -> bool:
return self._depth_thread is not None and self._depth_thread.is_alive()
# The old spelling, kept for the tests and for readability at call sites.
_depth_busy = depth_busy
def _poll_depth(self) -> None:
"""UI-thread poll for the worker result."""
thread = self._depth_thread
if thread is None or thread.is_alive():
return
self._depth_poll.stop()
pending = self._depth_pending_seq
record = self._depth_pending_record
self._depth_pending_seq = None
self._depth_pending_position = None
self._depth_pending_record = None
if self._depth_error is not None:
error_record, exc = self._depth_error
self._depth_error = None
if error_record != self._record_serial:
# The failure belongs to a recording that is no longer open.
return
self._depth_ok = False
self._depth_qimage = None
self._depth_message = t("playback.depth_failed", detail=str(exc))
self.status_changed.emit()
self.frames_changed.emit()
return
if record != self._record_serial:
# Another recording was opened while this was running.
return
result = self._depth_result
if result is None or self._depth_result_seq != pending:
# A newer request (or a moved cursor) owns the pane now.
self._refresh_depth_pane()
self.frames_changed.emit()
return
if not self._depth_matches_cursor(result):
# The cursor moved while the SDK was reading: do not draw an answer
# for another frame, and say the pane needs recomputing.
self._refresh_depth_pane()
self.frames_changed.emit()
return
self._render_depth_result(result)
self.status_changed.emit()
self.frames_changed.emit()
def _current_frame(self):
"""The recorded frame under the cursor, or None without a frame index."""
frame_at = getattr(self._reader, "frame_at", None)
if not callable(frame_at):
return None
try:
return frame_at(self._position)
except Exception: # noqa: BLE001 - a stub reader has no frame index
return None
def _depth_matches_cursor(self, result) -> bool:
"""True when ``result`` was computed for the frame under the cursor."""
frame = self._current_frame()
frame_id = getattr(result, "frame_id", None)
if frame is not None and frame_id is not None and int(frame_id) >= 0:
return int(frame_id) == int(frame.frame_id)
t_rel = getattr(result, "t_rel_s", None)
if t_rel is None:
# Nothing in the result says which frame it belongs to, so it cannot
# be shown to be stale either.
return True
if frame is not None:
return abs(float(t_rel) - float(frame.t_rel_s)) < 1e-6
return abs(float(t_rel) - self._position) < 1e-9
def _render_depth_result(self, result) -> None:
code = str(getattr(result, "error", "") or "")
if code in ("not_recomputed", "recomputing", "stale"):
# A placeholder, not a failure: the pane describes the cursor.
self._depth_ok = False
self._depth_qimage = None
self._depth_message = t(f"playback.depth_{code}")
return
if not result.ok:
self._depth_ok = False
self._depth_qimage = None
self._depth_message = t("playback.depth_failed", detail=result.error)
return
from orisys_app_shared.acquisition.arrows import depth_map_to_bgr
depth = np.asarray(result.depth)
self._latch_depth_range(depth)
bgr = depth_map_to_bgr(depth, value_range=self._depth_range)
if bgr is None:
self._depth_ok = False
self._depth_qimage = None
self._depth_message = t("playback.depth_empty")
return
self._depth_ok = True
self._depth_message = self._depth_warning_text(result)
self._depth_qimage = _bgr_to_qimage(bgr)
def _latch_depth_range(self, depth: np.ndarray) -> None:
if self._depth_range is not None:
return
finite = np.isfinite(depth)
if depth.size == 0 or not finite.any():
return
low = float(np.nanmin(depth[finite]))
high = float(np.nanmax(depth[finite]))
if high <= low:
high = low + 1.0
self._depth_range = (low, high)
def depth_range(self) -> tuple[float, float] | None:
return self._depth_range
@staticmethod
def _depth_warning_text(result) -> str:
"""Say when a replay could not reproduce the live algorithm state."""
codes = tuple(getattr(result, "warnings", ()) or ())
if not codes:
return ""
return t(
"playback.depth_warnings",
detail=", ".join(t(f"playback.warn_{code}") for code in codes),
)
def _refresh_depth_pane(self) -> None:
"""Re-derive the depth pane for the cursor's frame.
Re-drawing the held map here is what used to show a stale depth as though
it were in sync with the cursor.
"""
result = self._depth_result
if result is not None and self._depth_matches_cursor(result):
self._render_depth_result(result)
return
if (
self._depth_pending_seq is not None
and self._depth_pending_position == self._position
and self._depth_busy()
):
self._render_depth_result(
DepthResult(error="recomputing", t_rel_s=self._position)
)
return
self._render_depth_result(
DepthResult(error="not_recomputed", t_rel_s=self._position)
)
@@ -118,8 +118,9 @@ class ForcePlotsPanel(QFrame):
mode_changed = pyqtSignal(str)
#: The unit chip was clicked: switch between newton and raw.
unit_toggle_requested = pyqtSignal()
#: The play/pause button was pressed (a recording is on screen).
play_toggled = pyqtSignal(bool)
#: The operator took the timeline over (dragged the cursor, box-zoomed, panned
#: or moved the overview marker): playback stops where it is.
timeline_taken_over = pyqtSignal()
def __init__(self, parent=None, *, theme: str = "dark") -> None:
super().__init__(parent)
@@ -175,15 +176,9 @@ class ForcePlotsPanel(QFrame):
self.mode_label.setObjectName("hintText")
header.addWidget(self.mode_label)
header.addStretch(1)
# Play/pause leads the header the way a transport leads an editor: the
# cursor is the playhead, and dragging it pauses.
self.btn_play = self._header_button("play")
self.btn_play.setCheckable(True)
self.btn_play.setEnabled(False)
self.btn_play.toggled.connect(self._on_play_toggled)
header.addWidget(self.btn_play)
# The three window actions are symbols: the tooltip explains each one, and
# the pointer stays inside the chart instead of reading three labels.
# The transport lives on the rail (the primary button of the replay view);
# the header keeps the window actions, which are symbols because the
# tooltip explains each one.
self.btn_latest = self._header_button("latest")
self.btn_latest.clicked.connect(self.follow_latest)
header.addWidget(self.btn_latest)
@@ -334,31 +329,9 @@ class ForcePlotsPanel(QFrame):
button._icon_name = icon_name # noqa: SLF001 - read back by set_theme
return button
def _on_play_toggled(self, playing: bool) -> None:
"""Draw the transport button for the state it is in, then tell the window."""
self._set_play_icon(bool(playing))
self.play_toggled.emit(bool(playing))
def _set_play_icon(self, playing: bool) -> None:
name = "pause" if playing else "play"
self.btn_play._icon_name = name # noqa: SLF001 - read back by set_theme
self.btn_play.setIcon(icons.icon(name, self._theme))
self.btn_play.setToolTip(t("chart.pause_tip" if playing else "chart.play_tip"))
self.btn_play.setAccessibleName(
t("chart.pause" if playing else "chart.play")
)
def set_playing(self, playing: bool) -> None:
"""Reflect playback that the window started or stopped itself."""
if self.btn_play.isChecked() != bool(playing):
self.btn_play.setChecked(bool(playing))
else:
self._set_play_icon(bool(playing))
def pause_playback(self) -> None:
"""A manual gesture takes over the timeline: stop advancing it."""
if self.btn_play.isChecked():
self.btn_play.setChecked(False)
"""The operator touched the timeline: playback stops where it is."""
self.timeline_taken_over.emit()
def _build_value_card(
self, channel: str
@@ -418,7 +391,7 @@ class ForcePlotsPanel(QFrame):
text = color(theme, "text_primary")
muted = color(theme, "text_muted")
# Icons are painted in the theme's own colour, so they follow a switch.
for button in (self.btn_latest, self.btn_overview, self.btn_box_zoom, self.btn_play):
for button in (self.btn_latest, self.btn_overview, self.btn_box_zoom):
name = getattr(button, "_icon_name", "")
if name:
button.setIcon(icons.icon(name, theme))
@@ -1079,9 +1052,6 @@ class ForcePlotsPanel(QFrame):
self._mode == "browse" and not self.is_recording_source()
)
self.btn_overview.setEnabled(self._source_ready)
self.btn_play.setEnabled(self.is_recording_source())
if not self.is_recording_source():
self.pause_playback()
def _reload_from_store(self) -> None:
"""Re-populate the visible buffer from the attached source."""
@@ -1241,7 +1211,6 @@ class ForcePlotsPanel(QFrame):
name_label.setText(t(f"chart.{key}_name"))
self.btn_latest.setToolTip(t("chart.return_latest_tip"))
self.btn_latest.setAccessibleName(t("chart.return_latest"))
self._set_play_icon(self.btn_play.isChecked())
self.btn_overview.setToolTip(t("chart.overview_tip"))
self.btn_overview.setAccessibleName(t("chart.overview"))
self.btn_box_zoom.setToolTip(t("chart.box_zoom_tip"))
+3 -29
View File
@@ -14,13 +14,13 @@ from __future__ import annotations
from functools import lru_cache
from PyQt5.QtCore import QPointF, QRectF, Qt
from PyQt5.QtGui import QColor, QIcon, QPainter, QPen, QPixmap, QPolygonF
from PyQt5.QtCore import QRectF, Qt
from PyQt5.QtGui import QColor, QIcon, QPainter, QPen, QPixmap
from orisys_app_shared.theme import color
#: Names of the icons this module can paint.
NAMES = ("zoom_fit", "box_zoom", "latest", "play", "pause")
NAMES = ("zoom_fit", "box_zoom", "latest")
ICON_SIZE = 16
_PEN_WIDTH = 1.4
@@ -76,36 +76,10 @@ def _paint_latest(painter: QPainter, size: int) -> None:
)
def _paint_play(painter: QPainter, size: int) -> None:
"""A filled triangle: play the recording from the cursor."""
left = size * 0.30
right = size * 0.76
painter.setBrush(painter.pen().color())
painter.drawPolygon(
QPolygonF(
[
QPointF(left, size * 0.22),
QPointF(right, size * 0.50),
QPointF(left, size * 0.78),
]
)
)
def _paint_pause(painter: QPainter, size: int) -> None:
"""Two bars: hold the recording where it is."""
painter.setBrush(painter.pen().color())
width = size * 0.16
for x in (size * 0.32, size * 0.56):
painter.drawRect(QRectF(x, size * 0.24, width, size * 0.52))
_PAINTERS = {
"zoom_fit": _paint_zoom_fit,
"box_zoom": _paint_box_zoom,
"latest": _paint_latest,
"play": _paint_play,
"pause": _paint_pause,
}
+42 -1
View File
@@ -105,6 +105,8 @@ class ToolbarWidget(QWidget):
self._source_view = "live"
self._source_controls_enabled = True
self._video_path = ""
#: ``acquire`` while measuring, ``playback`` while replaying a recording.
self._run_role = "acquire"
outer = QVBoxLayout(self)
outer.setContentsMargins(2, 2, 2, 2)
@@ -369,6 +371,22 @@ class ToolbarWidget(QWidget):
self._source_view = wanted
self._apply_source_mode_ui()
def set_run_role(self, role: str) -> None:
"""What the primary button does in this view: acquire, or play back."""
wanted = "playback" if str(role) == "playback" else "acquire"
if wanted == self._run_role:
return
self._run_role = wanted
# A view switch never carries the other view's running state across.
if self.btn_run.isChecked():
self.btn_run.blockSignals(True)
self.btn_run.setChecked(False)
self.btn_run.blockSignals(False)
self._refresh_run_label()
def run_role(self) -> str:
return self._run_role
def source_view(self) -> str:
return self._source_view
@@ -581,6 +599,15 @@ class ToolbarWidget(QWidget):
self.btn_preview.setText(t("toolbar.preview"))
def _refresh_run_label(self) -> None:
if self._run_role == "playback":
# The primary action of the replay view is playing the recording —
# the same button, doing what this view is for.
self.btn_run.setText(
t("toolbar.playback_pause") if self.btn_run.isChecked() else t("toolbar.playback")
)
self._refresh_emphasis()
self._apply_run_tooltip()
return
if self.btn_run.isChecked():
# While connecting, cancel is the action the user actually needs —
# a device open can hang, and this is the way out of it.
@@ -608,7 +635,14 @@ class ToolbarWidget(QWidget):
# The phase counts as running even if the button has not been repainted
# yet: the rail must describe the session, not the widget's lag.
running = self.btn_run.isChecked() or self._run_phase in ("connecting", "running")
if recording:
if self._run_role == "playback":
# The transport is the replay view's own action, not a session stop:
# filled while it plays, an outline when idle, and never red.
self.btn_record.setObjectName(
"dangerButton" if recording else "primaryButton"
)
self.btn_run.setObjectName("primaryButton" if running else "ghostButton")
elif recording:
self.btn_record.setObjectName("dangerButton")
self.btn_run.setObjectName("dangerOutline")
else:
@@ -624,6 +658,13 @@ class ToolbarWidget(QWidget):
button.style().polish(button)
def _apply_run_tooltip(self) -> None:
if self._run_role == "playback":
key = "toolbar.playback_pause_tip" if self.btn_run.isChecked() else "toolbar.playback_tip"
if not self._run_available and self._run_tooltip_reason:
self.btn_run.setToolTip(self._run_tooltip_reason)
else:
self.btn_run.setToolTip(t(key))
return
if self.btn_run.isChecked():
if self._run_phase == "connecting":
self.btn_run.setToolTip(t("toolbar.cancel_tip"))