回放与实时统一为一套界面;修复 30 fps 设备被以 16.7 fps 消费的计时器缺陷 - What: 1) 删除 ui/replay_bar.py(进度条、录制目录选择、重算深度按钮、提示行)与回放条的全部接线:历史回放现在与实时监测同布局(分割比例同为 0,360,1080,0)、同一条状态轨、同一套曲线手势,唯一差别是来源槽——实时是摄像机,回放是「选择录制视频…」(toolbar.btn_replay_video,与文件菜单「打开视频…」同一条 _load_replay_video 路径:视频所在目录即录制目录,曲线自动配对同目录 forces.csv)。时间轴改为曲线游标本身:表头新增播放/暂停图标按钮(icons.play/pause,空格键同效)以 1× 推进(按墙钟计时,漏拍不变慢),到末尾自动停;拖动游标、框选、滚轮平移或拖总览条一律自动暂停(ForcePlotsPanel.pause_playback)。回放的降采样/跨版本提示移入来源摘要 tooltip,打开录制时另发一条 6 秒通知。修复视频文件名残留在实时视图的问题:_refresh_status_rail 只在 _view_preset == "replay" 时写视频名。2) 录制格式不变:raw_*.mp4 与 forces.csv/frames.csv/session.json 继续一起写。按「两者都要」新增 视图 → 由视频重算力值(act_recompute):默认曲线读录制的 CSV,打开后把录制视频重新跑一遍视觉管线(进 SessionStore、2D 面板交给管线绘制、只能顺序前进,播放按钮即开始/暂停,关闭即挂回 CSV),启动路径唯一(set_playing → _on_chart_play_toggled → _start_run),并让文件源的时间轴改为视频自身时间(mono_t = 帧号 × 1/fps),否则重算结果会落在与 CSV 不同的横轴上。回放 2D 面板不再提供深度视图(REPLAY_VIEW_MODES=("recorded",)),离线深度重算引擎保留但无界面入口。3) 采集只有 16.67 fps 的根因是上一轮引入的等待:Windows 默认计时器分辨率 15.6 ms,time.sleep(0.002) 实测睡 15.5 ms,重复帧等两拍即跨过 30 fps 的 33 ms 周期,30 fps 设备被以 ~16.7 fps 消费并隔帧丢弃(配置本身正确:mini/sensor.json 写 camera.fps=30;离线实测整套管线 15.2 ms/帧≈66 fps,余量充足)。新增 _high_resolution_timer() 在会话期间把分辨率提到 1 ms(结束还原),并把重复帧等待改为 min(距下一帧时间, 半个周期)、到期只 sleep(0)。 - Why: 用户评审 3 点——回放界面与实时不一致且拖拽条效果一般、不想为回放录制深度信息、想同时保留视频与 CSV 记录、以及采集帧率只有 16.67 fps(应为 30)。 - Verification: ui_check.py 562 项 0 失败(新增两视图同布局/无 bar、来源槽随视图切换且无残留、播放暂停与手动手势暂停、重算模式切换与视频源、回放提示在 tooltip);test_ui_smoke.py 73 项 0 失败(新增视图统一、传输控制、重算模式);drive_shared.py 150 项 0 失败(新增 shared/tests/test_worker_frame_rate.py::test_a_file_source_runs_on_the_video_s_own_timeline,并把 30 fps 用例改成必须落在 27–31.5 fps;同文件实测无 1 ms 计时器 21.6 fps、有则 30.0 fps,同一帧返回 3 次时 11317:39);locale_studio.py 266 键齐全、zh==en;reproduce.py 退出 0;ui_snapshot.py 三张复核图(统一后的回放、实时、选区中英)。 - Not in this change: 录制文件格式与 CSV 列序不变;离线深度重算引擎(ReplayController.request_depth / DepthRecomputer)保留并有独立测试,但界面已无入口,可下一步删除;重算模式用有损 mp4,数值与原 CSV 会有差异(这正是它要显式呈现的)。

