Files
orisys-app/apps/studio/src/orisys-studio/app_window.py
T
2026-09-12 13:51:05 +08:00

1003 lines
40 KiB
Python

"""Main window shell for the customer app."""
from __future__ import annotations
import logging
import subprocess
import sys
from pathlib import Path
from typing import Any
import numpy as np
from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtGui import QIcon, QImage
from PyQt5.QtWidgets import (
QAction,
QActionGroup,
QApplication,
QFileDialog,
QFrame,
QHBoxLayout,
QLabel,
QMainWindow,
QMessageBox,
QSplitter,
QStatusBar,
QVBoxLayout,
QWidget,
)
from orisys_app_shared.force import select_display_forces
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
from orisys_app_shared.prefs import save_preferences
from orisys_app_shared.theme import build_qss, clear_theme_cache, color
from orisys_customer.ui.about_dialog import AboutDialog
from orisys_customer.ui.arrow_panel import ArrowPanel
from orisys_customer.ui.brand_bar import BrandBar
from orisys_customer.ui.cal_panel import CalPanel
from orisys_customer.ui.force_plots import ForcePlotsPanel
from orisys_customer.ui.mesh3d_panel import Mesh3dPanel
from orisys_customer.ui.plot_settings_dialog import (
DEFAULT_PLOT_FN_MAX,
DEFAULT_PLOT_SHEAR_MAX,
DEFAULT_PLOT_WINDOW_SEC,
PlotSettingsDialog,
)
from orisys_customer.ui.sensor_picker import SensorPickerWidget
from orisys_customer.ui.toolbar import ToolbarWidget
logger = logging.getLogger(__name__)
RUNNABLE_TYPES = frozenset({"box", "mini", "finger"})
ARROW_ROTATE_TYPES = frozenset({"box", "mini"})
DEFAULT_SPLITTER_SIZES = (340, 480, 400)
MIN_PANEL_WIDTHS = {
"mesh3d": 240,
"arrow": 300,
"plots": 340,
"cal": 300,
}
SESSION_PHASES = ("idle", "connecting", "running", "error")
def _bgr_to_qimage(frame: np.ndarray) -> QImage:
rgb = frame[:, :, ::-1].copy()
h, w, ch = rgb.shape
bytes_per_line = ch * w
return QImage(rgb.data, w, h, bytes_per_line, QImage.Format_RGB888).copy()
def _reveal_in_explorer(path: Path) -> None:
path = path.resolve()
if sys.platform == "win32":
subprocess.run(["explorer", "/select,", str(path)], check=False)
return
if sys.platform == "darwin":
subprocess.run(["open", "-R", str(path)], check=False)
return
folder = path.parent if path.is_file() else path
subprocess.run(["xdg-open", str(folder)], check=False)
class CustomerMainWindow(QMainWindow):
def __init__(self, prefs: dict[str, Any] | None = None) -> None:
super().__init__()
self.prefs: dict[str, Any] = dict(prefs or {})
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._show_finger_3d = bool(self.prefs.get("show_finger_3d", True))
self._show_fps = bool(self.prefs.get("show_fps", False))
self._session_phase = "idle"
self._running = False
self._preview = None
self._preview_timer = QTimer(self)
self._preview_timer.setInterval(33)
self._preview_timer.timeout.connect(self._on_preview_tick)
self._run_timer = QTimer(self)
self._run_timer.setInterval(16)
self._run_timer.timeout.connect(self._on_run_tick)
self._box_worker = None
self._finger_worker = None
self._active_worker = None
self._active_sensor_type = "box"
self._last_session_error = ""
self._last_plot_packet_key: tuple[int, float] | None = None
self._last_rendered_view_mode: str | None = None
self._run_source_label = ""
icon = QIcon(str(logo_path()))
if not icon.isNull():
self.setWindowIcon(icon)
central = QWidget()
self.setCentralWidget(central)
root = QVBoxLayout(central)
root.setContentsMargins(10, 10, 10, 10)
root.setSpacing(8)
self.brand_bar = BrandBar(theme=self._theme)
root.addWidget(self.brand_bar)
top = QHBoxLayout()
top.setSpacing(10)
self.toolbar = ToolbarWidget()
self.sensor_picker = SensorPickerWidget()
sensor_card = QFrame()
sensor_card.setObjectName("contentPanel")
sensor_card_layout = QVBoxLayout(sensor_card)
sensor_card_layout.setContentsMargins(0, 0, 0, 0)
sensor_card_layout.setSpacing(0)
sensor_card_layout.addWidget(self.sensor_picker)
top.addWidget(self.toolbar, 2)
top.addWidget(sensor_card)
root.addLayout(top)
self.content_splitter = QSplitter(Qt.Horizontal)
self.mesh3d_panel = Mesh3dPanel()
self.arrow_panel = ArrowPanel()
self.force_plots = ForcePlotsPanel(theme=self._theme)
self.cal_panel = CalPanel(theme=self._theme)
self.content_splitter.addWidget(self.mesh3d_panel)
self.content_splitter.addWidget(self.arrow_panel)
self.content_splitter.addWidget(self.force_plots)
self.content_splitter.addWidget(self.cal_panel)
self.content_splitter.setChildrenCollapsible(False)
root.addWidget(self.content_splitter, 1)
self.setStatusBar(QStatusBar())
self.status_chip = QLabel()
self.status_chip.setObjectName("statusChip")
self.statusBar().addPermanentWidget(self.status_chip)
self.cal_panel.status_message.connect(self.statusBar().showMessage)
self.cal_panel.calibration_exported.connect(self._on_calibration_exported)
self._set_phase("idle")
self._build_menus()
self._wire_toolbar()
self._wire_sensor_picker()
self._restore_prefs_into_ui()
self._apply_panel_visibility()
self._restore_splitter_sizes()
self._update_run_enabled()
self.cal_panel.set_meta_context(
sensor_type=self.sensor_picker.sensor_type(),
config_ref=self._resolve_config_ref(),
)
self.retranslate()
self._restore_geometry()
self._apply_default_size()
@property
def _preview_open(self) -> bool:
return self._preview is not None and bool(self._preview.is_open)
def _get_preview(self):
if self._preview is None:
from orisys_app_shared.camera import RawPreviewSession
self._preview = RawPreviewSession()
return self._preview
def _ensure_workers(self) -> None:
if self._box_worker is not None and self._finger_worker is not None:
return
from orisys_app_shared.acquisition.finger_worker import FingerAcquisitionWorker
from orisys_app_shared.acquisition.sdk_worker import SdkAcquisitionWorker
if self._box_worker is None:
self._box_worker = SdkAcquisitionWorker(
on_error=self._on_worker_error,
on_status=self._on_worker_status,
)
if self._finger_worker is None:
self._finger_worker = FingerAcquisitionWorker(
on_error=self._on_worker_error,
on_status=self._on_worker_status,
)
def _wire_toolbar(self) -> None:
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.run_toggled.connect(self._on_run_toggled)
self.toolbar.reset_clicked.connect(self._on_reset)
self.toolbar.units_changed.connect(self._on_units_changed)
def _wire_sensor_picker(self) -> None:
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)
def _restore_prefs_into_ui(self) -> None:
raw_id = str(self.prefs.get("camera_id") or "0").strip()
try:
cam_id = int(raw_id)
except ValueError:
cam_id = 0
self.toolbar.set_camera_id(cam_id)
self.toolbar.set_source_mode(str(self.prefs.get("source_mode") or "camera"))
self.toolbar.set_video_path(str(self.prefs.get("video_path") or ""))
self.toolbar.set_units(str(self.prefs.get("units") or "newton"))
self.arrow_panel.set_view_mode(str(self.prefs.get("image_view_mode") or "arrows_pure"))
self.arrow_panel.set_fps_visible(self._show_fps)
self.arrow_panel.set_rotation_deg(self.prefs.get("arrow_rotation_deg", 90))
self._apply_plot_prefs_to_panel()
sensor_type = str(self.prefs.get("sensor_type") or "box")
config_name = str(self.prefs.get("config_name") or "")
self.sensor_picker.set_sensor_type(sensor_type)
if config_name:
self.sensor_picker.set_config_name(config_name)
else:
self.prefs["config_name"] = self.sensor_picker.config_name()
self._apply_arrow_rotation_for_sensor()
def _apply_default_size(self) -> None:
"""Give the first launch a usable size for the multi-panel layout."""
default = self.prefs.get("window_default_size") or [1440, 880]
try:
width, height = int(default[0]), int(default[1])
except (TypeError, ValueError, IndexError):
width, height = 1440, 880
screen = QApplication.primaryScreen()
if screen is not None:
avail = screen.availableGeometry()
width = min(width, max(1024, avail.width() - 80))
height = min(height, max(680, avail.height() - 80))
if not self.prefs.get("window_geometry"):
self.resize(width, height)
def _restore_splitter_sizes(self) -> None:
stored = self.prefs.get("splitter_sizes")
if isinstance(stored, list) and len(stored) == self.content_splitter.count():
try:
self.content_splitter.setSizes([int(v) for v in stored])
return
except (TypeError, ValueError):
pass
sizes = list(DEFAULT_SPLITTER_SIZES)
if not self._show_finger_3d or self.sensor_picker.sensor_type() != "finger":
sizes[0] = 0
self.content_splitter.setSizes(sizes)
def _build_menus(self) -> None:
menu_bar = self.menuBar()
self.file_menu = menu_bar.addMenu("")
self.act_open_video = QAction(self)
self.act_open_video.triggered.connect(self._on_browse_video)
self.file_menu.addAction(self.act_open_video)
self.act_reveal_config = QAction(self)
self.act_reveal_config.triggered.connect(self._on_reveal_config)
self.file_menu.addAction(self.act_reveal_config)
self.view_menu = menu_bar.addMenu("")
self.act_cal_panel = QAction(self)
self.act_cal_panel.setCheckable(True)
self.act_cal_panel.setChecked(self._show_cal)
self.act_cal_panel.toggled.connect(self._on_cal_panel_toggled)
self.view_menu.addAction(self.act_cal_panel)
self.act_finger_3d = QAction(self)
self.act_finger_3d.setCheckable(True)
self.act_finger_3d.setChecked(self._show_finger_3d)
self.act_finger_3d.toggled.connect(self._on_finger_3d_toggled)
self.view_menu.addAction(self.act_finger_3d)
self.act_show_fps = QAction(self)
self.act_show_fps.setCheckable(True)
self.act_show_fps.setChecked(self._show_fps)
self.act_show_fps.toggled.connect(self._on_show_fps_toggled)
self.view_menu.addAction(self.act_show_fps)
self.act_plot_settings = QAction(self)
self.act_plot_settings.triggered.connect(self._on_plot_settings)
self.view_menu.addAction(self.act_plot_settings)
self.act_rotate_arrows = QAction(self)
self.act_rotate_arrows.triggered.connect(self._on_rotate_arrows)
self.view_menu.addAction(self.act_rotate_arrows)
self.view_menu.addSeparator()
self.act_run = QAction(self)
self.act_run.setShortcut("F5")
self.act_run.triggered.connect(self._on_run_shortcut)
self.view_menu.addAction(self.act_run)
self.act_preview = QAction(self)
self.act_preview.setShortcut("F6")
self.act_preview.triggered.connect(self._on_preview_shortcut)
self.view_menu.addAction(self.act_preview)
self.act_reset = QAction(self)
self.act_reset.setShortcut("Ctrl+R")
self.act_reset.triggered.connect(self._on_reset)
self.view_menu.addAction(self.act_reset)
self.view_menu.addSeparator()
self.theme_menu = self.view_menu.addMenu("")
self.theme_group = QActionGroup(self)
self.theme_group.setExclusive(True)
self.act_theme_dark = QAction(self)
self.act_theme_dark.setCheckable(True)
self.act_theme_light = QAction(self)
self.act_theme_light.setCheckable(True)
self.theme_group.addAction(self.act_theme_dark)
self.theme_group.addAction(self.act_theme_light)
self.theme_menu.addAction(self.act_theme_dark)
self.theme_menu.addAction(self.act_theme_light)
self.act_theme_dark.triggered.connect(lambda: self._set_theme("dark"))
self.act_theme_light.triggered.connect(lambda: self._set_theme("light"))
self.lang_menu = self.view_menu.addMenu("")
self.lang_group = QActionGroup(self)
self.lang_group.setExclusive(True)
self.act_lang_zh = QAction(self)
self.act_lang_zh.setCheckable(True)
self.act_lang_en = QAction(self)
self.act_lang_en.setCheckable(True)
self.lang_group.addAction(self.act_lang_zh)
self.lang_group.addAction(self.act_lang_en)
self.lang_menu.addAction(self.act_lang_zh)
self.lang_menu.addAction(self.act_lang_en)
self.act_lang_zh.triggered.connect(lambda: self._set_language("zh_CN"))
self.act_lang_en.triggered.connect(lambda: self._set_language("en_US"))
self.help_menu = menu_bar.addMenu("")
self.act_about = QAction(self)
self.act_about.triggered.connect(self._show_about)
self.help_menu.addAction(self.act_about)
self._sync_menu_checks()
def _sync_menu_checks(self) -> None:
self.act_theme_dark.setChecked(self._theme != "light")
self.act_theme_light.setChecked(self._theme == "light")
self.act_lang_zh.setChecked(self._language.startswith("zh"))
self.act_lang_en.setChecked(not self._language.startswith("zh"))
self.act_cal_panel.blockSignals(True)
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)
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._apply_arrow_rotation_for_sensor()
def _apply_panel_visibility(self) -> None:
is_finger = self.sensor_picker.sensor_type() == "finger"
show_3d = is_finger and bool(self._show_finger_3d)
self.mesh3d_panel.setVisible(show_3d)
self.cal_panel.setVisible(self._show_cal)
self.mesh3d_panel.setMinimumWidth(MIN_PANEL_WIDTHS["mesh3d"])
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)
self.act_finger_3d.blockSignals(True)
self.act_finger_3d.setChecked(show_3d)
self.act_finger_3d.blockSignals(False)
for i in range(self.content_splitter.count()):
widget = self.content_splitter.widget(i)
if widget is self.mesh3d_panel:
self.content_splitter.setStretchFactor(i, 2 if show_3d else 0)
elif widget is self.arrow_panel:
self.content_splitter.setStretchFactor(i, 3)
elif widget is self.force_plots:
self.content_splitter.setStretchFactor(i, 4)
elif widget is self.cal_panel:
self.content_splitter.setStretchFactor(i, 3 if self._show_cal else 0)
def _update_run_enabled(self) -> None:
sensor_ok = self.sensor_picker.sensor_type() in RUNNABLE_TYPES
source_ok = True
reason = ""
if not sensor_ok:
reason = t("toolbar.run_disabled_sensor")
elif self.toolbar.source_mode() == "video" and not self.toolbar.video_path():
source_ok = False
reason = t("toolbar.run_disabled_video")
self.toolbar.set_run_tooltip_reason(reason)
self.toolbar.set_run_enabled(self._running or (sensor_ok and source_ok))
if not self._running:
self.toolbar.refresh_labels()
self._sync_shortcut_actions()
def retranslate(self) -> None:
self.setWindowTitle(t("app.title"))
self.file_menu.setTitle(t("menu.file"))
self.act_open_video.setText(t("menu.open_video"))
self.act_reveal_config.setText(t("menu.reveal_config"))
self.view_menu.setTitle(t("menu.view"))
self.act_cal_panel.setText(t("menu.calibration_panel"))
self.act_finger_3d.setText(t("menu.finger_3d"))
self.act_show_fps.setText(t("menu.show_fps"))
self.act_plot_settings.setText(t("menu.plot_settings"))
self.act_rotate_arrows.setText(t("menu.rotate_arrows"))
self.act_run.setText(t("menu.run"))
self.act_preview.setText(t("menu.preview"))
self.act_reset.setText(t("menu.reset"))
self.theme_menu.setTitle(t("menu.theme"))
self.act_theme_dark.setText(t("menu.theme_dark"))
self.act_theme_light.setText(t("menu.theme_light"))
self.lang_menu.setTitle(t("menu.language"))
self.act_lang_zh.setText(t("menu.lang_zh"))
self.act_lang_en.setText(t("menu.lang_en"))
self.help_menu.setTitle(t("menu.help"))
self.act_about.setText(t("menu.about"))
self.toolbar.retranslate()
self.sensor_picker.retranslate()
self.mesh3d_panel.retranslate()
self.arrow_panel.retranslate()
self.force_plots.retranslate()
self.cal_panel.retranslate()
self.brand_bar.retranslate()
self._refresh_status_chip()
if not self._preview_open and not self._running:
self.statusBar().showMessage(t("status.ready"))
self._update_run_enabled()
def _set_phase(self, phase: str) -> None:
"""Track the acquisition phase and reflect it in the status chip."""
self._session_phase = phase if phase in SESSION_PHASES else "idle"
self._refresh_status_chip()
def _refresh_status_chip(self) -> None:
chip = getattr(self, "status_chip", None)
if chip is None:
return
phase = self._session_phase if self._session_phase in SESSION_PHASES else "idle"
token = f"status_{phase}"
chip.setText(t(f"status.phase_{phase}"))
chip.setStyleSheet(
f"QLabel#statusChip {{ color: {color(self._theme, token)};"
" background: transparent; font-weight: 600; padding: 0 10px 0 4px; }"
)
def _set_theme(self, theme: str) -> None:
self._theme = theme
self.prefs["theme"] = theme
clear_theme_cache()
app = QApplication.instance()
if app is not None:
app.setStyleSheet(build_qss(theme))
self.force_plots.set_theme(theme)
self.cal_panel.set_theme(theme)
self.brand_bar.set_theme(theme)
self._refresh_status_chip()
self._sync_menu_checks()
self._persist_prefs()
def _set_language(self, language: str) -> None:
self._language = language
self.prefs["language"] = language
set_locale(language)
self.retranslate()
self._sync_menu_checks()
self._persist_prefs()
def _show_about(self) -> None:
AboutDialog(self).exec_()
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
self._persist_prefs()
self._update_run_enabled()
def _on_units_changed(self, units: str) -> None:
self.prefs["units"] = units
self.force_plots.set_fixed_newton(str(units) == "newton")
self._persist_prefs()
def _on_image_view_mode_changed(self, mode: str) -> None:
self.prefs["image_view_mode"] = str(mode)
self._last_rendered_view_mode = None
self._persist_prefs()
if self._running:
if self._active_worker is not None:
self._render_run_image(self._active_worker.latest())
def _on_plot_settings(self) -> None:
dialog = PlotSettingsDialog(
self,
window_sec=float(self.prefs.get("plot_window_sec") or DEFAULT_PLOT_WINDOW_SEC),
fn_max=float(self.prefs.get("plot_fn_max") or DEFAULT_PLOT_FN_MAX),
shear_max=float(self.prefs.get("plot_shear_max") or DEFAULT_PLOT_SHEAR_MAX),
units=self.toolbar.units(),
)
if dialog.exec_() != PlotSettingsDialog.Accepted:
return
window_sec, fn_max, shear_max = dialog.values()
self.prefs["plot_window_sec"] = window_sec
self.prefs["plot_fn_max"] = fn_max
self.prefs["plot_shear_max"] = shear_max
self.force_plots.set_window_sec(window_sec)
self.force_plots.set_axis_limits(fn_max, shear_max)
self._last_plot_packet_key = None
self._persist_prefs()
def _apply_plot_prefs_to_panel(self) -> None:
self.force_plots.set_window_sec(
float(self.prefs.get("plot_window_sec") or DEFAULT_PLOT_WINDOW_SEC)
)
self.force_plots.set_axis_limits(
float(self.prefs.get("plot_fn_max") or DEFAULT_PLOT_FN_MAX),
float(self.prefs.get("plot_shear_max") or DEFAULT_PLOT_SHEAR_MAX),
)
self.force_plots.set_fixed_newton(self.toolbar.units() == "newton")
def _on_cal_panel_toggled(self, visible: bool) -> None:
self._show_cal = bool(visible)
self.prefs["show_cal_panel"] = self._show_cal
self._apply_panel_visibility()
self._persist_prefs()
def _on_calibration_exported(self, path: str) -> None:
"""Apply newly written force_calibration.json to the running Sensor."""
self._ensure_workers()
for worker in (self._box_worker, self._finger_worker):
if worker is not None and hasattr(worker, "set_pending_calibration"):
worker.set_pending_calibration(path)
def _on_finger_3d_toggled(self, visible: bool) -> None:
if self.sensor_picker.sensor_type() != "finger":
return
self._show_finger_3d = bool(visible)
self.prefs["show_finger_3d"] = self._show_finger_3d
self._apply_panel_visibility()
self._persist_prefs()
def _on_show_fps_toggled(self, visible: bool) -> None:
self._show_fps = bool(visible)
self.prefs["show_fps"] = self._show_fps
self.arrow_panel.set_fps_visible(self._show_fps)
if not self._show_fps:
self.arrow_panel.set_fps(None)
self._persist_prefs()
def _on_sensor_type_changed(self, sensor_type: str) -> None:
self.prefs["sensor_type"] = str(sensor_type)
self.prefs["config_name"] = self.sensor_picker.config_name()
self.cal_panel.set_meta_context(
sensor_type=str(sensor_type),
config_ref=self._resolve_config_ref(),
)
self._apply_panel_visibility()
self._apply_arrow_rotation_for_sensor()
self._update_run_enabled()
self._persist_prefs()
def _apply_arrow_rotation_for_sensor(self) -> None:
enabled = self.sensor_picker.sensor_type() in ARROW_ROTATE_TYPES
self.arrow_panel.set_rotation_enabled(enabled)
self.act_rotate_arrows.setEnabled(enabled)
def _on_rotate_arrows(self) -> None:
if self.sensor_picker.sensor_type() not in ARROW_ROTATE_TYPES:
return
deg = self.arrow_panel.rotate_90_ccw()
self.prefs["arrow_rotation_deg"] = deg
self._persist_prefs()
# ----------------------------------------------------------- shortcuts
def _sync_shortcut_actions(self) -> None:
if getattr(self, "act_run", None) is None:
return
self.act_run.setEnabled(self.toolbar.btn_run.isEnabled())
self.act_preview.setEnabled(self.toolbar.btn_preview.isEnabled())
self.act_reset.setEnabled(self.toolbar.btn_reset.isEnabled())
def _on_run_shortcut(self) -> None:
if not self.toolbar.btn_run.isEnabled():
return
self.toolbar.btn_run.setChecked(not self.toolbar.btn_run.isChecked())
def _on_preview_shortcut(self) -> None:
if not self.toolbar.btn_preview.isEnabled():
return
self.toolbar.btn_preview.setChecked(not self.toolbar.btn_preview.isChecked())
def _on_config_changed(self, config_name: str) -> None:
self.prefs["config_name"] = str(config_name)
self._persist_prefs()
def _on_browse_video(self) -> None:
if self._running or self._preview_open:
return
start = self.toolbar.video_path() or str(Path.home())
path, _ = QFileDialog.getOpenFileName(
self,
t("menu.open_video"),
start,
"Video (*.mp4 *.avi *.mov *.mkv);;All (*)",
)
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()
def _on_reveal_config(self) -> None:
sensor_type = self.sensor_picker.sensor_type()
path = sensor_json_path(sensor_type)
if path is None:
cfg = self.sensor_picker.config_name()
candidate = Path(cfg)
path = candidate if candidate.is_file() else None
if path is None:
self.statusBar().showMessage(t("status.config_missing", type=sensor_type))
return
try:
_reveal_in_explorer(path)
except Exception as exc:
self.statusBar().showMessage(t("status.reveal_failed", error=str(exc)))
def _resolve_config_ref(self) -> str | None:
sensor_type = self.sensor_picker.sensor_type()
local = sensor_json_path(sensor_type)
if local is not None:
return str(local)
cfg = self.sensor_picker.config_name()
if cfg and Path(cfg).is_file():
return cfg
if sensor_type in RUNNABLE_TYPES:
return sensor_type
return None
def _on_search_cameras(self) -> None:
if self._preview_open or self._running:
return
from orisys_app_shared.camera import list_cameras, pick_default_camera
preferred = str(self.toolbar.camera_id())
self.statusBar().showMessage(t("toolbar.search_cameras") + "…")
QApplication.processEvents()
cameras = list_cameras()
if not cameras:
self.statusBar().showMessage(t("status.camera_none"))
return
names = ", ".join(cam.label() for cam in cameras)
self.statusBar().showMessage(
t("status.camera_found", count=len(cameras), names=names)
)
selected = pick_default_camera(cameras, preferred)
if selected is not None:
self.toolbar.set_camera_id(int(selected))
self.prefs["camera_id"] = str(selected)
self._persist_prefs()
def _on_preview_toggled(self, checked: bool) -> None:
if checked:
self._start_preview()
else:
self._stop_preview(announce=True)
def _start_preview(self) -> None:
if self._running or self.toolbar.source_mode() != "camera":
self.toolbar.set_preview_checked(False)
return
camera_id = self.toolbar.camera_id()
try:
self._get_preview().open(camera_id)
except Exception as exc:
msg = t("status.preview_failed", error=str(exc))
self.statusBar().showMessage(msg)
self.toolbar.set_preview_checked(False)
self.arrow_panel.clear_preview(msg)
return
self.toolbar.set_source_controls_enabled(False)
self.toolbar.btn_preview.setEnabled(True)
self._update_run_enabled()
self._preview_timer.start()
self.statusBar().showMessage(t("status.preview_started", camera_id=camera_id))
def _stop_preview(self, *, announce: bool) -> None:
self._preview_timer.stop()
if self._preview is not None:
self._preview.close()
if not self._running:
self.toolbar.set_source_controls_enabled(True)
self.arrow_panel.clear_preview(t("panels.arrow_empty"))
self.toolbar.set_preview_checked(False)
self._update_run_enabled()
if announce:
self.statusBar().showMessage(t("status.preview_stopped"))
def _on_preview_tick(self) -> None:
if self._preview is None:
return
frame = self._preview.read()
if frame is None:
self._stop_preview(announce=True)
self.statusBar().showMessage(t("status.preview_failed", error="no frame"))
return
self.arrow_panel.show_frame(_bgr_to_qimage(frame))
def _on_run_toggled(self, checked: bool) -> None:
if checked:
self._start_run()
else:
self._stop_run(reason="user_stop")
def _start_run(self) -> None:
from orisys_app_shared.acquisition.sdk_worker import orisys_importable
sensor_type = self.sensor_picker.sensor_type()
if sensor_type not in RUNNABLE_TYPES:
self.toolbar.set_run_checked(False)
self.statusBar().showMessage(t("status.finger_run_unavailable"))
return
ok, detail = orisys_importable()
if not ok:
self.toolbar.set_run_checked(False)
QMessageBox.warning(self, t("app.title"), detail)
return
config_ref = self._resolve_config_ref()
if not config_ref:
self.toolbar.set_run_checked(False)
self.statusBar().showMessage(
t("status.config_missing", type=sensor_type)
)
return
source = self.toolbar.active_source()
if source is None:
self.toolbar.set_run_checked(False)
self.statusBar().showMessage(t("status.video_required"))
return
if self._preview_open:
self._stop_preview(announce=False)
self.force_plots.reset()
self._last_plot_packet_key = None
self._last_rendered_view_mode = None
self._last_session_error = ""
self._run_source_label = str(source)
self._active_sensor_type = sensor_type
self._ensure_workers()
self._active_worker = (
self._finger_worker if sensor_type == "finger" else self._box_worker
)
try:
self._active_worker.start(source, config_ref=config_ref)
except Exception as exc:
report_error(exc, message="Run failed")
self.toolbar.set_run_checked(False)
self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
return
self._running = True
self._set_phase("connecting")
self.cal_panel.set_meta_context(sensor_type=sensor_type, config_ref=config_ref)
self.cal_panel.set_running(True)
self.toolbar.set_source_controls_enabled(False)
self.sensor_picker.set_picker_enabled(False)
self.toolbar.set_reset_enabled(False)
self._run_timer.start()
self.statusBar().showMessage(t("status.connecting"))
logger.info(
"Connecting type=%s source=%s config=%s",
sensor_type,
source,
config_ref,
)
def _stop_run(self, *, reason: str = "user_stop") -> None:
self._run_timer.stop()
if self._active_worker is not None:
self._active_worker.stop()
self._running = False
self._set_phase("error" if reason == "failed" else "idle")
self.cal_panel.set_running(False)
self.toolbar.set_run_checked(False)
self.toolbar.set_reset_enabled(False)
self.toolbar.set_source_controls_enabled(True)
self.sensor_picker.set_picker_enabled(True)
self._update_run_enabled()
keep_frame = reason in ("ended", "disconnected")
if not keep_frame:
self.arrow_panel.clear_preview(t("panels.arrow_empty"))
else:
self.arrow_panel.set_fps(None)
if reason == "ended":
self.statusBar().showMessage(t("status.video_ended"))
elif reason == "disconnected":
self.statusBar().showMessage(t("status.camera_disconnected"))
elif reason == "failed":
pass # caller sets status
elif reason == "user_stop":
self.statusBar().showMessage(t("status.run_stopped"))
else:
self.statusBar().showMessage(t("status.run_stopped"))
def _on_reset(self) -> None:
if self._session_phase != "running" or self._active_worker is None:
return
try:
self._active_worker.reset()
self.force_plots.reset()
self._last_plot_packet_key = None
self.statusBar().showMessage(t("status.reset_ok"))
except Exception as exc:
self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
def _on_worker_error(self, message: str) -> None:
self._last_session_error = message
logger.error("Acquisition error: %s", message)
def _on_worker_status(self, message: str) -> None:
logger.info("%s", message)
def _on_run_tick(self) -> None:
if self._active_worker is None:
return
if self._last_session_error:
err = self._last_session_error
self._last_session_error = ""
self._active_worker.consume_end_reason()
self._stop_run(reason="failed")
self.statusBar().showMessage(t("status.run_failed", error=err))
return
end_reason = self._active_worker.consume_end_reason()
if end_reason in ("ended", "disconnected"):
self._stop_run(reason=end_reason)
return
if end_reason == "error":
self._stop_run(reason="failed")
self.statusBar().showMessage(t("status.run_failed", error="acquisition error"))
return
if not self._active_worker.is_running and self._running:
leftover = self._active_worker.consume_end_reason()
if leftover in ("ended", "disconnected"):
self._stop_run(reason=leftover)
elif leftover == "error":
self._stop_run(reason="failed")
self.statusBar().showMessage(
t("status.run_failed", error="acquisition error")
)
else:
self._stop_run(reason="user_stop")
return
packet = self._active_worker.latest()
if packet is None:
return
packet_key = (int(packet.session_id), float(packet.timestamp))
is_new = packet_key != self._last_plot_packet_key
if self._session_phase == "connecting" and is_new:
self._set_phase("running")
self.toolbar.set_reset_enabled(True)
self._sync_shortcut_actions()
self.statusBar().showMessage(
t("status.run_started", source=self._run_source_label)
)
if is_new:
self._last_plot_packet_key = packet_key
display = select_display_forces(
units_pref=self.toolbar.units(),
force_calibrated=packet.force_calibrated,
fn=packet.normal_force,
fx=packet.shear_x,
fy=packet.shear_y,
fn_raw=packet.normal_force_raw,
fx_raw=packet.shear_x_raw,
fy_raw=packet.shear_y_raw,
)
if display.using_raw_fallback:
self.statusBar().showMessage(t("status.units_raw_fallback"))
self.force_plots.push(
packet.timestamp,
display.fn,
display.fx,
display.fy,
unit=display.unit,
)
if self._show_cal:
self.cal_panel.push_raw(
packet.normal_force_raw,
packet.shear_x_raw,
packet.shear_y_raw,
packet.timestamp,
)
mode = self.arrow_panel.view_mode()
if is_new or mode != self._last_rendered_view_mode:
self._render_run_image(packet)
if self._show_fps and is_new:
self.arrow_panel.set_fps(packet.fps)
def _render_run_image(self, packet) -> None:
if packet is None:
return
from orisys_app_shared.acquisition.arrows import (
depth_map_to_bgr,
draw_arrow_display,
flow_colormap,
hex_to_bgr,
)
mode = self.arrow_panel.view_mode()
self._last_rendered_view_mode = mode
draw_style = "finger" if self._active_sensor_type == "finger" else "box"
colormap = flow_colormap(self._theme)
if mode == "depth":
depth_bgr = depth_map_to_bgr(packet.depth)
frame = depth_bgr if depth_bgr is not None else packet.processed_frame
elif mode == "arrows_stitched":
frame = draw_arrow_display(
packet.processed_frame,
packet.flow,
background="captured",
draw_style=draw_style,
valid_mask=packet.valid_mask,
colormap=colormap,
)
else:
canvas_bgr = hex_to_bgr(color(self._theme, "arrow_canvas", "#0B1830"))
frame = draw_arrow_display(
packet.processed_frame,
packet.flow,
background="pure_color",
canvas_bgr=canvas_bgr,
draw_style=draw_style,
valid_mask=packet.valid_mask,
colormap=colormap,
)
self.arrow_panel.show_frame(_bgr_to_qimage(frame))
def _restore_geometry(self) -> None:
geo = self.prefs.get("window_geometry")
if isinstance(geo, str) and geo:
try:
from PyQt5.QtCore import QByteArray
self.restoreGeometry(QByteArray.fromHex(geo.encode("ascii")))
except Exception:
logger.debug("Could not restore window geometry", exc_info=True)
def _persist_prefs(self) -> None:
geo = self.saveGeometry().toHex().data().decode("ascii")
self.prefs["window_geometry"] = geo
self.prefs["splitter_sizes"] = [int(v) for v in self.content_splitter.sizes()]
self.prefs["theme"] = self._theme
self.prefs["language"] = self._language
self.prefs["camera_id"] = str(self.toolbar.camera_id())
self.prefs["source_mode"] = self.toolbar.source_mode()
self.prefs["video_path"] = self.toolbar.video_path()
self.prefs["units"] = self.toolbar.units()
self.prefs["image_view_mode"] = self.arrow_panel.view_mode()
self.prefs["arrow_rotation_deg"] = self.arrow_panel.rotation_deg()
self.prefs.setdefault("plot_window_sec", DEFAULT_PLOT_WINDOW_SEC)
self.prefs.setdefault("plot_fn_max", DEFAULT_PLOT_FN_MAX)
self.prefs.setdefault("plot_shear_max", DEFAULT_PLOT_SHEAR_MAX)
self.prefs["show_cal_panel"] = bool(self._show_cal)
self.prefs["show_finger_3d"] = bool(self._show_finger_3d)
self.prefs["show_fps"] = bool(self._show_fps)
self.prefs["sensor_type"] = self.sensor_picker.sensor_type()
self.prefs["config_name"] = self.sensor_picker.config_name()
save_preferences(self.prefs)
def closeEvent(self, event) -> None: # noqa: N802
self._stop_preview(announce=False)
self._stop_run(reason="user_stop")
self._persist_prefs()
logger.info("Customer shell closed; preferences saved")
super().closeEvent(event)