cd D:\orisys_ws\orisys-app

git add apps/studio/config/locales/zh_CN.json `
        apps/studio/config/locales/en_US.json `
        apps/studio/tests/test_locales.py `
        apps/studio/tools/ui_check.py `
        apps/studio/tests/test_ui_smoke.py `
        apps/studio/src/orisys_studio/app_window.py `
        apps/studio/src/orisys_studio/ui/cal_panel.py `
        apps/studio/src/orisys_studio/ui/playback_dialog.py `
        apps/studio/src/orisys_studio/ui/force_plots.py `
        apps/studio/docs/record-channel.md `
        shared/src/orisys_app_shared/force/calibration_recorder.py `
        shared/src/orisys_app_shared/record/__init__.py `
        shared/src/orisys_app_shared/record/playback.py `
        shared/src/orisys_app_shared/record/depth.py `
        shared/tests/test_record_channel.py `
        shared/tests/test_playback.py
This commit is contained in:
ykcheungab
2026-09-13 14:01:24 +08:00
parent 92da51217c
commit 9354d7be1a
14 changed files with 1689 additions and 35 deletions
+12 -17
View File
@@ -523,11 +523,12 @@ class CalPanel(QFrame):
CalibrationPointDialog(point, theme=self._theme, parent=self).exec_()
def _on_toggle_excluded(self) -> None:
row = self.table.currentRow()
point = self._current_point()
if point is None:
if point is None or self._recorder is None:
return
self._recorder.set_excluded(row, not point.excluded)
# Pass the point itself: a table row index would resolve against the
# recorder's cross-channel list and could hit the wrong point.
self._recorder.set_excluded(point, not point.excluded)
self._rebuild_store()
self._refresh_table()
self.status_message.emit(
@@ -535,11 +536,10 @@ class CalPanel(QFrame):
)
def _on_toggle_validation(self) -> None:
row = self.table.currentRow()
point = self._current_point()
if point is None:
if point is None or self._recorder is None:
return
self._recorder.set_validation(row, not point.is_validation)
self._recorder.set_validation(point, not point.is_validation)
self._rebuild_store()
self._refresh_table()
self.status_message.emit(
@@ -551,7 +551,7 @@ class CalPanel(QFrame):
self._store.clear()
if self._recorder is None:
return
for point in self._recorder.points:
for point in self._recorder.all_points():
if point.excluded:
continue
stats = point.loaded_stats()
@@ -568,30 +568,25 @@ class CalPanel(QFrame):
)
def _on_delete_row(self) -> None:
row = self.table.currentRow()
point = self._current_point()
if point is None or self._recorder is None:
return
# Deleting is an exclusion: the recorded evidence stays, but it leaves
# the fit and the export.
self._recorder.set_excluded(row, True)
self._recorder.set_excluded(point, True)
self._rebuild_store()
self._refresh_table()
def _on_clear_channel(self) -> None:
self._store.clear(self._channel)
if self._recorder is not None:
with self._recorder._lock: # noqa: SLF001 - panel owns the view
self._recorder.points = [
p for p in self._recorder.points if p.channel != self._channel
]
self._recorder.clear_points(self._channel)
self._rebuild_store()
self._refresh_table()
def _on_clear_all(self) -> None:
self._store.clear()
if self._recorder is not None:
with self._recorder._lock: # noqa: SLF001
self._recorder.points = []
self._recorder.clear_points()
self._rebuild_store()
self._refresh_table()
def _on_export(self) -> None:
@@ -0,0 +1,380 @@
"""Synchronised replay of a recording: video, depth and force curves, one cursor.
Depth is recomputed on demand rather than read from disk (see
:mod:`orisys_app_shared.record.depth`), so the depth pane states plainly when it
is unavailable instead of showing a stand-in image.
The cursor is the single source of truth for the position: moving it updates the
video frame, the depth pane and the charts' cursor line together.
"""
from __future__ import annotations
import threading
from pathlib import Path
import numpy as np
from PyQt5.QtCore import Qt, QTimer, pyqtSignal
from PyQt5.QtGui import QImage, QPixmap
from PyQt5.QtWidgets import (
QDialog,
QFileDialog,
QHBoxLayout,
QLabel,
QPushButton,
QSlider,
QSplitter,
QVBoxLayout,
)
from orisys_app_shared.i18n import t
from orisys_app_shared.paths import recordings_dir
from orisys_app_shared.record import DepthRecomputer, RecordingReader, list_recordings
from orisys_app_shared.record.playback import FORCE_COLUMNS
from orisys_app_shared.theme import color
try:
import pyqtgraph as pg
except ImportError as exc: # pragma: no cover
raise ImportError("pyqtgraph is required for replay") from exc
CHANNEL_TOKENS = ("plot_fn", "plot_fx", "plot_fy")
SLIDER_STEPS = 2000
class PlaybackDialog(QDialog):
"""Replay one experiment directory."""
#: Emitted when the replay cursor moves, so the main charts can follow it.
cursor_moved = pyqtSignal(float)
def __init__(self, *, theme: str = "dark", directory: Path | None = None, parent=None) -> None:
super().__init__(parent)
self._theme = theme
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
self._depth_poll = QTimer(self)
self._depth_poll.setInterval(120)
self._depth_poll.timeout.connect(self._poll_depth)
self.setWindowTitle(t("playback.title"))
self.resize(1080, 640)
root = QVBoxLayout(self)
root.setSpacing(8)
controls = QHBoxLayout()
self.open_button = QPushButton()
self.open_button.clicked.connect(self._on_open)
controls.addWidget(self.open_button)
self.path_label = QLabel()
self.path_label.setObjectName("hintText")
controls.addWidget(self.path_label, 1)
self.unit_button = QPushButton()
self.unit_button.setProperty("compact", "true")
self.unit_button.clicked.connect(self._on_toggle_unit)
controls.addWidget(self.unit_button)
self.depth_button = QPushButton()
self.depth_button.setProperty("compact", "true")
self.depth_button.clicked.connect(self._on_recompute_depth)
controls.addWidget(self.depth_button)
root.addLayout(controls)
self.warning_label = QLabel()
self.warning_label.setObjectName("hintText")
self.warning_label.setWordWrap(True)
root.addWidget(self.warning_label)
splitter = QSplitter(Qt.Horizontal)
self.video_label = QLabel()
self.video_label.setAlignment(Qt.AlignCenter)
self.video_label.setMinimumSize(280, 200)
self.video_label.setWordWrap(True)
splitter.addWidget(self.video_label)
self.depth_label = QLabel()
self.depth_label.setAlignment(Qt.AlignCenter)
self.depth_label.setMinimumSize(200, 160)
self.depth_label.setWordWrap(True)
splitter.addWidget(self.depth_label)
splitter.setSizes([420, 320])
root.addWidget(splitter, 1)
self.plot_widget = pg.GraphicsLayoutWidget()
self.plot_widget.setBackground(color(theme, "plot_bg"))
self.plot_widget.setMinimumHeight(180)
root.addWidget(self.plot_widget, 1)
self.curves = []
self.cursor_lines = []
for row, token in enumerate(CHANNEL_TOKENS):
plot = self.plot_widget.addPlot(row=row, col=0)
plot.showGrid(x=True, y=True, alpha=0.25)
plot.setMouseEnabled(x=False, y=False)
pen = pg.mkPen(color(theme, "text_secondary"))
for axis_name in ("left", "bottom"):
plot.getAxis(axis_name).setPen(pen)
plot.getAxis(axis_name).setTextPen(pen)
plot.setLabel("left", ("FN", "FX", "FY")[row], color=color(theme, token))
self.curves.append(plot.plot(pen=pg.mkPen(color(theme, token), width=2)))
line = pg.InfiniteLine(pos=0.0, angle=90, movable=False, pen=pg.mkPen("#888888"))
plot.addItem(line, ignoreBounds=True)
self.cursor_lines.append(line)
position_row = QHBoxLayout()
self.slider = QSlider(Qt.Horizontal)
self.slider.setRange(0, SLIDER_STEPS)
self.slider.setEnabled(False)
self.slider.valueChanged.connect(self._on_slider)
position_row.addWidget(self.slider, 1)
self.position_label = QLabel()
self.position_label.setObjectName("panelMeta")
position_row.addWidget(self.position_label)
root.addLayout(position_row)
if directory is not None:
self.open_recording(Path(directory))
# ------------------------------------------------------------------ opening
def _on_open(self) -> None:
start = str(recordings_dir())
chosen = QFileDialog.getExistingDirectory(self, t("playback.open"), start)
if not chosen:
return
try:
self.open_recording(Path(chosen))
except Exception as exc: # noqa: BLE001
self.warning_label.setText(t("playback.open_failed", error=str(exc)))
def open_recording(self, directory: Path) -> None:
if self._recomputer is not None:
self._recomputer.close()
self._recomputer = None
self._reader = RecordingReader(directory)
self._recomputer = DepthRecomputer(self._reader)
info = self._reader.info()
self.path_label.setText(
t(
"playback.loaded",
name=directory.name,
frames=len(info.frames),
duration=f"{info.duration_s:.1f}",
)
)
self.slider.setEnabled(info.duration_s > 0)
self._render_curves()
self._refresh_warnings()
self._depth_ok = False
self.set_position(0.0)
def _refresh_warnings(self) -> None:
if self._reader is None:
return
_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)
if not codes:
self.warning_label.setText("")
return
self.warning_label.setText(
t("playback.warnings", detail=", ".join(t(f"playback.warn_{c}") for c in codes))
)
# ------------------------------------------------------------------ drawing
def _render_curves(self) -> None:
if self._reader is None:
return
info = self._reader.info()
if info.times.size == 0:
for curve in self.curves:
curve.setData([], [])
return
values = info.value_for_unit(self._unit)
for index, curve in enumerate(self.curves):
column = values[:, index]
finite = np.isfinite(column)
curve.setData(info.times[finite], column[finite])
self.plot_widget.ci.layout.setRowStretchFactor(index, 1)
self.plot_widget.getItem(index, 0).setXRange(
float(info.times[0]), float(info.times[-1]), padding=0.02
)
def _on_toggle_unit(self) -> None:
self._unit = "N" if self._unit == "raw" else "raw"
self._render_curves()
self.set_position(self._position)
# ------------------------------------------------------------------ cursor
def position(self) -> float:
return self._position
def _on_slider(self, value: int) -> None:
if self._reader is None:
return
duration = self._reader.info().duration_s
self.set_position(duration * (value / SLIDER_STEPS))
def set_position(self, t_rel_s: float) -> None:
"""Move the single cursor: video, depth and charts follow it."""
if self._reader is None:
return
duration = self._reader.info().duration_s
self._position = max(0.0, min(float(t_rel_s), duration if duration > 0 else 0.0))
self.slider.blockSignals(True)
if duration > 0:
self.slider.setValue(int(SLIDER_STEPS * self._position / duration))
self.slider.blockSignals(False)
self.position_label.setText(
t("playback.position", position=f"{self._position:.2f}", duration=f"{duration:.2f}")
)
for line in self.cursor_lines:
line.setValue(self._position)
self._show_video_frame()
if self._depth_ok:
self._show_depth_frame()
self.cursor_moved.emit(self._position)
def _show_video_frame(self) -> None:
if self._reader is None:
return
image, frame = self._reader.video_frame_at(self._position)
if image is None:
reason = (
t("playback.frame_dropped")
if frame is not None and not frame.in_video
else t("playback.frame_unreadable")
)
self.video_label.setPixmap(QPixmap())
self.video_label.setText(reason)
return
rgb = image[:, :, ::-1].copy()
height, width, channels = rgb.shape
qimage = QImage(
rgb.data, width, height, channels * width, QImage.Format_RGB888
).copy()
pixmap = QPixmap.fromImage(qimage).scaled(
self.video_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation
)
self.video_label.setPixmap(pixmap)
self.video_label.setText("")
# -------------------------------------------------------------------- depth
def _on_recompute_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 window
would freeze.
"""
if self._recomputer is None:
return
problem = self._recomputer.preflight()
if problem:
self._depth_ok = False
self.depth_label.setPixmap(QPixmap())
self.depth_label.setText(t(f"playback.depth_{problem}"))
return
if self._depth_thread is not None and self._depth_thread.is_alive():
self.depth_label.setText(t("playback.depth_busy"))
return
self._depth_ok = False
self._depth_result = None
self._depth_error = None
self.depth_label.setText(t("playback.depth_running"))
position = self._position
def work() -> None:
# Runs off the UI thread and touches no Qt object.
try:
self._depth_result = self._recomputer.depth_at(position)
except BaseException as exc: # noqa: BLE001 - reported via the poll
self._depth_error = exc
self._depth_thread = threading.Thread(
target=work, name="depth-replay", daemon=True
)
self._depth_thread.start()
self._depth_poll.start()
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()
if self._depth_error is not None:
self._depth_ok = False
self.depth_label.setPixmap(QPixmap())
self.depth_label.setText(
t("playback.depth_failed", detail=str(self._depth_error))
)
return
result = self._depth_result
if result is None:
return
self._render_depth_result(result)
def _render_depth_result(self, result) -> None:
if not result.ok:
self._depth_ok = False
self.depth_label.setPixmap(QPixmap())
self.depth_label.setText(t("playback.depth_failed", detail=result.error))
return
from orisys_app_shared.acquisition.arrows import depth_map_to_bgr
bgr = depth_map_to_bgr(np.asarray(result.depth))
if bgr is None:
self._depth_ok = False
self.depth_label.setPixmap(QPixmap())
self.depth_label.setText(t("playback.depth_empty"))
return
self._depth_ok = True
rgb = bgr[:, :, ::-1].copy()
height, width, channels = rgb.shape
qimage = QImage(
rgb.data, width, height, channels * width, QImage.Format_RGB888
).copy()
pixmap = QPixmap.fromImage(qimage).scaled(
self.depth_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation
)
self.depth_label.setPixmap(pixmap)
self.depth_label.setText("")
def _show_depth_frame(self) -> None:
"""Re-render the last result at the new position (no SDK work)."""
if self._depth_result is not None:
self._render_depth_result(self._depth_result)
# ------------------------------------------------------------------- labels
def retranslate(self) -> None:
self.setWindowTitle(t("playback.title"))
self.open_button.setText(t("playback.open"))
self.unit_button.setText(
t("playback.show_raw") if self._unit == "N" else t("playback.show_newton")
)
self.depth_button.setText(t("playback.recompute_depth"))
self.depth_button.setToolTip(t("playback.recompute_depth_tip"))
if self._reader is None:
self.video_label.setText(t("playback.empty"))
self.depth_label.setText(t("playback.depth_hint"))
self.path_label.setText("")
def closeEvent(self, event) -> None: # noqa: N802
self._depth_poll.stop()
# A depth recomputation that is blocked inside the SDK cannot be
# cancelled; it is a daemon thread, so it does not keep the process
# alive and the window always closes.
if self._recomputer is not None and not self._depth_busy():
self._recomputer.close()
super().closeEvent(event)
def _depth_busy(self) -> bool:
return self._depth_thread is not None and self._depth_thread.is_alive()