initial commit, functinal software

This commit is contained in:
ykcheungab
2026-09-06 21:57:50 +08:00
commit e2d5e69229
78 changed files with 5407 additions and 0 deletions
+25
View File
@@ -0,0 +1,25 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.eggs/
dist/
build/_pyinstaller/
*.egg
.venv/
.venv-build/
venv/
.env
# App runtime
**/outputs/**
!**/.gitkeep
**/preference.json
apps/customer/preference.json
# IDE / OS
.idea/
.vscode/
*.swp
Thumbs.db
.DS_Store
+47
View File
@@ -0,0 +1,47 @@
# orisys-app
Desktop applications that consume the Orisys SDK.
| App | Status |
|-----|--------|
| `apps/customer` | Scaffold shell (theme / locale / prefs / logging) |
| `apps/internal` | Stub for future calibration & automated testing |
## Layout
- `shared/` — common paths, prefs, theme, i18n, logging
- `apps/customer/config/` — locales, default prefs, editable `sensors/` copies
- `apps/customer/outputs/` — generated logs and calibration exports (gitignored)
- `build/` — Windows PyInstaller packaging (`build/customer/build.ps1`)
## Run (customer shell)
```powershell
cd D:\repo\orisys-app
python -m pip install -e .\shared -e .\apps\customer
python .\apps\customer\run.py
```
Or without install:
```powershell
$env:PYTHONPATH = "D:\repo\orisys-app\shared\src;D:\repo\orisys-app\apps\customer\src"
python D:\repo\orisys-app\apps\customer\run.py
```
## Branding
Window / EXE icon: `shared/resources/branding/logo.png`
Top bar / About: `shared/resources/branding/logo_topbar.png`
Dark mode is the default. Logos currently sit on a black tile; a transparent (or light) variant may be needed for polished light mode later.
## Versioning
- **Customer app version** (`orisys_customer.__version__`) — shown in Help → About and used for releases.
- **Shared package version** (`orisys_app_shared.__version__`) — internal library version.
- **Shared API** (`orisys_app_shared.API_VERSION`) — compatibility tag. Customer declares `REQUIRES_SHARED_API`; startup logs a warning on mismatch.
## Deferred
Run/Stop pipeline, arrow/force plots, units, calibration capture, Open3D finger mesh, sync-from-wheel for `config/sensors`, PyInstaller packaging, and the internal app.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

