commit e2d5e6922909aa5a7b52017017485fddd32f2031 Author: ykcheungab Date: Sun Sep 6 21:57:50 2026 +0800 initial commit, functinal software diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a81c655 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..2e8ed5e --- /dev/null +++ b/README.md @@ -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. diff --git a/apps/customer/config/compound_eye.png b/apps/customer/config/compound_eye.png new file mode 100644 index 0000000..7cccd46 Binary files /dev/null and b/apps/customer/config/compound_eye.png differ diff --git a/apps/customer/config/locales/en_US.json b/apps/customer/config/locales/en_US.json new file mode 100644 index 0000000..f6b7e91 --- /dev/null +++ b/apps/customer/config/locales/en_US.json @@ -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}" + } +} diff --git a/apps/customer/config/locales/zh_CN.json b/apps/customer/config/locales/zh_CN.json new file mode 100644 index 0000000..0e57793 --- /dev/null +++ b/apps/customer/config/locales/zh_CN.json @@ -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}" + } +} diff --git a/apps/customer/config/preferences.default.json b/apps/customer/config/preferences.default.json new file mode 100644 index 0000000..593d65c --- /dev/null +++ b/apps/customer/config/preferences.default.json @@ -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" +} diff --git a/apps/customer/config/sensors/box/force_calibration.json b/apps/customer/config/sensors/box/force_calibration.json new file mode 100644 index 0000000..351eb0e --- /dev/null +++ b/apps/customer/config/sensors/box/force_calibration.json @@ -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 + } + ] + } + } +} diff --git a/apps/customer/config/sensors/box/sensor.json b/apps/customer/config/sensors/box/sensor.json new file mode 100644 index 0000000..af88413 --- /dev/null +++ b/apps/customer/config/sensors/box/sensor.json @@ -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 + } +} + + diff --git a/apps/customer/config/sensors/finger/assets/30fps-0703/README.md b/apps/customer/config/sensors/finger/assets/30fps-0703/README.md new file mode 100644 index 0000000..24a039e --- /dev/null +++ b/apps/customer/config/sensors/finger/assets/30fps-0703/README.md @@ -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. diff --git a/apps/customer/config/sensors/finger/assets/30fps-0703/camera_intrinsics.npz b/apps/customer/config/sensors/finger/assets/30fps-0703/camera_intrinsics.npz new file mode 100644 index 0000000..888b1eb Binary files /dev/null and b/apps/customer/config/sensors/finger/assets/30fps-0703/camera_intrinsics.npz differ diff --git a/apps/customer/config/sensors/finger/assets/30fps-0703/mesh.npz b/apps/customer/config/sensors/finger/assets/30fps-0703/mesh.npz new file mode 100644 index 0000000..4da6bb1 Binary files /dev/null and b/apps/customer/config/sensors/finger/assets/30fps-0703/mesh.npz differ diff --git a/apps/customer/config/sensors/finger/assets/30fps-0703/pixel_to_xyz.npz b/apps/customer/config/sensors/finger/assets/30fps-0703/pixel_to_xyz.npz new file mode 100644 index 0000000..15eee73 Binary files /dev/null and b/apps/customer/config/sensors/finger/assets/30fps-0703/pixel_to_xyz.npz differ diff --git a/apps/customer/config/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz b/apps/customer/config/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz new file mode 100644 index 0000000..995cee0 Binary files /dev/null and b/apps/customer/config/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz differ diff --git a/apps/customer/config/sensors/finger/assets/30fps-0703/world_to_cam.npy b/apps/customer/config/sensors/finger/assets/30fps-0703/world_to_cam.npy new file mode 100644 index 0000000..3d6f897 Binary files /dev/null and b/apps/customer/config/sensors/finger/assets/30fps-0703/world_to_cam.npy differ diff --git a/apps/customer/config/sensors/finger/assets/visualization/README.md b/apps/customer/config/sensors/finger/assets/visualization/README.md new file mode 100644 index 0000000..fe08404 --- /dev/null +++ b/apps/customer/config/sensors/finger/assets/visualization/README.md @@ -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. diff --git a/apps/customer/config/sensors/finger/assets/visualization/camera_intrinsics.npz b/apps/customer/config/sensors/finger/assets/visualization/camera_intrinsics.npz new file mode 100644 index 0000000..1d211d5 Binary files /dev/null and b/apps/customer/config/sensors/finger/assets/visualization/camera_intrinsics.npz differ diff --git a/apps/customer/config/sensors/finger/assets/visualization/flatten_remap.npz b/apps/customer/config/sensors/finger/assets/visualization/flatten_remap.npz new file mode 100644 index 0000000..02ca287 Binary files /dev/null and b/apps/customer/config/sensors/finger/assets/visualization/flatten_remap.npz differ diff --git a/apps/customer/config/sensors/finger/assets/visualization/mesh.npz b/apps/customer/config/sensors/finger/assets/visualization/mesh.npz new file mode 100644 index 0000000..ad81542 Binary files /dev/null and b/apps/customer/config/sensors/finger/assets/visualization/mesh.npz differ diff --git a/apps/customer/config/sensors/finger/assets/visualization/pixel_to_xyz.npz b/apps/customer/config/sensors/finger/assets/visualization/pixel_to_xyz.npz new file mode 100644 index 0000000..4096205 Binary files /dev/null and b/apps/customer/config/sensors/finger/assets/visualization/pixel_to_xyz.npz differ diff --git a/apps/customer/config/sensors/finger/assets/visualization/world_to_cam.npy b/apps/customer/config/sensors/finger/assets/visualization/world_to_cam.npy new file mode 100644 index 0000000..2d5e411 Binary files /dev/null and b/apps/customer/config/sensors/finger/assets/visualization/world_to_cam.npy differ diff --git a/apps/customer/config/sensors/finger/manifest.json b/apps/customer/config/sensors/finger/manifest.json new file mode 100644 index 0000000..27f92c8 --- /dev/null +++ b/apps/customer/config/sensors/finger/manifest.json @@ -0,0 +1,6 @@ +{ + "name": "finger", + "sensor_json": "sensor.json", + "assets_dir": "assets/30fps-0703", + "viz_pose": "assets/visualization/world_to_cam.npy" +} diff --git a/apps/customer/config/sensors/finger/sensor.json b/apps/customer/config/sensors/finger/sensor.json new file mode 100644 index 0000000..ebdb709 --- /dev/null +++ b/apps/customer/config/sensors/finger/sensor.json @@ -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 + } +} diff --git a/apps/customer/config/sensors/mini/sensor.json b/apps/customer/config/sensors/mini/sensor.json new file mode 100644 index 0000000..af88413 --- /dev/null +++ b/apps/customer/config/sensors/mini/sensor.json @@ -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 + } +} + + diff --git a/apps/customer/pyproject.toml b/apps/customer/pyproject.toml new file mode 100644 index 0000000..d4f4375 --- /dev/null +++ b/apps/customer/pyproject.toml @@ -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"] diff --git a/apps/customer/run.py b/apps/customer/run.py new file mode 100644 index 0000000..a604305 --- /dev/null +++ b/apps/customer/run.py @@ -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()) diff --git a/apps/customer/src/orisys_customer/__init__.py b/apps/customer/src/orisys_customer/__init__.py new file mode 100644 index 0000000..304e8af --- /dev/null +++ b/apps/customer/src/orisys_customer/__init__.py @@ -0,0 +1,4 @@ +"""Orisys customer desktop application.""" + +__version__ = "0.1.0" +REQUIRES_SHARED_API = "0.1" diff --git a/apps/customer/src/orisys_customer/app_window.py b/apps/customer/src/orisys_customer/app_window.py new file mode 100644 index 0000000..c49051d --- /dev/null +++ b/apps/customer/src/orisys_customer/app_window.py @@ -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) diff --git a/apps/customer/src/orisys_customer/main.py b/apps/customer/src/orisys_customer/main.py new file mode 100644 index 0000000..49ebafd --- /dev/null +++ b/apps/customer/src/orisys_customer/main.py @@ -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()) diff --git a/apps/customer/src/orisys_customer/ui/__init__.py b/apps/customer/src/orisys_customer/ui/__init__.py new file mode 100644 index 0000000..fe18e0f --- /dev/null +++ b/apps/customer/src/orisys_customer/ui/__init__.py @@ -0,0 +1 @@ +"""UI package.""" diff --git a/apps/customer/src/orisys_customer/ui/about_dialog.py b/apps/customer/src/orisys_customer/ui/about_dialog.py new file mode 100644 index 0000000..3308a09 --- /dev/null +++ b/apps/customer/src/orisys_customer/ui/about_dialog.py @@ -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)) diff --git a/apps/customer/src/orisys_customer/ui/arrow_panel.py b/apps/customer/src/orisys_customer/ui/arrow_panel.py new file mode 100644 index 0000000..1043ff2 --- /dev/null +++ b/apps/customer/src/orisys_customer/ui/arrow_panel.py @@ -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) diff --git a/apps/customer/src/orisys_customer/ui/cal_panel.py b/apps/customer/src/orisys_customer/ui/cal_panel.py new file mode 100644 index 0000000..454dec4 --- /dev/null +++ b/apps/customer/src/orisys_customer/ui/cal_panel.py @@ -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) diff --git a/apps/customer/src/orisys_customer/ui/force_plots.py b/apps/customer/src/orisys_customer/ui/force_plots.py new file mode 100644 index 0000000..b6c62e6 --- /dev/null +++ b/apps/customer/src/orisys_customer/ui/force_plots.py @@ -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")) diff --git a/apps/customer/src/orisys_customer/ui/mesh3d_panel.py b/apps/customer/src/orisys_customer/ui/mesh3d_panel.py new file mode 100644 index 0000000..75b2170 --- /dev/null +++ b/apps/customer/src/orisys_customer/ui/mesh3d_panel.py @@ -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")) diff --git a/apps/customer/src/orisys_customer/ui/plot_settings_dialog.py b/apps/customer/src/orisys_customer/ui/plot_settings_dialog.py new file mode 100644 index 0000000..198accf --- /dev/null +++ b/apps/customer/src/orisys_customer/ui/plot_settings_dialog.py @@ -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")) diff --git a/apps/customer/src/orisys_customer/ui/sensor_picker.py b/apps/customer/src/orisys_customer/ui/sensor_picker.py new file mode 100644 index 0000000..1c6f002 --- /dev/null +++ b/apps/customer/src/orisys_customer/ui/sensor_picker.py @@ -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() diff --git a/apps/customer/src/orisys_customer/ui/toolbar.py b/apps/customer/src/orisys_customer/ui/toolbar.py new file mode 100644 index 0000000..c21a387 --- /dev/null +++ b/apps/customer/src/orisys_customer/ui/toolbar.py @@ -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")) diff --git a/apps/internal/README.md b/apps/internal/README.md new file mode 100644 index 0000000..6d21c7f --- /dev/null +++ b/apps/internal/README.md @@ -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. diff --git a/build/README.md b/build/README.md new file mode 100644 index 0000000..7a1dcd6 --- /dev/null +++ b/build/README.md @@ -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 +``` diff --git a/build/customer/build.bat b/build/customer/build.bat new file mode 100644 index 0000000..65710d0 --- /dev/null +++ b/build/customer/build.bat @@ -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% diff --git a/build/customer/build.ps1 b/build/customer/build.ps1 new file mode 100644 index 0000000..8d8c667 --- /dev/null +++ b/build/customer/build.ps1 @@ -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." + } +} diff --git a/build/customer/entry.py b/build/customer/entry.py new file mode 100644 index 0000000..21326ee --- /dev/null +++ b/build/customer/entry.py @@ -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()) diff --git a/build/customer/orisys-customer.spec b/build/customer/orisys-customer.spec new file mode 100644 index 0000000..2738eb2 --- /dev/null +++ b/build/customer/orisys-customer.spec @@ -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", +) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0891a0e --- /dev/null +++ b/pyproject.toml @@ -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 = [] diff --git a/shared/pyproject.toml b/shared/pyproject.toml new file mode 100644 index 0000000..b90e752 --- /dev/null +++ b/shared/pyproject.toml @@ -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"] diff --git a/shared/resources/branding/logo.ico b/shared/resources/branding/logo.ico new file mode 100644 index 0000000..8f11783 Binary files /dev/null and b/shared/resources/branding/logo.ico differ diff --git a/shared/resources/branding/logo.png b/shared/resources/branding/logo.png new file mode 100644 index 0000000..9679888 Binary files /dev/null and b/shared/resources/branding/logo.png differ diff --git a/shared/resources/branding/logo_topbar.png b/shared/resources/branding/logo_topbar.png new file mode 100644 index 0000000..449121c Binary files /dev/null and b/shared/resources/branding/logo_topbar.png differ diff --git a/shared/src/orisys_app_shared/__init__.py b/shared/src/orisys_app_shared/__init__.py new file mode 100644 index 0000000..ce1ca2f --- /dev/null +++ b/shared/src/orisys_app_shared/__init__.py @@ -0,0 +1,4 @@ +"""Shared package for orisys-app desktop products.""" + +__version__ = "0.1.0" +API_VERSION = "0.1" diff --git a/shared/src/orisys_app_shared/acquisition/__init__.py b/shared/src/orisys_app_shared/acquisition/__init__.py new file mode 100644 index 0000000..d4ac82f --- /dev/null +++ b/shared/src/orisys_app_shared/acquisition/__init__.py @@ -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 diff --git a/shared/src/orisys_app_shared/acquisition/arrows.py b/shared/src/orisys_app_shared/acquisition/arrows.py new file mode 100644 index 0000000..5600553 --- /dev/null +++ b/shared/src/orisys_app_shared/acquisition/arrows.py @@ -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) diff --git a/shared/src/orisys_app_shared/acquisition/constants.py b/shared/src/orisys_app_shared/acquisition/constants.py new file mode 100644 index 0000000..72a8f44 --- /dev/null +++ b/shared/src/orisys_app_shared/acquisition/constants.py @@ -0,0 +1,4 @@ +"""Shared acquisition constants (no heavy imports).""" + +LOWPASS_CUTOFF_HZ = 10.0 +FORCE_SAMPLE_RATE_HZ = 30.0 diff --git a/shared/src/orisys_app_shared/acquisition/finger_worker.py b/shared/src/orisys_app_shared/acquisition/finger_worker.py new file mode 100644 index 0000000..9537d80 --- /dev/null +++ b/shared/src/orisys_app_shared/acquisition/finger_worker.py @@ -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") diff --git a/shared/src/orisys_app_shared/acquisition/sdk_worker.py b/shared/src/orisys_app_shared/acquisition/sdk_worker.py new file mode 100644 index 0000000..93ec596 --- /dev/null +++ b/shared/src/orisys_app_shared/acquisition/sdk_worker.py @@ -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") diff --git a/shared/src/orisys_app_shared/camera/__init__.py b/shared/src/orisys_app_shared/camera/__init__.py new file mode 100644 index 0000000..f9ee6b5 --- /dev/null +++ b/shared/src/orisys_app_shared/camera/__init__.py @@ -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", +] diff --git a/shared/src/orisys_app_shared/camera/discovery.py b/shared/src/orisys_app_shared/camera/discovery.py new file mode 100644 index 0000000..2bf54f2 --- /dev/null +++ b/shared/src/orisys_app_shared/camera/discovery.py @@ -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 diff --git a/shared/src/orisys_app_shared/camera/preview.py b/shared/src/orisys_app_shared/camera/preview.py new file mode 100644 index 0000000..8f8c2d2 --- /dev/null +++ b/shared/src/orisys_app_shared/camera/preview.py @@ -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 = "" diff --git a/shared/src/orisys_app_shared/compat.py b/shared/src/orisys_app_shared/compat.py new file mode 100644 index 0000000..28ef559 --- /dev/null +++ b/shared/src/orisys_app_shared/compat.py @@ -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 diff --git a/shared/src/orisys_app_shared/force/__init__.py b/shared/src/orisys_app_shared/force/__init__.py new file mode 100644 index 0000000..ed73b8c --- /dev/null +++ b/shared/src/orisys_app_shared/force/__init__.py @@ -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", +] diff --git a/shared/src/orisys_app_shared/force/calibration_io.py b/shared/src/orisys_app_shared/force/calibration_io.py new file mode 100644 index 0000000..1824ed6 --- /dev/null +++ b/shared/src/orisys_app_shared/force/calibration_io.py @@ -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 diff --git a/shared/src/orisys_app_shared/force/history.py b/shared/src/orisys_app_shared/force/history.py new file mode 100644 index 0000000..36002bf --- /dev/null +++ b/shared/src/orisys_app_shared/force/history.py @@ -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), + ) diff --git a/shared/src/orisys_app_shared/force/units.py b/shared/src/orisys_app_shared/force/units.py new file mode 100644 index 0000000..10bcd34 --- /dev/null +++ b/shared/src/orisys_app_shared/force/units.py @@ -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, + ) diff --git a/shared/src/orisys_app_shared/i18n/__init__.py b/shared/src/orisys_app_shared/i18n/__init__.py new file mode 100644 index 0000000..c8b3a57 --- /dev/null +++ b/shared/src/orisys_app_shared/i18n/__init__.py @@ -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"] diff --git a/shared/src/orisys_app_shared/i18n/translator.py b/shared/src/orisys_app_shared/i18n/translator.py new file mode 100644 index 0000000..3c8ed74 --- /dev/null +++ b/shared/src/orisys_app_shared/i18n/translator.py @@ -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 diff --git a/shared/src/orisys_app_shared/logging/__init__.py b/shared/src/orisys_app_shared/logging/__init__.py new file mode 100644 index 0000000..547c0b6 --- /dev/null +++ b/shared/src/orisys_app_shared/logging/__init__.py @@ -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", +] diff --git a/shared/src/orisys_app_shared/logging/errors.py b/shared/src/orisys_app_shared/logging/errors.py new file mode 100644 index 0000000..5ec7ab2 --- /dev/null +++ b/shared/src/orisys_app_shared/logging/errors.py @@ -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) diff --git a/shared/src/orisys_app_shared/logging/excepthook.py b/shared/src/orisys_app_shared/logging/excepthook.py new file mode 100644 index 0000000..4cb41bd --- /dev/null +++ b/shared/src/orisys_app_shared/logging/excepthook.py @@ -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 diff --git a/shared/src/orisys_app_shared/logging/qt_handler.py b/shared/src/orisys_app_shared/logging/qt_handler.py new file mode 100644 index 0000000..ceee7c7 --- /dev/null +++ b/shared/src/orisys_app_shared/logging/qt_handler.py @@ -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) diff --git a/shared/src/orisys_app_shared/logging/setup.py b/shared/src/orisys_app_shared/logging/setup.py new file mode 100644 index 0000000..4b60f8d --- /dev/null +++ b/shared/src/orisys_app_shared/logging/setup.py @@ -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 diff --git a/shared/src/orisys_app_shared/paths.py b/shared/src/orisys_app_shared/paths.py new file mode 100644 index 0000000..7a71833 --- /dev/null +++ b/shared/src/orisys_app_shared/paths.py @@ -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//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 diff --git a/shared/src/orisys_app_shared/prefs/__init__.py b/shared/src/orisys_app_shared/prefs/__init__.py new file mode 100644 index 0000000..f738007 --- /dev/null +++ b/shared/src/orisys_app_shared/prefs/__init__.py @@ -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", +] diff --git a/shared/src/orisys_app_shared/prefs/store.py b/shared/src/orisys_app_shared/prefs/store.py new file mode 100644 index 0000000..d6e43cb --- /dev/null +++ b/shared/src/orisys_app_shared/prefs/store.py @@ -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 diff --git a/shared/src/orisys_app_shared/sensors/__init__.py b/shared/src/orisys_app_shared/sensors/__init__.py new file mode 100644 index 0000000..302e2d5 --- /dev/null +++ b/shared/src/orisys_app_shared/sensors/__init__.py @@ -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"] diff --git a/shared/src/orisys_app_shared/sensors/catalog.py b/shared/src/orisys_app_shared/sensors/catalog.py new file mode 100644 index 0000000..0caad8f --- /dev/null +++ b/shared/src/orisys_app_shared/sensors/catalog.py @@ -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//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)] diff --git a/shared/src/orisys_app_shared/theme/__init__.py b/shared/src/orisys_app_shared/theme/__init__.py new file mode 100644 index 0000000..957386a --- /dev/null +++ b/shared/src/orisys_app_shared/theme/__init__.py @@ -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"] diff --git a/shared/src/orisys_app_shared/theme/colors_dark.json b/shared/src/orisys_app_shared/theme/colors_dark.json new file mode 100644 index 0000000..00122a7 --- /dev/null +++ b/shared/src/orisys_app_shared/theme/colors_dark.json @@ -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" +} diff --git a/shared/src/orisys_app_shared/theme/colors_light.json b/shared/src/orisys_app_shared/theme/colors_light.json new file mode 100644 index 0000000..9474180 --- /dev/null +++ b/shared/src/orisys_app_shared/theme/colors_light.json @@ -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" +} diff --git a/shared/src/orisys_app_shared/theme/qss.py b/shared/src/orisys_app_shared/theme/qss.py new file mode 100644 index 0000000..e921d38 --- /dev/null +++ b/shared/src/orisys_app_shared/theme/qss.py @@ -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()