回放并入主窗口,并修掉开场尖峰 / 录制时长 / 录制帧率 / 回放被摄像头覆盖等 9 项评审问题 - What: 回放不再是独立窗口——删除 PlaybackDialog,拆成 replay.py::ReplayController(读盘、游标、深度重算,只发信号)与 ui/replay_bar.py(录制选择、可拖拽进度条、单位、重算深度、提示行),品牌轨「历史回放」直接换主窗口布局,文件菜单两条入口都切到该视图,曲线改读 RecordingCurveSource(区间按需读盘 + 缓存包络),2D 面板切「原始图像 / 深度图」;传感器与标定合并到 SensorDialog(类型/配置下拉 + 当前生效标定 + 最近导出系数),标定面板加纵向滚动区、九按钮两行、六列表头 ResizeToContents。评审 9 项:1) 开场尖峰两个根因——push() 遇单位变化只记 _unit 而保留旧单位样本(raw 计数被画在牛顿轴上),改为「切换即换单位」:清缓冲按新单位重建、触发切换的那一帧仍刷数值卡;_start_run 先 session_store.reset() 再 set_store(),新会话不再带上次的尾巴(复现脚本 .repro/probe_item1.py:旧 39 样本峰值 9.841 → 新 37 样本峰值 2.390)。2) 箭头页脚改为「箭头按位移放大 ×{factor}」。3) 表头单位芯片由只读 QLabel 改为可点按钮,一次点击同时改曲线、数值卡与设置区下拉。4-6) 全程总览/框选/返回最新 改为 ui/icons.py 按主题令牌运行时绘制的图标,tooltip 与 accessibleName 保留文字,全程总览区域名仍是文字。7) stats() 在录制进行中即返回 _last_t - _t0,记录条开始计时(此前只在 _finalise 写、整场停在 00:00.0)。8) 容器 fps 改用传感器配置的速率(packet.fps 是"每秒完成多少帧"的计数器,突发/文件源会读到 48),视频按 1/fps 节流并把超前的帧计入 resampled_video(frames.csv/forces.csv 一行不少,报告写明原因),session.json 同时记 fps 与 measured_fps。9) _replay_owns_image() 让回放视图独占 2D 面板,实时帧不再覆盖;push() 在录制源挂载时连单位都不切,曲线/数值卡/单位一并归录制。附带:数值卡改读游标处样本(_readout_at_cursor)、回放按录制实际有的单位打开(_replay_unit_for + ReplayController.set_unit,避免 raw-only 录制在 newton 偏好下画三张空图)、视频速率下限。 - Why: 用户评审 9 点,其中 1/7/8/9 是代码缺陷(开场尖峰、录制时长不更新、录制 48fps 与实际 30fps 不符、回放时 2D 面板仍被摄像头刷新),2-6 是可读性问题(×2 含义不明、单位与视图控制应可点/应符号化)。 - Verification: ui_check.py 524 项 0 失败(新增 chart header and unit chip / acquisition correctness / replay owns the 2D pane 三段)、test_ui_smoke.py 69 项 0 失败(新增 7 个回归用例:单位切换不混单位、单位芯片、图标语义、录制中时长、传感器帧率+节流、回放独占面板、回放游标读数与单位回退)、drive_shared.py 146 项 0 失败、locale_studio.py(268 键齐全、zh==en、prefs 对齐)、reproduce.py、ui_snapshot.py 四张复核图(深色实时/回放、浅色 1280x720 实时与标定)。 - Not in this change: 录制文件格式(forces.csv 列序、frames.csv、session.json 路径与分段规则)不变,节流只影响"哪些帧进视频",并显式计数上报;三维面板仍是占位符。

