回放与实时统一为一套界面;修复 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:
@@ -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():
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"))
|
||||
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user