initial commit, functinal software
This commit is contained in:
+25
@@ -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
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# orisys-app
|
||||||
|
|
||||||
|
Desktop applications that consume the Orisys SDK.
|
||||||
|
|
||||||
|
| App | Status |
|
||||||
|
|-----|--------|
|
||||||
|
| `apps/customer` | Scaffold shell (theme / locale / prefs / logging) |
|
||||||
|
| `apps/internal` | Stub for future calibration & automated testing |
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
- `shared/` — common paths, prefs, theme, i18n, logging
|
||||||
|
- `apps/customer/config/` — locales, default prefs, editable `sensors/` copies
|
||||||
|
- `apps/customer/outputs/` — generated logs and calibration exports (gitignored)
|
||||||
|
- `build/` — Windows PyInstaller packaging (`build/customer/build.ps1`)
|
||||||
|
|
||||||
|
## Run (customer shell)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd D:\repo\orisys-app
|
||||||
|
python -m pip install -e .\shared -e .\apps\customer
|
||||||
|
python .\apps\customer\run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Or without install:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:PYTHONPATH = "D:\repo\orisys-app\shared\src;D:\repo\orisys-app\apps\customer\src"
|
||||||
|
python D:\repo\orisys-app\apps\customer\run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Branding
|
||||||
|
|
||||||
|
Window / EXE icon: `shared/resources/branding/logo.png`
|
||||||
|
Top bar / About: `shared/resources/branding/logo_topbar.png`
|
||||||
|
|
||||||
|
Dark mode is the default. Logos currently sit on a black tile; a transparent (or light) variant may be needed for polished light mode later.
|
||||||
|
|
||||||
|
## Versioning
|
||||||
|
|
||||||
|
- **Customer app version** (`orisys_customer.__version__`) — shown in Help → About and used for releases.
|
||||||
|
- **Shared package version** (`orisys_app_shared.__version__`) — internal library version.
|
||||||
|
- **Shared API** (`orisys_app_shared.API_VERSION`) — compatibility tag. Customer declares `REQUIRES_SHARED_API`; startup logs a warning on mismatch.
|
||||||
|
|
||||||
|
## Deferred
|
||||||
|
|
||||||
|
Run/Stop pipeline, arrow/force plots, units, calibration capture, Open3D finger mesh, sync-from-wheel for `config/sensors`, PyInstaller packaging, and the internal app.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
@@ -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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"title": "Orisys 应用",
|
||||||
|
"about_title": "关于",
|
||||||
|
"about_body": "Orisys 客户端桌面应用\n版本: {app_version}",
|
||||||
|
"scaffold_hint": "脚手架界面 — 采集功能稍后加入"
|
||||||
|
},
|
||||||
|
"menu": {
|
||||||
|
"file": "文件",
|
||||||
|
"open_video": "打开视频…",
|
||||||
|
"reveal_config": "在资源管理器中显示配置…",
|
||||||
|
"view": "视图",
|
||||||
|
"theme": "主题",
|
||||||
|
"theme_dark": "深色",
|
||||||
|
"theme_light": "浅色",
|
||||||
|
"language": "语言",
|
||||||
|
"lang_zh": "简体中文",
|
||||||
|
"lang_en": "English",
|
||||||
|
"calibration_panel": "标定面板",
|
||||||
|
"finger_3d": "手指 3D 视图",
|
||||||
|
"show_fps": "显示 FPS",
|
||||||
|
"plot_settings": "绘图设置…",
|
||||||
|
"help": "帮助",
|
||||||
|
"about": "关于"
|
||||||
|
},
|
||||||
|
"dialog": {
|
||||||
|
"plot_settings_title": "绘图设置",
|
||||||
|
"plot_window": "时间窗口",
|
||||||
|
"plot_window_tip": "力曲线时间窗口(秒)",
|
||||||
|
"plot_fn_max": "FN 上限",
|
||||||
|
"plot_fn_max_tip": "法向力纵轴上限(牛顿模式)",
|
||||||
|
"plot_shear_max": "剪切上限",
|
||||||
|
"plot_shear_max_tip": "剪切力 FX/FY 纵轴 ±上限(牛顿模式)",
|
||||||
|
"reset_defaults": "恢复默认",
|
||||||
|
"ok": "确定",
|
||||||
|
"cancel": "取消"
|
||||||
|
},
|
||||||
|
"toolbar": {
|
||||||
|
"source": "来源",
|
||||||
|
"source_camera": "摄像机",
|
||||||
|
"source_video": "视频文件",
|
||||||
|
"search_cameras": "搜索",
|
||||||
|
"cam_id": "摄像机",
|
||||||
|
"browse_video": "浏览…",
|
||||||
|
"video_placeholder": "未选择视频",
|
||||||
|
"preview": "预览",
|
||||||
|
"preview_stop": "停止预览",
|
||||||
|
"run": "运行",
|
||||||
|
"stop": "停止",
|
||||||
|
"reset": "重置",
|
||||||
|
"units": "单位",
|
||||||
|
"units_newton": "牛顿",
|
||||||
|
"units_raw": "原始"
|
||||||
|
},
|
||||||
|
"sensor": {
|
||||||
|
"type": "传感器类型",
|
||||||
|
"config": "配置",
|
||||||
|
"thumb_placeholder": "缩略图"
|
||||||
|
},
|
||||||
|
"panels": {
|
||||||
|
"arrow": "形变箭头",
|
||||||
|
"view_arrows_pure": "箭头",
|
||||||
|
"view_arrows_stitched": "箭头/采集图像",
|
||||||
|
"view_depth": "深度图",
|
||||||
|
"view_mode_tip": "运行时的图像显示模式",
|
||||||
|
"plots": "力曲线图",
|
||||||
|
"cal": "标定",
|
||||||
|
"mesh3d": "三维网格",
|
||||||
|
"mesh3d_hint": "手指三维视图占位 — Open3D 网格稍后加入"
|
||||||
|
},
|
||||||
|
"cal": {
|
||||||
|
"weight": "砝码重量 (N)",
|
||||||
|
"duration": "采集时长",
|
||||||
|
"record": "记录",
|
||||||
|
"delete_row": "删除行",
|
||||||
|
"clear_channel": "清空当前通道",
|
||||||
|
"clear_all": "清空全部",
|
||||||
|
"export": "导出…",
|
||||||
|
"sn": "序列号",
|
||||||
|
"sn_placeholder": "序列号(可选)",
|
||||||
|
"live_raw": "实时原始值 FN={fn} FX={fx} FY={fy}",
|
||||||
|
"col_weight": "重量 (N)",
|
||||||
|
"col_raw": "原始均值",
|
||||||
|
"col_n": "n",
|
||||||
|
"col_std": "σ",
|
||||||
|
"col_actions": "",
|
||||||
|
"recording": "正在采集标定样本…",
|
||||||
|
"record_ok": "已记录 weight={weight} raw={raw}",
|
||||||
|
"record_failed_few": "采集失败 — 样本过少",
|
||||||
|
"export_ok": "已导出 {path}",
|
||||||
|
"export_local": "同时写入 {path}",
|
||||||
|
"export_warnings": "跳过: {detail}",
|
||||||
|
"export_failed": "导出失败: {error}"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"ready": "就绪",
|
||||||
|
"camera_none": "未发现摄像机",
|
||||||
|
"camera_found": "发现 {count} 个摄像机: {names}",
|
||||||
|
"preview_started": "预览已启动(摄像机 {camera_id})",
|
||||||
|
"preview_stopped": "预览已停止",
|
||||||
|
"preview_failed": "预览失败: {error}",
|
||||||
|
"connecting": "正在连接…",
|
||||||
|
"run_started": "运行中({source})",
|
||||||
|
"run_stopped": "已停止",
|
||||||
|
"run_failed": "启动失败: {error}",
|
||||||
|
"video_ended": "视频已结束",
|
||||||
|
"camera_disconnected": "摄像机已断开",
|
||||||
|
"reset_ok": "追踪器已重置",
|
||||||
|
"finger_run_unavailable": "此传感器类型暂不支持运行",
|
||||||
|
"video_required": "请先选择视频文件",
|
||||||
|
"config_missing": "未找到传感器配置: {type}",
|
||||||
|
"units_raw_fallback": "无标定数据 — 显示原始单位",
|
||||||
|
"reveal_failed": "无法打开配置位置: {error}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"language": "zh_CN",
|
||||||
|
"theme": "dark",
|
||||||
|
"window_geometry": null,
|
||||||
|
"camera_id": "0",
|
||||||
|
"source_mode": "camera",
|
||||||
|
"video_path": "",
|
||||||
|
"show_cal_panel": false,
|
||||||
|
"show_finger_3d": true,
|
||||||
|
"sensor_type": "box",
|
||||||
|
"config_name": "",
|
||||||
|
"units": "newton",
|
||||||
|
"plot_window_sec": 4.0,
|
||||||
|
"plot_fn_max": 20.0,
|
||||||
|
"plot_shear_max": 20.0,
|
||||||
|
"image_view_mode": "arrows_pure"
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 2,
|
||||||
|
"unit": "N",
|
||||||
|
"channels": {
|
||||||
|
"normal": {
|
||||||
|
"method": "piecewise_linear",
|
||||||
|
"raw_key": "fnormal",
|
||||||
|
"extrapolate": true,
|
||||||
|
"clamp_output_min": 0.0,
|
||||||
|
"points": [
|
||||||
|
[
|
||||||
|
3167.3289065950753,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
3617.3242262492,
|
||||||
|
1.0
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"shear_x": {
|
||||||
|
"method": "piecewise_linear",
|
||||||
|
"raw_key": "fshearx",
|
||||||
|
"extrapolate": true,
|
||||||
|
"clamp_output_min": 0.0,
|
||||||
|
"points": [
|
||||||
|
[
|
||||||
|
1471.613952418736,
|
||||||
|
0.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
1743.8539805266817,
|
||||||
|
1.0
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"signed": true
|
||||||
|
},
|
||||||
|
"shear_y": {
|
||||||
|
"method": "piecewise_linear",
|
||||||
|
"raw_key": "fsheary",
|
||||||
|
"extrapolate": true,
|
||||||
|
"clamp_output_min": 0.0,
|
||||||
|
"points": [
|
||||||
|
[
|
||||||
|
3845.5798720537605,
|
||||||
|
1.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
3881.793802339737,
|
||||||
|
0.0
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"signed": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"date": "2026-09-06T21:54:09+08:00",
|
||||||
|
"sensor_type": "box",
|
||||||
|
"sn": "a",
|
||||||
|
"stitch_resolution": [
|
||||||
|
365,
|
||||||
|
365
|
||||||
|
],
|
||||||
|
"dis_mode": 3,
|
||||||
|
"hhd_downsample": 3,
|
||||||
|
"record_duration_s": 7.0,
|
||||||
|
"config_ref": "D:\\repo\\orisys-app\\apps\\customer\\config\\sensors\\box\\sensor.json",
|
||||||
|
"samples": {
|
||||||
|
"fn": [
|
||||||
|
{
|
||||||
|
"weight_n": 0.0,
|
||||||
|
"raw_mean": 3167.3289065950753,
|
||||||
|
"n": 283,
|
||||||
|
"std": 283.516893458341
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"weight_n": 1.0,
|
||||||
|
"raw_mean": 3617.3242262492,
|
||||||
|
"n": 293,
|
||||||
|
"std": 271.21386400281835
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fx": [
|
||||||
|
{
|
||||||
|
"weight_n": 0.0,
|
||||||
|
"raw_mean": -1471.613952418736,
|
||||||
|
"n": 280,
|
||||||
|
"std": 311.9173147283949
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"weight_n": 1.0,
|
||||||
|
"raw_mean": -1743.8539805266817,
|
||||||
|
"n": 295,
|
||||||
|
"std": 324.2175747527586
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fy": [
|
||||||
|
{
|
||||||
|
"weight_n": 0.0,
|
||||||
|
"raw_mean": 3881.793802339737,
|
||||||
|
"n": 292,
|
||||||
|
"std": 362.16000171157486
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"weight_n": 1.0,
|
||||||
|
"raw_mean": 3845.5798720537605,
|
||||||
|
"n": 295,
|
||||||
|
"std": 390.42421990805127
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "prototype",
|
||||||
|
"version": "1.0",
|
||||||
|
"camera": {
|
||||||
|
"resolution_raw": [1920, 1080],
|
||||||
|
"fps": 30,
|
||||||
|
"units": 15,
|
||||||
|
"units_used": 9,
|
||||||
|
"resolution_stitch": [365, 365],
|
||||||
|
"stitch_res_list": [365,263,123,63]
|
||||||
|
},
|
||||||
|
"stitcher": {
|
||||||
|
"min_area_frac": 0.01,
|
||||||
|
"max_area_frac": 0.05,
|
||||||
|
"max_aspect_ratio": 50,
|
||||||
|
"index": [1,2,3,6,7,8,11,12,13],
|
||||||
|
"margin": 10,
|
||||||
|
"nominal_ov_pos": 100,
|
||||||
|
"n": [3,3],
|
||||||
|
"row_tol": 0.6,
|
||||||
|
"box_size": [250, 250],
|
||||||
|
"blend":"numba",
|
||||||
|
"blend_list": ["numpy", "numba","none"]
|
||||||
|
},
|
||||||
|
"tracker": {
|
||||||
|
"adaptive": true,
|
||||||
|
"lower_threshold": 12.0,
|
||||||
|
"upper_threshold": 18.0,
|
||||||
|
"loop_closure_interval": 1,
|
||||||
|
"algorithm": "dis",
|
||||||
|
"dis_preset": "medium",
|
||||||
|
"dis_mode": 3,
|
||||||
|
"algorithm_list": ["raft", "dis"],
|
||||||
|
"hhd_downsample": 3
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"min_contour_area": 3000,
|
||||||
|
"flow_threshold":5,
|
||||||
|
"div_threshold":0.05,
|
||||||
|
"expansion_threshold": 0.01,
|
||||||
|
"kernel_size": [5,5],
|
||||||
|
"denoise_sigma": 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
Place the ORY-FINGER runtime asset bundle files in this directory before
|
||||||
|
building release wheels:
|
||||||
|
|
||||||
|
- `mesh.npz`
|
||||||
|
- `camera_intrinsics.npz`
|
||||||
|
- `world_to_cam.npy`
|
||||||
|
|
||||||
|
These binary assets are validated by the wheel build and are intentionally not
|
||||||
|
generated by the package build step.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "finger",
|
||||||
|
"sensor_json": "sensor.json",
|
||||||
|
"assets_dir": "assets/30fps-0703",
|
||||||
|
"viz_pose": "assets/visualization/world_to_cam.npy"
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "dd02-ov",
|
||||||
|
"type": "single_camera",
|
||||||
|
"version": "1.0",
|
||||||
|
"camera": {
|
||||||
|
"resolution_raw": [1280, 720],
|
||||||
|
"fps": 30,
|
||||||
|
"resolution_stitch": [280, 300],
|
||||||
|
"source_scale": 0.4,
|
||||||
|
"old":[672, 728, 1,0.5],
|
||||||
|
"work": [504, 546,1],
|
||||||
|
"work2": [420, 450,0.8],
|
||||||
|
"work3": [350, 375,4,0.7],
|
||||||
|
"work4": [280, 300,4,0.4,0.2]
|
||||||
|
},
|
||||||
|
"tracker": {
|
||||||
|
"adaptive": true,
|
||||||
|
"lower_threshold": 12.0,
|
||||||
|
"upper_threshold": 18.0,
|
||||||
|
"loop_closure_interval": 1,
|
||||||
|
"algorithm": "dis",
|
||||||
|
"dis_preset": "medium",
|
||||||
|
"dis_mode": 1,
|
||||||
|
"algorithm_list": ["raft", "dis"],
|
||||||
|
"hhd_downsample": 3
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"min_contour_area": 3000,
|
||||||
|
"flow_threshold": 0.2,
|
||||||
|
"div_threshold": 0.022,
|
||||||
|
"expansion_threshold": 0.01,
|
||||||
|
"kernel_size": [5, 5],
|
||||||
|
"denoise_sigma": 12
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "prototype",
|
||||||
|
"version": "1.0",
|
||||||
|
"camera": {
|
||||||
|
"resolution_raw": [1920, 1080],
|
||||||
|
"fps": 30,
|
||||||
|
"units": 15,
|
||||||
|
"units_used": 9,
|
||||||
|
"resolution_stitch": [365, 365],
|
||||||
|
"stitch_res_list": [365,263,123,63]
|
||||||
|
},
|
||||||
|
"stitcher": {
|
||||||
|
"min_area_frac": 0.01,
|
||||||
|
"max_area_frac": 0.05,
|
||||||
|
"max_aspect_ratio": 50,
|
||||||
|
"index": [1,2,3,6,7,8,11,12,13],
|
||||||
|
"margin": 10,
|
||||||
|
"nominal_ov_pos": 100,
|
||||||
|
"n": [3,3],
|
||||||
|
"row_tol": 0.6,
|
||||||
|
"box_size": [250, 250],
|
||||||
|
"blend":"numba",
|
||||||
|
"blend_list": ["numpy", "numba","none"]
|
||||||
|
},
|
||||||
|
"tracker": {
|
||||||
|
"adaptive": true,
|
||||||
|
"lower_threshold": 12.0,
|
||||||
|
"upper_threshold": 18.0,
|
||||||
|
"loop_closure_interval": 1,
|
||||||
|
"algorithm": "dis",
|
||||||
|
"dis_preset": "medium",
|
||||||
|
"dis_mode": 3,
|
||||||
|
"algorithm_list": ["raft", "dis"],
|
||||||
|
"hhd_downsample": 3
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"min_contour_area": 3000,
|
||||||
|
"flow_threshold":5,
|
||||||
|
"div_threshold":0.05,
|
||||||
|
"expansion_threshold": 0.01,
|
||||||
|
"kernel_size": [5,5],
|
||||||
|
"denoise_sigma": 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -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"]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Launch the Orisys customer app shell."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
SRC = ROOT / "src"
|
||||||
|
SHARED_SRC = ROOT.parents[1] / "shared" / "src"
|
||||||
|
for path in (SRC, SHARED_SRC):
|
||||||
|
if path.is_dir() and str(path) not in sys.path:
|
||||||
|
sys.path.insert(0, str(path))
|
||||||
|
|
||||||
|
# Ensure path helpers resolve to this app root when launched via run.py
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.chdir(ROOT)
|
||||||
|
|
||||||
|
from orisys_customer.main import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""Orisys customer desktop application."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
REQUIRES_SHARED_API = "0.1"
|
||||||
@@ -0,0 +1,842 @@
|
|||||||
|
"""Main window shell for the customer app."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PyQt5.QtCore import Qt, QTimer
|
||||||
|
from PyQt5.QtGui import QIcon, QImage
|
||||||
|
from PyQt5.QtWidgets import (
|
||||||
|
QAction,
|
||||||
|
QActionGroup,
|
||||||
|
QApplication,
|
||||||
|
QFileDialog,
|
||||||
|
QHBoxLayout,
|
||||||
|
QMainWindow,
|
||||||
|
QMessageBox,
|
||||||
|
QSplitter,
|
||||||
|
QStatusBar,
|
||||||
|
QVBoxLayout,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from orisys_app_shared.force import select_display_forces
|
||||||
|
from orisys_app_shared.i18n import set_locale, t
|
||||||
|
from orisys_app_shared.logging import report_error
|
||||||
|
from orisys_app_shared.paths import logo_path, sensor_json_path
|
||||||
|
from orisys_app_shared.prefs import save_preferences
|
||||||
|
from orisys_app_shared.theme import build_qss, color
|
||||||
|
|
||||||
|
from orisys_customer.ui.about_dialog import AboutDialog
|
||||||
|
from orisys_customer.ui.arrow_panel import ArrowPanel
|
||||||
|
from orisys_customer.ui.cal_panel import CalPanel
|
||||||
|
from orisys_customer.ui.force_plots import ForcePlotsPanel
|
||||||
|
from orisys_customer.ui.mesh3d_panel import Mesh3dPanel
|
||||||
|
from orisys_customer.ui.plot_settings_dialog import (
|
||||||
|
DEFAULT_PLOT_FN_MAX,
|
||||||
|
DEFAULT_PLOT_SHEAR_MAX,
|
||||||
|
DEFAULT_PLOT_WINDOW_SEC,
|
||||||
|
PlotSettingsDialog,
|
||||||
|
)
|
||||||
|
from orisys_customer.ui.sensor_picker import SensorPickerWidget
|
||||||
|
from orisys_customer.ui.toolbar import ToolbarWidget
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
RUNNABLE_TYPES = frozenset({"box", "mini", "finger"})
|
||||||
|
|
||||||
|
|
||||||
|
def _bgr_to_qimage(frame: np.ndarray) -> QImage:
|
||||||
|
rgb = frame[:, :, ::-1].copy()
|
||||||
|
h, w, ch = rgb.shape
|
||||||
|
bytes_per_line = ch * w
|
||||||
|
return QImage(rgb.data, w, h, bytes_per_line, QImage.Format_RGB888).copy()
|
||||||
|
|
||||||
|
|
||||||
|
def _reveal_in_explorer(path: Path) -> None:
|
||||||
|
path = path.resolve()
|
||||||
|
if sys.platform == "win32":
|
||||||
|
subprocess.run(["explorer", "/select,", str(path)], check=False)
|
||||||
|
return
|
||||||
|
if sys.platform == "darwin":
|
||||||
|
subprocess.run(["open", "-R", str(path)], check=False)
|
||||||
|
return
|
||||||
|
folder = path.parent if path.is_file() else path
|
||||||
|
subprocess.run(["xdg-open", str(folder)], check=False)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerMainWindow(QMainWindow):
|
||||||
|
def __init__(self, prefs: dict[str, Any] | None = None) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.prefs: dict[str, Any] = dict(prefs or {})
|
||||||
|
self._theme = str(self.prefs.get("theme") or "dark")
|
||||||
|
self._language = str(self.prefs.get("language") or "zh_CN")
|
||||||
|
self._show_cal = bool(self.prefs.get("show_cal_panel", False))
|
||||||
|
self._show_finger_3d = bool(self.prefs.get("show_finger_3d", True))
|
||||||
|
self._show_fps = bool(self.prefs.get("show_fps", False))
|
||||||
|
self._session_phase = "idle"
|
||||||
|
self._running = False
|
||||||
|
self._preview = None
|
||||||
|
self._preview_timer = QTimer(self)
|
||||||
|
self._preview_timer.setInterval(33)
|
||||||
|
self._preview_timer.timeout.connect(self._on_preview_tick)
|
||||||
|
self._run_timer = QTimer(self)
|
||||||
|
self._run_timer.setInterval(16)
|
||||||
|
self._run_timer.timeout.connect(self._on_run_tick)
|
||||||
|
self._box_worker = None
|
||||||
|
self._finger_worker = None
|
||||||
|
self._active_worker = None
|
||||||
|
self._active_sensor_type = "box"
|
||||||
|
self._last_session_error = ""
|
||||||
|
self._last_plot_packet_key: tuple[int, float] | None = None
|
||||||
|
self._last_rendered_view_mode: str | None = None
|
||||||
|
self._run_source_label = ""
|
||||||
|
|
||||||
|
icon = QIcon(str(logo_path()))
|
||||||
|
if not icon.isNull():
|
||||||
|
self.setWindowIcon(icon)
|
||||||
|
|
||||||
|
central = QWidget()
|
||||||
|
self.setCentralWidget(central)
|
||||||
|
root = QVBoxLayout(central)
|
||||||
|
root.setContentsMargins(10, 10, 10, 10)
|
||||||
|
root.setSpacing(8)
|
||||||
|
|
||||||
|
top = QHBoxLayout()
|
||||||
|
self.toolbar = ToolbarWidget()
|
||||||
|
self.sensor_picker = SensorPickerWidget()
|
||||||
|
top.addWidget(self.toolbar, 2)
|
||||||
|
top.addWidget(self.sensor_picker, 1)
|
||||||
|
root.addLayout(top)
|
||||||
|
|
||||||
|
self.content_splitter = QSplitter(Qt.Horizontal)
|
||||||
|
self.mesh3d_panel = Mesh3dPanel()
|
||||||
|
self.arrow_panel = ArrowPanel()
|
||||||
|
self.force_plots = ForcePlotsPanel(theme=self._theme)
|
||||||
|
self.cal_panel = CalPanel()
|
||||||
|
self.content_splitter.addWidget(self.mesh3d_panel)
|
||||||
|
self.content_splitter.addWidget(self.arrow_panel)
|
||||||
|
self.content_splitter.addWidget(self.force_plots)
|
||||||
|
self.content_splitter.addWidget(self.cal_panel)
|
||||||
|
root.addWidget(self.content_splitter, 1)
|
||||||
|
|
||||||
|
self.setStatusBar(QStatusBar())
|
||||||
|
self.cal_panel.status_message.connect(self.statusBar().showMessage)
|
||||||
|
self._build_menus()
|
||||||
|
self._wire_toolbar()
|
||||||
|
self._wire_sensor_picker()
|
||||||
|
self._restore_prefs_into_ui()
|
||||||
|
self._apply_panel_visibility()
|
||||||
|
self._update_run_enabled()
|
||||||
|
self.cal_panel.set_meta_context(
|
||||||
|
sensor_type=self.sensor_picker.sensor_type(),
|
||||||
|
config_ref=self._resolve_config_ref(),
|
||||||
|
)
|
||||||
|
self.retranslate()
|
||||||
|
self._restore_geometry()
|
||||||
|
self.resize(1280, 720)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _preview_open(self) -> bool:
|
||||||
|
return self._preview is not None and bool(self._preview.is_open)
|
||||||
|
|
||||||
|
def _get_preview(self):
|
||||||
|
if self._preview is None:
|
||||||
|
from orisys_app_shared.camera import RawPreviewSession
|
||||||
|
|
||||||
|
self._preview = RawPreviewSession()
|
||||||
|
return self._preview
|
||||||
|
|
||||||
|
def _ensure_workers(self) -> None:
|
||||||
|
if self._box_worker is not None and self._finger_worker is not None:
|
||||||
|
return
|
||||||
|
from orisys_app_shared.acquisition.finger_worker import FingerAcquisitionWorker
|
||||||
|
from orisys_app_shared.acquisition.sdk_worker import SdkAcquisitionWorker
|
||||||
|
|
||||||
|
if self._box_worker is None:
|
||||||
|
self._box_worker = SdkAcquisitionWorker(
|
||||||
|
on_error=self._on_worker_error,
|
||||||
|
on_status=self._on_worker_status,
|
||||||
|
)
|
||||||
|
if self._finger_worker is None:
|
||||||
|
self._finger_worker = FingerAcquisitionWorker(
|
||||||
|
on_error=self._on_worker_error,
|
||||||
|
on_status=self._on_worker_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _wire_toolbar(self) -> None:
|
||||||
|
self.toolbar.search_clicked.connect(self._on_search_cameras)
|
||||||
|
self.toolbar.preview_toggled.connect(self._on_preview_toggled)
|
||||||
|
self.toolbar.camera_id_changed.connect(self._on_camera_id_changed)
|
||||||
|
self.toolbar.source_mode_changed.connect(self._on_source_mode_changed)
|
||||||
|
self.toolbar.browse_video_clicked.connect(self._on_browse_video)
|
||||||
|
self.toolbar.run_toggled.connect(self._on_run_toggled)
|
||||||
|
self.toolbar.reset_clicked.connect(self._on_reset)
|
||||||
|
self.toolbar.units_changed.connect(self._on_units_changed)
|
||||||
|
|
||||||
|
def _wire_sensor_picker(self) -> None:
|
||||||
|
self.sensor_picker.sensor_type_changed.connect(self._on_sensor_type_changed)
|
||||||
|
self.sensor_picker.config_changed.connect(self._on_config_changed)
|
||||||
|
self.arrow_panel.view_mode_changed.connect(self._on_image_view_mode_changed)
|
||||||
|
|
||||||
|
def _restore_prefs_into_ui(self) -> None:
|
||||||
|
raw_id = str(self.prefs.get("camera_id") or "0").strip()
|
||||||
|
try:
|
||||||
|
cam_id = int(raw_id)
|
||||||
|
except ValueError:
|
||||||
|
cam_id = 0
|
||||||
|
self.toolbar.set_camera_id(cam_id)
|
||||||
|
self.toolbar.set_source_mode(str(self.prefs.get("source_mode") or "camera"))
|
||||||
|
self.toolbar.set_video_path(str(self.prefs.get("video_path") or ""))
|
||||||
|
self.toolbar.set_units(str(self.prefs.get("units") or "newton"))
|
||||||
|
self.arrow_panel.set_view_mode(str(self.prefs.get("image_view_mode") or "arrows_pure"))
|
||||||
|
self.arrow_panel.set_fps_visible(self._show_fps)
|
||||||
|
self._apply_plot_prefs_to_panel()
|
||||||
|
|
||||||
|
sensor_type = str(self.prefs.get("sensor_type") or "box")
|
||||||
|
config_name = str(self.prefs.get("config_name") or "")
|
||||||
|
self.sensor_picker.set_sensor_type(sensor_type)
|
||||||
|
if config_name:
|
||||||
|
self.sensor_picker.set_config_name(config_name)
|
||||||
|
else:
|
||||||
|
self.prefs["config_name"] = self.sensor_picker.config_name()
|
||||||
|
|
||||||
|
def _build_menus(self) -> None:
|
||||||
|
menu_bar = self.menuBar()
|
||||||
|
|
||||||
|
self.file_menu = menu_bar.addMenu("")
|
||||||
|
self.act_open_video = QAction(self)
|
||||||
|
self.act_open_video.triggered.connect(self._on_browse_video)
|
||||||
|
self.file_menu.addAction(self.act_open_video)
|
||||||
|
self.act_reveal_config = QAction(self)
|
||||||
|
self.act_reveal_config.triggered.connect(self._on_reveal_config)
|
||||||
|
self.file_menu.addAction(self.act_reveal_config)
|
||||||
|
|
||||||
|
self.view_menu = menu_bar.addMenu("")
|
||||||
|
self.act_cal_panel = QAction(self)
|
||||||
|
self.act_cal_panel.setCheckable(True)
|
||||||
|
self.act_cal_panel.setChecked(self._show_cal)
|
||||||
|
self.act_cal_panel.toggled.connect(self._on_cal_panel_toggled)
|
||||||
|
self.view_menu.addAction(self.act_cal_panel)
|
||||||
|
|
||||||
|
self.act_finger_3d = QAction(self)
|
||||||
|
self.act_finger_3d.setCheckable(True)
|
||||||
|
self.act_finger_3d.setChecked(self._show_finger_3d)
|
||||||
|
self.act_finger_3d.toggled.connect(self._on_finger_3d_toggled)
|
||||||
|
self.view_menu.addAction(self.act_finger_3d)
|
||||||
|
|
||||||
|
self.act_show_fps = QAction(self)
|
||||||
|
self.act_show_fps.setCheckable(True)
|
||||||
|
self.act_show_fps.setChecked(self._show_fps)
|
||||||
|
self.act_show_fps.toggled.connect(self._on_show_fps_toggled)
|
||||||
|
self.view_menu.addAction(self.act_show_fps)
|
||||||
|
|
||||||
|
self.act_plot_settings = QAction(self)
|
||||||
|
self.act_plot_settings.triggered.connect(self._on_plot_settings)
|
||||||
|
self.view_menu.addAction(self.act_plot_settings)
|
||||||
|
self.view_menu.addSeparator()
|
||||||
|
|
||||||
|
self.theme_menu = self.view_menu.addMenu("")
|
||||||
|
self.theme_group = QActionGroup(self)
|
||||||
|
self.theme_group.setExclusive(True)
|
||||||
|
self.act_theme_dark = QAction(self)
|
||||||
|
self.act_theme_dark.setCheckable(True)
|
||||||
|
self.act_theme_light = QAction(self)
|
||||||
|
self.act_theme_light.setCheckable(True)
|
||||||
|
self.theme_group.addAction(self.act_theme_dark)
|
||||||
|
self.theme_group.addAction(self.act_theme_light)
|
||||||
|
self.theme_menu.addAction(self.act_theme_dark)
|
||||||
|
self.theme_menu.addAction(self.act_theme_light)
|
||||||
|
self.act_theme_dark.triggered.connect(lambda: self._set_theme("dark"))
|
||||||
|
self.act_theme_light.triggered.connect(lambda: self._set_theme("light"))
|
||||||
|
|
||||||
|
self.lang_menu = self.view_menu.addMenu("")
|
||||||
|
self.lang_group = QActionGroup(self)
|
||||||
|
self.lang_group.setExclusive(True)
|
||||||
|
self.act_lang_zh = QAction(self)
|
||||||
|
self.act_lang_zh.setCheckable(True)
|
||||||
|
self.act_lang_en = QAction(self)
|
||||||
|
self.act_lang_en.setCheckable(True)
|
||||||
|
self.lang_group.addAction(self.act_lang_zh)
|
||||||
|
self.lang_group.addAction(self.act_lang_en)
|
||||||
|
self.lang_menu.addAction(self.act_lang_zh)
|
||||||
|
self.lang_menu.addAction(self.act_lang_en)
|
||||||
|
self.act_lang_zh.triggered.connect(lambda: self._set_language("zh_CN"))
|
||||||
|
self.act_lang_en.triggered.connect(lambda: self._set_language("en_US"))
|
||||||
|
|
||||||
|
self.help_menu = menu_bar.addMenu("")
|
||||||
|
self.act_about = QAction(self)
|
||||||
|
self.act_about.triggered.connect(self._show_about)
|
||||||
|
self.help_menu.addAction(self.act_about)
|
||||||
|
|
||||||
|
self._sync_menu_checks()
|
||||||
|
|
||||||
|
def _sync_menu_checks(self) -> None:
|
||||||
|
self.act_theme_dark.setChecked(self._theme != "light")
|
||||||
|
self.act_theme_light.setChecked(self._theme == "light")
|
||||||
|
self.act_lang_zh.setChecked(self._language.startswith("zh"))
|
||||||
|
self.act_lang_en.setChecked(not self._language.startswith("zh"))
|
||||||
|
self.act_cal_panel.blockSignals(True)
|
||||||
|
self.act_cal_panel.setChecked(self._show_cal)
|
||||||
|
self.act_cal_panel.blockSignals(False)
|
||||||
|
is_finger = self.sensor_picker.sensor_type() == "finger"
|
||||||
|
self.act_finger_3d.setEnabled(is_finger)
|
||||||
|
self.act_finger_3d.blockSignals(True)
|
||||||
|
self.act_finger_3d.setChecked(bool(self._show_finger_3d) if is_finger else False)
|
||||||
|
self.act_finger_3d.blockSignals(False)
|
||||||
|
|
||||||
|
def _apply_panel_visibility(self) -> None:
|
||||||
|
is_finger = self.sensor_picker.sensor_type() == "finger"
|
||||||
|
show_3d = is_finger and bool(self._show_finger_3d)
|
||||||
|
self.mesh3d_panel.setVisible(show_3d)
|
||||||
|
self.cal_panel.setVisible(self._show_cal)
|
||||||
|
self.act_finger_3d.setEnabled(is_finger)
|
||||||
|
self.act_finger_3d.blockSignals(True)
|
||||||
|
self.act_finger_3d.setChecked(show_3d)
|
||||||
|
self.act_finger_3d.blockSignals(False)
|
||||||
|
for i in range(self.content_splitter.count()):
|
||||||
|
widget = self.content_splitter.widget(i)
|
||||||
|
if widget is self.mesh3d_panel:
|
||||||
|
self.content_splitter.setStretchFactor(i, 3 if show_3d else 0)
|
||||||
|
elif widget is self.arrow_panel:
|
||||||
|
self.content_splitter.setStretchFactor(i, 3)
|
||||||
|
elif widget is self.force_plots:
|
||||||
|
self.content_splitter.setStretchFactor(i, 3)
|
||||||
|
elif widget is self.cal_panel:
|
||||||
|
self.content_splitter.setStretchFactor(i, 2 if self._show_cal else 0)
|
||||||
|
|
||||||
|
def _update_run_enabled(self) -> None:
|
||||||
|
sensor_ok = self.sensor_picker.sensor_type() in RUNNABLE_TYPES
|
||||||
|
source_ok = True
|
||||||
|
if self.toolbar.source_mode() == "video" and not self.toolbar.video_path():
|
||||||
|
source_ok = False
|
||||||
|
self.toolbar.set_run_enabled(self._running or (sensor_ok and source_ok))
|
||||||
|
if not self._running:
|
||||||
|
self.toolbar._refresh_run_label()
|
||||||
|
|
||||||
|
def retranslate(self) -> None:
|
||||||
|
self.setWindowTitle(t("app.title"))
|
||||||
|
self.file_menu.setTitle(t("menu.file"))
|
||||||
|
self.act_open_video.setText(t("menu.open_video"))
|
||||||
|
self.act_reveal_config.setText(t("menu.reveal_config"))
|
||||||
|
self.view_menu.setTitle(t("menu.view"))
|
||||||
|
self.act_cal_panel.setText(t("menu.calibration_panel"))
|
||||||
|
self.act_finger_3d.setText(t("menu.finger_3d"))
|
||||||
|
self.act_show_fps.setText(t("menu.show_fps"))
|
||||||
|
self.act_plot_settings.setText(t("menu.plot_settings"))
|
||||||
|
self.theme_menu.setTitle(t("menu.theme"))
|
||||||
|
self.act_theme_dark.setText(t("menu.theme_dark"))
|
||||||
|
self.act_theme_light.setText(t("menu.theme_light"))
|
||||||
|
self.lang_menu.setTitle(t("menu.language"))
|
||||||
|
self.act_lang_zh.setText(t("menu.lang_zh"))
|
||||||
|
self.act_lang_en.setText(t("menu.lang_en"))
|
||||||
|
self.help_menu.setTitle(t("menu.help"))
|
||||||
|
self.act_about.setText(t("menu.about"))
|
||||||
|
self.toolbar.retranslate()
|
||||||
|
self.sensor_picker.retranslate()
|
||||||
|
self.mesh3d_panel.retranslate()
|
||||||
|
self.arrow_panel.retranslate()
|
||||||
|
self.force_plots.retranslate()
|
||||||
|
self.cal_panel.retranslate()
|
||||||
|
if not self._preview_open and not self._running:
|
||||||
|
self.statusBar().showMessage(t("status.ready"))
|
||||||
|
self._update_run_enabled()
|
||||||
|
|
||||||
|
def _set_theme(self, theme: str) -> None:
|
||||||
|
self._theme = theme
|
||||||
|
self.prefs["theme"] = theme
|
||||||
|
app = QApplication.instance()
|
||||||
|
if app is not None:
|
||||||
|
app.setStyleSheet(build_qss(theme))
|
||||||
|
self.force_plots.set_theme(theme)
|
||||||
|
self._sync_menu_checks()
|
||||||
|
self._persist_prefs()
|
||||||
|
|
||||||
|
def _set_language(self, language: str) -> None:
|
||||||
|
self._language = language
|
||||||
|
self.prefs["language"] = language
|
||||||
|
set_locale(language)
|
||||||
|
self.retranslate()
|
||||||
|
self._sync_menu_checks()
|
||||||
|
self._persist_prefs()
|
||||||
|
|
||||||
|
def _show_about(self) -> None:
|
||||||
|
AboutDialog(self).exec_()
|
||||||
|
|
||||||
|
def _on_camera_id_changed(self, value: int) -> None:
|
||||||
|
self.prefs["camera_id"] = str(int(value))
|
||||||
|
self._persist_prefs()
|
||||||
|
|
||||||
|
def _on_source_mode_changed(self, mode: str) -> None:
|
||||||
|
self.prefs["source_mode"] = mode
|
||||||
|
self._persist_prefs()
|
||||||
|
self._update_run_enabled()
|
||||||
|
|
||||||
|
def _on_units_changed(self, units: str) -> None:
|
||||||
|
self.prefs["units"] = units
|
||||||
|
self.force_plots.set_fixed_newton(str(units) == "newton")
|
||||||
|
self._persist_prefs()
|
||||||
|
|
||||||
|
def _on_image_view_mode_changed(self, mode: str) -> None:
|
||||||
|
self.prefs["image_view_mode"] = str(mode)
|
||||||
|
self._last_rendered_view_mode = None
|
||||||
|
self._persist_prefs()
|
||||||
|
if self._running:
|
||||||
|
if self._active_worker is not None:
|
||||||
|
self._render_run_image(self._active_worker.latest())
|
||||||
|
|
||||||
|
def _on_plot_settings(self) -> None:
|
||||||
|
dialog = PlotSettingsDialog(
|
||||||
|
self,
|
||||||
|
window_sec=float(self.prefs.get("plot_window_sec") or DEFAULT_PLOT_WINDOW_SEC),
|
||||||
|
fn_max=float(self.prefs.get("plot_fn_max") or DEFAULT_PLOT_FN_MAX),
|
||||||
|
shear_max=float(self.prefs.get("plot_shear_max") or DEFAULT_PLOT_SHEAR_MAX),
|
||||||
|
units=self.toolbar.units(),
|
||||||
|
)
|
||||||
|
if dialog.exec_() != PlotSettingsDialog.Accepted:
|
||||||
|
return
|
||||||
|
window_sec, fn_max, shear_max = dialog.values()
|
||||||
|
self.prefs["plot_window_sec"] = window_sec
|
||||||
|
self.prefs["plot_fn_max"] = fn_max
|
||||||
|
self.prefs["plot_shear_max"] = shear_max
|
||||||
|
self.force_plots.set_window_sec(window_sec)
|
||||||
|
self.force_plots.set_axis_limits(fn_max, shear_max)
|
||||||
|
self._last_plot_packet_key = None
|
||||||
|
self._persist_prefs()
|
||||||
|
|
||||||
|
def _apply_plot_prefs_to_panel(self) -> None:
|
||||||
|
self.force_plots.set_window_sec(
|
||||||
|
float(self.prefs.get("plot_window_sec") or DEFAULT_PLOT_WINDOW_SEC)
|
||||||
|
)
|
||||||
|
self.force_plots.set_axis_limits(
|
||||||
|
float(self.prefs.get("plot_fn_max") or DEFAULT_PLOT_FN_MAX),
|
||||||
|
float(self.prefs.get("plot_shear_max") or DEFAULT_PLOT_SHEAR_MAX),
|
||||||
|
)
|
||||||
|
self.force_plots.set_fixed_newton(self.toolbar.units() == "newton")
|
||||||
|
|
||||||
|
def _on_cal_panel_toggled(self, visible: bool) -> None:
|
||||||
|
self._show_cal = bool(visible)
|
||||||
|
self.prefs["show_cal_panel"] = self._show_cal
|
||||||
|
self._apply_panel_visibility()
|
||||||
|
self._persist_prefs()
|
||||||
|
|
||||||
|
def _on_finger_3d_toggled(self, visible: bool) -> None:
|
||||||
|
if self.sensor_picker.sensor_type() != "finger":
|
||||||
|
return
|
||||||
|
self._show_finger_3d = bool(visible)
|
||||||
|
self.prefs["show_finger_3d"] = self._show_finger_3d
|
||||||
|
self._apply_panel_visibility()
|
||||||
|
self._persist_prefs()
|
||||||
|
|
||||||
|
def _on_show_fps_toggled(self, visible: bool) -> None:
|
||||||
|
self._show_fps = bool(visible)
|
||||||
|
self.prefs["show_fps"] = self._show_fps
|
||||||
|
self.arrow_panel.set_fps_visible(self._show_fps)
|
||||||
|
if not self._show_fps:
|
||||||
|
self.arrow_panel.set_fps(None)
|
||||||
|
self._persist_prefs()
|
||||||
|
|
||||||
|
def _on_sensor_type_changed(self, sensor_type: str) -> None:
|
||||||
|
self.prefs["sensor_type"] = str(sensor_type)
|
||||||
|
self.prefs["config_name"] = self.sensor_picker.config_name()
|
||||||
|
self.cal_panel.set_meta_context(
|
||||||
|
sensor_type=str(sensor_type),
|
||||||
|
config_ref=self._resolve_config_ref(),
|
||||||
|
)
|
||||||
|
self._apply_panel_visibility()
|
||||||
|
self._update_run_enabled()
|
||||||
|
self._persist_prefs()
|
||||||
|
|
||||||
|
def _on_config_changed(self, config_name: str) -> None:
|
||||||
|
self.prefs["config_name"] = str(config_name)
|
||||||
|
self._persist_prefs()
|
||||||
|
|
||||||
|
def _on_browse_video(self) -> None:
|
||||||
|
if self._running or self._preview_open:
|
||||||
|
return
|
||||||
|
start = self.toolbar.video_path() or str(Path.home())
|
||||||
|
path, _ = QFileDialog.getOpenFileName(
|
||||||
|
self,
|
||||||
|
t("menu.open_video"),
|
||||||
|
start,
|
||||||
|
"Video (*.mp4 *.avi *.mov *.mkv);;All (*)",
|
||||||
|
)
|
||||||
|
if not path:
|
||||||
|
return
|
||||||
|
self.toolbar.set_source_mode("video")
|
||||||
|
self.toolbar.set_video_path(path)
|
||||||
|
self.prefs["source_mode"] = "video"
|
||||||
|
self.prefs["video_path"] = path
|
||||||
|
self._persist_prefs()
|
||||||
|
self._update_run_enabled()
|
||||||
|
|
||||||
|
def _on_reveal_config(self) -> None:
|
||||||
|
sensor_type = self.sensor_picker.sensor_type()
|
||||||
|
path = sensor_json_path(sensor_type)
|
||||||
|
if path is None:
|
||||||
|
cfg = self.sensor_picker.config_name()
|
||||||
|
candidate = Path(cfg)
|
||||||
|
path = candidate if candidate.is_file() else None
|
||||||
|
if path is None:
|
||||||
|
self.statusBar().showMessage(t("status.config_missing", type=sensor_type))
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
_reveal_in_explorer(path)
|
||||||
|
except Exception as exc:
|
||||||
|
self.statusBar().showMessage(t("status.reveal_failed", error=str(exc)))
|
||||||
|
|
||||||
|
def _resolve_config_ref(self) -> str | None:
|
||||||
|
sensor_type = self.sensor_picker.sensor_type()
|
||||||
|
local = sensor_json_path(sensor_type)
|
||||||
|
if local is not None:
|
||||||
|
return str(local)
|
||||||
|
cfg = self.sensor_picker.config_name()
|
||||||
|
if cfg and Path(cfg).is_file():
|
||||||
|
return cfg
|
||||||
|
if sensor_type in RUNNABLE_TYPES:
|
||||||
|
return sensor_type
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _on_search_cameras(self) -> None:
|
||||||
|
if self._preview_open or self._running:
|
||||||
|
return
|
||||||
|
from orisys_app_shared.camera import list_cameras, pick_default_camera
|
||||||
|
|
||||||
|
preferred = str(self.toolbar.camera_id())
|
||||||
|
self.statusBar().showMessage(t("toolbar.search_cameras") + "…")
|
||||||
|
QApplication.processEvents()
|
||||||
|
cameras = list_cameras()
|
||||||
|
if not cameras:
|
||||||
|
self.statusBar().showMessage(t("status.camera_none"))
|
||||||
|
return
|
||||||
|
names = ", ".join(cam.label() for cam in cameras)
|
||||||
|
self.statusBar().showMessage(
|
||||||
|
t("status.camera_found", count=len(cameras), names=names)
|
||||||
|
)
|
||||||
|
selected = pick_default_camera(cameras, preferred)
|
||||||
|
if selected is not None:
|
||||||
|
self.toolbar.set_camera_id(int(selected))
|
||||||
|
self.prefs["camera_id"] = str(selected)
|
||||||
|
self._persist_prefs()
|
||||||
|
|
||||||
|
def _on_preview_toggled(self, checked: bool) -> None:
|
||||||
|
if checked:
|
||||||
|
self._start_preview()
|
||||||
|
else:
|
||||||
|
self._stop_preview(announce=True)
|
||||||
|
|
||||||
|
def _start_preview(self) -> None:
|
||||||
|
if self._running or self.toolbar.source_mode() != "camera":
|
||||||
|
self.toolbar.set_preview_checked(False)
|
||||||
|
return
|
||||||
|
camera_id = self.toolbar.camera_id()
|
||||||
|
try:
|
||||||
|
self._get_preview().open(camera_id)
|
||||||
|
except Exception as exc:
|
||||||
|
msg = t("status.preview_failed", error=str(exc))
|
||||||
|
self.statusBar().showMessage(msg)
|
||||||
|
self.toolbar.set_preview_checked(False)
|
||||||
|
self.arrow_panel.clear_preview(msg)
|
||||||
|
return
|
||||||
|
self.toolbar.set_source_controls_enabled(False)
|
||||||
|
self.toolbar.btn_preview.setEnabled(True)
|
||||||
|
self._update_run_enabled()
|
||||||
|
self._preview_timer.start()
|
||||||
|
self.statusBar().showMessage(t("status.preview_started", camera_id=camera_id))
|
||||||
|
|
||||||
|
def _stop_preview(self, *, announce: bool) -> None:
|
||||||
|
self._preview_timer.stop()
|
||||||
|
if self._preview is not None:
|
||||||
|
self._preview.close()
|
||||||
|
if not self._running:
|
||||||
|
self.toolbar.set_source_controls_enabled(True)
|
||||||
|
self.arrow_panel.clear_preview(t("app.scaffold_hint"))
|
||||||
|
self.toolbar.set_preview_checked(False)
|
||||||
|
self._update_run_enabled()
|
||||||
|
if announce:
|
||||||
|
self.statusBar().showMessage(t("status.preview_stopped"))
|
||||||
|
|
||||||
|
def _on_preview_tick(self) -> None:
|
||||||
|
if self._preview is None:
|
||||||
|
return
|
||||||
|
frame = self._preview.read()
|
||||||
|
if frame is None:
|
||||||
|
self._stop_preview(announce=True)
|
||||||
|
self.statusBar().showMessage(t("status.preview_failed", error="no frame"))
|
||||||
|
return
|
||||||
|
self.arrow_panel.show_frame(_bgr_to_qimage(frame))
|
||||||
|
|
||||||
|
def _on_run_toggled(self, checked: bool) -> None:
|
||||||
|
if checked:
|
||||||
|
self._start_run()
|
||||||
|
else:
|
||||||
|
self._stop_run(reason="user_stop")
|
||||||
|
|
||||||
|
def _start_run(self) -> None:
|
||||||
|
from orisys_app_shared.acquisition.sdk_worker import orisys_importable
|
||||||
|
|
||||||
|
sensor_type = self.sensor_picker.sensor_type()
|
||||||
|
if sensor_type not in RUNNABLE_TYPES:
|
||||||
|
self.toolbar.set_run_checked(False)
|
||||||
|
self.statusBar().showMessage(t("status.finger_run_unavailable"))
|
||||||
|
return
|
||||||
|
ok, detail = orisys_importable()
|
||||||
|
if not ok:
|
||||||
|
self.toolbar.set_run_checked(False)
|
||||||
|
QMessageBox.warning(self, t("app.title"), detail)
|
||||||
|
return
|
||||||
|
config_ref = self._resolve_config_ref()
|
||||||
|
if not config_ref:
|
||||||
|
self.toolbar.set_run_checked(False)
|
||||||
|
self.statusBar().showMessage(
|
||||||
|
t("status.config_missing", type=sensor_type)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
source = self.toolbar.active_source()
|
||||||
|
if source is None:
|
||||||
|
self.toolbar.set_run_checked(False)
|
||||||
|
self.statusBar().showMessage(t("status.video_required"))
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._preview_open:
|
||||||
|
self._stop_preview(announce=False)
|
||||||
|
|
||||||
|
self.force_plots.reset()
|
||||||
|
self._last_plot_packet_key = None
|
||||||
|
self._last_rendered_view_mode = None
|
||||||
|
self._last_session_error = ""
|
||||||
|
self._run_source_label = str(source)
|
||||||
|
self._active_sensor_type = sensor_type
|
||||||
|
self._ensure_workers()
|
||||||
|
self._active_worker = (
|
||||||
|
self._finger_worker if sensor_type == "finger" else self._box_worker
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self._active_worker.start(source, config_ref=config_ref)
|
||||||
|
except Exception as exc:
|
||||||
|
report_error(exc, message="Run failed")
|
||||||
|
self.toolbar.set_run_checked(False)
|
||||||
|
self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
|
||||||
|
return
|
||||||
|
|
||||||
|
self._running = True
|
||||||
|
self._session_phase = "connecting"
|
||||||
|
self.cal_panel.set_meta_context(sensor_type=sensor_type, config_ref=config_ref)
|
||||||
|
self.cal_panel.set_running(True)
|
||||||
|
self.toolbar.set_source_controls_enabled(False)
|
||||||
|
self.sensor_picker.type_combo.setEnabled(False)
|
||||||
|
self.sensor_picker.config_combo.setEnabled(False)
|
||||||
|
self.toolbar.set_reset_enabled(False)
|
||||||
|
self._run_timer.start()
|
||||||
|
self.statusBar().showMessage(t("status.connecting"))
|
||||||
|
logger.info(
|
||||||
|
"Connecting type=%s source=%s config=%s",
|
||||||
|
sensor_type,
|
||||||
|
source,
|
||||||
|
config_ref,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _stop_run(self, *, reason: str = "user_stop") -> None:
|
||||||
|
self._run_timer.stop()
|
||||||
|
if self._active_worker is not None:
|
||||||
|
self._active_worker.stop()
|
||||||
|
self._running = False
|
||||||
|
self._session_phase = "idle"
|
||||||
|
self.cal_panel.set_running(False)
|
||||||
|
self.toolbar.set_run_checked(False)
|
||||||
|
self.toolbar.set_reset_enabled(False)
|
||||||
|
self.toolbar.set_source_controls_enabled(True)
|
||||||
|
self.sensor_picker.type_combo.setEnabled(True)
|
||||||
|
self.sensor_picker.config_combo.setEnabled(True)
|
||||||
|
self._update_run_enabled()
|
||||||
|
keep_frame = reason in ("ended", "disconnected")
|
||||||
|
if not keep_frame:
|
||||||
|
self.arrow_panel.clear_preview(t("app.scaffold_hint"))
|
||||||
|
else:
|
||||||
|
self.arrow_panel.set_fps(None)
|
||||||
|
if reason == "ended":
|
||||||
|
self.statusBar().showMessage(t("status.video_ended"))
|
||||||
|
elif reason == "disconnected":
|
||||||
|
self.statusBar().showMessage(t("status.camera_disconnected"))
|
||||||
|
elif reason == "failed":
|
||||||
|
pass # caller sets status
|
||||||
|
elif reason == "user_stop":
|
||||||
|
self.statusBar().showMessage(t("status.run_stopped"))
|
||||||
|
else:
|
||||||
|
self.statusBar().showMessage(t("status.run_stopped"))
|
||||||
|
|
||||||
|
def _on_reset(self) -> None:
|
||||||
|
if self._session_phase != "running" or self._active_worker is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._active_worker.reset()
|
||||||
|
self.force_plots.reset()
|
||||||
|
self._last_plot_packet_key = None
|
||||||
|
self.statusBar().showMessage(t("status.reset_ok"))
|
||||||
|
except Exception as exc:
|
||||||
|
self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
|
||||||
|
|
||||||
|
def _on_worker_error(self, message: str) -> None:
|
||||||
|
self._last_session_error = message
|
||||||
|
logger.error("Acquisition error: %s", message)
|
||||||
|
|
||||||
|
def _on_worker_status(self, message: str) -> None:
|
||||||
|
logger.info("%s", message)
|
||||||
|
|
||||||
|
def _on_run_tick(self) -> None:
|
||||||
|
if self._active_worker is None:
|
||||||
|
return
|
||||||
|
if self._last_session_error:
|
||||||
|
err = self._last_session_error
|
||||||
|
self._last_session_error = ""
|
||||||
|
self._active_worker.consume_end_reason()
|
||||||
|
self._stop_run(reason="failed")
|
||||||
|
self.statusBar().showMessage(t("status.run_failed", error=err))
|
||||||
|
return
|
||||||
|
|
||||||
|
end_reason = self._active_worker.consume_end_reason()
|
||||||
|
if end_reason in ("ended", "disconnected"):
|
||||||
|
self._stop_run(reason=end_reason)
|
||||||
|
return
|
||||||
|
if end_reason == "error":
|
||||||
|
self._stop_run(reason="failed")
|
||||||
|
self.statusBar().showMessage(t("status.run_failed", error="acquisition error"))
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self._active_worker.is_running and self._running:
|
||||||
|
leftover = self._active_worker.consume_end_reason()
|
||||||
|
if leftover in ("ended", "disconnected"):
|
||||||
|
self._stop_run(reason=leftover)
|
||||||
|
elif leftover == "error":
|
||||||
|
self._stop_run(reason="failed")
|
||||||
|
self.statusBar().showMessage(
|
||||||
|
t("status.run_failed", error="acquisition error")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._stop_run(reason="user_stop")
|
||||||
|
return
|
||||||
|
|
||||||
|
packet = self._active_worker.latest()
|
||||||
|
if packet is None:
|
||||||
|
return
|
||||||
|
packet_key = (int(packet.session_id), float(packet.timestamp))
|
||||||
|
is_new = packet_key != self._last_plot_packet_key
|
||||||
|
|
||||||
|
if self._session_phase == "connecting" and is_new:
|
||||||
|
self._session_phase = "running"
|
||||||
|
self.toolbar.set_reset_enabled(True)
|
||||||
|
self.statusBar().showMessage(
|
||||||
|
t("status.run_started", source=self._run_source_label)
|
||||||
|
)
|
||||||
|
|
||||||
|
if is_new:
|
||||||
|
self._last_plot_packet_key = packet_key
|
||||||
|
display = select_display_forces(
|
||||||
|
units_pref=self.toolbar.units(),
|
||||||
|
force_calibrated=packet.force_calibrated,
|
||||||
|
fn=packet.normal_force,
|
||||||
|
fx=packet.shear_x,
|
||||||
|
fy=packet.shear_y,
|
||||||
|
fn_raw=packet.normal_force_raw,
|
||||||
|
fx_raw=packet.shear_x_raw,
|
||||||
|
fy_raw=packet.shear_y_raw,
|
||||||
|
)
|
||||||
|
if display.using_raw_fallback:
|
||||||
|
self.statusBar().showMessage(t("status.units_raw_fallback"))
|
||||||
|
self.force_plots.push(
|
||||||
|
packet.timestamp,
|
||||||
|
display.fn,
|
||||||
|
display.fx,
|
||||||
|
display.fy,
|
||||||
|
unit=display.unit,
|
||||||
|
)
|
||||||
|
if self._show_cal:
|
||||||
|
self.cal_panel.push_raw(
|
||||||
|
packet.normal_force_raw,
|
||||||
|
packet.shear_x_raw,
|
||||||
|
packet.shear_y_raw,
|
||||||
|
packet.timestamp,
|
||||||
|
)
|
||||||
|
|
||||||
|
mode = self.arrow_panel.view_mode()
|
||||||
|
if is_new or mode != self._last_rendered_view_mode:
|
||||||
|
self._render_run_image(packet)
|
||||||
|
if self._show_fps and is_new:
|
||||||
|
self.arrow_panel.set_fps(packet.fps)
|
||||||
|
|
||||||
|
def _render_run_image(self, packet) -> None:
|
||||||
|
if packet is None:
|
||||||
|
return
|
||||||
|
from orisys_app_shared.acquisition.arrows import (
|
||||||
|
depth_map_to_bgr,
|
||||||
|
draw_arrow_display,
|
||||||
|
hex_to_bgr,
|
||||||
|
)
|
||||||
|
|
||||||
|
mode = self.arrow_panel.view_mode()
|
||||||
|
self._last_rendered_view_mode = mode
|
||||||
|
draw_style = "finger" if self._active_sensor_type == "finger" else "box"
|
||||||
|
if mode == "depth":
|
||||||
|
depth_bgr = depth_map_to_bgr(packet.depth)
|
||||||
|
frame = depth_bgr if depth_bgr is not None else packet.processed_frame
|
||||||
|
elif mode == "arrows_stitched":
|
||||||
|
frame = draw_arrow_display(
|
||||||
|
packet.processed_frame,
|
||||||
|
packet.flow,
|
||||||
|
background="captured",
|
||||||
|
draw_style=draw_style,
|
||||||
|
valid_mask=packet.valid_mask,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
canvas_bgr = hex_to_bgr(color(self._theme, "arrow_canvas", "#121316"))
|
||||||
|
frame = draw_arrow_display(
|
||||||
|
packet.processed_frame,
|
||||||
|
packet.flow,
|
||||||
|
background="pure_color",
|
||||||
|
canvas_bgr=canvas_bgr,
|
||||||
|
draw_style=draw_style,
|
||||||
|
valid_mask=packet.valid_mask,
|
||||||
|
)
|
||||||
|
self.arrow_panel.show_frame(_bgr_to_qimage(frame))
|
||||||
|
|
||||||
|
def _restore_geometry(self) -> None:
|
||||||
|
geo = self.prefs.get("window_geometry")
|
||||||
|
if isinstance(geo, str) and geo:
|
||||||
|
try:
|
||||||
|
from PyQt5.QtCore import QByteArray
|
||||||
|
|
||||||
|
self.restoreGeometry(QByteArray.fromHex(geo.encode("ascii")))
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Could not restore window geometry", exc_info=True)
|
||||||
|
|
||||||
|
def _persist_prefs(self) -> None:
|
||||||
|
geo = self.saveGeometry().toHex().data().decode("ascii")
|
||||||
|
self.prefs["window_geometry"] = geo
|
||||||
|
self.prefs["theme"] = self._theme
|
||||||
|
self.prefs["language"] = self._language
|
||||||
|
self.prefs["camera_id"] = str(self.toolbar.camera_id())
|
||||||
|
self.prefs["source_mode"] = self.toolbar.source_mode()
|
||||||
|
self.prefs["video_path"] = self.toolbar.video_path()
|
||||||
|
self.prefs["units"] = self.toolbar.units()
|
||||||
|
self.prefs["image_view_mode"] = self.arrow_panel.view_mode()
|
||||||
|
self.prefs.setdefault("plot_window_sec", DEFAULT_PLOT_WINDOW_SEC)
|
||||||
|
self.prefs.setdefault("plot_fn_max", DEFAULT_PLOT_FN_MAX)
|
||||||
|
self.prefs.setdefault("plot_shear_max", DEFAULT_PLOT_SHEAR_MAX)
|
||||||
|
self.prefs["show_cal_panel"] = bool(self._show_cal)
|
||||||
|
self.prefs["show_finger_3d"] = bool(self._show_finger_3d)
|
||||||
|
self.prefs["show_fps"] = bool(self._show_fps)
|
||||||
|
self.prefs["sensor_type"] = self.sensor_picker.sensor_type()
|
||||||
|
self.prefs["config_name"] = self.sensor_picker.config_name()
|
||||||
|
save_preferences(self.prefs)
|
||||||
|
|
||||||
|
def closeEvent(self, event) -> None: # noqa: N802
|
||||||
|
self._stop_preview(announce=False)
|
||||||
|
self._stop_run(reason="user_stop")
|
||||||
|
self._persist_prefs()
|
||||||
|
logger.info("Customer shell closed; preferences saved")
|
||||||
|
super().closeEvent(event)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Customer app entrypoint."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PyQt5.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from orisys_app_shared import API_VERSION, __version__ as shared_version
|
||||||
|
from orisys_app_shared.compat import check_shared_compat
|
||||||
|
from orisys_app_shared.i18n import set_locale
|
||||||
|
from orisys_app_shared.logging import init_logging, install_exception_hooks
|
||||||
|
from orisys_app_shared.paths import prefer_local_sensor_bundles
|
||||||
|
from orisys_app_shared.prefs import load_preferences
|
||||||
|
from orisys_app_shared.theme import build_qss
|
||||||
|
|
||||||
|
from orisys_customer import REQUIRES_SHARED_API, __version__ as app_version
|
||||||
|
from orisys_customer.app_window import CustomerMainWindow
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
init_logging("orisys-customer")
|
||||||
|
install_exception_hooks()
|
||||||
|
preferred = prefer_local_sensor_bundles()
|
||||||
|
if preferred is not None:
|
||||||
|
logger.info("Preferring local sensor bundles at %s", preferred)
|
||||||
|
check_shared_compat(REQUIRES_SHARED_API)
|
||||||
|
logger.info(
|
||||||
|
"Versions: app=%s shared=%s api=%s (requires %s)",
|
||||||
|
app_version,
|
||||||
|
shared_version,
|
||||||
|
API_VERSION,
|
||||||
|
REQUIRES_SHARED_API,
|
||||||
|
)
|
||||||
|
|
||||||
|
prefs = load_preferences()
|
||||||
|
language = str(prefs.get("language") or "zh_CN")
|
||||||
|
theme = str(prefs.get("theme") or "dark")
|
||||||
|
set_locale(language)
|
||||||
|
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
app.setApplicationName("Orisys Customer")
|
||||||
|
app.setApplicationVersion(app_version)
|
||||||
|
app.setStyleSheet(build_qss(theme))
|
||||||
|
|
||||||
|
window = CustomerMainWindow(prefs=prefs)
|
||||||
|
window.show()
|
||||||
|
logger.info("Customer shell started (theme=%s, language=%s)", theme, language)
|
||||||
|
return int(app.exec_())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""UI package."""
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""About dialog for Help > About."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PyQt5.QtCore import Qt
|
||||||
|
from PyQt5.QtGui import QPixmap
|
||||||
|
from PyQt5.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
from orisys_app_shared.paths import logo_topbar_path
|
||||||
|
|
||||||
|
from orisys_customer import __version__ as app_version
|
||||||
|
|
||||||
|
|
||||||
|
class AboutDialog(QDialog):
|
||||||
|
def __init__(self, parent=None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle(t("menu.about"))
|
||||||
|
self.setModal(True)
|
||||||
|
self.resize(420, 220)
|
||||||
|
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
row = QHBoxLayout()
|
||||||
|
logo = QLabel()
|
||||||
|
pix = QPixmap(str(logo_topbar_path()))
|
||||||
|
if not pix.isNull():
|
||||||
|
logo.setPixmap(pix.scaledToHeight(48, Qt.SmoothTransformation))
|
||||||
|
row.addWidget(logo)
|
||||||
|
row.addStretch(1)
|
||||||
|
root.addLayout(row)
|
||||||
|
|
||||||
|
self.body = QLabel()
|
||||||
|
self.body.setWordWrap(True)
|
||||||
|
self.body.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||||
|
root.addWidget(self.body)
|
||||||
|
|
||||||
|
close_btn = QPushButton("OK")
|
||||||
|
close_btn.clicked.connect(self.accept)
|
||||||
|
root.addWidget(close_btn, alignment=Qt.AlignRight)
|
||||||
|
self.retranslate()
|
||||||
|
|
||||||
|
def retranslate(self) -> None:
|
||||||
|
self.setWindowTitle(t("app.about_title"))
|
||||||
|
self.body.setText(t("app.about_body", app_version=app_version))
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""2D image panel: arrows / depth view with mode selector."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PyQt5.QtCore import Qt, pyqtSignal
|
||||||
|
from PyQt5.QtGui import QImage, QPixmap
|
||||||
|
from PyQt5.QtWidgets import (
|
||||||
|
QComboBox,
|
||||||
|
QFrame,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QSizePolicy,
|
||||||
|
QVBoxLayout,
|
||||||
|
)
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
|
VIEW_MODES = ("arrows_pure", "arrows_stitched", "depth")
|
||||||
|
|
||||||
|
|
||||||
|
class ArrowPanel(QFrame):
|
||||||
|
view_mode_changed = pyqtSignal(str)
|
||||||
|
|
||||||
|
def __init__(self, parent=None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName("contentPanel")
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
|
||||||
|
header = QHBoxLayout()
|
||||||
|
self.title = QLabel()
|
||||||
|
self.title.setObjectName("panelTitle")
|
||||||
|
self.fps_label = QLabel()
|
||||||
|
self.fps_label.setObjectName("panelMeta")
|
||||||
|
self.fps_label.setVisible(False)
|
||||||
|
self._fps_visible = False
|
||||||
|
self._fps_value: float | None = None
|
||||||
|
self.view_combo = QComboBox()
|
||||||
|
self.view_combo.setMinimumWidth(140)
|
||||||
|
self.view_combo.currentIndexChanged.connect(self._on_view_index_changed)
|
||||||
|
header.addWidget(self.title)
|
||||||
|
header.addStretch(1)
|
||||||
|
header.addWidget(self.fps_label)
|
||||||
|
header.addWidget(self.view_combo)
|
||||||
|
layout.addLayout(header)
|
||||||
|
|
||||||
|
self.image_label = QLabel()
|
||||||
|
self.image_label.setAlignment(Qt.AlignCenter)
|
||||||
|
self.image_label.setMinimumSize(320, 240)
|
||||||
|
self.image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||||
|
self.image_label.setScaledContents(False)
|
||||||
|
layout.addWidget(self.image_label, 1)
|
||||||
|
self._placeholder = ""
|
||||||
|
self._populate_view_combo()
|
||||||
|
self.retranslate()
|
||||||
|
|
||||||
|
def view_mode(self) -> str:
|
||||||
|
data = self.view_combo.currentData()
|
||||||
|
return str(data or "arrows_pure")
|
||||||
|
|
||||||
|
def set_view_mode(self, mode: str) -> None:
|
||||||
|
wanted = str(mode or "arrows_pure")
|
||||||
|
if wanted not in VIEW_MODES:
|
||||||
|
wanted = "arrows_pure"
|
||||||
|
idx = self.view_combo.findData(wanted)
|
||||||
|
if idx < 0:
|
||||||
|
idx = 0
|
||||||
|
self.view_combo.blockSignals(True)
|
||||||
|
self.view_combo.setCurrentIndex(idx)
|
||||||
|
self.view_combo.blockSignals(False)
|
||||||
|
|
||||||
|
def set_fps_visible(self, visible: bool) -> None:
|
||||||
|
self._fps_visible = bool(visible)
|
||||||
|
self._refresh_fps_label()
|
||||||
|
|
||||||
|
def set_fps(self, fps: float | None) -> None:
|
||||||
|
self._fps_value = None if fps is None else float(fps)
|
||||||
|
self._refresh_fps_label()
|
||||||
|
|
||||||
|
def _refresh_fps_label(self) -> None:
|
||||||
|
if not self._fps_visible or self._fps_value is None:
|
||||||
|
self.fps_label.clear()
|
||||||
|
self.fps_label.setVisible(False)
|
||||||
|
return
|
||||||
|
self.fps_label.setText(f"{self._fps_value:.1f} FPS")
|
||||||
|
self.fps_label.setVisible(True)
|
||||||
|
|
||||||
|
def _populate_view_combo(self) -> None:
|
||||||
|
current = self.view_combo.currentData()
|
||||||
|
self.view_combo.blockSignals(True)
|
||||||
|
self.view_combo.clear()
|
||||||
|
for mode in VIEW_MODES:
|
||||||
|
self.view_combo.addItem("", mode)
|
||||||
|
if current:
|
||||||
|
idx = self.view_combo.findData(current)
|
||||||
|
self.view_combo.setCurrentIndex(max(0, idx))
|
||||||
|
self.view_combo.blockSignals(False)
|
||||||
|
|
||||||
|
def _on_view_index_changed(self, _index: int) -> None:
|
||||||
|
self.view_mode_changed.emit(self.view_mode())
|
||||||
|
|
||||||
|
def retranslate(self) -> None:
|
||||||
|
self.title.setText(t("panels.arrow"))
|
||||||
|
labels = (
|
||||||
|
t("panels.view_arrows_pure"),
|
||||||
|
t("panels.view_arrows_stitched"),
|
||||||
|
t("panels.view_depth"),
|
||||||
|
)
|
||||||
|
for i, label in enumerate(labels):
|
||||||
|
if i < self.view_combo.count():
|
||||||
|
self.view_combo.setItemText(i, label)
|
||||||
|
self.view_combo.setToolTip(t("panels.view_mode_tip"))
|
||||||
|
if not self.image_label.pixmap() or self.image_label.pixmap().isNull():
|
||||||
|
self._placeholder = t("app.scaffold_hint")
|
||||||
|
self.image_label.setText(self._placeholder)
|
||||||
|
|
||||||
|
def show_frame(self, image: QImage) -> None:
|
||||||
|
if image.isNull():
|
||||||
|
return
|
||||||
|
pix = QPixmap.fromImage(image)
|
||||||
|
scaled = pix.scaled(
|
||||||
|
self.image_label.size(),
|
||||||
|
Qt.KeepAspectRatio,
|
||||||
|
Qt.SmoothTransformation,
|
||||||
|
)
|
||||||
|
self.image_label.setPixmap(scaled)
|
||||||
|
self.image_label.setText("")
|
||||||
|
|
||||||
|
def clear_preview(self, message: str | None = None) -> None:
|
||||||
|
self.image_label.clear()
|
||||||
|
self.image_label.setPixmap(QPixmap())
|
||||||
|
text = message if message is not None else t("app.scaffold_hint")
|
||||||
|
self._placeholder = text
|
||||||
|
self.image_label.setText(text)
|
||||||
|
self.set_fps(None)
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
"""Force calibration sampling panel (FN / FX / FY)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
|
||||||
|
from PyQt5.QtCore import Qt, pyqtSignal
|
||||||
|
from PyQt5.QtWidgets import (
|
||||||
|
QAbstractItemView,
|
||||||
|
QButtonGroup,
|
||||||
|
QDoubleSpinBox,
|
||||||
|
QFormLayout,
|
||||||
|
QFrame,
|
||||||
|
QHBoxLayout,
|
||||||
|
QHeaderView,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QMessageBox,
|
||||||
|
QProgressBar,
|
||||||
|
QPushButton,
|
||||||
|
QRadioButton,
|
||||||
|
QTableWidget,
|
||||||
|
QTableWidgetItem,
|
||||||
|
QVBoxLayout,
|
||||||
|
)
|
||||||
|
|
||||||
|
from orisys_app_shared.force.calibration_io import (
|
||||||
|
CalSample,
|
||||||
|
CalSessionStore,
|
||||||
|
ChannelKey,
|
||||||
|
export_force_calibration_v2,
|
||||||
|
meta_from_sensor_json,
|
||||||
|
)
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
from orisys_app_shared.paths import calibration_export_dir, sensors_dir
|
||||||
|
|
||||||
|
|
||||||
|
class CalPanel(QFrame):
|
||||||
|
status_message = pyqtSignal(str)
|
||||||
|
|
||||||
|
def __init__(self, parent=None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName("contentPanel")
|
||||||
|
self._store = CalSessionStore()
|
||||||
|
self._channel: ChannelKey = "fn"
|
||||||
|
self._running = False
|
||||||
|
self._recording = False
|
||||||
|
self._record_deadline = 0.0
|
||||||
|
self._record_started = 0.0
|
||||||
|
self._record_samples: list[float] = []
|
||||||
|
self._live_fn = 0.0
|
||||||
|
self._live_fx = 0.0
|
||||||
|
self._live_fy = 0.0
|
||||||
|
self._sensor_type = "box"
|
||||||
|
self._config_ref: str | None = None
|
||||||
|
self._record_duration_s = 7.0
|
||||||
|
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
self.title = QLabel()
|
||||||
|
self.title.setObjectName("panelTitle")
|
||||||
|
root.addWidget(self.title)
|
||||||
|
|
||||||
|
channel_row = QHBoxLayout()
|
||||||
|
self.channel_group = QButtonGroup(self)
|
||||||
|
self.radio_fn = QRadioButton("FN")
|
||||||
|
self.radio_fx = QRadioButton("FX")
|
||||||
|
self.radio_fy = QRadioButton("FY")
|
||||||
|
self.radio_fn.setChecked(True)
|
||||||
|
for i, radio in enumerate((self.radio_fn, self.radio_fx, self.radio_fy)):
|
||||||
|
self.channel_group.addButton(radio, i)
|
||||||
|
channel_row.addWidget(radio)
|
||||||
|
channel_row.addStretch(1)
|
||||||
|
self.channel_group.buttonToggled.connect(self._on_channel_button)
|
||||||
|
root.addLayout(channel_row)
|
||||||
|
|
||||||
|
form = QFormLayout()
|
||||||
|
self._weight_label = QLabel()
|
||||||
|
self.weight_spin = QDoubleSpinBox()
|
||||||
|
self.weight_spin.setRange(0.0, 1e6)
|
||||||
|
self.weight_spin.setDecimals(4)
|
||||||
|
self.weight_spin.setSingleStep(0.1)
|
||||||
|
self.weight_spin.setValue(0.0)
|
||||||
|
self._duration_label = QLabel()
|
||||||
|
self.duration_spin = QDoubleSpinBox()
|
||||||
|
self.duration_spin.setRange(5.0, 10.0)
|
||||||
|
self.duration_spin.setDecimals(1)
|
||||||
|
self.duration_spin.setSingleStep(0.5)
|
||||||
|
self.duration_spin.setValue(7.0)
|
||||||
|
self.duration_spin.setSuffix(" s")
|
||||||
|
form.addRow(self._weight_label, self.weight_spin)
|
||||||
|
form.addRow(self._duration_label, self.duration_spin)
|
||||||
|
root.addLayout(form)
|
||||||
|
|
||||||
|
rec_row = QHBoxLayout()
|
||||||
|
self.btn_record = QPushButton()
|
||||||
|
self.btn_record.clicked.connect(self._on_record_clicked)
|
||||||
|
rec_row.addWidget(self.btn_record)
|
||||||
|
rec_row.addStretch(1)
|
||||||
|
root.addLayout(rec_row)
|
||||||
|
|
||||||
|
self.progress = QProgressBar()
|
||||||
|
self.progress.setRange(0, 1000)
|
||||||
|
self.progress.setValue(0)
|
||||||
|
self.progress.setTextVisible(True)
|
||||||
|
root.addWidget(self.progress)
|
||||||
|
|
||||||
|
self.live_label = QLabel()
|
||||||
|
self.live_label.setWordWrap(True)
|
||||||
|
root.addWidget(self.live_label)
|
||||||
|
|
||||||
|
self.table = QTableWidget(0, 5)
|
||||||
|
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||||
|
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
|
||||||
|
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||||
|
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
|
||||||
|
self.table.verticalHeader().setVisible(False)
|
||||||
|
root.addWidget(self.table, 1)
|
||||||
|
|
||||||
|
sn_row = QHBoxLayout()
|
||||||
|
self._sn_label = QLabel()
|
||||||
|
self.sn_edit = QLineEdit()
|
||||||
|
self.sn_edit.setPlaceholderText("")
|
||||||
|
sn_row.addWidget(self._sn_label)
|
||||||
|
sn_row.addWidget(self.sn_edit, 1)
|
||||||
|
root.addLayout(sn_row)
|
||||||
|
|
||||||
|
actions = QHBoxLayout()
|
||||||
|
self.btn_delete = QPushButton()
|
||||||
|
self.btn_clear_channel = QPushButton()
|
||||||
|
self.btn_clear_all = QPushButton()
|
||||||
|
self.btn_export = QPushButton()
|
||||||
|
self.btn_delete.clicked.connect(self._on_delete_row)
|
||||||
|
self.btn_clear_channel.clicked.connect(self._on_clear_channel)
|
||||||
|
self.btn_clear_all.clicked.connect(self._on_clear_all)
|
||||||
|
self.btn_export.clicked.connect(self._on_export)
|
||||||
|
actions.addWidget(self.btn_delete)
|
||||||
|
actions.addWidget(self.btn_clear_channel)
|
||||||
|
actions.addWidget(self.btn_clear_all)
|
||||||
|
actions.addStretch(1)
|
||||||
|
actions.addWidget(self.btn_export)
|
||||||
|
root.addLayout(actions)
|
||||||
|
|
||||||
|
self._refresh_enabled()
|
||||||
|
self._refresh_table()
|
||||||
|
self.retranslate()
|
||||||
|
|
||||||
|
def set_running(self, running: bool) -> None:
|
||||||
|
self._running = bool(running)
|
||||||
|
if not self._running and self._recording:
|
||||||
|
self._cancel_recording()
|
||||||
|
self._refresh_enabled()
|
||||||
|
|
||||||
|
def set_meta_context(self, *, sensor_type: str, config_ref: str | None) -> None:
|
||||||
|
self._sensor_type = str(sensor_type or "box")
|
||||||
|
self._config_ref = config_ref
|
||||||
|
|
||||||
|
def push_raw(self, fn: float, fx: float, fy: float, _timestamp: float | None = None) -> None:
|
||||||
|
self._live_fn = float(fn)
|
||||||
|
self._live_fx = float(fx)
|
||||||
|
self._live_fy = float(fy)
|
||||||
|
self._update_live_label()
|
||||||
|
if not self._recording:
|
||||||
|
return
|
||||||
|
now = time.perf_counter()
|
||||||
|
value = {"fn": self._live_fn, "fx": self._live_fx, "fy": self._live_fy}[self._channel]
|
||||||
|
self._record_samples.append(float(value))
|
||||||
|
span = max(1e-6, self._record_deadline - self._record_started)
|
||||||
|
frac = min(1.0, max(0.0, (now - self._record_started) / span))
|
||||||
|
self.progress.setValue(int(frac * 1000))
|
||||||
|
self.progress.setFormat(f"{now - self._record_started:.1f} / {span:.1f} s")
|
||||||
|
if now >= self._record_deadline:
|
||||||
|
self._finish_recording()
|
||||||
|
|
||||||
|
def retranslate(self) -> None:
|
||||||
|
self.title.setText(t("panels.cal"))
|
||||||
|
self._weight_label.setText(t("cal.weight"))
|
||||||
|
self._duration_label.setText(t("cal.duration"))
|
||||||
|
self.btn_record.setText(t("cal.record"))
|
||||||
|
self.btn_delete.setText(t("cal.delete_row"))
|
||||||
|
self.btn_clear_channel.setText(t("cal.clear_channel"))
|
||||||
|
self.btn_clear_all.setText(t("cal.clear_all"))
|
||||||
|
self.btn_export.setText(t("cal.export"))
|
||||||
|
self._sn_label.setText(t("cal.sn"))
|
||||||
|
self.sn_edit.setPlaceholderText(t("cal.sn_placeholder"))
|
||||||
|
headers = (
|
||||||
|
t("cal.col_weight"),
|
||||||
|
t("cal.col_raw"),
|
||||||
|
t("cal.col_n"),
|
||||||
|
t("cal.col_std"),
|
||||||
|
t("cal.col_actions"),
|
||||||
|
)
|
||||||
|
for i, text in enumerate(headers):
|
||||||
|
item = self.table.horizontalHeaderItem(i)
|
||||||
|
if item is None:
|
||||||
|
item = QTableWidgetItem(text)
|
||||||
|
self.table.setHorizontalHeaderItem(i, item)
|
||||||
|
else:
|
||||||
|
item.setText(text)
|
||||||
|
self._update_live_label()
|
||||||
|
self._refresh_enabled()
|
||||||
|
|
||||||
|
def _on_channel_button(self, button, checked: bool) -> None:
|
||||||
|
if not checked:
|
||||||
|
return
|
||||||
|
mapping = {self.radio_fn: "fn", self.radio_fx: "fx", self.radio_fy: "fy"}
|
||||||
|
key = mapping.get(button)
|
||||||
|
if key is None:
|
||||||
|
return
|
||||||
|
if self._recording:
|
||||||
|
self._cancel_recording()
|
||||||
|
self._channel = key # type: ignore[assignment]
|
||||||
|
self._refresh_table()
|
||||||
|
|
||||||
|
def _refresh_enabled(self) -> None:
|
||||||
|
can_record = self._running and not self._recording
|
||||||
|
self.btn_record.setEnabled(can_record)
|
||||||
|
self.weight_spin.setEnabled(not self._recording)
|
||||||
|
self.duration_spin.setEnabled(not self._recording)
|
||||||
|
for radio in (self.radio_fn, self.radio_fx, self.radio_fy):
|
||||||
|
radio.setEnabled(not self._recording)
|
||||||
|
self.btn_export.setEnabled(not self._recording)
|
||||||
|
|
||||||
|
def _update_live_label(self) -> None:
|
||||||
|
self.live_label.setText(
|
||||||
|
t(
|
||||||
|
"cal.live_raw",
|
||||||
|
fn=f"{self._live_fn:.3f}",
|
||||||
|
fx=f"{self._live_fx:.3f}",
|
||||||
|
fy=f"{self._live_fy:.3f}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_record_clicked(self) -> None:
|
||||||
|
if not self._running or self._recording:
|
||||||
|
return
|
||||||
|
self._recording = True
|
||||||
|
self._record_samples = []
|
||||||
|
self._record_duration_s = float(self.duration_spin.value())
|
||||||
|
self._record_started = time.perf_counter()
|
||||||
|
self._record_deadline = self._record_started + self._record_duration_s
|
||||||
|
self.progress.setValue(0)
|
||||||
|
self.progress.setFormat(f"0.0 / {self._record_duration_s:.1f} s")
|
||||||
|
self._refresh_enabled()
|
||||||
|
self.status_message.emit(t("cal.recording"))
|
||||||
|
|
||||||
|
def _cancel_recording(self) -> None:
|
||||||
|
self._recording = False
|
||||||
|
self._record_samples = []
|
||||||
|
self.progress.setValue(0)
|
||||||
|
self.progress.setFormat("")
|
||||||
|
self._refresh_enabled()
|
||||||
|
|
||||||
|
def _finish_recording(self) -> None:
|
||||||
|
samples = list(self._record_samples)
|
||||||
|
self._recording = False
|
||||||
|
self._record_samples = []
|
||||||
|
self.progress.setValue(1000)
|
||||||
|
self._refresh_enabled()
|
||||||
|
if len(samples) < 2:
|
||||||
|
self.status_message.emit(t("cal.record_failed_few"))
|
||||||
|
return
|
||||||
|
mean = sum(samples) / len(samples)
|
||||||
|
var = sum((x - mean) ** 2 for x in samples) / len(samples)
|
||||||
|
std = math.sqrt(var)
|
||||||
|
sample = CalSample(
|
||||||
|
weight_n=float(self.weight_spin.value()),
|
||||||
|
raw_mean=mean,
|
||||||
|
n=len(samples),
|
||||||
|
std=std,
|
||||||
|
)
|
||||||
|
self._store.upsert(self._channel, sample)
|
||||||
|
self._refresh_table()
|
||||||
|
self.status_message.emit(
|
||||||
|
t("cal.record_ok", weight=f"{sample.weight_n:g}", raw=f"{sample.raw_mean:.3f}")
|
||||||
|
)
|
||||||
|
|
||||||
|
def _refresh_table(self) -> None:
|
||||||
|
rows = self._store.channel(self._channel)
|
||||||
|
self.table.setRowCount(len(rows))
|
||||||
|
for r, sample in enumerate(rows):
|
||||||
|
values = (
|
||||||
|
f"{sample.weight_n:g}",
|
||||||
|
f"{sample.raw_mean:.4f}",
|
||||||
|
str(sample.n),
|
||||||
|
f"{sample.std:.4f}",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
for c, text in enumerate(values):
|
||||||
|
item = QTableWidgetItem(text)
|
||||||
|
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
|
||||||
|
self.table.setItem(r, c, item)
|
||||||
|
|
||||||
|
def _on_delete_row(self) -> None:
|
||||||
|
row = self.table.currentRow()
|
||||||
|
if row < 0:
|
||||||
|
return
|
||||||
|
self._store.remove_at(self._channel, row)
|
||||||
|
self._refresh_table()
|
||||||
|
|
||||||
|
def _on_clear_channel(self) -> None:
|
||||||
|
self._store.clear(self._channel)
|
||||||
|
self._refresh_table()
|
||||||
|
|
||||||
|
def _on_clear_all(self) -> None:
|
||||||
|
self._store.clear()
|
||||||
|
self._refresh_table()
|
||||||
|
|
||||||
|
def _on_export(self) -> None:
|
||||||
|
meta = meta_from_sensor_json(self._config_ref)
|
||||||
|
meta["sensor_type"] = self._sensor_type
|
||||||
|
meta["sn"] = self.sn_edit.text().strip()
|
||||||
|
meta["record_duration_s"] = float(self.duration_spin.value())
|
||||||
|
local_bundle = sensors_dir() / self._sensor_type
|
||||||
|
if not local_bundle.is_dir():
|
||||||
|
local_bundle = None
|
||||||
|
try:
|
||||||
|
primary, local_path, warnings = export_force_calibration_v2(
|
||||||
|
self._store,
|
||||||
|
export_dir=calibration_export_dir(),
|
||||||
|
sensor_type=self._sensor_type,
|
||||||
|
meta=meta,
|
||||||
|
local_bundle_dir=local_bundle,
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
QMessageBox.warning(self, t("panels.cal"), str(exc))
|
||||||
|
return
|
||||||
|
except Exception as exc:
|
||||||
|
QMessageBox.warning(self, t("panels.cal"), t("cal.export_failed", error=str(exc)))
|
||||||
|
return
|
||||||
|
|
||||||
|
msg = t("cal.export_ok", path=str(primary))
|
||||||
|
if local_path is not None:
|
||||||
|
msg += "\n" + t("cal.export_local", path=str(local_path))
|
||||||
|
if warnings:
|
||||||
|
msg += "\n" + t("cal.export_warnings", detail="; ".join(warnings))
|
||||||
|
self.status_message.emit(msg)
|
||||||
|
QMessageBox.information(self, t("panels.cal"), msg)
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""FN / FX / FY force charts with live value labels."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PyQt5.QtCore import Qt
|
||||||
|
from PyQt5.QtGui import QColor
|
||||||
|
from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout
|
||||||
|
|
||||||
|
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ, LOWPASS_CUTOFF_HZ
|
||||||
|
from orisys_app_shared.force import MetricHistory
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
from orisys_app_shared.theme import color
|
||||||
|
|
||||||
|
try:
|
||||||
|
import pyqtgraph as pg
|
||||||
|
except ImportError as exc: # pragma: no cover
|
||||||
|
raise ImportError("pyqtgraph is required for force charts") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _nice_ticks(lo: float, hi: float, count: int = 5) -> list[tuple[float, str]]:
|
||||||
|
if hi <= lo:
|
||||||
|
return [(lo, f"{lo:g}")]
|
||||||
|
step = (hi - lo) / max(1, count - 1)
|
||||||
|
return [(lo + i * step, f"{(lo + i * step):g}") for i in range(count)]
|
||||||
|
|
||||||
|
|
||||||
|
class ForcePlotsPanel(QFrame):
|
||||||
|
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName("contentPanel")
|
||||||
|
self._theme = theme
|
||||||
|
self._fn_max = 20.0
|
||||||
|
self._shear_max = 20.0
|
||||||
|
self._fixed_newton = True
|
||||||
|
self._unit = "raw"
|
||||||
|
self.history = MetricHistory(
|
||||||
|
channels=3,
|
||||||
|
window_sec=4.0,
|
||||||
|
sample_rate_hz=FORCE_SAMPLE_RATE_HZ,
|
||||||
|
lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ,
|
||||||
|
y_axis_min_span=0.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
self.title = QLabel()
|
||||||
|
self.title.setObjectName("panelTitle")
|
||||||
|
root.addWidget(self.title)
|
||||||
|
|
||||||
|
self.plot_widget = pg.GraphicsLayoutWidget()
|
||||||
|
self.plots = []
|
||||||
|
self.curves = []
|
||||||
|
names = ("FN", "FX", "FY")
|
||||||
|
for i, name in enumerate(names):
|
||||||
|
plot = self.plot_widget.addPlot(row=i, col=0)
|
||||||
|
plot.showGrid(x=True, y=True, alpha=0.25)
|
||||||
|
plot.setLabel("left", name)
|
||||||
|
plot.setMouseEnabled(x=False, y=False)
|
||||||
|
if i > 0:
|
||||||
|
plot.setXLink(self.plots[0])
|
||||||
|
curve = plot.plot(pen=pg.mkPen("#8ab4f8", width=2))
|
||||||
|
self.plots.append(plot)
|
||||||
|
self.curves.append(curve)
|
||||||
|
root.addWidget(self.plot_widget, 1)
|
||||||
|
|
||||||
|
values_row = QHBoxLayout()
|
||||||
|
self.value_labels = []
|
||||||
|
for _ in names:
|
||||||
|
lab = QLabel("—")
|
||||||
|
lab.setAlignment(Qt.AlignCenter)
|
||||||
|
values_row.addWidget(lab)
|
||||||
|
self.value_labels.append(lab)
|
||||||
|
root.addLayout(values_row)
|
||||||
|
self.set_theme(theme)
|
||||||
|
self._apply_axis_mode()
|
||||||
|
self.retranslate()
|
||||||
|
|
||||||
|
def set_theme(self, theme: str) -> None:
|
||||||
|
self._theme = theme
|
||||||
|
bg = color(theme, "plot_bg", "#121316")
|
||||||
|
text = color(theme, "text_primary", "#e8eaed")
|
||||||
|
self.plot_widget.setBackground(bg)
|
||||||
|
axis_pen = pg.mkPen(text)
|
||||||
|
for plot in self.plots:
|
||||||
|
try:
|
||||||
|
plot.getViewBox().setBackgroundColor(QColor(bg))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
for axis_name in ("left", "bottom"):
|
||||||
|
axis = plot.getAxis(axis_name)
|
||||||
|
axis.setPen(axis_pen)
|
||||||
|
axis.setTextPen(axis_pen)
|
||||||
|
plot.showGrid(x=True, y=True, alpha=0.25)
|
||||||
|
label = plot.getAxis("left").labelText or ""
|
||||||
|
if label:
|
||||||
|
plot.setLabel("left", label, color=text)
|
||||||
|
pens = (
|
||||||
|
color(theme, "plot_fn", "#8ab4f8"),
|
||||||
|
color(theme, "plot_fx", "#34a853"),
|
||||||
|
color(theme, "plot_fy", "#fbbc04"),
|
||||||
|
)
|
||||||
|
for curve, pen in zip(self.curves, pens):
|
||||||
|
curve.setPen(pg.mkPen(pen, width=2))
|
||||||
|
|
||||||
|
def set_window_sec(self, sec: float) -> None:
|
||||||
|
self.history.set_window_sec(sec)
|
||||||
|
self._clear_curves()
|
||||||
|
self._apply_axis_mode()
|
||||||
|
|
||||||
|
def set_axis_limits(self, fn_max: float, shear_max: float) -> None:
|
||||||
|
self._fn_max = max(0.1, float(fn_max))
|
||||||
|
self._shear_max = max(0.1, float(shear_max))
|
||||||
|
self._apply_axis_mode()
|
||||||
|
|
||||||
|
def set_fixed_newton(self, enabled: bool) -> None:
|
||||||
|
self._fixed_newton = bool(enabled)
|
||||||
|
self._apply_axis_mode()
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self.history.reset()
|
||||||
|
self._clear_curves()
|
||||||
|
for lab in self.value_labels:
|
||||||
|
lab.setText("—")
|
||||||
|
self._apply_axis_mode()
|
||||||
|
|
||||||
|
def push(
|
||||||
|
self,
|
||||||
|
timestamp: float,
|
||||||
|
fn: float,
|
||||||
|
fx: float,
|
||||||
|
fy: float,
|
||||||
|
*,
|
||||||
|
unit: str = "raw",
|
||||||
|
) -> None:
|
||||||
|
self._unit = unit
|
||||||
|
use_fixed = self._uses_fixed_y()
|
||||||
|
plot_fn = max(0.0, float(fn)) if use_fixed else float(fn)
|
||||||
|
self.history.push(timestamp, plot_fn, float(fx), float(fy))
|
||||||
|
self._update_value_labels(fn, fx, fy)
|
||||||
|
state = self.history.state()
|
||||||
|
if state is None:
|
||||||
|
return
|
||||||
|
values = state.values.copy()
|
||||||
|
if use_fixed:
|
||||||
|
values[:, 0] = np.maximum(values[:, 0], 0.0)
|
||||||
|
for i, curve in enumerate(self.curves):
|
||||||
|
curve.setData(state.times, values[:, i])
|
||||||
|
self.plots[i].setXRange(*state.x_range, padding=0.0)
|
||||||
|
if use_fixed:
|
||||||
|
ymin, ymax = self._fixed_y_range(i)
|
||||||
|
self.plots[i].setYRange(ymin, ymax, padding=0.0)
|
||||||
|
self.plots[i].getAxis("left").setTicks([_nice_ticks(ymin, ymax, count=5)])
|
||||||
|
else:
|
||||||
|
self.plots[i].getAxis("left").setTicks(None)
|
||||||
|
self.plots[i].setYRange(*state.y_ranges[i], padding=0.0)
|
||||||
|
|
||||||
|
def _uses_fixed_y(self) -> bool:
|
||||||
|
return bool(self._fixed_newton) and self._unit == "N"
|
||||||
|
|
||||||
|
def _fixed_y_range(self, channel_index: int) -> tuple[float, float]:
|
||||||
|
if channel_index == 0:
|
||||||
|
return 0.0, self._fn_max
|
||||||
|
return -self._shear_max, self._shear_max
|
||||||
|
|
||||||
|
def _apply_axis_mode(self) -> None:
|
||||||
|
# Preview fixed newton axes when newton mode is selected, even before samples.
|
||||||
|
preview_fixed = bool(self._fixed_newton)
|
||||||
|
use_fixed = self._uses_fixed_y() or (preview_fixed and self.history.sample_count == 0)
|
||||||
|
for i, plot in enumerate(self.plots):
|
||||||
|
if use_fixed:
|
||||||
|
ymin, ymax = self._fixed_y_range(i)
|
||||||
|
plot.setYRange(ymin, ymax, padding=0.0)
|
||||||
|
plot.getAxis("left").setTicks([_nice_ticks(ymin, ymax, count=5)])
|
||||||
|
else:
|
||||||
|
plot.getAxis("left").setTicks(None)
|
||||||
|
for plot in self.plots:
|
||||||
|
plot.setXRange(0.0, self.history.window_sec, padding=0.0)
|
||||||
|
|
||||||
|
def _clear_curves(self) -> None:
|
||||||
|
for curve in self.curves:
|
||||||
|
curve.setData([], [])
|
||||||
|
|
||||||
|
def _update_value_labels(self, fn: float, fx: float, fy: float) -> None:
|
||||||
|
unit = self._unit
|
||||||
|
fmt = "{name}: {value:.4f} {unit}" if unit == "N" else "{name}: {value:.2f} {unit}"
|
||||||
|
for lab, name, value in zip(self.value_labels, ("FN", "FX", "FY"), (fn, fx, fy)):
|
||||||
|
lab.setText(fmt.format(name=name, value=value, unit=unit))
|
||||||
|
|
||||||
|
def retranslate(self) -> None:
|
||||||
|
self.title.setText(t("panels.plots"))
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Placeholder panel for finger 3D mesh view."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PyQt5.QtCore import Qt
|
||||||
|
from PyQt5.QtWidgets import QFrame, QLabel, QVBoxLayout
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
|
|
||||||
|
class Mesh3dPanel(QFrame):
|
||||||
|
def __init__(self, parent=None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setObjectName("contentPanel")
|
||||||
|
layout = QVBoxLayout(self)
|
||||||
|
self.title = QLabel()
|
||||||
|
self.title.setObjectName("panelTitle")
|
||||||
|
self.body = QLabel()
|
||||||
|
self.body.setAlignment(Qt.AlignCenter)
|
||||||
|
self.body.setWordWrap(True)
|
||||||
|
layout.addWidget(self.title)
|
||||||
|
layout.addWidget(self.body, 1)
|
||||||
|
self.retranslate()
|
||||||
|
|
||||||
|
def retranslate(self) -> None:
|
||||||
|
self.title.setText(t("panels.mesh3d"))
|
||||||
|
self.body.setText(t("panels.mesh3d_hint"))
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Modal dialog for force-plot window and Y-axis limits."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PyQt5.QtWidgets import (
|
||||||
|
QDialog,
|
||||||
|
QDialogButtonBox,
|
||||||
|
QDoubleSpinBox,
|
||||||
|
QFormLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
)
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
|
DEFAULT_PLOT_WINDOW_SEC = 4.0
|
||||||
|
DEFAULT_PLOT_FN_MAX = 20.0
|
||||||
|
DEFAULT_PLOT_SHEAR_MAX = 20.0
|
||||||
|
|
||||||
|
|
||||||
|
class PlotSettingsDialog(QDialog):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent=None,
|
||||||
|
*,
|
||||||
|
window_sec: float = DEFAULT_PLOT_WINDOW_SEC,
|
||||||
|
fn_max: float = DEFAULT_PLOT_FN_MAX,
|
||||||
|
shear_max: float = DEFAULT_PLOT_SHEAR_MAX,
|
||||||
|
units: str = "newton",
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setModal(True)
|
||||||
|
self.resize(360, 200)
|
||||||
|
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
form = QFormLayout()
|
||||||
|
|
||||||
|
self.window_label = QLabel()
|
||||||
|
self.window_spin = QDoubleSpinBox()
|
||||||
|
self.window_spin.setRange(1.0, 30.0)
|
||||||
|
self.window_spin.setSingleStep(1.0)
|
||||||
|
self.window_spin.setDecimals(0)
|
||||||
|
self.window_spin.setSuffix(" s")
|
||||||
|
self.window_spin.setValue(float(window_sec))
|
||||||
|
|
||||||
|
self.fn_max_label = QLabel()
|
||||||
|
self.fn_max_spin = QDoubleSpinBox()
|
||||||
|
self.fn_max_spin.setRange(1.0, 100.0)
|
||||||
|
self.fn_max_spin.setSingleStep(1.0)
|
||||||
|
self.fn_max_spin.setDecimals(0)
|
||||||
|
self.fn_max_spin.setSuffix(" N")
|
||||||
|
self.fn_max_spin.setValue(float(fn_max))
|
||||||
|
|
||||||
|
self.shear_max_label = QLabel()
|
||||||
|
self.shear_max_spin = QDoubleSpinBox()
|
||||||
|
self.shear_max_spin.setRange(1.0, 100.0)
|
||||||
|
self.shear_max_spin.setSingleStep(1.0)
|
||||||
|
self.shear_max_spin.setDecimals(0)
|
||||||
|
self.shear_max_spin.setSuffix(" N")
|
||||||
|
self.shear_max_spin.setValue(float(shear_max))
|
||||||
|
|
||||||
|
form.addRow(self.window_label, self.window_spin)
|
||||||
|
form.addRow(self.fn_max_label, self.fn_max_spin)
|
||||||
|
form.addRow(self.shear_max_label, self.shear_max_spin)
|
||||||
|
root.addLayout(form)
|
||||||
|
|
||||||
|
newton = str(units or "newton").lower().startswith("n")
|
||||||
|
self.fn_max_spin.setEnabled(newton)
|
||||||
|
self.shear_max_spin.setEnabled(newton)
|
||||||
|
self.fn_max_label.setEnabled(newton)
|
||||||
|
self.shear_max_label.setEnabled(newton)
|
||||||
|
|
||||||
|
buttons_row = QHBoxLayout()
|
||||||
|
self.btn_reset = QPushButton()
|
||||||
|
self.btn_reset.clicked.connect(self._reset_defaults)
|
||||||
|
buttons_row.addWidget(self.btn_reset)
|
||||||
|
buttons_row.addStretch(1)
|
||||||
|
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||||
|
self.button_box.accepted.connect(self.accept)
|
||||||
|
self.button_box.rejected.connect(self.reject)
|
||||||
|
buttons_row.addWidget(self.button_box)
|
||||||
|
root.addLayout(buttons_row)
|
||||||
|
|
||||||
|
self.retranslate()
|
||||||
|
|
||||||
|
def values(self) -> tuple[float, float, float]:
|
||||||
|
return (
|
||||||
|
float(self.window_spin.value()),
|
||||||
|
float(self.fn_max_spin.value()),
|
||||||
|
float(self.shear_max_spin.value()),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _reset_defaults(self) -> None:
|
||||||
|
self.window_spin.setValue(DEFAULT_PLOT_WINDOW_SEC)
|
||||||
|
self.fn_max_spin.setValue(DEFAULT_PLOT_FN_MAX)
|
||||||
|
self.shear_max_spin.setValue(DEFAULT_PLOT_SHEAR_MAX)
|
||||||
|
|
||||||
|
def retranslate(self) -> None:
|
||||||
|
self.setWindowTitle(t("dialog.plot_settings_title"))
|
||||||
|
self.window_label.setText(t("dialog.plot_window"))
|
||||||
|
self.window_spin.setToolTip(t("dialog.plot_window_tip"))
|
||||||
|
self.fn_max_label.setText(t("dialog.plot_fn_max"))
|
||||||
|
self.fn_max_spin.setToolTip(t("dialog.plot_fn_max_tip"))
|
||||||
|
self.shear_max_label.setText(t("dialog.plot_shear_max"))
|
||||||
|
self.shear_max_spin.setToolTip(t("dialog.plot_shear_max_tip"))
|
||||||
|
self.btn_reset.setText(t("dialog.reset_defaults"))
|
||||||
|
ok_btn = self.button_box.button(QDialogButtonBox.Ok)
|
||||||
|
cancel_btn = self.button_box.button(QDialogButtonBox.Cancel)
|
||||||
|
if ok_btn is not None:
|
||||||
|
ok_btn.setText(t("dialog.ok"))
|
||||||
|
if cancel_btn is not None:
|
||||||
|
cancel_btn.setText(t("dialog.cancel"))
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Sensor type + config picker with thumbnail placeholder."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PyQt5.QtCore import Qt, pyqtSignal
|
||||||
|
from PyQt5.QtWidgets import QComboBox, QFrame, QHBoxLayout, QLabel, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
from orisys_app_shared.sensors import list_configs_for_type, list_sensor_types
|
||||||
|
|
||||||
|
|
||||||
|
class SensorPickerWidget(QWidget):
|
||||||
|
sensor_type_changed = pyqtSignal(str)
|
||||||
|
config_changed = pyqtSignal(str)
|
||||||
|
|
||||||
|
def __init__(self, parent=None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
layout = QHBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(8)
|
||||||
|
|
||||||
|
self.thumb = QFrame()
|
||||||
|
self.thumb.setObjectName("contentPanel")
|
||||||
|
self.thumb.setFixedSize(72, 72)
|
||||||
|
thumb_layout = QVBoxLayout(self.thumb)
|
||||||
|
self.thumb_label = QLabel()
|
||||||
|
self.thumb_label.setAlignment(Qt.AlignCenter)
|
||||||
|
self.thumb_label.setWordWrap(True)
|
||||||
|
thumb_layout.addWidget(self.thumb_label)
|
||||||
|
|
||||||
|
form = QVBoxLayout()
|
||||||
|
form.setSpacing(4)
|
||||||
|
type_row = QHBoxLayout()
|
||||||
|
self.type_label = QLabel()
|
||||||
|
self.type_combo = QComboBox()
|
||||||
|
for name in list_sensor_types():
|
||||||
|
self.type_combo.addItem(name, name)
|
||||||
|
self.type_combo.currentIndexChanged.connect(self._on_type_index_changed)
|
||||||
|
type_row.addWidget(self.type_label)
|
||||||
|
type_row.addWidget(self.type_combo, 1)
|
||||||
|
|
||||||
|
config_row = QHBoxLayout()
|
||||||
|
self.config_label = QLabel()
|
||||||
|
self.config_combo = QComboBox()
|
||||||
|
self.config_combo.currentIndexChanged.connect(self._on_config_index_changed)
|
||||||
|
config_row.addWidget(self.config_label)
|
||||||
|
config_row.addWidget(self.config_combo, 1)
|
||||||
|
|
||||||
|
form.addLayout(type_row)
|
||||||
|
form.addLayout(config_row)
|
||||||
|
layout.addWidget(self.thumb)
|
||||||
|
layout.addLayout(form, 1)
|
||||||
|
self._reload_configs()
|
||||||
|
self._refresh_thumb()
|
||||||
|
self.retranslate()
|
||||||
|
|
||||||
|
def sensor_type(self) -> str:
|
||||||
|
data = self.type_combo.currentData()
|
||||||
|
return str(data or self.type_combo.currentText() or "box")
|
||||||
|
|
||||||
|
def config_name(self) -> str:
|
||||||
|
data = self.config_combo.currentData()
|
||||||
|
if data is None:
|
||||||
|
return str(self.config_combo.currentText() or "")
|
||||||
|
return str(data)
|
||||||
|
|
||||||
|
def set_sensor_type(self, sensor_type: str) -> None:
|
||||||
|
wanted = str(sensor_type or "box")
|
||||||
|
idx = self.type_combo.findData(wanted)
|
||||||
|
if idx < 0:
|
||||||
|
idx = self.type_combo.findText(wanted)
|
||||||
|
if idx < 0:
|
||||||
|
idx = 0
|
||||||
|
self.type_combo.blockSignals(True)
|
||||||
|
self.type_combo.setCurrentIndex(idx)
|
||||||
|
self.type_combo.blockSignals(False)
|
||||||
|
self._reload_configs()
|
||||||
|
self._refresh_thumb()
|
||||||
|
|
||||||
|
def set_config_name(self, config_name: str) -> None:
|
||||||
|
wanted = str(config_name or "")
|
||||||
|
idx = self.config_combo.findData(wanted)
|
||||||
|
if idx < 0 and wanted:
|
||||||
|
idx = self.config_combo.findText(wanted)
|
||||||
|
if idx < 0:
|
||||||
|
idx = 0
|
||||||
|
self.config_combo.blockSignals(True)
|
||||||
|
self.config_combo.setCurrentIndex(max(0, idx))
|
||||||
|
self.config_combo.blockSignals(False)
|
||||||
|
|
||||||
|
def _reload_configs(self) -> None:
|
||||||
|
self.config_combo.blockSignals(True)
|
||||||
|
self.config_combo.clear()
|
||||||
|
for display, key in list_configs_for_type(self.sensor_type()):
|
||||||
|
self.config_combo.addItem(display, key)
|
||||||
|
if self.config_combo.count() == 0:
|
||||||
|
self.config_combo.addItem(self.sensor_type(), self.sensor_type())
|
||||||
|
self.config_combo.blockSignals(False)
|
||||||
|
|
||||||
|
def _refresh_thumb(self) -> None:
|
||||||
|
name = self.sensor_type().upper()
|
||||||
|
self.thumb_label.setText(name)
|
||||||
|
|
||||||
|
def _on_type_index_changed(self, _index: int) -> None:
|
||||||
|
self._reload_configs()
|
||||||
|
self._refresh_thumb()
|
||||||
|
self.sensor_type_changed.emit(self.sensor_type())
|
||||||
|
self.config_changed.emit(self.config_name())
|
||||||
|
|
||||||
|
def _on_config_index_changed(self, _index: int) -> None:
|
||||||
|
self.config_changed.emit(self.config_name())
|
||||||
|
|
||||||
|
def retranslate(self) -> None:
|
||||||
|
self.type_label.setText(t("sensor.type"))
|
||||||
|
self.config_label.setText(t("sensor.config"))
|
||||||
|
if not self.thumb_label.text().strip():
|
||||||
|
self.thumb_label.setText(t("sensor.thumb_placeholder"))
|
||||||
|
else:
|
||||||
|
self._refresh_thumb()
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
"""Top toolbar: source, camera/video, run/reset, units."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PyQt5.QtCore import pyqtSignal
|
||||||
|
from PyQt5.QtWidgets import (
|
||||||
|
QComboBox,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
|
QPushButton,
|
||||||
|
QSpinBox,
|
||||||
|
QStackedWidget,
|
||||||
|
QWidget,
|
||||||
|
)
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
|
|
||||||
|
class ToolbarWidget(QWidget):
|
||||||
|
search_clicked = pyqtSignal()
|
||||||
|
preview_toggled = pyqtSignal(bool)
|
||||||
|
camera_id_changed = pyqtSignal(int)
|
||||||
|
source_mode_changed = pyqtSignal(str)
|
||||||
|
browse_video_clicked = pyqtSignal()
|
||||||
|
run_toggled = pyqtSignal(bool)
|
||||||
|
reset_clicked = pyqtSignal()
|
||||||
|
units_changed = pyqtSignal(str)
|
||||||
|
|
||||||
|
def __init__(self, parent=None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
layout = QHBoxLayout(self)
|
||||||
|
layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
layout.setSpacing(8)
|
||||||
|
|
||||||
|
self.source_label = QLabel()
|
||||||
|
self.source_combo = QComboBox()
|
||||||
|
self.source_combo.currentIndexChanged.connect(self._on_source_index_changed)
|
||||||
|
|
||||||
|
self.source_stack = QStackedWidget()
|
||||||
|
cam_page = QWidget()
|
||||||
|
cam_layout = QHBoxLayout(cam_page)
|
||||||
|
cam_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
cam_layout.setSpacing(8)
|
||||||
|
self.cam_id_label = QLabel()
|
||||||
|
self.cam_id_spin = QSpinBox()
|
||||||
|
self.cam_id_spin.setRange(0, 20)
|
||||||
|
self.cam_id_spin.setValue(0)
|
||||||
|
self.cam_id_spin.setMinimumWidth(72)
|
||||||
|
self.cam_id_spin.valueChanged.connect(self.camera_id_changed.emit)
|
||||||
|
cam_layout.addWidget(self.cam_id_label)
|
||||||
|
cam_layout.addWidget(self.cam_id_spin)
|
||||||
|
|
||||||
|
video_page = QWidget()
|
||||||
|
video_layout = QHBoxLayout(video_page)
|
||||||
|
video_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
video_layout.setSpacing(8)
|
||||||
|
self.video_edit = QLineEdit()
|
||||||
|
self.video_edit.setMinimumWidth(180)
|
||||||
|
self.video_edit.setReadOnly(True)
|
||||||
|
self.btn_browse_video = QPushButton()
|
||||||
|
self.btn_browse_video.clicked.connect(self.browse_video_clicked.emit)
|
||||||
|
video_layout.addWidget(self.video_edit, 1)
|
||||||
|
video_layout.addWidget(self.btn_browse_video)
|
||||||
|
|
||||||
|
self.source_stack.addWidget(cam_page)
|
||||||
|
self.source_stack.addWidget(video_page)
|
||||||
|
|
||||||
|
self.btn_search = QPushButton()
|
||||||
|
self.btn_search.clicked.connect(self.search_clicked.emit)
|
||||||
|
|
||||||
|
self.btn_preview = QPushButton()
|
||||||
|
self.btn_preview.setCheckable(True)
|
||||||
|
self.btn_preview.toggled.connect(self._on_preview_toggled)
|
||||||
|
|
||||||
|
self.btn_run = QPushButton()
|
||||||
|
self.btn_run.setCheckable(True)
|
||||||
|
self.btn_run.toggled.connect(self._on_run_toggled)
|
||||||
|
|
||||||
|
self.btn_reset = QPushButton()
|
||||||
|
self.btn_reset.setEnabled(False)
|
||||||
|
self.btn_reset.clicked.connect(self.reset_clicked.emit)
|
||||||
|
|
||||||
|
self.units_label = QLabel()
|
||||||
|
self.units_combo = QComboBox()
|
||||||
|
self.units_combo.currentIndexChanged.connect(self._on_units_index_changed)
|
||||||
|
|
||||||
|
layout.addWidget(self.source_label)
|
||||||
|
layout.addWidget(self.source_combo)
|
||||||
|
layout.addWidget(self.source_stack, 1)
|
||||||
|
layout.addWidget(self.btn_search)
|
||||||
|
layout.addWidget(self.btn_preview)
|
||||||
|
layout.addWidget(self.btn_run)
|
||||||
|
layout.addWidget(self.btn_reset)
|
||||||
|
layout.addWidget(self.units_label)
|
||||||
|
layout.addWidget(self.units_combo)
|
||||||
|
layout.addStretch(1)
|
||||||
|
|
||||||
|
self._populate_source_combo()
|
||||||
|
self._populate_units_combo()
|
||||||
|
self.retranslate()
|
||||||
|
self._apply_source_mode_ui()
|
||||||
|
|
||||||
|
def source_mode(self) -> str:
|
||||||
|
data = self.source_combo.currentData()
|
||||||
|
return str(data or "camera")
|
||||||
|
|
||||||
|
def set_source_mode(self, mode: str) -> None:
|
||||||
|
wanted = "video" if str(mode).lower().startswith("v") else "camera"
|
||||||
|
idx = self.source_combo.findData(wanted)
|
||||||
|
if idx < 0:
|
||||||
|
idx = 0
|
||||||
|
self.source_combo.blockSignals(True)
|
||||||
|
self.source_combo.setCurrentIndex(idx)
|
||||||
|
self.source_combo.blockSignals(False)
|
||||||
|
self._apply_source_mode_ui()
|
||||||
|
|
||||||
|
def camera_id(self) -> int:
|
||||||
|
return int(self.cam_id_spin.value())
|
||||||
|
|
||||||
|
def set_camera_id(self, value: int) -> None:
|
||||||
|
self.cam_id_spin.blockSignals(True)
|
||||||
|
self.cam_id_spin.setValue(int(value))
|
||||||
|
self.cam_id_spin.blockSignals(False)
|
||||||
|
|
||||||
|
def video_path(self) -> str:
|
||||||
|
return self.video_edit.text().strip()
|
||||||
|
|
||||||
|
def set_video_path(self, path: str) -> None:
|
||||||
|
text = str(path or "").strip()
|
||||||
|
self.video_edit.setText(text)
|
||||||
|
if text:
|
||||||
|
self.video_edit.setToolTip(text)
|
||||||
|
else:
|
||||||
|
self.video_edit.setToolTip("")
|
||||||
|
|
||||||
|
def units(self) -> str:
|
||||||
|
data = self.units_combo.currentData()
|
||||||
|
return str(data or "newton")
|
||||||
|
|
||||||
|
def set_units(self, units: str) -> None:
|
||||||
|
wanted = "raw" if str(units).lower().startswith("r") else "newton"
|
||||||
|
idx = self.units_combo.findData(wanted)
|
||||||
|
if idx < 0:
|
||||||
|
idx = 0
|
||||||
|
self.units_combo.blockSignals(True)
|
||||||
|
self.units_combo.setCurrentIndex(idx)
|
||||||
|
self.units_combo.blockSignals(False)
|
||||||
|
|
||||||
|
def set_preview_checked(self, checked: bool) -> None:
|
||||||
|
self.btn_preview.blockSignals(True)
|
||||||
|
self.btn_preview.setChecked(bool(checked))
|
||||||
|
self.btn_preview.blockSignals(False)
|
||||||
|
self._refresh_preview_label()
|
||||||
|
|
||||||
|
def set_run_checked(self, checked: bool) -> None:
|
||||||
|
self.btn_run.blockSignals(True)
|
||||||
|
self.btn_run.setChecked(bool(checked))
|
||||||
|
self.btn_run.blockSignals(False)
|
||||||
|
self._refresh_run_label()
|
||||||
|
if not checked:
|
||||||
|
self.btn_reset.setEnabled(False)
|
||||||
|
|
||||||
|
def set_reset_enabled(self, enabled: bool) -> None:
|
||||||
|
self.btn_reset.setEnabled(bool(enabled))
|
||||||
|
|
||||||
|
def set_camera_controls_enabled(self, enabled: bool) -> None:
|
||||||
|
self.btn_search.setEnabled(enabled and self.source_mode() == "camera")
|
||||||
|
self.cam_id_spin.setEnabled(enabled and self.source_mode() == "camera")
|
||||||
|
|
||||||
|
def set_source_controls_enabled(self, enabled: bool) -> None:
|
||||||
|
self.source_combo.setEnabled(enabled)
|
||||||
|
self.btn_browse_video.setEnabled(enabled and self.source_mode() == "video")
|
||||||
|
self.set_camera_controls_enabled(enabled)
|
||||||
|
self.btn_preview.setEnabled(enabled and self.source_mode() == "camera")
|
||||||
|
|
||||||
|
def set_run_enabled(self, enabled: bool) -> None:
|
||||||
|
self.btn_run.setEnabled(bool(enabled))
|
||||||
|
|
||||||
|
def active_source(self) -> str | int | None:
|
||||||
|
if self.source_mode() == "video":
|
||||||
|
path = self.video_path()
|
||||||
|
return path if path else None
|
||||||
|
return self.camera_id()
|
||||||
|
|
||||||
|
def _populate_source_combo(self) -> None:
|
||||||
|
self.source_combo.blockSignals(True)
|
||||||
|
self.source_combo.clear()
|
||||||
|
self.source_combo.addItem("", "camera")
|
||||||
|
self.source_combo.addItem("", "video")
|
||||||
|
self.source_combo.blockSignals(False)
|
||||||
|
|
||||||
|
def _populate_units_combo(self) -> None:
|
||||||
|
current = self.units_combo.currentData()
|
||||||
|
self.units_combo.blockSignals(True)
|
||||||
|
self.units_combo.clear()
|
||||||
|
self.units_combo.addItem("", "newton")
|
||||||
|
self.units_combo.addItem("", "raw")
|
||||||
|
if current:
|
||||||
|
idx = self.units_combo.findData(current)
|
||||||
|
self.units_combo.setCurrentIndex(max(0, idx))
|
||||||
|
self.units_combo.blockSignals(False)
|
||||||
|
|
||||||
|
def _apply_source_mode_ui(self) -> None:
|
||||||
|
video = self.source_mode() == "video"
|
||||||
|
self.source_stack.setCurrentIndex(1 if video else 0)
|
||||||
|
self.btn_search.setEnabled(not video)
|
||||||
|
self.btn_preview.setEnabled(not video)
|
||||||
|
if video and self.btn_preview.isChecked():
|
||||||
|
self.set_preview_checked(False)
|
||||||
|
|
||||||
|
def _on_source_index_changed(self, _index: int) -> None:
|
||||||
|
self._apply_source_mode_ui()
|
||||||
|
self.source_mode_changed.emit(self.source_mode())
|
||||||
|
|
||||||
|
def _on_preview_toggled(self, checked: bool) -> None:
|
||||||
|
self._refresh_preview_label()
|
||||||
|
self.preview_toggled.emit(bool(checked))
|
||||||
|
|
||||||
|
def _on_run_toggled(self, checked: bool) -> None:
|
||||||
|
self._refresh_run_label()
|
||||||
|
if not checked:
|
||||||
|
self.btn_reset.setEnabled(False)
|
||||||
|
self.run_toggled.emit(bool(checked))
|
||||||
|
|
||||||
|
def _on_units_index_changed(self, _index: int) -> None:
|
||||||
|
self.units_changed.emit(self.units())
|
||||||
|
|
||||||
|
def _refresh_preview_label(self) -> None:
|
||||||
|
if self.btn_preview.isChecked():
|
||||||
|
self.btn_preview.setText(t("toolbar.preview_stop"))
|
||||||
|
else:
|
||||||
|
self.btn_preview.setText(t("toolbar.preview"))
|
||||||
|
|
||||||
|
def _refresh_run_label(self) -> None:
|
||||||
|
if self.btn_run.isChecked():
|
||||||
|
self.btn_run.setText("■")
|
||||||
|
self.btn_run.setToolTip(t("toolbar.stop"))
|
||||||
|
else:
|
||||||
|
self.btn_run.setText("▶")
|
||||||
|
self.btn_run.setToolTip(t("toolbar.run"))
|
||||||
|
|
||||||
|
def retranslate(self) -> None:
|
||||||
|
self.source_label.setText(t("toolbar.source"))
|
||||||
|
self.source_combo.setItemText(0, t("toolbar.source_camera"))
|
||||||
|
self.source_combo.setItemText(1, t("toolbar.source_video"))
|
||||||
|
self.cam_id_label.setText(t("toolbar.cam_id"))
|
||||||
|
self.btn_browse_video.setText(t("toolbar.browse_video"))
|
||||||
|
path = self.video_path()
|
||||||
|
self.video_edit.setPlaceholderText(
|
||||||
|
Path(path).name if path else t("toolbar.video_placeholder")
|
||||||
|
)
|
||||||
|
self.btn_search.setText(t("toolbar.search_cameras"))
|
||||||
|
self._refresh_preview_label()
|
||||||
|
self._refresh_run_label()
|
||||||
|
self.btn_reset.setText(t("toolbar.reset"))
|
||||||
|
self.units_label.setText(t("toolbar.units"))
|
||||||
|
self.units_combo.setItemText(0, t("toolbar.units_newton"))
|
||||||
|
self.units_combo.setItemText(1, t("toolbar.units_raw"))
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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%
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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 = []
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "orisys-app-shared"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Shared paths, prefs, theme, i18n, and logging for orisys-app"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
authors = [{ name = "Orisys-Tech" }]
|
||||||
|
dependencies = [
|
||||||
|
"PyQt5>=5.15",
|
||||||
|
"opencv-python-headless>=4.10.0.84,<5",
|
||||||
|
"numpy>=1.24.4",
|
||||||
|
"scipy>=1.10.1",
|
||||||
|
"pyqtgraph>=0.13",
|
||||||
|
"orisys>=0.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
package-dir = { "" = "src" }
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
orisys_app_shared = ["theme/*.json"]
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 7.8 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
"""Shared package for orisys-app desktop products."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
API_VERSION = "0.1"
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Acquisition helpers."""
|
||||||
|
|
||||||
|
from orisys_app_shared.acquisition.constants import (
|
||||||
|
FORCE_SAMPLE_RATE_HZ,
|
||||||
|
LOWPASS_CUTOFF_HZ,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"FORCE_SAMPLE_RATE_HZ",
|
||||||
|
"LOWPASS_CUTOFF_HZ",
|
||||||
|
"FingerAcquisitionWorker",
|
||||||
|
"SdkAcquisitionWorker",
|
||||||
|
"SdkPacket",
|
||||||
|
"depth_map_to_bgr",
|
||||||
|
"draw_arrow_display",
|
||||||
|
"hex_to_bgr",
|
||||||
|
"load_fixed_mode",
|
||||||
|
"orisys_importable",
|
||||||
|
"resolve_finger_export_dir",
|
||||||
|
]
|
||||||
|
|
||||||
|
_LAZY_ATTRS = {
|
||||||
|
"FingerAcquisitionWorker": ("orisys_app_shared.acquisition.finger_worker", "FingerAcquisitionWorker"),
|
||||||
|
"resolve_finger_export_dir": ("orisys_app_shared.acquisition.finger_worker", "resolve_finger_export_dir"),
|
||||||
|
"SdkAcquisitionWorker": ("orisys_app_shared.acquisition.sdk_worker", "SdkAcquisitionWorker"),
|
||||||
|
"SdkPacket": ("orisys_app_shared.acquisition.sdk_worker", "SdkPacket"),
|
||||||
|
"load_fixed_mode": ("orisys_app_shared.acquisition.sdk_worker", "load_fixed_mode"),
|
||||||
|
"orisys_importable": ("orisys_app_shared.acquisition.sdk_worker", "orisys_importable"),
|
||||||
|
"depth_map_to_bgr": ("orisys_app_shared.acquisition.arrows", "depth_map_to_bgr"),
|
||||||
|
"draw_arrow_display": ("orisys_app_shared.acquisition.arrows", "draw_arrow_display"),
|
||||||
|
"hex_to_bgr": ("orisys_app_shared.acquisition.arrows", "hex_to_bgr"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str):
|
||||||
|
target = _LAZY_ATTRS.get(name)
|
||||||
|
if target is None:
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
module_name, attr = target
|
||||||
|
import importlib
|
||||||
|
|
||||||
|
module = importlib.import_module(module_name)
|
||||||
|
value = getattr(module, attr)
|
||||||
|
globals()[name] = value
|
||||||
|
return value
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Arrow overlay helpers for processed tactile frames."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Optional, Sequence
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
ARROW_BG_BGR = (0x16, 0x13, 0x12)
|
||||||
|
_ARROW_DRAW_KW = dict(
|
||||||
|
threshold=2,
|
||||||
|
grid_spacing=20,
|
||||||
|
arrow_scale=0.5,
|
||||||
|
below_threshold_color=(160, 160, 160),
|
||||||
|
)
|
||||||
|
_FINGER_ARROW_DRAW_KW = dict(
|
||||||
|
threshold=0.4,
|
||||||
|
grid_spacing=10,
|
||||||
|
arrow_scale=1.0,
|
||||||
|
below_threshold_color=(160, 160, 160),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def hex_to_bgr(hex_color: str, fallback: tuple[int, int, int] = ARROW_BG_BGR) -> tuple[int, int, int]:
|
||||||
|
text = str(hex_color or "").strip().lstrip("#")
|
||||||
|
if len(text) != 6:
|
||||||
|
return fallback
|
||||||
|
try:
|
||||||
|
r = int(text[0:2], 16)
|
||||||
|
g = int(text[2:4], 16)
|
||||||
|
b = int(text[4:6], 16)
|
||||||
|
except ValueError:
|
||||||
|
return fallback
|
||||||
|
return (b, g, r)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_bgr(img: np.ndarray) -> np.ndarray:
|
||||||
|
if img.ndim == 2:
|
||||||
|
return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
|
||||||
|
if img.ndim == 3 and img.shape[2] == 1:
|
||||||
|
return cv2.cvtColor(img[:, :, 0], cv2.COLOR_GRAY2BGR)
|
||||||
|
if img.ndim == 3 and img.shape[2] == 4:
|
||||||
|
return cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def draw_arrow_display(
|
||||||
|
processed_frame: np.ndarray,
|
||||||
|
flow: Optional[np.ndarray],
|
||||||
|
*,
|
||||||
|
background: str = "pure_color",
|
||||||
|
canvas_bgr: Sequence[int] | None = None,
|
||||||
|
draw_style: str = "box",
|
||||||
|
valid_mask: Optional[np.ndarray] = None,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Draw flow arrows on a solid canvas or over the processed frame."""
|
||||||
|
base = _ensure_bgr(processed_frame)
|
||||||
|
if flow is None:
|
||||||
|
return base
|
||||||
|
draw_kw = _FINGER_ARROW_DRAW_KW if str(draw_style).lower().startswith("f") else _ARROW_DRAW_KW
|
||||||
|
try:
|
||||||
|
import orisys
|
||||||
|
|
||||||
|
if background == "pure_color":
|
||||||
|
canvas = np.empty_like(base)
|
||||||
|
color = ARROW_BG_BGR if canvas_bgr is None else tuple(int(v) for v in canvas_bgr)
|
||||||
|
canvas[:, :] = color
|
||||||
|
return orisys.util.draw_arrows(
|
||||||
|
canvas, flow, valid_mask=valid_mask, **draw_kw
|
||||||
|
)
|
||||||
|
return orisys.util.draw_arrows(base, flow, valid_mask=valid_mask, **draw_kw)
|
||||||
|
except Exception:
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def depth_map_to_bgr(depth: Optional[np.ndarray]) -> Optional[np.ndarray]:
|
||||||
|
"""Convert a float depth/divergence map to a JET colormap BGR image."""
|
||||||
|
if depth is None:
|
||||||
|
return None
|
||||||
|
arr = np.asarray(depth, dtype=np.float64)
|
||||||
|
if arr.size == 0 or arr.ndim < 2:
|
||||||
|
return None
|
||||||
|
finite = np.isfinite(arr)
|
||||||
|
if not np.any(finite):
|
||||||
|
return None
|
||||||
|
lo = float(np.nanmin(arr[finite]))
|
||||||
|
hi = float(np.nanmax(arr[finite]))
|
||||||
|
if hi > lo:
|
||||||
|
normalized = ((arr - lo) / (hi - lo) * 255.0).astype(np.uint8)
|
||||||
|
else:
|
||||||
|
normalized = np.zeros(arr.shape, dtype=np.uint8)
|
||||||
|
return cv2.applyColorMap(normalized, cv2.COLORMAP_JET)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""Shared acquisition constants (no heavy imports)."""
|
||||||
|
|
||||||
|
LOWPASS_CUTOFF_HZ = 10.0
|
||||||
|
FORCE_SAMPLE_RATE_HZ = 30.0
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
"""Orisys finger acquisition worker (FingerRuntime → SdkPacket)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from orisys_app_shared.acquisition.constants import (
|
||||||
|
FORCE_SAMPLE_RATE_HZ,
|
||||||
|
LOWPASS_CUTOFF_HZ,
|
||||||
|
)
|
||||||
|
from orisys_app_shared.acquisition.sdk_worker import (
|
||||||
|
CAMERA_DISCONNECT_TIMEOUT_S,
|
||||||
|
VIDEO_EOF_TIMEOUT_S,
|
||||||
|
FixedCameraMode,
|
||||||
|
SdkPacket,
|
||||||
|
_ensure_bgr,
|
||||||
|
_is_file_source,
|
||||||
|
load_fixed_mode,
|
||||||
|
orisys_importable,
|
||||||
|
read_forces_from_sensor,
|
||||||
|
)
|
||||||
|
|
||||||
|
_EXPORT_REQUIRED = ("mesh.npz", "camera_intrinsics.npz", "world_to_cam.npy")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_finger_export_dir(config_ref: str | Path) -> Path | None:
|
||||||
|
"""Return local ``assets/30fps-0703`` when present and complete.
|
||||||
|
|
||||||
|
Missing folder → ``None`` (SDK default export). Incomplete folder → error
|
||||||
|
so Run does not silently fall back to bundled assets.
|
||||||
|
"""
|
||||||
|
path = Path(str(config_ref))
|
||||||
|
if path.is_file() and path.name.lower() == "sensor.json":
|
||||||
|
candidate = path.parent / "assets" / "30fps-0703"
|
||||||
|
elif path.is_dir():
|
||||||
|
candidate = path / "assets" / "30fps-0703"
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
if not candidate.is_dir():
|
||||||
|
return None
|
||||||
|
missing = [name for name in _EXPORT_REQUIRED if not (candidate / name).is_file()]
|
||||||
|
if missing:
|
||||||
|
raise FileNotFoundError(
|
||||||
|
f"Incomplete finger export at {candidate}: missing {', '.join(missing)}"
|
||||||
|
)
|
||||||
|
return candidate.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
class FingerAcquisitionWorker:
|
||||||
|
"""Background loop: FingerRuntime grab/unroll + deformation + force readout."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
on_error: Optional[Callable[[str], None]] = None,
|
||||||
|
on_status: Optional[Callable[[str], None]] = None,
|
||||||
|
):
|
||||||
|
self._on_error = on_error
|
||||||
|
self._on_status = on_status
|
||||||
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._latest: Optional[SdkPacket] = None
|
||||||
|
self._session_id = 0
|
||||||
|
self._runtime = None
|
||||||
|
self._mode: Optional[FixedCameraMode] = None
|
||||||
|
self._video_source: Optional[str | int] = None
|
||||||
|
self._export_dir: Optional[Path] = None
|
||||||
|
self._end_reason: Optional[str] = None
|
||||||
|
self._phase: str = "idle"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
return self._thread is not None and self._thread.is_alive()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def phase(self) -> str:
|
||||||
|
with self._lock:
|
||||||
|
return self._phase
|
||||||
|
|
||||||
|
def latest(self) -> Optional[SdkPacket]:
|
||||||
|
with self._lock:
|
||||||
|
return self._latest
|
||||||
|
|
||||||
|
def consume_end_reason(self) -> Optional[str]:
|
||||||
|
with self._lock:
|
||||||
|
reason = self._end_reason
|
||||||
|
self._end_reason = None
|
||||||
|
return reason
|
||||||
|
|
||||||
|
def start(
|
||||||
|
self,
|
||||||
|
video_source: str | int,
|
||||||
|
*,
|
||||||
|
config_ref: str | Path,
|
||||||
|
) -> FixedCameraMode:
|
||||||
|
self.stop()
|
||||||
|
ok, detail = orisys_importable()
|
||||||
|
if not ok:
|
||||||
|
raise RuntimeError(f"orisys SDK is not importable: {detail}")
|
||||||
|
mode = load_fixed_mode(config_ref)
|
||||||
|
self._mode = mode
|
||||||
|
self._video_source = video_source
|
||||||
|
self._export_dir = resolve_finger_export_dir(config_ref)
|
||||||
|
with self._lock:
|
||||||
|
self._latest = None
|
||||||
|
self._end_reason = None
|
||||||
|
self._phase = "connecting"
|
||||||
|
self._session_id += 1
|
||||||
|
session = self._session_id
|
||||||
|
self._stop.clear()
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._run,
|
||||||
|
name=f"FingerAcquisition-{session}",
|
||||||
|
daemon=True,
|
||||||
|
args=(session,),
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
return mode
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._set_end_reason("stopped")
|
||||||
|
self._stop.set()
|
||||||
|
thread = self._thread
|
||||||
|
if thread is not None and thread.is_alive():
|
||||||
|
thread.join(timeout=8.0)
|
||||||
|
self._thread = None
|
||||||
|
self._close_runtime()
|
||||||
|
with self._lock:
|
||||||
|
self._latest = None
|
||||||
|
self._phase = "idle"
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
runtime = self._runtime
|
||||||
|
if runtime is None:
|
||||||
|
raise RuntimeError("Finger runtime is not running")
|
||||||
|
runtime.tactile.reset()
|
||||||
|
|
||||||
|
def _set_end_reason(self, reason: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if self._end_reason is None:
|
||||||
|
self._end_reason = reason
|
||||||
|
|
||||||
|
def _set_phase(self, phase: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._phase = phase
|
||||||
|
|
||||||
|
def _close_runtime(self) -> None:
|
||||||
|
runtime = self._runtime
|
||||||
|
self._runtime = None
|
||||||
|
if runtime is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
runtime.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _status(self, message: str) -> None:
|
||||||
|
if self._on_status is not None:
|
||||||
|
self._on_status(message)
|
||||||
|
|
||||||
|
def _run(self, session: int) -> None:
|
||||||
|
from orisys.finger import FingerRuntime
|
||||||
|
|
||||||
|
mode = self._mode
|
||||||
|
source = self._video_source
|
||||||
|
if mode is None or source is None:
|
||||||
|
return
|
||||||
|
is_file = _is_file_source(source)
|
||||||
|
try:
|
||||||
|
self._status(f"Opening FingerRuntime ({mode.label()})…")
|
||||||
|
open_kw: dict = {
|
||||||
|
"config": mode.config_ref,
|
||||||
|
"video": source,
|
||||||
|
"verbose": False,
|
||||||
|
}
|
||||||
|
if self._export_dir is not None:
|
||||||
|
open_kw["export_dir"] = str(self._export_dir)
|
||||||
|
self._runtime = FingerRuntime.open(**open_kw)
|
||||||
|
if self._stop.is_set() or session != self._session_id:
|
||||||
|
self._close_runtime()
|
||||||
|
return
|
||||||
|
self._runtime.warmup(color=False)
|
||||||
|
if self._stop.is_set() or session != self._session_id:
|
||||||
|
self._close_runtime()
|
||||||
|
return
|
||||||
|
raw_sensor = getattr(self._runtime.tactile, "raw_sensor", None)
|
||||||
|
if raw_sensor is not None and hasattr(raw_sensor, "configure"):
|
||||||
|
try:
|
||||||
|
raw_sensor.configure(
|
||||||
|
lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ,
|
||||||
|
lowpass_sample_rate_hz=FORCE_SAMPLE_RATE_HZ,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._status(f"Streaming finger {mode.label()}")
|
||||||
|
except Exception as exc:
|
||||||
|
self._set_end_reason("error")
|
||||||
|
if self._on_error is not None and session == self._session_id:
|
||||||
|
self._on_error(str(exc))
|
||||||
|
self._set_phase("idle")
|
||||||
|
return
|
||||||
|
|
||||||
|
frame_times: list[float] = []
|
||||||
|
saw_frame = False
|
||||||
|
last_frame_mono = 0.0
|
||||||
|
none_streak_start: Optional[float] = None
|
||||||
|
runtime = self._runtime
|
||||||
|
|
||||||
|
while not self._stop.is_set() and session == self._session_id and runtime is not None:
|
||||||
|
try:
|
||||||
|
now = time.perf_counter()
|
||||||
|
if saw_frame:
|
||||||
|
if is_file:
|
||||||
|
if none_streak_start is not None and (
|
||||||
|
now - none_streak_start
|
||||||
|
) >= VIDEO_EOF_TIMEOUT_S:
|
||||||
|
self._set_end_reason("ended")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
if (now - last_frame_mono) >= CAMERA_DISCONNECT_TIMEOUT_S:
|
||||||
|
self._set_end_reason("disconnected")
|
||||||
|
break
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
ok, source_frame = runtime.read_source()
|
||||||
|
if not ok or source_frame is None:
|
||||||
|
if saw_frame and is_file and none_streak_start is None:
|
||||||
|
none_streak_start = time.perf_counter()
|
||||||
|
time.sleep(0.005)
|
||||||
|
continue
|
||||||
|
|
||||||
|
unrolled = runtime.process_frame(source_frame, color=False)
|
||||||
|
if unrolled is None:
|
||||||
|
if saw_frame and is_file and none_streak_start is None:
|
||||||
|
none_streak_start = time.perf_counter()
|
||||||
|
time.sleep(0.005)
|
||||||
|
continue
|
||||||
|
|
||||||
|
none_streak_start = None
|
||||||
|
tactile = runtime.tactile
|
||||||
|
try:
|
||||||
|
tactile.compute_deformation(decompose=True, check_motion=False)
|
||||||
|
tactile.compute_contact()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
t1 = time.perf_counter()
|
||||||
|
frame_times.append(t1)
|
||||||
|
cutoff = t1 - 1.0
|
||||||
|
frame_times = [t for t in frame_times if t >= cutoff]
|
||||||
|
fps = float(len(frame_times)) if frame_times else 0.0
|
||||||
|
forces = read_forces_from_sensor(
|
||||||
|
tactile,
|
||||||
|
config_ref=mode.config_ref,
|
||||||
|
)
|
||||||
|
packet = SdkPacket(
|
||||||
|
raw_frame=_ensure_bgr(source_frame),
|
||||||
|
processed_frame=_ensure_bgr(unrolled),
|
||||||
|
timestamp=time.time(),
|
||||||
|
fps=fps,
|
||||||
|
process_ms=(t1 - t0) * 1000.0,
|
||||||
|
normal_force=forces["normal_force"],
|
||||||
|
shear_x=forces["shear_x"],
|
||||||
|
shear_y=forces["shear_y"],
|
||||||
|
normal_force_raw=forces["normal_force_raw"],
|
||||||
|
shear_x_raw=forces["shear_x_raw"],
|
||||||
|
shear_y_raw=forces["shear_y_raw"],
|
||||||
|
force_unit=forces["force_unit"],
|
||||||
|
force_calibrated=forces["force_calibrated"],
|
||||||
|
session_id=session,
|
||||||
|
flow=forces["flow"],
|
||||||
|
depth=forces.get("depth"),
|
||||||
|
valid_mask=runtime.valid_mask,
|
||||||
|
)
|
||||||
|
with self._lock:
|
||||||
|
self._latest = packet
|
||||||
|
self._phase = "running"
|
||||||
|
saw_frame = True
|
||||||
|
last_frame_mono = t1
|
||||||
|
except Exception as exc:
|
||||||
|
self._set_end_reason("error")
|
||||||
|
if self._on_error is not None and session == self._session_id:
|
||||||
|
self._on_error(str(exc))
|
||||||
|
break
|
||||||
|
|
||||||
|
self._close_runtime()
|
||||||
|
self._set_phase("idle")
|
||||||
|
if self._stop.is_set():
|
||||||
|
self._set_end_reason("stopped")
|
||||||
|
self._status("Finger acquisition stopped")
|
||||||
@@ -0,0 +1,430 @@
|
|||||||
|
"""Orisys SDK acquisition worker for box/mini-style sensors."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from orisys_app_shared.acquisition.constants import (
|
||||||
|
FORCE_SAMPLE_RATE_HZ,
|
||||||
|
LOWPASS_CUTOFF_HZ,
|
||||||
|
)
|
||||||
|
|
||||||
|
VIDEO_EOF_TIMEOUT_S = 0.5
|
||||||
|
CAMERA_DISCONNECT_TIMEOUT_S = 1.5
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FixedCameraMode:
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
fps: float
|
||||||
|
config_ref: str
|
||||||
|
|
||||||
|
def label(self) -> str:
|
||||||
|
return f"{self.width}x{self.height} @{self.fps:g}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SdkPacket:
|
||||||
|
raw_frame: np.ndarray
|
||||||
|
processed_frame: np.ndarray
|
||||||
|
timestamp: float
|
||||||
|
fps: float
|
||||||
|
process_ms: float
|
||||||
|
normal_force: float = 0.0
|
||||||
|
shear_x: float = 0.0
|
||||||
|
shear_y: float = 0.0
|
||||||
|
normal_force_raw: float = 0.0
|
||||||
|
shear_x_raw: float = 0.0
|
||||||
|
shear_y_raw: float = 0.0
|
||||||
|
force_unit: str = "raw"
|
||||||
|
force_calibrated: bool = False
|
||||||
|
session_id: int = 0
|
||||||
|
flow: Optional[np.ndarray] = None
|
||||||
|
depth: Optional[np.ndarray] = None
|
||||||
|
valid_mask: Optional[np.ndarray] = None
|
||||||
|
|
||||||
|
|
||||||
|
def orisys_importable() -> tuple[bool, str]:
|
||||||
|
try:
|
||||||
|
import orisys # noqa: F401
|
||||||
|
|
||||||
|
return True, getattr(__import__("orisys"), "__version__", "unknown")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return False, str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def load_fixed_mode(config_ref: str | Path) -> FixedCameraMode:
|
||||||
|
ref = str(config_ref).strip()
|
||||||
|
path = Path(ref)
|
||||||
|
if path.is_file():
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
resolved = str(path.resolve())
|
||||||
|
else:
|
||||||
|
import orisys
|
||||||
|
|
||||||
|
data = orisys.configs.load_config(ref)
|
||||||
|
resolved = ref
|
||||||
|
camera = data.get("camera") or {}
|
||||||
|
raw = camera.get("resolution_raw") or [1920, 1080]
|
||||||
|
fps = float(camera.get("fps") or 30.0)
|
||||||
|
return FixedCameraMode(
|
||||||
|
width=int(raw[0]),
|
||||||
|
height=int(raw[1]),
|
||||||
|
fps=fps,
|
||||||
|
config_ref=resolved,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_bgr(img: np.ndarray) -> np.ndarray:
|
||||||
|
if img.ndim == 2:
|
||||||
|
return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
|
||||||
|
if img.ndim == 3 and img.shape[2] == 1:
|
||||||
|
return cv2.cvtColor(img[:, :, 0], cv2.COLOR_GRAY2BGR)
|
||||||
|
if img.ndim == 3 and img.shape[2] == 4:
|
||||||
|
return cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
|
||||||
|
return img
|
||||||
|
|
||||||
|
|
||||||
|
def _sensor_force_calibrated(sensor, *, config_ref: str | Path | None = None) -> bool:
|
||||||
|
if getattr(sensor, "_force_cal_sensor_name", None) is not None:
|
||||||
|
return True
|
||||||
|
if config_ref is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
import orisys
|
||||||
|
|
||||||
|
path = Path(str(config_ref))
|
||||||
|
name = path.parent.name if path.name == "sensor.json" else path.stem
|
||||||
|
return orisys.configs.load_force_calibration(name) is not None
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def read_forces_from_sensor(sensor, *, config_ref: str | Path | None = None):
|
||||||
|
info = sensor.info
|
||||||
|
fn = fx = fy = 0.0
|
||||||
|
fn_raw = fx_raw = fy_raw = 0.0
|
||||||
|
flow = None
|
||||||
|
depth = None
|
||||||
|
read_raw = all(
|
||||||
|
hasattr(info, name)
|
||||||
|
for name in ("FNORMAL_RAW", "FSHEARX_RAW", "FSHEARY_RAW")
|
||||||
|
)
|
||||||
|
has_flow = hasattr(info, "VRAW")
|
||||||
|
has_depth = hasattr(info, "DEPTH")
|
||||||
|
try:
|
||||||
|
if read_raw and has_flow and has_depth:
|
||||||
|
fn, fx, fy, fn_raw, fx_raw, fy_raw, flow, depth = sensor.read_info(
|
||||||
|
info.FNORMAL,
|
||||||
|
info.FSHEARX,
|
||||||
|
info.FSHEARY,
|
||||||
|
info.FNORMAL_RAW,
|
||||||
|
info.FSHEARX_RAW,
|
||||||
|
info.FSHEARY_RAW,
|
||||||
|
info.VRAW,
|
||||||
|
info.DEPTH,
|
||||||
|
)
|
||||||
|
elif read_raw and has_flow:
|
||||||
|
fn, fx, fy, fn_raw, fx_raw, fy_raw, flow = sensor.read_info(
|
||||||
|
info.FNORMAL,
|
||||||
|
info.FSHEARX,
|
||||||
|
info.FSHEARY,
|
||||||
|
info.FNORMAL_RAW,
|
||||||
|
info.FSHEARX_RAW,
|
||||||
|
info.FSHEARY_RAW,
|
||||||
|
info.VRAW,
|
||||||
|
)
|
||||||
|
elif read_raw and has_depth:
|
||||||
|
fn, fx, fy, fn_raw, fx_raw, fy_raw, depth = sensor.read_info(
|
||||||
|
info.FNORMAL,
|
||||||
|
info.FSHEARX,
|
||||||
|
info.FSHEARY,
|
||||||
|
info.FNORMAL_RAW,
|
||||||
|
info.FSHEARX_RAW,
|
||||||
|
info.FSHEARY_RAW,
|
||||||
|
info.DEPTH,
|
||||||
|
)
|
||||||
|
elif read_raw:
|
||||||
|
fn, fx, fy, fn_raw, fx_raw, fy_raw = sensor.read_info(
|
||||||
|
info.FNORMAL,
|
||||||
|
info.FSHEARX,
|
||||||
|
info.FSHEARY,
|
||||||
|
info.FNORMAL_RAW,
|
||||||
|
info.FSHEARX_RAW,
|
||||||
|
info.FSHEARY_RAW,
|
||||||
|
)
|
||||||
|
elif has_flow and has_depth:
|
||||||
|
fn, fx, fy, flow, depth = sensor.read_info(
|
||||||
|
info.FNORMAL, info.FSHEARX, info.FSHEARY, info.VRAW, info.DEPTH
|
||||||
|
)
|
||||||
|
fn_raw, fx_raw, fy_raw = fn, fx, fy
|
||||||
|
elif has_flow:
|
||||||
|
fn, fx, fy, flow = sensor.read_info(
|
||||||
|
info.FNORMAL, info.FSHEARX, info.FSHEARY, info.VRAW
|
||||||
|
)
|
||||||
|
fn_raw, fx_raw, fy_raw = fn, fx, fy
|
||||||
|
elif has_depth:
|
||||||
|
fn, fx, fy, depth = sensor.read_info(
|
||||||
|
info.FNORMAL, info.FSHEARX, info.FSHEARY, info.DEPTH
|
||||||
|
)
|
||||||
|
fn_raw, fx_raw, fy_raw = fn, fx, fy
|
||||||
|
else:
|
||||||
|
fn, fx, fy = sensor.read_info(info.FNORMAL, info.FSHEARX, info.FSHEARY)
|
||||||
|
fn_raw, fx_raw, fy_raw = fn, fx, fy
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if depth is not None:
|
||||||
|
arr = np.asarray(depth)
|
||||||
|
if arr.size == 0:
|
||||||
|
depth = None
|
||||||
|
calibrated = _sensor_force_calibrated(sensor, config_ref=config_ref)
|
||||||
|
return {
|
||||||
|
"normal_force": float(fn),
|
||||||
|
"shear_x": float(fx),
|
||||||
|
"shear_y": float(fy),
|
||||||
|
"normal_force_raw": float(fn_raw),
|
||||||
|
"shear_x_raw": float(fx_raw),
|
||||||
|
"shear_y_raw": float(fy_raw),
|
||||||
|
"force_calibrated": calibrated,
|
||||||
|
"force_unit": "N" if calibrated else "raw",
|
||||||
|
"flow": flow,
|
||||||
|
"depth": depth,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_file_source(video_source: str | int) -> bool:
|
||||||
|
return isinstance(video_source, str)
|
||||||
|
|
||||||
|
|
||||||
|
class SdkAcquisitionWorker:
|
||||||
|
"""Background loop: Sensor.get_img + deformation + force readout."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
on_error: Optional[Callable[[str], None]] = None,
|
||||||
|
on_status: Optional[Callable[[str], None]] = None,
|
||||||
|
):
|
||||||
|
self._on_error = on_error
|
||||||
|
self._on_status = on_status
|
||||||
|
self._thread: Optional[threading.Thread] = None
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._latest: Optional[SdkPacket] = None
|
||||||
|
self._session_id = 0
|
||||||
|
self._sensor = None
|
||||||
|
self._mode: Optional[FixedCameraMode] = None
|
||||||
|
self._video_source: Optional[str | int] = None
|
||||||
|
self._end_reason: Optional[str] = None
|
||||||
|
self._phase: str = "idle"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
return self._thread is not None and self._thread.is_alive()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def phase(self) -> str:
|
||||||
|
with self._lock:
|
||||||
|
return self._phase
|
||||||
|
|
||||||
|
def latest(self) -> Optional[SdkPacket]:
|
||||||
|
with self._lock:
|
||||||
|
return self._latest
|
||||||
|
|
||||||
|
def consume_end_reason(self) -> Optional[str]:
|
||||||
|
with self._lock:
|
||||||
|
reason = self._end_reason
|
||||||
|
self._end_reason = None
|
||||||
|
return reason
|
||||||
|
|
||||||
|
def start(
|
||||||
|
self,
|
||||||
|
video_source: str | int,
|
||||||
|
*,
|
||||||
|
config_ref: str | Path,
|
||||||
|
) -> FixedCameraMode:
|
||||||
|
self.stop()
|
||||||
|
ok, detail = orisys_importable()
|
||||||
|
if not ok:
|
||||||
|
raise RuntimeError(f"orisys SDK is not importable: {detail}")
|
||||||
|
mode = load_fixed_mode(config_ref)
|
||||||
|
self._mode = mode
|
||||||
|
self._video_source = video_source
|
||||||
|
with self._lock:
|
||||||
|
self._latest = None
|
||||||
|
self._end_reason = None
|
||||||
|
self._phase = "connecting"
|
||||||
|
self._session_id += 1
|
||||||
|
session = self._session_id
|
||||||
|
self._stop.clear()
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._run,
|
||||||
|
name=f"SdkAcquisition-{session}",
|
||||||
|
daemon=True,
|
||||||
|
args=(session,),
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
return mode
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._set_end_reason("stopped")
|
||||||
|
self._stop.set()
|
||||||
|
thread = self._thread
|
||||||
|
if thread is not None and thread.is_alive():
|
||||||
|
thread.join(timeout=5.0)
|
||||||
|
self._thread = None
|
||||||
|
self._close_sensor()
|
||||||
|
with self._lock:
|
||||||
|
self._latest = None
|
||||||
|
self._phase = "idle"
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
sensor = self._sensor
|
||||||
|
if sensor is None:
|
||||||
|
raise RuntimeError("Sensor is not running")
|
||||||
|
sensor.reset()
|
||||||
|
|
||||||
|
def _set_end_reason(self, reason: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if self._end_reason is None:
|
||||||
|
self._end_reason = reason
|
||||||
|
|
||||||
|
def _set_phase(self, phase: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._phase = phase
|
||||||
|
|
||||||
|
def _close_sensor(self) -> None:
|
||||||
|
sensor = self._sensor
|
||||||
|
self._sensor = None
|
||||||
|
if sensor is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
sensor.disconnect()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _status(self, message: str) -> None:
|
||||||
|
if self._on_status is not None:
|
||||||
|
self._on_status(message)
|
||||||
|
|
||||||
|
def _run(self, session: int) -> None:
|
||||||
|
import orisys
|
||||||
|
|
||||||
|
mode = self._mode
|
||||||
|
source = self._video_source
|
||||||
|
if mode is None or source is None:
|
||||||
|
return
|
||||||
|
is_file = _is_file_source(source)
|
||||||
|
try:
|
||||||
|
self._status(f"Opening Orisys Sensor ({mode.label()})…")
|
||||||
|
self._sensor = orisys.Sensor(
|
||||||
|
source,
|
||||||
|
config_name=mode.config_ref,
|
||||||
|
verbose=False,
|
||||||
|
backend="auto",
|
||||||
|
)
|
||||||
|
if self._stop.is_set() or session != self._session_id:
|
||||||
|
self._close_sensor()
|
||||||
|
return
|
||||||
|
self._sensor.configure(
|
||||||
|
lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ,
|
||||||
|
lowpass_sample_rate_hz=FORCE_SAMPLE_RATE_HZ,
|
||||||
|
)
|
||||||
|
self._status(f"Streaming {mode.label()}")
|
||||||
|
except Exception as exc:
|
||||||
|
self._set_end_reason("error")
|
||||||
|
if self._on_error is not None and session == self._session_id:
|
||||||
|
self._on_error(str(exc))
|
||||||
|
self._set_phase("idle")
|
||||||
|
return
|
||||||
|
|
||||||
|
frame_times: list[float] = []
|
||||||
|
saw_frame = False
|
||||||
|
last_frame_mono = 0.0
|
||||||
|
none_streak_start: Optional[float] = None
|
||||||
|
|
||||||
|
while not self._stop.is_set() and session == self._session_id:
|
||||||
|
try:
|
||||||
|
now = time.perf_counter()
|
||||||
|
if saw_frame:
|
||||||
|
if is_file:
|
||||||
|
if none_streak_start is not None and (
|
||||||
|
now - none_streak_start
|
||||||
|
) >= VIDEO_EOF_TIMEOUT_S:
|
||||||
|
self._set_end_reason("ended")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
if (now - last_frame_mono) >= CAMERA_DISCONNECT_TIMEOUT_S:
|
||||||
|
self._set_end_reason("disconnected")
|
||||||
|
break
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
processed = self._sensor.get_img()
|
||||||
|
if processed is None:
|
||||||
|
if saw_frame and is_file and none_streak_start is None:
|
||||||
|
none_streak_start = time.perf_counter()
|
||||||
|
time.sleep(0.005)
|
||||||
|
continue
|
||||||
|
raw = getattr(self._sensor, "frame", None)
|
||||||
|
if raw is None:
|
||||||
|
if saw_frame and is_file and none_streak_start is None:
|
||||||
|
none_streak_start = time.perf_counter()
|
||||||
|
time.sleep(0.005)
|
||||||
|
continue
|
||||||
|
|
||||||
|
none_streak_start = None
|
||||||
|
try:
|
||||||
|
self._sensor.compute_deformation(decompose=True, check_motion=False)
|
||||||
|
self._sensor.compute_contact()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
t1 = time.perf_counter()
|
||||||
|
frame_times.append(t1)
|
||||||
|
cutoff = t1 - 1.0
|
||||||
|
frame_times = [t for t in frame_times if t >= cutoff]
|
||||||
|
fps = float(len(frame_times)) if frame_times else 0.0
|
||||||
|
forces = read_forces_from_sensor(self._sensor, config_ref=mode.config_ref)
|
||||||
|
packet = SdkPacket(
|
||||||
|
raw_frame=_ensure_bgr(raw),
|
||||||
|
processed_frame=_ensure_bgr(processed),
|
||||||
|
timestamp=time.time(),
|
||||||
|
fps=fps,
|
||||||
|
process_ms=(t1 - t0) * 1000.0,
|
||||||
|
normal_force=forces["normal_force"],
|
||||||
|
shear_x=forces["shear_x"],
|
||||||
|
shear_y=forces["shear_y"],
|
||||||
|
normal_force_raw=forces["normal_force_raw"],
|
||||||
|
shear_x_raw=forces["shear_x_raw"],
|
||||||
|
shear_y_raw=forces["shear_y_raw"],
|
||||||
|
force_unit=forces["force_unit"],
|
||||||
|
force_calibrated=forces["force_calibrated"],
|
||||||
|
session_id=session,
|
||||||
|
flow=forces["flow"],
|
||||||
|
depth=forces.get("depth"),
|
||||||
|
)
|
||||||
|
with self._lock:
|
||||||
|
self._latest = packet
|
||||||
|
self._phase = "running"
|
||||||
|
saw_frame = True
|
||||||
|
last_frame_mono = t1
|
||||||
|
except Exception as exc:
|
||||||
|
self._set_end_reason("error")
|
||||||
|
if self._on_error is not None and session == self._session_id:
|
||||||
|
self._on_error(str(exc))
|
||||||
|
break
|
||||||
|
|
||||||
|
self._close_sensor()
|
||||||
|
self._set_phase("idle")
|
||||||
|
if self._stop.is_set():
|
||||||
|
self._set_end_reason("stopped")
|
||||||
|
self._status("Acquisition stopped")
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""Camera discovery and raw preview helpers."""
|
||||||
|
|
||||||
|
from orisys_app_shared.camera.discovery import CameraInfo, list_cameras, pick_default_camera
|
||||||
|
from orisys_app_shared.camera.preview import RawPreviewSession
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CameraInfo",
|
||||||
|
"RawPreviewSession",
|
||||||
|
"list_cameras",
|
||||||
|
"pick_default_camera",
|
||||||
|
]
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
"""USB camera discovery for orisys-app (Windows DSHOW/MSMF, Linux V4L2)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
DEFAULT_MAX_CAMERA_INDEX = 10
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CameraInfo:
|
||||||
|
index: int
|
||||||
|
backend_name: str
|
||||||
|
backend_api: int
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source(self) -> str:
|
||||||
|
return str(self.index)
|
||||||
|
|
||||||
|
def label(self) -> str:
|
||||||
|
return f"{self.index} ({self.width}x{self.height}) [{self.backend_name}]"
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def _suppress_opencv_noise():
|
||||||
|
"""Hide OpenCV VideoCapture probe warnings (stderr + OpenCV log)."""
|
||||||
|
prev_level = None
|
||||||
|
set_level = None
|
||||||
|
try:
|
||||||
|
cv_logging = getattr(getattr(cv2, "utils", None), "logging", None)
|
||||||
|
if cv_logging is not None:
|
||||||
|
set_level = getattr(cv_logging, "setLogLevel", None)
|
||||||
|
get_level = getattr(cv_logging, "getLogLevel", None)
|
||||||
|
silent = getattr(cv_logging, "LOG_LEVEL_SILENT", 0)
|
||||||
|
if set_level is not None:
|
||||||
|
if get_level is not None:
|
||||||
|
prev_level = get_level()
|
||||||
|
set_level(silent)
|
||||||
|
except Exception:
|
||||||
|
prev_level = None
|
||||||
|
|
||||||
|
devnull = open(os.devnull, "w")
|
||||||
|
try:
|
||||||
|
with contextlib.redirect_stderr(devnull):
|
||||||
|
stderr_fd = sys.stderr.fileno()
|
||||||
|
saved_fd = os.dup(stderr_fd)
|
||||||
|
try:
|
||||||
|
os.dup2(devnull.fileno(), stderr_fd)
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
os.dup2(saved_fd, stderr_fd)
|
||||||
|
os.close(saved_fd)
|
||||||
|
finally:
|
||||||
|
devnull.close()
|
||||||
|
if set_level is not None and prev_level is not None:
|
||||||
|
try:
|
||||||
|
set_level(prev_level)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _backend_candidates() -> list[tuple[str, int]]:
|
||||||
|
candidates: list[tuple[str, int]] = []
|
||||||
|
if sys.platform == "win32":
|
||||||
|
if hasattr(cv2, "CAP_DSHOW"):
|
||||||
|
candidates.append(("DSHOW", cv2.CAP_DSHOW))
|
||||||
|
if hasattr(cv2, "CAP_MSMF"):
|
||||||
|
candidates.append(("MSMF", cv2.CAP_MSMF))
|
||||||
|
elif sys.platform.startswith("linux"):
|
||||||
|
if hasattr(cv2, "CAP_V4L2"):
|
||||||
|
candidates.append(("V4L2", cv2.CAP_V4L2))
|
||||||
|
if not candidates:
|
||||||
|
candidates.append(("ANY", cv2.CAP_ANY))
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _try_open(index: int, backend_name: str, backend_api: int) -> CameraInfo | None:
|
||||||
|
cap = cv2.VideoCapture(index, backend_api)
|
||||||
|
if not cap.isOpened():
|
||||||
|
cap.release()
|
||||||
|
return None
|
||||||
|
ok, frame = cap.read()
|
||||||
|
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
|
||||||
|
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
|
||||||
|
if not ok or frame is None:
|
||||||
|
if width <= 0 or height <= 0:
|
||||||
|
cap.release()
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
h, w = frame.shape[:2]
|
||||||
|
width = width or int(w)
|
||||||
|
height = height or int(h)
|
||||||
|
cap.release()
|
||||||
|
return CameraInfo(
|
||||||
|
index=index,
|
||||||
|
backend_name=backend_name,
|
||||||
|
backend_api=backend_api,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_cameras(max_index: int = DEFAULT_MAX_CAMERA_INDEX) -> list[CameraInfo]:
|
||||||
|
"""Probe indices ``0 .. max_index`` and return working cameras."""
|
||||||
|
found: list[CameraInfo] = []
|
||||||
|
seen_indices: set[int] = set()
|
||||||
|
backends = _backend_candidates()
|
||||||
|
|
||||||
|
with _suppress_opencv_noise():
|
||||||
|
for index in range(0, max_index + 1):
|
||||||
|
for backend_name, backend_api in backends:
|
||||||
|
info = _try_open(index, backend_name, backend_api)
|
||||||
|
if info is None:
|
||||||
|
continue
|
||||||
|
if index in seen_indices:
|
||||||
|
break
|
||||||
|
found.append(info)
|
||||||
|
seen_indices.add(index)
|
||||||
|
break
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def pick_default_camera(cameras: list[CameraInfo], preferred: str | None = None) -> str | None:
|
||||||
|
if not cameras:
|
||||||
|
return None
|
||||||
|
preferred = (preferred or "").strip()
|
||||||
|
if preferred:
|
||||||
|
for cam in cameras:
|
||||||
|
if cam.source == preferred:
|
||||||
|
return cam.source
|
||||||
|
return cameras[0].source
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
"""Raw OpenCV camera preview session."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
# Must be set before importing cv2 on Windows MSMF builds.
|
||||||
|
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from orisys_app_shared.camera.discovery import _backend_candidates
|
||||||
|
|
||||||
|
|
||||||
|
class RawPreviewSession:
|
||||||
|
"""Open a camera index and read BGR frames for UI preview."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._cap: Any = None
|
||||||
|
self._index: int | None = None
|
||||||
|
self._backend_name: str = ""
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_open(self) -> bool:
|
||||||
|
return self._cap is not None and bool(self._cap.isOpened())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def index(self) -> int | None:
|
||||||
|
return self._index
|
||||||
|
|
||||||
|
@property
|
||||||
|
def backend_name(self) -> str:
|
||||||
|
return self._backend_name
|
||||||
|
|
||||||
|
def open(self, index: int) -> None:
|
||||||
|
self.close()
|
||||||
|
last_error = f"Camera {index} failed to open"
|
||||||
|
for backend_name, backend_api in _backend_candidates():
|
||||||
|
cap = cv2.VideoCapture(int(index), backend_api)
|
||||||
|
if not cap.isOpened():
|
||||||
|
cap.release()
|
||||||
|
continue
|
||||||
|
ok, frame = cap.read()
|
||||||
|
if not ok or frame is None:
|
||||||
|
cap.release()
|
||||||
|
last_error = f"Camera {index} opened via {backend_name} but returned no frame"
|
||||||
|
continue
|
||||||
|
self._cap = cap
|
||||||
|
self._index = int(index)
|
||||||
|
self._backend_name = backend_name
|
||||||
|
return
|
||||||
|
raise RuntimeError(last_error)
|
||||||
|
|
||||||
|
def read(self) -> np.ndarray | None:
|
||||||
|
if not self.is_open:
|
||||||
|
return None
|
||||||
|
ok, frame = self._cap.read()
|
||||||
|
if not ok or frame is None:
|
||||||
|
return None
|
||||||
|
return frame
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self._cap is not None:
|
||||||
|
try:
|
||||||
|
self._cap.release()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._cap = None
|
||||||
|
self._index = None
|
||||||
|
self._backend_name = ""
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""Shared API compatibility helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from orisys_app_shared import API_VERSION, __version__ as shared_version
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def check_shared_compat(required_api: str) -> bool:
|
||||||
|
"""Return True if shared API matches the app requirement; log a warning otherwise."""
|
||||||
|
required = str(required_api or "").strip()
|
||||||
|
current = str(API_VERSION).strip()
|
||||||
|
ok = required == current
|
||||||
|
if not ok:
|
||||||
|
logger.warning(
|
||||||
|
"Shared API mismatch: app requires %s, shared provides %s (shared package %s)",
|
||||||
|
required,
|
||||||
|
current,
|
||||||
|
shared_version,
|
||||||
|
)
|
||||||
|
return ok
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Force history and unit helpers."""
|
||||||
|
|
||||||
|
from orisys_app_shared.force.calibration_io import (
|
||||||
|
CHANNEL_KEYS,
|
||||||
|
CalSample,
|
||||||
|
CalSessionStore,
|
||||||
|
build_force_calibration_v2,
|
||||||
|
export_force_calibration_v2,
|
||||||
|
meta_from_sensor_json,
|
||||||
|
)
|
||||||
|
from orisys_app_shared.force.history import MetricHistory, PlotState, lowpass_filter
|
||||||
|
from orisys_app_shared.force.units import DisplayForces, select_display_forces
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"CHANNEL_KEYS",
|
||||||
|
"CalSample",
|
||||||
|
"CalSessionStore",
|
||||||
|
"DisplayForces",
|
||||||
|
"MetricHistory",
|
||||||
|
"PlotState",
|
||||||
|
"build_force_calibration_v2",
|
||||||
|
"export_force_calibration_v2",
|
||||||
|
"lowpass_filter",
|
||||||
|
"meta_from_sensor_json",
|
||||||
|
"select_display_forces",
|
||||||
|
]
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
"""Build and write schema_version-2 force_calibration.json files."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from copy import deepcopy
|
||||||
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Iterable, Literal
|
||||||
|
|
||||||
|
ChannelKey = Literal["fn", "fx", "fy"]
|
||||||
|
CHANNEL_KEYS: tuple[ChannelKey, ...] = ("fn", "fx", "fy")
|
||||||
|
|
||||||
|
_CHANNEL_TO_JSON = {
|
||||||
|
"fn": "normal",
|
||||||
|
"fx": "shear_x",
|
||||||
|
"fy": "shear_y",
|
||||||
|
}
|
||||||
|
_JSON_RAW_KEY = {
|
||||||
|
"normal": "fnormal",
|
||||||
|
"shear_x": "fshearx",
|
||||||
|
"shear_y": "fsheary",
|
||||||
|
}
|
||||||
|
|
||||||
|
WEIGHT_EPS = 1e-6
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CalSample:
|
||||||
|
weight_n: float
|
||||||
|
raw_mean: float
|
||||||
|
n: int
|
||||||
|
std: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CalSessionStore:
|
||||||
|
"""In-memory calibration points for FN / FX / FY."""
|
||||||
|
|
||||||
|
fn: list[CalSample] = field(default_factory=list)
|
||||||
|
fx: list[CalSample] = field(default_factory=list)
|
||||||
|
fy: list[CalSample] = field(default_factory=list)
|
||||||
|
|
||||||
|
def channel(self, key: ChannelKey) -> list[CalSample]:
|
||||||
|
return getattr(self, key)
|
||||||
|
|
||||||
|
def set_channel(self, key: ChannelKey, samples: list[CalSample]) -> None:
|
||||||
|
setattr(self, key, list(samples))
|
||||||
|
|
||||||
|
def clear(self, key: ChannelKey | None = None) -> None:
|
||||||
|
if key is None:
|
||||||
|
self.fn.clear()
|
||||||
|
self.fx.clear()
|
||||||
|
self.fy.clear()
|
||||||
|
return
|
||||||
|
self.channel(key).clear()
|
||||||
|
|
||||||
|
def upsert(self, key: ChannelKey, sample: CalSample) -> None:
|
||||||
|
rows = self.channel(key)
|
||||||
|
for i, existing in enumerate(rows):
|
||||||
|
if abs(existing.weight_n - sample.weight_n) <= WEIGHT_EPS:
|
||||||
|
rows[i] = sample
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
rows.append(sample)
|
||||||
|
rows.sort(key=lambda s: s.weight_n)
|
||||||
|
self.set_channel(key, rows)
|
||||||
|
|
||||||
|
def remove_at(self, key: ChannelKey, index: int) -> None:
|
||||||
|
rows = self.channel(key)
|
||||||
|
if 0 <= index < len(rows):
|
||||||
|
rows.pop(index)
|
||||||
|
|
||||||
|
def to_audit_dict(self) -> dict[str, list[dict[str, float | int]]]:
|
||||||
|
out: dict[str, list[dict[str, float | int]]] = {}
|
||||||
|
for key in CHANNEL_KEYS:
|
||||||
|
out[key] = [asdict(s) for s in self.channel(key)]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def meta_from_sensor_json(path: str | Path | None) -> dict[str, Any]:
|
||||||
|
"""Pull stitch / tracker fields from a sensor.json for export meta."""
|
||||||
|
meta: dict[str, Any] = {
|
||||||
|
"stitch_resolution": None,
|
||||||
|
"dis_mode": None,
|
||||||
|
"hhd_downsample": None,
|
||||||
|
"config_ref": None,
|
||||||
|
}
|
||||||
|
if path is None:
|
||||||
|
return meta
|
||||||
|
p = Path(path)
|
||||||
|
meta["config_ref"] = str(p)
|
||||||
|
if not p.is_file():
|
||||||
|
return meta
|
||||||
|
try:
|
||||||
|
data = json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return meta
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return meta
|
||||||
|
camera = data.get("camera") if isinstance(data.get("camera"), dict) else {}
|
||||||
|
tracker = data.get("tracker") if isinstance(data.get("tracker"), dict) else {}
|
||||||
|
stitch = camera.get("resolution_stitch")
|
||||||
|
if isinstance(stitch, (list, tuple)) and len(stitch) >= 2:
|
||||||
|
meta["stitch_resolution"] = [int(stitch[0]), int(stitch[1])]
|
||||||
|
if "dis_mode" in tracker:
|
||||||
|
try:
|
||||||
|
meta["dis_mode"] = int(tracker["dis_mode"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
meta["dis_mode"] = tracker.get("dis_mode")
|
||||||
|
if "hhd_downsample" in tracker:
|
||||||
|
try:
|
||||||
|
meta["hhd_downsample"] = int(tracker["hhd_downsample"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
meta["hhd_downsample"] = tracker.get("hhd_downsample")
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def _piecewise_channel(
|
||||||
|
*,
|
||||||
|
json_name: str,
|
||||||
|
samples: Iterable[CalSample],
|
||||||
|
signed: bool,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
rows = sorted(samples, key=lambda s: abs(float(s.raw_mean)))
|
||||||
|
if len(rows) < 2:
|
||||||
|
return None
|
||||||
|
points = [[abs(float(s.raw_mean)), float(s.weight_n)] for s in rows]
|
||||||
|
block: dict[str, Any] = {
|
||||||
|
"method": "piecewise_linear",
|
||||||
|
"raw_key": _JSON_RAW_KEY[json_name],
|
||||||
|
"extrapolate": True,
|
||||||
|
"clamp_output_min": 0.0,
|
||||||
|
"points": points,
|
||||||
|
}
|
||||||
|
if signed:
|
||||||
|
block["signed"] = True
|
||||||
|
return block
|
||||||
|
|
||||||
|
|
||||||
|
def build_force_calibration_v2(
|
||||||
|
store: CalSessionStore,
|
||||||
|
*,
|
||||||
|
meta: dict[str, Any] | None = None,
|
||||||
|
) -> tuple[dict[str, Any], list[str]]:
|
||||||
|
"""Build schema v2 JSON. Returns (document, skipped_channel_warnings)."""
|
||||||
|
warnings: list[str] = []
|
||||||
|
channels: dict[str, Any] = {}
|
||||||
|
for key, json_name in _CHANNEL_TO_JSON.items():
|
||||||
|
signed = key in ("fx", "fy")
|
||||||
|
block = _piecewise_channel(
|
||||||
|
json_name=json_name,
|
||||||
|
samples=store.channel(key),
|
||||||
|
signed=signed,
|
||||||
|
)
|
||||||
|
if block is None:
|
||||||
|
if store.channel(key):
|
||||||
|
warnings.append(
|
||||||
|
f"{key}: need at least 2 points (have {len(store.channel(key))})"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
warnings.append(f"{key}: no samples")
|
||||||
|
continue
|
||||||
|
channels[json_name] = block
|
||||||
|
|
||||||
|
base_meta = {
|
||||||
|
"date": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
|
||||||
|
"sensor_type": "",
|
||||||
|
"sn": "",
|
||||||
|
"stitch_resolution": None,
|
||||||
|
"dis_mode": None,
|
||||||
|
"hhd_downsample": None,
|
||||||
|
"record_duration_s": None,
|
||||||
|
"config_ref": None,
|
||||||
|
"samples": store.to_audit_dict(),
|
||||||
|
}
|
||||||
|
if meta:
|
||||||
|
for k, v in meta.items():
|
||||||
|
if k == "samples":
|
||||||
|
continue
|
||||||
|
base_meta[k] = v
|
||||||
|
if "samples" in meta and meta["samples"] is not None:
|
||||||
|
base_meta["samples"] = meta["samples"]
|
||||||
|
|
||||||
|
doc = {
|
||||||
|
"schema_version": 2,
|
||||||
|
"unit": "N",
|
||||||
|
"channels": channels,
|
||||||
|
"meta": base_meta,
|
||||||
|
}
|
||||||
|
return doc, warnings
|
||||||
|
|
||||||
|
|
||||||
|
def write_force_calibration(
|
||||||
|
doc: dict[str, Any],
|
||||||
|
path: str | Path,
|
||||||
|
) -> Path:
|
||||||
|
path = Path(path)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(doc, indent=2, ensure_ascii=False) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return path.resolve()
|
||||||
|
|
||||||
|
|
||||||
|
def export_force_calibration_v2(
|
||||||
|
store: CalSessionStore,
|
||||||
|
*,
|
||||||
|
export_dir: str | Path,
|
||||||
|
sensor_type: str,
|
||||||
|
meta: dict[str, Any] | None = None,
|
||||||
|
local_bundle_dir: str | Path | None = None,
|
||||||
|
) -> tuple[Path, Path | None, list[str]]:
|
||||||
|
"""Write timestamped export and optional local bundle force_calibration.json."""
|
||||||
|
doc, warnings = build_force_calibration_v2(store, meta=meta)
|
||||||
|
if not doc.get("channels"):
|
||||||
|
raise ValueError(
|
||||||
|
"No complete channels to export (each needs ≥2 points). "
|
||||||
|
+ "; ".join(warnings)
|
||||||
|
)
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
export_path = Path(export_dir) / f"force_calibration_{sensor_type}_{stamp}.json"
|
||||||
|
primary = write_force_calibration(doc, export_path)
|
||||||
|
|
||||||
|
local_path: Path | None = None
|
||||||
|
if local_bundle_dir is not None:
|
||||||
|
bundle = Path(local_bundle_dir)
|
||||||
|
if bundle.is_dir():
|
||||||
|
local_path = write_force_calibration(
|
||||||
|
deepcopy(doc),
|
||||||
|
bundle / "force_calibration.json",
|
||||||
|
)
|
||||||
|
return primary, local_path, warnings
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""Rolling force history with optional display low-pass."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PlotState:
|
||||||
|
times: np.ndarray
|
||||||
|
values: np.ndarray
|
||||||
|
x_range: tuple[float, float]
|
||||||
|
y_ranges: tuple[tuple[float, float], ...]
|
||||||
|
|
||||||
|
|
||||||
|
def lowpass_filter(
|
||||||
|
data,
|
||||||
|
cutoff_hz: float,
|
||||||
|
sample_rate_hz: float,
|
||||||
|
order: int = 2,
|
||||||
|
) -> np.ndarray:
|
||||||
|
y = np.asarray(data, dtype=np.float64)
|
||||||
|
if cutoff_hz == 0 or y.size == 0:
|
||||||
|
return y.copy()
|
||||||
|
nyquist = 0.5 * sample_rate_hz
|
||||||
|
if cutoff_hz < 0 or cutoff_hz >= nyquist:
|
||||||
|
return y.copy()
|
||||||
|
from scipy.signal import butter, filtfilt
|
||||||
|
|
||||||
|
b, a = butter(order, cutoff_hz / nyquist, btype="low")
|
||||||
|
# filtfilt requires length strictly greater than padlen.
|
||||||
|
padlen = 3 * (max(len(b), len(a)) - 1)
|
||||||
|
if y.size <= padlen:
|
||||||
|
return y.copy()
|
||||||
|
try:
|
||||||
|
return filtfilt(b, a, y)
|
||||||
|
except ValueError:
|
||||||
|
return y.copy()
|
||||||
|
|
||||||
|
|
||||||
|
def _npoints_for_window(window_sec: float, sample_rate_hz: float) -> int:
|
||||||
|
return max(2, int(round(float(window_sec) * float(sample_rate_hz))))
|
||||||
|
|
||||||
|
|
||||||
|
class MetricHistory:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
npoints: int | None = None,
|
||||||
|
*,
|
||||||
|
channels: int = 3,
|
||||||
|
window_sec: float = 4.0,
|
||||||
|
sample_rate_hz: float | None = None,
|
||||||
|
lowpass_cutoff_hz: float = 0.0,
|
||||||
|
y_axis_min_span: float = 0.5,
|
||||||
|
):
|
||||||
|
self.channels = int(channels)
|
||||||
|
self.window_sec = float(window_sec)
|
||||||
|
self.sample_rate_hz = (
|
||||||
|
float(sample_rate_hz)
|
||||||
|
if sample_rate_hz is not None
|
||||||
|
else (30.0 if self.window_sec <= 0 else 30.0)
|
||||||
|
)
|
||||||
|
if npoints is None:
|
||||||
|
self.npoints = _npoints_for_window(self.window_sec, self.sample_rate_hz)
|
||||||
|
else:
|
||||||
|
self.npoints = int(npoints)
|
||||||
|
self.lowpass_cutoff_hz = float(lowpass_cutoff_hz)
|
||||||
|
self.y_axis_min_span = float(y_axis_min_span)
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
def set_window_sec(self, window_sec: float) -> None:
|
||||||
|
self.window_sec = max(0.1, float(window_sec))
|
||||||
|
self.npoints = _npoints_for_window(self.window_sec, self.sample_rate_hz)
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
self._data = np.full((self.npoints, 1 + self.channels), np.nan, dtype=np.float64)
|
||||||
|
self.sample_count = 0
|
||||||
|
self._t0: Optional[float] = None
|
||||||
|
self._y_range_smooth: list[Optional[tuple[float, float]]] = [
|
||||||
|
None for _ in range(self.channels)
|
||||||
|
]
|
||||||
|
|
||||||
|
def push(self, timestamp: float, *values: float) -> None:
|
||||||
|
if len(values) != self.channels:
|
||||||
|
raise ValueError(f"Expected {self.channels} values, got {len(values)}")
|
||||||
|
if self._t0 is None:
|
||||||
|
self._t0 = float(timestamp)
|
||||||
|
t = float(timestamp) - self._t0
|
||||||
|
row = (t, *map(float, values))
|
||||||
|
if self.sample_count < self.npoints:
|
||||||
|
self._data[self.sample_count, :] = row
|
||||||
|
self.sample_count += 1
|
||||||
|
return
|
||||||
|
self._data[:-1, :] = self._data[1:, :]
|
||||||
|
self._data[-1, :] = row
|
||||||
|
|
||||||
|
def _smooth_y_range(self, channel: int, ymin: float, ymax: float) -> tuple[float, float]:
|
||||||
|
if self._y_range_smooth[channel] is None:
|
||||||
|
smoothed = (ymin, ymax)
|
||||||
|
else:
|
||||||
|
prev_min, prev_max = self._y_range_smooth[channel]
|
||||||
|
ymin = 0.5 * ymin + 0.5 * prev_min
|
||||||
|
ymax = 0.5 * ymax + 0.5 * prev_max
|
||||||
|
smoothed = (ymin, ymax)
|
||||||
|
span = smoothed[1] - smoothed[0]
|
||||||
|
if span < self.y_axis_min_span:
|
||||||
|
mid = 0.5 * (smoothed[0] + smoothed[1])
|
||||||
|
half = self.y_axis_min_span / 2.0
|
||||||
|
smoothed = (mid - half, mid + half)
|
||||||
|
self._y_range_smooth[channel] = smoothed
|
||||||
|
pad = max((smoothed[1] - smoothed[0]) * 0.12, 0.01)
|
||||||
|
return smoothed[0] - pad, smoothed[1] + pad
|
||||||
|
|
||||||
|
def state(self) -> Optional[PlotState]:
|
||||||
|
n = self.sample_count
|
||||||
|
if n == 0:
|
||||||
|
return None
|
||||||
|
block = self._data[:n]
|
||||||
|
times = block[:, 0].copy()
|
||||||
|
values = block[:, 1:].copy()
|
||||||
|
if self.lowpass_cutoff_hz > 0:
|
||||||
|
for i in range(self.channels):
|
||||||
|
values[:, i] = lowpass_filter(
|
||||||
|
values[:, i],
|
||||||
|
self.lowpass_cutoff_hz,
|
||||||
|
sample_rate_hz=self.sample_rate_hz,
|
||||||
|
)
|
||||||
|
t_end = float(times[-1])
|
||||||
|
if t_end >= self.window_sec:
|
||||||
|
x_range = (t_end - self.window_sec, t_end)
|
||||||
|
else:
|
||||||
|
x_range = (0.0, self.window_sec)
|
||||||
|
y_ranges = []
|
||||||
|
for i in range(self.channels):
|
||||||
|
col = values[:, i]
|
||||||
|
ymin = float(np.nanmin(col))
|
||||||
|
ymax = float(np.nanmax(col))
|
||||||
|
if not np.isfinite(ymin) or not np.isfinite(ymax):
|
||||||
|
ymin, ymax = 0.0, 1.0
|
||||||
|
y_ranges.append(self._smooth_y_range(i, ymin, ymax))
|
||||||
|
return PlotState(
|
||||||
|
times=times,
|
||||||
|
values=values,
|
||||||
|
x_range=x_range,
|
||||||
|
y_ranges=tuple(y_ranges),
|
||||||
|
)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Force unit selection for display."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DisplayForces:
|
||||||
|
fn: float
|
||||||
|
fx: float
|
||||||
|
fy: float
|
||||||
|
unit: str
|
||||||
|
using_raw_fallback: bool
|
||||||
|
|
||||||
|
|
||||||
|
def select_display_forces(
|
||||||
|
*,
|
||||||
|
units_pref: str,
|
||||||
|
force_calibrated: bool,
|
||||||
|
fn: float,
|
||||||
|
fx: float,
|
||||||
|
fy: float,
|
||||||
|
fn_raw: float,
|
||||||
|
fx_raw: float,
|
||||||
|
fy_raw: float,
|
||||||
|
) -> DisplayForces:
|
||||||
|
"""Return values for plots/labels given newton/raw preference."""
|
||||||
|
want_newton = str(units_pref or "newton").lower().startswith("n")
|
||||||
|
if want_newton and force_calibrated:
|
||||||
|
return DisplayForces(fn=fn, fx=fx, fy=fy, unit="N", using_raw_fallback=False)
|
||||||
|
if want_newton and not force_calibrated:
|
||||||
|
return DisplayForces(
|
||||||
|
fn=fn_raw,
|
||||||
|
fx=fx_raw,
|
||||||
|
fy=fy_raw,
|
||||||
|
unit="raw",
|
||||||
|
using_raw_fallback=True,
|
||||||
|
)
|
||||||
|
return DisplayForces(
|
||||||
|
fn=fn_raw,
|
||||||
|
fx=fx_raw,
|
||||||
|
fy=fy_raw,
|
||||||
|
unit="raw",
|
||||||
|
using_raw_fallback=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""i18n helpers."""
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n.translator import Translator, get_translator, set_locale, t
|
||||||
|
|
||||||
|
__all__ = ["Translator", "get_translator", "set_locale", "t"]
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Simple JSON locale translator with en_US fallback."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from orisys_app_shared.paths import locales_dir
|
||||||
|
|
||||||
|
|
||||||
|
class Translator:
|
||||||
|
def __init__(self, locales_path: Path | None = None, locale: str = "zh_CN") -> None:
|
||||||
|
self._locales_path = locales_path or locales_dir()
|
||||||
|
self._locale = locale
|
||||||
|
self._strings: dict[str, Any] = {}
|
||||||
|
self._fallback: dict[str, Any] = {}
|
||||||
|
self.set_locale(locale)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def locale(self) -> str:
|
||||||
|
return self._locale
|
||||||
|
|
||||||
|
def set_locale(self, locale: str) -> None:
|
||||||
|
self._locale = locale
|
||||||
|
self._fallback = self._load("en_US")
|
||||||
|
self._strings = self._load(locale)
|
||||||
|
|
||||||
|
def _load(self, locale: str) -> dict[str, Any]:
|
||||||
|
path = self._locales_path / f"{locale}.json"
|
||||||
|
if not path.is_file():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
def t(self, key: str, **kwargs: Any) -> str:
|
||||||
|
value = self._lookup(self._strings, key)
|
||||||
|
if value is None:
|
||||||
|
value = self._lookup(self._fallback, key)
|
||||||
|
if value is None:
|
||||||
|
return key
|
||||||
|
text = str(value)
|
||||||
|
if kwargs:
|
||||||
|
try:
|
||||||
|
return text.format(**kwargs)
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
return text
|
||||||
|
return text
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _lookup(data: dict[str, Any], key: str) -> Any | None:
|
||||||
|
node: Any = data
|
||||||
|
for part in key.split("."):
|
||||||
|
if not isinstance(node, dict) or part not in node:
|
||||||
|
return None
|
||||||
|
node = node[part]
|
||||||
|
return node
|
||||||
|
|
||||||
|
|
||||||
|
_translator: Translator | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_translator() -> Translator:
|
||||||
|
global _translator
|
||||||
|
if _translator is None:
|
||||||
|
_translator = Translator()
|
||||||
|
return _translator
|
||||||
|
|
||||||
|
|
||||||
|
def t(key: str, **kwargs: Any) -> str:
|
||||||
|
return get_translator().t(key, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def set_locale(locale: str) -> Translator:
|
||||||
|
tr = get_translator()
|
||||||
|
tr.set_locale(locale)
|
||||||
|
return tr
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
"""Logging helpers."""
|
||||||
|
|
||||||
|
from orisys_app_shared.logging.errors import report_error, report_warning
|
||||||
|
from orisys_app_shared.logging.excepthook import install_exception_hooks
|
||||||
|
from orisys_app_shared.logging.qt_handler import QtLogHandler
|
||||||
|
from orisys_app_shared.logging.setup import init_logging
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"QtLogHandler",
|
||||||
|
"init_logging",
|
||||||
|
"install_exception_hooks",
|
||||||
|
"report_error",
|
||||||
|
"report_warning",
|
||||||
|
]
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""User/developer error reporting helpers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import traceback
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def report_error(
|
||||||
|
exc: BaseException | None = None,
|
||||||
|
*,
|
||||||
|
message: str = "",
|
||||||
|
logger_name: str = "orisys_app",
|
||||||
|
) -> None:
|
||||||
|
"""Log an error with optional exception traceback (dialog wiring later)."""
|
||||||
|
logger = logging.getLogger(logger_name)
|
||||||
|
if exc is not None:
|
||||||
|
logger.error(
|
||||||
|
"%s\n%s",
|
||||||
|
message or str(exc),
|
||||||
|
"".join(traceback.format_exception(type(exc), exc, exc.__traceback__)),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
logger.error("%s", message or "Unknown error")
|
||||||
|
|
||||||
|
|
||||||
|
def report_warning(message: str, *, logger_name: str = "orisys_app", **_kwargs: Any) -> None:
|
||||||
|
logging.getLogger(logger_name).warning("%s", message)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Install process-wide exception hooks that log tracebacks."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
|
||||||
|
def install_exception_hooks() -> None:
|
||||||
|
logger = logging.getLogger("orisys_app_shared.excepthook")
|
||||||
|
|
||||||
|
def handle_exception(exc_type, exc_value, exc_traceback):
|
||||||
|
if issubclass(exc_type, KeyboardInterrupt):
|
||||||
|
sys.__excepthook__(exc_type, exc_value, exc_traceback)
|
||||||
|
return
|
||||||
|
logger.error(
|
||||||
|
"Unhandled exception\n%s",
|
||||||
|
"".join(traceback.format_exception(exc_type, exc_value, exc_traceback)),
|
||||||
|
)
|
||||||
|
|
||||||
|
sys.excepthook = handle_exception
|
||||||
|
|
||||||
|
if hasattr(threading, "excepthook"):
|
||||||
|
|
||||||
|
def thread_hook(args):
|
||||||
|
if args.exc_type is None:
|
||||||
|
return
|
||||||
|
if issubclass(args.exc_type, SystemExit):
|
||||||
|
return
|
||||||
|
logger.error(
|
||||||
|
"Unhandled thread exception in %s\n%s",
|
||||||
|
getattr(args.thread, "name", "?"),
|
||||||
|
"".join(traceback.format_exception(args.exc_type, args.exc_value, args.exc_traceback)),
|
||||||
|
)
|
||||||
|
|
||||||
|
threading.excepthook = thread_hook
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Qt log handler stub for a future log dock."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
|
||||||
|
class QtLogHandler(logging.Handler):
|
||||||
|
"""Forward log records to a callable (e.g. append to a QTextEdit)."""
|
||||||
|
|
||||||
|
def __init__(self, sink: Callable[[str], None] | None = None) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self._sink = sink
|
||||||
|
|
||||||
|
def set_sink(self, sink: Callable[[str], None] | None) -> None:
|
||||||
|
self._sink = sink
|
||||||
|
|
||||||
|
def emit(self, record: logging.LogRecord) -> None:
|
||||||
|
if self._sink is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._sink(self.format(record))
|
||||||
|
except Exception:
|
||||||
|
self.handleError(record)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Logging setup for orisys-app products."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from orisys_app_shared.paths import logs_dir
|
||||||
|
|
||||||
|
_INITIALIZED = False
|
||||||
|
|
||||||
|
|
||||||
|
def init_logging(app_name: str = "orisys-app", *, level: int = logging.INFO) -> Path:
|
||||||
|
"""Configure root logger with console + session file under outputs/logs."""
|
||||||
|
global _INITIALIZED
|
||||||
|
log_path = logs_dir() / f"{app_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
|
||||||
|
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.setLevel(level)
|
||||||
|
# Avoid duplicate handlers on repeated init (tests / re-entry).
|
||||||
|
if _INITIALIZED:
|
||||||
|
return log_path
|
||||||
|
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
"%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S",
|
||||||
|
)
|
||||||
|
|
||||||
|
file_handler = logging.FileHandler(log_path, encoding="utf-8")
|
||||||
|
file_handler.setFormatter(formatter)
|
||||||
|
file_handler.setLevel(level)
|
||||||
|
root.addHandler(file_handler)
|
||||||
|
|
||||||
|
console = logging.StreamHandler()
|
||||||
|
console.setFormatter(formatter)
|
||||||
|
console.setLevel(level)
|
||||||
|
root.addHandler(console)
|
||||||
|
|
||||||
|
_INITIALIZED = True
|
||||||
|
logging.getLogger(__name__).info("Logging initialized: %s", log_path)
|
||||||
|
return log_path
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
"""Resolve app, config, and output paths for editable and frozen runs."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def is_frozen() -> bool:
|
||||||
|
return bool(getattr(sys, "frozen", False))
|
||||||
|
|
||||||
|
|
||||||
|
def _customer_root_from_shared() -> Path:
|
||||||
|
"""Repo layout: shared/src/orisys_app_shared -> apps/customer."""
|
||||||
|
here = Path(__file__).resolve()
|
||||||
|
# .../orisys-app/shared/src/orisys_app_shared/paths.py
|
||||||
|
repo_root = here.parents[3]
|
||||||
|
return repo_root / "apps" / "customer"
|
||||||
|
|
||||||
|
|
||||||
|
def app_root() -> Path:
|
||||||
|
"""Return the customer app root (editable) or EXE directory (frozen)."""
|
||||||
|
if is_frozen():
|
||||||
|
return Path(sys.executable).resolve().parent
|
||||||
|
# Prefer cwd override when launched from apps/customer
|
||||||
|
cwd = Path.cwd().resolve()
|
||||||
|
if (cwd / "config").is_dir() and (cwd / "run.py").is_file():
|
||||||
|
return cwd
|
||||||
|
if (cwd / "apps" / "customer" / "config").is_dir():
|
||||||
|
return cwd / "apps" / "customer"
|
||||||
|
return _customer_root_from_shared()
|
||||||
|
|
||||||
|
|
||||||
|
def config_dir() -> Path:
|
||||||
|
if is_frozen():
|
||||||
|
beside = app_root() / "config"
|
||||||
|
if beside.is_dir():
|
||||||
|
return beside
|
||||||
|
internal = app_root() / "_internal" / "config"
|
||||||
|
if internal.is_dir():
|
||||||
|
return internal
|
||||||
|
return app_root() / "config"
|
||||||
|
|
||||||
|
|
||||||
|
def sensors_dir() -> Path:
|
||||||
|
return config_dir() / "sensors"
|
||||||
|
|
||||||
|
|
||||||
|
def locales_dir() -> Path:
|
||||||
|
return config_dir() / "locales"
|
||||||
|
|
||||||
|
|
||||||
|
def outputs_dir() -> Path:
|
||||||
|
path = app_root() / "outputs"
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def logs_dir() -> Path:
|
||||||
|
path = outputs_dir() / "logs"
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def calibration_export_dir() -> Path:
|
||||||
|
path = outputs_dir() / "calibration"
|
||||||
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def preference_path() -> Path:
|
||||||
|
return app_root() / "preference.json"
|
||||||
|
|
||||||
|
|
||||||
|
def preferences_default_path() -> Path:
|
||||||
|
return config_dir() / "preferences.default.json"
|
||||||
|
|
||||||
|
|
||||||
|
def branding_dir() -> Path:
|
||||||
|
"""Branding assets shipped with the shared package."""
|
||||||
|
# editable: shared/resources/branding
|
||||||
|
# package: next to installed module via package resources fallback
|
||||||
|
here = Path(__file__).resolve()
|
||||||
|
candidates = [
|
||||||
|
here.parents[2] / "resources" / "branding", # shared/resources/branding
|
||||||
|
here.parent / "resources" / "branding",
|
||||||
|
]
|
||||||
|
if is_frozen():
|
||||||
|
candidates.insert(0, app_root() / "branding")
|
||||||
|
candidates.insert(0, app_root() / "_internal" / "branding")
|
||||||
|
for path in candidates:
|
||||||
|
if path.is_dir():
|
||||||
|
return path
|
||||||
|
return candidates[0]
|
||||||
|
|
||||||
|
|
||||||
|
def logo_path() -> Path:
|
||||||
|
return branding_dir() / "logo.png"
|
||||||
|
|
||||||
|
|
||||||
|
def logo_topbar_path() -> Path:
|
||||||
|
return branding_dir() / "logo_topbar.png"
|
||||||
|
|
||||||
|
|
||||||
|
def sensor_json_path(sensor_type: str) -> Path | None:
|
||||||
|
"""Return ``config/sensors/<type>/sensor.json`` when present."""
|
||||||
|
path = sensors_dir() / str(sensor_type) / "sensor.json"
|
||||||
|
return path if path.is_file() else None
|
||||||
|
|
||||||
|
|
||||||
|
_PREFERRED_SENSOR_ROOT: Path | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def prefer_local_sensor_bundles(root: Path | None = None) -> Path | None:
|
||||||
|
"""Prefer app ``config/sensors`` over packaged Orisys sensor bundles."""
|
||||||
|
global _PREFERRED_SENSOR_ROOT
|
||||||
|
|
||||||
|
if root is not None:
|
||||||
|
sensors = (Path(root) / "config" / "sensors").resolve()
|
||||||
|
if not sensors.is_dir():
|
||||||
|
sensors = Path(root).resolve()
|
||||||
|
else:
|
||||||
|
sensors = sensors_dir().resolve()
|
||||||
|
if not sensors.is_dir():
|
||||||
|
return None
|
||||||
|
|
||||||
|
if _PREFERRED_SENSOR_ROOT is not None and _PREFERRED_SENSOR_ROOT == sensors:
|
||||||
|
return sensors
|
||||||
|
|
||||||
|
try:
|
||||||
|
import orisys.configs as configs
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
original = configs._candidate_sensor_roots
|
||||||
|
marker = str(sensors)
|
||||||
|
if getattr(original, "_orisys_app_local_pref", None) == marker:
|
||||||
|
_PREFERRED_SENSOR_ROOT = sensors
|
||||||
|
return sensors
|
||||||
|
|
||||||
|
def _candidate_sensor_roots_prefer_local() -> list[Path]:
|
||||||
|
roots = [sensors]
|
||||||
|
for candidate in original():
|
||||||
|
resolved = Path(candidate).resolve()
|
||||||
|
if resolved != sensors and resolved not in roots:
|
||||||
|
roots.append(resolved)
|
||||||
|
return roots
|
||||||
|
|
||||||
|
_candidate_sensor_roots_prefer_local._orisys_app_local_pref = marker # type: ignore[attr-defined]
|
||||||
|
configs._candidate_sensor_roots = _candidate_sensor_roots_prefer_local
|
||||||
|
_PREFERRED_SENSOR_ROOT = sensors
|
||||||
|
return sensors
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Preference store re-exports."""
|
||||||
|
|
||||||
|
from orisys_app_shared.prefs.store import (
|
||||||
|
default_preferences,
|
||||||
|
get_preference,
|
||||||
|
load_preferences,
|
||||||
|
save_preferences,
|
||||||
|
set_preference,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"default_preferences",
|
||||||
|
"get_preference",
|
||||||
|
"load_preferences",
|
||||||
|
"save_preferences",
|
||||||
|
"set_preference",
|
||||||
|
]
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
"""Load/save preference.json with defaults merge."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from copy import deepcopy
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from orisys_app_shared.paths import preference_path, preferences_default_path
|
||||||
|
|
||||||
|
_DEFAULTS: dict[str, Any] = {
|
||||||
|
"language": "zh_CN",
|
||||||
|
"theme": "dark",
|
||||||
|
"window_geometry": None,
|
||||||
|
"camera_id": "0",
|
||||||
|
"source_mode": "camera",
|
||||||
|
"video_path": "",
|
||||||
|
"show_cal_panel": False,
|
||||||
|
"show_finger_3d": True,
|
||||||
|
"sensor_type": "box",
|
||||||
|
"config_name": "",
|
||||||
|
"units": "newton",
|
||||||
|
"plot_window_sec": 4.0,
|
||||||
|
"plot_fn_max": 20.0,
|
||||||
|
"plot_shear_max": 20.0,
|
||||||
|
"image_view_mode": "arrows_pure",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_json(path: Path) -> dict[str, Any]:
|
||||||
|
if not path.is_file():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def default_preferences() -> dict[str, Any]:
|
||||||
|
merged = deepcopy(_DEFAULTS)
|
||||||
|
merged.update(_load_json(preferences_default_path()))
|
||||||
|
return merged
|
||||||
|
|
||||||
|
|
||||||
|
def load_preferences() -> dict[str, Any]:
|
||||||
|
prefs = default_preferences()
|
||||||
|
prefs.update(_load_json(preference_path()))
|
||||||
|
return prefs
|
||||||
|
|
||||||
|
|
||||||
|
def save_preferences(prefs: dict[str, Any]) -> Path:
|
||||||
|
path = preference_path()
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
payload = default_preferences()
|
||||||
|
payload.update(prefs)
|
||||||
|
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def get_preference(key: str, default: Any = None) -> Any:
|
||||||
|
return load_preferences().get(key, default)
|
||||||
|
|
||||||
|
|
||||||
|
def set_preference(key: str, value: Any) -> dict[str, Any]:
|
||||||
|
prefs = load_preferences()
|
||||||
|
prefs[key] = value
|
||||||
|
save_preferences(prefs)
|
||||||
|
return prefs
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Sensor catalog helpers."""
|
||||||
|
|
||||||
|
from orisys_app_shared.sensors.catalog import list_configs_for_type, list_sensor_types
|
||||||
|
|
||||||
|
__all__ = ["list_configs_for_type", "list_sensor_types"]
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""List local editable sensor bundles under config/sensors."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from orisys_app_shared.paths import sensors_dir
|
||||||
|
|
||||||
|
PRODUCT_TYPES = ("box", "mini", "finger")
|
||||||
|
|
||||||
|
|
||||||
|
def list_sensor_types() -> list[str]:
|
||||||
|
return list(PRODUCT_TYPES)
|
||||||
|
|
||||||
|
|
||||||
|
def list_configs_for_type(sensor_type: str) -> list[tuple[str, str]]:
|
||||||
|
"""Return ``(display_name, config_key)`` entries for one sensor type.
|
||||||
|
|
||||||
|
Prefers ``config/sensors/<type>/sensor.json`` when present; otherwise a
|
||||||
|
single default entry named after the type (for prefs / future Run).
|
||||||
|
"""
|
||||||
|
name = str(sensor_type or "").strip()
|
||||||
|
if not name:
|
||||||
|
return []
|
||||||
|
root = sensors_dir() / name
|
||||||
|
sensor_json = root / "sensor.json"
|
||||||
|
if sensor_json.is_file():
|
||||||
|
return [(f"{name}/sensor.json", str(sensor_json))]
|
||||||
|
# Also accept extra *.json next to sensor.json later; for now only sensor.json.
|
||||||
|
extras = sorted(p for p in root.glob("*.json") if p.name != "manifest.json")
|
||||||
|
if extras:
|
||||||
|
return [(f"{name}/{p.name}", str(p)) for p in extras]
|
||||||
|
return [(name, name)]
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Theme helpers."""
|
||||||
|
|
||||||
|
from orisys_app_shared.theme.qss import build_qss, clear_theme_cache, color, load_colors
|
||||||
|
|
||||||
|
__all__ = ["build_qss", "clear_theme_cache", "color", "load_colors"]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"window_bg": "#121316",
|
||||||
|
"panel_bg": "#1a1c20",
|
||||||
|
"panel_border": "#2a2d33",
|
||||||
|
"text_primary": "#e8eaed",
|
||||||
|
"text_secondary": "#9aa0a6",
|
||||||
|
"accent": "#536dce",
|
||||||
|
"accent_hover": "#6b82d9",
|
||||||
|
"button_bg": "#2a2d33",
|
||||||
|
"button_hover": "#3a3f47",
|
||||||
|
"input_bg": "#0f1012",
|
||||||
|
"input_border": "#3a3f47",
|
||||||
|
"menu_bg": "#1a1c20",
|
||||||
|
"menu_hover": "#2a2d33",
|
||||||
|
"plot_fn": "#8ab4f8",
|
||||||
|
"plot_fx": "#34a853",
|
||||||
|
"plot_fy": "#fbbc04",
|
||||||
|
"plot_bg": "#121316",
|
||||||
|
"plot_grid": "#2a2d33",
|
||||||
|
"arrow_canvas": "#121316",
|
||||||
|
"danger": "#ea4335",
|
||||||
|
"success": "#34a853"
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"window_bg": "#f5f6f8",
|
||||||
|
"panel_bg": "#ffffff",
|
||||||
|
"panel_border": "#d0d4dc",
|
||||||
|
"text_primary": "#1a1c20",
|
||||||
|
"text_secondary": "#5f6368",
|
||||||
|
"accent": "#536dce",
|
||||||
|
"accent_hover": "#3f56b5",
|
||||||
|
"button_bg": "#e8eaed",
|
||||||
|
"button_hover": "#d0d4dc",
|
||||||
|
"input_bg": "#ffffff",
|
||||||
|
"input_border": "#c0c4cc",
|
||||||
|
"menu_bg": "#ffffff",
|
||||||
|
"menu_hover": "#e8eaed",
|
||||||
|
"plot_fn": "#1a73e8",
|
||||||
|
"plot_fx": "#188038",
|
||||||
|
"plot_fy": "#f9ab00",
|
||||||
|
"plot_bg": "#ffffff",
|
||||||
|
"plot_grid": "#e0e3e8",
|
||||||
|
"arrow_canvas": "#eef0f3",
|
||||||
|
"danger": "#d93025",
|
||||||
|
"success": "#188038"
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user