changed customer to studio

This commit is contained in:
ykcheungab
2026-09-12 14:03:00 +08:00
parent a2db648560
commit 904c27125f
71 changed files with 112 additions and 3959 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

-145
View File
@@ -1,145 +0,0 @@
{
"app": {
"title": "Orisys Customer",
"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…",
"rotate_arrows": "Rotate arrows 90°",
"run": "Run / Stop",
"preview": "Preview / Stop preview",
"reset": "Reset tracker",
"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",
"run_label": "▶ Run",
"stop_label": "■ Stop",
"run_unavailable": "Not available yet",
"run_disabled_video": "Select a video file first",
"run_disabled_sensor": "Run is not available for this sensor type",
"reset": "Reset",
"units": "Units",
"units_newton": "Newton",
"units_raw": "Raw"
},
"units": {
"newton_short": "N",
"raw_short": "raw"
},
"sensor": {
"type": "Sensor type",
"config": "Config",
"thumb_placeholder": "Product model"
},
"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",
"arrow_empty": "No image yet\nPick a video or camera, then press Run",
"arrow_empty_tip": "The deformation arrow image appears here while running or previewing",
"plots": "Force Charts",
"cal": "Calibration",
"mesh3d": "3D mesh",
"mesh3d_empty": "Finger 3D view placeholder —\nOpen3D mesh coming later",
"mesh3d_empty_tip": "3D mesh preview for finger sensors"
},
"chart": {
"fn": "FN",
"fx": "FX",
"fy": "FY",
"no_data": "—"
},
"cal": {
"weight": "Weight (N)",
"duration": "Record duration",
"record": "Record",
"recording_stop": "Stop",
"record_disabled": "Press Run to start acquisition before recording samples",
"record_cancelled": "Recording cancelled",
"empty_hint": "No samples for this channel yet: run, pick a channel and press Record.",
"delete_row": "Delete row",
"clear_channel": "Clear channel",
"clear_all": "Clear all",
"export": "Export…", "sn": "SN",
"sn_placeholder": "Serial number (optional)",
"live_raw_label": "Live raw",
"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",
"phase_idle": "Idle",
"phase_connecting": "Connecting",
"phase_running": "Running",
"phase_error": "Error",
"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}"
}
}
-146
View File
@@ -1,146 +0,0 @@
{
"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": "绘图设置…",
"rotate_arrows": "旋转箭头图 90°",
"run": "运行 / 停止",
"preview": "预览 / 停止预览",
"reset": "重置追踪器",
"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": "停止",
"run_label": "▶ 运行",
"stop_label": "■ 停止",
"run_unavailable": "当前不可运行",
"run_disabled_video": "请先选择一个视频文件",
"run_disabled_sensor": "此传感器类型暂不支持运行",
"reset": "重置",
"units": "单位",
"units_newton": "牛顿",
"units_raw": "原始"
},
"units": {
"newton_short": "N",
"raw_short": "raw"
},
"sensor": {
"type": "传感器类型",
"config": "配置",
"thumb_placeholder": "产品型号"
},
"panels": {
"arrow": "形变箭头",
"view_arrows_pure": "箭头",
"view_arrows_stitched": "箭头/采集图像",
"view_depth": "深度图",
"view_mode_tip": "运行时的图像显示模式",
"arrow_empty": "尚未采集图像\n请选择视频或摄像机后点击「运行」",
"arrow_empty_tip": "运行或预览后在此显示形变箭头图",
"plots": "力曲线图",
"cal": "标定",
"mesh3d": "三维网格",
"mesh3d_empty": "手指三维视图占位 —\nOpen3D 网格稍后加入",
"mesh3d_empty_tip": "手指传感器的三维网格预览"
},
"chart": {
"fn": "FN",
"fx": "FX",
"fy": "FY",
"no_data": "—"
},
"cal": {
"weight": "砝码重量 (N)",
"duration": "采集时长",
"record": "记录",
"recording_stop": "停止",
"record_disabled": "请先点击「运行」开始采集,再记录标定样本",
"record_cancelled": "已取消本次采集",
"empty_hint": "当前通道尚无样本:运行后选择通道并点击「记录」。",
"delete_row": "删除行",
"clear_channel": "清空通道",
"clear_all": "全部清空",
"export": "导出…",
"sn": "序列号",
"sn_placeholder": "序列号(可选)",
"live_raw_label": "实时原始值",
"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": "就绪",
"phase_idle": "空闲",
"phase_connecting": "连接中",
"phase_running": "运行中",
"phase_error": "出错",
"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}"
}
}
@@ -1,21 +0,0 @@
{
"language": "zh_CN",
"theme": "dark",
"window_geometry": null,
"window_default_size": [1440, 880],
"camera_id": "0",
"source_mode": "camera",
"video_path": "",
"show_cal_panel": false,
"show_finger_3d": true,
"show_fps": false,
"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",
"arrow_rotation_deg": 90,
"splitter_sizes": [340, 480, 400]
}
@@ -1,30 +0,0 @@
{
"schema_version": 1,
"unit": "N",
"channels": {
"normal": {
"method": "piecewise_linear",
"raw_key": "fnormal",
"extrapolate": true,
"clamp_output_min": 0.0,
"points": [
[1000.0, 0.0],
[10000.0, 0.377],
[30000.0, 1.377],
[62000.0, 2.377]
]
},
"shear": {
"method": "piecewise_linear",
"input": "components",
"signed": true,
"extrapolate": true,
"clamp_output_min": 0.0,
"points": [
[1000.0, 0.0],
[36000.0, 1.19],
[68000.0, 2.06]
]
}
}
}
@@ -1,46 +0,0 @@
{
"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
}
}
@@ -1,9 +0,0 @@
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.
@@ -1,6 +0,0 @@
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.
@@ -1,6 +0,0 @@
{
"name": "finger",
"sensor_json": "sensor.json",
"assets_dir": "assets/30fps-0703",
"viz_pose": "assets/visualization/world_to_cam.npy"
}
@@ -1,35 +0,0 @@
{
"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
}
}
@@ -1,46 +0,0 @@
{
"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
@@ -1,26 +0,0 @@
[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
@@ -1,24 +0,0 @@
#!/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())
@@ -1,992 +0,0 @@
"""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.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)
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._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._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,
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["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)
-56
View File
@@ -1,56 +0,0 @@
"""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())
@@ -1,44 +0,0 @@
"""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))
-73
View File
@@ -1,73 +0,0 @@
"""Shared fixtures for the customer app UI tests.
The tests render real Qt widgets with the offscreen platform plugin, so they need
no display and no camera hardware. A single ``QApplication`` is shared by the
whole session (Qt forbids creating more than one).
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
_fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
if _fonts.is_dir():
os.environ["QT_QPA_FONTDIR"] = str(_fonts)
@pytest.fixture(scope="session")
def qt_app():
from PyQt5.QtWidgets import QApplication
app = QApplication.instance() or QApplication([])
yield app
@pytest.fixture
def make_window(qt_app, tmp_path, monkeypatch):
"""Build a ``CustomerMainWindow`` whose preference writes stay in tmp_path."""
from orisys_app_shared import prefs as prefs_pkg
from orisys_app_shared import paths as paths_mod
from orisys_customer.app_window import CustomerMainWindow
prefs_file = tmp_path / "preference.json"
monkeypatch.setattr(paths_mod, "preference_path", lambda: prefs_file)
monkeypatch.setattr(prefs_pkg.store, "preference_path", lambda: prefs_file)
default_prefs = {
"language": "zh_CN",
"theme": "dark",
"sensor_type": "box",
"source_mode": "camera",
}
created = []
def _make(**overrides):
data = dict(default_prefs)
data.update(overrides)
window = CustomerMainWindow(prefs=data)
created.append(window)
return window
yield _make
for window in created:
try:
window.close()
except Exception:
pass
def visible_descendants(widget):
"""Yield every visible descendant widget of ``widget``."""
for child in widget.findChildren(object):
if not hasattr(child, "isVisible") or not hasattr(child, "geometry"):
continue
if child.isVisible():
yield child
-76
View File
@@ -1,76 +0,0 @@
"""Static checks: translation keys and preference defaults stay in sync."""
from __future__ import annotations
import json
import re
from pathlib import Path
APP_ROOT = Path(__file__).resolve().parents[1]
SRC_DIR = APP_ROOT / "src"
LOCALES_DIR = APP_ROOT / "config" / "locales"
DEFAULTS_JSON = APP_ROOT / "config" / "preferences.default.json"
REPO_ROOT = APP_ROOT.parents[1]
STORE_PY = REPO_ROOT / "shared" / "src" / "orisys_app_shared" / "prefs" / "store.py"
_T_CALL = re.compile(r"""\bt\(\s*["']([A-Za-z0-9_.]+)["']""")
_DEFAULT_BLOCK = re.compile(r"_DEFAULTS[^=]*=\s*\{(?P<body>.*?)\n\}", re.DOTALL)
_KEY = re.compile(r"""["']([A-Za-z0-9_]+)["']\s*:""")
def _load_locale(name: str) -> dict:
return json.loads((LOCALES_DIR / f"{name}.json").read_text(encoding="utf-8"))
def _flatten(data: dict, prefix: str = "") -> set[str]:
keys: set[str] = set()
for key, value in data.items():
dotted = f"{prefix}{key}"
if isinstance(value, dict):
keys |= _flatten(value, f"{dotted}.")
else:
keys.add(dotted)
return keys
def _referenced_keys() -> set[str]:
found: set[str] = set()
for path in sorted(SRC_DIR.rglob("*.py")):
found |= set(_T_CALL.findall(path.read_text(encoding="utf-8")))
return found
def test_locales_parity_and_used_keys():
zh = _flatten(_load_locale("zh_CN"))
en = _flatten(_load_locale("en_US"))
assert zh == en, {
"zh_only": sorted(zh - en),
"en_only": sorted(en - zh),
}
referenced = _referenced_keys()
assert referenced, "no t() calls found — the source scan is broken"
missing = sorted(key for key in referenced if key not in zh)
assert not missing, f"t() keys missing from locale files: {missing}"
def test_preference_defaults_match_store_defaults():
json_keys = set(json.loads(DEFAULTS_JSON.read_text(encoding="utf-8")))
source = STORE_PY.read_text(encoding="utf-8")
match = _DEFAULT_BLOCK.search(source)
assert match, "could not locate _DEFAULTS in prefs/store.py"
store_keys = set(_KEY.findall(match.group("body")))
assert json_keys == store_keys, {
"json_only": sorted(json_keys - store_keys),
"store_only": sorted(store_keys - json_keys),
}
def test_locale_files_are_valid_utf8_json():
for name in ("zh_CN", "en_US"):
data = _load_locale(name)
assert isinstance(data, dict)
assert data.get("app", {}).get("title")
-171
View File
@@ -1,171 +0,0 @@
"""Offscreen UI smoke tests: construction, layout adaptation, theme tokens."""
from __future__ import annotations
import pytest
from tests.conftest import visible_descendants
RESOLUTIONS = ((1280, 720), (1600, 900), (1920, 1080))
def _show(window, size):
window.resize(*size)
window.show()
from PyQt5.QtWidgets import QApplication
QApplication.processEvents()
return window
@pytest.mark.parametrize(("theme", "sensor_type"), [
("dark", "box"),
("light", "box"),
("dark", "finger"),
("light", "finger"),
])
def test_window_builds_in_every_theme(make_window, theme, sensor_type):
window = _show(make_window(theme=theme, sensor_type=sensor_type), (1400, 880))
assert window.windowTitle()
assert window.content_splitter.count() == 4
@pytest.mark.parametrize("size", RESOLUTIONS)
def test_panels_fit_within_window(make_window, size):
window = _show(
make_window(sensor_type="finger", show_cal_panel=True),
size,
)
sizes = window.content_splitter.sizes()
panels = [
window.mesh3d_panel,
window.arrow_panel,
window.force_plots,
window.cal_panel,
]
for panel, width in zip(panels, sizes):
assert panel.isVisible()
assert width >= panel.minimumWidth(), (
f"{panel.objectName() or type(panel).__name__} too narrow: {width}"
)
assert window.force_plots.width() >= window.force_plots.minimumWidth()
assert window.arrow_panel.width() >= window.arrow_panel.minimumWidth()
@pytest.mark.parametrize("size", RESOLUTIONS)
def test_children_stay_inside_their_parent(make_window, size):
window = _show(make_window(sensor_type="finger", show_cal_panel=True), size)
oversize = []
for child in visible_descendants(window):
parent = child.parentWidget()
if parent is None or not parent.isVisible():
continue
geometry = child.geometry()
if geometry.width() > parent.width() + 1 or geometry.height() > parent.height() + 1:
oversize.append(
f"{type(child).__name__} {geometry.width()}x{geometry.height()}"
f" > parent {parent.width()}x{parent.height()}"
)
assert not oversize, oversize
def test_theme_tokens_have_identical_keys():
from orisys_app_shared.theme import build_qss, load_colors
dark = set(load_colors("dark"))
light = set(load_colors("light"))
assert dark == light, {"dark_only": sorted(dark - light), "light_only": sorted(light - dark)}
assert len(build_qss("dark")) > 1000
assert len(build_qss("light")) > 1000
def test_arrow_canvas_differs_from_accent():
"""The pure-color arrow canvas must not collide with the accent pen color."""
from orisys_app_shared.theme import load_colors
for theme in ("dark", "light"):
colors = load_colors(theme)
assert colors["arrow_canvas"] != colors["accent"], theme
def test_disabled_buttons_use_muted_text_not_hover_background():
from orisys_app_shared.theme import build_qss, load_colors
for theme in ("dark", "light"):
colors = load_colors(theme)
qss = build_qss(theme)
assert f"color: {colors['text_muted']}" in qss, theme
# The old rule painted disabled buttons with the brighter hover color.
assert f"background-color: {colors['button_hover']};\n border-color: {colors['panel_border']}" not in qss
def test_splitter_sizes_round_trip_through_prefs(make_window):
window = _show(make_window(), (1440, 880))
window.content_splitter.setSizes([300, 420, 520, 0])
window._persist_prefs()
stored = window.prefs["splitter_sizes"]
assert len(stored) == 4
assert stored[2] > stored[0]
def test_status_chip_tracks_phase(make_window):
window = _show(make_window(), (1400, 880))
assert window._session_phase == "idle"
window._set_phase("running")
assert window.status_chip.text()
assert "status_running" not in window.status_chip.text()
window._set_phase("nonexistent-phase")
assert window._session_phase == "idle"
def test_run_tooltip_explains_disabled_reason(make_window):
window = _show(make_window(source_mode="video", video_path=""), (1400, 880))
assert not window.toolbar.btn_run.isEnabled()
assert window.toolbar.btn_run.toolTip()
window.toolbar.set_video_path("C:/tmp/demo.mp4")
window._update_run_enabled()
assert window.toolbar.btn_run.isEnabled()
def test_toolbar_run_button_is_labelled(make_window):
window = _show(make_window(), (1400, 880))
label = window.toolbar.btn_run.text()
assert label.strip()
assert label != "▶"
window.toolbar.set_run_checked(True)
assert window.toolbar.btn_run.text() != label
def test_theme_switch_updates_panels(make_window):
window = _show(make_window(), (1400, 880))
window._set_theme("light")
assert window._theme == "light"
window._set_theme("dark")
assert window._theme == "dark"
def test_cal_panel_records_and_lists_samples(make_window):
window = _show(make_window(), (1400, 880))
cal = window.cal_panel
cal.set_running(True)
assert cal.btn_record.isEnabled()
assert cal.btn_record.text()
from orisys_app_shared.force.calibration_io import CalSample
cal._store.upsert("fn", CalSample(weight_n=1.0, raw_mean=9.7, n=120, std=0.02))
cal._refresh_table()
assert cal.table.rowCount() == 1
assert not cal.empty_hint.isVisible()
def test_force_plots_render_demo_data(make_window):
window = _show(make_window(), (1440, 880))
for i in range(60):
window.force_plots.push(i / 30.0, float(i), -1.0, 1.0, unit="N")
from PyQt5.QtWidgets import QApplication
QApplication.processEvents()
assert window.force_plots.history.sample_count > 0
assert all(label.text() for label in window.force_plots.value_labels)
-213
View File
@@ -1,213 +0,0 @@
#!/usr/bin/env python3
"""Dependency-free smoke run for the customer UI.
``pytest`` is the canonical runner (``apps/customer/tests``), but this script
needs nothing beyond the app's own runtime dependencies, so it can be used in
minimal environments:
python tools/ui_check.py
It builds the real main window offscreen and asserts the layout, theming and
status-feedback invariants that the UI work depends on.
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
APP_ROOT = Path(__file__).resolve().parents[1]
REPO_ROOT = APP_ROOT.parents[1]
for _path in (APP_ROOT / "src", REPO_ROOT / "shared" / "src"):
if _path.is_dir() and str(_path) not in sys.path:
sys.path.insert(0, str(_path))
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
_fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
if _fonts.is_dir():
os.environ["QT_QPA_FONTDIR"] = str(_fonts)
os.chdir(APP_ROOT)
FAILURES: list[str] = []
CHECKS = 0
def check(condition: bool, message: str) -> None:
global CHECKS
CHECKS += 1
if not condition:
FAILURES.append(message)
def section(name: str) -> None:
print(f"\n== {name} ==")
def main() -> int:
from PyQt5.QtWidgets import QApplication
from orisys_app_shared.force.calibration_io import CalSample
from orisys_app_shared.i18n import set_locale
from orisys_app_shared.theme import build_qss, load_colors
from orisys_customer.app_window import CustomerMainWindow
app = QApplication.instance() or QApplication(sys.argv)
set_locale("zh_CN")
section("theme tokens")
dark = set(load_colors("dark"))
light = set(load_colors("light"))
check(dark == light, f"token keys differ: {sorted(dark ^ light)}")
for theme in ("dark", "light"):
qss = build_qss(theme)
check(len(qss) > 1000, f"{theme} qss suspiciously short")
check(
load_colors(theme)["arrow_canvas"] != load_colors(theme)["accent"],
f"{theme}: arrow canvas collides with accent",
)
check("QProgressBar::chunk" in qss, f"{theme}: progress bar not styled")
check("QHeaderView::section" in qss, f"{theme}: table header not styled")
check("QRadioButton::indicator" in qss, f"{theme}: radio not styled")
check("QScrollBar::handle" in qss, f"{theme}: scrollbar not styled")
scene_count = 0
for theme in ("dark", "light"):
for sensor in ("box", "finger"):
section(f"window {theme}/{sensor}")
scene_count += 1
app.setStyleSheet(build_qss(theme))
window = CustomerMainWindow(
prefs={
"language": "zh_CN",
"theme": theme,
"sensor_type": sensor,
"show_cal_panel": True,
}
)
window.resize(1440, 880)
window.show()
app.processEvents()
check(window.windowTitle() != "", "window title empty")
check(
window.status_chip.text() != "",
"status chip has no phase text",
)
check(
window.toolbar.btn_run.text().strip() not in ("", "▶"),
f"run button is unlabelled: {window.toolbar.btn_run.text()!r}",
)
panels = [
window.mesh3d_panel,
window.arrow_panel,
window.force_plots,
window.cal_panel,
]
for panel, width in zip(panels, window.content_splitter.sizes()):
if panel.isVisible():
check(
width >= panel.minimumWidth(),
f"{type(panel).__name__} width {width} < min {panel.minimumWidth()}",
)
check(
window.force_plots.width() >= window.force_plots.minimumWidth(),
"force plots narrower than its minimum",
)
# value cards must keep their text visible
for card, label in zip(
window.force_plots.value_cards, window.force_plots.value_labels
):
check(
label.height() >= label.fontMetrics().height(),
"value card text is clipped",
)
check(label.text() != "", "value card is empty")
# demo data flows through the plots and the calibration table
for i in range(90):
window.force_plots.push(i / 30.0, float(i % 7), -1.0, 1.0, unit="N")
app.processEvents()
check(
window.force_plots.history.sample_count > 0,
"plots did not accept samples",
)
window.cal_panel.set_running(True)
check(window.cal_panel.btn_record.isEnabled(), "record button stays disabled")
window.cal_panel._store.upsert(
"fn", CalSample(weight_n=1.0, raw_mean=9.7, n=120, std=0.02)
)
window.cal_panel._refresh_table()
check(window.cal_panel.table.rowCount() == 1, "calibration row missing")
check(
not window.cal_panel.empty_hint.isVisible(),
"empty hint still visible with rows",
)
window._set_phase("running")
check("status_running" not in window.status_chip.text(), "chip not localized")
window._set_theme("light" if theme == "dark" else "dark")
app.processEvents()
window._persist_prefs()
check(
len(window.prefs.get("splitter_sizes", [])) == 4,
"splitter sizes not persisted",
)
window.close()
app.processEvents()
section("small window")
app.setStyleSheet(build_qss("dark"))
window = CustomerMainWindow(
prefs={
"language": "en_US",
"theme": "dark",
"sensor_type": "finger",
"show_cal_panel": True,
"show_finger_3d": True,
}
)
window.resize(1280, 720)
window.show()
app.processEvents()
for panel, width in zip(
[
window.mesh3d_panel,
window.arrow_panel,
window.force_plots,
window.cal_panel,
],
window.content_splitter.sizes(),
):
if panel.isVisible():
check(width >= panel.minimumWidth(), f"1280px: {type(panel).__name__} squeezed")
oversize = []
for child in window.findChildren(object):
if not hasattr(child, "isVisible") or not child.isVisible():
continue
if not hasattr(child, "geometry"):
continue
parent = child.parentWidget()
if parent is None or not parent.isVisible():
continue
geometry = child.geometry()
if geometry.width() > parent.width() + 1 or geometry.height() > parent.height() + 1:
oversize.append(type(child).__name__)
check(not oversize, f"widgets overflow their parent: {oversize}")
window.close()
app.processEvents()
print(f"\nscenes: {scene_count} checks: {CHECKS} failures: {len(FAILURES)}")
for failure in FAILURES:
print(f" FAIL: {failure}")
return 1 if FAILURES else 0
if __name__ == "__main__":
raise SystemExit(main())
-118
View File
@@ -1,118 +0,0 @@
#!/usr/bin/env python3
"""Render the customer UI offscreen to PNG files for visual review.
The tool never opens a camera and never starts acquisition: it builds the real
main window, optionally injects deterministic demo data, then grabs the window.
Examples
--------
python tools/ui_snapshot.py --theme dark --out out_dark.png
python tools/ui_snapshot.py --theme light --sensor finger \
--panels cal,3d --demo --size 1600x900 --out out_light.png
"""
from __future__ import annotations
import argparse
import math
import os
import sys
from pathlib import Path
APP_ROOT = Path(__file__).resolve().parents[1]
REPO_ROOT = APP_ROOT.parents[1]
for _path in (APP_ROOT / "src", REPO_ROOT / "shared" / "src"):
if _path.is_dir() and str(_path) not in sys.path:
sys.path.insert(0, str(_path))
# Silent platform must be selected before QApplication exists.
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts"
if fonts.is_dir():
os.environ["QT_QPA_FONTDIR"] = str(fonts)
os.chdir(APP_ROOT)
def _demo_data(window) -> None:
"""Fill the plots and the calibration table with deterministic samples."""
plots = window.force_plots
for i in range(240):
t = i / 30.0
fn = 6.0 + 5.0 * math.sin(t * 1.4)
fx = 4.0 * math.sin(t * 2.1 + 0.4)
fy = -3.0 + 2.5 * math.cos(t * 1.1)
plots.push(t, fn, fx, fy, unit="N")
cal = window.cal_panel
cal.set_running(True)
cal.push_raw(9.81, -0.42, 0.10)
for weight, raw, std in ((1.0, 9.72, 0.031), (2.0, 19.51, 0.044), (5.0, 48.90, 0.061)):
from orisys_app_shared.force.calibration_io import CalSample
cal._store.upsert("fn", CalSample(weight_n=weight, raw_mean=raw, n=180, std=std))
cal._refresh_table()
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--theme", choices=("dark", "light"), default="dark")
parser.add_argument("--lang", choices=("zh_CN", "en_US"), default="zh_CN")
parser.add_argument("--sensor", choices=("box", "mini", "finger"), default="box")
parser.add_argument(
"--panels",
default="",
help="comma list of optional panels to show: cal,3d",
)
parser.add_argument("--size", default="1440x880", help="WxH window size")
parser.add_argument("--view", default="arrows_pure")
parser.add_argument("--demo", action="store_true", help="inject demo plot/table data")
parser.add_argument("--out", required=True, help="output PNG path")
args = parser.parse_args()
try:
width, height = (int(part) for part in args.size.lower().split("x", 1))
except ValueError:
parser.error("--size must look like 1440x880")
panels = {part.strip().lower() for part in args.panels.split(",") if part.strip()}
from PyQt5.QtWidgets import QApplication
from orisys_app_shared.i18n import set_locale
from orisys_app_shared.theme import build_qss
from orisys_customer.app_window import CustomerMainWindow
app = QApplication(sys.argv)
set_locale(args.lang)
app.setStyleSheet(build_qss(args.theme))
prefs = {
"language": args.lang,
"theme": args.theme,
"sensor_type": args.sensor,
"show_cal_panel": "cal" in panels,
"show_finger_3d": "3d" in panels,
"show_fps": True,
"image_view_mode": args.view,
}
window = CustomerMainWindow(prefs=prefs)
window.resize(width, height)
window.show()
app.processEvents()
if args.demo:
_demo_data(window)
for _ in range(4):
app.processEvents()
out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
ok = window.grab().save(str(out))
print(f"{'saved' if ok else 'FAILED'} {out} ({width}x{height}, theme={args.theme})")
window.close()
return 0 if ok else 1
if __name__ == "__main__":
raise SystemExit(main())
+2 -2
View File
@@ -2,7 +2,7 @@
"app": {
"title": "Orisys Studio",
"about_title": "About",
"about_body": "Orisys customer desktop application\nVersion: {app_version}",
"about_body": "Orisys Studio desktop application\nVersion: {app_version}",
"scaffold_hint": "Scaffold shell — acquisition coming later"
},
"brand": {
@@ -70,7 +70,7 @@
"raw_short": "raw"
},
"sensor": {
"type": "Sensor type",
"type": "Product",
"config": "Config",
"thumb_placeholder": "Product model"
},
+2 -2
View File
@@ -2,7 +2,7 @@
"app": {
"title": "Orisys Studio",
"about_title": "关于",
"about_body": "Orisys 客户端桌面应用\n版本: {app_version}",
"about_body": "Orisys Studio 桌面应用\n版本: {app_version}",
"scaffold_hint": "脚手架界面 — 采集功能稍后加入"
},
"brand": {
@@ -70,7 +70,7 @@
"raw_short": "raw"
},
"sensor": {
"type": "传感器类型",
"type": "产品",
"config": "配置",
"thumb_placeholder": "产品型号"
},
+4 -4
View File
@@ -1,4 +1,4 @@
# Orisys Customer 配色规范
# Orisys Studio 配色规范
本文件记录客户端的品牌配色体系、它与 UI 设计稿的关系,以及每一处有意偏离设计稿
的理由。所有色值都存放在主题令牌文件里,控件代码中**不允许出现十六进制颜色**:
@@ -8,7 +8,7 @@ orisys-app/shared/src/orisys_app_shared/theme/colors_dark.json
orisys-app/shared/src/orisys_app_shared/theme/colors_light.json
```
规则由 `apps/customer/tools/ui_check.py` 的 `audit_palette()` 机械校验,pytest 套件
规则由 `apps/studio/tools/ui_check.py` 的 `audit_palette()` 机械校验,pytest 套件
(`tests/test_ui_smoke.py::test_palette_rules_hold`)复用它,因此只有一个真值来源。
---
@@ -152,8 +152,8 @@ flow_ramp = 画布色, #6E4AD0, #FA02F3, #FF8A6B, #D8FF00
```powershell
$py = "D:\orisys_ws\orisys-app\.venv-build\Scripts\python.exe"
$env:PYTHONPATH = "D:\orisys_ws\orisys-app\shared\src;D:\orisys_ws\orisys-app\apps\customer\src"
cd D:\orisys_ws\orisys-app\apps\customer
$env:PYTHONPATH = "D:\orisys_ws\orisys-app\shared\src;D:\orisys_ws\orisys-app\apps\studio\src"
cd D:\orisys_ws\orisys-app\apps\studio
& $py tools/ui_check.py # 含 palette 规则段落
& $py tools/ui_snapshot.py --theme dark --lang zh_CN --demo --out outputs/_snap_dark.png
+3 -3
View File
@@ -3,9 +3,9 @@ requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "orisys-customer"
name = "orisys-studio"
version = "0.1.0"
description = "Orisys customer desktop app"
description = "Orisys Studio desktop app"
requires-python = ">=3.10"
authors = [{ name = "Orisys-Tech" }]
dependencies = [
@@ -17,7 +17,7 @@ dependencies = [
]
[project.scripts]
orisys-customer = "orisys_customer.main:main"
orisys-studio = "orisys_studio.main:main"
[tool.setuptools]
package-dir = { "" = "src" }
+2 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Launch the Orisys customer app shell."""
"""Launch the Orisys Studio app shell."""
from __future__ import annotations
@@ -18,7 +18,7 @@ import os
os.chdir(ROOT)
from orisys_customer.main import main
from orisys_studio.main import main
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,4 +0,0 @@
"""Orisys customer desktop application."""
__version__ = "0.1.0"
REQUIRES_SHARED_API = "0.1"
@@ -1 +0,0 @@
"""UI package."""
@@ -1,189 +0,0 @@
"""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, QTransform
from PyQt5.QtWidgets import (
QComboBox,
QFrame,
QHBoxLayout,
QLabel,
QSizePolicy,
QVBoxLayout,
)
from orisys_app_shared.i18n import t
VIEW_MODES = ("arrows_pure", "arrows_stitched", "depth")
_ROTATION_STEPS = (0, 90, 180, 270)
def _normalize_rotation_deg(value: object, default: int = 90) -> int:
try:
deg = int(value) % 360 # type: ignore[arg-type]
except (TypeError, ValueError):
return default
if deg not in _ROTATION_STEPS:
deg = int(round(deg / 90.0) * 90) % 360
return deg
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._rotation_deg = 90
self._rotation_enabled = True
self._source_image: QImage | 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(240, 180)
self.image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.image_label.setScaledContents(False)
self.image_label.setWordWrap(True)
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 rotation_deg(self) -> int:
return self._rotation_deg
def set_rotation_deg(self, deg: object) -> None:
self._rotation_deg = _normalize_rotation_deg(deg)
self._repaint_source()
def set_rotation_enabled(self, enabled: bool) -> None:
wanted = bool(enabled)
if wanted == self._rotation_enabled:
return
self._rotation_enabled = wanted
self._repaint_source()
def rotate_90_ccw(self) -> int:
if not self._rotation_enabled:
return self._rotation_deg
self.set_rotation_deg(self._rotation_deg + 90)
return self._rotation_deg
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"))
self.image_label.setToolTip(t("panels.arrow_empty_tip"))
if not self.image_label.pixmap() or self.image_label.pixmap().isNull():
self._placeholder = t("panels.arrow_empty")
self.image_label.setText(self._placeholder)
def _effective_rotation_deg(self) -> int:
if not self._rotation_enabled:
return 0
return self._rotation_deg
def _repaint_source(self) -> None:
if self._source_image is None or self._source_image.isNull():
return
self._paint_image(self._source_image)
def _paint_image(self, image: QImage) -> None:
pix = QPixmap.fromImage(image)
deg = self._effective_rotation_deg()
if deg:
# QTransform.rotate is clockwise on screen pixmaps; negate for CCW.
pix = pix.transformed(QTransform().rotate(-deg), Qt.FastTransformation)
scaled = pix.scaled(
self.image_label.size(),
Qt.KeepAspectRatio,
Qt.SmoothTransformation,
)
self.image_label.setPixmap(scaled)
self.image_label.setText("")
def show_frame(self, image: QImage) -> None:
if image.isNull():
return
self._source_image = image
self._paint_image(image)
def clear_preview(self, message: str | None = None) -> None:
self._source_image = None
self.image_label.clear()
self.image_label.setPixmap(QPixmap())
text = message if message is not None else t("panels.arrow_empty")
self._placeholder = text
self.image_label.setText(text)
self.set_fps(None)
@@ -1,440 +0,0 @@
"""Force calibration sampling panel (FN / FX / FY)."""
from __future__ import annotations
import math
import time
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtGui import QFont, QFontDatabase
from PyQt5.QtWidgets import (
QAbstractItemView,
QButtonGroup,
QDoubleSpinBox,
QFormLayout,
QFrame,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMessageBox,
QProgressBar,
QPushButton,
QRadioButton,
QSizePolicy,
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
from orisys_app_shared.theme import color
CHANNEL_LABELS = ("fn", "fx", "fy")
def _mono_font() -> str:
families = set(QFontDatabase().families())
for name in ("Consolas", "Cascadia Mono", "DejaVu Sans Mono", "Courier New"):
if name in families:
return name
return "monospace"
class CalPanel(QFrame):
status_message = pyqtSignal(str)
calibration_exported = pyqtSignal(str) # path applied to running sensor
def __init__(self, parent=None, *, theme: str = "dark") -> None:
super().__init__(parent)
self.setObjectName("contentPanel")
self._theme = theme
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
self._mono = _mono_font()
self._mono_font = QFont(self._mono, 10)
root = QVBoxLayout(self)
root.setSpacing(8)
title_row = QHBoxLayout()
self.title = QLabel()
self.title.setObjectName("panelTitle")
self.channel_badge = QLabel()
self.channel_badge.setObjectName("unitBadge")
title_row.addWidget(self.title)
title_row.addStretch(1)
title_row.addWidget(self.channel_badge)
root.addLayout(title_row)
channel_row = QHBoxLayout()
channel_row.setSpacing(10)
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()
form.setHorizontalSpacing(10)
form.setVerticalSpacing(6)
form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter)
self._weight_label = QLabel()
self._weight_label.setObjectName("fieldLabel")
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_label.setObjectName("fieldLabel")
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.setMinimumHeight(32)
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)
self.live_label.setFont(self._mono_font)
root.addWidget(self.live_label)
self.empty_hint = QLabel()
self.empty_hint.setObjectName("hintText")
self.empty_hint.setWordWrap(True)
root.addWidget(self.empty_hint)
self.table = QTableWidget(0, 4)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.table.setAlternatingRowColors(True)
self.table.setShowGrid(False)
header = self.table.horizontalHeader()
header.setSectionResizeMode(QHeaderView.Stretch)
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
self.table.verticalHeader().setVisible(False)
self.table.verticalHeader().setDefaultSectionSize(26)
root.addWidget(self.table, 1)
sn_row = QHBoxLayout()
self._sn_label = QLabel()
self._sn_label.setObjectName("fieldLabel")
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()
actions.setSpacing(6)
self.btn_delete = QPushButton()
self.btn_clear_channel = QPushButton()
self.btn_clear_all = QPushButton()
self.btn_export = QPushButton()
self.btn_export.setObjectName("primaryButton")
for button in (self.btn_delete, self.btn_clear_channel, self.btn_clear_all):
# The action row must survive a narrow three-panel layout.
button.setProperty("compact", "true")
button.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
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.set_theme(theme)
self.retranslate()
# ------------------------------------------------------------------ API
def set_theme(self, theme: str) -> None:
self._theme = theme
self._render_live_label()
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._render_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.recording_stop") if self._recording else 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"))
self.empty_hint.setText(t("cal.empty_hint"))
headers = (
t("cal.col_weight"),
t("cal.col_raw"),
t("cal.col_n"),
t("cal.col_std"),
)
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._refresh_channel_badge()
self._render_live_label()
self._refresh_enabled()
# ------------------------------------------------------------- internals
def _refresh_channel_badge(self) -> None:
color_hex = color(self._theme, f"plot_{self._channel}")
self.channel_badge.setText(self._channel.upper())
self.channel_badge.setStyleSheet(
f"QLabel#unitBadge {{ color: {color_hex};"
f" border: 1px solid {color(self._theme, 'card_border')};"
f" background-color: {color(self._theme, 'card_bg')}; }}"
)
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_channel_badge()
self._refresh_table()
def _refresh_enabled(self) -> None:
can_record = self._running and not self._recording
self.btn_record.setEnabled(can_record)
self.btn_record.setObjectName("dangerButton" if self._recording else "primaryButton")
self.btn_record.style().unpolish(self.btn_record)
self.btn_record.style().polish(self.btn_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)
if self._recording:
self.progress.setVisible(True)
record_hint = t("cal.record_disabled") if not self._running else ""
if not self._recording:
self.btn_record.setToolTip(record_hint)
def _render_live_label(self) -> None:
colors = {
"fn": color(self._theme, "plot_fn"),
"fx": color(self._theme, "plot_fx"),
"fy": color(self._theme, "plot_fy"),
}
parts = []
for key, value in (
("fn", self._live_fn),
("fx", self._live_fx),
("fy", self._live_fy),
):
parts.append(
f"<span style='color:{colors[key]}; font-weight:600'>{key.upper()}</span>"
f"&nbsp;{value:.3f}"
)
self.live_label.setText(
f"<span style='color:{color(self._theme, 'text_secondary')}'>"
f"{t('cal.live_raw_label')}</span>&nbsp;&nbsp;" + "&nbsp;&nbsp;&nbsp;".join(parts)
)
def _on_record_clicked(self) -> None:
if self._recording:
self._cancel_recording()
self.status_message.emit(t("cal.record_cancelled"))
return
if not self._running:
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.setVisible(True)
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.progress.setFormat("")
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)
if c != 2:
item.setTextAlignment(int(Qt.AlignRight | Qt.AlignVCenter))
else:
item.setTextAlignment(int(Qt.AlignCenter))
self.table.setItem(r, c, item)
self.empty_hint.setVisible(not rows)
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)
# Prefer the bundle file for live apply so the next Sensor() also sees it.
apply_path = local_path if local_path is not None else primary
self.calibration_exported.emit(str(apply_path))
QMessageBox.information(self, t("panels.cal"), msg)
@@ -1,288 +0,0 @@
"""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, QFont, QFontDatabase
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.force.history import DEFAULT_MAX_FPS
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
CHANNEL_KEYS = ("fn", "fx", "fy")
CHANNEL_LABELS = ("FN", "FX", "FY")
# Theme token that colors each channel curve / label, per index.
CHANNEL_TOKENS = ("plot_fn", "plot_fx", "plot_fy")
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)]
def format_force(value: float, unit: str) -> str:
"""Render one force reading for the value cards."""
if unit == "N":
return f"{value:.3f}"
return f"{value:.1f}"
def unit_display(unit: str) -> str:
return t("units.newton_short") if unit == "N" else t("units.raw_short")
def _mono_font(point_size: int = 9, *, bold: bool = False) -> QFont:
"""Pick the first available monospace face for stable digit alignment."""
families = set(QFontDatabase().families())
chosen = "monospace"
for name in ("Consolas", "Cascadia Mono", "DejaVu Sans Mono", "Courier New"):
if name in families:
chosen = name
break
font = QFont(chosen, point_size)
font.setBold(bold)
return font
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._last_values: tuple[float, float, float] = (0.0, 0.0, 0.0)
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,
max_fps=DEFAULT_MAX_FPS,
)
root = QVBoxLayout(self)
root.setContentsMargins(10, 8, 10, 10)
root.setSpacing(6)
header = QHBoxLayout()
header.setSpacing(6)
self.title = QLabel()
self.title.setObjectName("panelTitle")
header.addWidget(self.title)
header.addStretch(1)
self.unit_badge = QLabel()
self.unit_badge.setObjectName("unitBadge")
header.addWidget(self.unit_badge)
root.addLayout(header)
self.plot_frame = QFrame()
self.plot_frame.setObjectName("cardPanel")
plot_layout = QVBoxLayout(self.plot_frame)
plot_layout.setContentsMargins(4, 4, 4, 4)
self.plot_widget = pg.GraphicsLayoutWidget()
self.plot_widget.ci.setSpacing(2)
plot_layout.addWidget(self.plot_widget)
root.addWidget(self.plot_frame, 1)
self.plots = []
self.curves = []
pen = pg.mkPen(color(theme, CHANNEL_TOKENS[0]), width=2)
for i, _name in enumerate(CHANNEL_LABELS):
plot = self.plot_widget.addPlot(row=i, col=0)
plot.showGrid(x=True, y=True, alpha=0.25)
plot.setMouseEnabled(x=False, y=False)
if i > 0:
plot.setXLink(self.plots[0])
curve = plot.plot(pen=pen)
self.plots.append(plot)
self.curves.append(curve)
values_row = QHBoxLayout()
values_row.setSpacing(6)
self.value_cards: list[QFrame] = []
self.value_labels: list[QLabel] = []
self.channel_badges: list[QLabel] = []
self.value_units: list[QLabel] = []
for label in CHANNEL_LABELS:
card, badge, value_label, unit_label = self._build_value_card(label)
values_row.addWidget(card, 1)
self.value_cards.append(card)
self.channel_badges.append(badge)
self.value_labels.append(value_label)
self.value_units.append(unit_label)
root.addLayout(values_row)
self.set_theme(theme)
self._apply_axis_mode()
self.retranslate()
def _build_value_card(self, channel: str) -> tuple[QFrame, QLabel, QLabel, QLabel]:
card = QFrame()
card.setObjectName("cardPanel")
layout = QVBoxLayout(card)
layout.setContentsMargins(7, 4, 7, 4)
layout.setSpacing(1)
top = QHBoxLayout()
top.setSpacing(6)
badge = QLabel(channel)
badge.setObjectName("unitBadge")
top.addWidget(badge)
top.addStretch(1)
unit_label = QLabel()
unit_label.setObjectName("hintText")
top.addWidget(unit_label)
value_label = QLabel()
value_label.setObjectName("valueCard")
value_label.setFixedHeight(20)
value_label.setContentsMargins(6, 0, 6, 0)
value_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
value_label.setFont(_mono_font(9, bold=True))
layout.addLayout(top)
layout.addWidget(value_label)
return card, badge, value_label, unit_label
def set_theme(self, theme: str) -> None:
self._theme = theme
bg = color(theme, "plot_bg")
text = color(theme, "text_primary")
self.plot_widget.setBackground(bg)
axis_pen = pg.mkPen(text)
for i, plot in enumerate(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)
# Color each channel axis label to match its curve pen.
plot.setLabel("left", CHANNEL_LABELS[i], color=color(theme, CHANNEL_TOKENS[i]))
pens = tuple(color(theme, token) for token in CHANNEL_TOKENS)
for curve, pen in zip(self.curves, pens):
curve.setPen(pg.mkPen(pen, width=2))
for badge, token in zip(self.channel_badges, CHANNEL_TOKENS):
badge.setStyleSheet(
f"QLabel#unitBadge {{ color: {color(theme, token)};"
f" background-color: {color(theme, 'card_bg')};"
f" border: 1px solid {color(theme, 'card_border')}; }}"
)
for value_label, token in zip(self.value_labels, CHANNEL_TOKENS):
value_label.setStyleSheet(
f"QLabel#valueCard {{ color: {color(theme, token)}; }}"
)
self._render_value_cards()
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()
self._last_values = (0.0, 0.0, 0.0)
self._unit = "raw"
self._render_value_cards()
self._apply_axis_mode()
def push(
self,
timestamp: float,
fn: float,
fx: float,
fy: float,
*,
unit: str = "raw",
) -> None:
self._unit = unit
self._last_values = (float(fn), float(fx), float(fy))
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._render_value_cards()
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 _render_value_cards(self) -> None:
fmt = "{value:.3f}" if self._unit == "N" else "{value:.1f}"
for value_label, unit_label, value in zip(
self.value_labels, self.value_units, self._last_values
):
value_label.setText(fmt.format(value=value))
unit_label.setText(unit_display(self._unit))
self.unit_badge.setText(unit_display(self._unit))
def retranslate(self) -> None:
self.title.setText(t("panels.plots"))
for badge, label in zip(self.channel_badges, CHANNEL_LABELS):
badge.setText(label)
self._render_value_cards()
@@ -1,35 +0,0 @@
"""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)
header = QVBoxLayout()
header.setSpacing(2)
self.title = QLabel()
self.title.setObjectName("panelTitle")
header.addWidget(self.title)
layout.addLayout(header)
self.body = QLabel()
self.body.setObjectName("hintText")
self.body.setAlignment(Qt.AlignCenter)
self.body.setWordWrap(True)
layout.addWidget(self.body, 1)
self.retranslate()
def retranslate(self) -> None:
self.title.setText(t("panels.mesh3d"))
self.body.setText(t("panels.mesh3d_empty"))
self.body.setToolTip(t("panels.mesh3d_empty_tip"))
@@ -1,114 +0,0 @@
"""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"))
@@ -1,145 +0,0 @@
"""Sensor type + config picker with product badge."""
from __future__ import annotations
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import (
QComboBox,
QFormLayout,
QFrame,
QHBoxLayout,
QLabel,
QSizePolicy,
QVBoxLayout,
QWidget,
)
from orisys_app_shared.i18n import t
from orisys_app_shared.sensors import list_configs_for_type, list_sensor_types
THUMB_SIZE = 56
CONTROL_HEIGHT = 30
MAX_CONTROL_WIDTH = 220
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(8, 6, 8, 6)
layout.setSpacing(10)
self.thumb = QFrame()
self.thumb.setObjectName("cardPanel")
self.thumb.setFixedSize(THUMB_SIZE, THUMB_SIZE)
thumb_layout = QVBoxLayout(self.thumb)
thumb_layout.setContentsMargins(2, 2, 2, 2)
thumb_layout.setSpacing(0)
self.thumb_label = QLabel()
self.thumb_label.setObjectName("unitBadge")
self.thumb_label.setAlignment(Qt.AlignCenter)
self.thumb_label.setWordWrap(False)
thumb_layout.addWidget(self.thumb_label)
form = QFormLayout()
form.setContentsMargins(0, 0, 0, 0)
form.setHorizontalSpacing(8)
form.setVerticalSpacing(6)
form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter)
form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow)
self.type_label = QLabel()
self.type_label.setObjectName("fieldLabel")
self.type_combo = QComboBox()
self.type_combo.setMinimumHeight(CONTROL_HEIGHT)
self.type_combo.setMaximumWidth(MAX_CONTROL_WIDTH)
self.type_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
for name in list_sensor_types():
self.type_combo.addItem(name, name)
self.type_combo.currentIndexChanged.connect(self._on_type_index_changed)
self.config_label = QLabel()
self.config_label.setObjectName("fieldLabel")
self.config_combo = QComboBox()
self.config_combo.setMinimumHeight(CONTROL_HEIGHT)
self.config_combo.setMaximumWidth(MAX_CONTROL_WIDTH)
self.config_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.config_combo.currentIndexChanged.connect(self._on_config_index_changed)
form.addRow(self.type_label, self.type_combo)
form.addRow(self.config_label, self.config_combo)
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 set_picker_enabled(self, enabled: bool) -> None:
"""Enable/disable both pickers (used while a session is running)."""
self.type_combo.setEnabled(bool(enabled))
self.config_combo.setEnabled(bool(enabled))
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:
self.thumb_label.setText(self.sensor_type().upper())
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"))
self.thumb.setToolTip(t("sensor.thumb_placeholder"))
self._refresh_thumb()
-348
View File
@@ -1,348 +0,0 @@
"""Top toolbar: source, camera/video, run/reset, units."""
from __future__ import annotations
from pathlib import Path
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import (
QComboBox,
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QScrollArea,
QSizePolicy,
QSpinBox,
QStackedWidget,
QVBoxLayout,
QWidget,
)
from orisys_app_shared.i18n import t
CONTROL_HEIGHT = 30
BUTTON_HEIGHT = 32
def _separator() -> QFrame:
line = QFrame()
line.setObjectName("sectionSeparator")
line.setFrameShape(QFrame.VLine)
line.setFixedWidth(1)
line.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
return line
def _group(*widgets: QWidget) -> QWidget:
box = QWidget()
layout = QHBoxLayout(box)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
for widget in widgets:
layout.addWidget(widget)
return box
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)
self._run_tooltip_reason = ""
self._run_available = True
outer = QVBoxLayout(self)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(0)
self._scroll = QScrollArea()
self._scroll.setWidgetResizable(True)
self._scroll.setFrameShape(QFrame.NoFrame)
self._scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
outer.addWidget(self._scroll)
content = QWidget()
self._scroll.setWidget(content)
layout = QHBoxLayout(content)
layout.setContentsMargins(2, 2, 2, 2)
layout.setSpacing(10)
# --- source group -------------------------------------------------
self.source_label = QLabel()
self.source_label.setObjectName("fieldLabel")
self.source_combo = QComboBox()
self.source_combo.setMinimumWidth(96)
self.source_combo.setMinimumHeight(CONTROL_HEIGHT)
self.source_combo.currentIndexChanged.connect(self._on_source_index_changed)
self.source_stack = QStackedWidget()
cam_page = QWidget()
cam_layout = QHBoxLayout(cam_page)
cam_layout.setContentsMargins(0, 0, 0, 0)
cam_layout.setSpacing(8)
self.cam_id_label = QLabel()
self.cam_id_label.setObjectName("fieldLabel")
self.cam_id_spin = QSpinBox()
self.cam_id_spin.setRange(0, 20)
self.cam_id_spin.setValue(0)
self.cam_id_spin.setFixedWidth(72)
self.cam_id_spin.setMinimumHeight(CONTROL_HEIGHT)
self.cam_id_spin.valueChanged.connect(self.camera_id_changed.emit)
cam_layout.addWidget(self.cam_id_label)
cam_layout.addWidget(self.cam_id_spin)
video_page = QWidget()
video_layout = QHBoxLayout(video_page)
video_layout.setContentsMargins(0, 0, 0, 0)
video_layout.setSpacing(8)
self.video_edit = QLineEdit()
self.video_edit.setMinimumWidth(180)
self.video_edit.setMinimumHeight(CONTROL_HEIGHT)
self.video_edit.setReadOnly(True)
self.btn_browse_video = QPushButton()
self.btn_browse_video.setMinimumHeight(BUTTON_HEIGHT)
self.btn_browse_video.clicked.connect(self.browse_video_clicked.emit)
video_layout.addWidget(self.video_edit, 1)
video_layout.addWidget(self.btn_browse_video)
self.source_stack.addWidget(cam_page)
self.source_stack.addWidget(video_page)
# --- action group -------------------------------------------------
self.btn_search = QPushButton()
self.btn_search.setMinimumHeight(BUTTON_HEIGHT)
self.btn_search.clicked.connect(self.search_clicked.emit)
self.btn_preview = QPushButton()
self.btn_preview.setMinimumHeight(BUTTON_HEIGHT)
self.btn_preview.setCheckable(True)
self.btn_preview.toggled.connect(self._on_preview_toggled)
self.btn_run = QPushButton()
self.btn_run.setObjectName("primaryButton")
self.btn_run.setMinimumHeight(BUTTON_HEIGHT)
self.btn_run.setMinimumWidth(96)
self.btn_run.setCheckable(True)
self.btn_run.toggled.connect(self._on_run_toggled)
self.btn_reset = QPushButton()
self.btn_reset.setMinimumHeight(BUTTON_HEIGHT)
self.btn_reset.setEnabled(False)
self.btn_reset.clicked.connect(self.reset_clicked.emit)
# --- units group --------------------------------------------------
self.units_label = QLabel()
self.units_label.setObjectName("fieldLabel")
self.units_combo = QComboBox()
self.units_combo.setFixedWidth(96)
self.units_combo.setMinimumHeight(CONTROL_HEIGHT)
self.units_combo.currentIndexChanged.connect(self._on_units_index_changed)
layout.addWidget(
_group(self.source_label, self.source_combo, self.source_stack)
)
layout.addWidget(_separator())
layout.addWidget(
_group(self.btn_search, self.btn_preview, self.btn_run, self.btn_reset)
)
layout.addWidget(_separator())
layout.addWidget(_group(self.units_label, self.units_combo))
layout.addStretch(1)
self._populate_source_combo()
self._populate_units_combo()
self.retranslate()
self._apply_source_mode_ui()
# ------------------------------------------------------------------ API
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)
self.video_edit.setToolTip(text)
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_labels()
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_labels()
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._run_available = bool(enabled)
self.btn_run.setEnabled(bool(enabled))
self._apply_run_tooltip()
def set_run_tooltip_reason(self, reason: str) -> None:
"""Explain *why* Run is unavailable (shown in the button tooltip)."""
self._run_tooltip_reason = str(reason or "")
self._apply_run_tooltip()
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 refresh_labels(self) -> None:
"""Public refresh hook (kept separate from ``retranslate`` for reuse)."""
self._refresh_preview_label()
self._refresh_run_label()
# ------------------------------------------------------------- internals
def _populate_source_combo(self) -> None:
self.source_combo.blockSignals(True)
self.source_combo.clear()
self.source_combo.addItem("", "camera")
self.source_combo.addItem("", "video")
self.source_combo.blockSignals(False)
def _populate_units_combo(self) -> None:
current = self.units_combo.currentData()
self.units_combo.blockSignals(True)
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(t("toolbar.stop_label"))
else:
self.btn_run.setText(t("toolbar.run_label"))
self.btn_run.setObjectName(
"dangerButton" if self.btn_run.isChecked() else "primaryButton"
)
self.btn_run.style().unpolish(self.btn_run)
self.btn_run.style().polish(self.btn_run)
self._apply_run_tooltip()
def _apply_run_tooltip(self) -> None:
if self.btn_run.isChecked():
self.btn_run.setToolTip(t("toolbar.stop"))
elif not self._run_available and self._run_tooltip_reason:
self.btn_run.setToolTip(self._run_tooltip_reason)
elif not self._run_available:
self.btn_run.setToolTip(t("toolbar.run_unavailable"))
else:
tip = t("toolbar.run")
if self._run_tooltip_reason:
tip = f"{tip} — {self._run_tooltip_reason}"
self.btn_run.setToolTip(tip)
def retranslate(self) -> None:
self.source_label.setText(t("toolbar.source"))
self.source_combo.setItemText(0, t("toolbar.source_camera"))
self.source_combo.setItemText(1, t("toolbar.source_video"))
self.cam_id_label.setText(t("toolbar.cam_id"))
self.btn_browse_video.setText(t("toolbar.browse_video"))
path = self.video_path()
self.video_edit.setPlaceholderText(
Path(path).name if path else t("toolbar.video_placeholder")
)
self.btn_search.setText(t("toolbar.search_cameras"))
self.refresh_labels()
self.btn_reset.setText(t("toolbar.reset"))
self.units_label.setText(t("toolbar.units"))
self.units_combo.setItemText(0, t("toolbar.units_newton"))
self.units_combo.setItemText(1, t("toolbar.units_raw"))
@@ -1,4 +1,4 @@
"""Orisys customer desktop application."""
"""Orisys Studio desktop application."""
__version__ = "0.1.0"
REQUIRES_SHARED_API = "0.1"
@@ -1,4 +1,4 @@
"""Main window shell for the customer app."""
"""Main window shell for the studio app."""
from __future__ import annotations
@@ -34,20 +34,20 @@ 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 (
from orisys_studio.ui.about_dialog import AboutDialog
from orisys_studio.ui.arrow_panel import ArrowPanel
from orisys_studio.ui.brand_bar import BrandBar
from orisys_studio.ui.cal_panel import CalPanel
from orisys_studio.ui.force_plots import ForcePlotsPanel
from orisys_studio.ui.mesh3d_panel import Mesh3dPanel
from orisys_studio.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
from orisys_studio.ui.sensor_picker import SensorPickerWidget
from orisys_studio.ui.toolbar import ToolbarWidget
logger = logging.getLogger(__name__)
@@ -82,7 +82,7 @@ def _reveal_in_explorer(path: Path) -> None:
subprocess.run(["xdg-open", str(folder)], check=False)
class CustomerMainWindow(QMainWindow):
class StudioMainWindow(QMainWindow):
def __init__(self, prefs: dict[str, Any] | None = None) -> None:
super().__init__()
self.prefs: dict[str, Any] = dict(prefs or {})
@@ -998,5 +998,5 @@ class CustomerMainWindow(QMainWindow):
self._stop_preview(announce=False)
self._stop_run(reason="user_stop")
self._persist_prefs()
logger.info("Customer shell closed; preferences saved")
logger.info("Studio shell closed; preferences saved")
super().closeEvent(event)
@@ -1,4 +1,4 @@
"""Customer app entrypoint."""
"""Studio app entrypoint."""
from __future__ import annotations
@@ -15,14 +15,14 @@ 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
from orisys_studio import REQUIRES_SHARED_API, __version__ as app_version
from orisys_studio.app_window import StudioMainWindow
logger = logging.getLogger(__name__)
def main() -> int:
init_logging("orisys-customer")
init_logging("orisys-studio")
install_exception_hooks()
preferred = prefer_local_sensor_bundles()
if preferred is not None:
@@ -42,13 +42,13 @@ def main() -> int:
set_locale(language)
app = QApplication(sys.argv)
app.setApplicationName("Orisys Customer")
app.setApplicationName("Orisys Studio")
app.setApplicationVersion(app_version)
app.setStyleSheet(build_qss(theme))
window = CustomerMainWindow(prefs=prefs)
window = StudioMainWindow(prefs=prefs)
window.show()
logger.info("Customer shell started (theme=%s, language=%s)", theme, language)
logger.info("Studio shell started (theme=%s, language=%s)", theme, language)
return int(app.exec_())
@@ -9,7 +9,7 @@ from PyQt5.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayo
from orisys_app_shared.i18n import t
from orisys_app_shared.paths import logo_topbar_path
from orisys_customer import __version__ as app_version
from orisys_studio import __version__ as app_version
class AboutDialog(QDialog):
@@ -1,4 +1,4 @@
"""Brand bar shown at the top of the customer shell.
"""Brand bar shown at the top of the studio shell.
A slim deep-navy rail carrying the corporate logo lockup and the product name.
Logo colours are re-tinted at runtime so a single asset works on the brand
+4 -4
View File
@@ -1,4 +1,4 @@
"""Shared fixtures for the customer app UI tests.
"""Shared fixtures for the studio app UI tests.
The tests render real Qt widgets with the offscreen platform plugin, so they need
no display and no camera hardware. A single ``QApplication`` is shared by the
@@ -36,10 +36,10 @@ def qt_app():
@pytest.fixture
def make_window(qt_app, tmp_path, monkeypatch):
"""Build a ``CustomerMainWindow`` whose preference writes stay in tmp_path."""
"""Build a ``StudioMainWindow`` whose preference writes stay in tmp_path."""
from orisys_app_shared import prefs as prefs_pkg
from orisys_app_shared import paths as paths_mod
from orisys_customer.app_window import CustomerMainWindow
from orisys_studio.app_window import StudioMainWindow
prefs_file = tmp_path / "preference.json"
monkeypatch.setattr(paths_mod, "preference_path", lambda: prefs_file)
@@ -57,7 +57,7 @@ def make_window(qt_app, tmp_path, monkeypatch):
def _make(**overrides):
data = dict(default_prefs)
data.update(overrides)
window = CustomerMainWindow(prefs=data)
window = StudioMainWindow(prefs=data)
created.append(window)
return window
+6 -6
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""Dependency-free smoke run for the customer UI.
"""Dependency-free smoke run for the studio UI.
``pytest`` is the canonical runner (``apps/customer/tests``), but this script
``pytest`` is the canonical runner (``apps/studio/tests``), but this script
needs nothing beyond the app's own runtime dependencies, so it can be used in
minimal environments:
@@ -100,7 +100,7 @@ GRAPHIC_PAIRS: tuple[tuple[str, tuple[str, ...]], ...] = (
VIZ_KEYS = ("plot_fn", "plot_fx", "plot_fy")
# Deliberate, documented exemptions (see apps/customer/docs/ui-palette.md):
# Deliberate, documented exemptions (see apps/studio/docs/ui-palette.md):
# decorative 1px container outlines, and disabled states which WCAG 1.4.3
# explicitly exempts.
DECORATIVE_PAIRS: tuple[tuple[str, tuple[str, ...]], ...] = (
@@ -261,7 +261,7 @@ def main() -> int:
from orisys_app_shared.force.calibration_io import CalSample
from orisys_app_shared.i18n import set_locale, t
from orisys_app_shared.theme import build_qss, load_colors
from orisys_customer.app_window import CustomerMainWindow
from orisys_studio.app_window import StudioMainWindow
app = QApplication.instance() or QApplication(sys.argv)
set_locale("zh_CN")
@@ -297,7 +297,7 @@ def main() -> int:
section(f"window {theme}/{sensor}")
scene_count += 1
app.setStyleSheet(build_qss(theme))
window = CustomerMainWindow(
window = StudioMainWindow(
prefs={
"language": "zh_CN",
"theme": theme,
@@ -401,7 +401,7 @@ def main() -> int:
section("small window")
app.setStyleSheet(build_qss("dark"))
window = CustomerMainWindow(
window = StudioMainWindow(
prefs={
"language": "en_US",
"theme": "dark",
+4 -4
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Render the customer UI offscreen to PNG files for visual review.
"""Render the studio UI offscreen to PNG files for visual review.
The tool never opens a camera and never starts acquisition: it builds the real
main window, optionally injects deterministic demo data, then grabs the window.
@@ -63,7 +63,7 @@ def _demo_data(window) -> None:
)
from orisys_app_shared.theme import color
from orisys_customer.app_window import _bgr_to_qimage
from orisys_studio.app_window import _bgr_to_qimage
size = 320
grid_y, grid_x = np.mgrid[0:size, 0:size].astype(np.float32)
@@ -110,7 +110,7 @@ def main() -> int:
from orisys_app_shared.i18n import set_locale
from orisys_app_shared.theme import build_qss
from orisys_customer.app_window import CustomerMainWindow
from orisys_studio.app_window import StudioMainWindow
app = QApplication(sys.argv)
set_locale(args.lang)
@@ -125,7 +125,7 @@ def main() -> int:
"show_fps": True,
"image_view_mode": args.view,
}
window = CustomerMainWindow(prefs=prefs)
window = StudioMainWindow(prefs=prefs)
window.resize(width, height)
window.show()
app.processEvents()