回放并入主窗口,并修掉开场尖峰 / 录制时长 / 录制帧率 / 回放被摄像头覆盖等 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:
@@ -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,
|
||||
|
||||
+235
-292
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user