initial commit, functinal software

This commit is contained in:
ykcheungab
2026-09-06 21:57:50 +08:00
commit e2d5e69229
78 changed files with 5407 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

+115
View File
@@ -0,0 +1,115 @@
{
"app": {
"title": "Orisys App",
"about_title": "About",
"about_body": "Orisys customer desktop application\nVersion: {app_version}",
"scaffold_hint": "Scaffold shell — acquisition coming later"
},
"menu": {
"file": "File",
"open_video": "Open video…",
"reveal_config": "Reveal sensor config…",
"view": "View",
"theme": "Theme",
"theme_dark": "Dark",
"theme_light": "Light",
"language": "Language",
"lang_zh": "简体中文",
"lang_en": "English",
"calibration_panel": "Calibration panel",
"finger_3d": "Finger 3D view",
"show_fps": "Show FPS",
"plot_settings": "Plot settings…",
"help": "Help",
"about": "About"
},
"dialog": {
"plot_settings_title": "Plot settings",
"plot_window": "Time window",
"plot_window_tip": "Force plot time window (seconds)",
"plot_fn_max": "FN max",
"plot_fn_max_tip": "Normal force axis max (Newton mode)",
"plot_shear_max": "Shear max",
"plot_shear_max_tip": "Shear force axis ±max for FX/FY (Newton mode)",
"reset_defaults": "Reset to defaults",
"ok": "OK",
"cancel": "Cancel"
},
"toolbar": {
"source": "Source",
"source_camera": "Camera",
"source_video": "Video file",
"search_cameras": "Search",
"cam_id": "Cam ID",
"browse_video": "Browse…",
"video_placeholder": "No video selected",
"preview": "Preview",
"preview_stop": "Stop preview",
"run": "Run",
"stop": "Stop",
"reset": "Reset",
"units": "Units",
"units_newton": "Newton",
"units_raw": "Raw"
},
"sensor": {
"type": "Sensor type",
"config": "Config",
"thumb_placeholder": "Thumbnail"
},
"panels": {
"arrow": "2D arrows",
"view_arrows_pure": "Arrows",
"view_arrows_stitched": "Arrows / Captured",
"view_depth": "Depth map",
"view_mode_tip": "Image display mode while running",
"plots": "Force Charts",
"cal": "Calibration",
"mesh3d": "3D mesh",
"mesh3d_hint": "Finger 3D view placeholder — Open3D mesh coming later"
},
"cal": {
"weight": "Weight (N)",
"duration": "Record duration",
"record": "Record",
"delete_row": "Delete row",
"clear_channel": "Clear channel",
"clear_all": "Clear all",
"export": "Export…",
"sn": "SN",
"sn_placeholder": "Serial number (optional)",
"live_raw": "Live raw FN={fn} FX={fx} FY={fy}",
"col_weight": "Weight (N)",
"col_raw": "Raw mean",
"col_n": "n",
"col_std": "σ",
"col_actions": "",
"recording": "Recording calibration samples…",
"record_ok": "Recorded weight={weight} raw={raw}",
"record_failed_few": "Recording failed — too few samples",
"export_ok": "Exported {path}",
"export_local": "Also wrote {path}",
"export_warnings": "Skipped: {detail}",
"export_failed": "Export failed: {error}"
},
"status": {
"ready": "Ready",
"camera_none": "No cameras found",
"camera_found": "Found {count} camera(s): {names}",
"preview_started": "Preview started (camera {camera_id})",
"preview_stopped": "Preview stopped",
"preview_failed": "Preview failed: {error}",
"connecting": "Connecting…",
"run_started": "Running ({source})",
"run_stopped": "Stopped",
"run_failed": "Run failed: {error}",
"video_ended": "Video ended",
"camera_disconnected": "Camera disconnected",
"reset_ok": "Tracker reset",
"finger_run_unavailable": "Run is not available for this sensor type",
"video_required": "Select a video file first",
"config_missing": "Sensor config not found for {type}",
"units_raw_fallback": "Calibration unavailable — showing raw units",
"reveal_failed": "Could not reveal config: {error}"
}
}
+115
View File
@@ -0,0 +1,115 @@
{
"app": {
"title": "Orisys 应用",
"about_title": "关于",
"about_body": "Orisys 客户端桌面应用\n版本: {app_version}",
"scaffold_hint": "脚手架界面 — 采集功能稍后加入"
},
"menu": {
"file": "文件",
"open_video": "打开视频…",
"reveal_config": "在资源管理器中显示配置…",
"view": "视图",
"theme": "主题",
"theme_dark": "深色",
"theme_light": "浅色",
"language": "语言",
"lang_zh": "简体中文",
"lang_en": "English",
"calibration_panel": "标定面板",
"finger_3d": "手指 3D 视图",
"show_fps": "显示 FPS",
"plot_settings": "绘图设置…",
"help": "帮助",
"about": "关于"
},
"dialog": {
"plot_settings_title": "绘图设置",
"plot_window": "时间窗口",
"plot_window_tip": "力曲线时间窗口(秒)",
"plot_fn_max": "FN 上限",
"plot_fn_max_tip": "法向力纵轴上限(牛顿模式)",
"plot_shear_max": "剪切上限",
"plot_shear_max_tip": "剪切力 FX/FY 纵轴 ±上限(牛顿模式)",
"reset_defaults": "恢复默认",
"ok": "确定",
"cancel": "取消"
},
"toolbar": {
"source": "来源",
"source_camera": "摄像机",
"source_video": "视频文件",
"search_cameras": "搜索",
"cam_id": "摄像机",
"browse_video": "浏览…",
"video_placeholder": "未选择视频",
"preview": "预览",
"preview_stop": "停止预览",
"run": "运行",
"stop": "停止",
"reset": "重置",
"units": "单位",
"units_newton": "牛顿",
"units_raw": "原始"
},
"sensor": {
"type": "传感器类型",
"config": "配置",
"thumb_placeholder": "缩略图"
},
"panels": {
"arrow": "形变箭头",
"view_arrows_pure": "箭头",
"view_arrows_stitched": "箭头/采集图像",
"view_depth": "深度图",
"view_mode_tip": "运行时的图像显示模式",
"plots": "力曲线图",
"cal": "标定",
"mesh3d": "三维网格",
"mesh3d_hint": "手指三维视图占位 — Open3D 网格稍后加入"
},
"cal": {
"weight": "砝码重量 (N)",
"duration": "采集时长",
"record": "记录",
"delete_row": "删除行",
"clear_channel": "清空当前通道",
"clear_all": "清空全部",
"export": "导出…",
"sn": "序列号",
"sn_placeholder": "序列号(可选)",
"live_raw": "实时原始值 FN={fn} FX={fx} FY={fy}",
"col_weight": "重量 (N)",
"col_raw": "原始均值",
"col_n": "n",
"col_std": "σ",
"col_actions": "",
"recording": "正在采集标定样本…",
"record_ok": "已记录 weight={weight} raw={raw}",
"record_failed_few": "采集失败 — 样本过少",
"export_ok": "已导出 {path}",
"export_local": "同时写入 {path}",
"export_warnings": "跳过: {detail}",
"export_failed": "导出失败: {error}"
},
"status": {
"ready": "就绪",
"camera_none": "未发现摄像机",
"camera_found": "发现 {count} 个摄像机: {names}",
"preview_started": "预览已启动(摄像机 {camera_id})",
"preview_stopped": "预览已停止",
"preview_failed": "预览失败: {error}",
"connecting": "正在连接…",
"run_started": "运行中({source})",
"run_stopped": "已停止",
"run_failed": "启动失败: {error}",
"video_ended": "视频已结束",
"camera_disconnected": "摄像机已断开",
"reset_ok": "追踪器已重置",
"finger_run_unavailable": "此传感器类型暂不支持运行",
"video_required": "请先选择视频文件",
"config_missing": "未找到传感器配置: {type}",
"units_raw_fallback": "无标定数据 — 显示原始单位",
"reveal_failed": "无法打开配置位置: {error}"
}
}
@@ -0,0 +1,17 @@
{
"language": "zh_CN",
"theme": "dark",
"window_geometry": null,
"camera_id": "0",
"source_mode": "camera",
"video_path": "",
"show_cal_panel": false,
"show_finger_3d": true,
"sensor_type": "box",
"config_name": "",
"units": "newton",
"plot_window_sec": 4.0,
"plot_fn_max": 20.0,
"plot_shear_max": 20.0,
"image_view_mode": "arrows_pure"
}
@@ -0,0 +1,113 @@
{
"schema_version": 2,
"unit": "N",
"channels": {
"normal": {
"method": "piecewise_linear",
"raw_key": "fnormal",
"extrapolate": true,
"clamp_output_min": 0.0,
"points": [
[
3167.3289065950753,
0.0
],
[
3617.3242262492,
1.0
]
]
},
"shear_x": {
"method": "piecewise_linear",
"raw_key": "fshearx",
"extrapolate": true,
"clamp_output_min": 0.0,
"points": [
[
1471.613952418736,
0.0
],
[
1743.8539805266817,
1.0
]
],
"signed": true
},
"shear_y": {
"method": "piecewise_linear",
"raw_key": "fsheary",
"extrapolate": true,
"clamp_output_min": 0.0,
"points": [
[
3845.5798720537605,
1.0
],
[
3881.793802339737,
0.0
]
],
"signed": true
}
},
"meta": {
"date": "2026-09-06T21:54:09+08:00",
"sensor_type": "box",
"sn": "a",
"stitch_resolution": [
365,
365
],
"dis_mode": 3,
"hhd_downsample": 3,
"record_duration_s": 7.0,
"config_ref": "D:\\repo\\orisys-app\\apps\\customer\\config\\sensors\\box\\sensor.json",
"samples": {
"fn": [
{
"weight_n": 0.0,
"raw_mean": 3167.3289065950753,
"n": 283,
"std": 283.516893458341
},
{
"weight_n": 1.0,
"raw_mean": 3617.3242262492,
"n": 293,
"std": 271.21386400281835
}
],
"fx": [
{
"weight_n": 0.0,
"raw_mean": -1471.613952418736,
"n": 280,
"std": 311.9173147283949
},
{
"weight_n": 1.0,
"raw_mean": -1743.8539805266817,
"n": 295,
"std": 324.2175747527586
}
],
"fy": [
{
"weight_n": 0.0,
"raw_mean": 3881.793802339737,
"n": 292,
"std": 362.16000171157486
},
{
"weight_n": 1.0,
"raw_mean": 3845.5798720537605,
"n": 295,
"std": 390.42421990805127
}
]
}
}
}
@@ -0,0 +1,46 @@
{
"name": "prototype",
"version": "1.0",
"camera": {
"resolution_raw": [1920, 1080],
"fps": 30,
"units": 15,
"units_used": 9,
"resolution_stitch": [365, 365],
"stitch_res_list": [365,263,123,63]
},
"stitcher": {
"min_area_frac": 0.01,
"max_area_frac": 0.05,
"max_aspect_ratio": 50,
"index": [1,2,3,6,7,8,11,12,13],
"margin": 10,
"nominal_ov_pos": 100,
"n": [3,3],
"row_tol": 0.6,
"box_size": [250, 250],
"blend":"numba",
"blend_list": ["numpy", "numba","none"]
},
"tracker": {
"adaptive": true,
"lower_threshold": 12.0,
"upper_threshold": 18.0,
"loop_closure_interval": 1,
"algorithm": "dis",
"dis_preset": "medium",
"dis_mode": 3,
"algorithm_list": ["raft", "dis"],
"hhd_downsample": 3
},
"contact": {
"min_contour_area": 3000,
"flow_threshold":5,
"div_threshold":0.05,
"expansion_threshold": 0.01,
"kernel_size": [5,5],
"denoise_sigma": 12
}
}
@@ -0,0 +1,9 @@
Place the ORY-FINGER runtime asset bundle files in this directory before
building release wheels:
- `mesh.npz`
- `camera_intrinsics.npz`
- `world_to_cam.npy`
These binary assets are validated by the wheel build and are intentionally not
generated by the package build step.
@@ -0,0 +1,6 @@
Place visualization camera assets in this directory before building release
wheels:
- `world_to_cam.npy`
The 3D dashboard uses this pose as the default view preset when available.
@@ -0,0 +1,6 @@
{
"name": "finger",
"sensor_json": "sensor.json",
"assets_dir": "assets/30fps-0703",
"viz_pose": "assets/visualization/world_to_cam.npy"
}
@@ -0,0 +1,35 @@
{
"name": "dd02-ov",
"type": "single_camera",
"version": "1.0",
"camera": {
"resolution_raw": [1280, 720],
"fps": 30,
"resolution_stitch": [280, 300],
"source_scale": 0.4,
"old":[672, 728, 1,0.5],
"work": [504, 546,1],
"work2": [420, 450,0.8],
"work3": [350, 375,4,0.7],
"work4": [280, 300,4,0.4,0.2]
},
"tracker": {
"adaptive": true,
"lower_threshold": 12.0,
"upper_threshold": 18.0,
"loop_closure_interval": 1,
"algorithm": "dis",
"dis_preset": "medium",
"dis_mode": 1,
"algorithm_list": ["raft", "dis"],
"hhd_downsample": 3
},
"contact": {
"min_contour_area": 3000,
"flow_threshold": 0.2,
"div_threshold": 0.022,
"expansion_threshold": 0.01,
"kernel_size": [5, 5],
"denoise_sigma": 12
}
}
@@ -0,0 +1,46 @@
{
"name": "prototype",
"version": "1.0",
"camera": {
"resolution_raw": [1920, 1080],
"fps": 30,
"units": 15,
"units_used": 9,
"resolution_stitch": [365, 365],
"stitch_res_list": [365,263,123,63]
},
"stitcher": {
"min_area_frac": 0.01,
"max_area_frac": 0.05,
"max_aspect_ratio": 50,
"index": [1,2,3,6,7,8,11,12,13],
"margin": 10,
"nominal_ov_pos": 100,
"n": [3,3],
"row_tol": 0.6,
"box_size": [250, 250],
"blend":"numba",
"blend_list": ["numpy", "numba","none"]
},
"tracker": {
"adaptive": true,
"lower_threshold": 12.0,
"upper_threshold": 18.0,
"loop_closure_interval": 1,
"algorithm": "dis",
"dis_preset": "medium",
"dis_mode": 3,
"algorithm_list": ["raft", "dis"],
"hhd_downsample": 3
},
"contact": {
"min_contour_area": 3000,
"flow_threshold":5,
"div_threshold":0.05,
"expansion_threshold": 0.01,
"kernel_size": [5,5],
"denoise_sigma": 12
}
}
+26
View File
@@ -0,0 +1,26 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "orisys-customer"
version = "0.1.0"
description = "Orisys customer desktop app"
requires-python = ">=3.10"
authors = [{ name = "Orisys-Tech" }]
dependencies = [
"PyQt5>=5.15",
"orisys-app-shared>=0.1.0",
"orisys>=0.5",
"scipy>=1.10.1",
"pyqtgraph>=0.13",
]
[project.scripts]
orisys-customer = "orisys_customer.main:main"
[tool.setuptools]
package-dir = { "" = "src" }
[tool.setuptools.packages.find]
where = ["src"]
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env python3
"""Launch the Orisys customer app shell."""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
SRC = ROOT / "src"
SHARED_SRC = ROOT.parents[1] / "shared" / "src"
for path in (SRC, SHARED_SRC):
if path.is_dir() and str(path) not in sys.path:
sys.path.insert(0, str(path))
# Ensure path helpers resolve to this app root when launched via run.py
import os
os.chdir(ROOT)
from orisys_customer.main import main
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,4 @@
"""Orisys customer desktop application."""
__version__ = "0.1.0"
REQUIRES_SHARED_API = "0.1"
@@ -0,0 +1,842 @@
"""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,
QHBoxLayout,
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, color
from orisys_customer.ui.about_dialog import AboutDialog
from orisys_customer.ui.arrow_panel import ArrowPanel
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"})
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)
top = QHBoxLayout()
self.toolbar = ToolbarWidget()
self.sensor_picker = SensorPickerWidget()
top.addWidget(self.toolbar, 2)
top.addWidget(self.sensor_picker, 1)
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()
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)
root.addWidget(self.content_splitter, 1)
self.setStatusBar(QStatusBar())
self.cal_panel.status_message.connect(self.statusBar().showMessage)
self._build_menus()
self._wire_toolbar()
self._wire_sensor_picker()
self._restore_prefs_into_ui()
self._apply_panel_visibility()
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.resize(1280, 720)
@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._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()
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.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)
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.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, 3 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, 3)
elif widget is self.cal_panel:
self.content_splitter.setStretchFactor(i, 2 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
if self.toolbar.source_mode() == "video" and not self.toolbar.video_path():
source_ok = False
self.toolbar.set_run_enabled(self._running or (sensor_ok and source_ok))
if not self._running:
self.toolbar._refresh_run_label()
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.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()
if not self._preview_open and not self._running:
self.statusBar().showMessage(t("status.ready"))
self._update_run_enabled()
def _set_theme(self, theme: str) -> None:
self._theme = theme
self.prefs["theme"] = theme
app = QApplication.instance()
if app is not None:
app.setStyleSheet(build_qss(theme))
self.force_plots.set_theme(theme)
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_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._update_run_enabled()
self._persist_prefs()
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("app.scaffold_hint"))
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._session_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.type_combo.setEnabled(False)
self.sensor_picker.config_combo.setEnabled(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._session_phase = "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.type_combo.setEnabled(True)
self.sensor_picker.config_combo.setEnabled(True)
self._update_run_enabled()
keep_frame = reason in ("ended", "disconnected")
if not keep_frame:
self.arrow_panel.clear_preview(t("app.scaffold_hint"))
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._session_phase = "running"
self.toolbar.set_reset_enabled(True)
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,
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"
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,
)
else:
canvas_bgr = hex_to_bgr(color(self._theme, "arrow_canvas", "#121316"))
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,
)
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["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.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)
+56
View File
@@ -0,0 +1,56 @@
"""Customer app entrypoint."""
from __future__ import annotations
import logging
import sys
from PyQt5.QtWidgets import QApplication
from orisys_app_shared import API_VERSION, __version__ as shared_version
from orisys_app_shared.compat import check_shared_compat
from orisys_app_shared.i18n import set_locale
from orisys_app_shared.logging import init_logging, install_exception_hooks
from orisys_app_shared.paths import prefer_local_sensor_bundles
from orisys_app_shared.prefs import load_preferences
from orisys_app_shared.theme import build_qss
from orisys_customer import REQUIRES_SHARED_API, __version__ as app_version
from orisys_customer.app_window import CustomerMainWindow
logger = logging.getLogger(__name__)
def main() -> int:
init_logging("orisys-customer")
install_exception_hooks()
preferred = prefer_local_sensor_bundles()
if preferred is not None:
logger.info("Preferring local sensor bundles at %s", preferred)
check_shared_compat(REQUIRES_SHARED_API)
logger.info(
"Versions: app=%s shared=%s api=%s (requires %s)",
app_version,
shared_version,
API_VERSION,
REQUIRES_SHARED_API,
)
prefs = load_preferences()
language = str(prefs.get("language") or "zh_CN")
theme = str(prefs.get("theme") or "dark")
set_locale(language)
app = QApplication(sys.argv)
app.setApplicationName("Orisys Customer")
app.setApplicationVersion(app_version)
app.setStyleSheet(build_qss(theme))
window = CustomerMainWindow(prefs=prefs)
window.show()
logger.info("Customer shell started (theme=%s, language=%s)", theme, language)
return int(app.exec_())
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1 @@
"""UI package."""
@@ -0,0 +1,44 @@
"""About dialog for Help > About."""
from __future__ import annotations
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QPixmap
from PyQt5.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
from orisys_app_shared.i18n import t
from orisys_app_shared.paths import logo_topbar_path
from orisys_customer import __version__ as app_version
class AboutDialog(QDialog):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setWindowTitle(t("menu.about"))
self.setModal(True)
self.resize(420, 220)
root = QVBoxLayout(self)
row = QHBoxLayout()
logo = QLabel()
pix = QPixmap(str(logo_topbar_path()))
if not pix.isNull():
logo.setPixmap(pix.scaledToHeight(48, Qt.SmoothTransformation))
row.addWidget(logo)
row.addStretch(1)
root.addLayout(row)
self.body = QLabel()
self.body.setWordWrap(True)
self.body.setTextInteractionFlags(Qt.TextSelectableByMouse)
root.addWidget(self.body)
close_btn = QPushButton("OK")
close_btn.clicked.connect(self.accept)
root.addWidget(close_btn, alignment=Qt.AlignRight)
self.retranslate()
def retranslate(self) -> None:
self.setWindowTitle(t("app.about_title"))
self.body.setText(t("app.about_body", app_version=app_version))
@@ -0,0 +1,134 @@
"""2D image panel: arrows / depth view with mode selector."""
from __future__ import annotations
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtGui import QImage, QPixmap
from PyQt5.QtWidgets import (
QComboBox,
QFrame,
QHBoxLayout,
QLabel,
QSizePolicy,
QVBoxLayout,
)
from orisys_app_shared.i18n import t
VIEW_MODES = ("arrows_pure", "arrows_stitched", "depth")
class ArrowPanel(QFrame):
view_mode_changed = pyqtSignal(str)
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setObjectName("contentPanel")
layout = QVBoxLayout(self)
header = QHBoxLayout()
self.title = QLabel()
self.title.setObjectName("panelTitle")
self.fps_label = QLabel()
self.fps_label.setObjectName("panelMeta")
self.fps_label.setVisible(False)
self._fps_visible = False
self._fps_value: float | None = None
self.view_combo = QComboBox()
self.view_combo.setMinimumWidth(140)
self.view_combo.currentIndexChanged.connect(self._on_view_index_changed)
header.addWidget(self.title)
header.addStretch(1)
header.addWidget(self.fps_label)
header.addWidget(self.view_combo)
layout.addLayout(header)
self.image_label = QLabel()
self.image_label.setAlignment(Qt.AlignCenter)
self.image_label.setMinimumSize(320, 240)
self.image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.image_label.setScaledContents(False)
layout.addWidget(self.image_label, 1)
self._placeholder = ""
self._populate_view_combo()
self.retranslate()
def view_mode(self) -> str:
data = self.view_combo.currentData()
return str(data or "arrows_pure")
def set_view_mode(self, mode: str) -> None:
wanted = str(mode or "arrows_pure")
if wanted not in VIEW_MODES:
wanted = "arrows_pure"
idx = self.view_combo.findData(wanted)
if idx < 0:
idx = 0
self.view_combo.blockSignals(True)
self.view_combo.setCurrentIndex(idx)
self.view_combo.blockSignals(False)
def set_fps_visible(self, visible: bool) -> None:
self._fps_visible = bool(visible)
self._refresh_fps_label()
def set_fps(self, fps: float | None) -> None:
self._fps_value = None if fps is None else float(fps)
self._refresh_fps_label()
def _refresh_fps_label(self) -> None:
if not self._fps_visible or self._fps_value is None:
self.fps_label.clear()
self.fps_label.setVisible(False)
return
self.fps_label.setText(f"{self._fps_value:.1f} FPS")
self.fps_label.setVisible(True)
def _populate_view_combo(self) -> None:
current = self.view_combo.currentData()
self.view_combo.blockSignals(True)
self.view_combo.clear()
for mode in VIEW_MODES:
self.view_combo.addItem("", mode)
if current:
idx = self.view_combo.findData(current)
self.view_combo.setCurrentIndex(max(0, idx))
self.view_combo.blockSignals(False)
def _on_view_index_changed(self, _index: int) -> None:
self.view_mode_changed.emit(self.view_mode())
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):
if i < self.view_combo.count():
self.view_combo.setItemText(i, label)
self.view_combo.setToolTip(t("panels.view_mode_tip"))
if not self.image_label.pixmap() or self.image_label.pixmap().isNull():
self._placeholder = t("app.scaffold_hint")
self.image_label.setText(self._placeholder)
def show_frame(self, image: QImage) -> None:
if image.isNull():
return
pix = QPixmap.fromImage(image)
scaled = pix.scaled(
self.image_label.size(),
Qt.KeepAspectRatio,
Qt.SmoothTransformation,
)
self.image_label.setPixmap(scaled)
self.image_label.setText("")
def clear_preview(self, message: str | None = None) -> None:
self.image_label.clear()
self.image_label.setPixmap(QPixmap())
text = message if message is not None else t("app.scaffold_hint")
self._placeholder = text
self.image_label.setText(text)
self.set_fps(None)
@@ -0,0 +1,338 @@
"""Force calibration sampling panel (FN / FX / FY)."""
from __future__ import annotations
import math
import time
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import (
QAbstractItemView,
QButtonGroup,
QDoubleSpinBox,
QFormLayout,
QFrame,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMessageBox,
QProgressBar,
QPushButton,
QRadioButton,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
)
from orisys_app_shared.force.calibration_io import (
CalSample,
CalSessionStore,
ChannelKey,
export_force_calibration_v2,
meta_from_sensor_json,
)
from orisys_app_shared.i18n import t
from orisys_app_shared.paths import calibration_export_dir, sensors_dir
class CalPanel(QFrame):
status_message = pyqtSignal(str)
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setObjectName("contentPanel")
self._store = CalSessionStore()
self._channel: ChannelKey = "fn"
self._running = False
self._recording = False
self._record_deadline = 0.0
self._record_started = 0.0
self._record_samples: list[float] = []
self._live_fn = 0.0
self._live_fx = 0.0
self._live_fy = 0.0
self._sensor_type = "box"
self._config_ref: str | None = None
self._record_duration_s = 7.0
root = QVBoxLayout(self)
self.title = QLabel()
self.title.setObjectName("panelTitle")
root.addWidget(self.title)
channel_row = QHBoxLayout()
self.channel_group = QButtonGroup(self)
self.radio_fn = QRadioButton("FN")
self.radio_fx = QRadioButton("FX")
self.radio_fy = QRadioButton("FY")
self.radio_fn.setChecked(True)
for i, radio in enumerate((self.radio_fn, self.radio_fx, self.radio_fy)):
self.channel_group.addButton(radio, i)
channel_row.addWidget(radio)
channel_row.addStretch(1)
self.channel_group.buttonToggled.connect(self._on_channel_button)
root.addLayout(channel_row)
form = QFormLayout()
self._weight_label = QLabel()
self.weight_spin = QDoubleSpinBox()
self.weight_spin.setRange(0.0, 1e6)
self.weight_spin.setDecimals(4)
self.weight_spin.setSingleStep(0.1)
self.weight_spin.setValue(0.0)
self._duration_label = QLabel()
self.duration_spin = QDoubleSpinBox()
self.duration_spin.setRange(5.0, 10.0)
self.duration_spin.setDecimals(1)
self.duration_spin.setSingleStep(0.5)
self.duration_spin.setValue(7.0)
self.duration_spin.setSuffix(" s")
form.addRow(self._weight_label, self.weight_spin)
form.addRow(self._duration_label, self.duration_spin)
root.addLayout(form)
rec_row = QHBoxLayout()
self.btn_record = QPushButton()
self.btn_record.clicked.connect(self._on_record_clicked)
rec_row.addWidget(self.btn_record)
rec_row.addStretch(1)
root.addLayout(rec_row)
self.progress = QProgressBar()
self.progress.setRange(0, 1000)
self.progress.setValue(0)
self.progress.setTextVisible(True)
root.addWidget(self.progress)
self.live_label = QLabel()
self.live_label.setWordWrap(True)
root.addWidget(self.live_label)
self.table = QTableWidget(0, 5)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.table.verticalHeader().setVisible(False)
root.addWidget(self.table, 1)
sn_row = QHBoxLayout()
self._sn_label = QLabel()
self.sn_edit = QLineEdit()
self.sn_edit.setPlaceholderText("")
sn_row.addWidget(self._sn_label)
sn_row.addWidget(self.sn_edit, 1)
root.addLayout(sn_row)
actions = QHBoxLayout()
self.btn_delete = QPushButton()
self.btn_clear_channel = QPushButton()
self.btn_clear_all = QPushButton()
self.btn_export = QPushButton()
self.btn_delete.clicked.connect(self._on_delete_row)
self.btn_clear_channel.clicked.connect(self._on_clear_channel)
self.btn_clear_all.clicked.connect(self._on_clear_all)
self.btn_export.clicked.connect(self._on_export)
actions.addWidget(self.btn_delete)
actions.addWidget(self.btn_clear_channel)
actions.addWidget(self.btn_clear_all)
actions.addStretch(1)
actions.addWidget(self.btn_export)
root.addLayout(actions)
self._refresh_enabled()
self._refresh_table()
self.retranslate()
def set_running(self, running: bool) -> None:
self._running = bool(running)
if not self._running and self._recording:
self._cancel_recording()
self._refresh_enabled()
def set_meta_context(self, *, sensor_type: str, config_ref: str | None) -> None:
self._sensor_type = str(sensor_type or "box")
self._config_ref = config_ref
def push_raw(self, fn: float, fx: float, fy: float, _timestamp: float | None = None) -> None:
self._live_fn = float(fn)
self._live_fx = float(fx)
self._live_fy = float(fy)
self._update_live_label()
if not self._recording:
return
now = time.perf_counter()
value = {"fn": self._live_fn, "fx": self._live_fx, "fy": self._live_fy}[self._channel]
self._record_samples.append(float(value))
span = max(1e-6, self._record_deadline - self._record_started)
frac = min(1.0, max(0.0, (now - self._record_started) / span))
self.progress.setValue(int(frac * 1000))
self.progress.setFormat(f"{now - self._record_started:.1f} / {span:.1f} s")
if now >= self._record_deadline:
self._finish_recording()
def retranslate(self) -> None:
self.title.setText(t("panels.cal"))
self._weight_label.setText(t("cal.weight"))
self._duration_label.setText(t("cal.duration"))
self.btn_record.setText(t("cal.record"))
self.btn_delete.setText(t("cal.delete_row"))
self.btn_clear_channel.setText(t("cal.clear_channel"))
self.btn_clear_all.setText(t("cal.clear_all"))
self.btn_export.setText(t("cal.export"))
self._sn_label.setText(t("cal.sn"))
self.sn_edit.setPlaceholderText(t("cal.sn_placeholder"))
headers = (
t("cal.col_weight"),
t("cal.col_raw"),
t("cal.col_n"),
t("cal.col_std"),
t("cal.col_actions"),
)
for i, text in enumerate(headers):
item = self.table.horizontalHeaderItem(i)
if item is None:
item = QTableWidgetItem(text)
self.table.setHorizontalHeaderItem(i, item)
else:
item.setText(text)
self._update_live_label()
self._refresh_enabled()
def _on_channel_button(self, button, checked: bool) -> None:
if not checked:
return
mapping = {self.radio_fn: "fn", self.radio_fx: "fx", self.radio_fy: "fy"}
key = mapping.get(button)
if key is None:
return
if self._recording:
self._cancel_recording()
self._channel = key # type: ignore[assignment]
self._refresh_table()
def _refresh_enabled(self) -> None:
can_record = self._running and not self._recording
self.btn_record.setEnabled(can_record)
self.weight_spin.setEnabled(not self._recording)
self.duration_spin.setEnabled(not self._recording)
for radio in (self.radio_fn, self.radio_fx, self.radio_fy):
radio.setEnabled(not self._recording)
self.btn_export.setEnabled(not self._recording)
def _update_live_label(self) -> None:
self.live_label.setText(
t(
"cal.live_raw",
fn=f"{self._live_fn:.3f}",
fx=f"{self._live_fx:.3f}",
fy=f"{self._live_fy:.3f}",
)
)
def _on_record_clicked(self) -> None:
if not self._running or self._recording:
return
self._recording = True
self._record_samples = []
self._record_duration_s = float(self.duration_spin.value())
self._record_started = time.perf_counter()
self._record_deadline = self._record_started + self._record_duration_s
self.progress.setValue(0)
self.progress.setFormat(f"0.0 / {self._record_duration_s:.1f} s")
self._refresh_enabled()
self.status_message.emit(t("cal.recording"))
def _cancel_recording(self) -> None:
self._recording = False
self._record_samples = []
self.progress.setValue(0)
self.progress.setFormat("")
self._refresh_enabled()
def _finish_recording(self) -> None:
samples = list(self._record_samples)
self._recording = False
self._record_samples = []
self.progress.setValue(1000)
self._refresh_enabled()
if len(samples) < 2:
self.status_message.emit(t("cal.record_failed_few"))
return
mean = sum(samples) / len(samples)
var = sum((x - mean) ** 2 for x in samples) / len(samples)
std = math.sqrt(var)
sample = CalSample(
weight_n=float(self.weight_spin.value()),
raw_mean=mean,
n=len(samples),
std=std,
)
self._store.upsert(self._channel, sample)
self._refresh_table()
self.status_message.emit(
t("cal.record_ok", weight=f"{sample.weight_n:g}", raw=f"{sample.raw_mean:.3f}")
)
def _refresh_table(self) -> None:
rows = self._store.channel(self._channel)
self.table.setRowCount(len(rows))
for r, sample in enumerate(rows):
values = (
f"{sample.weight_n:g}",
f"{sample.raw_mean:.4f}",
str(sample.n),
f"{sample.std:.4f}",
"",
)
for c, text in enumerate(values):
item = QTableWidgetItem(text)
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
self.table.setItem(r, c, item)
def _on_delete_row(self) -> None:
row = self.table.currentRow()
if row < 0:
return
self._store.remove_at(self._channel, row)
self._refresh_table()
def _on_clear_channel(self) -> None:
self._store.clear(self._channel)
self._refresh_table()
def _on_clear_all(self) -> None:
self._store.clear()
self._refresh_table()
def _on_export(self) -> None:
meta = meta_from_sensor_json(self._config_ref)
meta["sensor_type"] = self._sensor_type
meta["sn"] = self.sn_edit.text().strip()
meta["record_duration_s"] = float(self.duration_spin.value())
local_bundle = sensors_dir() / self._sensor_type
if not local_bundle.is_dir():
local_bundle = None
try:
primary, local_path, warnings = export_force_calibration_v2(
self._store,
export_dir=calibration_export_dir(),
sensor_type=self._sensor_type,
meta=meta,
local_bundle_dir=local_bundle,
)
except ValueError as exc:
QMessageBox.warning(self, t("panels.cal"), str(exc))
return
except Exception as exc:
QMessageBox.warning(self, t("panels.cal"), t("cal.export_failed", error=str(exc)))
return
msg = t("cal.export_ok", path=str(primary))
if local_path is not None:
msg += "\n" + t("cal.export_local", path=str(local_path))
if warnings:
msg += "\n" + t("cal.export_warnings", detail="; ".join(warnings))
self.status_message.emit(msg)
QMessageBox.information(self, t("panels.cal"), msg)
@@ -0,0 +1,190 @@
"""FN / FX / FY force charts with live value labels."""
from __future__ import annotations
import numpy as np
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QColor
from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ, LOWPASS_CUTOFF_HZ
from orisys_app_shared.force import MetricHistory
from orisys_app_shared.i18n import t
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 force charts") from exc
def _nice_ticks(lo: float, hi: float, count: int = 5) -> list[tuple[float, str]]:
if hi <= lo:
return [(lo, f"{lo:g}")]
step = (hi - lo) / max(1, count - 1)
return [(lo + i * step, f"{(lo + i * step):g}") for i in range(count)]
class ForcePlotsPanel(QFrame):
def __init__(self, parent=None, *, theme: str = "dark") -> None:
super().__init__(parent)
self.setObjectName("contentPanel")
self._theme = theme
self._fn_max = 20.0
self._shear_max = 20.0
self._fixed_newton = True
self._unit = "raw"
self.history = MetricHistory(
channels=3,
window_sec=4.0,
sample_rate_hz=FORCE_SAMPLE_RATE_HZ,
lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ,
y_axis_min_span=0.5,
)
root = QVBoxLayout(self)
self.title = QLabel()
self.title.setObjectName("panelTitle")
root.addWidget(self.title)
self.plot_widget = pg.GraphicsLayoutWidget()
self.plots = []
self.curves = []
names = ("FN", "FX", "FY")
for i, name in enumerate(names):
plot = self.plot_widget.addPlot(row=i, col=0)
plot.showGrid(x=True, y=True, alpha=0.25)
plot.setLabel("left", name)
plot.setMouseEnabled(x=False, y=False)
if i > 0:
plot.setXLink(self.plots[0])
curve = plot.plot(pen=pg.mkPen("#8ab4f8", width=2))
self.plots.append(plot)
self.curves.append(curve)
root.addWidget(self.plot_widget, 1)
values_row = QHBoxLayout()
self.value_labels = []
for _ in names:
lab = QLabel("—")
lab.setAlignment(Qt.AlignCenter)
values_row.addWidget(lab)
self.value_labels.append(lab)
root.addLayout(values_row)
self.set_theme(theme)
self._apply_axis_mode()
self.retranslate()
def set_theme(self, theme: str) -> None:
self._theme = theme
bg = color(theme, "plot_bg", "#121316")
text = color(theme, "text_primary", "#e8eaed")
self.plot_widget.setBackground(bg)
axis_pen = pg.mkPen(text)
for plot in self.plots:
try:
plot.getViewBox().setBackgroundColor(QColor(bg))
except Exception:
pass
for axis_name in ("left", "bottom"):
axis = plot.getAxis(axis_name)
axis.setPen(axis_pen)
axis.setTextPen(axis_pen)
plot.showGrid(x=True, y=True, alpha=0.25)
label = plot.getAxis("left").labelText or ""
if label:
plot.setLabel("left", label, color=text)
pens = (
color(theme, "plot_fn", "#8ab4f8"),
color(theme, "plot_fx", "#34a853"),
color(theme, "plot_fy", "#fbbc04"),
)
for curve, pen in zip(self.curves, pens):
curve.setPen(pg.mkPen(pen, width=2))
def set_window_sec(self, sec: float) -> None:
self.history.set_window_sec(sec)
self._clear_curves()
self._apply_axis_mode()
def set_axis_limits(self, fn_max: float, shear_max: float) -> None:
self._fn_max = max(0.1, float(fn_max))
self._shear_max = max(0.1, float(shear_max))
self._apply_axis_mode()
def set_fixed_newton(self, enabled: bool) -> None:
self._fixed_newton = bool(enabled)
self._apply_axis_mode()
def reset(self) -> None:
self.history.reset()
self._clear_curves()
for lab in self.value_labels:
lab.setText("—")
self._apply_axis_mode()
def push(
self,
timestamp: float,
fn: float,
fx: float,
fy: float,
*,
unit: str = "raw",
) -> None:
self._unit = unit
use_fixed = self._uses_fixed_y()
plot_fn = max(0.0, float(fn)) if use_fixed else float(fn)
self.history.push(timestamp, plot_fn, float(fx), float(fy))
self._update_value_labels(fn, fx, fy)
state = self.history.state()
if state is None:
return
values = state.values.copy()
if use_fixed:
values[:, 0] = np.maximum(values[:, 0], 0.0)
for i, curve in enumerate(self.curves):
curve.setData(state.times, values[:, i])
self.plots[i].setXRange(*state.x_range, padding=0.0)
if use_fixed:
ymin, ymax = self._fixed_y_range(i)
self.plots[i].setYRange(ymin, ymax, padding=0.0)
self.plots[i].getAxis("left").setTicks([_nice_ticks(ymin, ymax, count=5)])
else:
self.plots[i].getAxis("left").setTicks(None)
self.plots[i].setYRange(*state.y_ranges[i], padding=0.0)
def _uses_fixed_y(self) -> bool:
return bool(self._fixed_newton) and self._unit == "N"
def _fixed_y_range(self, channel_index: int) -> tuple[float, float]:
if channel_index == 0:
return 0.0, self._fn_max
return -self._shear_max, self._shear_max
def _apply_axis_mode(self) -> None:
# Preview fixed newton axes when newton mode is selected, even before samples.
preview_fixed = bool(self._fixed_newton)
use_fixed = self._uses_fixed_y() or (preview_fixed and self.history.sample_count == 0)
for i, plot in enumerate(self.plots):
if use_fixed:
ymin, ymax = self._fixed_y_range(i)
plot.setYRange(ymin, ymax, padding=0.0)
plot.getAxis("left").setTicks([_nice_ticks(ymin, ymax, count=5)])
else:
plot.getAxis("left").setTicks(None)
for plot in self.plots:
plot.setXRange(0.0, self.history.window_sec, padding=0.0)
def _clear_curves(self) -> None:
for curve in self.curves:
curve.setData([], [])
def _update_value_labels(self, fn: float, fx: float, fy: float) -> None:
unit = self._unit
fmt = "{name}: {value:.4f} {unit}" if unit == "N" else "{name}: {value:.2f} {unit}"
for lab, name, value in zip(self.value_labels, ("FN", "FX", "FY"), (fn, fx, fy)):
lab.setText(fmt.format(name=name, value=value, unit=unit))
def retranslate(self) -> None:
self.title.setText(t("panels.plots"))
@@ -0,0 +1,27 @@
"""Placeholder panel for finger 3D mesh view."""
from __future__ import annotations
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QFrame, QLabel, QVBoxLayout
from orisys_app_shared.i18n import t
class Mesh3dPanel(QFrame):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setObjectName("contentPanel")
layout = QVBoxLayout(self)
self.title = QLabel()
self.title.setObjectName("panelTitle")
self.body = QLabel()
self.body.setAlignment(Qt.AlignCenter)
self.body.setWordWrap(True)
layout.addWidget(self.title)
layout.addWidget(self.body, 1)
self.retranslate()
def retranslate(self) -> None:
self.title.setText(t("panels.mesh3d"))
self.body.setText(t("panels.mesh3d_hint"))
@@ -0,0 +1,114 @@
"""Modal dialog for force-plot window and Y-axis limits."""
from __future__ import annotations
from PyQt5.QtWidgets import (
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
)
from orisys_app_shared.i18n import t
DEFAULT_PLOT_WINDOW_SEC = 4.0
DEFAULT_PLOT_FN_MAX = 20.0
DEFAULT_PLOT_SHEAR_MAX = 20.0
class PlotSettingsDialog(QDialog):
def __init__(
self,
parent=None,
*,
window_sec: float = DEFAULT_PLOT_WINDOW_SEC,
fn_max: float = DEFAULT_PLOT_FN_MAX,
shear_max: float = DEFAULT_PLOT_SHEAR_MAX,
units: str = "newton",
) -> None:
super().__init__(parent)
self.setModal(True)
self.resize(360, 200)
root = QVBoxLayout(self)
form = QFormLayout()
self.window_label = QLabel()
self.window_spin = QDoubleSpinBox()
self.window_spin.setRange(1.0, 30.0)
self.window_spin.setSingleStep(1.0)
self.window_spin.setDecimals(0)
self.window_spin.setSuffix(" s")
self.window_spin.setValue(float(window_sec))
self.fn_max_label = QLabel()
self.fn_max_spin = QDoubleSpinBox()
self.fn_max_spin.setRange(1.0, 100.0)
self.fn_max_spin.setSingleStep(1.0)
self.fn_max_spin.setDecimals(0)
self.fn_max_spin.setSuffix(" N")
self.fn_max_spin.setValue(float(fn_max))
self.shear_max_label = QLabel()
self.shear_max_spin = QDoubleSpinBox()
self.shear_max_spin.setRange(1.0, 100.0)
self.shear_max_spin.setSingleStep(1.0)
self.shear_max_spin.setDecimals(0)
self.shear_max_spin.setSuffix(" N")
self.shear_max_spin.setValue(float(shear_max))
form.addRow(self.window_label, self.window_spin)
form.addRow(self.fn_max_label, self.fn_max_spin)
form.addRow(self.shear_max_label, self.shear_max_spin)
root.addLayout(form)
newton = str(units or "newton").lower().startswith("n")
self.fn_max_spin.setEnabled(newton)
self.shear_max_spin.setEnabled(newton)
self.fn_max_label.setEnabled(newton)
self.shear_max_label.setEnabled(newton)
buttons_row = QHBoxLayout()
self.btn_reset = QPushButton()
self.btn_reset.clicked.connect(self._reset_defaults)
buttons_row.addWidget(self.btn_reset)
buttons_row.addStretch(1)
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
self.button_box.accepted.connect(self.accept)
self.button_box.rejected.connect(self.reject)
buttons_row.addWidget(self.button_box)
root.addLayout(buttons_row)
self.retranslate()
def values(self) -> tuple[float, float, float]:
return (
float(self.window_spin.value()),
float(self.fn_max_spin.value()),
float(self.shear_max_spin.value()),
)
def _reset_defaults(self) -> None:
self.window_spin.setValue(DEFAULT_PLOT_WINDOW_SEC)
self.fn_max_spin.setValue(DEFAULT_PLOT_FN_MAX)
self.shear_max_spin.setValue(DEFAULT_PLOT_SHEAR_MAX)
def retranslate(self) -> None:
self.setWindowTitle(t("dialog.plot_settings_title"))
self.window_label.setText(t("dialog.plot_window"))
self.window_spin.setToolTip(t("dialog.plot_window_tip"))
self.fn_max_label.setText(t("dialog.plot_fn_max"))
self.fn_max_spin.setToolTip(t("dialog.plot_fn_max_tip"))
self.shear_max_label.setText(t("dialog.plot_shear_max"))
self.shear_max_spin.setToolTip(t("dialog.plot_shear_max_tip"))
self.btn_reset.setText(t("dialog.reset_defaults"))
ok_btn = self.button_box.button(QDialogButtonBox.Ok)
cancel_btn = self.button_box.button(QDialogButtonBox.Cancel)
if ok_btn is not None:
ok_btn.setText(t("dialog.ok"))
if cancel_btn is not None:
cancel_btn.setText(t("dialog.cancel"))
@@ -0,0 +1,119 @@
"""Sensor type + config picker with thumbnail placeholder."""
from __future__ import annotations
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import QComboBox, QFrame, QHBoxLayout, QLabel, QVBoxLayout, QWidget
from orisys_app_shared.i18n import t
from orisys_app_shared.sensors import list_configs_for_type, list_sensor_types
class SensorPickerWidget(QWidget):
sensor_type_changed = pyqtSignal(str)
config_changed = pyqtSignal(str)
def __init__(self, parent=None) -> None:
super().__init__(parent)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
self.thumb = QFrame()
self.thumb.setObjectName("contentPanel")
self.thumb.setFixedSize(72, 72)
thumb_layout = QVBoxLayout(self.thumb)
self.thumb_label = QLabel()
self.thumb_label.setAlignment(Qt.AlignCenter)
self.thumb_label.setWordWrap(True)
thumb_layout.addWidget(self.thumb_label)
form = QVBoxLayout()
form.setSpacing(4)
type_row = QHBoxLayout()
self.type_label = QLabel()
self.type_combo = QComboBox()
for name in list_sensor_types():
self.type_combo.addItem(name, name)
self.type_combo.currentIndexChanged.connect(self._on_type_index_changed)
type_row.addWidget(self.type_label)
type_row.addWidget(self.type_combo, 1)
config_row = QHBoxLayout()
self.config_label = QLabel()
self.config_combo = QComboBox()
self.config_combo.currentIndexChanged.connect(self._on_config_index_changed)
config_row.addWidget(self.config_label)
config_row.addWidget(self.config_combo, 1)
form.addLayout(type_row)
form.addLayout(config_row)
layout.addWidget(self.thumb)
layout.addLayout(form, 1)
self._reload_configs()
self._refresh_thumb()
self.retranslate()
def sensor_type(self) -> str:
data = self.type_combo.currentData()
return str(data or self.type_combo.currentText() or "box")
def config_name(self) -> str:
data = self.config_combo.currentData()
if data is None:
return str(self.config_combo.currentText() or "")
return str(data)
def set_sensor_type(self, sensor_type: str) -> None:
wanted = str(sensor_type or "box")
idx = self.type_combo.findData(wanted)
if idx < 0:
idx = self.type_combo.findText(wanted)
if idx < 0:
idx = 0
self.type_combo.blockSignals(True)
self.type_combo.setCurrentIndex(idx)
self.type_combo.blockSignals(False)
self._reload_configs()
self._refresh_thumb()
def set_config_name(self, config_name: str) -> None:
wanted = str(config_name or "")
idx = self.config_combo.findData(wanted)
if idx < 0 and wanted:
idx = self.config_combo.findText(wanted)
if idx < 0:
idx = 0
self.config_combo.blockSignals(True)
self.config_combo.setCurrentIndex(max(0, idx))
self.config_combo.blockSignals(False)
def _reload_configs(self) -> None:
self.config_combo.blockSignals(True)
self.config_combo.clear()
for display, key in list_configs_for_type(self.sensor_type()):
self.config_combo.addItem(display, key)
if self.config_combo.count() == 0:
self.config_combo.addItem(self.sensor_type(), self.sensor_type())
self.config_combo.blockSignals(False)
def _refresh_thumb(self) -> None:
name = self.sensor_type().upper()
self.thumb_label.setText(name)
def _on_type_index_changed(self, _index: int) -> None:
self._reload_configs()
self._refresh_thumb()
self.sensor_type_changed.emit(self.sensor_type())
self.config_changed.emit(self.config_name())
def _on_config_index_changed(self, _index: int) -> None:
self.config_changed.emit(self.config_name())
def retranslate(self) -> None:
self.type_label.setText(t("sensor.type"))
self.config_label.setText(t("sensor.config"))
if not self.thumb_label.text().strip():
self.thumb_label.setText(t("sensor.thumb_placeholder"))
else:
self._refresh_thumb()
@@ -0,0 +1,261 @@
"""Top toolbar: source, camera/video, run/reset, units."""
from __future__ import annotations
from pathlib import Path
from PyQt5.QtCore import pyqtSignal
from PyQt5.QtWidgets import (
QComboBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QSpinBox,
QStackedWidget,
QWidget,
)
from orisys_app_shared.i18n import t
class ToolbarWidget(QWidget):
search_clicked = pyqtSignal()
preview_toggled = pyqtSignal(bool)
camera_id_changed = pyqtSignal(int)
source_mode_changed = pyqtSignal(str)
browse_video_clicked = pyqtSignal()
run_toggled = pyqtSignal(bool)
reset_clicked = pyqtSignal()
units_changed = pyqtSignal(str)
def __init__(self, parent=None) -> None:
super().__init__(parent)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
self.source_label = QLabel()
self.source_combo = QComboBox()
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_spin = QSpinBox()
self.cam_id_spin.setRange(0, 20)
self.cam_id_spin.setValue(0)
self.cam_id_spin.setMinimumWidth(72)
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.setReadOnly(True)
self.btn_browse_video = QPushButton()
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)
self.btn_search = QPushButton()
self.btn_search.clicked.connect(self.search_clicked.emit)
self.btn_preview = QPushButton()
self.btn_preview.setCheckable(True)
self.btn_preview.toggled.connect(self._on_preview_toggled)
self.btn_run = QPushButton()
self.btn_run.setCheckable(True)
self.btn_run.toggled.connect(self._on_run_toggled)
self.btn_reset = QPushButton()
self.btn_reset.setEnabled(False)
self.btn_reset.clicked.connect(self.reset_clicked.emit)
self.units_label = QLabel()
self.units_combo = QComboBox()
self.units_combo.currentIndexChanged.connect(self._on_units_index_changed)
layout.addWidget(self.source_label)
layout.addWidget(self.source_combo)
layout.addWidget(self.source_stack, 1)
layout.addWidget(self.btn_search)
layout.addWidget(self.btn_preview)
layout.addWidget(self.btn_run)
layout.addWidget(self.btn_reset)
layout.addWidget(self.units_label)
layout.addWidget(self.units_combo)
layout.addStretch(1)
self._populate_source_combo()
self._populate_units_combo()
self.retranslate()
self._apply_source_mode_ui()
def source_mode(self) -> str:
data = self.source_combo.currentData()
return str(data or "camera")
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._apply_source_mode_ui()
def camera_id(self) -> int:
return int(self.cam_id_spin.value())
def set_camera_id(self, value: int) -> None:
self.cam_id_spin.blockSignals(True)
self.cam_id_spin.setValue(int(value))
self.cam_id_spin.blockSignals(False)
def video_path(self) -> str:
return self.video_edit.text().strip()
def set_video_path(self, path: str) -> None:
text = str(path or "").strip()
self.video_edit.setText(text)
if text:
self.video_edit.setToolTip(text)
else:
self.video_edit.setToolTip("")
def units(self) -> str:
data = self.units_combo.currentData()
return str(data or "newton")
def set_units(self, units: str) -> None:
wanted = "raw" if str(units).lower().startswith("r") else "newton"
idx = self.units_combo.findData(wanted)
if idx < 0:
idx = 0
self.units_combo.blockSignals(True)
self.units_combo.setCurrentIndex(idx)
self.units_combo.blockSignals(False)
def set_preview_checked(self, checked: bool) -> None:
self.btn_preview.blockSignals(True)
self.btn_preview.setChecked(bool(checked))
self.btn_preview.blockSignals(False)
self._refresh_preview_label()
def set_run_checked(self, checked: bool) -> None:
self.btn_run.blockSignals(True)
self.btn_run.setChecked(bool(checked))
self.btn_run.blockSignals(False)
self._refresh_run_label()
if not checked:
self.btn_reset.setEnabled(False)
def set_reset_enabled(self, enabled: bool) -> None:
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")
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")
def set_run_enabled(self, enabled: bool) -> None:
self.btn_run.setEnabled(bool(enabled))
def active_source(self) -> str | int | None:
if self.source_mode() == "video":
path = self.video_path()
return path if path else None
return self.camera_id()
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)
self.units_combo.clear()
self.units_combo.addItem("", "newton")
self.units_combo.addItem("", "raw")
if current:
idx = self.units_combo.findData(current)
self.units_combo.setCurrentIndex(max(0, idx))
self.units_combo.blockSignals(False)
def _apply_source_mode_ui(self) -> None:
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)
if video and self.btn_preview.isChecked():
self.set_preview_checked(False)
def _on_source_index_changed(self, _index: int) -> None:
self._apply_source_mode_ui()
self.source_mode_changed.emit(self.source_mode())
def _on_preview_toggled(self, checked: bool) -> None:
self._refresh_preview_label()
self.preview_toggled.emit(bool(checked))
def _on_run_toggled(self, checked: bool) -> None:
self._refresh_run_label()
if not checked:
self.btn_reset.setEnabled(False)
self.run_toggled.emit(bool(checked))
def _on_units_index_changed(self, _index: int) -> None:
self.units_changed.emit(self.units())
def _refresh_preview_label(self) -> None:
if self.btn_preview.isChecked():
self.btn_preview.setText(t("toolbar.preview_stop"))
else:
self.btn_preview.setText(t("toolbar.preview"))
def _refresh_run_label(self) -> None:
if self.btn_run.isChecked():
self.btn_run.setText("■")
self.btn_run.setToolTip(t("toolbar.stop"))
else:
self.btn_run.setText("▶")
self.btn_run.setToolTip(t("toolbar.run"))
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._refresh_preview_label()
self._refresh_run_label()
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"))
+5
View File
@@ -0,0 +1,5 @@
# Internal app (stub)
Future home for automated calibration and laboratory testing tools.
This directory is intentionally empty in the customer-facing scaffold. Shared code will come from `shared/`; do not ship this app in the customer installer.