+115
View File
@@ -0,0 +1,115 @@
{
"app": {
"title": "Orisys App",
"about_title": "About",
"about_body": "Orisys customer desktop application\nVersion: {app_version}",
"scaffold_hint": "Scaffold shell — acquisition coming later"
},
"menu": {
"file": "File",
"open_video": "Open video…",
"reveal_config": "Reveal sensor config…",
"view": "View",
"theme": "Theme",
"theme_dark": "Dark",
"theme_light": "Light",
"language": "Language",
"lang_zh": "简体中文",
"lang_en": "English",
"calibration_panel": "Calibration panel",
"finger_3d": "Finger 3D view",
"show_fps": "Show FPS",
"plot_settings": "Plot settings…",
"help": "Help",
"about": "About"
},
"dialog": {
"plot_settings_title": "Plot settings",
"plot_window": "Time window",
"plot_window_tip": "Force plot time window (seconds)",
"plot_fn_max": "FN max",
"plot_fn_max_tip": "Normal force axis max (Newton mode)",
"plot_shear_max": "Shear max",
"plot_shear_max_tip": "Shear force axis ±max for FX/FY (Newton mode)",
"reset_defaults": "Reset to defaults",
"ok": "OK",
"cancel": "Cancel"
},
"toolbar": {
"source": "Source",
"source_camera": "Camera",
"source_video": "Video file",
"search_cameras": "Search",
"cam_id": "Cam ID",
"browse_video": "Browse…",
"video_placeholder": "No video selected",
"preview": "Preview",
"preview_stop": "Stop preview",
"run": "Run",
"stop": "Stop",
"reset": "Reset",
"units": "Units",
"units_newton": "Newton",
"units_raw": "Raw"
},
"sensor": {
"type": "Sensor type",
"config": "Config",
"thumb_placeholder": "Thumbnail"
},
"panels": {
"arrow": "2D arrows",
"view_arrows_pure": "Arrows",
"view_arrows_stitched": "Arrows / Captured",
"view_depth": "Depth map",
"view_mode_tip": "Image display mode while running",
"plots": "Force Charts",
"cal": "Calibration",
"mesh3d": "3D mesh",
"mesh3d_hint": "Finger 3D view placeholder — Open3D mesh coming later"
},
"cal": {
"weight": "Weight (N)",
"duration": "Record duration",
"record": "Record",
"delete_row": "Delete row",
"clear_channel": "Clear channel",
"clear_all": "Clear all",
"export": "Export…",
"sn": "SN",
"sn_placeholder": "Serial number (optional)",
"live_raw": "Live raw FN={fn} FX={fx} FY={fy}",
"col_weight": "Weight (N)",
"col_raw": "Raw mean",
"col_n": "n",
"col_std": "σ",
"col_actions": "",
"recording": "Recording calibration samples…",
"record_ok": "Recorded weight={weight} raw={raw}",
"record_failed_few": "Recording failed — too few samples",
"export_ok": "Exported {path}",
"export_local": "Also wrote {path}",
"export_warnings": "Skipped: {detail}",
"export_failed": "Export failed: {error}"
},
"status": {
"ready": "Ready",
"camera_none": "No cameras found",
"camera_found": "Found {count} camera(s): {names}",
"preview_started": "Preview started (camera {camera_id})",
"preview_stopped": "Preview stopped",
"preview_failed": "Preview failed: {error}",
"connecting": "Connecting…",
"run_started": "Running ({source})",
"run_stopped": "Stopped",
"run_failed": "Run failed: {error}",
"video_ended": "Video ended",
"camera_disconnected": "Camera disconnected",
"reset_ok": "Tracker reset",
"finger_run_unavailable": "Run is not available for this sensor type",
"video_required": "Select a video file first",
"config_missing": "Sensor config not found for {type}",
"units_raw_fallback": "Calibration unavailable — showing raw units",
"reveal_failed": "Could not reveal config: {error}"
}
}
+115
View File
@@ -0,0 +1,115 @@
{
"app": {
"title": "Orisys 应用",
"about_title": "关于",
"about_body": "Orisys 客户端桌面应用\n版本: {app_version}",
"scaffold_hint": "脚手架界面 — 采集功能稍后加入"
},
"menu": {
"file": "文件",
"open_video": "打开视频…",
"reveal_config": "在资源管理器中显示配置…",
"view": "视图",
"theme": "主题",
"theme_dark": "深色",
"theme_light": "浅色",
"language": "语言",
"lang_zh": "简体中文",
"lang_en": "English",
"calibration_panel": "标定面板",
"finger_3d": "手指 3D 视图",
"show_fps": "显示 FPS",
"plot_settings": "绘图设置…",
"help": "帮助",
"about": "关于"
},
"dialog": {
"plot_settings_title": "绘图设置",
"plot_window": "时间窗口",
"plot_window_tip": "力曲线时间窗口(秒)",
"plot_fn_max": "FN 上限",
"plot_fn_max_tip": "法向力纵轴上限(牛顿模式)",
"plot_shear_max": "剪切上限",
"plot_shear_max_tip": "剪切力 FX/FY 纵轴 ±上限(牛顿模式)",
"reset_defaults": "恢复默认",
"ok": "确定",
"cancel": "取消"
},
"toolbar": {
"source": "来源",
"source_camera": "摄像机",
"source_video": "视频文件",
"search_cameras": "搜索",
"cam_id": "摄像机",
"browse_video": "浏览…",
"video_placeholder": "未选择视频",
"preview": "预览",
"preview_stop": "停止预览",
"run": "运行",
"stop": "停止",
"reset": "重置",
"units": "单位",
"units_newton": "牛顿",
"units_raw": "原始"
},
"sensor": {
"type": "传感器类型",
"config": "配置",
"thumb_placeholder": "缩略图"
},
"panels": {
"arrow": "形变箭头",
"view_arrows_pure": "箭头",
"view_arrows_stitched": "箭头/采集图像",
"view_depth": "深度图",
"view_mode_tip": "运行时的图像显示模式",
"plots": "力曲线图",
"cal": "标定",
"mesh3d": "三维网格",
"mesh3d_hint": "手指三维视图占位 — Open3D 网格稍后加入"
},
"cal": {
"weight": "砝码重量 (N)",
"duration": "采集时长",
"record": "记录",
"delete_row": "删除行",
"clear_channel": "清空当前通道",
"clear_all": "清空全部",
"export": "导出…",
"sn": "序列号",
"sn_placeholder": "序列号(可选)",
"live_raw": "实时原始值 FN={fn} FX={fx} FY={fy}",
"col_weight": "重量 (N)",
"col_raw": "原始均值",
"col_n": "n",
"col_std": "σ",
"col_actions": "",
"recording": "正在采集标定样本…",
"record_ok": "已记录 weight={weight} raw={raw}",
"record_failed_few": "采集失败 — 样本过少",
"export_ok": "已导出 {path}",
"export_local": "同时写入 {path}",
"export_warnings": "跳过: {detail}",
"export_failed": "导出失败: {error}"
},
"status": {
"ready": "就绪",
"camera_none": "未发现摄像机",
"camera_found": "发现 {count} 个摄像机: {names}",
"preview_started": "预览已启动(摄像机 {camera_id})",
"preview_stopped": "预览已停止",
"preview_failed": "预览失败: {error}",
"connecting": "正在连接…",
"run_started": "运行中({source})",
"run_stopped": "已停止",
"run_failed": "启动失败: {error}",
"video_ended": "视频已结束",
"camera_disconnected": "摄像机已断开",
"reset_ok": "追踪器已重置",
"finger_run_unavailable": "此传感器类型暂不支持运行",
"video_required": "请先选择视频文件",
"config_missing": "未找到传感器配置: {type}",
"units_raw_fallback": "无标定数据 — 显示原始单位",
"reveal_failed": "无法打开配置位置: {error}"
}
}
@@ -0,0 +1,17 @@
{
"language": "zh_CN",
"theme": "dark",
"window_geometry": null,
"camera_id": "0",
"source_mode": "camera",
"video_path": "",
"show_cal_panel": false,
"show_finger_3d": true,
"sensor_type": "box",
"config_name": "",
"units": "newton",
"plot_window_sec": 4.0,
"plot_fn_max": 20.0,
"plot_shear_max": 20.0,
"image_view_mode": "arrows_pure"
}
@@ -0,0 +1,113 @@
{
"schema_version": 2,
"unit": "N",
"channels": {
"normal": {
"method": "piecewise_linear",
"raw_key": "fnormal",
"extrapolate": true,
"clamp_output_min": 0.0,
"points": [
[
3167.3289065950753,
0.0
],
[
3617.3242262492,
1.0
]
]
},
"shear_x": {
"method": "piecewise_linear",
"raw_key": "fshearx",
"extrapolate": true,
"clamp_output_min": 0.0,
"points": [
[
1471.613952418736,
0.0
],
[
1743.8539805266817,
1.0
]
],
"signed": true
},
"shear_y": {
"method": "piecewise_linear",
"raw_key": "fsheary",
"extrapolate": true,
"clamp_output_min": 0.0,
"points": [
[
3845.5798720537605,
1.0
],
[
3881.793802339737,
0.0
]
],
"signed": true
}
},
"meta": {
"date": "2026-09-06T21:54:09+08:00",
"sensor_type": "box",
"sn": "a",
"stitch_resolution": [
365,
365
],
"dis_mode": 3,
"hhd_downsample": 3,
"record_duration_s": 7.0,
"config_ref": "D:\\repo\\orisys-app\\apps\\customer\\config\\sensors\\box\\sensor.json",
"samples": {
"fn": [
{
"weight_n": 0.0,
"raw_mean": 3167.3289065950753,
"n": 283,
"std": 283.516893458341
},
{
"weight_n": 1.0,
"raw_mean": 3617.3242262492,
"n": 293,
"std": 271.21386400281835
}
],
"fx": [
{
"weight_n": 0.0,
"raw_mean": -1471.613952418736,
"n": 280,
"std": 311.9173147283949
},
{
"weight_n": 1.0,
"raw_mean": -1743.8539805266817,
"n": 295,
"std": 324.2175747527586
}
],
"fy": [
{
"weight_n": 0.0,
"raw_mean": 3881.793802339737,
"n": 292,
"std": 362.16000171157486
},
{
"weight_n": 1.0,
"raw_mean": 3845.5798720537605,
"n": 295,
"std": 390.42421990805127
}
]
}
}
}
@@ -0,0 +1,46 @@
{
"name": "prototype",
"version": "1.0",
"camera": {
"resolution_raw": [1920, 1080],
"fps": 30,
"units": 15,
"units_used": 9,
"resolution_stitch": [365, 365],
"stitch_res_list": [365,263,123,63]
},
"stitcher": {
"min_area_frac": 0.01,
"max_area_frac": 0.05,
"max_aspect_ratio": 50,
"index": [1,2,3,6,7,8,11,12,13],
"margin": 10,
"nominal_ov_pos": 100,
"n": [3,3],
"row_tol": 0.6,
"box_size": [250, 250],
"blend":"numba",
"blend_list": ["numpy", "numba","none"]
},
"tracker": {
"adaptive": true,
"lower_threshold": 12.0,
"upper_threshold": 18.0,
"loop_closure_interval": 1,
"algorithm": "dis",
"dis_preset": "medium",
"dis_mode": 3,
"algorithm_list": ["raft", "dis"],
"hhd_downsample": 3
},
"contact": {
"min_contour_area": 3000,
"flow_threshold":5,
"div_threshold":0.05,
"expansion_threshold": 0.01,
"kernel_size": [5,5],
"denoise_sigma": 12
}
}
@@ -0,0 +1,9 @@
Place the ORY-FINGER runtime asset bundle files in this directory before
building release wheels:
- `mesh.npz`
- `camera_intrinsics.npz`
- `world_to_cam.npy`
These binary assets are validated by the wheel build and are intentionally not
generated by the package build step.
@@ -0,0 +1,6 @@
Place visualization camera assets in this directory before building release
wheels:
- `world_to_cam.npy`
The 3D dashboard uses this pose as the default view preset when available.
@@ -0,0 +1,6 @@
{
"name": "finger",
"sensor_json": "sensor.json",
"assets_dir": "assets/30fps-0703",
"viz_pose": "assets/visualization/world_to_cam.npy"
}
@@ -0,0 +1,35 @@
{
"name": "dd02-ov",
"type": "single_camera",
"version": "1.0",
"camera": {
"resolution_raw": [1280, 720],
"fps": 30,
"resolution_stitch": [280, 300],
"source_scale": 0.4,
"old":[672, 728, 1,0.5],
"work": [504, 546,1],
"work2": [420, 450,0.8],
"work3": [350, 375,4,0.7],
"work4": [280, 300,4,0.4,0.2]
},
"tracker": {
"adaptive": true,
"lower_threshold": 12.0,
"upper_threshold": 18.0,
"loop_closure_interval": 1,
"algorithm": "dis",
"dis_preset": "medium",
"dis_mode": 1,
"algorithm_list": ["raft", "dis"],
"hhd_downsample": 3
},
"contact": {
"min_contour_area": 3000,
"flow_threshold": 0.2,
"div_threshold": 0.022,
"expansion_threshold": 0.01,
"kernel_size": [5, 5],
"denoise_sigma": 12
}
}
@@ -0,0 +1,46 @@
{
"name": "prototype",
"version": "1.0",
"camera": {
"resolution_raw": [1920, 1080],
"fps": 30,
"units": 15,
"units_used": 9,
"resolution_stitch": [365, 365],
"stitch_res_list": [365,263,123,63]
},
"stitcher": {
"min_area_frac": 0.01,
"max_area_frac": 0.05,
"max_aspect_ratio": 50,
"index": [1,2,3,6,7,8,11,12,13],
"margin": 10,
"nominal_ov_pos": 100,
"n": [3,3],
"row_tol": 0.6,
"box_size": [250, 250],
"blend":"numba",
"blend_list": ["numpy", "numba","none"]
},
"tracker": {
"adaptive": true,
"lower_threshold": 12.0,
"upper_threshold": 18.0,
"loop_closure_interval": 1,
"algorithm": "dis",
"dis_preset": "medium",
"dis_mode": 3,
"algorithm_list": ["raft", "dis"],
"hhd_downsample": 3
},
"contact": {
"min_contour_area": 3000,
"flow_threshold":5,
"div_threshold":0.05,
"expansion_threshold": 0.01,
"kernel_size": [5,5],
"denoise_sigma": 12
}
}
+26
View File
@@ -0,0 +1,26 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "orisys-customer"
version = "0.1.0"
description = "Orisys customer desktop app"
requires-python = ">=3.10"
authors = [{ name = "Orisys-Tech" }]
dependencies = [
"PyQt5>=5.15",
"orisys-app-shared>=0.1.0",
"orisys>=0.5",
"scipy>=1.10.1",
"pyqtgraph>=0.13",
]
[project.scripts]
orisys-customer = "orisys_customer.main:main"
[tool.setuptools]
package-dir = { "" = "src" }
[tool.setuptools.packages.find]
where = ["src"]
+24
View File
@@ -0,0 +1,24 @@
#!/usr/bin/env python3
"""Launch the Orisys customer app shell."""
from __future__ import annotations
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
SRC = ROOT / "src"
SHARED_SRC = ROOT.parents[1] / "shared" / "src"
for path in (SRC, SHARED_SRC):
if path.is_dir() and str(path) not in sys.path:
sys.path.insert(0, str(path))
# Ensure path helpers resolve to this app root when launched via run.py
import os
os.chdir(ROOT)
from orisys_customer.main import main
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,4 @@
"""Orisys customer desktop application."""
__version__ = "0.1.0"
REQUIRES_SHARED_API = "0.1"
@@ -0,0 +1,842 @@
"""Main window shell for the customer app."""
from __future__ import annotations
import logging
import subprocess
import sys
from pathlib import Path
from typing import Any
import numpy as np
from PyQt5.QtCore import Qt, QTimer
from PyQt5.QtGui import QIcon, QImage
from PyQt5.QtWidgets import (
QAction,
QActionGroup,
QApplication,
QFileDialog,
QHBoxLayout,
QMainWindow,
QMessageBox,
QSplitter,
QStatusBar,
QVBoxLayout,
QWidget,
)
from orisys_app_shared.force import select_display_forces
from orisys_app_shared.i18n import set_locale, t
from orisys_app_shared.logging import report_error
from orisys_app_shared.paths import logo_path, sensor_json_path
from orisys_app_shared.prefs import save_preferences
from orisys_app_shared.theme import build_qss, color
from orisys_customer.ui.about_dialog import AboutDialog
from orisys_customer.ui.arrow_panel import ArrowPanel
from orisys_customer.ui.cal_panel import CalPanel
from orisys_customer.ui.force_plots import ForcePlotsPanel
from orisys_customer.ui.mesh3d_panel import Mesh3dPanel
from orisys_customer.ui.plot_settings_dialog import (
DEFAULT_PLOT_FN_MAX,
DEFAULT_PLOT_SHEAR_MAX,
DEFAULT_PLOT_WINDOW_SEC,
PlotSettingsDialog,
)
from orisys_customer.ui.sensor_picker import SensorPickerWidget
from orisys_customer.ui.toolbar import ToolbarWidget
logger = logging.getLogger(__name__)
RUNNABLE_TYPES = frozenset({"box", "mini", "finger"})
def _bgr_to_qimage(frame: np.ndarray) -> QImage:
rgb = frame[:, :, ::-1].copy()
h, w, ch = rgb.shape
bytes_per_line = ch * w
return QImage(rgb.data, w, h, bytes_per_line, QImage.Format_RGB888).copy()
def _reveal_in_explorer(path: Path) -> None:
path = path.resolve()
if sys.platform == "win32":
subprocess.run(["explorer", "/select,", str(path)], check=False)
return
if sys.platform == "darwin":
subprocess.run(["open", "-R", str(path)], check=False)
return
folder = path.parent if path.is_file() else path
subprocess.run(["xdg-open", str(folder)], check=False)
class CustomerMainWindow(QMainWindow):
def __init__(self, prefs: dict[str, Any] | None = None) -> None:
super().__init__()
self.prefs: dict[str, Any] = dict(prefs or {})
self._theme = str(self.prefs.get("theme") or "dark")
self._language = str(self.prefs.get("language") or "zh_CN")
self._show_cal = bool(self.prefs.get("show_cal_panel", False))
self._show_finger_3d = bool(self.prefs.get("show_finger_3d", True))
self._show_fps = bool(self.prefs.get("show_fps", False))
self._session_phase = "idle"
self._running = False
self._preview = None
self._preview_timer = QTimer(self)
self._preview_timer.setInterval(33)
self._preview_timer.timeout.connect(self._on_preview_tick)
self._run_timer = QTimer(self)
self._run_timer.setInterval(16)
self._run_timer.timeout.connect(self._on_run_tick)
self._box_worker = None
self._finger_worker = None
self._active_worker = None
self._active_sensor_type = "box"
self._last_session_error = ""
self._last_plot_packet_key: tuple[int, float] | None = None
self._last_rendered_view_mode: str | None = None
self._run_source_label = ""
icon = QIcon(str(logo_path()))
if not icon.isNull():
self.setWindowIcon(icon)
central = QWidget()
self.setCentralWidget(central)
root = QVBoxLayout(central)
root.setContentsMargins(10, 10, 10, 10)
root.setSpacing(8)
top = QHBoxLayout()
self.toolbar = ToolbarWidget()
self.sensor_picker = SensorPickerWidget()
top.addWidget(self.toolbar, 2)
top.addWidget(self.sensor_picker, 1)
root.addLayout(top)
self.content_splitter = QSplitter(Qt.Horizontal)
self.mesh3d_panel = Mesh3dPanel()
self.arrow_panel = ArrowPanel()
self.force_plots = ForcePlotsPanel(theme=self._theme)
self.cal_panel = CalPanel()
self.content_splitter.addWidget(self.mesh3d_panel)
self.content_splitter.addWidget(self.arrow_panel)
self.content_splitter.addWidget(self.force_plots)
self.content_splitter.addWidget(self.cal_panel)
root.addWidget(self.content_splitter, 1)
self.setStatusBar(QStatusBar())
self.cal_panel.status_message.connect(self.statusBar().showMessage)
self._build_menus()
self._wire_toolbar()
self._wire_sensor_picker()
self._restore_prefs_into_ui()
self._apply_panel_visibility()
self._update_run_enabled()
self.cal_panel.set_meta_context(
sensor_type=self.sensor_picker.sensor_type(),
config_ref=self._resolve_config_ref(),
)
self.retranslate()
self._restore_geometry()
self.resize(1280, 720)
@property
def _preview_open(self) -> bool:
return self._preview is not None and bool(self._preview.is_open)
def _get_preview(self):
if self._preview is None:
from orisys_app_shared.camera import RawPreviewSession
self._preview = RawPreviewSession()
return self._preview
def _ensure_workers(self) -> None:
if self._box_worker is not None and self._finger_worker is not None:
return
from orisys_app_shared.acquisition.finger_worker import FingerAcquisitionWorker
from orisys_app_shared.acquisition.sdk_worker import SdkAcquisitionWorker
if self._box_worker is None:
self._box_worker = SdkAcquisitionWorker(
on_error=self._on_worker_error,
on_status=self._on_worker_status,
)
if self._finger_worker is None:
self._finger_worker = FingerAcquisitionWorker(
on_error=self._on_worker_error,
on_status=self._on_worker_status,
)
def _wire_toolbar(self) -> None:
self.toolbar.search_clicked.connect(self._on_search_cameras)
self.toolbar.preview_toggled.connect(self._on_preview_toggled)
self.toolbar.camera_id_changed.connect(self._on_camera_id_changed)
self.toolbar.source_mode_changed.connect(self._on_source_mode_changed)
self.toolbar.browse_video_clicked.connect(self._on_browse_video)
self.toolbar.run_toggled.connect(self._on_run_toggled)
self.toolbar.reset_clicked.connect(self._on_reset)
self.toolbar.units_changed.connect(self._on_units_changed)
def _wire_sensor_picker(self) -> None:
self.sensor_picker.sensor_type_changed.connect(self._on_sensor_type_changed)
self.sensor_picker.config_changed.connect(self._on_config_changed)
self.arrow_panel.view_mode_changed.connect(self._on_image_view_mode_changed)
def _restore_prefs_into_ui(self) -> None:
raw_id = str(self.prefs.get("camera_id") or "0").strip()
try:
cam_id = int(raw_id)
except ValueError:
cam_id = 0
self.toolbar.set_camera_id(cam_id)
self.toolbar.set_source_mode(str(self.prefs.get("source_mode") or "camera"))
self.toolbar.set_video_path(str(self.prefs.get("video_path") or ""))
self.toolbar.set_units(str(self.prefs.get("units") or "newton"))
self.arrow_panel.set_view_mode(str(self.prefs.get("image_view_mode") or "arrows_pure"))
self.arrow_panel.set_fps_visible(self._show_fps)
self._apply_plot_prefs_to_panel()
sensor_type = str(self.prefs.get("sensor_type") or "box")
config_name = str(self.prefs.get("config_name") or "")
self.sensor_picker.set_sensor_type(sensor_type)
if config_name:
self.sensor_picker.set_config_name(config_name)
else:
self.prefs["config_name"] = self.sensor_picker.config_name()
def _build_menus(self) -> None:
menu_bar = self.menuBar()
self.file_menu = menu_bar.addMenu("")
self.act_open_video = QAction(self)
self.act_open_video.triggered.connect(self._on_browse_video)
self.file_menu.addAction(self.act_open_video)
self.act_reveal_config = QAction(self)
self.act_reveal_config.triggered.connect(self._on_reveal_config)
self.file_menu.addAction(self.act_reveal_config)
self.view_menu = menu_bar.addMenu("")
self.act_cal_panel = QAction(self)
self.act_cal_panel.setCheckable(True)
self.act_cal_panel.setChecked(self._show_cal)
self.act_cal_panel.toggled.connect(self._on_cal_panel_toggled)
self.view_menu.addAction(self.act_cal_panel)
self.act_finger_3d = QAction(self)
self.act_finger_3d.setCheckable(True)
self.act_finger_3d.setChecked(self._show_finger_3d)
self.act_finger_3d.toggled.connect(self._on_finger_3d_toggled)
self.view_menu.addAction(self.act_finger_3d)
self.act_show_fps = QAction(self)
self.act_show_fps.setCheckable(True)
self.act_show_fps.setChecked(self._show_fps)
self.act_show_fps.toggled.connect(self._on_show_fps_toggled)
self.view_menu.addAction(self.act_show_fps)
self.act_plot_settings = QAction(self)
self.act_plot_settings.triggered.connect(self._on_plot_settings)
self.view_menu.addAction(self.act_plot_settings)
self.view_menu.addSeparator()
self.theme_menu = self.view_menu.addMenu("")
self.theme_group = QActionGroup(self)
self.theme_group.setExclusive(True)
self.act_theme_dark = QAction(self)
self.act_theme_dark.setCheckable(True)
self.act_theme_light = QAction(self)
self.act_theme_light.setCheckable(True)
self.theme_group.addAction(self.act_theme_dark)
self.theme_group.addAction(self.act_theme_light)
self.theme_menu.addAction(self.act_theme_dark)
self.theme_menu.addAction(self.act_theme_light)
self.act_theme_dark.triggered.connect(lambda: self._set_theme("dark"))
self.act_theme_light.triggered.connect(lambda: self._set_theme("light"))
self.lang_menu = self.view_menu.addMenu("")
self.lang_group = QActionGroup(self)
self.lang_group.setExclusive(True)
self.act_lang_zh = QAction(self)
self.act_lang_zh.setCheckable(True)
self.act_lang_en = QAction(self)
self.act_lang_en.setCheckable(True)
self.lang_group.addAction(self.act_lang_zh)
self.lang_group.addAction(self.act_lang_en)
self.lang_menu.addAction(self.act_lang_zh)
self.lang_menu.addAction(self.act_lang_en)
self.act_lang_zh.triggered.connect(lambda: self._set_language("zh_CN"))
self.act_lang_en.triggered.connect(lambda: self._set_language("en_US"))
self.help_menu = menu_bar.addMenu("")
self.act_about = QAction(self)
self.act_about.triggered.connect(self._show_about)
self.help_menu.addAction(self.act_about)
self._sync_menu_checks()
def _sync_menu_checks(self) -> None:
self.act_theme_dark.setChecked(self._theme != "light")
self.act_theme_light.setChecked(self._theme == "light")
self.act_lang_zh.setChecked(self._language.startswith("zh"))
self.act_lang_en.setChecked(not self._language.startswith("zh"))
self.act_cal_panel.blockSignals(True)
self.act_cal_panel.setChecked(self._show_cal)
self.act_cal_panel.blockSignals(False)
is_finger = self.sensor_picker.sensor_type() == "finger"
self.act_finger_3d.setEnabled(is_finger)
self.act_finger_3d.blockSignals(True)
self.act_finger_3d.setChecked(bool(self._show_finger_3d) if is_finger else False)
self.act_finger_3d.blockSignals(False)
def _apply_panel_visibility(self) -> None:
is_finger = self.sensor_picker.sensor_type() == "finger"
show_3d = is_finger and bool(self._show_finger_3d)
self.mesh3d_panel.setVisible(show_3d)
self.cal_panel.setVisible(self._show_cal)
self.act_finger_3d.setEnabled(is_finger)
self.act_finger_3d.blockSignals(True)
self.act_finger_3d.setChecked(show_3d)
self.act_finger_3d.blockSignals(False)
for i in range(self.content_splitter.count()):
widget = self.content_splitter.widget(i)
if widget is self.mesh3d_panel:
self.content_splitter.setStretchFactor(i, 3 if show_3d else 0)
elif widget is self.arrow_panel:
self.content_splitter.setStretchFactor(i, 3)
elif widget is self.force_plots:
self.content_splitter.setStretchFactor(i, 3)
elif widget is self.cal_panel:
self.content_splitter.setStretchFactor(i, 2 if self._show_cal else 0)
def _update_run_enabled(self) -> None:
sensor_ok = self.sensor_picker.sensor_type() in RUNNABLE_TYPES
source_ok = True
if self.toolbar.source_mode() == "video" and not self.toolbar.video_path():
source_ok = False
self.toolbar.set_run_enabled(self._running or (sensor_ok and source_ok))
if not self._running:
self.toolbar._refresh_run_label()
def retranslate(self) -> None:
self.setWindowTitle(t("app.title"))
self.file_menu.setTitle(t("menu.file"))
self.act_open_video.setText(t("menu.open_video"))
self.act_reveal_config.setText(t("menu.reveal_config"))
self.view_menu.setTitle(t("menu.view"))
self.act_cal_panel.setText(t("menu.calibration_panel"))
self.act_finger_3d.setText(t("menu.finger_3d"))
self.act_show_fps.setText(t("menu.show_fps"))
self.act_plot_settings.setText(t("menu.plot_settings"))
self.theme_menu.setTitle(t("menu.theme"))
self.act_theme_dark.setText(t("menu.theme_dark"))
self.act_theme_light.setText(t("menu.theme_light"))
self.lang_menu.setTitle(t("menu.language"))
self.act_lang_zh.setText(t("menu.lang_zh"))
self.act_lang_en.setText(t("menu.lang_en"))
self.help_menu.setTitle(t("menu.help"))
self.act_about.setText(t("menu.about"))
self.toolbar.retranslate()
self.sensor_picker.retranslate()
self.mesh3d_panel.retranslate()
self.arrow_panel.retranslate()
self.force_plots.retranslate()
self.cal_panel.retranslate()
if not self._preview_open and not self._running:
self.statusBar().showMessage(t("status.ready"))
self._update_run_enabled()
def _set_theme(self, theme: str) -> None:
self._theme = theme
self.prefs["theme"] = theme
app = QApplication.instance()
if app is not None:
app.setStyleSheet(build_qss(theme))
self.force_plots.set_theme(theme)
self._sync_menu_checks()
self._persist_prefs()
def _set_language(self, language: str) -> None:
self._language = language
self.prefs["language"] = language
set_locale(language)
self.retranslate()
self._sync_menu_checks()
self._persist_prefs()
def _show_about(self) -> None:
AboutDialog(self).exec_()
def _on_camera_id_changed(self, value: int) -> None:
self.prefs["camera_id"] = str(int(value))
self._persist_prefs()
def _on_source_mode_changed(self, mode: str) -> None:
self.prefs["source_mode"] = mode
self._persist_prefs()
self._update_run_enabled()
def _on_units_changed(self, units: str) -> None:
self.prefs["units"] = units
self.force_plots.set_fixed_newton(str(units) == "newton")
self._persist_prefs()
def _on_image_view_mode_changed(self, mode: str) -> None:
self.prefs["image_view_mode"] = str(mode)
self._last_rendered_view_mode = None
self._persist_prefs()
if self._running:
if self._active_worker is not None:
self._render_run_image(self._active_worker.latest())
def _on_plot_settings(self) -> None:
dialog = PlotSettingsDialog(
self,
window_sec=float(self.prefs.get("plot_window_sec") or DEFAULT_PLOT_WINDOW_SEC),
fn_max=float(self.prefs.get("plot_fn_max") or DEFAULT_PLOT_FN_MAX),
shear_max=float(self.prefs.get("plot_shear_max") or DEFAULT_PLOT_SHEAR_MAX),
units=self.toolbar.units(),
)
if dialog.exec_() != PlotSettingsDialog.Accepted:
return
window_sec, fn_max, shear_max = dialog.values()
self.prefs["plot_window_sec"] = window_sec
self.prefs["plot_fn_max"] = fn_max
self.prefs["plot_shear_max"] = shear_max
self.force_plots.set_window_sec(window_sec)
self.force_plots.set_axis_limits(fn_max, shear_max)
self._last_plot_packet_key = None
self._persist_prefs()
def _apply_plot_prefs_to_panel(self) -> None:
self.force_plots.set_window_sec(
float(self.prefs.get("plot_window_sec") or DEFAULT_PLOT_WINDOW_SEC)
)
self.force_plots.set_axis_limits(
float(self.prefs.get("plot_fn_max") or DEFAULT_PLOT_FN_MAX),
float(self.prefs.get("plot_shear_max") or DEFAULT_PLOT_SHEAR_MAX),
)
self.force_plots.set_fixed_newton(self.toolbar.units() == "newton")
def _on_cal_panel_toggled(self, visible: bool) -> None:
self._show_cal = bool(visible)
self.prefs["show_cal_panel"] = self._show_cal
self._apply_panel_visibility()
self._persist_prefs()
def _on_finger_3d_toggled(self, visible: bool) -> None:
if self.sensor_picker.sensor_type() != "finger":
return
self._show_finger_3d = bool(visible)
self.prefs["show_finger_3d"] = self._show_finger_3d
self._apply_panel_visibility()
self._persist_prefs()
def _on_show_fps_toggled(self, visible: bool) -> None:
self._show_fps = bool(visible)
self.prefs["show_fps"] = self._show_fps
self.arrow_panel.set_fps_visible(self._show_fps)
if not self._show_fps:
self.arrow_panel.set_fps(None)
self._persist_prefs()
def _on_sensor_type_changed(self, sensor_type: str) -> None:
self.prefs["sensor_type"] = str(sensor_type)
self.prefs["config_name"] = self.sensor_picker.config_name()
self.cal_panel.set_meta_context(
sensor_type=str(sensor_type),
config_ref=self._resolve_config_ref(),
)
self._apply_panel_visibility()
self._update_run_enabled()
self._persist_prefs()
def _on_config_changed(self, config_name: str) -> None:
self.prefs["config_name"] = str(config_name)
self._persist_prefs()
def _on_browse_video(self) -> None:
if self._running or self._preview_open:
return
start = self.toolbar.video_path() or str(Path.home())
path, _ = QFileDialog.getOpenFileName(
self,
t("menu.open_video"),
start,
"Video (*.mp4 *.avi *.mov *.mkv);;All (*)",
)
if not path:
return
self.toolbar.set_source_mode("video")
self.toolbar.set_video_path(path)
self.prefs["source_mode"] = "video"
self.prefs["video_path"] = path
self._persist_prefs()
self._update_run_enabled()
def _on_reveal_config(self) -> None:
sensor_type = self.sensor_picker.sensor_type()
path = sensor_json_path(sensor_type)
if path is None:
cfg = self.sensor_picker.config_name()
candidate = Path(cfg)
path = candidate if candidate.is_file() else None
if path is None:
self.statusBar().showMessage(t("status.config_missing", type=sensor_type))
return
try:
_reveal_in_explorer(path)
except Exception as exc:
self.statusBar().showMessage(t("status.reveal_failed", error=str(exc)))
def _resolve_config_ref(self) -> str | None:
sensor_type = self.sensor_picker.sensor_type()
local = sensor_json_path(sensor_type)
if local is not None:
return str(local)
cfg = self.sensor_picker.config_name()
if cfg and Path(cfg).is_file():
return cfg
if sensor_type in RUNNABLE_TYPES:
return sensor_type
return None
def _on_search_cameras(self) -> None:
if self._preview_open or self._running:
return
from orisys_app_shared.camera import list_cameras, pick_default_camera
preferred = str(self.toolbar.camera_id())
self.statusBar().showMessage(t("toolbar.search_cameras") + "…")
QApplication.processEvents()
cameras = list_cameras()
if not cameras:
self.statusBar().showMessage(t("status.camera_none"))
return
names = ", ".join(cam.label() for cam in cameras)
self.statusBar().showMessage(
t("status.camera_found", count=len(cameras), names=names)
)
selected = pick_default_camera(cameras, preferred)
if selected is not None:
self.toolbar.set_camera_id(int(selected))
self.prefs["camera_id"] = str(selected)
self._persist_prefs()
def _on_preview_toggled(self, checked: bool) -> None:
if checked:
self._start_preview()
else:
self._stop_preview(announce=True)
def _start_preview(self) -> None:
if self._running or self.toolbar.source_mode() != "camera":
self.toolbar.set_preview_checked(False)
return
camera_id = self.toolbar.camera_id()
try:
self._get_preview().open(camera_id)
except Exception as exc:
msg = t("status.preview_failed", error=str(exc))
self.statusBar().showMessage(msg)
self.toolbar.set_preview_checked(False)
self.arrow_panel.clear_preview(msg)
return
self.toolbar.set_source_controls_enabled(False)
self.toolbar.btn_preview.setEnabled(True)
self._update_run_enabled()
self._preview_timer.start()
self.statusBar().showMessage(t("status.preview_started", camera_id=camera_id))
def _stop_preview(self, *, announce: bool) -> None:
self._preview_timer.stop()
if self._preview is not None:
self._preview.close()
if not self._running:
self.toolbar.set_source_controls_enabled(True)
self.arrow_panel.clear_preview(t("app.scaffold_hint"))
self.toolbar.set_preview_checked(False)
self._update_run_enabled()
if announce:
self.statusBar().showMessage(t("status.preview_stopped"))
def _on_preview_tick(self) -> None:
if self._preview is None:
return
frame = self._preview.read()
if frame is None:
self._stop_preview(announce=True)
self.statusBar().showMessage(t("status.preview_failed", error="no frame"))
return
self.arrow_panel.show_frame(_bgr_to_qimage(frame))
def _on_run_toggled(self, checked: bool) -> None:
if checked:
self._start_run()
else:
self._stop_run(reason="user_stop")
def _start_run(self) -> None:
from orisys_app_shared.acquisition.sdk_worker import orisys_importable
sensor_type = self.sensor_picker.sensor_type()
if sensor_type not in RUNNABLE_TYPES:
self.toolbar.set_run_checked(False)
self.statusBar().showMessage(t("status.finger_run_unavailable"))
return
ok, detail = orisys_importable()
if not ok:
self.toolbar.set_run_checked(False)
QMessageBox.warning(self, t("app.title"), detail)
return
config_ref = self._resolve_config_ref()
if not config_ref:
self.toolbar.set_run_checked(False)
self.statusBar().showMessage(
t("status.config_missing", type=sensor_type)
)
return
source = self.toolbar.active_source()
if source is None:
self.toolbar.set_run_checked(False)
self.statusBar().showMessage(t("status.video_required"))
return
if self._preview_open:
self._stop_preview(announce=False)
self.force_plots.reset()
self._last_plot_packet_key = None
self._last_rendered_view_mode = None
self._last_session_error = ""
self._run_source_label = str(source)
self._active_sensor_type = sensor_type
self._ensure_workers()
self._active_worker = (
self._finger_worker if sensor_type == "finger" else self._box_worker
)
try:
self._active_worker.start(source, config_ref=config_ref)
except Exception as exc:
report_error(exc, message="Run failed")
self.toolbar.set_run_checked(False)
self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
return
self._running = True
self._session_phase = "connecting"
self.cal_panel.set_meta_context(sensor_type=sensor_type, config_ref=config_ref)
self.cal_panel.set_running(True)
self.toolbar.set_source_controls_enabled(False)
self.sensor_picker.type_combo.setEnabled(False)
self.sensor_picker.config_combo.setEnabled(False)
self.toolbar.set_reset_enabled(False)
self._run_timer.start()
self.statusBar().showMessage(t("status.connecting"))
logger.info(
"Connecting type=%s source=%s config=%s",
sensor_type,
source,
config_ref,
)
def _stop_run(self, *, reason: str = "user_stop") -> None:
self._run_timer.stop()
if self._active_worker is not None:
self._active_worker.stop()
self._running = False
self._session_phase = "idle"
self.cal_panel.set_running(False)
self.toolbar.set_run_checked(False)
self.toolbar.set_reset_enabled(False)
self.toolbar.set_source_controls_enabled(True)
self.sensor_picker.type_combo.setEnabled(True)
self.sensor_picker.config_combo.setEnabled(True)
self._update_run_enabled()
keep_frame = reason in ("ended", "disconnected")
if not keep_frame:
self.arrow_panel.clear_preview(t("app.scaffold_hint"))
else:
self.arrow_panel.set_fps(None)
if reason == "ended":
self.statusBar().showMessage(t("status.video_ended"))
elif reason == "disconnected":
self.statusBar().showMessage(t("status.camera_disconnected"))
elif reason == "failed":
pass # caller sets status
elif reason == "user_stop":
self.statusBar().showMessage(t("status.run_stopped"))
else:
self.statusBar().showMessage(t("status.run_stopped"))
def _on_reset(self) -> None:
if self._session_phase != "running" or self._active_worker is None:
return
try:
self._active_worker.reset()
self.force_plots.reset()
self._last_plot_packet_key = None
self.statusBar().showMessage(t("status.reset_ok"))
except Exception as exc:
self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
def _on_worker_error(self, message: str) -> None:
self._last_session_error = message
logger.error("Acquisition error: %s", message)
def _on_worker_status(self, message: str) -> None:
logger.info("%s", message)
def _on_run_tick(self) -> None:
if self._active_worker is None:
return
if self._last_session_error:
err = self._last_session_error
self._last_session_error = ""
self._active_worker.consume_end_reason()
self._stop_run(reason="failed")
self.statusBar().showMessage(t("status.run_failed", error=err))
return
end_reason = self._active_worker.consume_end_reason()
if end_reason in ("ended", "disconnected"):
self._stop_run(reason=end_reason)
return
if end_reason == "error":
self._stop_run(reason="failed")
self.statusBar().showMessage(t("status.run_failed", error="acquisition error"))
return
if not self._active_worker.is_running and self._running:
leftover = self._active_worker.consume_end_reason()
if leftover in ("ended", "disconnected"):
self._stop_run(reason=leftover)
elif leftover == "error":
self._stop_run(reason="failed")
self.statusBar().showMessage(
t("status.run_failed", error="acquisition error")
)
else:
self._stop_run(reason="user_stop")
return
packet = self._active_worker.latest()
if packet is None:
return
packet_key = (int(packet.session_id), float(packet.timestamp))
is_new = packet_key != self._last_plot_packet_key
if self._session_phase == "connecting" and is_new:
self._session_phase = "running"
self.toolbar.set_reset_enabled(True)
self.statusBar().showMessage(
t("status.run_started", source=self._run_source_label)
)
if is_new:
self._last_plot_packet_key = packet_key
display = select_display_forces(
units_pref=self.toolbar.units(),
force_calibrated=packet.force_calibrated,
fn=packet.normal_force,
fx=packet.shear_x,
fy=packet.shear_y,
fn_raw=packet.normal_force_raw,
fx_raw=packet.shear_x_raw,
fy_raw=packet.shear_y_raw,
)
if display.using_raw_fallback:
self.statusBar().showMessage(t("status.units_raw_fallback"))
self.force_plots.push(
packet.timestamp,
display.fn,
display.fx,
display.fy,
unit=display.unit,
)
if self._show_cal:
self.cal_panel.push_raw(
packet.normal_force_raw,
packet.shear_x_raw,
packet.shear_y_raw,
packet.timestamp,
)
mode = self.arrow_panel.view_mode()
if is_new or mode != self._last_rendered_view_mode:
self._render_run_image(packet)
if self._show_fps and is_new:
self.arrow_panel.set_fps(packet.fps)
def _render_run_image(self, packet) -> None:
if packet is None:
return
from orisys_app_shared.acquisition.arrows import (
depth_map_to_bgr,
draw_arrow_display,
hex_to_bgr,
)
mode = self.arrow_panel.view_mode()
self._last_rendered_view_mode = mode
draw_style = "finger" if self._active_sensor_type == "finger" else "box"
if mode == "depth":
depth_bgr = depth_map_to_bgr(packet.depth)
frame = depth_bgr if depth_bgr is not None else packet.processed_frame
elif mode == "arrows_stitched":
frame = draw_arrow_display(
packet.processed_frame,
packet.flow,
background="captured",
draw_style=draw_style,
valid_mask=packet.valid_mask,
)
else:
canvas_bgr = hex_to_bgr(color(self._theme, "arrow_canvas", "#121316"))
frame = draw_arrow_display(
packet.processed_frame,
packet.flow,
background="pure_color",
canvas_bgr=canvas_bgr,
draw_style=draw_style,
valid_mask=packet.valid_mask,
)
self.arrow_panel.show_frame(_bgr_to_qimage(frame))
def _restore_geometry(self) -> None:
geo = self.prefs.get("window_geometry")
if isinstance(geo, str) and geo:
try:
from PyQt5.QtCore import QByteArray
self.restoreGeometry(QByteArray.fromHex(geo.encode("ascii")))
except Exception:
logger.debug("Could not restore window geometry", exc_info=True)
def _persist_prefs(self) -> None:
geo = self.saveGeometry().toHex().data().decode("ascii")
self.prefs["window_geometry"] = geo
self.prefs["theme"] = self._theme
self.prefs["language"] = self._language
self.prefs["camera_id"] = str(self.toolbar.camera_id())
self.prefs["source_mode"] = self.toolbar.source_mode()
self.prefs["video_path"] = self.toolbar.video_path()
self.prefs["units"] = self.toolbar.units()
self.prefs["image_view_mode"] = self.arrow_panel.view_mode()
self.prefs.setdefault("plot_window_sec", DEFAULT_PLOT_WINDOW_SEC)
self.prefs.setdefault("plot_fn_max", DEFAULT_PLOT_FN_MAX)
self.prefs.setdefault("plot_shear_max", DEFAULT_PLOT_SHEAR_MAX)
self.prefs["show_cal_panel"] = bool(self._show_cal)
self.prefs["show_finger_3d"] = bool(self._show_finger_3d)
self.prefs["show_fps"] = bool(self._show_fps)
self.prefs["sensor_type"] = self.sensor_picker.sensor_type()
self.prefs["config_name"] = self.sensor_picker.config_name()
save_preferences(self.prefs)
def closeEvent(self, event) -> None: # noqa: N802
self._stop_preview(announce=False)
self._stop_run(reason="user_stop")
self._persist_prefs()
logger.info("Customer shell closed; preferences saved")
super().closeEvent(event)
+56
View File
@@ -0,0 +1,56 @@
"""Customer app entrypoint."""
from __future__ import annotations
import logging
import sys
from PyQt5.QtWidgets import QApplication
from orisys_app_shared import API_VERSION, __version__ as shared_version
from orisys_app_shared.compat import check_shared_compat
from orisys_app_shared.i18n import set_locale
from orisys_app_shared.logging import init_logging, install_exception_hooks
from orisys_app_shared.paths import prefer_local_sensor_bundles
from orisys_app_shared.prefs import load_preferences
from orisys_app_shared.theme import build_qss
from orisys_customer import REQUIRES_SHARED_API, __version__ as app_version
from orisys_customer.app_window import CustomerMainWindow
logger = logging.getLogger(__name__)
def main() -> int:
init_logging("orisys-customer")
install_exception_hooks()
preferred = prefer_local_sensor_bundles()
if preferred is not None:
logger.info("Preferring local sensor bundles at %s", preferred)
check_shared_compat(REQUIRES_SHARED_API)
logger.info(
"Versions: app=%s shared=%s api=%s (requires %s)",
app_version,
shared_version,
API_VERSION,
REQUIRES_SHARED_API,
)
prefs = load_preferences()
language = str(prefs.get("language") or "zh_CN")
theme = str(prefs.get("theme") or "dark")
set_locale(language)
app = QApplication(sys.argv)
app.setApplicationName("Orisys Customer")
app.setApplicationVersion(app_version)
app.setStyleSheet(build_qss(theme))
window = CustomerMainWindow(prefs=prefs)
window.show()
logger.info("Customer shell started (theme=%s, language=%s)", theme, language)
return int(app.exec_())
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1 @@
"""UI package."""
@@ -0,0 +1,44 @@
"""About dialog for Help > About."""
from __future__ import annotations
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QPixmap
from PyQt5.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
from orisys_app_shared.i18n import t
from orisys_app_shared.paths import logo_topbar_path
from orisys_customer import __version__ as app_version
class AboutDialog(QDialog):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setWindowTitle(t("menu.about"))
self.setModal(True)
self.resize(420, 220)
root = QVBoxLayout(self)
row = QHBoxLayout()
logo = QLabel()
pix = QPixmap(str(logo_topbar_path()))
if not pix.isNull():
logo.setPixmap(pix.scaledToHeight(48, Qt.SmoothTransformation))
row.addWidget(logo)
row.addStretch(1)
root.addLayout(row)
self.body = QLabel()
self.body.setWordWrap(True)
self.body.setTextInteractionFlags(Qt.TextSelectableByMouse)
root.addWidget(self.body)
close_btn = QPushButton("OK")
close_btn.clicked.connect(self.accept)
root.addWidget(close_btn, alignment=Qt.AlignRight)
self.retranslate()
def retranslate(self) -> None:
self.setWindowTitle(t("app.about_title"))
self.body.setText(t("app.about_body", app_version=app_version))
@@ -0,0 +1,134 @@
"""2D image panel: arrows / depth view with mode selector."""
from __future__ import annotations
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtGui import QImage, QPixmap
from PyQt5.QtWidgets import (
QComboBox,
QFrame,
QHBoxLayout,
QLabel,
QSizePolicy,
QVBoxLayout,
)
from orisys_app_shared.i18n import t
VIEW_MODES = ("arrows_pure", "arrows_stitched", "depth")
class ArrowPanel(QFrame):
view_mode_changed = pyqtSignal(str)
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setObjectName("contentPanel")
layout = QVBoxLayout(self)
header = QHBoxLayout()
self.title = QLabel()
self.title.setObjectName("panelTitle")
self.fps_label = QLabel()
self.fps_label.setObjectName("panelMeta")
self.fps_label.setVisible(False)
self._fps_visible = False
self._fps_value: float | None = None
self.view_combo = QComboBox()
self.view_combo.setMinimumWidth(140)
self.view_combo.currentIndexChanged.connect(self._on_view_index_changed)
header.addWidget(self.title)
header.addStretch(1)
header.addWidget(self.fps_label)
header.addWidget(self.view_combo)
layout.addLayout(header)
self.image_label = QLabel()
self.image_label.setAlignment(Qt.AlignCenter)
self.image_label.setMinimumSize(320, 240)
self.image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.image_label.setScaledContents(False)
layout.addWidget(self.image_label, 1)
self._placeholder = ""
self._populate_view_combo()
self.retranslate()
def view_mode(self) -> str:
data = self.view_combo.currentData()
return str(data or "arrows_pure")
def set_view_mode(self, mode: str) -> None:
wanted = str(mode or "arrows_pure")
if wanted not in VIEW_MODES:
wanted = "arrows_pure"
idx = self.view_combo.findData(wanted)
if idx < 0:
idx = 0
self.view_combo.blockSignals(True)
self.view_combo.setCurrentIndex(idx)
self.view_combo.blockSignals(False)
def set_fps_visible(self, visible: bool) -> None:
self._fps_visible = bool(visible)
self._refresh_fps_label()
def set_fps(self, fps: float | None) -> None:
self._fps_value = None if fps is None else float(fps)
self._refresh_fps_label()
def _refresh_fps_label(self) -> None:
if not self._fps_visible or self._fps_value is None:
self.fps_label.clear()
self.fps_label.setVisible(False)
return
self.fps_label.setText(f"{self._fps_value:.1f} FPS")
self.fps_label.setVisible(True)
def _populate_view_combo(self) -> None:
current = self.view_combo.currentData()
self.view_combo.blockSignals(True)
self.view_combo.clear()
for mode in VIEW_MODES:
self.view_combo.addItem("", mode)
if current:
idx = self.view_combo.findData(current)
self.view_combo.setCurrentIndex(max(0, idx))
self.view_combo.blockSignals(False)
def _on_view_index_changed(self, _index: int) -> None:
self.view_mode_changed.emit(self.view_mode())
def retranslate(self) -> None:
self.title.setText(t("panels.arrow"))
labels = (
t("panels.view_arrows_pure"),
t("panels.view_arrows_stitched"),
t("panels.view_depth"),
)
for i, label in enumerate(labels):
if i < self.view_combo.count():
self.view_combo.setItemText(i, label)
self.view_combo.setToolTip(t("panels.view_mode_tip"))
if not self.image_label.pixmap() or self.image_label.pixmap().isNull():
self._placeholder = t("app.scaffold_hint")
self.image_label.setText(self._placeholder)
def show_frame(self, image: QImage) -> None:
if image.isNull():
return
pix = QPixmap.fromImage(image)
scaled = pix.scaled(
self.image_label.size(),
Qt.KeepAspectRatio,
Qt.SmoothTransformation,
)
self.image_label.setPixmap(scaled)
self.image_label.setText("")
def clear_preview(self, message: str | None = None) -> None:
self.image_label.clear()
self.image_label.setPixmap(QPixmap())
text = message if message is not None else t("app.scaffold_hint")
self._placeholder = text
self.image_label.setText(text)
self.set_fps(None)
@@ -0,0 +1,338 @@
"""Force calibration sampling panel (FN / FX / FY)."""
from __future__ import annotations
import math
import time
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import (
QAbstractItemView,
QButtonGroup,
QDoubleSpinBox,
QFormLayout,
QFrame,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMessageBox,
QProgressBar,
QPushButton,
QRadioButton,
QTableWidget,
QTableWidgetItem,
QVBoxLayout,
)
from orisys_app_shared.force.calibration_io import (
CalSample,
CalSessionStore,
ChannelKey,
export_force_calibration_v2,
meta_from_sensor_json,
)
from orisys_app_shared.i18n import t
from orisys_app_shared.paths import calibration_export_dir, sensors_dir
class CalPanel(QFrame):
status_message = pyqtSignal(str)
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setObjectName("contentPanel")
self._store = CalSessionStore()
self._channel: ChannelKey = "fn"
self._running = False
self._recording = False
self._record_deadline = 0.0
self._record_started = 0.0
self._record_samples: list[float] = []
self._live_fn = 0.0
self._live_fx = 0.0
self._live_fy = 0.0
self._sensor_type = "box"
self._config_ref: str | None = None
self._record_duration_s = 7.0
root = QVBoxLayout(self)
self.title = QLabel()
self.title.setObjectName("panelTitle")
root.addWidget(self.title)
channel_row = QHBoxLayout()
self.channel_group = QButtonGroup(self)
self.radio_fn = QRadioButton("FN")
self.radio_fx = QRadioButton("FX")
self.radio_fy = QRadioButton("FY")
self.radio_fn.setChecked(True)
for i, radio in enumerate((self.radio_fn, self.radio_fx, self.radio_fy)):
self.channel_group.addButton(radio, i)
channel_row.addWidget(radio)
channel_row.addStretch(1)
self.channel_group.buttonToggled.connect(self._on_channel_button)
root.addLayout(channel_row)
form = QFormLayout()
self._weight_label = QLabel()
self.weight_spin = QDoubleSpinBox()
self.weight_spin.setRange(0.0, 1e6)
self.weight_spin.setDecimals(4)
self.weight_spin.setSingleStep(0.1)
self.weight_spin.setValue(0.0)
self._duration_label = QLabel()
self.duration_spin = QDoubleSpinBox()
self.duration_spin.setRange(5.0, 10.0)
self.duration_spin.setDecimals(1)
self.duration_spin.setSingleStep(0.5)
self.duration_spin.setValue(7.0)
self.duration_spin.setSuffix(" s")
form.addRow(self._weight_label, self.weight_spin)
form.addRow(self._duration_label, self.duration_spin)
root.addLayout(form)
rec_row = QHBoxLayout()
self.btn_record = QPushButton()
self.btn_record.clicked.connect(self._on_record_clicked)
rec_row.addWidget(self.btn_record)
rec_row.addStretch(1)
root.addLayout(rec_row)
self.progress = QProgressBar()
self.progress.setRange(0, 1000)
self.progress.setValue(0)
self.progress.setTextVisible(True)
root.addWidget(self.progress)
self.live_label = QLabel()
self.live_label.setWordWrap(True)
root.addWidget(self.live_label)
self.table = QTableWidget(0, 5)
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
self.table.verticalHeader().setVisible(False)
root.addWidget(self.table, 1)
sn_row = QHBoxLayout()
self._sn_label = QLabel()
self.sn_edit = QLineEdit()
self.sn_edit.setPlaceholderText("")
sn_row.addWidget(self._sn_label)
sn_row.addWidget(self.sn_edit, 1)
root.addLayout(sn_row)
actions = QHBoxLayout()
self.btn_delete = QPushButton()
self.btn_clear_channel = QPushButton()
self.btn_clear_all = QPushButton()
self.btn_export = QPushButton()
self.btn_delete.clicked.connect(self._on_delete_row)
self.btn_clear_channel.clicked.connect(self._on_clear_channel)
self.btn_clear_all.clicked.connect(self._on_clear_all)
self.btn_export.clicked.connect(self._on_export)
actions.addWidget(self.btn_delete)
actions.addWidget(self.btn_clear_channel)
actions.addWidget(self.btn_clear_all)
actions.addStretch(1)
actions.addWidget(self.btn_export)
root.addLayout(actions)
self._refresh_enabled()
self._refresh_table()
self.retranslate()
def set_running(self, running: bool) -> None:
self._running = bool(running)
if not self._running and self._recording:
self._cancel_recording()
self._refresh_enabled()
def set_meta_context(self, *, sensor_type: str, config_ref: str | None) -> None:
self._sensor_type = str(sensor_type or "box")
self._config_ref = config_ref
def push_raw(self, fn: float, fx: float, fy: float, _timestamp: float | None = None) -> None:
self._live_fn = float(fn)
self._live_fx = float(fx)
self._live_fy = float(fy)
self._update_live_label()
if not self._recording:
return
now = time.perf_counter()
value = {"fn": self._live_fn, "fx": self._live_fx, "fy": self._live_fy}[self._channel]
self._record_samples.append(float(value))
span = max(1e-6, self._record_deadline - self._record_started)
frac = min(1.0, max(0.0, (now - self._record_started) / span))
self.progress.setValue(int(frac * 1000))
self.progress.setFormat(f"{now - self._record_started:.1f} / {span:.1f} s")
if now >= self._record_deadline:
self._finish_recording()
def retranslate(self) -> None:
self.title.setText(t("panels.cal"))
self._weight_label.setText(t("cal.weight"))
self._duration_label.setText(t("cal.duration"))
self.btn_record.setText(t("cal.record"))
self.btn_delete.setText(t("cal.delete_row"))
self.btn_clear_channel.setText(t("cal.clear_channel"))
self.btn_clear_all.setText(t("cal.clear_all"))
self.btn_export.setText(t("cal.export"))
self._sn_label.setText(t("cal.sn"))
self.sn_edit.setPlaceholderText(t("cal.sn_placeholder"))
headers = (
t("cal.col_weight"),
t("cal.col_raw"),
t("cal.col_n"),
t("cal.col_std"),
t("cal.col_actions"),
)
for i, text in enumerate(headers):
item = self.table.horizontalHeaderItem(i)
if item is None:
item = QTableWidgetItem(text)
self.table.setHorizontalHeaderItem(i, item)
else:
item.setText(text)
self._update_live_label()
self._refresh_enabled()
def _on_channel_button(self, button, checked: bool) -> None:
if not checked:
return
mapping = {self.radio_fn: "fn", self.radio_fx: "fx", self.radio_fy: "fy"}
key = mapping.get(button)
if key is None:
return
if self._recording:
self._cancel_recording()
self._channel = key # type: ignore[assignment]
self._refresh_table()
def _refresh_enabled(self) -> None:
can_record = self._running and not self._recording
self.btn_record.setEnabled(can_record)
self.weight_spin.setEnabled(not self._recording)
self.duration_spin.setEnabled(not self._recording)
for radio in (self.radio_fn, self.radio_fx, self.radio_fy):
radio.setEnabled(not self._recording)
self.btn_export.setEnabled(not self._recording)
def _update_live_label(self) -> None:
self.live_label.setText(
t(
"cal.live_raw",
fn=f"{self._live_fn:.3f}",
fx=f"{self._live_fx:.3f}",
fy=f"{self._live_fy:.3f}",
)
)
def _on_record_clicked(self) -> None:
if not self._running or self._recording:
return
self._recording = True
self._record_samples = []
self._record_duration_s = float(self.duration_spin.value())
self._record_started = time.perf_counter()
self._record_deadline = self._record_started + self._record_duration_s
self.progress.setValue(0)
self.progress.setFormat(f"0.0 / {self._record_duration_s:.1f} s")
self._refresh_enabled()
self.status_message.emit(t("cal.recording"))
def _cancel_recording(self) -> None:
self._recording = False
self._record_samples = []
self.progress.setValue(0)
self.progress.setFormat("")
self._refresh_enabled()
def _finish_recording(self) -> None:
samples = list(self._record_samples)
self._recording = False
self._record_samples = []
self.progress.setValue(1000)
self._refresh_enabled()
if len(samples) < 2:
self.status_message.emit(t("cal.record_failed_few"))
return
mean = sum(samples) / len(samples)
var = sum((x - mean) ** 2 for x in samples) / len(samples)
std = math.sqrt(var)
sample = CalSample(
weight_n=float(self.weight_spin.value()),
raw_mean=mean,
n=len(samples),
std=std,
)
self._store.upsert(self._channel, sample)
self._refresh_table()
self.status_message.emit(
t("cal.record_ok", weight=f"{sample.weight_n:g}", raw=f"{sample.raw_mean:.3f}")
)
def _refresh_table(self) -> None:
rows = self._store.channel(self._channel)
self.table.setRowCount(len(rows))
for r, sample in enumerate(rows):
values = (
f"{sample.weight_n:g}",
f"{sample.raw_mean:.4f}",
str(sample.n),
f"{sample.std:.4f}",
"",
)
for c, text in enumerate(values):
item = QTableWidgetItem(text)
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
self.table.setItem(r, c, item)
def _on_delete_row(self) -> None:
row = self.table.currentRow()
if row < 0:
return
self._store.remove_at(self._channel, row)
self._refresh_table()
def _on_clear_channel(self) -> None:
self._store.clear(self._channel)
self._refresh_table()
def _on_clear_all(self) -> None:
self._store.clear()
self._refresh_table()
def _on_export(self) -> None:
meta = meta_from_sensor_json(self._config_ref)
meta["sensor_type"] = self._sensor_type
meta["sn"] = self.sn_edit.text().strip()
meta["record_duration_s"] = float(self.duration_spin.value())
local_bundle = sensors_dir() / self._sensor_type
if not local_bundle.is_dir():
local_bundle = None
try:
primary, local_path, warnings = export_force_calibration_v2(
self._store,
export_dir=calibration_export_dir(),
sensor_type=self._sensor_type,
meta=meta,
local_bundle_dir=local_bundle,
)
except ValueError as exc:
QMessageBox.warning(self, t("panels.cal"), str(exc))
return
except Exception as exc:
QMessageBox.warning(self, t("panels.cal"), t("cal.export_failed", error=str(exc)))
return
msg = t("cal.export_ok", path=str(primary))
if local_path is not None:
msg += "\n" + t("cal.export_local", path=str(local_path))
if warnings:
msg += "\n" + t("cal.export_warnings", detail="; ".join(warnings))
self.status_message.emit(msg)
QMessageBox.information(self, t("panels.cal"), msg)
@@ -0,0 +1,190 @@
"""FN / FX / FY force charts with live value labels."""
from __future__ import annotations
import numpy as np
from PyQt5.QtCore import Qt
from PyQt5.QtGui import QColor
from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ, LOWPASS_CUTOFF_HZ
from orisys_app_shared.force import MetricHistory
from orisys_app_shared.i18n import t
from orisys_app_shared.theme import color
try:
import pyqtgraph as pg
except ImportError as exc: # pragma: no cover
raise ImportError("pyqtgraph is required for force charts") from exc
def _nice_ticks(lo: float, hi: float, count: int = 5) -> list[tuple[float, str]]:
if hi <= lo:
return [(lo, f"{lo:g}")]
step = (hi - lo) / max(1, count - 1)
return [(lo + i * step, f"{(lo + i * step):g}") for i in range(count)]
class ForcePlotsPanel(QFrame):
def __init__(self, parent=None, *, theme: str = "dark") -> None:
super().__init__(parent)
self.setObjectName("contentPanel")
self._theme = theme
self._fn_max = 20.0
self._shear_max = 20.0
self._fixed_newton = True
self._unit = "raw"
self.history = MetricHistory(
channels=3,
window_sec=4.0,
sample_rate_hz=FORCE_SAMPLE_RATE_HZ,
lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ,
y_axis_min_span=0.5,
)
root = QVBoxLayout(self)
self.title = QLabel()
self.title.setObjectName("panelTitle")
root.addWidget(self.title)
self.plot_widget = pg.GraphicsLayoutWidget()
self.plots = []
self.curves = []
names = ("FN", "FX", "FY")
for i, name in enumerate(names):
plot = self.plot_widget.addPlot(row=i, col=0)
plot.showGrid(x=True, y=True, alpha=0.25)
plot.setLabel("left", name)
plot.setMouseEnabled(x=False, y=False)
if i > 0:
plot.setXLink(self.plots[0])
curve = plot.plot(pen=pg.mkPen("#8ab4f8", width=2))
self.plots.append(plot)
self.curves.append(curve)
root.addWidget(self.plot_widget, 1)
values_row = QHBoxLayout()
self.value_labels = []
for _ in names:
lab = QLabel("—")
lab.setAlignment(Qt.AlignCenter)
values_row.addWidget(lab)
self.value_labels.append(lab)
root.addLayout(values_row)
self.set_theme(theme)
self._apply_axis_mode()
self.retranslate()
def set_theme(self, theme: str) -> None:
self._theme = theme
bg = color(theme, "plot_bg", "#121316")
text = color(theme, "text_primary", "#e8eaed")
self.plot_widget.setBackground(bg)
axis_pen = pg.mkPen(text)
for plot in self.plots:
try:
plot.getViewBox().setBackgroundColor(QColor(bg))
except Exception:
pass
for axis_name in ("left", "bottom"):
axis = plot.getAxis(axis_name)
axis.setPen(axis_pen)
axis.setTextPen(axis_pen)
plot.showGrid(x=True, y=True, alpha=0.25)
label = plot.getAxis("left").labelText or ""
if label:
plot.setLabel("left", label, color=text)
pens = (
color(theme, "plot_fn", "#8ab4f8"),
color(theme, "plot_fx", "#34a853"),
color(theme, "plot_fy", "#fbbc04"),
)
for curve, pen in zip(self.curves, pens):
curve.setPen(pg.mkPen(pen, width=2))
def set_window_sec(self, sec: float) -> None:
self.history.set_window_sec(sec)
self._clear_curves()
self._apply_axis_mode()
def set_axis_limits(self, fn_max: float, shear_max: float) -> None:
self._fn_max = max(0.1, float(fn_max))
self._shear_max = max(0.1, float(shear_max))
self._apply_axis_mode()
def set_fixed_newton(self, enabled: bool) -> None:
self._fixed_newton = bool(enabled)
self._apply_axis_mode()
def reset(self) -> None:
self.history.reset()
self._clear_curves()
for lab in self.value_labels:
lab.setText("—")
self._apply_axis_mode()
def push(
self,
timestamp: float,
fn: float,
fx: float,
fy: float,
*,
unit: str = "raw",
) -> None:
self._unit = unit
use_fixed = self._uses_fixed_y()
plot_fn = max(0.0, float(fn)) if use_fixed else float(fn)
self.history.push(timestamp, plot_fn, float(fx), float(fy))
self._update_value_labels(fn, fx, fy)
state = self.history.state()
if state is None:
return
values = state.values.copy()
if use_fixed:
values[:, 0] = np.maximum(values[:, 0], 0.0)
for i, curve in enumerate(self.curves):
curve.setData(state.times, values[:, i])
self.plots[i].setXRange(*state.x_range, padding=0.0)
if use_fixed:
ymin, ymax = self._fixed_y_range(i)
self.plots[i].setYRange(ymin, ymax, padding=0.0)
self.plots[i].getAxis("left").setTicks([_nice_ticks(ymin, ymax, count=5)])
else:
self.plots[i].getAxis("left").setTicks(None)
self.plots[i].setYRange(*state.y_ranges[i], padding=0.0)
def _uses_fixed_y(self) -> bool:
return bool(self._fixed_newton) and self._unit == "N"
def _fixed_y_range(self, channel_index: int) -> tuple[float, float]:
if channel_index == 0:
return 0.0, self._fn_max
return -self._shear_max, self._shear_max
def _apply_axis_mode(self) -> None:
# Preview fixed newton axes when newton mode is selected, even before samples.
preview_fixed = bool(self._fixed_newton)
use_fixed = self._uses_fixed_y() or (preview_fixed and self.history.sample_count == 0)
for i, plot in enumerate(self.plots):
if use_fixed:
ymin, ymax = self._fixed_y_range(i)
plot.setYRange(ymin, ymax, padding=0.0)
plot.getAxis("left").setTicks([_nice_ticks(ymin, ymax, count=5)])
else:
plot.getAxis("left").setTicks(None)
for plot in self.plots:
plot.setXRange(0.0, self.history.window_sec, padding=0.0)
def _clear_curves(self) -> None:
for curve in self.curves:
curve.setData([], [])
def _update_value_labels(self, fn: float, fx: float, fy: float) -> None:
unit = self._unit
fmt = "{name}: {value:.4f} {unit}" if unit == "N" else "{name}: {value:.2f} {unit}"
for lab, name, value in zip(self.value_labels, ("FN", "FX", "FY"), (fn, fx, fy)):
lab.setText(fmt.format(name=name, value=value, unit=unit))
def retranslate(self) -> None:
self.title.setText(t("panels.plots"))
@@ -0,0 +1,27 @@
"""Placeholder panel for finger 3D mesh view."""
from __future__ import annotations
from PyQt5.QtCore import Qt
from PyQt5.QtWidgets import QFrame, QLabel, QVBoxLayout
from orisys_app_shared.i18n import t
class Mesh3dPanel(QFrame):
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setObjectName("contentPanel")
layout = QVBoxLayout(self)
self.title = QLabel()
self.title.setObjectName("panelTitle")
self.body = QLabel()
self.body.setAlignment(Qt.AlignCenter)
self.body.setWordWrap(True)
layout.addWidget(self.title)
layout.addWidget(self.body, 1)
self.retranslate()
def retranslate(self) -> None:
self.title.setText(t("panels.mesh3d"))
self.body.setText(t("panels.mesh3d_hint"))
@@ -0,0 +1,114 @@
"""Modal dialog for force-plot window and Y-axis limits."""
from __future__ import annotations
from PyQt5.QtWidgets import (
QDialog,
QDialogButtonBox,
QDoubleSpinBox,
QFormLayout,
QHBoxLayout,
QLabel,
QPushButton,
QVBoxLayout,
)
from orisys_app_shared.i18n import t
DEFAULT_PLOT_WINDOW_SEC = 4.0
DEFAULT_PLOT_FN_MAX = 20.0
DEFAULT_PLOT_SHEAR_MAX = 20.0
class PlotSettingsDialog(QDialog):
def __init__(
self,
parent=None,
*,
window_sec: float = DEFAULT_PLOT_WINDOW_SEC,
fn_max: float = DEFAULT_PLOT_FN_MAX,
shear_max: float = DEFAULT_PLOT_SHEAR_MAX,
units: str = "newton",
) -> None:
super().__init__(parent)
self.setModal(True)
self.resize(360, 200)
root = QVBoxLayout(self)
form = QFormLayout()
self.window_label = QLabel()
self.window_spin = QDoubleSpinBox()
self.window_spin.setRange(1.0, 30.0)
self.window_spin.setSingleStep(1.0)
self.window_spin.setDecimals(0)
self.window_spin.setSuffix(" s")
self.window_spin.setValue(float(window_sec))
self.fn_max_label = QLabel()
self.fn_max_spin = QDoubleSpinBox()
self.fn_max_spin.setRange(1.0, 100.0)
self.fn_max_spin.setSingleStep(1.0)
self.fn_max_spin.setDecimals(0)
self.fn_max_spin.setSuffix(" N")
self.fn_max_spin.setValue(float(fn_max))
self.shear_max_label = QLabel()
self.shear_max_spin = QDoubleSpinBox()
self.shear_max_spin.setRange(1.0, 100.0)
self.shear_max_spin.setSingleStep(1.0)
self.shear_max_spin.setDecimals(0)
self.shear_max_spin.setSuffix(" N")
self.shear_max_spin.setValue(float(shear_max))
form.addRow(self.window_label, self.window_spin)
form.addRow(self.fn_max_label, self.fn_max_spin)
form.addRow(self.shear_max_label, self.shear_max_spin)
root.addLayout(form)
newton = str(units or "newton").lower().startswith("n")
self.fn_max_spin.setEnabled(newton)
self.shear_max_spin.setEnabled(newton)
self.fn_max_label.setEnabled(newton)
self.shear_max_label.setEnabled(newton)
buttons_row = QHBoxLayout()
self.btn_reset = QPushButton()
self.btn_reset.clicked.connect(self._reset_defaults)
buttons_row.addWidget(self.btn_reset)
buttons_row.addStretch(1)
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
self.button_box.accepted.connect(self.accept)
self.button_box.rejected.connect(self.reject)
buttons_row.addWidget(self.button_box)
root.addLayout(buttons_row)
self.retranslate()
def values(self) -> tuple[float, float, float]:
return (
float(self.window_spin.value()),
float(self.fn_max_spin.value()),
float(self.shear_max_spin.value()),
)
def _reset_defaults(self) -> None:
self.window_spin.setValue(DEFAULT_PLOT_WINDOW_SEC)
self.fn_max_spin.setValue(DEFAULT_PLOT_FN_MAX)
self.shear_max_spin.setValue(DEFAULT_PLOT_SHEAR_MAX)
def retranslate(self) -> None:
self.setWindowTitle(t("dialog.plot_settings_title"))
self.window_label.setText(t("dialog.plot_window"))
self.window_spin.setToolTip(t("dialog.plot_window_tip"))
self.fn_max_label.setText(t("dialog.plot_fn_max"))
self.fn_max_spin.setToolTip(t("dialog.plot_fn_max_tip"))
self.shear_max_label.setText(t("dialog.plot_shear_max"))
self.shear_max_spin.setToolTip(t("dialog.plot_shear_max_tip"))
self.btn_reset.setText(t("dialog.reset_defaults"))
ok_btn = self.button_box.button(QDialogButtonBox.Ok)
cancel_btn = self.button_box.button(QDialogButtonBox.Cancel)
if ok_btn is not None:
ok_btn.setText(t("dialog.ok"))
if cancel_btn is not None:
cancel_btn.setText(t("dialog.cancel"))
@@ -0,0 +1,119 @@
"""Sensor type + config picker with thumbnail placeholder."""
from __future__ import annotations
from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import QComboBox, QFrame, QHBoxLayout, QLabel, QVBoxLayout, QWidget
from orisys_app_shared.i18n import t
from orisys_app_shared.sensors import list_configs_for_type, list_sensor_types
class SensorPickerWidget(QWidget):
sensor_type_changed = pyqtSignal(str)
config_changed = pyqtSignal(str)
def __init__(self, parent=None) -> None:
super().__init__(parent)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
self.thumb = QFrame()
self.thumb.setObjectName("contentPanel")
self.thumb.setFixedSize(72, 72)
thumb_layout = QVBoxLayout(self.thumb)
self.thumb_label = QLabel()
self.thumb_label.setAlignment(Qt.AlignCenter)
self.thumb_label.setWordWrap(True)
thumb_layout.addWidget(self.thumb_label)
form = QVBoxLayout()
form.setSpacing(4)
type_row = QHBoxLayout()
self.type_label = QLabel()
self.type_combo = QComboBox()
for name in list_sensor_types():
self.type_combo.addItem(name, name)
self.type_combo.currentIndexChanged.connect(self._on_type_index_changed)
type_row.addWidget(self.type_label)
type_row.addWidget(self.type_combo, 1)
config_row = QHBoxLayout()
self.config_label = QLabel()
self.config_combo = QComboBox()
self.config_combo.currentIndexChanged.connect(self._on_config_index_changed)
config_row.addWidget(self.config_label)
config_row.addWidget(self.config_combo, 1)
form.addLayout(type_row)
form.addLayout(config_row)
layout.addWidget(self.thumb)
layout.addLayout(form, 1)
self._reload_configs()
self._refresh_thumb()
self.retranslate()
def sensor_type(self) -> str:
data = self.type_combo.currentData()
return str(data or self.type_combo.currentText() or "box")
def config_name(self) -> str:
data = self.config_combo.currentData()
if data is None:
return str(self.config_combo.currentText() or "")
return str(data)
def set_sensor_type(self, sensor_type: str) -> None:
wanted = str(sensor_type or "box")
idx = self.type_combo.findData(wanted)
if idx < 0:
idx = self.type_combo.findText(wanted)
if idx < 0:
idx = 0
self.type_combo.blockSignals(True)
self.type_combo.setCurrentIndex(idx)
self.type_combo.blockSignals(False)
self._reload_configs()
self._refresh_thumb()
def set_config_name(self, config_name: str) -> None:
wanted = str(config_name or "")
idx = self.config_combo.findData(wanted)
if idx < 0 and wanted:
idx = self.config_combo.findText(wanted)
if idx < 0:
idx = 0
self.config_combo.blockSignals(True)
self.config_combo.setCurrentIndex(max(0, idx))
self.config_combo.blockSignals(False)
def _reload_configs(self) -> None:
self.config_combo.blockSignals(True)
self.config_combo.clear()
for display, key in list_configs_for_type(self.sensor_type()):
self.config_combo.addItem(display, key)
if self.config_combo.count() == 0:
self.config_combo.addItem(self.sensor_type(), self.sensor_type())
self.config_combo.blockSignals(False)
def _refresh_thumb(self) -> None:
name = self.sensor_type().upper()
self.thumb_label.setText(name)
def _on_type_index_changed(self, _index: int) -> None:
self._reload_configs()
self._refresh_thumb()
self.sensor_type_changed.emit(self.sensor_type())
self.config_changed.emit(self.config_name())
def _on_config_index_changed(self, _index: int) -> None:
self.config_changed.emit(self.config_name())
def retranslate(self) -> None:
self.type_label.setText(t("sensor.type"))
self.config_label.setText(t("sensor.config"))
if not self.thumb_label.text().strip():
self.thumb_label.setText(t("sensor.thumb_placeholder"))
else:
self._refresh_thumb()
@@ -0,0 +1,261 @@
"""Top toolbar: source, camera/video, run/reset, units."""
from __future__ import annotations
from pathlib import Path
from PyQt5.QtCore import pyqtSignal
from PyQt5.QtWidgets import (
QComboBox,
QHBoxLayout,
QLabel,
QLineEdit,
QPushButton,
QSpinBox,
QStackedWidget,
QWidget,
)
from orisys_app_shared.i18n import t
class ToolbarWidget(QWidget):
search_clicked = pyqtSignal()
preview_toggled = pyqtSignal(bool)
camera_id_changed = pyqtSignal(int)
source_mode_changed = pyqtSignal(str)
browse_video_clicked = pyqtSignal()
run_toggled = pyqtSignal(bool)
reset_clicked = pyqtSignal()
units_changed = pyqtSignal(str)
def __init__(self, parent=None) -> None:
super().__init__(parent)
layout = QHBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(8)
self.source_label = QLabel()
self.source_combo = QComboBox()
self.source_combo.currentIndexChanged.connect(self._on_source_index_changed)
self.source_stack = QStackedWidget()
cam_page = QWidget()
cam_layout = QHBoxLayout(cam_page)
cam_layout.setContentsMargins(0, 0, 0, 0)
cam_layout.setSpacing(8)
self.cam_id_label = QLabel()
self.cam_id_spin = QSpinBox()
self.cam_id_spin.setRange(0, 20)
self.cam_id_spin.setValue(0)
self.cam_id_spin.setMinimumWidth(72)
self.cam_id_spin.valueChanged.connect(self.camera_id_changed.emit)
cam_layout.addWidget(self.cam_id_label)
cam_layout.addWidget(self.cam_id_spin)
video_page = QWidget()
video_layout = QHBoxLayout(video_page)
video_layout.setContentsMargins(0, 0, 0, 0)
video_layout.setSpacing(8)
self.video_edit = QLineEdit()
self.video_edit.setMinimumWidth(180)
self.video_edit.setReadOnly(True)
self.btn_browse_video = QPushButton()
self.btn_browse_video.clicked.connect(self.browse_video_clicked.emit)
video_layout.addWidget(self.video_edit, 1)
video_layout.addWidget(self.btn_browse_video)
self.source_stack.addWidget(cam_page)
self.source_stack.addWidget(video_page)
self.btn_search = QPushButton()
self.btn_search.clicked.connect(self.search_clicked.emit)
self.btn_preview = QPushButton()
self.btn_preview.setCheckable(True)
self.btn_preview.toggled.connect(self._on_preview_toggled)
self.btn_run = QPushButton()
self.btn_run.setCheckable(True)
self.btn_run.toggled.connect(self._on_run_toggled)
self.btn_reset = QPushButton()
self.btn_reset.setEnabled(False)
self.btn_reset.clicked.connect(self.reset_clicked.emit)
self.units_label = QLabel()
self.units_combo = QComboBox()
self.units_combo.currentIndexChanged.connect(self._on_units_index_changed)
layout.addWidget(self.source_label)
layout.addWidget(self.source_combo)
layout.addWidget(self.source_stack, 1)
layout.addWidget(self.btn_search)
layout.addWidget(self.btn_preview)
layout.addWidget(self.btn_run)
layout.addWidget(self.btn_reset)
layout.addWidget(self.units_label)
layout.addWidget(self.units_combo)
layout.addStretch(1)
self._populate_source_combo()
self._populate_units_combo()
self.retranslate()
self._apply_source_mode_ui()
def source_mode(self) -> str:
data = self.source_combo.currentData()
return str(data or "camera")
def set_source_mode(self, mode: str) -> None:
wanted = "video" if str(mode).lower().startswith("v") else "camera"
idx = self.source_combo.findData(wanted)
if idx < 0:
idx = 0
self.source_combo.blockSignals(True)
self.source_combo.setCurrentIndex(idx)
self.source_combo.blockSignals(False)
self._apply_source_mode_ui()
def camera_id(self) -> int:
return int(self.cam_id_spin.value())
def set_camera_id(self, value: int) -> None:
self.cam_id_spin.blockSignals(True)
self.cam_id_spin.setValue(int(value))
self.cam_id_spin.blockSignals(False)
def video_path(self) -> str:
return self.video_edit.text().strip()
def set_video_path(self, path: str) -> None:
text = str(path or "").strip()
self.video_edit.setText(text)
if text:
self.video_edit.setToolTip(text)
else:
self.video_edit.setToolTip("")
def units(self) -> str:
data = self.units_combo.currentData()
return str(data or "newton")
def set_units(self, units: str) -> None:
wanted = "raw" if str(units).lower().startswith("r") else "newton"
idx = self.units_combo.findData(wanted)
if idx < 0:
idx = 0
self.units_combo.blockSignals(True)
self.units_combo.setCurrentIndex(idx)
self.units_combo.blockSignals(False)
def set_preview_checked(self, checked: bool) -> None:
self.btn_preview.blockSignals(True)
self.btn_preview.setChecked(bool(checked))
self.btn_preview.blockSignals(False)
self._refresh_preview_label()
def set_run_checked(self, checked: bool) -> None:
self.btn_run.blockSignals(True)
self.btn_run.setChecked(bool(checked))
self.btn_run.blockSignals(False)
self._refresh_run_label()
if not checked:
self.btn_reset.setEnabled(False)
def set_reset_enabled(self, enabled: bool) -> None:
self.btn_reset.setEnabled(bool(enabled))
def set_camera_controls_enabled(self, enabled: bool) -> None:
self.btn_search.setEnabled(enabled and self.source_mode() == "camera")
self.cam_id_spin.setEnabled(enabled and self.source_mode() == "camera")
def set_source_controls_enabled(self, enabled: bool) -> None:
self.source_combo.setEnabled(enabled)
self.btn_browse_video.setEnabled(enabled and self.source_mode() == "video")
self.set_camera_controls_enabled(enabled)
self.btn_preview.setEnabled(enabled and self.source_mode() == "camera")
def set_run_enabled(self, enabled: bool) -> None:
self.btn_run.setEnabled(bool(enabled))
def active_source(self) -> str | int | None:
if self.source_mode() == "video":
path = self.video_path()
return path if path else None
return self.camera_id()
def _populate_source_combo(self) -> None:
self.source_combo.blockSignals(True)
self.source_combo.clear()
self.source_combo.addItem("", "camera")
self.source_combo.addItem("", "video")
self.source_combo.blockSignals(False)
def _populate_units_combo(self) -> None:
current = self.units_combo.currentData()
self.units_combo.blockSignals(True)
self.units_combo.clear()
self.units_combo.addItem("", "newton")
self.units_combo.addItem("", "raw")
if current:
idx = self.units_combo.findData(current)
self.units_combo.setCurrentIndex(max(0, idx))
self.units_combo.blockSignals(False)
def _apply_source_mode_ui(self) -> None:
video = self.source_mode() == "video"
self.source_stack.setCurrentIndex(1 if video else 0)
self.btn_search.setEnabled(not video)
self.btn_preview.setEnabled(not video)
if video and self.btn_preview.isChecked():
self.set_preview_checked(False)
def _on_source_index_changed(self, _index: int) -> None:
self._apply_source_mode_ui()
self.source_mode_changed.emit(self.source_mode())
def _on_preview_toggled(self, checked: bool) -> None:
self._refresh_preview_label()
self.preview_toggled.emit(bool(checked))
def _on_run_toggled(self, checked: bool) -> None:
self._refresh_run_label()
if not checked:
self.btn_reset.setEnabled(False)
self.run_toggled.emit(bool(checked))
def _on_units_index_changed(self, _index: int) -> None:
self.units_changed.emit(self.units())
def _refresh_preview_label(self) -> None:
if self.btn_preview.isChecked():
self.btn_preview.setText(t("toolbar.preview_stop"))
else:
self.btn_preview.setText(t("toolbar.preview"))
def _refresh_run_label(self) -> None:
if self.btn_run.isChecked():
self.btn_run.setText("■")
self.btn_run.setToolTip(t("toolbar.stop"))
else:
self.btn_run.setText("▶")
self.btn_run.setToolTip(t("toolbar.run"))
def retranslate(self) -> None:
self.source_label.setText(t("toolbar.source"))
self.source_combo.setItemText(0, t("toolbar.source_camera"))
self.source_combo.setItemText(1, t("toolbar.source_video"))
self.cam_id_label.setText(t("toolbar.cam_id"))
self.btn_browse_video.setText(t("toolbar.browse_video"))
path = self.video_path()
self.video_edit.setPlaceholderText(
Path(path).name if path else t("toolbar.video_placeholder")
)
self.btn_search.setText(t("toolbar.search_cameras"))
self._refresh_preview_label()
self._refresh_run_label()
self.btn_reset.setText(t("toolbar.reset"))
self.units_label.setText(t("toolbar.units"))
self.units_combo.setItemText(0, t("toolbar.units_newton"))
self.units_combo.setItemText(1, t("toolbar.units_raw"))
+5
View File
@@ -0,0 +1,5 @@
# Internal app (stub)
Future home for automated calibration and laboratory testing tools.
This directory is intentionally empty in the customer-facing scaffold. Shared code will come from `shared/`; do not ship this app in the customer installer.
+80
View File
@@ -0,0 +1,80 @@
# Build
Windows packaging for the customer desktop app.
## Layout
```text
build/
customer/
entry.py # frozen entry (no source chdir)
orisys-customer.spec # PyInstaller onedir spec
build.ps1 # create/reuse .venv-build and build
_pyinstaller/ # PyInstaller workpath (gitignored)
dist/
OrisysCustomer/
OrisysCustomer.exe
config/ # lean locales + sensors (no finger visualization/)
_internal/ # Python, PyQt5, opencv-headless, orisys, …
```
## Prerequisites
- Windows + Python 3.10+ on PATH
- `orisys` installable into the build venv (`pip install orisys>=0.5`)
- Do **not** install `orisys[gui]` / Open3D for this build
## Build
From `build\customer` (recommended):
```bat
build.bat
build.bat -Clean
build.bat -SkipInstall
```
Or from repo root:
```powershell
powershell -ExecutionPolicy Bypass -File .\build\customer\build.ps1
```
`build.bat` always resolves `build.ps1` next to itself (cwd-independent) and forwards flags.
Flags:
| Flag | Meaning |
|------|---------|
| `-Clean` | Pass `--clean` to PyInstaller (use after dep/spec changes) |
| `-SkipInstall` | Reuse packages already in `.venv-build` (faster iteration) |
Output: `dist\OrisysCustomer\OrisysCustomer.exe`
## Dependency notes
- App shared dep uses **`opencv-python-headless`** (PyQt owns the GUI).
- Upstream `orisys` may still declare `opencv-python`. `build.ps1` uninstalls that wheel and installs **`opencv-python-headless>=4.10,<5`** (5.x has shipped broken wheels without a `cv2` module). The script verifies `import cv2` before packaging.
- **Open3D is excluded** (spec + build env). Finger 3D remains a placeholder in the UI.
## Size / startup
- **onedir** (not onefile): faster cold start; ship the whole `OrisysCustomer` folder.
- Finger `assets/visualization/` is stripped from the shipped `config/` (~11 MB saved).
- Customer shell defers OpenCV / acquisition imports until Preview or Run.
Expect hundreds of MB total (Qt + numpy/scipy/numba + OpenCV). Without Open3D this stays smaller than a full `orisys[gui]` bundle.
## Verification
1. Launch `OrisysCustomer.exe` on the build machine (and ideally a clean PC without Python).
2. Theme / locale / prefs work; `preference.json` and `outputs\` appear next to the EXE.
3. Camera preview + box/mini/finger Run (video) with image modes and force plots.
4. Confirm no `open3d*` files under `dist\OrisysCustomer`.
## Editable run (unchanged)
```powershell
$env:PYTHONPATH = "D:\repo\orisys-app\shared\src;D:\repo\orisys-app\apps\customer\src"
python D:\repo\orisys-app\apps\customer\run.py
```
+20
View File
@@ -0,0 +1,20 @@
@echo off
setlocal EnableExtensions
REM Build Orisys Customer EXE. Works from any cwd; forwards args to build.ps1
REM Examples:
REM build.bat
REM build.bat -Clean
REM build.bat -SkipInstall
REM build.bat -Clean -SkipInstall
set "SCRIPT_DIR=%~dp0"
set "PS1=%SCRIPT_DIR%build.ps1"
if not exist "%PS1%" (
echo ERROR: build.ps1 not found at "%PS1%"
exit /b 1
)
powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%PS1%" %*
set "ERR=%ERRORLEVEL%"
exit /b %ERR%
+116
View File
@@ -0,0 +1,116 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Build Orisys Customer Windows onedir EXE with PyInstaller.
.PARAMETER Clean
Pass --clean to PyInstaller (slower; use when deps/spec change).
.PARAMETER SkipInstall
Skip pip install steps (reuse existing .venv-build packages).
#>
param(
[switch]$Clean,
[switch]$SkipInstall
)
$ErrorActionPreference = "Stop"
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
$VenvDir = Join-Path $RepoRoot ".venv-build"
$Python = Join-Path $VenvDir "Scripts\python.exe"
$Spec = Join-Path $PSScriptRoot "orisys-customer.spec"
$WorkPath = Join-Path $RepoRoot "build\_pyinstaller"
$DistPath = Join-Path $RepoRoot "dist"
Write-Host "Repo: $RepoRoot"
if (-not (Test-Path $Python)) {
Write-Host "Creating build venv at $VenvDir"
python -m venv $VenvDir
}
if (-not $SkipInstall) {
Write-Host "Installing build dependencies (editable shared + customer + orisys + pyinstaller)…"
& $Python -m pip install --upgrade pip
& $Python -m pip install "pyinstaller>=6.0"
& $Python -m pip install -e (Join-Path $RepoRoot "shared")
& $Python -m pip install -e (Join-Path $RepoRoot "apps\customer")
# orisys may already be installed; ensure present without [gui]/open3d
& $Python -m pip install "orisys>=0.5"
Write-Host "Forcing opencv-python-headless 4.x (orisys may have pulled opencv-python; 5.x can be broken)…"
& $Python -m pip uninstall -y opencv-python opencv-python-headless 2>$null
& $Python -m pip install "opencv-python-headless>=4.10.0.84,<5"
& $Python -c "import cv2; print('cv2 OK', cv2.__version__)"
if ($LASTEXITCODE -ne 0) {
Write-Error "cv2 import failed after installing opencv-python-headless"
exit $LASTEXITCODE
}
# Guard: Open3D must not be present in the build env
$hasOpen3d = & $Python -c "import importlib.util; print(importlib.util.find_spec('open3d') is not None)"
if ($hasOpen3d -eq "True") {
Write-Host "Uninstalling open3d from build venv…"
& $Python -m pip uninstall -y open3d
}
}
$pyiArgs = @(
"--noconfirm",
"--distpath", $DistPath,
"--workpath", $WorkPath,
$Spec
)
if ($Clean) {
$pyiArgs = @("--clean") + $pyiArgs
}
Write-Host "Running PyInstaller…"
& $Python -m PyInstaller @pyiArgs
if ($LASTEXITCODE -ne 0) {
Write-Error "PyInstaller failed with exit code $LASTEXITCODE"
exit $LASTEXITCODE
}
$OutDir = Join-Path $DistPath "OrisysCustomer"
Write-Host ""
Write-Host "Build complete: $OutDir"
# Lean config beside EXE (exclude finger visualization assets)
$ConfigSrc = Join-Path $RepoRoot "apps\customer\config"
$ConfigDst = Join-Path $OutDir "config"
if (Test-Path $ConfigSrc) {
Write-Host "Copying lean config to $ConfigDst …"
if (Test-Path $ConfigDst) {
Remove-Item $ConfigDst -Recurse -Force
}
New-Item -ItemType Directory -Path $ConfigDst -Force | Out-Null
Get-ChildItem $ConfigSrc -Force | ForEach-Object {
Copy-Item $_.FullName -Destination $ConfigDst -Recurse -Force
}
$viz = Join-Path $ConfigDst "sensors\finger\assets\visualization"
if (Test-Path $viz) {
Remove-Item $viz -Recurse -Force
Write-Host "Excluded sensors/finger/assets/visualization"
}
}
Write-Host "Launch: $(Join-Path $OutDir 'OrisysCustomer.exe')"
# Size summary
if (Test-Path $OutDir) {
$bytes = (Get-ChildItem $OutDir -Recurse -File -ErrorAction SilentlyContinue |
Measure-Object -Property Length -Sum).Sum
$mb = [math]::Round($bytes / 1MB, 1)
Write-Host ("Output size: {0} MB" -f $mb)
$o3dHits = Get-ChildItem $OutDir -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match 'open3d' }
if ($o3dHits) {
Write-Warning "Open3D-related files found under dist (should be empty):"
$o3dHits | ForEach-Object { Write-Warning $_.FullName }
} else {
Write-Host "Open3D check: none found under dist."
}
}
+8
View File
@@ -0,0 +1,8 @@
"""Frozen entry for Orisys Customer (no source-tree chdir)."""
from __future__ import annotations
from orisys_customer.main import main
if __name__ == "__main__":
raise SystemExit(main())
+131
View File
@@ -0,0 +1,131 @@
# -*- mode: python ; coding: utf-8 -*-
"""PyInstaller onedir spec for Orisys Customer (Windows)."""
from __future__ import annotations
from pathlib import Path
from PyInstaller.building.api import COLLECT, EXE, PYZ
from PyInstaller.building.build_main import Analysis
from PyInstaller.utils.hooks import collect_all, collect_data_files
SPEC_DIR = Path(SPECPATH).resolve()
REPO_ROOT = SPEC_DIR.parents[1]
BRANDING_DIR = REPO_ROOT / "shared" / "resources" / "branding"
ENTRY = SPEC_DIR / "entry.py"
def _branding_datas() -> list[tuple[str, str]]:
datas: list[tuple[str, str]] = []
if not BRANDING_DIR.is_dir():
return datas
for path in BRANDING_DIR.rglob("*"):
if path.is_file():
datas.append((str(path), "branding"))
return datas
block_cipher = None
# Config is copied beside the EXE by build.ps1 (writable / editable).
# Branding + package data go into _internal via Analysis datas.
datas = _branding_datas()
binaries: list = []
hiddenimports = [
"cv2",
"PyQt5.sip",
"pyqtgraph.graphicsItems",
"scipy.signal",
"numba",
]
datas += collect_data_files("orisys_app_shared", includes=["theme/*.json"])
try:
datas += collect_data_files("orisys")
except Exception:
pass
try:
cv2_datas, cv2_binaries, cv2_hidden = collect_all("cv2")
datas += cv2_datas
binaries += cv2_binaries
hiddenimports += list(cv2_hidden)
except Exception:
pass
icon_path = BRANDING_DIR / "logo.ico"
icon = str(icon_path) if icon_path.is_file() else None
a = Analysis(
[str(ENTRY)],
pathex=[
str(REPO_ROOT / "apps" / "customer" / "src"),
str(REPO_ROOT / "shared" / "src"),
],
binaries=binaries,
datas=datas,
hiddenimports=hiddenimports,
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[
"open3d",
"orisys.finger.viewer",
"tkinter",
"matplotlib",
"IPython",
"jupyter",
"notebook",
"pytest",
"PyQt5.QtBluetooth",
"PyQt5.QtNfc",
"PyQt5.QtPositioning",
"PyQt5.QtLocation",
"PyQt5.QtMultimedia",
"PyQt5.QtMultimediaWidgets",
"PyQt5.QtSensors",
"PyQt5.QtSerialPort",
"PyQt5.QtWebChannel",
"PyQt5.QtWebEngine",
"PyQt5.QtWebEngineCore",
"PyQt5.QtWebEngineWidgets",
"PyQt5.QtWebSockets",
"PySide2",
"PySide6",
"PyQt6",
],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True,
name="OrisysCustomer",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False,
console=False,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon=icon,
)
coll = COLLECT(
exe,
a.binaries,
a.zipfiles,
a.datas,
strip=False,
upx=False,
upx_exclude=[],
name="OrisysCustomer",
)
+20
View File
@@ -0,0 +1,20 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "orisys-app"
version = "0.1.0"
description = "Orisys desktop applications (customer + future internal)"
requires-python = ">=3.10"
readme = "README.md"
authors = [{ name = "Orisys-Tech" }]
[project.optional-dependencies]
dev = [
"orisys-app-shared @ file:./shared",
"orisys-customer @ file:./apps/customer",
]
[tool.setuptools]
packages = []
+27
View File
@@ -0,0 +1,27 @@
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "orisys-app-shared"
version = "0.1.0"
description = "Shared paths, prefs, theme, i18n, and logging for orisys-app"
requires-python = ">=3.10"
authors = [{ name = "Orisys-Tech" }]
dependencies = [
"PyQt5>=5.15",
"opencv-python-headless>=4.10.0.84,<5",
"numpy>=1.24.4",
"scipy>=1.10.1",
"pyqtgraph>=0.13",
"orisys>=0.5",
]
[tool.setuptools]
package-dir = { "" = "src" }
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
orisys_app_shared = ["theme/*.json"]
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

+4
View File
@@ -0,0 +1,4 @@
"""Shared package for orisys-app desktop products."""
__version__ = "0.1.0"
API_VERSION = "0.1"
@@ -0,0 +1,45 @@
"""Acquisition helpers."""
from orisys_app_shared.acquisition.constants import (
FORCE_SAMPLE_RATE_HZ,
LOWPASS_CUTOFF_HZ,
)
__all__ = [
"FORCE_SAMPLE_RATE_HZ",
"LOWPASS_CUTOFF_HZ",
"FingerAcquisitionWorker",
"SdkAcquisitionWorker",
"SdkPacket",
"depth_map_to_bgr",
"draw_arrow_display",
"hex_to_bgr",
"load_fixed_mode",
"orisys_importable",
"resolve_finger_export_dir",
]
_LAZY_ATTRS = {
"FingerAcquisitionWorker": ("orisys_app_shared.acquisition.finger_worker", "FingerAcquisitionWorker"),
"resolve_finger_export_dir": ("orisys_app_shared.acquisition.finger_worker", "resolve_finger_export_dir"),
"SdkAcquisitionWorker": ("orisys_app_shared.acquisition.sdk_worker", "SdkAcquisitionWorker"),
"SdkPacket": ("orisys_app_shared.acquisition.sdk_worker", "SdkPacket"),
"load_fixed_mode": ("orisys_app_shared.acquisition.sdk_worker", "load_fixed_mode"),
"orisys_importable": ("orisys_app_shared.acquisition.sdk_worker", "orisys_importable"),
"depth_map_to_bgr": ("orisys_app_shared.acquisition.arrows", "depth_map_to_bgr"),
"draw_arrow_display": ("orisys_app_shared.acquisition.arrows", "draw_arrow_display"),
"hex_to_bgr": ("orisys_app_shared.acquisition.arrows", "hex_to_bgr"),
}
def __getattr__(name: str):
target = _LAZY_ATTRS.get(name)
if target is None:
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
module_name, attr = target
import importlib
module = importlib.import_module(module_name)
value = getattr(module, attr)
globals()[name] = value
return value
@@ -0,0 +1,93 @@
"""Arrow overlay helpers for processed tactile frames."""
from __future__ import annotations
from typing import Optional, Sequence
import cv2
import numpy as np
ARROW_BG_BGR = (0x16, 0x13, 0x12)
_ARROW_DRAW_KW = dict(
threshold=2,
grid_spacing=20,
arrow_scale=0.5,
below_threshold_color=(160, 160, 160),
)
_FINGER_ARROW_DRAW_KW = dict(
threshold=0.4,
grid_spacing=10,
arrow_scale=1.0,
below_threshold_color=(160, 160, 160),
)
def hex_to_bgr(hex_color: str, fallback: tuple[int, int, int] = ARROW_BG_BGR) -> tuple[int, int, int]:
text = str(hex_color or "").strip().lstrip("#")
if len(text) != 6:
return fallback
try:
r = int(text[0:2], 16)
g = int(text[2:4], 16)
b = int(text[4:6], 16)
except ValueError:
return fallback
return (b, g, r)
def _ensure_bgr(img: np.ndarray) -> np.ndarray:
if img.ndim == 2:
return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
if img.ndim == 3 and img.shape[2] == 1:
return cv2.cvtColor(img[:, :, 0], cv2.COLOR_GRAY2BGR)
if img.ndim == 3 and img.shape[2] == 4:
return cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
return img
def draw_arrow_display(
processed_frame: np.ndarray,
flow: Optional[np.ndarray],
*,
background: str = "pure_color",
canvas_bgr: Sequence[int] | None = None,
draw_style: str = "box",
valid_mask: Optional[np.ndarray] = None,
) -> np.ndarray:
"""Draw flow arrows on a solid canvas or over the processed frame."""
base = _ensure_bgr(processed_frame)
if flow is None:
return base
draw_kw = _FINGER_ARROW_DRAW_KW if str(draw_style).lower().startswith("f") else _ARROW_DRAW_KW
try:
import orisys
if background == "pure_color":
canvas = np.empty_like(base)
color = ARROW_BG_BGR if canvas_bgr is None else tuple(int(v) for v in canvas_bgr)
canvas[:, :] = color
return orisys.util.draw_arrows(
canvas, flow, valid_mask=valid_mask, **draw_kw
)
return orisys.util.draw_arrows(base, flow, valid_mask=valid_mask, **draw_kw)
except Exception:
return base
def depth_map_to_bgr(depth: Optional[np.ndarray]) -> Optional[np.ndarray]:
"""Convert a float depth/divergence map to a JET colormap BGR image."""
if depth is None:
return None
arr = np.asarray(depth, dtype=np.float64)
if arr.size == 0 or arr.ndim < 2:
return None
finite = np.isfinite(arr)
if not np.any(finite):
return None
lo = float(np.nanmin(arr[finite]))
hi = float(np.nanmax(arr[finite]))
if hi > lo:
normalized = ((arr - lo) / (hi - lo) * 255.0).astype(np.uint8)
else:
normalized = np.zeros(arr.shape, dtype=np.uint8)
return cv2.applyColorMap(normalized, cv2.COLORMAP_JET)
@@ -0,0 +1,4 @@
"""Shared acquisition constants (no heavy imports)."""
LOWPASS_CUTOFF_HZ = 10.0
FORCE_SAMPLE_RATE_HZ = 30.0
@@ -0,0 +1,296 @@
"""Orisys finger acquisition worker (FingerRuntime → SdkPacket)."""
from __future__ import annotations
import threading
import time
from pathlib import Path
from typing import Callable, Optional
import numpy as np
from orisys_app_shared.acquisition.constants import (
FORCE_SAMPLE_RATE_HZ,
LOWPASS_CUTOFF_HZ,
)
from orisys_app_shared.acquisition.sdk_worker import (
CAMERA_DISCONNECT_TIMEOUT_S,
VIDEO_EOF_TIMEOUT_S,
FixedCameraMode,
SdkPacket,
_ensure_bgr,
_is_file_source,
load_fixed_mode,
orisys_importable,
read_forces_from_sensor,
)
_EXPORT_REQUIRED = ("mesh.npz", "camera_intrinsics.npz", "world_to_cam.npy")
def resolve_finger_export_dir(config_ref: str | Path) -> Path | None:
"""Return local ``assets/30fps-0703`` when present and complete.
Missing folder → ``None`` (SDK default export). Incomplete folder → error
so Run does not silently fall back to bundled assets.
"""
path = Path(str(config_ref))
if path.is_file() and path.name.lower() == "sensor.json":
candidate = path.parent / "assets" / "30fps-0703"
elif path.is_dir():
candidate = path / "assets" / "30fps-0703"
else:
return None
if not candidate.is_dir():
return None
missing = [name for name in _EXPORT_REQUIRED if not (candidate / name).is_file()]
if missing:
raise FileNotFoundError(
f"Incomplete finger export at {candidate}: missing {', '.join(missing)}"
)
return candidate.resolve()
class FingerAcquisitionWorker:
"""Background loop: FingerRuntime grab/unroll + deformation + force readout."""
def __init__(
self,
*,
on_error: Optional[Callable[[str], None]] = None,
on_status: Optional[Callable[[str], None]] = None,
):
self._on_error = on_error
self._on_status = on_status
self._thread: Optional[threading.Thread] = None
self._stop = threading.Event()
self._lock = threading.Lock()
self._latest: Optional[SdkPacket] = None
self._session_id = 0
self._runtime = None
self._mode: Optional[FixedCameraMode] = None
self._video_source: Optional[str | int] = None
self._export_dir: Optional[Path] = None
self._end_reason: Optional[str] = None
self._phase: str = "idle"
@property
def is_running(self) -> bool:
return self._thread is not None and self._thread.is_alive()
@property
def phase(self) -> str:
with self._lock:
return self._phase
def latest(self) -> Optional[SdkPacket]:
with self._lock:
return self._latest
def consume_end_reason(self) -> Optional[str]:
with self._lock:
reason = self._end_reason
self._end_reason = None
return reason
def start(
self,
video_source: str | int,
*,
config_ref: str | Path,
) -> FixedCameraMode:
self.stop()
ok, detail = orisys_importable()
if not ok:
raise RuntimeError(f"orisys SDK is not importable: {detail}")
mode = load_fixed_mode(config_ref)
self._mode = mode
self._video_source = video_source
self._export_dir = resolve_finger_export_dir(config_ref)
with self._lock:
self._latest = None
self._end_reason = None
self._phase = "connecting"
self._session_id += 1
session = self._session_id
self._stop.clear()
self._thread = threading.Thread(
target=self._run,
name=f"FingerAcquisition-{session}",
daemon=True,
args=(session,),
)
self._thread.start()
return mode
def stop(self) -> None:
self._set_end_reason("stopped")
self._stop.set()
thread = self._thread
if thread is not None and thread.is_alive():
thread.join(timeout=8.0)
self._thread = None
self._close_runtime()
with self._lock:
self._latest = None
self._phase = "idle"
def reset(self) -> None:
with self._lock:
runtime = self._runtime
if runtime is None:
raise RuntimeError("Finger runtime is not running")
runtime.tactile.reset()
def _set_end_reason(self, reason: str) -> None:
with self._lock:
if self._end_reason is None:
self._end_reason = reason
def _set_phase(self, phase: str) -> None:
with self._lock:
self._phase = phase
def _close_runtime(self) -> None:
runtime = self._runtime
self._runtime = None
if runtime is None:
return
try:
runtime.close()
except Exception:
pass
def _status(self, message: str) -> None:
if self._on_status is not None:
self._on_status(message)
def _run(self, session: int) -> None:
from orisys.finger import FingerRuntime
mode = self._mode
source = self._video_source
if mode is None or source is None:
return
is_file = _is_file_source(source)
try:
self._status(f"Opening FingerRuntime ({mode.label()})…")
open_kw: dict = {
"config": mode.config_ref,
"video": source,
"verbose": False,
}
if self._export_dir is not None:
open_kw["export_dir"] = str(self._export_dir)
self._runtime = FingerRuntime.open(**open_kw)
if self._stop.is_set() or session != self._session_id:
self._close_runtime()
return
self._runtime.warmup(color=False)
if self._stop.is_set() or session != self._session_id:
self._close_runtime()
return
raw_sensor = getattr(self._runtime.tactile, "raw_sensor", None)
if raw_sensor is not None and hasattr(raw_sensor, "configure"):
try:
raw_sensor.configure(
lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ,
lowpass_sample_rate_hz=FORCE_SAMPLE_RATE_HZ,
)
except Exception:
pass
self._status(f"Streaming finger {mode.label()}")
except Exception as exc:
self._set_end_reason("error")
if self._on_error is not None and session == self._session_id:
self._on_error(str(exc))
self._set_phase("idle")
return
frame_times: list[float] = []
saw_frame = False
last_frame_mono = 0.0
none_streak_start: Optional[float] = None
runtime = self._runtime
while not self._stop.is_set() and session == self._session_id and runtime is not None:
try:
now = time.perf_counter()
if saw_frame:
if is_file:
if none_streak_start is not None and (
now - none_streak_start
) >= VIDEO_EOF_TIMEOUT_S:
self._set_end_reason("ended")
break
else:
if (now - last_frame_mono) >= CAMERA_DISCONNECT_TIMEOUT_S:
self._set_end_reason("disconnected")
break
t0 = time.perf_counter()
ok, source_frame = runtime.read_source()
if not ok or source_frame is None:
if saw_frame and is_file and none_streak_start is None:
none_streak_start = time.perf_counter()
time.sleep(0.005)
continue
unrolled = runtime.process_frame(source_frame, color=False)
if unrolled is None:
if saw_frame and is_file and none_streak_start is None:
none_streak_start = time.perf_counter()
time.sleep(0.005)
continue
none_streak_start = None
tactile = runtime.tactile
try:
tactile.compute_deformation(decompose=True, check_motion=False)
tactile.compute_contact()
except Exception:
pass
t1 = time.perf_counter()
frame_times.append(t1)
cutoff = t1 - 1.0
frame_times = [t for t in frame_times if t >= cutoff]
fps = float(len(frame_times)) if frame_times else 0.0
forces = read_forces_from_sensor(
tactile,
config_ref=mode.config_ref,
)
packet = SdkPacket(
raw_frame=_ensure_bgr(source_frame),
processed_frame=_ensure_bgr(unrolled),
timestamp=time.time(),
fps=fps,
process_ms=(t1 - t0) * 1000.0,
normal_force=forces["normal_force"],
shear_x=forces["shear_x"],
shear_y=forces["shear_y"],
normal_force_raw=forces["normal_force_raw"],
shear_x_raw=forces["shear_x_raw"],
shear_y_raw=forces["shear_y_raw"],
force_unit=forces["force_unit"],
force_calibrated=forces["force_calibrated"],
session_id=session,
flow=forces["flow"],
depth=forces.get("depth"),
valid_mask=runtime.valid_mask,
)
with self._lock:
self._latest = packet
self._phase = "running"
saw_frame = True
last_frame_mono = t1
except Exception as exc:
self._set_end_reason("error")
if self._on_error is not None and session == self._session_id:
self._on_error(str(exc))
break
self._close_runtime()
self._set_phase("idle")
if self._stop.is_set():
self._set_end_reason("stopped")
self._status("Finger acquisition stopped")
@@ -0,0 +1,430 @@
"""Orisys SDK acquisition worker for box/mini-style sensors."""
from __future__ import annotations
import json
import threading
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Optional
import cv2
import numpy as np
from orisys_app_shared.acquisition.constants import (
FORCE_SAMPLE_RATE_HZ,
LOWPASS_CUTOFF_HZ,
)
VIDEO_EOF_TIMEOUT_S = 0.5
CAMERA_DISCONNECT_TIMEOUT_S = 1.5
@dataclass(frozen=True)
class FixedCameraMode:
width: int
height: int
fps: float
config_ref: str
def label(self) -> str:
return f"{self.width}x{self.height} @{self.fps:g}"
@dataclass
class SdkPacket:
raw_frame: np.ndarray
processed_frame: np.ndarray
timestamp: float
fps: float
process_ms: float
normal_force: float = 0.0
shear_x: float = 0.0
shear_y: float = 0.0
normal_force_raw: float = 0.0
shear_x_raw: float = 0.0
shear_y_raw: float = 0.0
force_unit: str = "raw"
force_calibrated: bool = False
session_id: int = 0
flow: Optional[np.ndarray] = None
depth: Optional[np.ndarray] = None
valid_mask: Optional[np.ndarray] = None
def orisys_importable() -> tuple[bool, str]:
try:
import orisys # noqa: F401
return True, getattr(__import__("orisys"), "__version__", "unknown")
except Exception as exc: # noqa: BLE001
return False, str(exc)
def load_fixed_mode(config_ref: str | Path) -> FixedCameraMode:
ref = str(config_ref).strip()
path = Path(ref)
if path.is_file():
data = json.loads(path.read_text(encoding="utf-8"))
resolved = str(path.resolve())
else:
import orisys
data = orisys.configs.load_config(ref)
resolved = ref
camera = data.get("camera") or {}
raw = camera.get("resolution_raw") or [1920, 1080]
fps = float(camera.get("fps") or 30.0)
return FixedCameraMode(
width=int(raw[0]),
height=int(raw[1]),
fps=fps,
config_ref=resolved,
)
def _ensure_bgr(img: np.ndarray) -> np.ndarray:
if img.ndim == 2:
return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
if img.ndim == 3 and img.shape[2] == 1:
return cv2.cvtColor(img[:, :, 0], cv2.COLOR_GRAY2BGR)
if img.ndim == 3 and img.shape[2] == 4:
return cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
return img
def _sensor_force_calibrated(sensor, *, config_ref: str | Path | None = None) -> bool:
if getattr(sensor, "_force_cal_sensor_name", None) is not None:
return True
if config_ref is None:
return False
try:
import orisys
path = Path(str(config_ref))
name = path.parent.name if path.name == "sensor.json" else path.stem
return orisys.configs.load_force_calibration(name) is not None
except Exception:
return False
def read_forces_from_sensor(sensor, *, config_ref: str | Path | None = None):
info = sensor.info
fn = fx = fy = 0.0
fn_raw = fx_raw = fy_raw = 0.0
flow = None
depth = None
read_raw = all(
hasattr(info, name)
for name in ("FNORMAL_RAW", "FSHEARX_RAW", "FSHEARY_RAW")
)
has_flow = hasattr(info, "VRAW")
has_depth = hasattr(info, "DEPTH")
try:
if read_raw and has_flow and has_depth:
fn, fx, fy, fn_raw, fx_raw, fy_raw, flow, depth = sensor.read_info(
info.FNORMAL,
info.FSHEARX,
info.FSHEARY,
info.FNORMAL_RAW,
info.FSHEARX_RAW,
info.FSHEARY_RAW,
info.VRAW,
info.DEPTH,
)
elif read_raw and has_flow:
fn, fx, fy, fn_raw, fx_raw, fy_raw, flow = sensor.read_info(
info.FNORMAL,
info.FSHEARX,
info.FSHEARY,
info.FNORMAL_RAW,
info.FSHEARX_RAW,
info.FSHEARY_RAW,
info.VRAW,
)
elif read_raw and has_depth:
fn, fx, fy, fn_raw, fx_raw, fy_raw, depth = sensor.read_info(
info.FNORMAL,
info.FSHEARX,
info.FSHEARY,
info.FNORMAL_RAW,
info.FSHEARX_RAW,
info.FSHEARY_RAW,
info.DEPTH,
)
elif read_raw:
fn, fx, fy, fn_raw, fx_raw, fy_raw = sensor.read_info(
info.FNORMAL,
info.FSHEARX,
info.FSHEARY,
info.FNORMAL_RAW,
info.FSHEARX_RAW,
info.FSHEARY_RAW,
)
elif has_flow and has_depth:
fn, fx, fy, flow, depth = sensor.read_info(
info.FNORMAL, info.FSHEARX, info.FSHEARY, info.VRAW, info.DEPTH
)
fn_raw, fx_raw, fy_raw = fn, fx, fy
elif has_flow:
fn, fx, fy, flow = sensor.read_info(
info.FNORMAL, info.FSHEARX, info.FSHEARY, info.VRAW
)
fn_raw, fx_raw, fy_raw = fn, fx, fy
elif has_depth:
fn, fx, fy, depth = sensor.read_info(
info.FNORMAL, info.FSHEARX, info.FSHEARY, info.DEPTH
)
fn_raw, fx_raw, fy_raw = fn, fx, fy
else:
fn, fx, fy = sensor.read_info(info.FNORMAL, info.FSHEARX, info.FSHEARY)
fn_raw, fx_raw, fy_raw = fn, fx, fy
except Exception:
pass
if depth is not None:
arr = np.asarray(depth)
if arr.size == 0:
depth = None
calibrated = _sensor_force_calibrated(sensor, config_ref=config_ref)
return {
"normal_force": float(fn),
"shear_x": float(fx),
"shear_y": float(fy),
"normal_force_raw": float(fn_raw),
"shear_x_raw": float(fx_raw),
"shear_y_raw": float(fy_raw),
"force_calibrated": calibrated,
"force_unit": "N" if calibrated else "raw",
"flow": flow,
"depth": depth,
}
def _is_file_source(video_source: str | int) -> bool:
return isinstance(video_source, str)
class SdkAcquisitionWorker:
"""Background loop: Sensor.get_img + deformation + force readout."""
def __init__(
self,
*,
on_error: Optional[Callable[[str], None]] = None,
on_status: Optional[Callable[[str], None]] = None,
):
self._on_error = on_error
self._on_status = on_status
self._thread: Optional[threading.Thread] = None
self._stop = threading.Event()
self._lock = threading.Lock()
self._latest: Optional[SdkPacket] = None
self._session_id = 0
self._sensor = None
self._mode: Optional[FixedCameraMode] = None
self._video_source: Optional[str | int] = None
self._end_reason: Optional[str] = None
self._phase: str = "idle"
@property
def is_running(self) -> bool:
return self._thread is not None and self._thread.is_alive()
@property
def phase(self) -> str:
with self._lock:
return self._phase
def latest(self) -> Optional[SdkPacket]:
with self._lock:
return self._latest
def consume_end_reason(self) -> Optional[str]:
with self._lock:
reason = self._end_reason
self._end_reason = None
return reason
def start(
self,
video_source: str | int,
*,
config_ref: str | Path,
) -> FixedCameraMode:
self.stop()
ok, detail = orisys_importable()
if not ok:
raise RuntimeError(f"orisys SDK is not importable: {detail}")
mode = load_fixed_mode(config_ref)
self._mode = mode
self._video_source = video_source
with self._lock:
self._latest = None
self._end_reason = None
self._phase = "connecting"
self._session_id += 1
session = self._session_id
self._stop.clear()
self._thread = threading.Thread(
target=self._run,
name=f"SdkAcquisition-{session}",
daemon=True,
args=(session,),
)
self._thread.start()
return mode
def stop(self) -> None:
self._set_end_reason("stopped")
self._stop.set()
thread = self._thread
if thread is not None and thread.is_alive():
thread.join(timeout=5.0)
self._thread = None
self._close_sensor()
with self._lock:
self._latest = None
self._phase = "idle"
def reset(self) -> None:
with self._lock:
sensor = self._sensor
if sensor is None:
raise RuntimeError("Sensor is not running")
sensor.reset()
def _set_end_reason(self, reason: str) -> None:
with self._lock:
if self._end_reason is None:
self._end_reason = reason
def _set_phase(self, phase: str) -> None:
with self._lock:
self._phase = phase
def _close_sensor(self) -> None:
sensor = self._sensor
self._sensor = None
if sensor is None:
return
try:
sensor.disconnect()
except Exception:
pass
def _status(self, message: str) -> None:
if self._on_status is not None:
self._on_status(message)
def _run(self, session: int) -> None:
import orisys
mode = self._mode
source = self._video_source
if mode is None or source is None:
return
is_file = _is_file_source(source)
try:
self._status(f"Opening Orisys Sensor ({mode.label()})…")
self._sensor = orisys.Sensor(
source,
config_name=mode.config_ref,
verbose=False,
backend="auto",
)
if self._stop.is_set() or session != self._session_id:
self._close_sensor()
return
self._sensor.configure(
lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ,
lowpass_sample_rate_hz=FORCE_SAMPLE_RATE_HZ,
)
self._status(f"Streaming {mode.label()}")
except Exception as exc:
self._set_end_reason("error")
if self._on_error is not None and session == self._session_id:
self._on_error(str(exc))
self._set_phase("idle")
return
frame_times: list[float] = []
saw_frame = False
last_frame_mono = 0.0
none_streak_start: Optional[float] = None
while not self._stop.is_set() and session == self._session_id:
try:
now = time.perf_counter()
if saw_frame:
if is_file:
if none_streak_start is not None and (
now - none_streak_start
) >= VIDEO_EOF_TIMEOUT_S:
self._set_end_reason("ended")
break
else:
if (now - last_frame_mono) >= CAMERA_DISCONNECT_TIMEOUT_S:
self._set_end_reason("disconnected")
break
t0 = time.perf_counter()
processed = self._sensor.get_img()
if processed is None:
if saw_frame and is_file and none_streak_start is None:
none_streak_start = time.perf_counter()
time.sleep(0.005)
continue
raw = getattr(self._sensor, "frame", None)
if raw is None:
if saw_frame and is_file and none_streak_start is None:
none_streak_start = time.perf_counter()
time.sleep(0.005)
continue
none_streak_start = None
try:
self._sensor.compute_deformation(decompose=True, check_motion=False)
self._sensor.compute_contact()
except Exception:
pass
t1 = time.perf_counter()
frame_times.append(t1)
cutoff = t1 - 1.0
frame_times = [t for t in frame_times if t >= cutoff]
fps = float(len(frame_times)) if frame_times else 0.0
forces = read_forces_from_sensor(self._sensor, config_ref=mode.config_ref)
packet = SdkPacket(
raw_frame=_ensure_bgr(raw),
processed_frame=_ensure_bgr(processed),
timestamp=time.time(),
fps=fps,
process_ms=(t1 - t0) * 1000.0,
normal_force=forces["normal_force"],
shear_x=forces["shear_x"],
shear_y=forces["shear_y"],
normal_force_raw=forces["normal_force_raw"],
shear_x_raw=forces["shear_x_raw"],
shear_y_raw=forces["shear_y_raw"],
force_unit=forces["force_unit"],
force_calibrated=forces["force_calibrated"],
session_id=session,
flow=forces["flow"],
depth=forces.get("depth"),
)
with self._lock:
self._latest = packet
self._phase = "running"
saw_frame = True
last_frame_mono = t1
except Exception as exc:
self._set_end_reason("error")
if self._on_error is not None and session == self._session_id:
self._on_error(str(exc))
break
self._close_sensor()
self._set_phase("idle")
if self._stop.is_set():
self._set_end_reason("stopped")
self._status("Acquisition stopped")
@@ -0,0 +1,11 @@
"""Camera discovery and raw preview helpers."""
from orisys_app_shared.camera.discovery import CameraInfo, list_cameras, pick_default_camera
from orisys_app_shared.camera.preview import RawPreviewSession
__all__ = [
"CameraInfo",
"RawPreviewSession",
"list_cameras",
"pick_default_camera",
]
@@ -0,0 +1,138 @@
"""USB camera discovery for orisys-app (Windows DSHOW/MSMF, Linux V4L2)."""
from __future__ import annotations
import contextlib
import os
import sys
from dataclasses import dataclass
import cv2
DEFAULT_MAX_CAMERA_INDEX = 10
@dataclass(frozen=True)
class CameraInfo:
index: int
backend_name: str
backend_api: int
width: int
height: int
@property
def source(self) -> str:
return str(self.index)
def label(self) -> str:
return f"{self.index} ({self.width}x{self.height}) [{self.backend_name}]"
@contextlib.contextmanager
def _suppress_opencv_noise():
"""Hide OpenCV VideoCapture probe warnings (stderr + OpenCV log)."""
prev_level = None
set_level = None
try:
cv_logging = getattr(getattr(cv2, "utils", None), "logging", None)
if cv_logging is not None:
set_level = getattr(cv_logging, "setLogLevel", None)
get_level = getattr(cv_logging, "getLogLevel", None)
silent = getattr(cv_logging, "LOG_LEVEL_SILENT", 0)
if set_level is not None:
if get_level is not None:
prev_level = get_level()
set_level(silent)
except Exception:
prev_level = None
devnull = open(os.devnull, "w")
try:
with contextlib.redirect_stderr(devnull):
stderr_fd = sys.stderr.fileno()
saved_fd = os.dup(stderr_fd)
try:
os.dup2(devnull.fileno(), stderr_fd)
yield
finally:
os.dup2(saved_fd, stderr_fd)
os.close(saved_fd)
finally:
devnull.close()
if set_level is not None and prev_level is not None:
try:
set_level(prev_level)
except Exception:
pass
def _backend_candidates() -> list[tuple[str, int]]:
candidates: list[tuple[str, int]] = []
if sys.platform == "win32":
if hasattr(cv2, "CAP_DSHOW"):
candidates.append(("DSHOW", cv2.CAP_DSHOW))
if hasattr(cv2, "CAP_MSMF"):
candidates.append(("MSMF", cv2.CAP_MSMF))
elif sys.platform.startswith("linux"):
if hasattr(cv2, "CAP_V4L2"):
candidates.append(("V4L2", cv2.CAP_V4L2))
if not candidates:
candidates.append(("ANY", cv2.CAP_ANY))
return candidates
def _try_open(index: int, backend_name: str, backend_api: int) -> CameraInfo | None:
cap = cv2.VideoCapture(index, backend_api)
if not cap.isOpened():
cap.release()
return None
ok, frame = cap.read()
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
if not ok or frame is None:
if width <= 0 or height <= 0:
cap.release()
return None
else:
h, w = frame.shape[:2]
width = width or int(w)
height = height or int(h)
cap.release()
return CameraInfo(
index=index,
backend_name=backend_name,
backend_api=backend_api,
width=width,
height=height,
)
def list_cameras(max_index: int = DEFAULT_MAX_CAMERA_INDEX) -> list[CameraInfo]:
"""Probe indices ``0 .. max_index`` and return working cameras."""
found: list[CameraInfo] = []
seen_indices: set[int] = set()
backends = _backend_candidates()
with _suppress_opencv_noise():
for index in range(0, max_index + 1):
for backend_name, backend_api in backends:
info = _try_open(index, backend_name, backend_api)
if info is None:
continue
if index in seen_indices:
break
found.append(info)
seen_indices.add(index)
break
return found
def pick_default_camera(cameras: list[CameraInfo], preferred: str | None = None) -> str | None:
if not cameras:
return None
preferred = (preferred or "").strip()
if preferred:
for cam in cameras:
if cam.source == preferred:
return cam.source
return cameras[0].source
@@ -0,0 +1,72 @@
"""Raw OpenCV camera preview session."""
from __future__ import annotations
import os
from typing import Any
# Must be set before importing cv2 on Windows MSMF builds.
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
import cv2
import numpy as np
from orisys_app_shared.camera.discovery import _backend_candidates
class RawPreviewSession:
"""Open a camera index and read BGR frames for UI preview."""
def __init__(self) -> None:
self._cap: Any = None
self._index: int | None = None
self._backend_name: str = ""
@property
def is_open(self) -> bool:
return self._cap is not None and bool(self._cap.isOpened())
@property
def index(self) -> int | None:
return self._index
@property
def backend_name(self) -> str:
return self._backend_name
def open(self, index: int) -> None:
self.close()
last_error = f"Camera {index} failed to open"
for backend_name, backend_api in _backend_candidates():
cap = cv2.VideoCapture(int(index), backend_api)
if not cap.isOpened():
cap.release()
continue
ok, frame = cap.read()
if not ok or frame is None:
cap.release()
last_error = f"Camera {index} opened via {backend_name} but returned no frame"
continue
self._cap = cap
self._index = int(index)
self._backend_name = backend_name
return
raise RuntimeError(last_error)
def read(self) -> np.ndarray | None:
if not self.is_open:
return None
ok, frame = self._cap.read()
if not ok or frame is None:
return None
return frame
def close(self) -> None:
if self._cap is not None:
try:
self._cap.release()
except Exception:
pass
self._cap = None
self._index = None
self._backend_name = ""
+24
View File
@@ -0,0 +1,24 @@
"""Shared API compatibility helpers."""
from __future__ import annotations
import logging
from orisys_app_shared import API_VERSION, __version__ as shared_version
logger = logging.getLogger(__name__)
def check_shared_compat(required_api: str) -> bool:
"""Return True if shared API matches the app requirement; log a warning otherwise."""
required = str(required_api or "").strip()
current = str(API_VERSION).strip()
ok = required == current
if not ok:
logger.warning(
"Shared API mismatch: app requires %s, shared provides %s (shared package %s)",
required,
current,
shared_version,
)
return ok
@@ -0,0 +1,26 @@
"""Force history and unit helpers."""
from orisys_app_shared.force.calibration_io import (
CHANNEL_KEYS,
CalSample,
CalSessionStore,
build_force_calibration_v2,
export_force_calibration_v2,
meta_from_sensor_json,
)
from orisys_app_shared.force.history import MetricHistory, PlotState, lowpass_filter
from orisys_app_shared.force.units import DisplayForces, select_display_forces
__all__ = [
"CHANNEL_KEYS",
"CalSample",
"CalSessionStore",
"DisplayForces",
"MetricHistory",
"PlotState",
"build_force_calibration_v2",
"export_force_calibration_v2",
"lowpass_filter",
"meta_from_sensor_json",
"select_display_forces",
]
@@ -0,0 +1,235 @@
"""Build and write schema_version-2 force_calibration.json files."""
from __future__ import annotations
import json
from copy import deepcopy
from dataclasses import asdict, dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Iterable, Literal
ChannelKey = Literal["fn", "fx", "fy"]
CHANNEL_KEYS: tuple[ChannelKey, ...] = ("fn", "fx", "fy")
_CHANNEL_TO_JSON = {
"fn": "normal",
"fx": "shear_x",
"fy": "shear_y",
}
_JSON_RAW_KEY = {
"normal": "fnormal",
"shear_x": "fshearx",
"shear_y": "fsheary",
}
WEIGHT_EPS = 1e-6
@dataclass
class CalSample:
weight_n: float
raw_mean: float
n: int
std: float = 0.0
@dataclass
class CalSessionStore:
"""In-memory calibration points for FN / FX / FY."""
fn: list[CalSample] = field(default_factory=list)
fx: list[CalSample] = field(default_factory=list)
fy: list[CalSample] = field(default_factory=list)
def channel(self, key: ChannelKey) -> list[CalSample]:
return getattr(self, key)
def set_channel(self, key: ChannelKey, samples: list[CalSample]) -> None:
setattr(self, key, list(samples))
def clear(self, key: ChannelKey | None = None) -> None:
if key is None:
self.fn.clear()
self.fx.clear()
self.fy.clear()
return
self.channel(key).clear()
def upsert(self, key: ChannelKey, sample: CalSample) -> None:
rows = self.channel(key)
for i, existing in enumerate(rows):
if abs(existing.weight_n - sample.weight_n) <= WEIGHT_EPS:
rows[i] = sample
break
else:
rows.append(sample)
rows.sort(key=lambda s: s.weight_n)
self.set_channel(key, rows)
def remove_at(self, key: ChannelKey, index: int) -> None:
rows = self.channel(key)
if 0 <= index < len(rows):
rows.pop(index)
def to_audit_dict(self) -> dict[str, list[dict[str, float | int]]]:
out: dict[str, list[dict[str, float | int]]] = {}
for key in CHANNEL_KEYS:
out[key] = [asdict(s) for s in self.channel(key)]
return out
def meta_from_sensor_json(path: str | Path | None) -> dict[str, Any]:
"""Pull stitch / tracker fields from a sensor.json for export meta."""
meta: dict[str, Any] = {
"stitch_resolution": None,
"dis_mode": None,
"hhd_downsample": None,
"config_ref": None,
}
if path is None:
return meta
p = Path(path)
meta["config_ref"] = str(p)
if not p.is_file():
return meta
try:
data = json.loads(p.read_text(encoding="utf-8"))
except Exception:
return meta
if not isinstance(data, dict):
return meta
camera = data.get("camera") if isinstance(data.get("camera"), dict) else {}
tracker = data.get("tracker") if isinstance(data.get("tracker"), dict) else {}
stitch = camera.get("resolution_stitch")
if isinstance(stitch, (list, tuple)) and len(stitch) >= 2:
meta["stitch_resolution"] = [int(stitch[0]), int(stitch[1])]
if "dis_mode" in tracker:
try:
meta["dis_mode"] = int(tracker["dis_mode"])
except (TypeError, ValueError):
meta["dis_mode"] = tracker.get("dis_mode")
if "hhd_downsample" in tracker:
try:
meta["hhd_downsample"] = int(tracker["hhd_downsample"])
except (TypeError, ValueError):
meta["hhd_downsample"] = tracker.get("hhd_downsample")
return meta
def _piecewise_channel(
*,
json_name: str,
samples: Iterable[CalSample],
signed: bool,
) -> dict[str, Any] | None:
rows = sorted(samples, key=lambda s: abs(float(s.raw_mean)))
if len(rows) < 2:
return None
points = [[abs(float(s.raw_mean)), float(s.weight_n)] for s in rows]
block: dict[str, Any] = {
"method": "piecewise_linear",
"raw_key": _JSON_RAW_KEY[json_name],
"extrapolate": True,
"clamp_output_min": 0.0,
"points": points,
}
if signed:
block["signed"] = True
return block
def build_force_calibration_v2(
store: CalSessionStore,
*,
meta: dict[str, Any] | None = None,
) -> tuple[dict[str, Any], list[str]]:
"""Build schema v2 JSON. Returns (document, skipped_channel_warnings)."""
warnings: list[str] = []
channels: dict[str, Any] = {}
for key, json_name in _CHANNEL_TO_JSON.items():
signed = key in ("fx", "fy")
block = _piecewise_channel(
json_name=json_name,
samples=store.channel(key),
signed=signed,
)
if block is None:
if store.channel(key):
warnings.append(
f"{key}: need at least 2 points (have {len(store.channel(key))})"
)
else:
warnings.append(f"{key}: no samples")
continue
channels[json_name] = block
base_meta = {
"date": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
"sensor_type": "",
"sn": "",
"stitch_resolution": None,
"dis_mode": None,
"hhd_downsample": None,
"record_duration_s": None,
"config_ref": None,
"samples": store.to_audit_dict(),
}
if meta:
for k, v in meta.items():
if k == "samples":
continue
base_meta[k] = v
if "samples" in meta and meta["samples"] is not None:
base_meta["samples"] = meta["samples"]
doc = {
"schema_version": 2,
"unit": "N",
"channels": channels,
"meta": base_meta,
}
return doc, warnings
def write_force_calibration(
doc: dict[str, Any],
path: str | Path,
) -> Path:
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(doc, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
)
return path.resolve()
def export_force_calibration_v2(
store: CalSessionStore,
*,
export_dir: str | Path,
sensor_type: str,
meta: dict[str, Any] | None = None,
local_bundle_dir: str | Path | None = None,
) -> tuple[Path, Path | None, list[str]]:
"""Write timestamped export and optional local bundle force_calibration.json."""
doc, warnings = build_force_calibration_v2(store, meta=meta)
if not doc.get("channels"):
raise ValueError(
"No complete channels to export (each needs ≥2 points). "
+ "; ".join(warnings)
)
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
export_path = Path(export_dir) / f"force_calibration_{sensor_type}_{stamp}.json"
primary = write_force_calibration(doc, export_path)
local_path: Path | None = None
if local_bundle_dir is not None:
bundle = Path(local_bundle_dir)
if bundle.is_dir():
local_path = write_force_calibration(
deepcopy(doc),
bundle / "force_calibration.json",
)
return primary, local_path, warnings
@@ -0,0 +1,150 @@
"""Rolling force history with optional display low-pass."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import numpy as np
@dataclass
class PlotState:
times: np.ndarray
values: np.ndarray
x_range: tuple[float, float]
y_ranges: tuple[tuple[float, float], ...]
def lowpass_filter(
data,
cutoff_hz: float,
sample_rate_hz: float,
order: int = 2,
) -> np.ndarray:
y = np.asarray(data, dtype=np.float64)
if cutoff_hz == 0 or y.size == 0:
return y.copy()
nyquist = 0.5 * sample_rate_hz
if cutoff_hz < 0 or cutoff_hz >= nyquist:
return y.copy()
from scipy.signal import butter, filtfilt
b, a = butter(order, cutoff_hz / nyquist, btype="low")
# filtfilt requires length strictly greater than padlen.
padlen = 3 * (max(len(b), len(a)) - 1)
if y.size <= padlen:
return y.copy()
try:
return filtfilt(b, a, y)
except ValueError:
return y.copy()
def _npoints_for_window(window_sec: float, sample_rate_hz: float) -> int:
return max(2, int(round(float(window_sec) * float(sample_rate_hz))))
class MetricHistory:
def __init__(
self,
npoints: int | None = None,
*,
channels: int = 3,
window_sec: float = 4.0,
sample_rate_hz: float | None = None,
lowpass_cutoff_hz: float = 0.0,
y_axis_min_span: float = 0.5,
):
self.channels = int(channels)
self.window_sec = float(window_sec)
self.sample_rate_hz = (
float(sample_rate_hz)
if sample_rate_hz is not None
else (30.0 if self.window_sec <= 0 else 30.0)
)
if npoints is None:
self.npoints = _npoints_for_window(self.window_sec, self.sample_rate_hz)
else:
self.npoints = int(npoints)
self.lowpass_cutoff_hz = float(lowpass_cutoff_hz)
self.y_axis_min_span = float(y_axis_min_span)
self.reset()
def set_window_sec(self, window_sec: float) -> None:
self.window_sec = max(0.1, float(window_sec))
self.npoints = _npoints_for_window(self.window_sec, self.sample_rate_hz)
self.reset()
def reset(self) -> None:
self._data = np.full((self.npoints, 1 + self.channels), np.nan, dtype=np.float64)
self.sample_count = 0
self._t0: Optional[float] = None
self._y_range_smooth: list[Optional[tuple[float, float]]] = [
None for _ in range(self.channels)
]
def push(self, timestamp: float, *values: float) -> None:
if len(values) != self.channels:
raise ValueError(f"Expected {self.channels} values, got {len(values)}")
if self._t0 is None:
self._t0 = float(timestamp)
t = float(timestamp) - self._t0
row = (t, *map(float, values))
if self.sample_count < self.npoints:
self._data[self.sample_count, :] = row
self.sample_count += 1
return
self._data[:-1, :] = self._data[1:, :]
self._data[-1, :] = row
def _smooth_y_range(self, channel: int, ymin: float, ymax: float) -> tuple[float, float]:
if self._y_range_smooth[channel] is None:
smoothed = (ymin, ymax)
else:
prev_min, prev_max = self._y_range_smooth[channel]
ymin = 0.5 * ymin + 0.5 * prev_min
ymax = 0.5 * ymax + 0.5 * prev_max
smoothed = (ymin, ymax)
span = smoothed[1] - smoothed[0]
if span < self.y_axis_min_span:
mid = 0.5 * (smoothed[0] + smoothed[1])
half = self.y_axis_min_span / 2.0
smoothed = (mid - half, mid + half)
self._y_range_smooth[channel] = smoothed
pad = max((smoothed[1] - smoothed[0]) * 0.12, 0.01)
return smoothed[0] - pad, smoothed[1] + pad
def state(self) -> Optional[PlotState]:
n = self.sample_count
if n == 0:
return None
block = self._data[:n]
times = block[:, 0].copy()
values = block[:, 1:].copy()
if self.lowpass_cutoff_hz > 0:
for i in range(self.channels):
values[:, i] = lowpass_filter(
values[:, i],
self.lowpass_cutoff_hz,
sample_rate_hz=self.sample_rate_hz,
)
t_end = float(times[-1])
if t_end >= self.window_sec:
x_range = (t_end - self.window_sec, t_end)
else:
x_range = (0.0, self.window_sec)
y_ranges = []
for i in range(self.channels):
col = values[:, i]
ymin = float(np.nanmin(col))
ymax = float(np.nanmax(col))
if not np.isfinite(ymin) or not np.isfinite(ymax):
ymin, ymax = 0.0, 1.0
y_ranges.append(self._smooth_y_range(i, ymin, ymax))
return PlotState(
times=times,
values=values,
x_range=x_range,
y_ranges=tuple(y_ranges),
)
@@ -0,0 +1,46 @@
"""Force unit selection for display."""
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class DisplayForces:
fn: float
fx: float
fy: float
unit: str
using_raw_fallback: bool
def select_display_forces(
*,
units_pref: str,
force_calibrated: bool,
fn: float,
fx: float,
fy: float,
fn_raw: float,
fx_raw: float,
fy_raw: float,
) -> DisplayForces:
"""Return values for plots/labels given newton/raw preference."""
want_newton = str(units_pref or "newton").lower().startswith("n")
if want_newton and force_calibrated:
return DisplayForces(fn=fn, fx=fx, fy=fy, unit="N", using_raw_fallback=False)
if want_newton and not force_calibrated:
return DisplayForces(
fn=fn_raw,
fx=fx_raw,
fy=fy_raw,
unit="raw",
using_raw_fallback=True,
)
return DisplayForces(
fn=fn_raw,
fx=fx_raw,
fy=fy_raw,
unit="raw",
using_raw_fallback=False,
)
@@ -0,0 +1,5 @@
"""i18n helpers."""
from orisys_app_shared.i18n.translator import Translator, get_translator, set_locale, t
__all__ = ["Translator", "get_translator", "set_locale", "t"]
@@ -0,0 +1,80 @@
"""Simple JSON locale translator with en_US fallback."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from orisys_app_shared.paths import locales_dir
class Translator:
def __init__(self, locales_path: Path | None = None, locale: str = "zh_CN") -> None:
self._locales_path = locales_path or locales_dir()
self._locale = locale
self._strings: dict[str, Any] = {}
self._fallback: dict[str, Any] = {}
self.set_locale(locale)
@property
def locale(self) -> str:
return self._locale
def set_locale(self, locale: str) -> None:
self._locale = locale
self._fallback = self._load("en_US")
self._strings = self._load(locale)
def _load(self, locale: str) -> dict[str, Any]:
path = self._locales_path / f"{locale}.json"
if not path.is_file():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return data if isinstance(data, dict) else {}
def t(self, key: str, **kwargs: Any) -> str:
value = self._lookup(self._strings, key)
if value is None:
value = self._lookup(self._fallback, key)
if value is None:
return key
text = str(value)
if kwargs:
try:
return text.format(**kwargs)
except (KeyError, ValueError):
return text
return text
@staticmethod
def _lookup(data: dict[str, Any], key: str) -> Any | None:
node: Any = data
for part in key.split("."):
if not isinstance(node, dict) or part not in node:
return None
node = node[part]
return node
_translator: Translator | None = None
def get_translator() -> Translator:
global _translator
if _translator is None:
_translator = Translator()
return _translator
def t(key: str, **kwargs: Any) -> str:
return get_translator().t(key, **kwargs)
def set_locale(locale: str) -> Translator:
tr = get_translator()
tr.set_locale(locale)
return tr
@@ -0,0 +1,14 @@
"""Logging helpers."""
from orisys_app_shared.logging.errors import report_error, report_warning
from orisys_app_shared.logging.excepthook import install_exception_hooks
from orisys_app_shared.logging.qt_handler import QtLogHandler
from orisys_app_shared.logging.setup import init_logging
__all__ = [
"QtLogHandler",
"init_logging",
"install_exception_hooks",
"report_error",
"report_warning",
]
@@ -0,0 +1,29 @@
"""User/developer error reporting helpers."""
from __future__ import annotations
import logging
import traceback
from typing import Any
def report_error(
exc: BaseException | None = None,
*,
message: str = "",
logger_name: str = "orisys_app",
) -> None:
"""Log an error with optional exception traceback (dialog wiring later)."""
logger = logging.getLogger(logger_name)
if exc is not None:
logger.error(
"%s\n%s",
message or str(exc),
"".join(traceback.format_exception(type(exc), exc, exc.__traceback__)),
)
else:
logger.error("%s", message or "Unknown error")
def report_warning(message: str, *, logger_name: str = "orisys_app", **_kwargs: Any) -> None:
logging.getLogger(logger_name).warning("%s", message)
@@ -0,0 +1,38 @@
"""Install process-wide exception hooks that log tracebacks."""
from __future__ import annotations
import logging
import sys
import threading
import traceback
def install_exception_hooks() -> None:
logger = logging.getLogger("orisys_app_shared.excepthook")
def handle_exception(exc_type, exc_value, exc_traceback):
if issubclass(exc_type, KeyboardInterrupt):
sys.__excepthook__(exc_type, exc_value, exc_traceback)
return
logger.error(
"Unhandled exception\n%s",
"".join(traceback.format_exception(exc_type, exc_value, exc_traceback)),
)
sys.excepthook = handle_exception
if hasattr(threading, "excepthook"):
def thread_hook(args):
if args.exc_type is None:
return
if issubclass(args.exc_type, SystemExit):
return
logger.error(
"Unhandled thread exception in %s\n%s",
getattr(args.thread, "name", "?"),
"".join(traceback.format_exception(args.exc_type, args.exc_value, args.exc_traceback)),
)
threading.excepthook = thread_hook
@@ -0,0 +1,25 @@
"""Qt log handler stub for a future log dock."""
from __future__ import annotations
import logging
from typing import Callable
class QtLogHandler(logging.Handler):
"""Forward log records to a callable (e.g. append to a QTextEdit)."""
def __init__(self, sink: Callable[[str], None] | None = None) -> None:
super().__init__()
self._sink = sink
def set_sink(self, sink: Callable[[str], None] | None) -> None:
self._sink = sink
def emit(self, record: logging.LogRecord) -> None:
if self._sink is None:
return
try:
self._sink(self.format(record))
except Exception:
self.handleError(record)
@@ -0,0 +1,42 @@
"""Logging setup for orisys-app products."""
from __future__ import annotations
import logging
from datetime import datetime
from pathlib import Path
from orisys_app_shared.paths import logs_dir
_INITIALIZED = False
def init_logging(app_name: str = "orisys-app", *, level: int = logging.INFO) -> Path:
"""Configure root logger with console + session file under outputs/logs."""
global _INITIALIZED
log_path = logs_dir() / f"{app_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
root = logging.getLogger()
root.setLevel(level)
# Avoid duplicate handlers on repeated init (tests / re-entry).
if _INITIALIZED:
return log_path
formatter = logging.Formatter(
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
file_handler = logging.FileHandler(log_path, encoding="utf-8")
file_handler.setFormatter(formatter)
file_handler.setLevel(level)
root.addHandler(file_handler)
console = logging.StreamHandler()
console.setFormatter(formatter)
console.setLevel(level)
root.addHandler(console)
_INITIALIZED = True
logging.getLogger(__name__).info("Logging initialized: %s", log_path)
return log_path
+152
View File
@@ -0,0 +1,152 @@
"""Resolve app, config, and output paths for editable and frozen runs."""
from __future__ import annotations
import sys
from pathlib import Path
def is_frozen() -> bool:
return bool(getattr(sys, "frozen", False))
def _customer_root_from_shared() -> Path:
"""Repo layout: shared/src/orisys_app_shared -> apps/customer."""
here = Path(__file__).resolve()
# .../orisys-app/shared/src/orisys_app_shared/paths.py
repo_root = here.parents[3]
return repo_root / "apps" / "customer"
def app_root() -> Path:
"""Return the customer app root (editable) or EXE directory (frozen)."""
if is_frozen():
return Path(sys.executable).resolve().parent
# Prefer cwd override when launched from apps/customer
cwd = Path.cwd().resolve()
if (cwd / "config").is_dir() and (cwd / "run.py").is_file():
return cwd
if (cwd / "apps" / "customer" / "config").is_dir():
return cwd / "apps" / "customer"
return _customer_root_from_shared()
def config_dir() -> Path:
if is_frozen():
beside = app_root() / "config"
if beside.is_dir():
return beside
internal = app_root() / "_internal" / "config"
if internal.is_dir():
return internal
return app_root() / "config"
def sensors_dir() -> Path:
return config_dir() / "sensors"
def locales_dir() -> Path:
return config_dir() / "locales"
def outputs_dir() -> Path:
path = app_root() / "outputs"
path.mkdir(parents=True, exist_ok=True)
return path
def logs_dir() -> Path:
path = outputs_dir() / "logs"
path.mkdir(parents=True, exist_ok=True)
return path
def calibration_export_dir() -> Path:
path = outputs_dir() / "calibration"
path.mkdir(parents=True, exist_ok=True)
return path
def preference_path() -> Path:
return app_root() / "preference.json"
def preferences_default_path() -> Path:
return config_dir() / "preferences.default.json"
def branding_dir() -> Path:
"""Branding assets shipped with the shared package."""
# editable: shared/resources/branding
# package: next to installed module via package resources fallback
here = Path(__file__).resolve()
candidates = [
here.parents[2] / "resources" / "branding", # shared/resources/branding
here.parent / "resources" / "branding",
]
if is_frozen():
candidates.insert(0, app_root() / "branding")
candidates.insert(0, app_root() / "_internal" / "branding")
for path in candidates:
if path.is_dir():
return path
return candidates[0]
def logo_path() -> Path:
return branding_dir() / "logo.png"
def logo_topbar_path() -> Path:
return branding_dir() / "logo_topbar.png"
def sensor_json_path(sensor_type: str) -> Path | None:
"""Return ``config/sensors/<type>/sensor.json`` when present."""
path = sensors_dir() / str(sensor_type) / "sensor.json"
return path if path.is_file() else None
_PREFERRED_SENSOR_ROOT: Path | None = None
def prefer_local_sensor_bundles(root: Path | None = None) -> Path | None:
"""Prefer app ``config/sensors`` over packaged Orisys sensor bundles."""
global _PREFERRED_SENSOR_ROOT
if root is not None:
sensors = (Path(root) / "config" / "sensors").resolve()
if not sensors.is_dir():
sensors = Path(root).resolve()
else:
sensors = sensors_dir().resolve()
if not sensors.is_dir():
return None
if _PREFERRED_SENSOR_ROOT is not None and _PREFERRED_SENSOR_ROOT == sensors:
return sensors
try:
import orisys.configs as configs
except Exception:
return None
original = configs._candidate_sensor_roots
marker = str(sensors)
if getattr(original, "_orisys_app_local_pref", None) == marker:
_PREFERRED_SENSOR_ROOT = sensors
return sensors
def _candidate_sensor_roots_prefer_local() -> list[Path]:
roots = [sensors]
for candidate in original():
resolved = Path(candidate).resolve()
if resolved != sensors and resolved not in roots:
roots.append(resolved)
return roots
_candidate_sensor_roots_prefer_local._orisys_app_local_pref = marker # type: ignore[attr-defined]
configs._candidate_sensor_roots = _candidate_sensor_roots_prefer_local
_PREFERRED_SENSOR_ROOT = sensors
return sensors
@@ -0,0 +1,17 @@
"""Preference store re-exports."""
from orisys_app_shared.prefs.store import (
default_preferences,
get_preference,
load_preferences,
save_preferences,
set_preference,
)
__all__ = [
"default_preferences",
"get_preference",
"load_preferences",
"save_preferences",
"set_preference",
]
@@ -0,0 +1,70 @@
"""Load/save preference.json with defaults merge."""
from __future__ import annotations
import json
from copy import deepcopy
from pathlib import Path
from typing import Any
from orisys_app_shared.paths import preference_path, preferences_default_path
_DEFAULTS: dict[str, Any] = {
"language": "zh_CN",
"theme": "dark",
"window_geometry": None,
"camera_id": "0",
"source_mode": "camera",
"video_path": "",
"show_cal_panel": False,
"show_finger_3d": True,
"sensor_type": "box",
"config_name": "",
"units": "newton",
"plot_window_sec": 4.0,
"plot_fn_max": 20.0,
"plot_shear_max": 20.0,
"image_view_mode": "arrows_pure",
}
def _load_json(path: Path) -> dict[str, Any]:
if not path.is_file():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
return data if isinstance(data, dict) else {}
def default_preferences() -> dict[str, Any]:
merged = deepcopy(_DEFAULTS)
merged.update(_load_json(preferences_default_path()))
return merged
def load_preferences() -> dict[str, Any]:
prefs = default_preferences()
prefs.update(_load_json(preference_path()))
return prefs
def save_preferences(prefs: dict[str, Any]) -> Path:
path = preference_path()
path.parent.mkdir(parents=True, exist_ok=True)
payload = default_preferences()
payload.update(prefs)
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
return path
def get_preference(key: str, default: Any = None) -> Any:
return load_preferences().get(key, default)
def set_preference(key: str, value: Any) -> dict[str, Any]:
prefs = load_preferences()
prefs[key] = value
save_preferences(prefs)
return prefs
@@ -0,0 +1,5 @@
"""Sensor catalog helpers."""
from orisys_app_shared.sensors.catalog import list_configs_for_type, list_sensor_types
__all__ = ["list_configs_for_type", "list_sensor_types"]
@@ -0,0 +1,33 @@
"""List local editable sensor bundles under config/sensors."""
from __future__ import annotations
from pathlib import Path
from orisys_app_shared.paths import sensors_dir
PRODUCT_TYPES = ("box", "mini", "finger")
def list_sensor_types() -> list[str]:
return list(PRODUCT_TYPES)
def list_configs_for_type(sensor_type: str) -> list[tuple[str, str]]:
"""Return ``(display_name, config_key)`` entries for one sensor type.
Prefers ``config/sensors/<type>/sensor.json`` when present; otherwise a
single default entry named after the type (for prefs / future Run).
"""
name = str(sensor_type or "").strip()
if not name:
return []
root = sensors_dir() / name
sensor_json = root / "sensor.json"
if sensor_json.is_file():
return [(f"{name}/sensor.json", str(sensor_json))]
# Also accept extra *.json next to sensor.json later; for now only sensor.json.
extras = sorted(p for p in root.glob("*.json") if p.name != "manifest.json")
if extras:
return [(f"{name}/{p.name}", str(p)) for p in extras]
return [(name, name)]
@@ -0,0 +1,5 @@
"""Theme helpers."""
from orisys_app_shared.theme.qss import build_qss, clear_theme_cache, color, load_colors
__all__ = ["build_qss", "clear_theme_cache", "color", "load_colors"]
@@ -0,0 +1,23 @@
{
"window_bg": "#121316",
"panel_bg": "#1a1c20",
"panel_border": "#2a2d33",
"text_primary": "#e8eaed",
"text_secondary": "#9aa0a6",
"accent": "#536dce",
"accent_hover": "#6b82d9",
"button_bg": "#2a2d33",
"button_hover": "#3a3f47",
"input_bg": "#0f1012",
"input_border": "#3a3f47",
"menu_bg": "#1a1c20",
"menu_hover": "#2a2d33",
"plot_fn": "#8ab4f8",
"plot_fx": "#34a853",
"plot_fy": "#fbbc04",
"plot_bg": "#121316",
"plot_grid": "#2a2d33",
"arrow_canvas": "#121316",
"danger": "#ea4335",
"success": "#34a853"
}
@@ -0,0 +1,23 @@
{
"window_bg": "#f5f6f8",
"panel_bg": "#ffffff",
"panel_border": "#d0d4dc",
"text_primary": "#1a1c20",
"text_secondary": "#5f6368",
"accent": "#536dce",
"accent_hover": "#3f56b5",
"button_bg": "#e8eaed",
"button_hover": "#d0d4dc",
"input_bg": "#ffffff",
"input_border": "#c0c4cc",
"menu_bg": "#ffffff",
"menu_hover": "#e8eaed",
"plot_fn": "#1a73e8",
"plot_fx": "#188038",
"plot_fy": "#f9ab00",
"plot_bg": "#ffffff",
"plot_grid": "#e0e3e8",
"arrow_canvas": "#eef0f3",
"danger": "#d93025",
"success": "#188038"
}
+105
View File
@@ -0,0 +1,105 @@
"""Build Qt stylesheets from theme color token JSON (no hex in widgets)."""
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
def _theme_dir() -> Path:
return Path(__file__).resolve().parent
@lru_cache(maxsize=4)
def load_colors(theme: str = "dark") -> dict[str, str]:
name = "colors_dark.json" if theme != "light" else "colors_light.json"
path = _theme_dir() / name
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError(f"Invalid theme file: {path}")
return {str(k): str(v) for k, v in data.items()}
def build_qss(theme: str = "dark") -> str:
c = load_colors(theme)
return f"""
QWidget {{
background-color: {c['window_bg']};
color: {c['text_primary']};
font-family: "Microsoft YaHei", "Segoe UI", sans-serif;
font-size: 13px;
}}
QMainWindow, QDialog {{
background-color: {c['window_bg']};
}}
QMenuBar {{
background-color: {c['menu_bg']};
color: {c['text_primary']};
border-bottom: 1px solid {c['panel_border']};
}}
QMenuBar::item:selected {{
background-color: {c['menu_hover']};
}}
QMenu {{
background-color: {c['menu_bg']};
color: {c['text_primary']};
border: 1px solid {c['panel_border']};
}}
QMenu::item:selected {{
background-color: {c['menu_hover']};
}}
QToolBar {{
background-color: {c['panel_bg']};
border-bottom: 1px solid {c['panel_border']};
spacing: 8px;
padding: 4px;
}}
QPushButton {{
background-color: {c['button_bg']};
color: {c['text_primary']};
border: 1px solid {c['panel_border']};
border-radius: 4px;
padding: 6px 12px;
}}
QPushButton:hover {{
background-color: {c['button_hover']};
}}
QPushButton:disabled {{
color: {c['text_secondary']};
}}
QComboBox, QLineEdit, QSpinBox {{
background-color: {c['input_bg']};
color: {c['text_primary']};
border: 1px solid {c['input_border']};
border-radius: 4px;
padding: 4px 8px;
min-height: 24px;
}}
QLabel#panelTitle {{
color: {c['text_secondary']};
font-weight: 600;
}}
QLabel#panelMeta {{
color: {c['text_secondary']};
font-weight: 500;
padding-right: 8px;
}}
QFrame#contentPanel {{
background-color: {c['panel_bg']};
border: 1px solid {c['panel_border']};
border-radius: 6px;
}}
QStatusBar {{
background-color: {c['panel_bg']};
color: {c['text_secondary']};
}}
"""
def color(theme: str, key: str, default: str = "#888888") -> str:
return load_colors(theme).get(key, default)
def clear_theme_cache() -> None:
load_colors.cache_clear()