删除深度重算引擎;修好回放视频每帧重开文件导致的无法播放,运输控制改到状态轨主按钮 - 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:
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user