This commit is contained in:
ykcheungab
2026-09-13 22:04:01 +08:00
parent e6a1e77a5b
commit 9b3c5fe46c
17 changed files with 896 additions and 387 deletions
+259 -72
View File
@@ -61,7 +61,6 @@ from orisys_studio.ui.plot_settings_dialog import (
DEFAULT_PLOT_WINDOW_SEC,
PlotSettingsDialog,
)
from orisys_studio.ui.replay_bar import ReplayBar
from orisys_studio.ui.sensor_picker import SensorPickerWidget
from orisys_studio.ui.toolbar import ToolbarWidget
@@ -82,15 +81,21 @@ SESSION_PHASES = ("idle", "connecting", "running", "error")
VIEW_PRESETS = ("custom", "live", "replay", "calibration")
#: Splitter sizes per view, in panel order (3D, 2D, charts, calibration).
#: ``custom`` keeps whatever the user arranged (see ``_restore_splitter_sizes``).
#: Replay is the live layout on purpose: the two views differ in their source
#: (a camera, or a recorded video), not in their furniture.
VIEW_SPLITTER_SIZES = {
"live": (0, 360, 1080, 0),
"replay": (0, 320, 1120, 0),
"replay": (0, 360, 1080, 0),
"calibration": (0, 0, 560, 880),
}
#: The 3D panel only belongs to the live view, and only for a finger sensor.
VIEW_SHOWS_3D = ("custom", "live")
#: After this long in ``connecting``, tell the user that Stop is the way out.
CONNECT_SLOW_HINT_S = 8.0
#: Playback tick while a recording is playing. 33 ms is one frame at 30 fps; the
#: cursor is advanced from the wall clock, so a missed tick does not slow the
#: recording down.
PLAYBACK_TICK_MS = 33
def _bgr_to_qimage(frame: np.ndarray) -> QImage:
@@ -145,6 +150,8 @@ class StudioMainWindow(QMainWindow):
self._connect_slow_notified = False
#: Whether this run has already said "showing raw, nothing is calibrated".
self._raw_fallback_notified = False
#: True while the run over the video of the recompute mode is going.
self._recompute_running = False
self._preview = None
self._preview_timer = QTimer(self)
self._preview_timer.setInterval(33)
@@ -237,12 +244,15 @@ class StudioMainWindow(QMainWindow):
self.content_splitter.setChildrenCollapsible(False)
root.addWidget(self.content_splitter, 1)
# The replay bar lives under the panels (not inside the splitter): it is
# a timeline about the whole session, and it appears only in the replay
# view. The splitter keeps its four panels and its four stored sizes.
self.replay_bar = ReplayBar()
self.replay_bar.setVisible(False)
root.addWidget(self.replay_bar)
# 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.
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
@@ -583,6 +593,7 @@ class StudioMainWindow(QMainWindow):
self.toolbar.camera_id_changed.connect(self._on_camera_id_changed)
self.toolbar.sensor_clicked.connect(self._open_sensor_dialog)
self.toolbar.use_camera_clicked.connect(self._on_use_camera)
self.toolbar.replay_video_clicked.connect(self._on_open_recording)
self.toolbar.run_toggled.connect(self._on_run_toggled)
self.toolbar.record_toggled.connect(self._on_record_toggled)
self.toolbar.reset_clicked.connect(self._on_reset)
@@ -598,11 +609,7 @@ class StudioMainWindow(QMainWindow):
self.force_plots.unit_toggle_requested.connect(self._on_chart_unit_toggle)
def _wire_replay(self) -> None:
"""The bar drives the engine; the engine updates the bar and the views."""
self.replay_bar.open_requested.connect(self._on_open_recording)
self.replay_bar.position_requested.connect(self.replay.set_position)
self.replay_bar.unit_toggled.connect(self._on_replay_unit_toggled)
self.replay_bar.depth_requested.connect(self.replay.request_depth)
"""The controller feeds the views; the chart is the only timeline."""
self.replay.position_changed.connect(self._on_replay_position)
self.replay.frames_changed.connect(self._render_replay_frame)
self.replay.status_changed.connect(self._refresh_replay_state)
@@ -611,28 +618,47 @@ class StudioMainWindow(QMainWindow):
self.force_plots.cursor_moved.connect(self._on_cursor_moved)
def _on_cursor_moved(self, t_rel_s: float) -> None:
if self._recompute_running:
# The recompute pass owns the timeline: its samples are arriving in
# order and the cursor follows them.
return
if self._view_preset == "replay" and self.replay.is_loaded():
self.replay.set_position(float(t_rel_s))
def _on_open_recording(self) -> None:
"""Choose a recording and switch to the view that replays it."""
from orisys_app_shared.record import list_recordings
"""Choose a recorded video and switch to the view that replays it.
chosen = QFileDialog.getExistingDirectory(
self, t("playback.open"), str(recordings_dir())
The recording a video belongs to is its own directory, so the picker
takes the *video* (what the operator recognises) and the charts pair it
with the ``forces.csv`` written beside it.
"""
start = str(recordings_dir())
chosen, _filter = QFileDialog.getOpenFileName(
self,
t("playback.open"),
start,
t("playback.video_filter"),
)
if not chosen:
return
self._load_recording(Path(chosen))
self._load_replay_video(Path(chosen))
def _load_replay_video(self, video: Path) -> bool:
"""Open the recording a recorded video belongs to."""
video = Path(video)
folder = video.parent if video.is_file() else video
if not self._load_recording(folder):
return False
self.toolbar.set_video_path(str(video))
self._refresh_status_rail()
return True
def _load_recording(self, directory: Path) -> bool:
if self._view_preset != "replay":
self._apply_view_preset("replay")
opened = self.replay.open_recording(Path(directory))
if not opened:
self.statusBar().showMessage(
t("playback.open_failed", error=Path(directory).name)
)
self._notify(t("playback.open_failed", error=Path(directory).name))
return False
self._refresh_replay_state()
return True
@@ -643,7 +669,7 @@ class StudioMainWindow(QMainWindow):
found = list_recordings(recordings_dir())
if not found:
self.statusBar().showMessage(t("playback.none_found"))
self._notify(t("playback.none_found"))
return
self._load_recording(Path(found[-1]))
@@ -651,53 +677,152 @@ class StudioMainWindow(QMainWindow):
"""A recording was opened (or dropped): re-point everything that reads it."""
self._refresh_replay_state()
if not opened:
self._pause_playback()
return
if self._view_preset == "replay":
self._apply_replay_source(True)
self._render_replay_frame()
def _refresh_replay_state(self) -> None:
"""Push the engine's state into the bar, the charts' mode and the rail."""
loaded = self.replay.is_loaded()
duration = self.replay.duration_s() if loaded else 0.0
self.replay_bar.set_loaded(loaded, duration)
if loaded:
info = self.replay.info()
name = self.replay.directory().name if self.replay.directory() else ""
self.replay_bar.set_info(
t(
"playback.loaded_short",
name=name,
frames=len(info.frames) if info is not None else 0,
duration=f"{duration:.1f}",
)
)
self.replay_bar.set_position(self.replay.position(), duration)
self.replay_bar.set_warnings(self.replay.warnings_text())
self.replay_bar.set_unit(self.replay.unit())
"""Push the engine's state into the charts' mode and the rail."""
if self.replay.is_loaded() and not self.toolbar.video_path():
# Opened without naming a video (the file menu's "latest"): name the
# segment that is on screen so the rail states the same thing the
# picker would.
reader = self.replay.reader()
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())
# 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._refresh_status_rail()
def _on_replay_position(self, position_s: float) -> None:
self.replay_bar.set_position(position_s, self.replay.duration_s())
self.force_plots.set_cursor(float(position_s))
def _on_replay_unit_toggled(self) -> None:
"""The replay's unit switch drives the same unit the charts display."""
unit = self.replay.toggle_unit()
self.replay_bar.set_unit(unit)
self._set_display_unit(unit)
"""The chart's unit chip drives the same unit the replay reads."""
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."""
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
# stateful), so playing again after the end starts from the top.
if playing:
self._recompute_running = True
self._start_run()
elif self._recompute_running:
self._stop_run(reason="user_stop")
self.force_plots.set_playing(bool(playing) and self._running)
return
if playing and self.replay.is_loaded():
self._start_playback()
else:
self._pause_playback()
def _start_playback(self) -> None:
duration = self.replay.duration_s()
if duration <= 0:
self.force_plots.set_playing(False)
return
position = self.replay.position()
if position >= duration - 0.02:
# At the end: playing again means playing from the start.
self._set_replay_position(0.0)
position = 0.0
self._play_from = float(position)
self._play_started_at = time.perf_counter()
self._play_timer.start()
self.force_plots.set_playing(True)
def _pause_playback(self) -> None:
if self._play_timer.isActive():
self._play_timer.stop()
self.force_plots.set_playing(False)
def _on_play_tick(self) -> None:
"""Advance the playhead from the wall clock, not from the tick count."""
duration = self.replay.duration_s()
elapsed = time.perf_counter() - self._play_started_at
position = self._play_from + elapsed
if position >= duration:
self._set_replay_position(duration)
self._pause_playback()
return
self._set_replay_position(position)
def _set_replay_position(self, position_s: float) -> None:
"""Move the playhead without the position signal echoing back into it."""
self.replay.set_position(float(position_s))
def _recompute_active(self) -> bool:
return bool(self.act_recompute.isChecked())
def _replay_video_segment(self) -> Path | None:
"""The recorded video a recompute pass should read."""
reader = self.replay.reader()
segments = sorted(reader.segments()) if reader is not None else []
if segments:
return Path(segments[0])
named = self.toolbar.video_path()
if named and Path(named).is_file():
return Path(named)
return None
def _on_recompute_toggled(self, checked: bool) -> None:
"""Off: read the recorded CSV. On: re-run the pipeline over the video.
The CSV is the session's own measurement. Re-running the vision chain
over the recorded (lossy) video answers a different question — "what
would this algorithm version say about that footage" — and it can only
run *forward*, because the chain is stateful, so it is a pass rather than
a scrub.
"""
wanted = bool(checked) and self._view_preset == "replay" and self.replay.is_loaded()
if self.act_recompute.isChecked() != wanted:
self.act_recompute.blockSignals(True)
self.act_recompute.setChecked(wanted)
self.act_recompute.blockSignals(False)
self._pause_playback()
if wanted:
segment = self._replay_video_segment()
if segment is None:
self._notify(t("playback.no_video"))
self.act_recompute.blockSignals(True)
self.act_recompute.setChecked(False)
self.act_recompute.blockSignals(False)
return
# The charts stop reading the CSV; the pipeline's own samples become
# the data, and the 2D panel shows what it produces. The pass starts
# here so the switch is not a blank chart waiting for a second click.
self.force_plots.clear_recording_source()
self.arrow_panel.set_source_kind("live")
self.toolbar.set_source_mode("video")
self.toolbar.set_video_path(str(segment))
self._refresh_status_rail()
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)
return
if self._recompute_running:
self._stop_run(reason="user_stop")
if self._view_preset == "replay" and self.replay.is_loaded():
self._apply_replay_source(True)
self._render_replay_frame()
def _render_replay_frame(self) -> None:
"""Draw what the 2D panel should show for the current replay cursor."""
if self._view_preset != "replay" or not self.replay.is_loaded():
return
if self.arrow_panel.view_mode() == "depth":
self.arrow_panel.set_depth_scale(self._replay_depth_scale_text())
image = self.replay.depth_image()
if image is None:
self.arrow_panel.clear_preview(self.replay.depth_message())
else:
self.arrow_panel.show_frame(image)
if self._recompute_running:
# The pipeline is drawing its own frames (see ``_replay_owns_image``).
return
self.arrow_panel.set_depth_scale(None)
image = self.replay.video_image()
@@ -707,7 +832,12 @@ class StudioMainWindow(QMainWindow):
self.arrow_panel.show_frame(image)
def _replay_depth_scale_text(self) -> str:
"""The depth colour scale, latched per recording by the controller."""
"""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 ""
@@ -724,10 +854,10 @@ class StudioMainWindow(QMainWindow):
# the recording can honour it.
wanted = "N" if self.force_plots.unit() == "N" else "raw"
unit = self._replay_unit_for(reader)
self.replay_bar.set_unit(self.replay.set_unit(unit))
self.replay.set_unit(unit)
self._set_display_unit(unit)
if unit != wanted:
self.statusBar().showMessage(t("status.units_raw_fallback"))
self._notify(t("status.units_raw_fallback"))
self.force_plots.show_all()
self.arrow_panel.set_source_kind("replay")
return
@@ -829,6 +959,14 @@ class StudioMainWindow(QMainWindow):
self.act_playback_latest.triggered.connect(self._open_latest_recording)
self.file_menu.addAction(self.act_playback_latest)
# The transport is on the chart header; the space bar is the editing
# convention everyone's hands already know.
self.act_play = QAction(self)
self.act_play.setShortcut("Space")
self.act_play.setShortcutContext(Qt.ApplicationShortcut)
self.act_play.triggered.connect(self._on_play_shortcut)
self.addAction(self.act_play)
self.view_menu = menu_bar.addMenu("")
self.act_cal_panel = QAction(self)
self.act_cal_panel.setCheckable(True)
@@ -861,6 +999,15 @@ class StudioMainWindow(QMainWindow):
self.act_rotate_arrows = QAction(self)
self.act_rotate_arrows.triggered.connect(self._on_rotate_arrows)
self.view_menu.addAction(self.act_rotate_arrows)
# The replay reads the recorded CSV (the values the session actually
# measured). This switches it to re-running the vision pipeline over the
# recorded video instead — an algorithm check, not the record.
self.act_recompute = QAction(self)
self.act_recompute.setCheckable(True)
self.act_recompute.setChecked(False)
self.act_recompute.toggled.connect(self._on_recompute_toggled)
self.view_menu.addAction(self.act_recompute)
self.view_menu.addSeparator()
self.act_run = QAction(self)
@@ -932,6 +1079,9 @@ class StudioMainWindow(QMainWindow):
self.act_finger_3d.blockSignals(True)
self.act_finger_3d.setChecked(bool(self._show_finger_3d) if is_finger else False)
self.act_finger_3d.blockSignals(False)
self.act_recompute.setEnabled(
self._view_preset == "replay" and self.replay.is_loaded()
)
self._apply_arrow_rotation_for_sensor()
def _apply_panel_visibility(self) -> None:
@@ -997,21 +1147,25 @@ class StudioMainWindow(QMainWindow):
self._show_cal = True
elif preset in ("live", "replay"):
self._show_cal = False
# The rail shows the picker this view needs: a camera while acquiring,
# nothing while replaying (the replay bar owns the recording picker).
# The rail shows the source this view needs, and only that source: a
# camera while measuring, a recorded video while replaying. It is the one
# visible difference between the two views.
self._pause_playback()
self.toolbar.set_source_view("replay" if preset == "replay" else "live")
self._apply_panel_visibility()
self.replay_bar.setVisible(preset == "replay")
if apply_sizes and preset in VIEW_SPLITTER_SIZES:
self.content_splitter.setSizes(list(VIEW_SPLITTER_SIZES[preset]))
if preset == "replay":
# History browsing is the point of this view: the charts read the
# open recording, and the bar scrubs it. Without a recording the
# charts stay on the live session and follow it.
# open recording, and the chart cursor is its timeline. Without a
# recording the charts stay on the live session and follow it.
self._apply_replay_source(True)
self._refresh_replay_state()
elif preset == "live":
self._apply_replay_source(False)
# Leaving the replay must not leave its video on the rail: the live
# view is a camera, and the summary says so (see ``_refresh_status_rail``).
self._on_recompute_toggled(False)
self.brand_bar.set_active_view(preset if preset != "custom" else None)
self._sync_menu_checks()
self._refresh_status_rail()
@@ -1053,6 +1207,8 @@ class StudioMainWindow(QMainWindow):
self.act_settings.setText(t("menu.settings"))
self.act_plot_settings.setText(t("menu.plot_settings"))
self.act_rotate_arrows.setText(t("menu.rotate_arrows"))
self.act_recompute.setText(t("menu.recompute"))
self.act_recompute.setToolTip(t("menu.recompute_tip"))
self.act_run.setText(t("menu.run"))
self.act_stop.setText(t("menu.stop"))
self.act_preview.setText(t("menu.preview"))
@@ -1123,16 +1279,27 @@ class StudioMainWindow(QMainWindow):
)
)
# The picker on the rail already names the camera, so the summary carries
# only what it cannot: the video file in use, and the frame rate.
if self.toolbar.source_mode() == "video":
# only what it cannot: the frame rate, and — **in the replay view only** —
# the video part being played. Naming the video in the live view was how a
# replay's file stayed on the rail after switching back to the camera.
detail = ""
if self._view_preset == "replay":
path = self.toolbar.video_path()
detail = t("status.rail_video_name", name=Path(path).name if path else "?")
else:
detail = ""
if path:
detail = t("status.rail_video_name", name=Path(path).name)
if self._show_fps and self._latest_fps is not None:
fps = f"{self._latest_fps:.1f} FPS"
detail = f"{detail} · {fps}" if detail else fps
self.toolbar.set_source_summary(detail)
# The full path and the replay's notes do not fit on a 30 px rail, so
# they live in the summary's tooltip (the bar that used to show them is
# gone: the two views share one layout).
tip_parts = [self.toolbar.video_path()]
if self._view_preset == "replay" and self.replay.is_loaded():
tip_parts.append(self.replay.warnings_text())
self.toolbar.set_source_summary_tip(
"\n".join(part for part in tip_parts if part)
)
self.toolbar.set_calibration_summary(self._calibration_summary())
if self._last_sample_valid is None:
self.toolbar.set_data_state("")
@@ -1330,6 +1497,9 @@ class StudioMainWindow(QMainWindow):
"""
if sync_toolbar:
self.toolbar.set_units("newton" if unit == "N" else "raw")
if self.replay.is_loaded():
# One unit for the charts, the rail and the replay controller.
self.replay.set_unit(unit)
self.prefs["units"] = self.toolbar.units()
self.force_plots.set_fixed_newton(unit == "N")
# Rebuild the visible curves from the attached source (the session store
@@ -1637,6 +1807,12 @@ class StudioMainWindow(QMainWindow):
self.act_preview.setEnabled(self.toolbar.btn_preview.isEnabled())
self.act_reset.setEnabled(self.toolbar.btn_reset.isEnabled())
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():
return
self.force_plots.btn_play.setChecked(not self.force_plots.btn_play.isChecked())
def _on_run_shortcut(self) -> None:
if not self.toolbar.btn_run.isEnabled():
return
@@ -1661,7 +1837,12 @@ class StudioMainWindow(QMainWindow):
self._persist_prefs()
def _on_browse_video(self) -> None:
"""Acquire from a video file instead of a camera (file menu)."""
"""Open a recorded video for replay (file menu).
Choosing a video is the replay view's source selection, so this takes the
same path as the rail's button: a recorded video is something to *play*,
not a second way to acquire.
"""
if self._running or self._preview_open:
return
start = self.toolbar.video_path() or str(Path.home())
@@ -1669,13 +1850,11 @@ class StudioMainWindow(QMainWindow):
self,
t("menu.open_video"),
start,
"Video (*.mp4 *.avi *.mov *.mkv);;All (*)",
t("playback.video_filter"),
)
if not path:
return
self.toolbar.set_video_path(path)
self.prefs["video_path"] = path
self._set_source_mode("video")
self._load_replay_video(Path(path))
def _on_reveal_config(self) -> None:
sensor_type = self.sensor_picker.sensor_type()
@@ -1891,6 +2070,9 @@ class StudioMainWindow(QMainWindow):
self._active_worker.stop(wait=wait)
was_connecting = self._session_phase == "connecting"
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)
self._set_phase("error" if reason == "failed" else "idle")
self.cal_panel.set_running(False)
self.toolbar.set_run_checked(False)
@@ -2148,9 +2330,14 @@ class StudioMainWindow(QMainWindow):
Without this the live renderer overwrote the replay's frame on every
packet *and* on every preview tick, so a replay running next to an active
session (or a preview) looked as if it had opened the camera instead of
the recording.
the recording. A recompute pass is the pipeline drawing again, so it owns
the panel while it runs.
"""
return self._view_preset == "replay" and self.replay.is_loaded()
return (
self._view_preset == "replay"
and self.replay.is_loaded()
and not self._recompute_running
)
def _render_run_image(self, packet) -> None:
if packet is None or self._replay_owns_image():
+1 -1
View File
@@ -244,7 +244,7 @@ class ReplayController(QObject):
if self._reader is None:
self._video_position = None
self._video_qimage = None
self._video_reason = t("playback.empty")
self._video_reason = t("playback.none_found")
return
if self._video_position == self._position:
return
@@ -19,9 +19,9 @@ from orisys_app_shared.theme import color
from orisys_studio.ui.fit_text import ShrinkableLabel
VIEW_MODES = ("arrows_pure", "arrows_stitched", "depth")
#: While replaying a recording there is no optical flow to draw: the frames come
#: from the recorded video, and the only processed view is the recomputed depth.
REPLAY_VIEW_MODES = ("recorded", "depth")
#: While replaying a recording there is no optical flow to draw and no depth is
#: stored: the panel shows the recorded frames, and nothing else.
REPLAY_VIEW_MODES = ("recorded",)
_VIEW_LABELS = {
"arrows_pure": "panels.view_arrows_pure",
"arrows_stitched": "panels.view_arrows_stitched",
@@ -118,6 +118,8 @@ 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)
def __init__(self, parent=None, *, theme: str = "dark") -> None:
super().__init__(parent)
@@ -173,6 +175,13 @@ 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.
self.btn_latest = self._header_button("latest")
@@ -325,6 +334,32 @@ 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)
def _build_value_card(
self, channel: str
) -> tuple[QFrame, QLabel, QLabel, QLabel, QLabel, QLabel]:
@@ -383,7 +418,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):
for button in (self.btn_latest, self.btn_overview, self.btn_box_zoom, self.btn_play):
name = getattr(button, "_icon_name", "")
if name:
button.setIcon(icons.icon(name, theme))
@@ -781,6 +816,7 @@ class ForcePlotsPanel(QFrame):
value = float(self.cursors[0].value())
if value == self._cursor_t:
return
self.pause_playback()
self.set_cursor(value, emit=True)
def _on_box_zoom_toggled(self, checked: bool) -> None:
@@ -806,6 +842,7 @@ class ForcePlotsPanel(QFrame):
x0, x1 = float(view_range[0][0]), float(view_range[0][1])
if not (x1 > x0):
return
self.pause_playback()
first, last = self._source.extent()
if last <= first:
return
@@ -1012,6 +1049,7 @@ class ForcePlotsPanel(QFrame):
start, end = (float(v) for v in self.overview_region.getRegion())
if not (end > start):
return
self.pause_playback()
first, last = self._source.extent()
if last <= first:
return
@@ -1035,11 +1073,15 @@ class ForcePlotsPanel(QFrame):
self.mode_label.setText(t(key))
self.mode_label.setToolTip(t(f"{key}_tip"))
# "Back to latest" only means something while reading a live session; a
# recording has no latest sample to return to.
# recording has no latest sample to return to. The transport exists only
# where there is a recording to move through.
self.btn_latest.setEnabled(
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."""
@@ -1199,6 +1241,7 @@ 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"))
+29 -3
View File
@@ -14,13 +14,13 @@ from __future__ import annotations
from functools import lru_cache
from PyQt5.QtCore import QRectF, Qt
from PyQt5.QtGui import QColor, QIcon, QPainter, QPen, QPixmap
from PyQt5.QtCore import QPointF, QRectF, Qt
from PyQt5.QtGui import QColor, QIcon, QPainter, QPen, QPixmap, QPolygonF
from orisys_app_shared.theme import color
#: Names of the icons this module can paint.
NAMES = ("zoom_fit", "box_zoom", "latest")
NAMES = ("zoom_fit", "box_zoom", "latest", "play", "pause")
ICON_SIZE = 16
_PEN_WIDTH = 1.4
@@ -76,10 +76,36 @@ 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,
}
@@ -1,158 +0,0 @@
"""Replay bar: pick a recording, scrub it, and read what it can tell you.
One full-width line under the panels, shown only in the history/replay view:
the recording picker, the draggable progress bar that *is* the playhead, the
position readout, the unit switch, the depth recomputation, and a second line
for the replay's own warnings (decimation, cross-version notes, depth state).
The bar owns no replay logic: it renders what
:class:`~orisys_studio.replay.ReplayController` reports and reports gestures
back through signals.
"""
from __future__ import annotations
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import (
QHBoxLayout,
QLabel,
QPushButton,
QSlider,
QVBoxLayout,
QWidget,
)
from orisys_app_shared.i18n import t
from orisys_studio.ui.fit_text import ShrinkableLabel
#: Slider resolution. Fine enough for frame-level scrubbing of a long session.
SLIDER_STEPS = 2000
class ReplayBar(QWidget):
"""Progress bar and controls for the recording being replayed."""
#: The user asked to choose another recording.
open_requested = pyqtSignal()
#: The user dragged the progress bar to this relative time.
position_requested = pyqtSignal(float)
#: The user asked to switch between newton and raw.
unit_toggled = pyqtSignal()
#: The user asked to recompute the depth for the current position.
depth_requested = pyqtSignal()
def __init__(self, parent=None) -> None:
super().__init__(parent)
self._duration = 0.0
self._loaded = False
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(2)
row = QHBoxLayout()
row.setSpacing(8)
self.btn_open = QPushButton()
self.btn_open.setProperty("compact", "true")
self.btn_open.clicked.connect(self.open_requested.emit)
self.info_label = ShrinkableLabel()
self.info_label.setObjectName("panelMeta")
self.slider = QSlider(Qt.Horizontal)
self.slider.setRange(0, SLIDER_STEPS)
self.slider.setTracking(True)
self.slider.setEnabled(False)
self.slider.valueChanged.connect(self._on_slider)
self.position_label = QLabel()
self.position_label.setObjectName("panelMeta")
self.position_label.setMinimumWidth(120)
self.btn_unit = QPushButton()
self.btn_unit.setProperty("compact", "true")
self.btn_unit.clicked.connect(self.unit_toggled.emit)
self.btn_depth = QPushButton()
self.btn_depth.setProperty("compact", "true")
self.btn_depth.clicked.connect(self.depth_requested.emit)
row.addWidget(self.btn_open)
row.addWidget(self.info_label, 1)
row.addWidget(self.slider, 3)
row.addWidget(self.position_label)
row.addWidget(self.btn_unit)
row.addWidget(self.btn_depth)
root.addLayout(row)
self.warning_label = ShrinkableLabel()
self.warning_label.setObjectName("hintText")
self.warning_label.setWordWrap(False)
root.addWidget(self.warning_label)
self.retranslate()
self.set_loaded(False, 0.0)
# ------------------------------------------------------------------ state
def set_loaded(self, loaded: bool, duration_s: float = 0.0) -> None:
self._loaded = bool(loaded)
self._duration = max(0.0, float(duration_s))
self.slider.setEnabled(self._loaded and self._duration > 0)
self.btn_unit.setEnabled(self._loaded)
self.btn_depth.setEnabled(self._loaded)
if not self._loaded:
self.info_label.setText(t("playback.empty"))
self.position_label.setText("")
self.warning_label.setText("")
self.slider.blockSignals(True)
self.slider.setValue(0)
self.slider.blockSignals(False)
def set_info(self, text: str) -> None:
self.info_label.setText(str(text or ""))
self.info_label.setToolTip(self.info_label.text())
def set_position(self, position_s: float, duration_s: float | None = None) -> None:
"""Reflect the controller's cursor without echoing it back."""
if duration_s is not None:
self._duration = max(0.0, float(duration_s))
self.position_label.setText(
t(
"playback.position",
position=f"{max(0.0, float(position_s)):.2f}",
duration=f"{self._duration:.2f}",
)
)
if self._duration <= 0:
return
value = int(SLIDER_STEPS * max(0.0, min(float(position_s), self._duration)) / self._duration)
self.slider.blockSignals(True)
self.slider.setValue(value)
self.slider.blockSignals(False)
def set_warnings(self, text: str) -> None:
self.warning_label.setText(str(text or ""))
self.warning_label.setToolTip(self.warning_label.text())
self.warning_label.setVisible(bool(text))
def set_depth_enabled(self, enabled: bool) -> None:
self.btn_depth.setEnabled(bool(enabled) and self._loaded)
def set_unit(self, unit: str) -> None:
self.btn_unit.setText(
t("playback.show_raw") if unit == "N" else t("playback.show_newton")
)
def duration_s(self) -> float:
return self._duration
# -------------------------------------------------------------- internals
def _on_slider(self, value: int) -> None:
if not self._loaded or self._duration <= 0:
return
self.position_requested.emit(self._duration * (value / SLIDER_STEPS))
def retranslate(self) -> None:
self.btn_open.setText(t("playback.open"))
self.btn_open.setToolTip(t("playback.open_tip"))
self.btn_depth.setText(t("playback.recompute_depth"))
self.btn_depth.setToolTip(t("playback.recompute_depth_tip"))
self.slider.setToolTip(t("playback.scrub_tip"))
self.set_unit("N")
if not self._loaded:
self.info_label.setText(t("playback.empty"))
+29 -4
View File
@@ -77,6 +77,8 @@ class ToolbarWidget(QWidget):
sensor_clicked = pyqtSignal()
#: "Back to the camera" while a video file is the run source.
use_camera_clicked = pyqtSignal()
#: Replay view: pick the recorded video to play.
replay_video_clicked = pyqtSignal()
run_toggled = pyqtSignal(bool)
reset_clicked = pyqtSignal()
units_changed = pyqtSignal(str)
@@ -193,8 +195,21 @@ class ToolbarWidget(QWidget):
self.btn_use_camera.setMinimumHeight(BUTTON_HEIGHT)
self.btn_use_camera.clicked.connect(self.use_camera_clicked.emit)
video_layout.addWidget(self.btn_use_camera)
# The replay view's source is a recorded video: one action in the same
# slot the live view uses for its camera. The file itself is named by the
# summary next to it, so the rail reads the same way in both views.
self.replay_group = QWidget()
replay_layout = QHBoxLayout(self.replay_group)
replay_layout.setContentsMargins(0, 0, 0, 0)
replay_layout.setSpacing(6)
self.btn_replay_video = QPushButton()
self.btn_replay_video.setProperty("compact", "true")
self.btn_replay_video.setMinimumHeight(BUTTON_HEIGHT)
self.btn_replay_video.clicked.connect(self.replay_video_clicked.emit)
replay_layout.addWidget(self.btn_replay_video)
slot.addWidget(self.camera_group)
slot.addWidget(self.video_group)
slot.addWidget(self.replay_group)
self.btn_record = QPushButton()
self.btn_record.setObjectName("primaryButton")
@@ -313,6 +328,10 @@ class ToolbarWidget(QWidget):
self.source_summary.setToolTip(self.source_summary.text())
self.source_summary.setVisible(bool(text))
def set_source_summary_tip(self, text: str) -> None:
"""What the summary cannot fit: the full path, and any replay notes."""
self.source_summary.setToolTip(str(text or ""))
def set_calibration_summary(self, text: str) -> None:
self.calibration_label.setText(str(text or ""))
self.calibration_label.setToolTip(self.calibration_label.text())
@@ -405,6 +424,9 @@ class ToolbarWidget(QWidget):
self.btn_search.setEnabled(allowed and camera)
self.cam_id_spin.setEnabled(allowed and camera)
self.btn_use_camera.setEnabled(allowed and not camera)
# The replay's video picker belongs to the replay view, and follows the
# same rule as the camera picker: not while a session is running.
self.btn_replay_video.setEnabled(bool(enabled) and not live)
def set_source_controls_enabled(self, enabled: bool) -> None:
self._source_controls_enabled = bool(enabled)
@@ -518,15 +540,16 @@ class ToolbarWidget(QWidget):
"""Show the picker the current view needs, and nothing else.
Live + camera: the camera id and its search button. Live + video: a
one-click way back to the camera (the file itself is chosen from the
file menu and named by the rail's summary). Replay: neither — the replay
bar owns the recording picker.
one-click way back to the camera (that source is the recompute pass's).
Replay: the recorded video, which is the one difference between the two
views' rails.
"""
live = self._source_view == "live"
video = self.source_mode() == "video"
self.camera_group.setVisible(live and not video)
self.video_group.setVisible(live and video)
self.source_slot.setVisible(live)
self.replay_group.setVisible(not live)
self.source_slot.setVisible(True)
self.set_source_controls_enabled(self._source_controls_enabled)
if video and self.btn_preview.isChecked():
self.set_preview_checked(False)
@@ -536,6 +559,7 @@ class ToolbarWidget(QWidget):
path = self.video_path()
self.source_summary.setToolTip(path or self.source_summary.text())
self.btn_use_camera.setToolTip(t("toolbar.use_camera_tip"))
self.btn_replay_video.setToolTip(t("toolbar.choose_video_tip"))
def _on_preview_toggled(self, checked: bool) -> None:
self._refresh_preview_label()
@@ -619,6 +643,7 @@ class ToolbarWidget(QWidget):
self.cam_id_label.setText(t("toolbar.cam_id"))
self.btn_search.setText(t("toolbar.search_cameras"))
self.btn_use_camera.setText(t("toolbar.use_camera"))
self.btn_replay_video.setText(t("toolbar.choose_video"))
self.refresh_labels()
self.btn_reset.setText(t("toolbar.reset"))
self.units_label.setText(t("toolbar.units"))