This commit is contained in:
ykcheungab
2026-09-13 20:46:27 +08:00
parent 35e86ec1f7
commit 824544fc7a
20 changed files with 3129 additions and 834 deletions
+334 -125
View File
@@ -39,6 +39,7 @@ from orisys_app_shared.paths import recordings_dir
from orisys_app_shared.record import RecordConfig
from orisys_app_shared.record.sample import to_record_sample
from orisys_studio.recording import RecordingCoordinator, record_config_from_prefs
from orisys_studio.replay import ReplayController
from orisys_app_shared.i18n import set_locale, t
from orisys_app_shared.logging import report_error
from orisys_app_shared.paths import logo_path, sensor_json_path
@@ -60,6 +61,7 @@ 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
@@ -117,7 +119,6 @@ class StudioMainWindow(QMainWindow):
self._theme = str(self.prefs.get("theme") or "dark")
self._language = str(self.prefs.get("language") or "zh_CN")
self._show_cal = bool(self.prefs.get("show_cal_panel", False))
self._layout_preset = str(self.prefs.get("layout_preset") or "default")
preset = str(self.prefs.get("view_preset") or "custom")
self._view_preset = preset if preset in VIEW_PRESETS else "custom"
self._show_finger_3d = bool(self.prefs.get("show_finger_3d", True))
@@ -141,6 +142,10 @@ class StudioMainWindow(QMainWindow):
self._last_plot_packet_key: tuple[int, float] | None = None
self._last_rendered_view_mode: str | None = None
self._run_source_label = ""
# ---- replay ---------------------------------------------------------
# One engine for the history view: the recording reader, the cursor and
# the asynchronous depth recomputation. It renders nothing itself.
self.replay = ReplayController(self)
# ---- record channel -------------------------------------------------
# Fed from the acquisition thread via the worker's sink list, so it does
# not depend on the UI refresh rate or on which panels are visible.
@@ -158,7 +163,6 @@ class StudioMainWindow(QMainWindow):
)
self.recorder = self.recording.recorder
self._session_counter = 0
self._playback = None
self._active_calibration_id = ""
self._record_error_count = 0
self._dropped_reported = 0
@@ -191,11 +195,13 @@ class StudioMainWindow(QMainWindow):
self.brand_bar.calibration_requested.connect(self._on_calibration_requested)
root.addWidget(self.brand_bar)
# The sensor picker leads the status rail: which product (box / mini /
# finger) and which config the window is configured for decides what
# everything else means, so it is not buried in a collapsed area.
# The sensor picker itself lives in the sensor dialog; the window owns it
# (it is the state every other panel depends on) and the rail shows a
# summary button that opens it.
self.sensor_picker = SensorPickerWidget()
self.toolbar = ToolbarWidget(theme=self._theme, sensor_picker=self.sensor_picker)
#: Built on first use so the pickers are parented where they belong.
self.sensor_dialog = None
self.toolbar = ToolbarWidget(theme=self._theme)
root.addWidget(self.toolbar)
self.status_chip = self.toolbar.status_chip
@@ -213,6 +219,13 @@ 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)
self.setStatusBar(QStatusBar())
# The phase/record state lives in the toolbar's rail; the status bar is
# for transient messages plus one permanent line of device identity.
@@ -226,6 +239,7 @@ class StudioMainWindow(QMainWindow):
self._build_menus()
self._wire_toolbar()
self._wire_sensor_picker()
self._wire_replay()
self._restore_prefs_into_ui()
# Visibility and the nav highlight come from the restored view; the
# stored splitter sizes still win at startup (``apply_sizes=False``).
@@ -279,8 +293,35 @@ class StudioMainWindow(QMainWindow):
# ------------------------------------------------------- experiment record
@property
def _record_fps(self) -> float:
"""The device's *measured* rate. A one-second completion counter, so a
burst of buffered frames (or an offline file source) pushes it well above
the configured rate: it is telemetry, not a time base."""
return self.recording.record_fps
def _video_fps(self) -> float:
"""The rate the recorded video is written at.
For a live camera that is the sensor configuration's own frame rate: the
container's fps must describe the spacing of the pictures, and using the
measured counter made a 30 fps sensor produce a video that declared (and
played at) 48 fps. A file source has no configured rate to honour, so its
measured rate is the honest one.
"""
measured = float(self._record_fps)
if not isinstance(self.toolbar.active_source(), str):
try:
from orisys_app_shared.acquisition.models import load_fixed_mode
mode = load_fixed_mode(self._resolve_config_ref() or "")
if mode.fps > 0:
return float(mode.fps)
except Exception: # noqa: BLE001 - the measured rate is the fallback
logger.debug("Could not read the configured frame rate", exc_info=True)
# Neither a stream that has not yet delivered a second of frames nor a
# config that could not be read may reach the recorder as a rate: a
# container of "0 fps" would thin the video to one picture per second.
return measured if measured > 0.0 else float(RecordConfig().fps)
@property
def _active_calibration_id(self) -> str:
return self.recording.active_calibration
@@ -317,7 +358,7 @@ class StudioMainWindow(QMainWindow):
return
mode = self.toolbar.record_mode()
config = record_config_from_prefs(
self.prefs, mode=mode, fps=float(self._record_fps)
self.prefs, mode=mode, fps=float(self._video_fps())
)
try:
directory = self.recording.start(
@@ -433,7 +474,10 @@ class StudioMainWindow(QMainWindow):
"height": int(getattr(info, "raw_frame", None).shape[0])
if getattr(info, "raw_frame", None) is not None
else None,
"fps": float(self._record_fps),
"fps": float(self._video_fps()),
# The device's own measured rate, kept so a mismatch between
# the two can be diagnosed after the fact.
"measured_fps": float(self._record_fps),
},
},
"versions": {
@@ -520,8 +564,8 @@ class StudioMainWindow(QMainWindow):
self.toolbar.search_clicked.connect(self._on_search_cameras)
self.toolbar.preview_toggled.connect(self._on_preview_toggled)
self.toolbar.camera_id_changed.connect(self._on_camera_id_changed)
self.toolbar.source_mode_changed.connect(self._on_source_mode_changed)
self.toolbar.browse_video_clicked.connect(self._on_browse_video)
self.toolbar.sensor_clicked.connect(self._open_sensor_dialog)
self.toolbar.use_camera_clicked.connect(self._on_use_camera)
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)
@@ -532,6 +576,170 @@ class StudioMainWindow(QMainWindow):
self.sensor_picker.sensor_type_changed.connect(self._on_sensor_type_changed)
self.sensor_picker.config_changed.connect(self._on_config_changed)
self.arrow_panel.view_mode_changed.connect(self._on_image_view_mode_changed)
self.arrow_panel.view_mode_changed.connect(lambda _mode: self._render_replay_frame())
# The unit chip on the chart header switches the same unit everywhere.
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)
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)
self.replay.recording_changed.connect(self._on_recording_changed)
# Dragging the shared cursor on the charts scrubs the recording too.
self.force_plots.cursor_moved.connect(self._on_cursor_moved)
def _on_cursor_moved(self, t_rel_s: float) -> None:
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
chosen = QFileDialog.getExistingDirectory(
self, t("playback.open"), str(recordings_dir())
)
if not chosen:
return
self._load_recording(Path(chosen))
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)
)
return False
self._refresh_replay_state()
return True
def _open_latest_recording(self) -> None:
"""Switch to the replay view on the most recent recording."""
from orisys_app_shared.record import list_recordings
found = list_recordings(recordings_dir())
if not found:
self.statusBar().showMessage(t("playback.none_found"))
return
self._load_recording(Path(found[-1]))
def _on_recording_changed(self, opened: bool) -> None:
"""A recording was opened (or dropped): re-point everything that reads it."""
self._refresh_replay_state()
if not opened:
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())
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)
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)
return
self.arrow_panel.set_depth_scale(None)
image = self.replay.video_image()
if image is None:
self.arrow_panel.clear_preview(self.replay.video_message())
else:
self.arrow_panel.show_frame(image)
def _replay_depth_scale_text(self) -> str:
"""The depth colour scale, latched per recording by the controller."""
span = self.replay.depth_range()
if span is None:
return ""
return t("panels.depth_scale", low=f"{span[0]:.3f}", high=f"{span[1]:.3f}")
def _apply_replay_source(self, active: bool) -> None:
"""Point the charts at the recording (or back at the live session)."""
if active and self.replay.is_loaded():
reader = self.replay.reader()
self.force_plots.set_recording_source(reader)
# A recording is shown in a unit it actually carries: newton only when
# it has a calibrated axis somewhere, otherwise the charts would be
# empty and every card a dash. The operator's preference is kept when
# 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._set_display_unit(unit)
if unit != wanted:
self.statusBar().showMessage(t("status.units_raw_fallback"))
self.force_plots.show_all()
self.arrow_panel.set_source_kind("replay")
return
if self.force_plots.is_recording_source():
self.force_plots.clear_recording_source()
self.force_plots.follow_latest()
self.arrow_panel.set_source_kind("live")
# The panel held a recorded frame: the next live packet must repaint it,
# so the "already rendered" marker is dropped.
self._last_rendered_view_mode = None
if not self._running and not self._preview_open:
self.arrow_panel.clear_preview(t("panels.arrow_empty"))
def _replay_unit_for(self, reader) -> str:
"""The unit a recording can be shown in: raw unless it has newtons.
``raw`` is always present (it is what the device measured); the calibrated
triplet only exists for the axes the recording actually calibrated. Asking
for newton on a raw-only recording draws three empty charts, so the same
fallback the live view uses applies here.
"""
if self.force_plots.unit() != "N":
return "raw"
info = reader.info()
newtons = getattr(info, "newtons", None)
if newtons is None:
return "raw"
values = np.asarray(newtons, dtype=float)
return "N" if values.size and bool(np.isfinite(values).any()) else "raw"
def _restore_prefs_into_ui(self) -> None:
raw_id = str(self.prefs.get("camera_id") or "0").strip()
@@ -598,7 +806,7 @@ class StudioMainWindow(QMainWindow):
self.file_menu.addAction(self.act_reveal_config)
self.file_menu.addSeparator()
self.act_playback = QAction(self)
self.act_playback.triggered.connect(self._open_playback)
self.act_playback.triggered.connect(self._on_open_recording)
self.file_menu.addAction(self.act_playback)
self.act_playback_latest = QAction(self)
self.act_playback_latest.triggered.connect(self._open_latest_recording)
@@ -611,23 +819,6 @@ class StudioMainWindow(QMainWindow):
self.act_cal_panel.toggled.connect(self._on_cal_panel_toggled)
self.view_menu.addAction(self.act_cal_panel)
self.layout_group = QActionGroup(self)
self.layout_group.setExclusive(True)
self.act_layout_default = QAction(self)
self.act_layout_default.setCheckable(True)
self.act_layout_default.setChecked(self._layout_preset != "2d")
self.act_layout_default.triggered.connect(
lambda: self._on_layout_preset("default")
)
self.act_layout_2d = QAction(self)
self.act_layout_2d.setCheckable(True)
self.act_layout_2d.setChecked(self._layout_preset == "2d")
self.act_layout_2d.triggered.connect(lambda: self._on_layout_preset("2d"))
self.layout_group.addAction(self.act_layout_default)
self.layout_group.addAction(self.act_layout_2d)
self.view_menu.addAction(self.act_layout_default)
self.view_menu.addAction(self.act_layout_2d)
self.act_finger_3d = QAction(self)
self.act_finger_3d.setCheckable(True)
self.act_finger_3d.setChecked(self._show_finger_3d)
@@ -720,34 +911,23 @@ class StudioMainWindow(QMainWindow):
self.act_cal_panel.setChecked(self._show_cal)
self.act_cal_panel.blockSignals(False)
is_finger = self.sensor_picker.sensor_type() == "finger"
self.act_finger_3d.setEnabled(is_finger and self._layout_preset != "2d")
self.act_finger_3d.setEnabled(is_finger)
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_layout_default.blockSignals(True)
self.act_layout_default.setChecked(self._layout_preset != "2d")
self.act_layout_default.blockSignals(False)
self.act_layout_2d.blockSignals(True)
self.act_layout_2d.setChecked(self._layout_preset == "2d")
self.act_layout_2d.blockSignals(False)
self._apply_arrow_rotation_for_sensor()
def _apply_panel_visibility(self) -> None:
"""One place decides which panels the current view shows.
The view preset owns the 2D panel and the calibration panel; the 3D
panel additionally needs a finger sensor, its own toggle and a layout
preset that has not folded it away. ``_show_cal`` is kept in step so the
View menu, the preset and the panels cannot disagree.
panel additionally needs a finger sensor and its own toggle. ``_show_cal``
is kept in step so the View menu, the preset and the panels cannot
disagree.
"""
is_finger = self.sensor_picker.sensor_type() == "finger"
preset = self._view_preset
show_3d = (
preset in VIEW_SHOWS_3D
and is_finger
and bool(self._show_finger_3d)
and self._layout_preset != "2d"
)
show_3d = preset in VIEW_SHOWS_3D and is_finger and bool(self._show_finger_3d)
show_arrow = preset != "calibration"
show_cal = self._show_cal if preset == "custom" else preset == "calibration"
self._show_cal = show_cal
@@ -759,17 +939,17 @@ class StudioMainWindow(QMainWindow):
self.arrow_panel.setMinimumWidth(MIN_PANEL_WIDTHS["arrow"])
self.force_plots.setMinimumWidth(MIN_PANEL_WIDTHS["plots"])
self.cal_panel.setMinimumWidth(MIN_PANEL_WIDTHS["cal"])
self.act_finger_3d.setEnabled(is_finger and self._layout_preset != "2d")
self.act_finger_3d.setEnabled(is_finger)
self.act_finger_3d.blockSignals(True)
self.act_finger_3d.setChecked(show_3d)
self.act_finger_3d.blockSignals(False)
self.act_cal_panel.blockSignals(True)
self.act_cal_panel.setChecked(show_cal)
self.act_cal_panel.blockSignals(False)
# The 2D preset is the reviewed layout: depth map and force charts side
# by side, sharing one time axis, with calibration folded away.
arrow_weight = 3 if self._layout_preset != "2d" else 5
plots_weight = 4 if self._layout_preset != "2d" else 6
# Panel weights: the charts are the widest panel in every view, and the
# 2D panel is deliberately the smaller one.
arrow_weight = 3
plots_weight = 4
for i in range(self.content_splitter.count()):
widget = self.content_splitter.widget(i)
if widget is self.mesh3d_panel:
@@ -800,39 +980,30 @@ 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).
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: open on the whole
# session so the overview strip is immediately draggable.
self.force_plots.show_all()
# 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.
self._apply_replay_source(True)
self._refresh_replay_state()
elif preset == "live":
self.force_plots.follow_latest()
self._apply_replay_source(False)
self.brand_bar.set_active_view(preset if preset != "custom" else None)
self._sync_menu_checks()
self._refresh_status_rail()
if persist:
self._persist_prefs()
def view_preset(self) -> str:
return self._view_preset
def _on_layout_preset(self, preset: str) -> None:
"""Switch between the free layout and the reviewed 2D preset."""
self._layout_preset = "2d" if preset == "2d" else "default"
self.prefs["layout_preset"] = self._layout_preset
if self._layout_preset == "2d":
# Depth map on the left, force charts on the right, one timeline.
self.arrow_panel.set_view_mode("depth")
self._show_cal = False
self.prefs["show_cal_panel"] = False
self._apply_panel_visibility()
self._sync_menu_checks()
self._persist_prefs()
self.statusBar().showMessage(
t("layout.preset_2d" if self._layout_preset == "2d" else "layout.preset_default")
)
def _update_run_enabled(self) -> None:
sensor_ok = self.sensor_picker.sensor_type() in RUNNABLE_TYPES
source_ok = True
@@ -860,8 +1031,6 @@ class StudioMainWindow(QMainWindow):
self.act_playback_latest.setText(t("menu.playback_latest"))
self.view_menu.setTitle(t("menu.view"))
self.act_cal_panel.setText(t("menu.calibration_panel"))
self.act_layout_default.setText(t("layout.default"))
self.act_layout_2d.setText(t("layout.preset_2d_name"))
self.act_finger_3d.setText(t("menu.finger_3d"))
self.act_show_fps.setText(t("menu.show_fps"))
self.act_settings.setText(t("menu.settings"))
@@ -915,18 +1084,30 @@ class StudioMainWindow(QMainWindow):
# ------------------------------------------------------ status rail state
def _refresh_status_rail(self) -> None:
"""Phase, device, calibration, validity, FPS and device identity.
"""Sensor, source detail, calibration, validity, FPS and device identity.
The rail is the single place these facts are written; the bottom status
bar keeps only transient messages.
"""
config = self.sensor_picker.config_name()
self.toolbar.set_sensor_summary(
t(
"sensor.summary",
type=self.sensor_picker.sensor_type().upper(),
config=Path(config).name if config else t("status.config_none"),
)
)
# 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":
summary = t("status.rail_video")
path = self.toolbar.video_path()
detail = t("status.rail_video_name", name=Path(path).name if path else "?")
else:
summary = t("status.rail_camera", id=self.toolbar.camera_id())
detail = ""
if self._show_fps and self._latest_fps is not None:
summary = f"{summary} · {self._latest_fps:.1f} FPS"
self.toolbar.set_sensor_summary(summary)
fps = f"{self._latest_fps:.1f} FPS"
detail = f"{detail} · {fps}" if detail else fps
self.toolbar.set_source_summary(detail)
self.toolbar.set_calibration_summary(self._calibration_summary())
if self._last_sample_valid is None:
self.toolbar.set_data_state("")
@@ -1085,54 +1266,60 @@ class StudioMainWindow(QMainWindow):
def _show_about(self) -> None:
AboutDialog(self).exec_()
def _open_playback(self) -> None:
"""Replay a recording: video, depth (recomputed) and curves, one cursor."""
from orisys_studio.ui.playback_dialog import PlaybackDialog
if self._playback is not None:
self._playback.close()
self._playback = None
dialog = PlaybackDialog(theme=self._theme, parent=self)
# The replay cursor and the live charts share one time position.
dialog.cursor_moved.connect(self._on_playback_cursor)
self._playback = dialog
dialog.show()
def _on_playback_cursor(self, t_rel_s: float) -> None:
self.force_plots.set_cursor(float(t_rel_s))
def _open_latest_recording(self) -> None:
from orisys_app_shared.record import list_recordings
found = list_recordings(recordings_dir())
if not found:
self.statusBar().showMessage(t("playback.none_found"))
return
from orisys_studio.ui.playback_dialog import PlaybackDialog
dialog = PlaybackDialog(theme=self._theme, directory=found[0], parent=self)
dialog.cursor_moved.connect(self._on_playback_cursor)
self._playback = dialog
dialog.show()
def _on_camera_id_changed(self, value: int) -> None:
self.prefs["camera_id"] = str(int(value))
self._persist_prefs()
def _on_source_mode_changed(self, mode: str) -> None:
self.prefs["source_mode"] = mode
def _set_source_mode(self, mode: str) -> None:
"""Camera or a video file as the acquisition source (file menu only)."""
self.toolbar.set_source_mode(mode)
self.prefs["source_mode"] = self.toolbar.source_mode()
self._persist_prefs()
self._update_run_enabled()
self._refresh_status_rail()
def _on_use_camera(self) -> None:
self.toolbar.set_video_path("")
self.prefs["video_path"] = ""
self._set_source_mode("camera")
def _open_sensor_dialog(self) -> None:
"""Show the sensor + calibration dialog (one instance, reused)."""
from orisys_studio.ui.sensor_dialog import SensorDialog
if self.sensor_dialog is None:
self.sensor_dialog = SensorDialog(
window=self, picker=self.sensor_picker, theme=self._theme
)
self.sensor_dialog.retranslate()
self.sensor_dialog.show()
self.sensor_dialog.raise_()
self.sensor_dialog.activateWindow()
def _on_units_changed(self, units: str) -> None:
self.prefs["units"] = units
self.force_plots.set_fixed_newton(str(units) == "newton")
# Rebuild the visible curves from the whole-session store, which keeps
# both triplets, instead of appending new-unit values onto old ones.
self.force_plots.set_units(
"N" if str(units) == "newton" else "raw",
store=self.session_store,
self._set_display_unit(
"N" if str(units) == "newton" else "raw", sync_toolbar=False
)
def _on_chart_unit_toggle(self) -> None:
"""The chart's own unit chip: newton <-> raw."""
self._set_display_unit("raw" if self.force_plots.unit() == "N" else "N")
def _set_display_unit(self, unit: str, *, sync_toolbar: bool = True) -> None:
"""One display unit for the toolbar, the charts and the value cards.
The toolbar combo can already *be* on the requested unit (for instance
after a replay's unit switch), in which case its signal never fires — so
the charts are told directly instead of relying on that signal.
"""
if sync_toolbar:
self.toolbar.set_units("newton" if unit == "N" else "raw")
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
# or the recording being replayed), which keeps both triplets, instead of
# appending new-unit values onto old ones.
self.force_plots.set_units(unit)
self._persist_prefs()
def _on_image_view_mode_changed(self, mode: str) -> None:
@@ -1384,6 +1571,9 @@ class StudioMainWindow(QMainWindow):
self._apply_panel_visibility()
self._apply_arrow_rotation_for_sensor()
self._update_run_enabled()
# The rail's sensor summary and the sensor dialog both state the product
# and its config, so both follow a change.
self._refresh_status_rail()
self._persist_prefs()
def _apply_arrow_rotation_for_sensor(self) -> None:
@@ -1449,9 +1639,13 @@ class StudioMainWindow(QMainWindow):
def _on_config_changed(self, config_name: str) -> None:
self.prefs["config_name"] = str(config_name)
# The config file is part of the rail's sensor summary and of the sensor
# dialog's calibration context.
self._refresh_status_rail()
self._persist_prefs()
def _on_browse_video(self) -> None:
"""Acquire from a video file instead of a camera (file menu)."""
if self._running or self._preview_open:
return
start = self.toolbar.video_path() or str(Path.home())
@@ -1463,12 +1657,9 @@ class StudioMainWindow(QMainWindow):
)
if not path:
return
self.toolbar.set_source_mode("video")
self.toolbar.set_video_path(path)
self.prefs["source_mode"] = "video"
self.prefs["video_path"] = path
self._persist_prefs()
self._update_run_enabled()
self._set_source_mode("video")
def _on_reveal_config(self) -> None:
sensor_type = self.sensor_picker.sensor_type()
@@ -1557,7 +1748,7 @@ class StudioMainWindow(QMainWindow):
self.statusBar().showMessage(t("status.preview_stopped"))
def _on_preview_tick(self) -> None:
if self._preview is None:
if self._preview is None or self._replay_owns_image():
return
frame = self._preview.read()
if frame is None:
@@ -1620,13 +1811,19 @@ class StudioMainWindow(QMainWindow):
self._latest_fps = None
self._depth_range_value = None
self._last_depth_frame = None
self.force_plots.set_store(self.session_store)
# A new session invalidates the previous recording.
self._stop_recording(wait=True)
# The new session's store is cleared *before* the charts are attached to
# it: attaching first rebuilt the window from the previous session's
# samples, so every run started with the old curve's tail on screen.
self._session_counter += 1
self.session_store.reset(
session_id=self._session_counter, calibration_id=""
)
self.force_plots.set_store(self.session_store)
# A new session is a live session: whichever view is on screen, the
# charts follow it from the first sample.
self.force_plots.follow_latest()
# A new session invalidates the previous recording.
self._stop_recording(wait=True)
self._ensure_workers()
self._active_worker = (
self._finger_worker if sensor_type == "finger" else self._box_worker
@@ -1842,7 +2039,9 @@ class StudioMainWindow(QMainWindow):
self._report_dropped_samples()
mode = self.arrow_panel.view_mode()
if is_new or mode != self._last_rendered_view_mode:
if (is_new or mode != self._last_rendered_view_mode) and not (
self._replay_owns_image()
):
self._render_run_image(packet)
if is_new:
self._latest_fps = getattr(packet, "fps", None)
@@ -1918,8 +2117,18 @@ class StudioMainWindow(QMainWindow):
return ""
return t("panels.depth_scale", low=f"{span[0]:.3f}", high=f"{span[1]:.3f}")
def _replay_owns_image(self) -> bool:
"""True while the 2D panel shows the replay, not the live camera.
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.
"""
return self._view_preset == "replay" and self.replay.is_loaded()
def _render_run_image(self, packet) -> None:
if packet is None:
if packet is None or self._replay_owns_image():
return
from orisys_app_shared.acquisition.arrows import (
arrow_display_scale,
@@ -1,11 +1,14 @@
"""Synchronised replay of a recording: video, depth and force curves, one cursor.
"""Replay engine: one recording, one cursor, and the depth it can recompute.
This is the headless half of the old replay window. It owns the recording
reader, the cursor and the asynchronous depth recomputation, and it *shows*
nothing: the main window renders the frames into the 2D panel, the curves read
the recording through :class:`~orisys_app_shared.force.source.RecordingCurveSource`
and the progress bar drives :meth:`set_position`.
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.
:mod:`orisys_app_shared.record.depth`), so the pane states plainly when it is
unavailable instead of showing a stand-in image.
"""
from __future__ import annotations
@@ -15,52 +18,39 @@ 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 PyQt5.QtCore import QObject, QTimer, pyqtSignal
from PyQt5.QtGui import QImage
from orisys_app_shared.i18n import t
from orisys_app_shared.paths import recordings_dir
from orisys_app_shared.record import (
DepthRecomputer,
DepthResult,
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
#: Upper bound on the points handed to the replay curves.
CHART_MAX_POINTS = 4000
logger = logging.getLogger(__name__)
class PlaybackDialog(QDialog):
"""Replay one experiment directory."""
def _bgr_to_qimage(frame: np.ndarray) -> QImage:
rgb = frame[:, :, ::-1].copy()
height, width, channels = rgb.shape
return QImage(rgb.data, width, height, channels * width, QImage.Format_RGB888).copy()
#: 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:
class ReplayController(QObject):
"""Open one recording and answer "what is at t?" for every view."""
#: The cursor moved; the progress bar and the charts' cursor follow.
position_changed = pyqtSignal(float)
#: The frame under the cursor changed (video and/or depth).
frames_changed = pyqtSignal()
#: Warnings, depth state or the loaded recording changed.
status_changed = pyqtSignal()
#: A recording was opened (True) or the previous one was dropped (False).
recording_changed = pyqtSignal(bool)
def __init__(self, parent=None) -> None:
super().__init__(parent)
self._theme = theme
self._reader: RecordingReader | None = None
self._recomputer: DepthRecomputer | None = None
self._unit = "raw"
@@ -87,100 +77,43 @@ class PlaybackDialog(QDialog):
self._depth_result_frame = -1
#: Processors whose owning request was still running when it was retired.
self._retired_recomputers: list = []
#: The frame cache: one video image per cursor position.
self._video_position: float | None = None
self._video_qimage: QImage | None = None
self._video_reason = ""
self._depth_qimage: QImage | None = None
self._depth_message = ""
self._warnings = ""
#: The depth colour scale, latched to the first recomputed frame of the
#: recording so two frames stay comparable (same rule as the live view).
self._depth_range: tuple[float, float] | None = None
self._depth_poll = QTimer(self)
self._depth_poll.setInterval(120)
self._depth_poll.timeout.connect(self._poll_depth)
self.setWindowTitle(t("playback.title"))
self.resize(1080, 640)
root = QVBoxLayout(self)
root.setSpacing(8)
# ------------------------------------------------------------- recording
def is_loaded(self) -> bool:
return self._reader is not None
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)
def directory(self) -> Path | None:
return self._reader.directory if self._reader is not None else None
self.warning_label = QLabel()
self.warning_label.setObjectName("hintText")
self.warning_label.setWordWrap(True)
root.addWidget(self.warning_label)
def reader(self):
return self._reader
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)
def info(self):
return self._reader.info() if self._reader is not None else None
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)
def source(self):
"""The curve source for the charts, or None when nothing is open."""
if self._reader is None:
return None
from orisys_app_shared.force.source import RecordingCurveSource
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)
return RecordingCurveSource(self._reader)
if directory is not None:
self.open_recording(Path(directory))
# Labels last: without this the three buttons stayed empty, because the
# dialog never retranslated after being built.
self.retranslate()
# ------------------------------------------------------------------ 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:
def open_recording(self, directory: Path) -> bool:
"""Open a recording. Any depth answer still in flight is invalidated."""
# A running request belongs to the *previous* recording: its result must
# never be pasted into the new one, and its processor must be closed by
@@ -191,24 +124,53 @@ class PlaybackDialog(QDialog):
self._depth_pending_seq = None
self._depth_pending_position = None
self._depth_error = None
self._reader = RecordingReader(directory)
try:
self._reader = RecordingReader(Path(directory))
info = self._reader.info()
except Exception: # noqa: BLE001 - reported through the status text
logger.exception("Could not open recording %s", directory)
self._reader = None
self._recomputer = None
self._reset_frames()
self.recording_changed.emit(False)
self.status_changed.emit()
return False
self._recomputer = DepthRecomputer(self._reader)
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._depth_result = None
self._depth_result_seq = None
self._depth_range = None
self._reset_frames()
self._position = 0.0
_ = info
self.recording_changed.emit(True)
self._refresh_warnings()
self._refresh_depth_pane()
self.set_position(0.0)
return True
def close(self) -> None:
"""Drop the recording and hand every processor to its owning thread."""
self._depth_poll.stop()
for recomputer in list(self._retired_recomputers):
if not self._depth_busy():
self._retired_recomputers.remove(recomputer)
try:
recomputer.close()
except Exception: # noqa: BLE001
logger.exception("Could not close a retired recomputer")
self._retire_recomputer()
self._reader = None
self._reset_frames()
self.recording_changed.emit(False)
self.status_changed.emit()
def _reset_frames(self) -> None:
self._video_position = None
self._video_qimage = None
self._video_reason = ""
self._depth_qimage = None
self._depth_message = ""
def _retire_recomputer(self) -> None:
"""Hand the current processor to its worker, or close it here if idle.
@@ -228,7 +190,88 @@ class PlaybackDialog(QDialog):
except Exception: # noqa: BLE001 - closing is best effort
logger.exception("Could not close the depth recomputer")
# ---------------------------------------------------------------- cursor
def position(self) -> float:
return self._position
def duration_s(self) -> float:
if self._reader is None:
return 0.0
return float(self._reader.info().duration_s)
def set_position(self, t_rel_s: float) -> None:
"""Move the single cursor: video, depth and charts follow it."""
if self._reader is None:
return
duration = self.duration_s()
self._position = max(0.0, min(float(t_rel_s), duration if duration > 0 else 0.0))
self._refresh_video()
if self._depth_ok or self._depth_result is not None:
# The held depth belongs to the frame it was computed for. Moving the
# cursor must not keep showing it as if it were synchronised.
self._refresh_depth_pane()
self.position_changed.emit(self._position)
self.frames_changed.emit()
def unit(self) -> str:
return self._unit
def set_unit(self, unit: str) -> str:
"""Show the recording in ``unit``; the bar and the charts share one.
Opening a recording that carries no calibrated axis in newton mode would
draw three empty charts, so the caller picks the unit from what the
recording actually has and tells both views about it here.
"""
self._unit = "N" if str(unit).upper().startswith("N") else "raw"
return self._unit
def toggle_unit(self) -> str:
self._unit = "N" if self._unit == "raw" else "raw"
self.status_changed.emit()
return self._unit
# ----------------------------------------------------------------- frames
def video_image(self) -> QImage | None:
self._refresh_video()
return self._video_qimage
def video_message(self) -> str:
self._refresh_video()
return self._video_reason
def _refresh_video(self) -> None:
if self._reader is None:
self._video_position = None
self._video_qimage = None
self._video_reason = t("playback.empty")
return
if self._video_position == self._position:
return
self._video_position = self._position
image, frame = self._reader.video_frame_at(self._position)
if image is None:
self._video_qimage = None
self._video_reason = (
t("playback.frame_dropped")
if frame is not None and not frame.in_video
else t("playback.frame_unreadable")
)
return
self._video_reason = ""
self._video_qimage = _bgr_to_qimage(image)
def depth_image(self) -> QImage | None:
return self._depth_qimage
def depth_message(self) -> str:
return self._depth_message
def warnings_text(self) -> str:
return self._warnings
def _refresh_warnings(self) -> None:
self._warnings = ""
if self._reader is None:
return
info = self._reader.info()
@@ -251,122 +294,35 @@ class PlaybackDialog(QDialog):
total=info.total_rows,
)
text = f"{text} {note}" if text else note
self.warning_label.setText(text)
# ------------------------------------------------------------------ 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
# Same rule as the live charts, per axis: a point is drawn only where the
# sample is valid and *that* axis is finite, so a single-axis calibration
# does not hide the axis that is calibrated. For a long recording the
# whole-session view is decimated while it is read from disk, in the unit
# being shown.
window_all = getattr(self._reader, "window_all", None)
if callable(window_all):
window = window_all(unit=self._unit, max_points=CHART_MAX_POINTS)
for index, curve in enumerate(self.curves):
times, values = window.plottable_axis(self._unit, index)
curve.setData(times, values)
return
for index, curve in enumerate(self.curves):
times, values = info.plottable_axis(self._unit, index)
curve.setData(times, values)
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 or self._depth_result is not None:
# The held depth belongs to the frame it was computed for. Moving the
# cursor must not keep showing it as if it were synchronised.
self._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("")
self._warnings = text
# -------------------------------------------------------------------- depth
def _on_recompute_depth(self) -> None:
def request_depth(self) -> None:
"""Kick off a recomputation on a worker thread.
Opening a video with the SDK and stepping to a frame can block, so it
must never run on the UI thread — that is exactly how the replay window
would freeze.
must never run on the UI thread — that is exactly how the replay would
freeze.
"""
if self._recomputer is None:
return
problem = self._recomputer.preflight()
if problem:
self._depth_ok = False
self.depth_label.setPixmap(QPixmap())
self.depth_label.setText(t(f"playback.depth_{problem}"))
self._depth_qimage = None
self._depth_message = t(f"playback.depth_{problem}")
self.status_changed.emit()
return
if self._depth_thread is not None and self._depth_thread.is_alive():
self.depth_label.setText(t("playback.depth_busy"))
self._depth_message = t("playback.depth_busy")
self.status_changed.emit()
return
self._depth_ok = False
self._depth_result = None
self._depth_result_seq = None
self._depth_error = None
self.depth_label.setText(t("playback.depth_running"))
self._depth_message = t("playback.depth_running")
self.status_changed.emit()
position = self._position
# Bind the answer to this request: the recording, the frame and a request
# sequence. A result that arrives after the cursor moved — or after
@@ -382,8 +338,8 @@ class PlaybackDialog(QDialog):
def work() -> None:
# Runs off the UI thread and touches no Qt object. It captures the
# processor it was given: the dialog may already be showing another
# recording by the time this returns.
# processor it was given: the controller may already be showing
# another recording by the time this returns.
try:
result = recomputer.depth_at(position)
except BaseException as exc: # noqa: BLE001 - reported via the poll
@@ -409,6 +365,12 @@ class PlaybackDialog(QDialog):
self._depth_thread.start()
self._depth_poll.start()
def depth_busy(self) -> bool:
return self._depth_thread is not None and self._depth_thread.is_alive()
# The old spelling, kept for the tests and for readability at call sites.
_depth_busy = depth_busy
def _poll_depth(self) -> None:
"""UI-thread poll for the worker result."""
thread = self._depth_thread
@@ -427,8 +389,10 @@ class PlaybackDialog(QDialog):
# The failure belongs to a recording that is no longer open.
return
self._depth_ok = False
self.depth_label.setPixmap(QPixmap())
self.depth_label.setText(t("playback.depth_failed", detail=str(exc)))
self._depth_qimage = None
self._depth_message = t("playback.depth_failed", detail=str(exc))
self.status_changed.emit()
self.frames_changed.emit()
return
if record != self._record_serial:
# Another recording was opened while this was running.
@@ -436,14 +400,18 @@ class PlaybackDialog(QDialog):
result = self._depth_result
if result is None or self._depth_result_seq != pending:
# A newer request (or a moved cursor) owns the pane now.
self._show_depth_frame()
self._refresh_depth_pane()
self.frames_changed.emit()
return
if not self._depth_matches_cursor(result):
# The cursor moved while the SDK was reading: do not draw an answer
# for another frame, and say the pane needs recomputing.
self._show_depth_frame()
self._refresh_depth_pane()
self.frames_changed.emit()
return
self._render_depth_result(result)
self.status_changed.emit()
self.frames_changed.emit()
def _current_frame(self):
"""The recorded frame under the cursor, or None without a frame index."""
@@ -475,33 +443,42 @@ class PlaybackDialog(QDialog):
if code in ("not_recomputed", "recomputing", "stale"):
# A placeholder, not a failure: the pane describes the cursor.
self._depth_ok = False
self.depth_label.setPixmap(QPixmap())
self.depth_label.setText(t(f"playback.depth_{code}"))
self._depth_qimage = None
self._depth_message = t(f"playback.depth_{code}")
return
if not result.ok:
self._depth_ok = False
self.depth_label.setPixmap(QPixmap())
self.depth_label.setText(t("playback.depth_failed", detail=result.error))
self._depth_qimage = None
self._depth_message = t("playback.depth_failed", detail=result.error)
return
from orisys_app_shared.acquisition.arrows import depth_map_to_bgr
bgr = depth_map_to_bgr(np.asarray(result.depth))
depth = np.asarray(result.depth)
self._latch_depth_range(depth)
bgr = depth_map_to_bgr(depth, value_range=self._depth_range)
if bgr is None:
self._depth_ok = False
self.depth_label.setPixmap(QPixmap())
self.depth_label.setText(t("playback.depth_empty"))
self._depth_qimage = None
self._depth_message = t("playback.depth_empty")
return
self._depth_ok = True
self.depth_label.setText(self._depth_warning_text(result))
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_message = self._depth_warning_text(result)
self._depth_qimage = _bgr_to_qimage(bgr)
def _latch_depth_range(self, depth: np.ndarray) -> None:
if self._depth_range is not None:
return
finite = np.isfinite(depth)
if depth.size == 0 or not finite.any():
return
low = float(np.nanmin(depth[finite]))
high = float(np.nanmax(depth[finite]))
if high <= low:
high = low + 1.0
self._depth_range = (low, high)
def depth_range(self) -> tuple[float, float] | None:
return self._depth_range
@staticmethod
def _depth_warning_text(result) -> str:
@@ -514,8 +491,8 @@ class PlaybackDialog(QDialog):
detail=", ".join(t(f"playback.warn_{code}") for code in codes),
)
def _show_depth_frame(self) -> None:
"""Draw the depth for the cursor's frame, or say it must be recomputed.
def _refresh_depth_pane(self) -> None:
"""Re-derive the depth pane for the cursor's frame.
Re-drawing the held map here is what used to show a stale depth as though
it were in sync with the cursor.
@@ -536,37 +513,3 @@ class PlaybackDialog(QDialog):
self._render_depth_result(
DepthResult(error="not_recomputed", t_rel_s=self._position)
)
# ------------------------------------------------------------------- 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. The processor is closed by whichever
# thread owns it (see ``_retire_recomputer``), never by the UI thread
# while a worker is reading it.
for recomputer in list(self._retired_recomputers):
if not self._depth_busy():
self._retired_recomputers.remove(recomputer)
try:
recomputer.close()
except Exception: # noqa: BLE001
logger.exception("Could not close a retired recomputer")
self._retire_recomputer()
super().closeEvent(event)
def _depth_busy(self) -> bool:
return self._depth_thread is not None and self._depth_thread.is_alive()
+59 -15
View File
@@ -19,6 +19,15 @@ 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")
_VIEW_LABELS = {
"arrows_pure": "panels.view_arrows_pure",
"arrows_stitched": "panels.view_arrows_stitched",
"depth": "panels.view_depth",
"recorded": "panels.view_recorded",
}
_ROTATION_STEPS = (0, 90, 180, 270)
_COLORBAR_WIDTH = 120
_COLORBAR_HEIGHT = 10
@@ -131,6 +140,7 @@ class ArrowPanel(QFrame):
self._depth_scale_text = ""
self._arrow_scale = 2.0
self._valid_area = False
self._source_kind = "live"
self.view_combo = QComboBox()
self.view_combo.setMinimumWidth(140)
self.view_combo.currentIndexChanged.connect(self._on_view_index_changed)
@@ -262,6 +272,29 @@ class ArrowPanel(QFrame):
t("panels.arrow_area_masked") if self._valid_area else t("panels.arrow_area_full")
)
def set_source_kind(self, kind: str) -> None:
"""Switch between live frames and a recording being replayed.
A recording has no optical flow to draw, so the arrow entries disappear
rather than offering a view that would be a lie.
"""
wanted = "replay" if str(kind) == "replay" else "live"
if wanted == self._source_kind:
return
self._source_kind = wanted
self._populate_view_combo()
self._refresh_pipeline_label()
self._refresh_footer_visibility()
if not self.image_label.pixmap() or self.image_label.pixmap().isNull():
self._placeholder = self._empty_message()
self.image_label.setText(self._placeholder)
def source_kind(self) -> str:
return self._source_kind
def view_modes(self) -> tuple[str, ...]:
return REPLAY_VIEW_MODES if self._source_kind == "replay" else VIEW_MODES
def set_theme(self, theme: str) -> None:
self._theme = str(theme)
self.colorbar.set_theme(self._theme)
@@ -282,9 +315,12 @@ class ArrowPanel(QFrame):
def _refresh_footer_visibility(self) -> None:
"""Arrow annotations and the depth legend are mutually exclusive."""
depth = self.view_mode() == "depth"
self.scale_label.setVisible(not depth)
replay = self._source_kind == "replay"
# The magnification and the valid area describe the live flow drawing;
# neither exists for a recorded frame.
self.scale_label.setVisible(not depth and not replay)
self.pipeline_label.setVisible(not depth)
self.area_label.setVisible(not depth)
self.area_label.setVisible(not depth and not replay)
self.colorbar.setVisible(depth)
self.depth_scale_label.setVisible(depth and bool(self._depth_scale_text))
@@ -292,11 +328,12 @@ class ArrowPanel(QFrame):
current = self.view_combo.currentData()
self.view_combo.blockSignals(True)
self.view_combo.clear()
for mode in VIEW_MODES:
self.view_combo.addItem("", mode)
for mode in self.view_modes():
self.view_combo.addItem(t(_VIEW_LABELS[mode]), mode)
if current:
idx = self.view_combo.findData(current)
self.view_combo.setCurrentIndex(max(0, idx))
if idx >= 0:
self.view_combo.setCurrentIndex(idx)
self.view_combo.blockSignals(False)
def _on_view_index_changed(self, _index: int) -> None:
@@ -305,24 +342,31 @@ class ArrowPanel(QFrame):
def retranslate(self) -> None:
self.title.setText(t("panels.arrow"))
labels = (
t("panels.view_arrows_pure"),
t("panels.view_arrows_stitched"),
t("panels.view_depth"),
)
for i, label in enumerate(labels):
for i, mode in enumerate(self.view_modes()):
if i < self.view_combo.count():
self.view_combo.setItemText(i, label)
self.view_combo.setItemText(i, t(_VIEW_LABELS[mode]))
self.view_combo.setToolTip(t("panels.view_mode_tip"))
self.image_label.setToolTip(t("panels.arrow_empty_tip"))
self.pipeline_label.setText(t("panels.arrow_pipeline"))
self.pipeline_label.setToolTip(t("panels.arrow_pipeline_tip"))
self._refresh_pipeline_label()
self._refresh_scale_label()
self.set_valid_area(self._valid_area)
if not self.image_label.pixmap() or self.image_label.pixmap().isNull():
self._placeholder = t("panels.arrow_empty")
self._placeholder = self._empty_message()
self.image_label.setText(self._placeholder)
def _refresh_pipeline_label(self) -> None:
if self._source_kind == "replay":
self.pipeline_label.setText(t("panels.replay_pipeline"))
self.pipeline_label.setToolTip(t("panels.replay_pipeline_tip"))
return
self.pipeline_label.setText(t("panels.arrow_pipeline"))
self.pipeline_label.setToolTip(t("panels.arrow_pipeline_tip"))
def _empty_message(self) -> str:
if self._source_kind == "replay":
return t("panels.replay_empty")
return t("panels.arrow_empty")
def _effective_rotation_deg(self) -> int:
if not self._rotation_enabled:
return 0
+10 -4
View File
@@ -243,8 +243,8 @@ class CalPanel(QFrame):
self.btn_clear_all.clicked.connect(self._on_clear_all)
self.btn_open_record.clicked.connect(self._on_open_record)
self.btn_save_record.clicked.connect(self._on_save_record)
self.btn_export.clicked.connect(self._on_export)
self.btn_apply.clicked.connect(self._on_apply)
self.btn_export.clicked.connect(self.export_coefficients)
self.btn_apply.clicked.connect(self.apply_coefficients)
# Two rows, not nine buttons in a row: in one line every label was
# squeezed until it was unreadable ("排 除 / 设 为 验 证 点").
selection_actions = QHBoxLayout()
@@ -678,7 +678,12 @@ class CalPanel(QFrame):
self._rebuild_store()
self._refresh_table()
def _on_export(self) -> None:
def export_coefficients(self) -> None:
"""Write the prepared model to the user's calibration directory.
Public because the sensor dialog offers the same action: one
implementation, reachable from both places.
"""
# Re-prepare first: the SN and the requested duration may have changed,
# and the file must be exactly the model the panel is showing.
self._rebuild_store()
@@ -720,7 +725,8 @@ class CalPanel(QFrame):
def _warnings(self) -> list[str]:
return list(getattr(self._prepared, "warnings", []) or [])
def _on_apply(self) -> None:
def apply_coefficients(self) -> None:
"""Ask for the last export to be pushed into the running device."""
if not self._last_export_path:
self.status_message.emit(t("cal.apply_no_export"))
return
+249 -54
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import time
import numpy as np
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtCore import Qt, QSize, pyqtSignal
from PyQt5.QtGui import QColor, QFont, QFontDatabase, QFontMetrics
from PyQt5.QtWidgets import (
QFrame,
@@ -22,6 +22,9 @@ from orisys_app_shared.force.source import StoreCurveSource
from orisys_app_shared.i18n import t
from orisys_app_shared.theme import color
from orisys_studio.ui import icons
from orisys_studio.ui.icons import ICON_SIZE
try:
import pyqtgraph as pg
except ImportError as exc: # pragma: no cover
@@ -43,6 +46,10 @@ WINDOW_BRUSH_ALPHA = 70
#: Point size of the axis readings. They are the number an operator reads from
#: across the bench, so they are not the body-text size.
VALUE_FONT_PT = 16
#: Half-width of the slice read around the cursor when the value cards follow the
#: timeline. One second is wide enough that a sample is always inside it and small
#: enough that reading a recording from disk stays free while scrubbing.
CURSOR_READOUT_S = 0.5
def _window_brush(theme: str):
@@ -105,6 +112,8 @@ class ForcePlotsPanel(QFrame):
cursor_moved = pyqtSignal(float)
#: "follow" (live) or "browse" (user-controlled window).
mode_changed = pyqtSignal(str)
#: The unit chip was clicked: switch between newton and raw.
unit_toggle_requested = pyqtSignal()
def __init__(self, parent=None, *, theme: str = "dark") -> None:
super().__init__(parent)
@@ -120,10 +129,14 @@ class ForcePlotsPanel(QFrame):
#: Per axis: does ``_last_values`` hold a real value in the active unit?
self._available: tuple[bool, bool, bool] = (True, True, True)
# -- browsing state -------------------------------------------------
self._store = None
#: Where the curves read from: the in-memory session store here, a
#: recording on disk in the replay. One interface either way.
self._source = StoreCurveSource()
#: True once a source is attached (either kind). ``_store is None`` was
#: the old proxy for this and could not express a disk source.
self._source_ready = False
#: The live store to return to when a recording source is detached.
self._live_store = None
self._mode = "follow"
self._window: tuple[float, float] = (0.0, 0.0)
self._cursor_t = 0.0
@@ -153,21 +166,25 @@ class ForcePlotsPanel(QFrame):
self.mode_label.setObjectName("hintText")
header.addWidget(self.mode_label)
header.addStretch(1)
self.btn_latest = QPushButton()
self.btn_latest.setProperty("compact", "true")
# 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")
self.btn_latest.clicked.connect(self.follow_latest)
header.addWidget(self.btn_latest)
self.btn_overview = QPushButton()
self.btn_overview.setProperty("compact", "true")
self.btn_overview = self._header_button("zoom_fit")
self.btn_overview.clicked.connect(self.show_all)
header.addWidget(self.btn_overview)
self.btn_box_zoom = QPushButton()
self.btn_box_zoom.setProperty("compact", "true")
self.btn_box_zoom = self._header_button("box_zoom")
self.btn_box_zoom.setCheckable(True)
self.btn_box_zoom.toggled.connect(self._on_box_zoom_toggled)
header.addWidget(self.btn_box_zoom)
self.unit_badge = QLabel()
# The unit chip is a button: switching between newton and raw is a chart
# concern, so it lives on the chart.
self.unit_badge = QPushButton()
self.unit_badge.setObjectName("unitBadge")
self.unit_badge.setProperty("compact", "true")
self.unit_badge.setCursor(Qt.PointingHandCursor)
self.unit_badge.clicked.connect(self.unit_toggle_requested.emit)
header.addWidget(self.unit_badge)
root.addLayout(header)
@@ -210,19 +227,29 @@ class ForcePlotsPanel(QFrame):
for i, _name in enumerate(CHANNEL_LABELS):
plot = self.plot_widget.addPlot(row=i, col=0)
plot.showGrid(x=True, y=True, alpha=GRID_ALPHA)
plot.setMouseEnabled(x=False, y=False)
# Panning and zooming are always available: a chart that ignores the
# mouse until it is already browsing could never *enter* browsing by
# being dragged. Y stays locked to the configured limits.
plot.setMouseEnabled(x=True, y=False)
if i > 0:
plot.setXLink(self.plots[0])
curve = plot.plot(pen=pen)
# One shared time cursor across all three channels.
# One shared time cursor across all three channels. It is draggable
# while browsing (see ``_set_mode``), which makes the cursor a second
# timeline control next to the overview strip.
cursor = pg.InfiniteLine(
pos=0.0,
angle=90,
movable=False,
pen=pg.mkPen(color(theme, "text_muted"), width=1),
)
cursor.sigPositionChanged.connect(self._on_cursor_dragged)
plot.addItem(cursor, ignoreBounds=True)
plot.getViewBox().sigXRangeChanged.connect(self._on_x_range_changed)
# ``sigRangeChangedManually`` — not ``sigXRangeChanged`` — is what
# pyqtgraph emits for a wheel/drag gesture. The plain range signal
# also fires when a resize propagates through the X links, which is
# not a user gesture and must not switch the view to browsing.
plot.getViewBox().sigRangeChangedManually.connect(self._on_manual_range_change)
self.plots.append(plot)
self.curves.append(curve)
self.cursors.append(cursor)
@@ -276,6 +303,17 @@ class ForcePlotsPanel(QFrame):
self._apply_axis_mode()
self.retranslate()
def _header_button(self, icon_name: str) -> QPushButton:
"""A chart-header action: an icon, a tooltip and no text."""
button = QPushButton()
button.setProperty("compact", "true")
button.setProperty("iconOnly", "true")
button.setIcon(icons.icon(icon_name, self._theme))
button.setIconSize(QSize(ICON_SIZE, ICON_SIZE))
button.setCursor(Qt.PointingHandCursor)
button._icon_name = icon_name # noqa: SLF001 - read back by set_theme
return button
def _build_value_card(
self, channel: str
) -> tuple[QFrame, QLabel, QLabel, QLabel, QLabel]:
@@ -323,6 +361,11 @@ class ForcePlotsPanel(QFrame):
bg = color(theme, "plot_bg")
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):
name = getattr(button, "_icon_name", "")
if name:
button.setIcon(icons.icon(name, theme))
self.plot_widget.setBackground(bg)
axis_pen = pg.mkPen(text)
for i, plot in enumerate(self.plots):
@@ -433,20 +476,53 @@ class ForcePlotsPanel(QFrame):
only its normal-force axis calibrated). Those keep NaN and show as a gap
and a dash, never as a raw count wearing a newton label.
"""
if unit != self._unit:
# Never mix units in one buffer. Callers switch units through
# ``set_units``/``set_fixed_newton`` which rebuild from the session
# store; a bare change here would silently reinterpret old samples.
self._unit = unit
if self.is_recording_source():
# The curves, the numbers and the unit all belong to the recording
# being shown, so a live sample must not touch any of them — not even
# to switch the unit (``set_units`` rebuilds the visible curves from
# the attached source). The acquisition is unaffected: the record strip
# and the data chip carry its state, and leaving the replay view puts
# the live values back within one frame.
return
self._last_values = (float(fn), float(fx), float(fy))
if available is not None:
self._available = tuple(bool(flag) for flag in available)
self._render_value_cards()
if unit != self._unit:
# A unit change mid-stream is a *switch*, never a reinterpretation:
# the buffered values were measured in the old unit and must not be
# drawn on the new axis. This is the spike that used to appear at the
# start of every session — a device that confirms its calibration a
# moment after the first frames reported raw counts for a few frames,
# and those counts were then drawn against the newton axis.
self._switch_unit(unit)
# The rebuild reads the session store, which already holds this
# sample (the record channel appends before the UI tick runs), so
# appending it again would duplicate — or reorder — the buffer.
state = self.history.state()
if state is not None:
self._syncing = True
try:
for i, curve in enumerate(self.curves):
curve.setData(state.times, state.values[:, i])
self.plots[i].setXRange(*state.x_range, padding=0.0)
finally:
self._syncing = False
self._render_value_cards()
# ...and if the view is browsing (or showing a recording) the switch
# is not a reason to read the live sample out: the cards follow the
# cursor, so re-read it in the new unit.
self._readout_at_cursor()
self._overview_dirty = True
self._sync_overview()
return
if self._mode == "browse":
# Browsing is a display pause, not a capture pause: the record
# channel keeps running and the view simply stops following.
# channel keeps running and the view simply stops following. The
# readout belongs to the cursor now, so a live sample must not
# overwrite it either.
self._overview_dirty = True
return
self._render_value_cards()
use_fixed = self._uses_fixed_y()
# Store the true value. Clamping here (the old ``max(0.0, fn)``) erased
# negative FN from the buffer and hid zero drift; out-of-range values are
@@ -483,15 +559,16 @@ class ForcePlotsPanel(QFrame):
self._last_overview_at = now
self._sync_overview()
def set_units(self, unit: str, store=None) -> None:
def set_units(self, unit: str) -> None:
"""Switch display units without mixing units in the curve buffer.
When a session store is supplied the visible window is rebuilt from it,
because it keeps both the raw and the calibrated triplet for every
sample. Otherwise the buffer is cleared rather than reinterpreted.
The visible window is rebuilt from whichever source is attached, because
a session store keeps both the raw and the calibrated triplet for every
sample and a recording stores both as well. Otherwise the buffer is
cleared rather than reinterpreted.
"""
unit = "N" if str(unit).upper().startswith("N") else "raw"
if unit == self._unit and store is None:
if unit == self._unit:
return
self._unit = unit
# ``clear`` keeps the buffer's time origin on purpose: the rebuilt history
@@ -499,34 +576,89 @@ class ForcePlotsPanel(QFrame):
self.history.clear()
self._clear_curves()
self._overview_dirty = True
if store is not None:
self.set_store(store)
if self._source_ready:
if self._mode == "browse":
self.refresh_from_store()
else:
self._reload_from_store(store)
self._reload_from_store()
self._render_value_cards()
self._apply_axis_mode()
# Re-read the point under the cursor in the new unit: while browsing, or
# with a recording attached, the live sample is not what the cards state.
self._readout_at_cursor()
# ---------------------------------------------------------------- browsing
def set_store(self, store) -> None:
"""Attach the whole-session store the browser reads from."""
self._store = store
self._source.set_store(store)
self._live_store = store
self._source = StoreCurveSource(store)
self._source_ready = store is not None
self._on_source_changed()
def set_recording_source(self, reader) -> None:
"""Read the curves from a recording on disk instead of the live store."""
from orisys_app_shared.force.source import RecordingCurveSource
self._source = RecordingCurveSource(reader)
self._source_ready = True
self._on_source_changed()
def clear_recording_source(self) -> None:
"""Go back to the live session store (a no-op if it already is)."""
if self.is_recording_source():
self.set_store(self._live_store)
def is_recording_source(self) -> bool:
return str(getattr(self._source, "kind", "")) == "disk"
def _switch_unit(self, unit: str) -> None:
"""Move the live buffer to another display unit without misreading it.
The window is rebuilt from the attached source, which keeps both the raw
and the calibrated triplet for every sample, so a sample that has no
value in the new unit becomes a gap instead of a number in the wrong
scale.
"""
self._unit = unit
self.history.clear()
self._clear_curves()
self._overview_dirty = True
if self._source_ready:
self._reload_from_store()
self._apply_axis_mode()
def _on_source_changed(self) -> None:
"""Reset the view for a new source: a recording is not a live session."""
self.history.clear()
self._clear_curves()
self._mode = "follow"
self._window = (0.0, 0.0)
self._overview_dirty = True
self._refresh_mode_label()
self._apply_axis_mode()
if not self._source_ready:
self.overview_curve.setData([], [])
self.window_label.setText("")
self.stats_label.setText("")
return
self._sync_overview()
self._reload_from_store()
def mode(self) -> str:
return self._mode
def unit(self) -> str:
"""The unit the curves are currently drawn in (``N`` or ``raw``)."""
return self._unit
def cursor_time(self) -> float:
return self._cursor_t
def window(self) -> tuple[float, float]:
if self._mode == "browse":
return self._window
if self._store is not None:
return self._store.extent()
if self._source_ready:
return self._source.extent()
return (0.0, 0.0)
def follow_latest(self) -> None:
@@ -559,7 +691,7 @@ class ForcePlotsPanel(QFrame):
def refresh_from_store(self) -> None:
"""Redraw the visible window. Called on pan, zoom and data changes."""
if self._store is None:
if not self._source_ready:
return
t0, t1 = self._window
# One shared rule for every view, decided **per axis**: valid and finite
@@ -596,14 +728,30 @@ class ForcePlotsPanel(QFrame):
self._interactive(mode == "browse")
self._refresh_mode_label()
self._sync_overview()
self._readout_at_cursor()
self.mode_changed.emit(mode)
def _interactive(self, enabled: bool) -> None:
"""Pan/zoom is only meaningful over a stored window."""
def _interactive(self, browsing: bool) -> None:
"""Pan/zoom is always available; only the *cursor* is browse-only.
Gating the mouse made browsing unreachable: entering it needs a drag, and
a chart that ignores the mouse never produces one.
"""
for plot in self.plots:
plot.setMouseEnabled(x=bool(enabled), y=False)
if not enabled:
plot.setMouseEnabled(x=True, y=False)
if not browsing:
plot.getViewBox().setMouseMode(pg.ViewBox.PanMode)
for cursor in self.cursors:
cursor.setMovable(bool(browsing))
def _on_cursor_dragged(self) -> None:
"""The shared cursor is a timeline control while browsing."""
if self._syncing or self._mode != "browse":
return
value = float(self.cursors[0].value())
if value == self._cursor_t:
return
self.set_cursor(value, emit=True)
def _on_box_zoom_toggled(self, checked: bool) -> None:
mode = pg.ViewBox.RectMode if checked else pg.ViewBox.PanMode
@@ -612,16 +760,20 @@ class ForcePlotsPanel(QFrame):
for plot in self.plots:
plot.getViewBox().setMouseMode(mode)
def _on_x_range_changed(self, _viewbox, x_range) -> None:
"""User scrolled or dragged: leave live mode and read from the store.
def _on_manual_range_change(self, mask=None) -> None:
"""The user zoomed or panned: leave live mode and read the store.
Programmatic range changes are ignored via ``self._syncing`` — without
that, redrawing a window would feed back in as if the user had dragged,
and "back to latest" could never take effect.
This is pyqtgraph's *manual* signal, so a programmatic range change (or
one propagated along the X links while the widget is resized) never
arrives here. ``self._syncing`` still guards our own redraws.
"""
if self._syncing or self._store is None:
if self._syncing or not self._source_ready:
return
x0, x1 = float(x_range[0]), float(x_range[1])
if mask is not None and len(mask) > 0 and not bool(mask[0]):
# A y-only gesture; the time axis did not change.
return
view_range = self.plots[0].getViewBox().viewRange()
x0, x1 = float(view_range[0][0]), float(view_range[0][1])
if not (x1 > x0):
return
first, last = self._source.extent()
@@ -647,11 +799,45 @@ class ForcePlotsPanel(QFrame):
for cursor in self.cursors:
cursor.setValue(self._cursor_t)
self.overview_cursor.setValue(self._cursor_t)
self._readout_at_cursor()
if emit:
self.cursor_moved.emit(self._cursor_t)
def _readout_at_cursor(self) -> None:
"""Make the value cards describe the point the cursor is on.
While following live the newest sample *is* the cursor — ``push`` writes
both — so there is nothing to read. Browsing history or a recording means
the cursor sits somewhere in the past, and then the cards must read that
point out of the source: a replay used to show a chart at 7.5e5 raw counts
next to cards reading the live 0.0, which reads as "the replay is not
open". Availability is decided per sample here as well, so a cursor on a
row with no value in this unit shows a dash, never a stale number.
"""
if self._mode != "browse" and not self.is_recording_source():
return
if not self._source_ready:
return
first, last = self._source.extent()
t0 = max(first, self._cursor_t - CURSOR_READOUT_S)
t1 = min(last, self._cursor_t + CURSOR_READOUT_S)
if not (t1 >= t0):
return
window = self._source.window(t0, t1, unit=self._unit)
if window is None or len(window) == 0:
return
times = np.asarray(window.times, dtype=float)
index = int(np.argmin(np.abs(times - self._cursor_t)))
values = np.asarray(window.values, dtype=float)[index]
valid = bool(np.asarray(window.valid, dtype=bool)[index])
self._last_values = tuple(float(value) for value in values)
self._available = tuple(
bool(valid and np.isfinite(value)) for value in values
)
self._render_value_cards()
def _update_selection_readout(self) -> None:
if self._store is None:
if not self._source_ready:
self.stats_label.setText("")
return
t0, t1 = self._window
@@ -680,7 +866,7 @@ class ForcePlotsPanel(QFrame):
)
def _sync_overview(self) -> None:
if self._store is None:
if not self._source_ready:
return
first, last = self._source.extent()
if last <= first:
@@ -738,7 +924,7 @@ class ForcePlotsPanel(QFrame):
The region is the timeline control, so a drag is a pan: it enters
history browsing exactly like dragging the charts themselves.
"""
if self._syncing or self._store is None:
if self._syncing or not self._source_ready:
return
start, end = (float(v) for v in self.overview_region.getRegion())
if not (end > start):
@@ -752,13 +938,20 @@ class ForcePlotsPanel(QFrame):
return 1200
def _refresh_mode_label(self) -> None:
key = "chart.mode_browse" if self._mode == "browse" else "chart.mode_follow"
if self.is_recording_source():
key = "chart.mode_replay"
else:
key = "chart.mode_browse" if self._mode == "browse" else "chart.mode_follow"
self.mode_label.setText(t(key))
self.btn_latest.setEnabled(self._mode == "browse")
self.btn_overview.setEnabled(self._store is not None)
# "Back to latest" only means something while reading a live session; a
# recording has no latest sample to return to.
self.btn_latest.setEnabled(
self._mode == "browse" and not self.is_recording_source()
)
self.btn_overview.setEnabled(self._source_ready)
def _reload_from_store(self, store) -> None:
"""Re-populate the visible buffer from the session source."""
def _reload_from_store(self) -> None:
"""Re-populate the visible buffer from the attached source."""
window = self.history.window_sec
first, last = self._source.extent()
span = window if window > 0 else max(0.0, last - first)
@@ -874,12 +1067,14 @@ class ForcePlotsPanel(QFrame):
badge.setText(label)
for name_label, key in zip(self.channel_names, ("fn", "fx", "fy")):
name_label.setText(t(f"chart.{key}_name"))
self.btn_latest.setText(t("chart.return_latest"))
self.btn_latest.setToolTip(t("chart.return_latest_tip"))
self.btn_overview.setText(t("chart.overview"))
self.btn_latest.setAccessibleName(t("chart.return_latest"))
self.btn_overview.setToolTip(t("chart.overview_tip"))
self.btn_box_zoom.setText(t("chart.box_zoom"))
self.btn_overview.setAccessibleName(t("chart.overview"))
self.btn_box_zoom.setToolTip(t("chart.box_zoom_tip"))
self.btn_box_zoom.setAccessibleName(t("chart.box_zoom"))
self.unit_badge.setToolTip(t("chart.unit_toggle_tip"))
self.unit_badge.setAccessibleName(t("chart.unit_toggle"))
self.window_caption.setText(t("chart.overview"))
self.window_caption.setToolTip(t("chart.window_tip"))
self._refresh_mode_label()
+117
View File
@@ -0,0 +1,117 @@
"""Small monochrome icons, painted at runtime from the theme tokens.
The app ships no icon assets beyond its logo, and Qt's built-in icons follow the
platform's own look. These three chart-header actions are therefore drawn here:
a transparent background and a pen colour taken from the theme, so the header
stays readable in both themes without a single hex literal in widget code.
The cache is keyed on the *resolved colour*, not on the theme name: a theme
switch therefore produces new pixmaps, and two themes that share a colour share
the pixmap.
"""
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 orisys_app_shared.theme import color
#: Names of the icons this module can paint.
NAMES = ("zoom_fit", "box_zoom", "latest")
ICON_SIZE = 16
_PEN_WIDTH = 1.4
def _paint_zoom_fit(painter: QPainter, size: int) -> None:
"""Four corner brackets around a small frame: "fit this to the screen"."""
inset = 2.0
arm = size * 0.28
far = size - inset
for x, y, dx, dy in (
(inset, inset, 1, 1),
(far, inset, -1, 1),
(inset, far, 1, -1),
(far, far, -1, -1),
):
painter.drawLine(
int(round(x)), int(round(y)), int(round(x + dx * arm)), int(round(y))
)
painter.drawLine(
int(round(x)), int(round(y)), int(round(x)), int(round(y + dy * arm))
)
side = size * 0.375
painter.drawRect(QRectF((size - side) / 2.0, (size - side) / 2.0, side, side))
def _paint_box_zoom(painter: QPainter, size: int) -> None:
"""A dashed frame: drag a region to zoom into it."""
pen = painter.pen()
pen.setStyle(Qt.DashLine)
pen.setDashPattern([2.2, 1.6])
painter.setPen(pen)
painter.drawRect(QRectF(2.0, 2.0, size - 4.0, size - 4.0))
def _paint_latest(painter: QPainter, size: int) -> None:
"""An arrow running into a bar: jump to the newest sample."""
top = 3.0
bottom = size - 3.0
mid = size / 2.0
bar_x = size - 3.0
draw_to = bar_x - 2.5
painter.drawLine(int(round(bar_x)), int(round(top)), int(round(bar_x)), int(round(bottom)))
painter.drawLine(2, int(round(mid)), int(round(draw_to)), int(round(mid)))
head = size * 0.26
painter.drawLine(
int(round(draw_to)), int(round(mid)),
int(round(draw_to - head)), int(round(mid - head)),
)
painter.drawLine(
int(round(draw_to)), int(round(mid)),
int(round(draw_to - head)), int(round(mid + head)),
)
_PAINTERS = {
"zoom_fit": _paint_zoom_fit,
"box_zoom": _paint_box_zoom,
"latest": _paint_latest,
}
@lru_cache(maxsize=32)
def pixmap(name: str, tint: str, size: int = ICON_SIZE) -> QPixmap:
"""One monochrome icon, drawn in ``tint`` (a ``#RRGGBB`` token value)."""
canvas = QPixmap(size, size)
canvas.fill(Qt.transparent)
paint = _PAINTERS.get(str(name))
if paint is None:
return canvas
painter = QPainter(canvas)
painter.setRenderHint(QPainter.Antialiasing, True)
pen = QPen()
pen.setWidthF(_PEN_WIDTH)
pen.setCapStyle(Qt.RoundCap)
pen.setJoinStyle(Qt.RoundJoin)
pen.setColor(QColor(tint))
painter.setPen(pen)
painter.setBrush(Qt.NoBrush)
try:
paint(painter, size)
finally:
painter.end()
return canvas
def icon(name: str, theme: str, *, token: str = "text_secondary") -> QIcon:
"""The named icon in the theme's colour for ``token``."""
return QIcon(pixmap(name, color(theme, token)))
def clear() -> None:
"""Drop the cached pixmaps (used by tests and by a theme rebuild)."""
pixmap.cache_clear()
@@ -0,0 +1,158 @@
"""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"))
@@ -0,0 +1,203 @@
"""Sensor and calibration dialog.
The status rail shows one summary button (``BOX · box/sensor.json``): which
product and config the window is configured for is worth reading at a glance,
but the pickers themselves — and the calibration that belongs to them — need
more room than a 26 px rail can give.
The calibration half is read-only on purpose: it states which calibration the
*device* confirmed, which axes it covers, and what the last exported
coefficients file says it belongs to. Exporting and applying stay single-entry
actions (they are the calibration panel's own methods), so there is exactly one
implementation of each.
"""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from PyQt5.QtWidgets import (
QDialog,
QFrame,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
)
from orisys_app_shared.i18n import t
from orisys_studio.ui.fit_text import ShrinkableLabel
class SensorDialog(QDialog):
"""Sensor type / config picker plus the calibration facts that follow it."""
def __init__(self, *, window, picker, theme: str = "dark") -> None:
super().__init__(window)
self._window = window
self._theme = str(theme)
self.picker = picker
self.setObjectName("sensorDialog")
self.setModal(False)
root = QVBoxLayout(self)
root.setSpacing(10)
self.picker_row = QHBoxLayout()
self.picker_row.addWidget(self.picker)
self.picker_row.addStretch(1)
root.addLayout(self.picker_row)
root.addWidget(self._section_rule())
self.calib_title = QLabel()
self.calib_title.setObjectName("panelTitle")
root.addWidget(self.calib_title)
self.version_label = ShrinkableLabel()
self.version_label.setObjectName("panelMeta")
self.axes_label = ShrinkableLabel()
self.axes_label.setObjectName("panelMeta")
self.config_label = ShrinkableLabel()
self.config_label.setObjectName("panelMeta")
for label in (self.version_label, self.axes_label):
root.addWidget(label)
config_row = QHBoxLayout()
config_row.addWidget(self.config_label)
self.btn_reveal = QPushButton()
self.btn_reveal.setProperty("compact", "true")
self.btn_reveal.clicked.connect(self._window._on_reveal_config)
config_row.addWidget(self.btn_reveal)
config_row.addStretch(1)
root.addLayout(config_row)
root.addWidget(self._section_rule())
self.export_title = QLabel()
self.export_title.setObjectName("panelTitle")
root.addWidget(self.export_title)
self.export_label = ShrinkableLabel()
self.export_label.setObjectName("panelMeta")
self.identity_label = ShrinkableLabel()
self.identity_label.setObjectName("panelMeta")
self.export_label.setWordWrap(True)
self.identity_label.setWordWrap(True)
root.addWidget(self.export_label)
root.addWidget(self.identity_label)
actions = QHBoxLayout()
actions.setSpacing(6)
self.btn_export = QPushButton()
self.btn_export.clicked.connect(self._on_export)
self.btn_apply = QPushButton()
self.btn_apply.clicked.connect(self._on_apply)
self.btn_close = QPushButton()
self.btn_close.setProperty("compact", "true")
self.btn_close.clicked.connect(self.close)
actions.addWidget(self.btn_export)
actions.addWidget(self.btn_apply)
actions.addStretch(1)
actions.addWidget(self.btn_close)
root.addLayout(actions)
# The picker's own changes change the calibration context, so the dialog
# follows them while it is open.
self.picker.sensor_type_changed.connect(lambda _value: self.refresh())
self.picker.config_changed.connect(lambda _value: self.refresh())
self.retranslate()
self.refresh()
@staticmethod
def _section_rule() -> QFrame:
rule = QFrame()
rule.setObjectName("sectionSeparator")
rule.setFrameShape(QFrame.HLine)
rule.setFixedHeight(1)
return rule
# ------------------------------------------------------------------ actions
def _on_export(self) -> None:
self._window.cal_panel.export_coefficients()
self.refresh()
def _on_apply(self) -> None:
self._window.cal_panel.apply_coefficients()
self.refresh()
# ------------------------------------------------------------------ content
def refresh(self) -> None:
"""Re-read the shell's calibration state. Called on open and on change."""
window = self._window
version = str(getattr(window, "_active_calibration_id", "") or "")
self.version_label.setText(
f"{t('sensor.calib_version')}: {version or t('sensor.calib_none')}"
)
axes = ""
summary = getattr(window, "_calibration_summary", None)
if callable(summary):
axes = str(summary())
self.axes_label.setText(f"{t('sensor.calib_axes')}: {axes or t('sensor.calib_none')}")
config_ref = ""
resolve = getattr(window, "_resolve_config_ref", None)
if callable(resolve):
config_ref = str(resolve() or "")
self.config_label.setText(
f"{t('sensor.calib_config')}: "
f"{Path(config_ref).name if config_ref else t('status.config_none')}"
)
self.btn_reveal.setEnabled(bool(config_ref))
export = str(getattr(window, "_last_calibration_export", "") or "")
if not export:
self.export_label.setText(t("sensor.export_none"))
self.identity_label.setText("")
self.btn_apply.setEnabled(False)
return
path = Path(export)
self.export_label.setText(
t(
"sensor.export_file",
name=path.name,
size=f"{path.stat().st_size / 1024:.1f}" if path.is_file() else "?",
time=(
datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
if path.is_file()
else "?"
),
)
)
identity = {}
read_identity = getattr(window, "calibration_identity", None)
if callable(read_identity):
identity = dict(read_identity(export) or {})
self.identity_label.setText(
t(
"sensor.export_identity",
sensor_type=identity.get("sensor_type") or "?",
sn=identity.get("sn") or "?",
config=Path(str(identity.get("config_ref") or "?")).name,
)
)
self.btn_apply.setEnabled(True)
def retranslate(self) -> None:
self.setWindowTitle(t("sensor.dialog_title"))
self.calib_title.setText(t("sensor.calib_section"))
self.export_title.setText(t("sensor.export_section"))
self.btn_reveal.setText(t("menu.reveal_config"))
self.btn_export.setText(t("cal.export"))
self.btn_export.setToolTip(t("cal.export_tip"))
self.btn_apply.setText(t("cal.apply"))
self.btn_apply.setToolTip(t("cal.apply_tip"))
self.btn_close.setText(t("dialog.cancel"))
self.picker.retranslate()
self.refresh()
def set_theme(self, theme: str) -> None:
self._theme = str(theme)
def showEvent(self, event) -> None: # noqa: N802 - Qt naming
# Every open re-reads the shell: the device may have confirmed a
# calibration since the dialog was last shown.
self.refresh()
super().showEvent(event)
+127 -117
View File
@@ -12,20 +12,16 @@ scrolls sideways instead of squeezing the controls into an unreadable row.
from __future__ import annotations
from pathlib import Path
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import (
QComboBox,
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
QSizePolicy,
QSpinBox,
QStackedWidget,
QVBoxLayout,
QWidget,
)
@@ -77,8 +73,10 @@ class ToolbarWidget(QWidget):
search_clicked = pyqtSignal()
preview_toggled = pyqtSignal(bool)
camera_id_changed = pyqtSignal(int)
source_mode_changed = pyqtSignal(str)
browse_video_clicked = pyqtSignal()
#: The sensor summary button: opens the sensor and calibration dialog.
sensor_clicked = pyqtSignal()
#: "Back to the camera" while a video file is the run source.
use_camera_clicked = pyqtSignal()
run_toggled = pyqtSignal(bool)
reset_clicked = pyqtSignal()
units_changed = pyqtSignal(str)
@@ -91,7 +89,6 @@ class ToolbarWidget(QWidget):
parent=None,
*,
theme: str = "dark",
sensor_picker: QWidget | None = None,
) -> None:
super().__init__(parent)
self._theme = str(theme)
@@ -99,6 +96,13 @@ class ToolbarWidget(QWidget):
self._run_available = True
self._run_phase = "idle"
self._settings_expanded = False
#: "camera" (the default) or "video": an offline acquisition run fed from
#: a video file. There is no source *combobox* any more — the view
#: decides which picker is on screen, and the file menu switches modes.
self._source_mode = "camera"
self._source_view = "live"
self._source_controls_enabled = True
self._video_path = ""
outer = QVBoxLayout(self)
outer.setContentsMargins(2, 2, 2, 2)
@@ -107,7 +111,7 @@ class ToolbarWidget(QWidget):
# The rail is always visible and never scrolls: it is one instrument
# line, and a scroll area around it would reserve a scrollbar's height
# (30 px in the Fusion metrics) even when nothing scrolls.
outer.addLayout(self._build_rail(sensor_picker))
outer.addLayout(self._build_rail())
# Only the settings area scrolls; it is the part that can outgrow a
# narrow window.
@@ -120,7 +124,6 @@ class ToolbarWidget(QWidget):
self._settings_scroll.setWidget(self.settings_page)
outer.addWidget(self._settings_scroll)
self._populate_source_combo()
self._populate_units_combo()
self._populate_record_modes()
self.retranslate()
@@ -128,25 +131,28 @@ class ToolbarWidget(QWidget):
self.set_settings_expanded(False)
# --------------------------------------------------------------- building
def _build_rail(self, sensor_picker: QWidget | None) -> QHBoxLayout:
"""Sensor choice, phase, device, calibration, validity, record, actions.
def _build_rail(self) -> QHBoxLayout:
"""Sensor, phase, capture source, calibration, validity, record, actions.
The sensor picker leads the line: which product and config the window is
configured for is the first thing to read, and it is what everything
else (units, plots, calibration) depends on.
The sensor choice is one summary button (the pickers themselves live in
the sensor dialog), and the capture source is the picker the *current
view* needs: a camera id while acquiring, a video file while running
from a file. Neither is a "source mode" combobox to be discovered.
"""
rail = QHBoxLayout()
rail.setSpacing(8)
if sensor_picker is not None:
rail.addWidget(sensor_picker)
rail.addWidget(_separator())
self.btn_sensor = QPushButton()
self.btn_sensor.setObjectName("ghostButton")
self.btn_sensor.setProperty("compact", "true")
self.btn_sensor.setMinimumHeight(BUTTON_HEIGHT)
self.btn_sensor.clicked.connect(self.sensor_clicked.emit)
self.status_chip = QLabel()
self.status_chip.setObjectName("statusChip")
self.status_chip.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred)
self.sensor_summary = ShrinkableLabel()
self.sensor_summary.setObjectName("panelMeta")
self.source_summary = ShrinkableLabel()
self.source_summary.setObjectName("panelMeta")
self.calibration_label = ShrinkableLabel()
self.calibration_label.setObjectName("panelMeta")
self.data_state = ShrinkableLabel()
@@ -154,6 +160,42 @@ class ToolbarWidget(QWidget):
self.data_state.setVisible(False)
self.record_chip = RecordStatusChip(theme=self._theme)
# --- capture source (one of the two groups is visible) -------------
self.source_slot = QWidget()
slot = QHBoxLayout(self.source_slot)
slot.setContentsMargins(0, 0, 0, 0)
slot.setSpacing(6)
self.camera_group = QWidget()
cam_layout = QHBoxLayout(self.camera_group)
cam_layout.setContentsMargins(0, 0, 0, 0)
cam_layout.setSpacing(6)
self.cam_id_label = QLabel()
self.cam_id_label.setObjectName("fieldLabel")
self.cam_id_spin = QSpinBox()
self.cam_id_spin.setRange(0, 20)
self.cam_id_spin.setValue(0)
self.cam_id_spin.setFixedWidth(64)
self.cam_id_spin.setProperty("rail", "true")
self.cam_id_spin.valueChanged.connect(self.camera_id_changed.emit)
self.btn_search = QPushButton()
self.btn_search.setProperty("compact", "true")
self.btn_search.setMinimumHeight(BUTTON_HEIGHT)
self.btn_search.clicked.connect(self.search_clicked.emit)
cam_layout.addWidget(self.cam_id_label)
cam_layout.addWidget(self.cam_id_spin)
cam_layout.addWidget(self.btn_search)
self.video_group = QWidget()
video_layout = QHBoxLayout(self.video_group)
video_layout.setContentsMargins(0, 0, 0, 0)
video_layout.setSpacing(6)
self.btn_use_camera = QPushButton()
self.btn_use_camera.setProperty("compact", "true")
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)
slot.addWidget(self.camera_group)
slot.addWidget(self.video_group)
self.btn_record = QPushButton()
self.btn_record.setObjectName("primaryButton")
self.btn_record.setMinimumHeight(BUTTON_HEIGHT)
@@ -181,9 +223,12 @@ class ToolbarWidget(QWidget):
self.btn_settings.setCheckable(True)
self.btn_settings.toggled.connect(self._on_settings_toggled)
rail.addWidget(_rail(self.btn_sensor))
rail.addWidget(_separator())
rail.addWidget(self.status_chip)
rail.addWidget(_separator())
rail.addWidget(self.sensor_summary)
rail.addWidget(self.source_slot)
rail.addWidget(self.source_summary)
rail.addWidget(self.calibration_label)
rail.addWidget(self.data_state)
rail.addStretch(1)
@@ -195,62 +240,16 @@ class ToolbarWidget(QWidget):
return rail
def _build_settings(self) -> QWidget:
"""Source, camera/video, search, preview and units.
"""Units, record mode and preview.
The sensor picker is *not* here: it leads the status rail, because the
product choice is session state, not a setting to dig for.
The capture source is on the rail (see ``_build_rail``) because it is
what the current view is *about*, not a setting to dig for.
"""
self.settings_page = QWidget()
page = QVBoxLayout(self.settings_page)
page.setContentsMargins(0, 2, 0, 0)
page.setSpacing(6)
# --- source group -------------------------------------------------
self.source_label = QLabel()
self.source_label.setObjectName("fieldLabel")
self.source_combo = QComboBox()
self.source_combo.setMinimumWidth(96)
self.source_combo.setMinimumHeight(CONTROL_HEIGHT)
self.source_combo.currentIndexChanged.connect(self._on_source_index_changed)
self.source_stack = QStackedWidget()
cam_page = QWidget()
cam_layout = QHBoxLayout(cam_page)
cam_layout.setContentsMargins(0, 0, 0, 0)
cam_layout.setSpacing(8)
self.cam_id_label = QLabel()
self.cam_id_label.setObjectName("fieldLabel")
self.cam_id_spin = QSpinBox()
self.cam_id_spin.setRange(0, 20)
self.cam_id_spin.setValue(0)
self.cam_id_spin.setFixedWidth(72)
self.cam_id_spin.setMinimumHeight(CONTROL_HEIGHT)
self.cam_id_spin.valueChanged.connect(self.camera_id_changed.emit)
cam_layout.addWidget(self.cam_id_label)
cam_layout.addWidget(self.cam_id_spin)
video_page = QWidget()
video_layout = QHBoxLayout(video_page)
video_layout.setContentsMargins(0, 0, 0, 0)
video_layout.setSpacing(8)
self.video_edit = QLineEdit()
self.video_edit.setMinimumWidth(180)
self.video_edit.setMinimumHeight(CONTROL_HEIGHT)
self.video_edit.setReadOnly(True)
self.btn_browse_video = QPushButton()
self.btn_browse_video.setMinimumHeight(BUTTON_HEIGHT)
self.btn_browse_video.clicked.connect(self.browse_video_clicked.emit)
video_layout.addWidget(self.video_edit, 1)
video_layout.addWidget(self.btn_browse_video)
self.source_stack.addWidget(cam_page)
self.source_stack.addWidget(video_page)
# --- action group -------------------------------------------------
self.btn_search = QPushButton()
self.btn_search.setMinimumHeight(BUTTON_HEIGHT)
self.btn_search.clicked.connect(self.search_clicked.emit)
self.btn_preview = QPushButton()
self.btn_preview.setMinimumHeight(BUTTON_HEIGHT)
self.btn_preview.setCheckable(True)
@@ -276,11 +275,7 @@ class ToolbarWidget(QWidget):
top_row = QHBoxLayout()
top_row.setSpacing(10)
top_row.addWidget(
_group(self.source_label, self.source_combo, self.source_stack)
)
top_row.addWidget(_separator())
top_row.addWidget(_group(self.btn_search, self.btn_preview))
top_row.addWidget(self.btn_preview)
top_row.addWidget(_separator())
top_row.addWidget(_group(self.units_label, self.units_combo))
top_row.addWidget(_separator())
@@ -307,10 +302,16 @@ class ToolbarWidget(QWidget):
self.record_chip.set_theme(self._theme)
def set_sensor_summary(self, text: str) -> None:
self.sensor_summary.setText(str(text or ""))
"""The rail's sensor button text (``BOX · box/sensor.json``)."""
self.btn_sensor.setText(str(text or ""))
self.btn_sensor.setToolTip(t("sensor.dialog_tip"))
def set_source_summary(self, text: str) -> None:
"""Extra detail about the capture source (fps, video/recording name)."""
self.source_summary.setText(str(text or ""))
# The rail may squeeze this label; the tooltip always carries it whole.
self.sensor_summary.setToolTip(self.sensor_summary.text())
self.sensor_summary.setVisible(bool(text))
self.source_summary.setToolTip(self.source_summary.text())
self.source_summary.setVisible(bool(text))
def set_calibration_summary(self, text: str) -> None:
self.calibration_label.setText(str(text or ""))
@@ -334,19 +335,24 @@ class ToolbarWidget(QWidget):
self.record_chip.set_state(*args, **kwargs)
def source_mode(self) -> str:
data = self.source_combo.currentData()
return str(data or "camera")
"""``camera`` or ``video``: what a run would acquire from."""
return self._source_mode
def set_source_mode(self, mode: str) -> None:
wanted = "video" if str(mode).lower().startswith("v") else "camera"
idx = self.source_combo.findData(wanted)
if idx < 0:
idx = 0
self.source_combo.blockSignals(True)
self.source_combo.setCurrentIndex(idx)
self.source_combo.blockSignals(False)
self._source_mode = "video" if str(mode).lower().startswith("v") else "camera"
self._apply_source_mode_ui()
def set_source_view(self, view: str) -> None:
"""Which picker the current view needs (``live`` or ``replay``)."""
wanted = "replay" if str(view) == "replay" else "live"
if wanted == self._source_view:
return
self._source_view = wanted
self._apply_source_mode_ui()
def source_view(self) -> str:
return self._source_view
def camera_id(self) -> int:
return int(self.cam_id_spin.value())
@@ -356,12 +362,11 @@ class ToolbarWidget(QWidget):
self.cam_id_spin.blockSignals(False)
def video_path(self) -> str:
return self.video_edit.text().strip()
return str(self._video_path or "")
def set_video_path(self, path: str) -> None:
text = str(path or "").strip()
self.video_edit.setText(text)
self.video_edit.setToolTip(text)
self._video_path = str(path or "").strip()
self._apply_source_mode_ui()
def units(self) -> str:
data = self.units_combo.currentData()
@@ -394,14 +399,21 @@ class ToolbarWidget(QWidget):
self.btn_reset.setEnabled(bool(enabled))
def set_camera_controls_enabled(self, enabled: bool) -> None:
self.btn_search.setEnabled(enabled and self.source_mode() == "camera")
self.cam_id_spin.setEnabled(enabled and self.source_mode() == "camera")
live = self._source_view == "live"
camera = self.source_mode() == "camera"
allowed = bool(enabled) and live
self.btn_search.setEnabled(allowed and camera)
self.cam_id_spin.setEnabled(allowed and camera)
self.btn_use_camera.setEnabled(allowed and not camera)
def set_source_controls_enabled(self, enabled: bool) -> None:
self.source_combo.setEnabled(enabled)
self.btn_browse_video.setEnabled(enabled and self.source_mode() == "video")
self.set_camera_controls_enabled(enabled)
self.btn_preview.setEnabled(enabled and self.source_mode() == "camera")
self._source_controls_enabled = bool(enabled)
self.set_camera_controls_enabled(self._source_controls_enabled)
self.btn_preview.setEnabled(
self._source_controls_enabled
and self._source_view == "live"
and self.source_mode() == "camera"
)
def set_run_enabled(self, enabled: bool) -> None:
self._run_available = bool(enabled)
@@ -430,13 +442,6 @@ class ToolbarWidget(QWidget):
self._refresh_run_label()
# ------------------------------------------------------------- internals
def _populate_source_combo(self) -> None:
self.source_combo.blockSignals(True)
self.source_combo.clear()
self.source_combo.addItem("", "camera")
self.source_combo.addItem("", "video")
self.source_combo.blockSignals(False)
def _populate_units_combo(self) -> None:
current = self.units_combo.currentData()
self.units_combo.blockSignals(True)
@@ -510,16 +515,27 @@ class ToolbarWidget(QWidget):
self._refresh_emphasis()
def _apply_source_mode_ui(self) -> None:
"""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.
"""
live = self._source_view == "live"
video = self.source_mode() == "video"
self.source_stack.setCurrentIndex(1 if video else 0)
self.btn_search.setEnabled(not video)
self.btn_preview.setEnabled(not video)
self.camera_group.setVisible(live and not video)
self.video_group.setVisible(live and video)
self.source_slot.setVisible(live)
self.set_source_controls_enabled(self._source_controls_enabled)
if video and self.btn_preview.isChecked():
self.set_preview_checked(False)
self._refresh_source_tooltips()
def _on_source_index_changed(self, _index: int) -> None:
self._apply_source_mode_ui()
self.source_mode_changed.emit(self.source_mode())
def _refresh_source_tooltips(self) -> None:
path = self.video_path()
self.source_summary.setToolTip(path or self.source_summary.text())
self.btn_use_camera.setToolTip(t("toolbar.use_camera_tip"))
def _on_preview_toggled(self, checked: bool) -> None:
self._refresh_preview_label()
@@ -600,21 +616,15 @@ class ToolbarWidget(QWidget):
self.btn_run.setToolTip(tip)
def retranslate(self) -> None:
self.source_label.setText(t("toolbar.source"))
self.source_combo.setItemText(0, t("toolbar.source_camera"))
self.source_combo.setItemText(1, t("toolbar.source_video"))
self.cam_id_label.setText(t("toolbar.cam_id"))
self.btn_browse_video.setText(t("toolbar.browse_video"))
path = self.video_path()
self.video_edit.setPlaceholderText(
Path(path).name if path else t("toolbar.video_placeholder")
)
self.btn_search.setText(t("toolbar.search_cameras"))
self.btn_use_camera.setText(t("toolbar.use_camera"))
self.refresh_labels()
self.btn_reset.setText(t("toolbar.reset"))
self.units_label.setText(t("toolbar.units"))
self.units_combo.setItemText(0, t("toolbar.units_newton"))
self.units_combo.setItemText(1, t("toolbar.units_raw"))
self._refresh_source_tooltips()
for index, (value, key) in enumerate(RECORD_MODES):
if index < self.record_mode_combo.count():
self.record_mode_combo.setItemText(index, t(key))