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