diff --git a/README.md b/README.md index 791ec6e..c0d2f48 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,12 @@ python -m pytest On Windows the offscreen platform finds no fonts unless `QT_QPA_FONTDIR` points at `C:\Windows\Fonts`; both tools set it automatically. +`ui_check.py` also runs the palette rules (WCAG contrast, colour separation, +ramp monotonicity) that guard the brand tokens; see +[`apps/customer/docs/ui-palette.md`](apps/customer/docs/ui-palette.md) for the +colour system, its relationship to the UI design spec, and every deliberate +deviation from it. + ## Branding Window / EXE icon: `shared/resources/branding/logo.png` diff --git a/apps/studio/config/compound_eye.png b/apps/studio/config/compound_eye.png new file mode 100644 index 0000000..7ea4e12 Binary files /dev/null and b/apps/studio/config/compound_eye.png differ diff --git a/apps/studio/config/locales/en_US.json b/apps/studio/config/locales/en_US.json new file mode 100644 index 0000000..0411948 --- /dev/null +++ b/apps/studio/config/locales/en_US.json @@ -0,0 +1,149 @@ +{ + "app": { + "title": "Orisys Studio", + "about_title": "About", + "about_body": "Orisys customer desktop application\nVersion: {app_version}", + "scaffold_hint": "Scaffold shell — acquisition coming later" + }, + "brand": { + "name": "ORISYS", + "tagline": "DieDong Technology" + }, + "menu": { + "file": "File", + "open_video": "Open video…", + "reveal_config": "Reveal sensor config…", + "view": "View", + "theme": "Theme", + "theme_dark": "Dark", + "theme_light": "Light", + "language": "Language", + "lang_zh": "简体中文", + "lang_en": "English", + "calibration_panel": "Calibration panel", + "finger_3d": "Finger 3D view", + "show_fps": "Show FPS", + "plot_settings": "Plot settings…", + "rotate_arrows": "Rotate arrows 90°", + "run": "Run / Stop", + "preview": "Preview / Stop preview", + "reset": "Reset tracker", + "help": "Help", + "about": "About" + }, + "dialog": { + "plot_settings_title": "Plot settings", + "plot_window": "Time window", + "plot_window_tip": "Force plot time window (seconds)", + "plot_fn_max": "FN max", + "plot_fn_max_tip": "Normal force axis max (Newton mode)", + "plot_shear_max": "Shear max", + "plot_shear_max_tip": "Shear force axis ±max for FX/FY (Newton mode)", + "reset_defaults": "Reset to defaults", + "ok": "OK", + "cancel": "Cancel" + }, + "toolbar": { + "source": "Source", + "source_camera": "Camera", + "source_video": "Video file", + "search_cameras": "Search", + "cam_id": "Cam ID", + "browse_video": "Browse…", + "video_placeholder": "No video selected", + "preview": "Preview", + "preview_stop": "Stop preview", + "run": "Run", + "stop": "Stop", + "run_label": "▶ Run", + "stop_label": "■ Stop", + "run_unavailable": "Not available yet", + "run_disabled_video": "Select a video file first", + "run_disabled_sensor": "Run is not available for this sensor type", + "reset": "Reset", + "units": "Units", + "units_newton": "Newton", + "units_raw": "Raw" + }, + "units": { + "newton_short": "N", + "raw_short": "raw" + }, + "sensor": { + "type": "Sensor type", + "config": "Config", + "thumb_placeholder": "Product model" + }, + "panels": { + "arrow": "2D arrows", + "view_arrows_pure": "Arrows", + "view_arrows_stitched": "Arrows / Captured", + "view_depth": "Depth map", + "view_mode_tip": "Image display mode while running", + "arrow_empty": "No image yet\nPick a video or camera, then press Run", + "arrow_empty_tip": "The deformation arrow image appears here while running or previewing", + "plots": "Force Charts", + "cal": "Calibration", + "mesh3d": "3D mesh", + "mesh3d_empty": "Finger 3D view placeholder —\nOpen3D mesh coming later", + "mesh3d_empty_tip": "3D mesh preview for finger sensors" + }, + "chart": { + "fn": "FN", + "fx": "FX", + "fy": "FY", + "no_data": "—" + }, + "cal": { + "weight": "Weight (N)", + "duration": "Record duration", + "record": "Record", + "recording_stop": "Stop", + "record_disabled": "Press Run to start acquisition before recording samples", + "record_cancelled": "Recording cancelled", + "empty_hint": "No samples for this channel yet: run, pick a channel and press Record.", + "delete_row": "Delete row", + "clear_channel": "Clear channel", + "clear_all": "Clear all", + "export": "Export…", "sn": "SN", + "sn_placeholder": "Serial number (optional)", + "live_raw_label": "Live raw", + "live_raw": "Live raw FN={fn} FX={fx} FY={fy}", + "col_weight": "Weight (N)", + "col_raw": "Raw mean", + "col_n": "n", + "col_std": "σ", + "col_actions": "", + "recording": "Recording calibration samples…", + "record_ok": "Recorded weight={weight} raw={raw}", + "record_failed_few": "Recording failed — too few samples", + "export_ok": "Exported {path}", + "export_local": "Also wrote {path}", + "export_warnings": "Skipped: {detail}", + "export_failed": "Export failed: {error}" + }, + "status": { + "ready": "Ready", + "phase_idle": "Idle", + "phase_connecting": "Connecting", + "phase_running": "Running", + "phase_error": "Error", + "camera_none": "No cameras found", + "camera_found": "Found {count} camera(s): {names}", + "preview_started": "Preview started (camera {camera_id})", + "preview_stopped": "Preview stopped", + "preview_failed": "Preview failed: {error}", + "connecting": "Connecting…", + "run_started": "Running ({source})", + "run_stopped": "Stopped", + "run_failed": "Run failed: {error}", + "video_ended": "Video ended", + "camera_disconnected": "Camera disconnected", + "reset_ok": "Tracker reset", + "finger_run_unavailable": "Run is not available for this sensor type", + "video_required": "Select a video file first", + "config_missing": "Sensor config not found for {type}", + "units_raw_fallback": "Calibration unavailable — showing raw units", + "reveal_failed": "Could not reveal config: {error}" + } +} diff --git a/apps/studio/config/locales/zh_CN.json b/apps/studio/config/locales/zh_CN.json new file mode 100644 index 0000000..6e63f21 --- /dev/null +++ b/apps/studio/config/locales/zh_CN.json @@ -0,0 +1,150 @@ +{ + "app": { + "title": "Orisys Studio", + "about_title": "关于", + "about_body": "Orisys 客户端桌面应用\n版本: {app_version}", + "scaffold_hint": "脚手架界面 — 采集功能稍后加入" + }, + "brand": { + "name": "ORISYS", + "tagline": "叠动科技" + }, + "menu": { + "file": "文件", + "open_video": "打开视频…", + "reveal_config": "在资源管理器中显示配置…", + "view": "视图", + "theme": "主题", + "theme_dark": "深色", + "theme_light": "浅色", + "language": "语言", + "lang_zh": "简体中文", + "lang_en": "English", + "calibration_panel": "标定面板", + "finger_3d": "手指 3D 视图", + "show_fps": "显示 FPS", + "plot_settings": "绘图设置…", + "rotate_arrows": "旋转箭头图 90°", + "run": "运行 / 停止", + "preview": "预览 / 停止预览", + "reset": "重置追踪器", + "help": "帮助", + "about": "关于" + }, + "dialog": { + "plot_settings_title": "绘图设置", + "plot_window": "时间窗口", + "plot_window_tip": "力曲线时间窗口(秒)", + "plot_fn_max": "FN 上限", + "plot_fn_max_tip": "法向力纵轴上限(牛顿模式)", + "plot_shear_max": "剪切上限", + "plot_shear_max_tip": "剪切力 FX/FY 纵轴 ±上限(牛顿模式)", + "reset_defaults": "恢复默认", + "ok": "确定", + "cancel": "取消" + }, + "toolbar": { + "source": "来源", + "source_camera": "摄像机", + "source_video": "视频文件", + "search_cameras": "搜索", + "cam_id": "摄像机", + "browse_video": "浏览…", + "video_placeholder": "未选择视频", + "preview": "预览", + "preview_stop": "停止预览", + "run": "运行", + "stop": "停止", + "run_label": "▶ 运行", + "stop_label": "■ 停止", + "run_unavailable": "当前不可运行", + "run_disabled_video": "请先选择一个视频文件", + "run_disabled_sensor": "此传感器类型暂不支持运行", + "reset": "重置", + "units": "单位", + "units_newton": "牛顿", + "units_raw": "原始" + }, + "units": { + "newton_short": "N", + "raw_short": "raw" + }, + "sensor": { + "type": "传感器类型", + "config": "配置", + "thumb_placeholder": "产品型号" + }, + "panels": { + "arrow": "形变箭头", + "view_arrows_pure": "箭头", + "view_arrows_stitched": "箭头/采集图像", + "view_depth": "深度图", + "view_mode_tip": "运行时的图像显示模式", + "arrow_empty": "尚未采集图像\n请选择视频或摄像机后点击「运行」", + "arrow_empty_tip": "运行或预览后在此显示形变箭头图", + "plots": "力曲线图", + "cal": "标定", + "mesh3d": "三维网格", + "mesh3d_empty": "手指三维视图占位 —\nOpen3D 网格稍后加入", + "mesh3d_empty_tip": "手指传感器的三维网格预览" + }, + "chart": { + "fn": "FN", + "fx": "FX", + "fy": "FY", + "no_data": "—" + }, + "cal": { + "weight": "砝码重量 (N)", + "duration": "采集时长", + "record": "记录", + "recording_stop": "停止", + "record_disabled": "请先点击「运行」开始采集,再记录标定样本", + "record_cancelled": "已取消本次采集", + "empty_hint": "当前通道尚无样本:运行后选择通道并点击「记录」。", + "delete_row": "删除行", + "clear_channel": "清空通道", + "clear_all": "全部清空", + "export": "导出…", + "sn": "序列号", + "sn_placeholder": "序列号(可选)", + "live_raw_label": "实时原始值", + "live_raw": "实时原始值 FN={fn} FX={fx} FY={fy}", + "col_weight": "重量 (N)", + "col_raw": "原始均值", + "col_n": "n", + "col_std": "σ", + "col_actions": "", + "recording": "正在采集标定样本…", + "record_ok": "已记录 weight={weight} raw={raw}", + "record_failed_few": "采集失败 — 样本过少", + "export_ok": "已导出 {path}", + "export_local": "同时写入 {path}", + "export_warnings": "跳过: {detail}", + "export_failed": "导出失败: {error}" + }, + "status": { + "ready": "就绪", + "phase_idle": "空闲", + "phase_connecting": "连接中", + "phase_running": "运行中", + "phase_error": "出错", + "camera_none": "未发现摄像机", + "camera_found": "发现 {count} 个摄像机: {names}", + "preview_started": "预览已启动(摄像机 {camera_id})", + "preview_stopped": "预览已停止", + "preview_failed": "预览失败: {error}", + "connecting": "正在连接…", + "run_started": "运行中({source})", + "run_stopped": "已停止", + "run_failed": "启动失败: {error}", + "video_ended": "视频已结束", + "camera_disconnected": "摄像机已断开", + "reset_ok": "追踪器已重置", + "finger_run_unavailable": "此传感器类型暂不支持运行", + "video_required": "请先选择视频文件", + "config_missing": "未找到传感器配置: {type}", + "units_raw_fallback": "无标定数据 — 显示原始单位", + "reveal_failed": "无法打开配置位置: {error}" + } +} diff --git a/apps/studio/config/preferences.default.json b/apps/studio/config/preferences.default.json new file mode 100644 index 0000000..dbb5f9c --- /dev/null +++ b/apps/studio/config/preferences.default.json @@ -0,0 +1,21 @@ +{ + "language": "zh_CN", + "theme": "dark", + "window_geometry": null, + "window_default_size": [1440, 880], + "camera_id": "0", + "source_mode": "camera", + "video_path": "", + "show_cal_panel": false, + "show_finger_3d": true, + "show_fps": false, + "sensor_type": "box", + "config_name": "", + "units": "newton", + "plot_window_sec": 4.0, + "plot_fn_max": 20.0, + "plot_shear_max": 20.0, + "image_view_mode": "arrows_pure", + "arrow_rotation_deg": 90, + "splitter_sizes": [340, 480, 400] +} diff --git a/apps/studio/config/sensors/box/force_calibration.json b/apps/studio/config/sensors/box/force_calibration.json new file mode 100644 index 0000000..015fc07 --- /dev/null +++ b/apps/studio/config/sensors/box/force_calibration.json @@ -0,0 +1,30 @@ +{ + "schema_version": 1, + "unit": "N", + "channels": { + "normal": { + "method": "piecewise_linear", + "raw_key": "fnormal", + "extrapolate": true, + "clamp_output_min": 0.0, + "points": [ + [1000.0, 0.0], + [10000.0, 0.377], + [30000.0, 1.377], + [62000.0, 2.377] + ] + }, + "shear": { + "method": "piecewise_linear", + "input": "components", + "signed": true, + "extrapolate": true, + "clamp_output_min": 0.0, + "points": [ + [1000.0, 0.0], + [36000.0, 1.19], + [68000.0, 2.06] + ] + } + } +} diff --git a/apps/studio/config/sensors/box/sensor.json b/apps/studio/config/sensors/box/sensor.json new file mode 100644 index 0000000..af88413 --- /dev/null +++ b/apps/studio/config/sensors/box/sensor.json @@ -0,0 +1,46 @@ +{ + "name": "prototype", + "version": "1.0", + "camera": { + "resolution_raw": [1920, 1080], + "fps": 30, + "units": 15, + "units_used": 9, + "resolution_stitch": [365, 365], + "stitch_res_list": [365,263,123,63] + }, + "stitcher": { + "min_area_frac": 0.01, + "max_area_frac": 0.05, + "max_aspect_ratio": 50, + "index": [1,2,3,6,7,8,11,12,13], + "margin": 10, + "nominal_ov_pos": 100, + "n": [3,3], + "row_tol": 0.6, + "box_size": [250, 250], + "blend":"numba", + "blend_list": ["numpy", "numba","none"] + }, + "tracker": { + "adaptive": true, + "lower_threshold": 12.0, + "upper_threshold": 18.0, + "loop_closure_interval": 1, + "algorithm": "dis", + "dis_preset": "medium", + "dis_mode": 3, + "algorithm_list": ["raft", "dis"], + "hhd_downsample": 3 + }, + "contact": { + "min_contour_area": 3000, + "flow_threshold":5, + "div_threshold":0.05, + "expansion_threshold": 0.01, + "kernel_size": [5,5], + "denoise_sigma": 12 + } +} + + diff --git a/apps/studio/config/sensors/finger/assets/30fps-0703/README.md b/apps/studio/config/sensors/finger/assets/30fps-0703/README.md new file mode 100644 index 0000000..24a039e --- /dev/null +++ b/apps/studio/config/sensors/finger/assets/30fps-0703/README.md @@ -0,0 +1,9 @@ +Place the ORY-FINGER runtime asset bundle files in this directory before +building release wheels: + +- `mesh.npz` +- `camera_intrinsics.npz` +- `world_to_cam.npy` + +These binary assets are validated by the wheel build and are intentionally not +generated by the package build step. diff --git a/apps/studio/config/sensors/finger/assets/30fps-0703/camera_intrinsics.npz b/apps/studio/config/sensors/finger/assets/30fps-0703/camera_intrinsics.npz new file mode 100644 index 0000000..888b1eb Binary files /dev/null and b/apps/studio/config/sensors/finger/assets/30fps-0703/camera_intrinsics.npz differ diff --git a/apps/studio/config/sensors/finger/assets/30fps-0703/mesh.npz b/apps/studio/config/sensors/finger/assets/30fps-0703/mesh.npz new file mode 100644 index 0000000..4da6bb1 Binary files /dev/null and b/apps/studio/config/sensors/finger/assets/30fps-0703/mesh.npz differ diff --git a/apps/studio/config/sensors/finger/assets/30fps-0703/pixel_to_xyz.npz b/apps/studio/config/sensors/finger/assets/30fps-0703/pixel_to_xyz.npz new file mode 100644 index 0000000..15eee73 Binary files /dev/null and b/apps/studio/config/sensors/finger/assets/30fps-0703/pixel_to_xyz.npz differ diff --git a/apps/studio/config/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz b/apps/studio/config/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz new file mode 100644 index 0000000..995cee0 Binary files /dev/null and b/apps/studio/config/sensors/finger/assets/30fps-0703/unroll_map_uv_s0.4.npz differ diff --git a/apps/studio/config/sensors/finger/assets/30fps-0703/world_to_cam.npy b/apps/studio/config/sensors/finger/assets/30fps-0703/world_to_cam.npy new file mode 100644 index 0000000..3d6f897 Binary files /dev/null and b/apps/studio/config/sensors/finger/assets/30fps-0703/world_to_cam.npy differ diff --git a/apps/studio/config/sensors/finger/assets/visualization/README.md b/apps/studio/config/sensors/finger/assets/visualization/README.md new file mode 100644 index 0000000..fe08404 --- /dev/null +++ b/apps/studio/config/sensors/finger/assets/visualization/README.md @@ -0,0 +1,6 @@ +Place visualization camera assets in this directory before building release +wheels: + +- `world_to_cam.npy` + +The 3D dashboard uses this pose as the default view preset when available. diff --git a/apps/studio/config/sensors/finger/assets/visualization/camera_intrinsics.npz b/apps/studio/config/sensors/finger/assets/visualization/camera_intrinsics.npz new file mode 100644 index 0000000..1d211d5 Binary files /dev/null and b/apps/studio/config/sensors/finger/assets/visualization/camera_intrinsics.npz differ diff --git a/apps/studio/config/sensors/finger/assets/visualization/flatten_remap.npz b/apps/studio/config/sensors/finger/assets/visualization/flatten_remap.npz new file mode 100644 index 0000000..02ca287 Binary files /dev/null and b/apps/studio/config/sensors/finger/assets/visualization/flatten_remap.npz differ diff --git a/apps/studio/config/sensors/finger/assets/visualization/mesh.npz b/apps/studio/config/sensors/finger/assets/visualization/mesh.npz new file mode 100644 index 0000000..ad81542 Binary files /dev/null and b/apps/studio/config/sensors/finger/assets/visualization/mesh.npz differ diff --git a/apps/studio/config/sensors/finger/assets/visualization/pixel_to_xyz.npz b/apps/studio/config/sensors/finger/assets/visualization/pixel_to_xyz.npz new file mode 100644 index 0000000..4096205 Binary files /dev/null and b/apps/studio/config/sensors/finger/assets/visualization/pixel_to_xyz.npz differ diff --git a/apps/studio/config/sensors/finger/assets/visualization/world_to_cam.npy b/apps/studio/config/sensors/finger/assets/visualization/world_to_cam.npy new file mode 100644 index 0000000..2d5e411 Binary files /dev/null and b/apps/studio/config/sensors/finger/assets/visualization/world_to_cam.npy differ diff --git a/apps/studio/config/sensors/finger/manifest.json b/apps/studio/config/sensors/finger/manifest.json new file mode 100644 index 0000000..27f92c8 --- /dev/null +++ b/apps/studio/config/sensors/finger/manifest.json @@ -0,0 +1,6 @@ +{ + "name": "finger", + "sensor_json": "sensor.json", + "assets_dir": "assets/30fps-0703", + "viz_pose": "assets/visualization/world_to_cam.npy" +} diff --git a/apps/studio/config/sensors/finger/sensor.json b/apps/studio/config/sensors/finger/sensor.json new file mode 100644 index 0000000..ebdb709 --- /dev/null +++ b/apps/studio/config/sensors/finger/sensor.json @@ -0,0 +1,35 @@ +{ + "name": "dd02-ov", + "type": "single_camera", + "version": "1.0", + "camera": { + "resolution_raw": [1280, 720], + "fps": 30, + "resolution_stitch": [280, 300], + "source_scale": 0.4, + "old":[672, 728, 1,0.5], + "work": [504, 546,1], + "work2": [420, 450,0.8], + "work3": [350, 375,4,0.7], + "work4": [280, 300,4,0.4,0.2] + }, + "tracker": { + "adaptive": true, + "lower_threshold": 12.0, + "upper_threshold": 18.0, + "loop_closure_interval": 1, + "algorithm": "dis", + "dis_preset": "medium", + "dis_mode": 1, + "algorithm_list": ["raft", "dis"], + "hhd_downsample": 3 + }, + "contact": { + "min_contour_area": 3000, + "flow_threshold": 0.2, + "div_threshold": 0.022, + "expansion_threshold": 0.01, + "kernel_size": [5, 5], + "denoise_sigma": 12 + } +} diff --git a/apps/studio/config/sensors/mini/sensor.json b/apps/studio/config/sensors/mini/sensor.json new file mode 100644 index 0000000..af88413 --- /dev/null +++ b/apps/studio/config/sensors/mini/sensor.json @@ -0,0 +1,46 @@ +{ + "name": "prototype", + "version": "1.0", + "camera": { + "resolution_raw": [1920, 1080], + "fps": 30, + "units": 15, + "units_used": 9, + "resolution_stitch": [365, 365], + "stitch_res_list": [365,263,123,63] + }, + "stitcher": { + "min_area_frac": 0.01, + "max_area_frac": 0.05, + "max_aspect_ratio": 50, + "index": [1,2,3,6,7,8,11,12,13], + "margin": 10, + "nominal_ov_pos": 100, + "n": [3,3], + "row_tol": 0.6, + "box_size": [250, 250], + "blend":"numba", + "blend_list": ["numpy", "numba","none"] + }, + "tracker": { + "adaptive": true, + "lower_threshold": 12.0, + "upper_threshold": 18.0, + "loop_closure_interval": 1, + "algorithm": "dis", + "dis_preset": "medium", + "dis_mode": 3, + "algorithm_list": ["raft", "dis"], + "hhd_downsample": 3 + }, + "contact": { + "min_contour_area": 3000, + "flow_threshold":5, + "div_threshold":0.05, + "expansion_threshold": 0.01, + "kernel_size": [5,5], + "denoise_sigma": 12 + } +} + + diff --git a/apps/studio/docs/ui-palette.md b/apps/studio/docs/ui-palette.md new file mode 100644 index 0000000..e2d64e7 --- /dev/null +++ b/apps/studio/docs/ui-palette.md @@ -0,0 +1,161 @@ +# Orisys Customer 配色规范 + +本文件记录客户端的品牌配色体系、它与 UI 设计稿的关系,以及每一处有意偏离设计稿 +的理由。所有色值都存放在主题令牌文件里,控件代码中**不允许出现十六进制颜色**: + +``` +orisys-app/shared/src/orisys_app_shared/theme/colors_dark.json +orisys-app/shared/src/orisys_app_shared/theme/colors_light.json +``` + +规则由 `apps/customer/tools/ui_check.py` 的 `audit_palette()` 机械校验,pytest 套件 +(`tests/test_ui_smoke.py::test_palette_rules_hold`)复用它,因此只有一个真值来源。 + +--- + +## 1. 设计稿色号 → 令牌映射 + +| 设计稿 | 角色 | 落地令牌 | +|---|---|---| +| `#003893` | 品牌主色 / 底图色 | `accent`、`plot_fn`(浅色);`arrow_canvas` 使用了更深的 logo 藏青 | +| `#4D62BE` | 交互蓝 | `accent_hover`(浅色)、`accent`(深色)、`focus_ring`(浅色) | +| `#12315D` | logo 实际藏青(见 §3.1) | `brand_bar_bg`、`arrow_canvas`(浅色) | +| `#FA02F3` | 洋红 | `flow_ramp` 第 3 档(原值保留) | +| `#FE5853` | 珊瑚 | `flow_ramp` 第 4 档(提亮为 `#FF8A6B`,见 §3.2);`danger` 家族取自同一色相 | +| `#D8FF00` | 柠檬绿 | `flow_ramp` 第 5 档(原值保留)、`plot_fy`(深色) | +| `#D5D5D5` | 应用画布 | **未采用**,改为 `#E1E6EF`(见 §3.3) | + +--- + +## 2. 实测对比度(WCAG) + +对比度用标准相对亮度公式实算(`tools/ui_check.py` 中的 `contrast_ratio`)。 +正文门槛 4.5:1,图形/控件/状态指示门槛 3.0:1。 + +### 2.1 设计稿本身的实测结果 + +| 组合 | 比值 | 判定 | +|---|---|---| +| 白字 on `#003893` | 10.57 | AAA ✅ | +| 白字 on `#4D62BE` | 5.52 | AA ✅ | +| `#4D62BE` on `#D5D5D5` | 3.76 | 图形达标,12–13px 文字**不达标** ⚠️ | +| `#D5D5D5` vs 白卡 | 1.47 | 卡片层次偏弱 ⚠️ | +| `#D8FF00` on `#D5D5D5` | 1.27 | 不可用 ❌ | +| `#D8FF00` on `#003893` | 9.17 | 极佳 ✅ | +| `#FA02F3` on `#003893` | 3.20 | 达标 ✅ | +| `#FE5853` on `#003893` | 3.39 | 达标 ✅ | +| **`#FA02F3` vs `#FE5853`** | **1.06** | **色带中段的亮度平台 ❌** | +| 白字 on `#FE5853` | 3.12 | 珊瑚色不能承载白字 ❌ | + +设计稿的色相选择是可靠的;三个霓虹色的**相对亮度**为 0.268 → 0.287 → 0.861, +色相沿 302° → 2° → 69° 单调前进 127°,作为"位移幅值 → 颜色"的顺序色带是正确用法。 +问题在于中段:洋红与珊瑚亮度几乎相同(1.06:1),只靠 60° 色相差区分,在灰度 +打印与色觉障碍下会糊成一片。 + +### 2.2 落地后 + +浅色主题(节选):白字 on `accent` 10.57,白字 on `accent_hover` 5.52, +`plot_fn/fx/fy` 对 `plot_bg` 分别 10.11 / 6.00 / 4.89, +`brand_bar_fg` on `brand_bar_bg` 12.94,`input_border` 对四种底均 ≥3.08。 + +深色主题(节选):白字 on `accent` 5.52,`accent` 对 `panel_bg` 3.09(贴线但仍达标), +`plot_fn/fx/fy` 分别 7.61 / 6.16 / 15.73,`focus_ring` 对 `input_bg` 6.51。 + +三通道可分辨性(色相差 / 相对亮度比): + +| 主题 | FN vs FX | FN vs FY | FX vs FY | +|---|---|---|---| +| 浅色 | 97.0° / 1.68 | 135.5° / 2.07 | 127.5° / 1.23 | +| 深色 | 96.7° / 1.24 | 154.7° / 2.07 | 108.7° / 2.55 | + +规则是"色相差 ≥90° **或** 相对亮度比 ≥1.2",两主题均满足。颜色在任何情况下都不是 +唯一编码:每个曲线有文字标签,每张数值卡有同色通道徽标。 + +--- + +## 3. 对设计稿的四处有意偏离 + +### 3.1 品牌藏青用 logo 实际取色 `#12315D` + +`logo.png` 与 `logo_topbar.png` 采样后字形的实际颜色都是 `#12315D` +(H215 S80.6% V36.5%),而设计稿写的是 `#003893`(H217 但 S100% V57.6%)。 +两者同色相家族,但后者明显更亮更艳。顶栏品牌条与浅色主题的箭头画布采用 logo +实际色,以保证"标识色"与"界面色"一致。 + +### 3.2 色带第 4 档由 `#FE5853` 提亮为 `#FF8A6B` + +见 §2.1:保留设计稿珊瑚色会在色带中段留下 1.06:1 的亮度平台。提亮后色带相对亮度 +为 **0.031 → 0.128 → 0.268 → 0.405 → 0.861**,严格单调。 +第 3 档洋红与第 5 档柠檬绿**保留设计稿原值不动**。 + +### 3.3 画布 `#D5D5D5` → `#E1E6EF` + +`#D5D5D5` 是无彩色(H=0 S=0% L=84%)。现代浅色画布普遍在 96–98% 亮度,目的是让 +白卡"浮起来";而在 `#D5D5D5` 上白卡只有 1.47:1,且 Qt 的 QSS 不支持 `box-shadow`, +卡片只能靠 1px 描边分离,观感会发平、发旧。改为品牌微蓝灰后: + +- 保留设计稿"灰底 + 白卡"的角色分工; +- `#4D62BE` 在画布上的对比度从 3.76 升到 4.41。 + +### 3.4 补齐语义色与深色主题 + +设计稿没有 success/warning/error/info,也只有浅色版;本应用默认主题是深色。 +同时原深色主题的语义色是一整套 Google Material 色 +(`#8ab4f8` / `#34a853` / `#fbbc04` / `#ea4335` / `#1a73e8`),对一个测量仪器 +产品属于即时可识别的品牌冲突,已全部替换为品牌衍生色。 + +`danger` 在深色主题被拆成两个令牌:`danger`(亮,用于卡片上的红字与状态指示) +与 `danger_fill`(暗,用于承载白字的危险按钮)—— 一个色值无法同时满足两个方向。 + +--- + +## 4. 品牌化箭头色带 + +设计稿的霓虹三色是**一张 colormap**(按位移幅值着色),不是三个分类色。因此它落在 +箭头可视化上,而不是 UI 控件上: + +``` +flow_ramp = 画布色, #6E4AD0, #FA02F3, #FF8A6B, #D8FF00 +``` + +- 第 1 档等于画布色,这样**零位移区域自然融入背景**; +- 由 `orisys_app_shared.acquisition.arrows.flow_colormap(theme)` 生成 256 档 + BGR 查找表,经 `draw_arrows(colormap=...)` 注入 SDK(**SDK 无需改动**); +- LUT 缓存的键是 ramp 字符串本身,因此切换主题后立刻生效; +- 非法 ramp 永不抛异常,回退到内置默认色带。 + +箭头画布在两个主题里都是品牌深蓝(浅 `#12315D` / 深 `#0B1830`),这也是霓虹色带 +唯一可用的背景 —— 色带第 3 档起对画布均 ≥3.0:1。**绘图区(`plot_bg`)保持浅色**, +所以浅色主题下霓虹只出现在箭头画布上,力曲线使用浅色安全的品牌三色。 + +--- + +## 5. 有记录在案的豁免 + +以下项目**不**被 `audit_palette()` 检查,理由是它们属于装饰而非状态指示: + +| 项 | 现状 | 理由 | +|---|---|---| +| 装饰性 1px 容器描边 | `card_border` / `panel_border` / `plot_grid` / `separator`(1.16–1.45:1) | WCAG 1.4.11 针对交互控件的边界与状态指示;容器轮廓属于装饰 | +| 禁用态文字 | 深色 `text_muted` on `button_bg` = 4.07 | WCAG 1.4.3 明确豁免禁用控件 | + +输入框的静止描边**不在豁免之列** —— 它被强制要求对 `input_bg`/`panel_bg`/`card_bg`/`toolbar_bg` +四种底均 ≥3.0:1(实测 3.08–4.09),这与 Material 3 描边式输入框的做法一致。焦点态同样由 +`focus_ring` 保证 ≥3.0:1。 + +深色主题的 `accent` 对 `panel_bg` 为 3.09:1(设计稿原值 `#4D62BE`),贴线但达标; +若后续视觉上偏软,可单点提升为 `#5468C6`(白字仍为 5.03)。 + +--- + +## 6. 复核方式 + +```powershell +$py = "D:\orisys_ws\orisys-app\.venv-build\Scripts\python.exe" +$env:PYTHONPATH = "D:\orisys_ws\orisys-app\shared\src;D:\orisys_ws\orisys-app\apps\customer\src" +cd D:\orisys_ws\orisys-app\apps\customer + +& $py tools/ui_check.py # 含 palette 规则段落 +& $py tools/ui_snapshot.py --theme dark --lang zh_CN --demo --out outputs/_snap_dark.png +& $py tools/ui_snapshot.py --theme light --lang en_US --sensor finger --panels cal,3d --size 1600x900 --demo --out outputs/_snap_light.png +``` diff --git a/apps/studio/pyproject.toml b/apps/studio/pyproject.toml new file mode 100644 index 0000000..d4f4375 --- /dev/null +++ b/apps/studio/pyproject.toml @@ -0,0 +1,26 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "orisys-customer" +version = "0.1.0" +description = "Orisys customer desktop app" +requires-python = ">=3.10" +authors = [{ name = "Orisys-Tech" }] +dependencies = [ + "PyQt5>=5.15", + "orisys-app-shared>=0.1.0", + "orisys>=0.5", + "scipy>=1.10.1", + "pyqtgraph>=0.13", +] + +[project.scripts] +orisys-customer = "orisys_customer.main:main" + +[tool.setuptools] +package-dir = { "" = "src" } + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/apps/studio/run.py b/apps/studio/run.py new file mode 100644 index 0000000..a604305 --- /dev/null +++ b/apps/studio/run.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Launch the Orisys customer app shell.""" + +from __future__ import annotations + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +SRC = ROOT / "src" +SHARED_SRC = ROOT.parents[1] / "shared" / "src" +for path in (SRC, SHARED_SRC): + if path.is_dir() and str(path) not in sys.path: + sys.path.insert(0, str(path)) + +# Ensure path helpers resolve to this app root when launched via run.py +import os + +os.chdir(ROOT) + +from orisys_customer.main import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/studio/src/orisys-studio/__init__.py b/apps/studio/src/orisys-studio/__init__.py new file mode 100644 index 0000000..304e8af --- /dev/null +++ b/apps/studio/src/orisys-studio/__init__.py @@ -0,0 +1,4 @@ +"""Orisys customer desktop application.""" + +__version__ = "0.1.0" +REQUIRES_SHARED_API = "0.1" diff --git a/apps/studio/src/orisys-studio/app_window.py b/apps/studio/src/orisys-studio/app_window.py new file mode 100644 index 0000000..a0a01f0 --- /dev/null +++ b/apps/studio/src/orisys-studio/app_window.py @@ -0,0 +1,1002 @@ +"""Main window shell for the customer app.""" + +from __future__ import annotations + +import logging +import subprocess +import sys +from pathlib import Path +from typing import Any + +import numpy as np +from PyQt5.QtCore import Qt, QTimer +from PyQt5.QtGui import QIcon, QImage +from PyQt5.QtWidgets import ( + QAction, + QActionGroup, + QApplication, + QFileDialog, + QFrame, + QHBoxLayout, + QLabel, + QMainWindow, + QMessageBox, + QSplitter, + QStatusBar, + QVBoxLayout, + QWidget, +) + +from orisys_app_shared.force import select_display_forces +from orisys_app_shared.i18n import set_locale, t +from orisys_app_shared.logging import report_error +from orisys_app_shared.paths import logo_path, sensor_json_path +from orisys_app_shared.prefs import save_preferences +from orisys_app_shared.theme import build_qss, clear_theme_cache, color + +from orisys_customer.ui.about_dialog import AboutDialog +from orisys_customer.ui.arrow_panel import ArrowPanel +from orisys_customer.ui.brand_bar import BrandBar +from orisys_customer.ui.cal_panel import CalPanel +from orisys_customer.ui.force_plots import ForcePlotsPanel +from orisys_customer.ui.mesh3d_panel import Mesh3dPanel +from orisys_customer.ui.plot_settings_dialog import ( + DEFAULT_PLOT_FN_MAX, + DEFAULT_PLOT_SHEAR_MAX, + DEFAULT_PLOT_WINDOW_SEC, + PlotSettingsDialog, +) +from orisys_customer.ui.sensor_picker import SensorPickerWidget +from orisys_customer.ui.toolbar import ToolbarWidget + +logger = logging.getLogger(__name__) + +RUNNABLE_TYPES = frozenset({"box", "mini", "finger"}) +ARROW_ROTATE_TYPES = frozenset({"box", "mini"}) +DEFAULT_SPLITTER_SIZES = (340, 480, 400) +MIN_PANEL_WIDTHS = { + "mesh3d": 240, + "arrow": 300, + "plots": 340, + "cal": 300, +} +SESSION_PHASES = ("idle", "connecting", "running", "error") + + +def _bgr_to_qimage(frame: np.ndarray) -> QImage: + rgb = frame[:, :, ::-1].copy() + h, w, ch = rgb.shape + bytes_per_line = ch * w + return QImage(rgb.data, w, h, bytes_per_line, QImage.Format_RGB888).copy() + + +def _reveal_in_explorer(path: Path) -> None: + path = path.resolve() + if sys.platform == "win32": + subprocess.run(["explorer", "/select,", str(path)], check=False) + return + if sys.platform == "darwin": + subprocess.run(["open", "-R", str(path)], check=False) + return + folder = path.parent if path.is_file() else path + subprocess.run(["xdg-open", str(folder)], check=False) + + +class CustomerMainWindow(QMainWindow): + def __init__(self, prefs: dict[str, Any] | None = None) -> None: + super().__init__() + self.prefs: dict[str, Any] = dict(prefs or {}) + self._theme = str(self.prefs.get("theme") or "dark") + self._language = str(self.prefs.get("language") or "zh_CN") + self._show_cal = bool(self.prefs.get("show_cal_panel", False)) + self._show_finger_3d = bool(self.prefs.get("show_finger_3d", True)) + self._show_fps = bool(self.prefs.get("show_fps", False)) + self._session_phase = "idle" + self._running = False + self._preview = None + self._preview_timer = QTimer(self) + self._preview_timer.setInterval(33) + self._preview_timer.timeout.connect(self._on_preview_tick) + self._run_timer = QTimer(self) + self._run_timer.setInterval(16) + self._run_timer.timeout.connect(self._on_run_tick) + self._box_worker = None + self._finger_worker = None + self._active_worker = None + self._active_sensor_type = "box" + self._last_session_error = "" + self._last_plot_packet_key: tuple[int, float] | None = None + self._last_rendered_view_mode: str | None = None + self._run_source_label = "" + + icon = QIcon(str(logo_path())) + if not icon.isNull(): + self.setWindowIcon(icon) + + central = QWidget() + self.setCentralWidget(central) + root = QVBoxLayout(central) + root.setContentsMargins(10, 10, 10, 10) + root.setSpacing(8) + + self.brand_bar = BrandBar(theme=self._theme) + root.addWidget(self.brand_bar) + + top = QHBoxLayout() + top.setSpacing(10) + self.toolbar = ToolbarWidget() + self.sensor_picker = SensorPickerWidget() + sensor_card = QFrame() + sensor_card.setObjectName("contentPanel") + sensor_card_layout = QVBoxLayout(sensor_card) + sensor_card_layout.setContentsMargins(0, 0, 0, 0) + sensor_card_layout.setSpacing(0) + sensor_card_layout.addWidget(self.sensor_picker) + top.addWidget(self.toolbar, 2) + top.addWidget(sensor_card) + root.addLayout(top) + + self.content_splitter = QSplitter(Qt.Horizontal) + self.mesh3d_panel = Mesh3dPanel() + self.arrow_panel = ArrowPanel() + self.force_plots = ForcePlotsPanel(theme=self._theme) + self.cal_panel = CalPanel(theme=self._theme) + self.content_splitter.addWidget(self.mesh3d_panel) + self.content_splitter.addWidget(self.arrow_panel) + self.content_splitter.addWidget(self.force_plots) + self.content_splitter.addWidget(self.cal_panel) + self.content_splitter.setChildrenCollapsible(False) + root.addWidget(self.content_splitter, 1) + + self.setStatusBar(QStatusBar()) + self.status_chip = QLabel() + self.status_chip.setObjectName("statusChip") + self.statusBar().addPermanentWidget(self.status_chip) + self.cal_panel.status_message.connect(self.statusBar().showMessage) + self.cal_panel.calibration_exported.connect(self._on_calibration_exported) + self._set_phase("idle") + self._build_menus() + self._wire_toolbar() + self._wire_sensor_picker() + self._restore_prefs_into_ui() + self._apply_panel_visibility() + self._restore_splitter_sizes() + self._update_run_enabled() + self.cal_panel.set_meta_context( + sensor_type=self.sensor_picker.sensor_type(), + config_ref=self._resolve_config_ref(), + ) + self.retranslate() + self._restore_geometry() + self._apply_default_size() + + @property + def _preview_open(self) -> bool: + return self._preview is not None and bool(self._preview.is_open) + + def _get_preview(self): + if self._preview is None: + from orisys_app_shared.camera import RawPreviewSession + + self._preview = RawPreviewSession() + return self._preview + + def _ensure_workers(self) -> None: + if self._box_worker is not None and self._finger_worker is not None: + return + from orisys_app_shared.acquisition.finger_worker import FingerAcquisitionWorker + from orisys_app_shared.acquisition.sdk_worker import SdkAcquisitionWorker + + if self._box_worker is None: + self._box_worker = SdkAcquisitionWorker( + on_error=self._on_worker_error, + on_status=self._on_worker_status, + ) + if self._finger_worker is None: + self._finger_worker = FingerAcquisitionWorker( + on_error=self._on_worker_error, + on_status=self._on_worker_status, + ) + + def _wire_toolbar(self) -> None: + self.toolbar.search_clicked.connect(self._on_search_cameras) + self.toolbar.preview_toggled.connect(self._on_preview_toggled) + self.toolbar.camera_id_changed.connect(self._on_camera_id_changed) + self.toolbar.source_mode_changed.connect(self._on_source_mode_changed) + self.toolbar.browse_video_clicked.connect(self._on_browse_video) + self.toolbar.run_toggled.connect(self._on_run_toggled) + self.toolbar.reset_clicked.connect(self._on_reset) + self.toolbar.units_changed.connect(self._on_units_changed) + + def _wire_sensor_picker(self) -> None: + self.sensor_picker.sensor_type_changed.connect(self._on_sensor_type_changed) + self.sensor_picker.config_changed.connect(self._on_config_changed) + self.arrow_panel.view_mode_changed.connect(self._on_image_view_mode_changed) + + def _restore_prefs_into_ui(self) -> None: + raw_id = str(self.prefs.get("camera_id") or "0").strip() + try: + cam_id = int(raw_id) + except ValueError: + cam_id = 0 + self.toolbar.set_camera_id(cam_id) + self.toolbar.set_source_mode(str(self.prefs.get("source_mode") or "camera")) + self.toolbar.set_video_path(str(self.prefs.get("video_path") or "")) + self.toolbar.set_units(str(self.prefs.get("units") or "newton")) + self.arrow_panel.set_view_mode(str(self.prefs.get("image_view_mode") or "arrows_pure")) + self.arrow_panel.set_fps_visible(self._show_fps) + self.arrow_panel.set_rotation_deg(self.prefs.get("arrow_rotation_deg", 90)) + self._apply_plot_prefs_to_panel() + + sensor_type = str(self.prefs.get("sensor_type") or "box") + config_name = str(self.prefs.get("config_name") or "") + self.sensor_picker.set_sensor_type(sensor_type) + if config_name: + self.sensor_picker.set_config_name(config_name) + else: + self.prefs["config_name"] = self.sensor_picker.config_name() + self._apply_arrow_rotation_for_sensor() + + def _apply_default_size(self) -> None: + """Give the first launch a usable size for the multi-panel layout.""" + default = self.prefs.get("window_default_size") or [1440, 880] + try: + width, height = int(default[0]), int(default[1]) + except (TypeError, ValueError, IndexError): + width, height = 1440, 880 + screen = QApplication.primaryScreen() + if screen is not None: + avail = screen.availableGeometry() + width = min(width, max(1024, avail.width() - 80)) + height = min(height, max(680, avail.height() - 80)) + if not self.prefs.get("window_geometry"): + self.resize(width, height) + + def _restore_splitter_sizes(self) -> None: + stored = self.prefs.get("splitter_sizes") + if isinstance(stored, list) and len(stored) == self.content_splitter.count(): + try: + self.content_splitter.setSizes([int(v) for v in stored]) + return + except (TypeError, ValueError): + pass + sizes = list(DEFAULT_SPLITTER_SIZES) + if not self._show_finger_3d or self.sensor_picker.sensor_type() != "finger": + sizes[0] = 0 + self.content_splitter.setSizes(sizes) + + def _build_menus(self) -> None: + menu_bar = self.menuBar() + + self.file_menu = menu_bar.addMenu("") + self.act_open_video = QAction(self) + self.act_open_video.triggered.connect(self._on_browse_video) + self.file_menu.addAction(self.act_open_video) + self.act_reveal_config = QAction(self) + self.act_reveal_config.triggered.connect(self._on_reveal_config) + self.file_menu.addAction(self.act_reveal_config) + + self.view_menu = menu_bar.addMenu("") + self.act_cal_panel = QAction(self) + self.act_cal_panel.setCheckable(True) + self.act_cal_panel.setChecked(self._show_cal) + self.act_cal_panel.toggled.connect(self._on_cal_panel_toggled) + self.view_menu.addAction(self.act_cal_panel) + + self.act_finger_3d = QAction(self) + self.act_finger_3d.setCheckable(True) + self.act_finger_3d.setChecked(self._show_finger_3d) + self.act_finger_3d.toggled.connect(self._on_finger_3d_toggled) + self.view_menu.addAction(self.act_finger_3d) + + self.act_show_fps = QAction(self) + self.act_show_fps.setCheckable(True) + self.act_show_fps.setChecked(self._show_fps) + self.act_show_fps.toggled.connect(self._on_show_fps_toggled) + self.view_menu.addAction(self.act_show_fps) + + self.act_plot_settings = QAction(self) + self.act_plot_settings.triggered.connect(self._on_plot_settings) + self.view_menu.addAction(self.act_plot_settings) + + self.act_rotate_arrows = QAction(self) + self.act_rotate_arrows.triggered.connect(self._on_rotate_arrows) + self.view_menu.addAction(self.act_rotate_arrows) + self.view_menu.addSeparator() + + self.act_run = QAction(self) + self.act_run.setShortcut("F5") + self.act_run.triggered.connect(self._on_run_shortcut) + self.view_menu.addAction(self.act_run) + + self.act_preview = QAction(self) + self.act_preview.setShortcut("F6") + self.act_preview.triggered.connect(self._on_preview_shortcut) + self.view_menu.addAction(self.act_preview) + + self.act_reset = QAction(self) + self.act_reset.setShortcut("Ctrl+R") + self.act_reset.triggered.connect(self._on_reset) + self.view_menu.addAction(self.act_reset) + self.view_menu.addSeparator() + + self.theme_menu = self.view_menu.addMenu("") + self.theme_group = QActionGroup(self) + self.theme_group.setExclusive(True) + self.act_theme_dark = QAction(self) + self.act_theme_dark.setCheckable(True) + self.act_theme_light = QAction(self) + self.act_theme_light.setCheckable(True) + self.theme_group.addAction(self.act_theme_dark) + self.theme_group.addAction(self.act_theme_light) + self.theme_menu.addAction(self.act_theme_dark) + self.theme_menu.addAction(self.act_theme_light) + self.act_theme_dark.triggered.connect(lambda: self._set_theme("dark")) + self.act_theme_light.triggered.connect(lambda: self._set_theme("light")) + + self.lang_menu = self.view_menu.addMenu("") + self.lang_group = QActionGroup(self) + self.lang_group.setExclusive(True) + self.act_lang_zh = QAction(self) + self.act_lang_zh.setCheckable(True) + self.act_lang_en = QAction(self) + self.act_lang_en.setCheckable(True) + self.lang_group.addAction(self.act_lang_zh) + self.lang_group.addAction(self.act_lang_en) + self.lang_menu.addAction(self.act_lang_zh) + self.lang_menu.addAction(self.act_lang_en) + self.act_lang_zh.triggered.connect(lambda: self._set_language("zh_CN")) + self.act_lang_en.triggered.connect(lambda: self._set_language("en_US")) + + self.help_menu = menu_bar.addMenu("") + self.act_about = QAction(self) + self.act_about.triggered.connect(self._show_about) + self.help_menu.addAction(self.act_about) + + self._sync_menu_checks() + + def _sync_menu_checks(self) -> None: + self.act_theme_dark.setChecked(self._theme != "light") + self.act_theme_light.setChecked(self._theme == "light") + self.act_lang_zh.setChecked(self._language.startswith("zh")) + self.act_lang_en.setChecked(not self._language.startswith("zh")) + self.act_cal_panel.blockSignals(True) + self.act_cal_panel.setChecked(self._show_cal) + self.act_cal_panel.blockSignals(False) + is_finger = self.sensor_picker.sensor_type() == "finger" + self.act_finger_3d.setEnabled(is_finger) + self.act_finger_3d.blockSignals(True) + self.act_finger_3d.setChecked(bool(self._show_finger_3d) if is_finger else False) + self.act_finger_3d.blockSignals(False) + self._apply_arrow_rotation_for_sensor() + + def _apply_panel_visibility(self) -> None: + is_finger = self.sensor_picker.sensor_type() == "finger" + show_3d = is_finger and bool(self._show_finger_3d) + self.mesh3d_panel.setVisible(show_3d) + self.cal_panel.setVisible(self._show_cal) + self.mesh3d_panel.setMinimumWidth(MIN_PANEL_WIDTHS["mesh3d"]) + self.arrow_panel.setMinimumWidth(MIN_PANEL_WIDTHS["arrow"]) + self.force_plots.setMinimumWidth(MIN_PANEL_WIDTHS["plots"]) + self.cal_panel.setMinimumWidth(MIN_PANEL_WIDTHS["cal"]) + self.act_finger_3d.setEnabled(is_finger) + self.act_finger_3d.blockSignals(True) + self.act_finger_3d.setChecked(show_3d) + self.act_finger_3d.blockSignals(False) + for i in range(self.content_splitter.count()): + widget = self.content_splitter.widget(i) + if widget is self.mesh3d_panel: + self.content_splitter.setStretchFactor(i, 2 if show_3d else 0) + elif widget is self.arrow_panel: + self.content_splitter.setStretchFactor(i, 3) + elif widget is self.force_plots: + self.content_splitter.setStretchFactor(i, 4) + elif widget is self.cal_panel: + self.content_splitter.setStretchFactor(i, 3 if self._show_cal else 0) + + def _update_run_enabled(self) -> None: + sensor_ok = self.sensor_picker.sensor_type() in RUNNABLE_TYPES + source_ok = True + reason = "" + if not sensor_ok: + reason = t("toolbar.run_disabled_sensor") + elif self.toolbar.source_mode() == "video" and not self.toolbar.video_path(): + source_ok = False + reason = t("toolbar.run_disabled_video") + self.toolbar.set_run_tooltip_reason(reason) + self.toolbar.set_run_enabled(self._running or (sensor_ok and source_ok)) + if not self._running: + self.toolbar.refresh_labels() + self._sync_shortcut_actions() + + def retranslate(self) -> None: + self.setWindowTitle(t("app.title")) + self.file_menu.setTitle(t("menu.file")) + self.act_open_video.setText(t("menu.open_video")) + self.act_reveal_config.setText(t("menu.reveal_config")) + self.view_menu.setTitle(t("menu.view")) + self.act_cal_panel.setText(t("menu.calibration_panel")) + self.act_finger_3d.setText(t("menu.finger_3d")) + self.act_show_fps.setText(t("menu.show_fps")) + self.act_plot_settings.setText(t("menu.plot_settings")) + self.act_rotate_arrows.setText(t("menu.rotate_arrows")) + self.act_run.setText(t("menu.run")) + self.act_preview.setText(t("menu.preview")) + self.act_reset.setText(t("menu.reset")) + self.theme_menu.setTitle(t("menu.theme")) + self.act_theme_dark.setText(t("menu.theme_dark")) + self.act_theme_light.setText(t("menu.theme_light")) + self.lang_menu.setTitle(t("menu.language")) + self.act_lang_zh.setText(t("menu.lang_zh")) + self.act_lang_en.setText(t("menu.lang_en")) + self.help_menu.setTitle(t("menu.help")) + self.act_about.setText(t("menu.about")) + self.toolbar.retranslate() + self.sensor_picker.retranslate() + self.mesh3d_panel.retranslate() + self.arrow_panel.retranslate() + self.force_plots.retranslate() + self.cal_panel.retranslate() + self.brand_bar.retranslate() + self._refresh_status_chip() + if not self._preview_open and not self._running: + self.statusBar().showMessage(t("status.ready")) + self._update_run_enabled() + + def _set_phase(self, phase: str) -> None: + """Track the acquisition phase and reflect it in the status chip.""" + self._session_phase = phase if phase in SESSION_PHASES else "idle" + self._refresh_status_chip() + + def _refresh_status_chip(self) -> None: + chip = getattr(self, "status_chip", None) + if chip is None: + return + phase = self._session_phase if self._session_phase in SESSION_PHASES else "idle" + token = f"status_{phase}" + chip.setText(t(f"status.phase_{phase}")) + chip.setStyleSheet( + f"QLabel#statusChip {{ color: {color(self._theme, token)};" + " background: transparent; font-weight: 600; padding: 0 10px 0 4px; }" + ) + + def _set_theme(self, theme: str) -> None: + self._theme = theme + self.prefs["theme"] = theme + clear_theme_cache() + app = QApplication.instance() + if app is not None: + app.setStyleSheet(build_qss(theme)) + self.force_plots.set_theme(theme) + self.cal_panel.set_theme(theme) + self.brand_bar.set_theme(theme) + self._refresh_status_chip() + self._sync_menu_checks() + self._persist_prefs() + + def _set_language(self, language: str) -> None: + self._language = language + self.prefs["language"] = language + set_locale(language) + self.retranslate() + self._sync_menu_checks() + self._persist_prefs() + + def _show_about(self) -> None: + AboutDialog(self).exec_() + + def _on_camera_id_changed(self, value: int) -> None: + self.prefs["camera_id"] = str(int(value)) + self._persist_prefs() + + def _on_source_mode_changed(self, mode: str) -> None: + self.prefs["source_mode"] = mode + self._persist_prefs() + self._update_run_enabled() + + def _on_units_changed(self, units: str) -> None: + self.prefs["units"] = units + self.force_plots.set_fixed_newton(str(units) == "newton") + self._persist_prefs() + + def _on_image_view_mode_changed(self, mode: str) -> None: + self.prefs["image_view_mode"] = str(mode) + self._last_rendered_view_mode = None + self._persist_prefs() + if self._running: + if self._active_worker is not None: + self._render_run_image(self._active_worker.latest()) + + def _on_plot_settings(self) -> None: + dialog = PlotSettingsDialog( + self, + window_sec=float(self.prefs.get("plot_window_sec") or DEFAULT_PLOT_WINDOW_SEC), + fn_max=float(self.prefs.get("plot_fn_max") or DEFAULT_PLOT_FN_MAX), + shear_max=float(self.prefs.get("plot_shear_max") or DEFAULT_PLOT_SHEAR_MAX), + units=self.toolbar.units(), + ) + if dialog.exec_() != PlotSettingsDialog.Accepted: + return + window_sec, fn_max, shear_max = dialog.values() + self.prefs["plot_window_sec"] = window_sec + self.prefs["plot_fn_max"] = fn_max + self.prefs["plot_shear_max"] = shear_max + self.force_plots.set_window_sec(window_sec) + self.force_plots.set_axis_limits(fn_max, shear_max) + self._last_plot_packet_key = None + self._persist_prefs() + + def _apply_plot_prefs_to_panel(self) -> None: + self.force_plots.set_window_sec( + float(self.prefs.get("plot_window_sec") or DEFAULT_PLOT_WINDOW_SEC) + ) + self.force_plots.set_axis_limits( + float(self.prefs.get("plot_fn_max") or DEFAULT_PLOT_FN_MAX), + float(self.prefs.get("plot_shear_max") or DEFAULT_PLOT_SHEAR_MAX), + ) + self.force_plots.set_fixed_newton(self.toolbar.units() == "newton") + + def _on_cal_panel_toggled(self, visible: bool) -> None: + self._show_cal = bool(visible) + self.prefs["show_cal_panel"] = self._show_cal + self._apply_panel_visibility() + self._persist_prefs() + + def _on_calibration_exported(self, path: str) -> None: + """Apply newly written force_calibration.json to the running Sensor.""" + self._ensure_workers() + for worker in (self._box_worker, self._finger_worker): + if worker is not None and hasattr(worker, "set_pending_calibration"): + worker.set_pending_calibration(path) + + def _on_finger_3d_toggled(self, visible: bool) -> None: + if self.sensor_picker.sensor_type() != "finger": + return + self._show_finger_3d = bool(visible) + self.prefs["show_finger_3d"] = self._show_finger_3d + self._apply_panel_visibility() + self._persist_prefs() + + def _on_show_fps_toggled(self, visible: bool) -> None: + self._show_fps = bool(visible) + self.prefs["show_fps"] = self._show_fps + self.arrow_panel.set_fps_visible(self._show_fps) + if not self._show_fps: + self.arrow_panel.set_fps(None) + self._persist_prefs() + + def _on_sensor_type_changed(self, sensor_type: str) -> None: + self.prefs["sensor_type"] = str(sensor_type) + self.prefs["config_name"] = self.sensor_picker.config_name() + self.cal_panel.set_meta_context( + sensor_type=str(sensor_type), + config_ref=self._resolve_config_ref(), + ) + self._apply_panel_visibility() + self._apply_arrow_rotation_for_sensor() + self._update_run_enabled() + self._persist_prefs() + + def _apply_arrow_rotation_for_sensor(self) -> None: + enabled = self.sensor_picker.sensor_type() in ARROW_ROTATE_TYPES + self.arrow_panel.set_rotation_enabled(enabled) + self.act_rotate_arrows.setEnabled(enabled) + + def _on_rotate_arrows(self) -> None: + if self.sensor_picker.sensor_type() not in ARROW_ROTATE_TYPES: + return + deg = self.arrow_panel.rotate_90_ccw() + self.prefs["arrow_rotation_deg"] = deg + self._persist_prefs() + + # ----------------------------------------------------------- shortcuts + def _sync_shortcut_actions(self) -> None: + if getattr(self, "act_run", None) is None: + return + self.act_run.setEnabled(self.toolbar.btn_run.isEnabled()) + self.act_preview.setEnabled(self.toolbar.btn_preview.isEnabled()) + self.act_reset.setEnabled(self.toolbar.btn_reset.isEnabled()) + + def _on_run_shortcut(self) -> None: + if not self.toolbar.btn_run.isEnabled(): + return + self.toolbar.btn_run.setChecked(not self.toolbar.btn_run.isChecked()) + + def _on_preview_shortcut(self) -> None: + if not self.toolbar.btn_preview.isEnabled(): + return + self.toolbar.btn_preview.setChecked(not self.toolbar.btn_preview.isChecked()) + + def _on_config_changed(self, config_name: str) -> None: + self.prefs["config_name"] = str(config_name) + self._persist_prefs() + + def _on_browse_video(self) -> None: + if self._running or self._preview_open: + return + start = self.toolbar.video_path() or str(Path.home()) + path, _ = QFileDialog.getOpenFileName( + self, + t("menu.open_video"), + start, + "Video (*.mp4 *.avi *.mov *.mkv);;All (*)", + ) + if not path: + return + self.toolbar.set_source_mode("video") + self.toolbar.set_video_path(path) + self.prefs["source_mode"] = "video" + self.prefs["video_path"] = path + self._persist_prefs() + self._update_run_enabled() + + def _on_reveal_config(self) -> None: + sensor_type = self.sensor_picker.sensor_type() + path = sensor_json_path(sensor_type) + if path is None: + cfg = self.sensor_picker.config_name() + candidate = Path(cfg) + path = candidate if candidate.is_file() else None + if path is None: + self.statusBar().showMessage(t("status.config_missing", type=sensor_type)) + return + try: + _reveal_in_explorer(path) + except Exception as exc: + self.statusBar().showMessage(t("status.reveal_failed", error=str(exc))) + + def _resolve_config_ref(self) -> str | None: + sensor_type = self.sensor_picker.sensor_type() + local = sensor_json_path(sensor_type) + if local is not None: + return str(local) + cfg = self.sensor_picker.config_name() + if cfg and Path(cfg).is_file(): + return cfg + if sensor_type in RUNNABLE_TYPES: + return sensor_type + return None + + def _on_search_cameras(self) -> None: + if self._preview_open or self._running: + return + from orisys_app_shared.camera import list_cameras, pick_default_camera + + preferred = str(self.toolbar.camera_id()) + self.statusBar().showMessage(t("toolbar.search_cameras") + "…") + QApplication.processEvents() + cameras = list_cameras() + if not cameras: + self.statusBar().showMessage(t("status.camera_none")) + return + names = ", ".join(cam.label() for cam in cameras) + self.statusBar().showMessage( + t("status.camera_found", count=len(cameras), names=names) + ) + selected = pick_default_camera(cameras, preferred) + if selected is not None: + self.toolbar.set_camera_id(int(selected)) + self.prefs["camera_id"] = str(selected) + self._persist_prefs() + + def _on_preview_toggled(self, checked: bool) -> None: + if checked: + self._start_preview() + else: + self._stop_preview(announce=True) + + def _start_preview(self) -> None: + if self._running or self.toolbar.source_mode() != "camera": + self.toolbar.set_preview_checked(False) + return + camera_id = self.toolbar.camera_id() + try: + self._get_preview().open(camera_id) + except Exception as exc: + msg = t("status.preview_failed", error=str(exc)) + self.statusBar().showMessage(msg) + self.toolbar.set_preview_checked(False) + self.arrow_panel.clear_preview(msg) + return + self.toolbar.set_source_controls_enabled(False) + self.toolbar.btn_preview.setEnabled(True) + self._update_run_enabled() + self._preview_timer.start() + self.statusBar().showMessage(t("status.preview_started", camera_id=camera_id)) + + def _stop_preview(self, *, announce: bool) -> None: + self._preview_timer.stop() + if self._preview is not None: + self._preview.close() + if not self._running: + self.toolbar.set_source_controls_enabled(True) + self.arrow_panel.clear_preview(t("panels.arrow_empty")) + self.toolbar.set_preview_checked(False) + self._update_run_enabled() + if announce: + self.statusBar().showMessage(t("status.preview_stopped")) + + def _on_preview_tick(self) -> None: + if self._preview is None: + return + frame = self._preview.read() + if frame is None: + self._stop_preview(announce=True) + self.statusBar().showMessage(t("status.preview_failed", error="no frame")) + return + self.arrow_panel.show_frame(_bgr_to_qimage(frame)) + + def _on_run_toggled(self, checked: bool) -> None: + if checked: + self._start_run() + else: + self._stop_run(reason="user_stop") + + def _start_run(self) -> None: + from orisys_app_shared.acquisition.sdk_worker import orisys_importable + + sensor_type = self.sensor_picker.sensor_type() + if sensor_type not in RUNNABLE_TYPES: + self.toolbar.set_run_checked(False) + self.statusBar().showMessage(t("status.finger_run_unavailable")) + return + ok, detail = orisys_importable() + if not ok: + self.toolbar.set_run_checked(False) + QMessageBox.warning(self, t("app.title"), detail) + return + config_ref = self._resolve_config_ref() + if not config_ref: + self.toolbar.set_run_checked(False) + self.statusBar().showMessage( + t("status.config_missing", type=sensor_type) + ) + return + source = self.toolbar.active_source() + if source is None: + self.toolbar.set_run_checked(False) + self.statusBar().showMessage(t("status.video_required")) + return + + if self._preview_open: + self._stop_preview(announce=False) + + self.force_plots.reset() + self._last_plot_packet_key = None + self._last_rendered_view_mode = None + self._last_session_error = "" + self._run_source_label = str(source) + self._active_sensor_type = sensor_type + self._ensure_workers() + self._active_worker = ( + self._finger_worker if sensor_type == "finger" else self._box_worker + ) + try: + self._active_worker.start(source, config_ref=config_ref) + except Exception as exc: + report_error(exc, message="Run failed") + self.toolbar.set_run_checked(False) + self.statusBar().showMessage(t("status.run_failed", error=str(exc))) + return + + self._running = True + self._set_phase("connecting") + self.cal_panel.set_meta_context(sensor_type=sensor_type, config_ref=config_ref) + self.cal_panel.set_running(True) + self.toolbar.set_source_controls_enabled(False) + self.sensor_picker.set_picker_enabled(False) + self.toolbar.set_reset_enabled(False) + self._run_timer.start() + self.statusBar().showMessage(t("status.connecting")) + logger.info( + "Connecting type=%s source=%s config=%s", + sensor_type, + source, + config_ref, + ) + + def _stop_run(self, *, reason: str = "user_stop") -> None: + self._run_timer.stop() + if self._active_worker is not None: + self._active_worker.stop() + self._running = False + self._set_phase("error" if reason == "failed" else "idle") + self.cal_panel.set_running(False) + self.toolbar.set_run_checked(False) + self.toolbar.set_reset_enabled(False) + self.toolbar.set_source_controls_enabled(True) + self.sensor_picker.set_picker_enabled(True) + self._update_run_enabled() + keep_frame = reason in ("ended", "disconnected") + if not keep_frame: + self.arrow_panel.clear_preview(t("panels.arrow_empty")) + else: + self.arrow_panel.set_fps(None) + if reason == "ended": + self.statusBar().showMessage(t("status.video_ended")) + elif reason == "disconnected": + self.statusBar().showMessage(t("status.camera_disconnected")) + elif reason == "failed": + pass # caller sets status + elif reason == "user_stop": + self.statusBar().showMessage(t("status.run_stopped")) + else: + self.statusBar().showMessage(t("status.run_stopped")) + + def _on_reset(self) -> None: + if self._session_phase != "running" or self._active_worker is None: + return + try: + self._active_worker.reset() + self.force_plots.reset() + self._last_plot_packet_key = None + self.statusBar().showMessage(t("status.reset_ok")) + except Exception as exc: + self.statusBar().showMessage(t("status.run_failed", error=str(exc))) + + def _on_worker_error(self, message: str) -> None: + self._last_session_error = message + logger.error("Acquisition error: %s", message) + + def _on_worker_status(self, message: str) -> None: + logger.info("%s", message) + + def _on_run_tick(self) -> None: + if self._active_worker is None: + return + if self._last_session_error: + err = self._last_session_error + self._last_session_error = "" + self._active_worker.consume_end_reason() + self._stop_run(reason="failed") + self.statusBar().showMessage(t("status.run_failed", error=err)) + return + + end_reason = self._active_worker.consume_end_reason() + if end_reason in ("ended", "disconnected"): + self._stop_run(reason=end_reason) + return + if end_reason == "error": + self._stop_run(reason="failed") + self.statusBar().showMessage(t("status.run_failed", error="acquisition error")) + return + + if not self._active_worker.is_running and self._running: + leftover = self._active_worker.consume_end_reason() + if leftover in ("ended", "disconnected"): + self._stop_run(reason=leftover) + elif leftover == "error": + self._stop_run(reason="failed") + self.statusBar().showMessage( + t("status.run_failed", error="acquisition error") + ) + else: + self._stop_run(reason="user_stop") + return + + packet = self._active_worker.latest() + if packet is None: + return + packet_key = (int(packet.session_id), float(packet.timestamp)) + is_new = packet_key != self._last_plot_packet_key + + if self._session_phase == "connecting" and is_new: + self._set_phase("running") + self.toolbar.set_reset_enabled(True) + self._sync_shortcut_actions() + self.statusBar().showMessage( + t("status.run_started", source=self._run_source_label) + ) + + if is_new: + self._last_plot_packet_key = packet_key + display = select_display_forces( + units_pref=self.toolbar.units(), + force_calibrated=packet.force_calibrated, + fn=packet.normal_force, + fx=packet.shear_x, + fy=packet.shear_y, + fn_raw=packet.normal_force_raw, + fx_raw=packet.shear_x_raw, + fy_raw=packet.shear_y_raw, + ) + if display.using_raw_fallback: + self.statusBar().showMessage(t("status.units_raw_fallback")) + self.force_plots.push( + packet.timestamp, + display.fn, + display.fx, + display.fy, + unit=display.unit, + ) + if self._show_cal: + self.cal_panel.push_raw( + packet.normal_force_raw, + packet.shear_x_raw, + packet.shear_y_raw, + packet.timestamp, + ) + + mode = self.arrow_panel.view_mode() + if is_new or mode != self._last_rendered_view_mode: + self._render_run_image(packet) + if self._show_fps and is_new: + self.arrow_panel.set_fps(packet.fps) + + def _render_run_image(self, packet) -> None: + if packet is None: + return + from orisys_app_shared.acquisition.arrows import ( + depth_map_to_bgr, + draw_arrow_display, + flow_colormap, + 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" + colormap = flow_colormap(self._theme) + 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, + colormap=colormap, + ) + else: + canvas_bgr = hex_to_bgr(color(self._theme, "arrow_canvas", "#0B1830")) + 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, + colormap=colormap, + ) + self.arrow_panel.show_frame(_bgr_to_qimage(frame)) + + def _restore_geometry(self) -> None: + geo = self.prefs.get("window_geometry") + if isinstance(geo, str) and geo: + try: + from PyQt5.QtCore import QByteArray + + self.restoreGeometry(QByteArray.fromHex(geo.encode("ascii"))) + except Exception: + logger.debug("Could not restore window geometry", exc_info=True) + + def _persist_prefs(self) -> None: + geo = self.saveGeometry().toHex().data().decode("ascii") + self.prefs["window_geometry"] = geo + self.prefs["splitter_sizes"] = [int(v) for v in self.content_splitter.sizes()] + self.prefs["theme"] = self._theme + self.prefs["language"] = self._language + self.prefs["camera_id"] = str(self.toolbar.camera_id()) + self.prefs["source_mode"] = self.toolbar.source_mode() + self.prefs["video_path"] = self.toolbar.video_path() + self.prefs["units"] = self.toolbar.units() + self.prefs["image_view_mode"] = self.arrow_panel.view_mode() + self.prefs["arrow_rotation_deg"] = self.arrow_panel.rotation_deg() + self.prefs.setdefault("plot_window_sec", DEFAULT_PLOT_WINDOW_SEC) + self.prefs.setdefault("plot_fn_max", DEFAULT_PLOT_FN_MAX) + self.prefs.setdefault("plot_shear_max", DEFAULT_PLOT_SHEAR_MAX) + self.prefs["show_cal_panel"] = bool(self._show_cal) + self.prefs["show_finger_3d"] = bool(self._show_finger_3d) + self.prefs["show_fps"] = bool(self._show_fps) + self.prefs["sensor_type"] = self.sensor_picker.sensor_type() + self.prefs["config_name"] = self.sensor_picker.config_name() + save_preferences(self.prefs) + + def closeEvent(self, event) -> None: # noqa: N802 + self._stop_preview(announce=False) + self._stop_run(reason="user_stop") + self._persist_prefs() + logger.info("Customer shell closed; preferences saved") + super().closeEvent(event) diff --git a/apps/studio/src/orisys-studio/main.py b/apps/studio/src/orisys-studio/main.py new file mode 100644 index 0000000..49ebafd --- /dev/null +++ b/apps/studio/src/orisys-studio/main.py @@ -0,0 +1,56 @@ +"""Customer app entrypoint.""" + +from __future__ import annotations + +import logging +import sys + +from PyQt5.QtWidgets import QApplication + +from orisys_app_shared import API_VERSION, __version__ as shared_version +from orisys_app_shared.compat import check_shared_compat +from orisys_app_shared.i18n import set_locale +from orisys_app_shared.logging import init_logging, install_exception_hooks +from orisys_app_shared.paths import prefer_local_sensor_bundles +from orisys_app_shared.prefs import load_preferences +from orisys_app_shared.theme import build_qss + +from orisys_customer import REQUIRES_SHARED_API, __version__ as app_version +from orisys_customer.app_window import CustomerMainWindow + +logger = logging.getLogger(__name__) + + +def main() -> int: + init_logging("orisys-customer") + install_exception_hooks() + preferred = prefer_local_sensor_bundles() + if preferred is not None: + logger.info("Preferring local sensor bundles at %s", preferred) + check_shared_compat(REQUIRES_SHARED_API) + logger.info( + "Versions: app=%s shared=%s api=%s (requires %s)", + app_version, + shared_version, + API_VERSION, + REQUIRES_SHARED_API, + ) + + prefs = load_preferences() + language = str(prefs.get("language") or "zh_CN") + theme = str(prefs.get("theme") or "dark") + set_locale(language) + + app = QApplication(sys.argv) + app.setApplicationName("Orisys Customer") + app.setApplicationVersion(app_version) + app.setStyleSheet(build_qss(theme)) + + window = CustomerMainWindow(prefs=prefs) + window.show() + logger.info("Customer shell started (theme=%s, language=%s)", theme, language) + return int(app.exec_()) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/studio/src/orisys-studio/ui/__init__.py b/apps/studio/src/orisys-studio/ui/__init__.py new file mode 100644 index 0000000..fe18e0f --- /dev/null +++ b/apps/studio/src/orisys-studio/ui/__init__.py @@ -0,0 +1 @@ +"""UI package.""" diff --git a/apps/studio/src/orisys-studio/ui/about_dialog.py b/apps/studio/src/orisys-studio/ui/about_dialog.py new file mode 100644 index 0000000..3308a09 --- /dev/null +++ b/apps/studio/src/orisys-studio/ui/about_dialog.py @@ -0,0 +1,44 @@ +"""About dialog for Help > About.""" + +from __future__ import annotations + +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QPixmap +from PyQt5.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout + +from orisys_app_shared.i18n import t +from orisys_app_shared.paths import logo_topbar_path + +from orisys_customer import __version__ as app_version + + +class AboutDialog(QDialog): + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.setWindowTitle(t("menu.about")) + self.setModal(True) + self.resize(420, 220) + + root = QVBoxLayout(self) + row = QHBoxLayout() + logo = QLabel() + pix = QPixmap(str(logo_topbar_path())) + if not pix.isNull(): + logo.setPixmap(pix.scaledToHeight(48, Qt.SmoothTransformation)) + row.addWidget(logo) + row.addStretch(1) + root.addLayout(row) + + self.body = QLabel() + self.body.setWordWrap(True) + self.body.setTextInteractionFlags(Qt.TextSelectableByMouse) + root.addWidget(self.body) + + close_btn = QPushButton("OK") + close_btn.clicked.connect(self.accept) + root.addWidget(close_btn, alignment=Qt.AlignRight) + self.retranslate() + + def retranslate(self) -> None: + self.setWindowTitle(t("app.about_title")) + self.body.setText(t("app.about_body", app_version=app_version)) diff --git a/apps/studio/src/orisys-studio/ui/arrow_panel.py b/apps/studio/src/orisys-studio/ui/arrow_panel.py new file mode 100644 index 0000000..f5b1241 --- /dev/null +++ b/apps/studio/src/orisys-studio/ui/arrow_panel.py @@ -0,0 +1,189 @@ +"""2D image panel: arrows / depth view with mode selector.""" + +from __future__ import annotations + +from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtGui import QImage, QPixmap, QTransform +from PyQt5.QtWidgets import ( + QComboBox, + QFrame, + QHBoxLayout, + QLabel, + QSizePolicy, + QVBoxLayout, +) + +from orisys_app_shared.i18n import t + +VIEW_MODES = ("arrows_pure", "arrows_stitched", "depth") +_ROTATION_STEPS = (0, 90, 180, 270) + + +def _normalize_rotation_deg(value: object, default: int = 90) -> int: + try: + deg = int(value) % 360 # type: ignore[arg-type] + except (TypeError, ValueError): + return default + if deg not in _ROTATION_STEPS: + deg = int(round(deg / 90.0) * 90) % 360 + return deg + + +class ArrowPanel(QFrame): + view_mode_changed = pyqtSignal(str) + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.setObjectName("contentPanel") + layout = QVBoxLayout(self) + + header = QHBoxLayout() + self.title = QLabel() + self.title.setObjectName("panelTitle") + self.fps_label = QLabel() + self.fps_label.setObjectName("panelMeta") + self.fps_label.setVisible(False) + self._fps_visible = False + self._fps_value: float | None = None + self._rotation_deg = 90 + self._rotation_enabled = True + self._source_image: QImage | None = None + self.view_combo = QComboBox() + self.view_combo.setMinimumWidth(140) + self.view_combo.currentIndexChanged.connect(self._on_view_index_changed) + header.addWidget(self.title) + header.addStretch(1) + header.addWidget(self.fps_label) + header.addWidget(self.view_combo) + layout.addLayout(header) + + self.image_label = QLabel() + self.image_label.setAlignment(Qt.AlignCenter) + self.image_label.setMinimumSize(240, 180) + self.image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + self.image_label.setScaledContents(False) + self.image_label.setWordWrap(True) + layout.addWidget(self.image_label, 1) + self._placeholder = "" + self._populate_view_combo() + self.retranslate() + + def view_mode(self) -> str: + data = self.view_combo.currentData() + return str(data or "arrows_pure") + + def set_view_mode(self, mode: str) -> None: + wanted = str(mode or "arrows_pure") + if wanted not in VIEW_MODES: + wanted = "arrows_pure" + idx = self.view_combo.findData(wanted) + if idx < 0: + idx = 0 + self.view_combo.blockSignals(True) + self.view_combo.setCurrentIndex(idx) + self.view_combo.blockSignals(False) + + def rotation_deg(self) -> int: + return self._rotation_deg + + def set_rotation_deg(self, deg: object) -> None: + self._rotation_deg = _normalize_rotation_deg(deg) + self._repaint_source() + + def set_rotation_enabled(self, enabled: bool) -> None: + wanted = bool(enabled) + if wanted == self._rotation_enabled: + return + self._rotation_enabled = wanted + self._repaint_source() + + def rotate_90_ccw(self) -> int: + if not self._rotation_enabled: + return self._rotation_deg + self.set_rotation_deg(self._rotation_deg + 90) + return self._rotation_deg + + def set_fps_visible(self, visible: bool) -> None: + self._fps_visible = bool(visible) + self._refresh_fps_label() + + def set_fps(self, fps: float | None) -> None: + self._fps_value = None if fps is None else float(fps) + self._refresh_fps_label() + + def _refresh_fps_label(self) -> None: + if not self._fps_visible or self._fps_value is None: + self.fps_label.clear() + self.fps_label.setVisible(False) + return + self.fps_label.setText(f"{self._fps_value:.1f} FPS") + self.fps_label.setVisible(True) + + def _populate_view_combo(self) -> None: + current = self.view_combo.currentData() + self.view_combo.blockSignals(True) + self.view_combo.clear() + for mode in VIEW_MODES: + self.view_combo.addItem("", mode) + if current: + idx = self.view_combo.findData(current) + self.view_combo.setCurrentIndex(max(0, idx)) + self.view_combo.blockSignals(False) + + def _on_view_index_changed(self, _index: int) -> None: + self.view_mode_changed.emit(self.view_mode()) + + def retranslate(self) -> None: + self.title.setText(t("panels.arrow")) + labels = ( + t("panels.view_arrows_pure"), + t("panels.view_arrows_stitched"), + t("panels.view_depth"), + ) + for i, label in enumerate(labels): + if i < self.view_combo.count(): + self.view_combo.setItemText(i, label) + self.view_combo.setToolTip(t("panels.view_mode_tip")) + self.image_label.setToolTip(t("panels.arrow_empty_tip")) + if not self.image_label.pixmap() or self.image_label.pixmap().isNull(): + self._placeholder = t("panels.arrow_empty") + self.image_label.setText(self._placeholder) + + def _effective_rotation_deg(self) -> int: + if not self._rotation_enabled: + return 0 + return self._rotation_deg + + def _repaint_source(self) -> None: + if self._source_image is None or self._source_image.isNull(): + return + self._paint_image(self._source_image) + + def _paint_image(self, image: QImage) -> None: + pix = QPixmap.fromImage(image) + deg = self._effective_rotation_deg() + if deg: + # QTransform.rotate is clockwise on screen pixmaps; negate for CCW. + pix = pix.transformed(QTransform().rotate(-deg), Qt.FastTransformation) + scaled = pix.scaled( + self.image_label.size(), + Qt.KeepAspectRatio, + Qt.SmoothTransformation, + ) + self.image_label.setPixmap(scaled) + self.image_label.setText("") + + def show_frame(self, image: QImage) -> None: + if image.isNull(): + return + self._source_image = image + self._paint_image(image) + + def clear_preview(self, message: str | None = None) -> None: + self._source_image = None + self.image_label.clear() + self.image_label.setPixmap(QPixmap()) + text = message if message is not None else t("panels.arrow_empty") + self._placeholder = text + self.image_label.setText(text) + self.set_fps(None) diff --git a/apps/studio/src/orisys-studio/ui/brand_bar.py b/apps/studio/src/orisys-studio/ui/brand_bar.py new file mode 100644 index 0000000..a43c92e --- /dev/null +++ b/apps/studio/src/orisys-studio/ui/brand_bar.py @@ -0,0 +1,115 @@ +"""Brand bar shown at the top of the customer shell. + +A slim deep-navy rail carrying the corporate logo lockup and the product name. +Logo colours are re-tinted at runtime so a single asset works on the brand +background in both themes; all other colours come from the theme tokens. +""" + +from __future__ import annotations + +import logging +from pathlib import Path + +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QColor, QPainter, QPixmap +from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout + +from orisys_app_shared.i18n import t +from orisys_app_shared.paths import logo_topbar_path +from orisys_app_shared.theme import color + +logger = logging.getLogger(__name__) + +LOGO_SIZE = 22 +BAR_HEIGHT = 40 +DIVIDER_HEIGHT = 18 + + +def tinted_pixmap(path: Path | str, tint: str, size: int = LOGO_SIZE) -> QPixmap: + """Return ``path`` recoloured to ``tint``, preserving its alpha channel. + + Returns a null pixmap when the source cannot be loaded, so callers can fall + back to a text-only lockup. + """ + source = QPixmap(str(path)) + if source.isNull(): + return QPixmap() + scaled = source.scaled( + size, size, Qt.KeepAspectRatio, Qt.SmoothTransformation + ) + out = QPixmap(scaled.size()) + out.fill(Qt.transparent) + painter = QPainter(out) + painter.drawPixmap(0, 0, scaled) + painter.setCompositionMode(QPainter.CompositionMode_SourceIn) + painter.fillRect(out.rect(), QColor(tint)) + painter.end() + return out + + +class BrandBar(QFrame): + """Thin brand rail: logo lockup on the left, product name on the right.""" + + def __init__(self, parent=None, *, theme: str = "dark") -> None: + super().__init__(parent) + self._theme = theme + self.setObjectName("brandBar") + self.setFixedHeight(BAR_HEIGHT) + + layout = QHBoxLayout(self) + layout.setContentsMargins(14, 0, 14, 0) + layout.setSpacing(10) + + self.logo = QLabel() + self.logo.setObjectName("brandLogo") + self.logo.setFixedSize(LOGO_SIZE, LOGO_SIZE) + self.logo.setAlignment(Qt.AlignCenter) + layout.addWidget(self.logo) + + lockup = QVBoxLayout() + lockup.setContentsMargins(0, 0, 0, 0) + lockup.setSpacing(0) + self.name = QLabel() + self.name.setObjectName("brandName") + self.tagline = QLabel() + self.tagline.setObjectName("brandTagline") + lockup.addWidget(self.name) + lockup.addWidget(self.tagline) + layout.addLayout(lockup) + + layout.addStretch(1) + + self.divider = QFrame() + self.divider.setObjectName("sectionSeparator") + self.divider.setFixedSize(1, DIVIDER_HEIGHT) + layout.addWidget(self.divider) + + self.product = QLabel() + self.product.setObjectName("brandProduct") + self.product.setAlignment(Qt.AlignRight | Qt.AlignVCenter) + layout.addWidget(self.product) + + self.set_theme(theme) + self.retranslate() + + def _refresh_logo(self) -> None: + pixmap = tinted_pixmap( + logo_topbar_path(), color(self._theme, "brand_bar_fg", "#FFFFFF") + ) + if pixmap.isNull(): + logger.debug("Brand logo unavailable; showing text-only lockup") + self.logo.clear() + self.logo.hide() + return + self.logo.setPixmap(pixmap) + self.logo.show() + + def set_theme(self, theme: str) -> None: + self._theme = str(theme) + self._refresh_logo() + + def retranslate(self) -> None: + self.name.setText(t("brand.name")) + self.tagline.setText(t("brand.tagline")) + self.product.setText(t("app.title")) + self.name.setToolTip(t("app.title")) diff --git a/apps/studio/src/orisys-studio/ui/cal_panel.py b/apps/studio/src/orisys-studio/ui/cal_panel.py new file mode 100644 index 0000000..3e78ab9 --- /dev/null +++ b/apps/studio/src/orisys-studio/ui/cal_panel.py @@ -0,0 +1,440 @@ +"""Force calibration sampling panel (FN / FX / FY).""" + +from __future__ import annotations + +import math +import time + +from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtGui import QFont, QFontDatabase +from PyQt5.QtWidgets import ( + QAbstractItemView, + QButtonGroup, + QDoubleSpinBox, + QFormLayout, + QFrame, + QHBoxLayout, + QHeaderView, + QLabel, + QLineEdit, + QMessageBox, + QProgressBar, + QPushButton, + QRadioButton, + QSizePolicy, + QTableWidget, + QTableWidgetItem, + QVBoxLayout, +) + +from orisys_app_shared.force.calibration_io import ( + CalSample, + CalSessionStore, + ChannelKey, + export_force_calibration_v2, + meta_from_sensor_json, +) +from orisys_app_shared.i18n import t +from orisys_app_shared.paths import calibration_export_dir, sensors_dir +from orisys_app_shared.theme import color + +CHANNEL_LABELS = ("fn", "fx", "fy") + + +def _mono_font() -> str: + families = set(QFontDatabase().families()) + for name in ("Consolas", "Cascadia Mono", "DejaVu Sans Mono", "Courier New"): + if name in families: + return name + return "monospace" + + +class CalPanel(QFrame): + status_message = pyqtSignal(str) + calibration_exported = pyqtSignal(str) # path applied to running sensor + + def __init__(self, parent=None, *, theme: str = "dark") -> None: + super().__init__(parent) + self.setObjectName("contentPanel") + self._theme = theme + self._store = CalSessionStore() + self._channel: ChannelKey = "fn" + self._running = False + self._recording = False + self._record_deadline = 0.0 + self._record_started = 0.0 + self._record_samples: list[float] = [] + self._live_fn = 0.0 + self._live_fx = 0.0 + self._live_fy = 0.0 + self._sensor_type = "box" + self._config_ref: str | None = None + self._record_duration_s = 7.0 + self._mono = _mono_font() + self._mono_font = QFont(self._mono, 10) + + root = QVBoxLayout(self) + root.setSpacing(8) + + title_row = QHBoxLayout() + self.title = QLabel() + self.title.setObjectName("panelTitle") + self.channel_badge = QLabel() + self.channel_badge.setObjectName("unitBadge") + title_row.addWidget(self.title) + title_row.addStretch(1) + title_row.addWidget(self.channel_badge) + root.addLayout(title_row) + + channel_row = QHBoxLayout() + channel_row.setSpacing(10) + self.channel_group = QButtonGroup(self) + self.radio_fn = QRadioButton("FN") + self.radio_fx = QRadioButton("FX") + self.radio_fy = QRadioButton("FY") + self.radio_fn.setChecked(True) + for i, radio in enumerate((self.radio_fn, self.radio_fx, self.radio_fy)): + self.channel_group.addButton(radio, i) + channel_row.addWidget(radio) + channel_row.addStretch(1) + self.channel_group.buttonToggled.connect(self._on_channel_button) + root.addLayout(channel_row) + + form = QFormLayout() + form.setHorizontalSpacing(10) + form.setVerticalSpacing(6) + form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) + self._weight_label = QLabel() + self._weight_label.setObjectName("fieldLabel") + self.weight_spin = QDoubleSpinBox() + self.weight_spin.setRange(0.0, 1e6) + self.weight_spin.setDecimals(4) + self.weight_spin.setSingleStep(0.1) + self.weight_spin.setValue(0.0) + self._duration_label = QLabel() + self._duration_label.setObjectName("fieldLabel") + self.duration_spin = QDoubleSpinBox() + self.duration_spin.setRange(5.0, 10.0) + self.duration_spin.setDecimals(1) + self.duration_spin.setSingleStep(0.5) + self.duration_spin.setValue(7.0) + self.duration_spin.setSuffix(" s") + form.addRow(self._weight_label, self.weight_spin) + form.addRow(self._duration_label, self.duration_spin) + root.addLayout(form) + + rec_row = QHBoxLayout() + self.btn_record = QPushButton() + self.btn_record.setMinimumHeight(32) + self.btn_record.clicked.connect(self._on_record_clicked) + rec_row.addWidget(self.btn_record) + rec_row.addStretch(1) + root.addLayout(rec_row) + + self.progress = QProgressBar() + self.progress.setRange(0, 1000) + self.progress.setValue(0) + self.progress.setTextVisible(True) + root.addWidget(self.progress) + + self.live_label = QLabel() + self.live_label.setWordWrap(True) + self.live_label.setFont(self._mono_font) + root.addWidget(self.live_label) + + self.empty_hint = QLabel() + self.empty_hint.setObjectName("hintText") + self.empty_hint.setWordWrap(True) + root.addWidget(self.empty_hint) + + self.table = QTableWidget(0, 4) + self.table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.table.setSelectionMode(QAbstractItemView.SingleSelection) + self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.table.setAlternatingRowColors(True) + self.table.setShowGrid(False) + header = self.table.horizontalHeader() + header.setSectionResizeMode(QHeaderView.Stretch) + header.setSectionResizeMode(2, QHeaderView.ResizeToContents) + self.table.verticalHeader().setVisible(False) + self.table.verticalHeader().setDefaultSectionSize(26) + root.addWidget(self.table, 1) + + sn_row = QHBoxLayout() + self._sn_label = QLabel() + self._sn_label.setObjectName("fieldLabel") + self.sn_edit = QLineEdit() + self.sn_edit.setPlaceholderText("") + sn_row.addWidget(self._sn_label) + sn_row.addWidget(self.sn_edit, 1) + root.addLayout(sn_row) + + actions = QHBoxLayout() + actions.setSpacing(6) + self.btn_delete = QPushButton() + self.btn_clear_channel = QPushButton() + self.btn_clear_all = QPushButton() + self.btn_export = QPushButton() + self.btn_export.setObjectName("primaryButton") + for button in (self.btn_delete, self.btn_clear_channel, self.btn_clear_all): + # The action row must survive a narrow three-panel layout. + button.setProperty("compact", "true") + button.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + self.btn_delete.clicked.connect(self._on_delete_row) + self.btn_clear_channel.clicked.connect(self._on_clear_channel) + self.btn_clear_all.clicked.connect(self._on_clear_all) + self.btn_export.clicked.connect(self._on_export) + actions.addWidget(self.btn_delete) + actions.addWidget(self.btn_clear_channel) + actions.addWidget(self.btn_clear_all) + actions.addStretch(1) + actions.addWidget(self.btn_export) + root.addLayout(actions) + + self._refresh_enabled() + self._refresh_table() + self.set_theme(theme) + self.retranslate() + + # ------------------------------------------------------------------ API + def set_theme(self, theme: str) -> None: + self._theme = theme + self._render_live_label() + + def set_running(self, running: bool) -> None: + self._running = bool(running) + if not self._running and self._recording: + self._cancel_recording() + self._refresh_enabled() + + def set_meta_context(self, *, sensor_type: str, config_ref: str | None) -> None: + self._sensor_type = str(sensor_type or "box") + self._config_ref = config_ref + + def push_raw(self, fn: float, fx: float, fy: float, _timestamp: float | None = None) -> None: + self._live_fn = float(fn) + self._live_fx = float(fx) + self._live_fy = float(fy) + self._render_live_label() + if not self._recording: + return + now = time.perf_counter() + value = {"fn": self._live_fn, "fx": self._live_fx, "fy": self._live_fy}[self._channel] + self._record_samples.append(float(value)) + span = max(1e-6, self._record_deadline - self._record_started) + frac = min(1.0, max(0.0, (now - self._record_started) / span)) + self.progress.setValue(int(frac * 1000)) + self.progress.setFormat(f"{now - self._record_started:.1f} / {span:.1f} s") + if now >= self._record_deadline: + self._finish_recording() + + def retranslate(self) -> None: + self.title.setText(t("panels.cal")) + self._weight_label.setText(t("cal.weight")) + self._duration_label.setText(t("cal.duration")) + self.btn_record.setText( + t("cal.recording_stop") if self._recording else t("cal.record") + ) + self.btn_delete.setText(t("cal.delete_row")) + self.btn_clear_channel.setText(t("cal.clear_channel")) + self.btn_clear_all.setText(t("cal.clear_all")) + self.btn_export.setText(t("cal.export")) + self._sn_label.setText(t("cal.sn")) + self.sn_edit.setPlaceholderText(t("cal.sn_placeholder")) + self.empty_hint.setText(t("cal.empty_hint")) + headers = ( + t("cal.col_weight"), + t("cal.col_raw"), + t("cal.col_n"), + t("cal.col_std"), + ) + for i, text in enumerate(headers): + item = self.table.horizontalHeaderItem(i) + if item is None: + item = QTableWidgetItem(text) + self.table.setHorizontalHeaderItem(i, item) + else: + item.setText(text) + self._refresh_channel_badge() + self._render_live_label() + self._refresh_enabled() + + # ------------------------------------------------------------- internals + def _refresh_channel_badge(self) -> None: + color_hex = color(self._theme, f"plot_{self._channel}") + self.channel_badge.setText(self._channel.upper()) + self.channel_badge.setStyleSheet( + f"QLabel#unitBadge {{ color: {color_hex};" + f" border: 1px solid {color(self._theme, 'card_border')};" + f" background-color: {color(self._theme, 'card_bg')}; }}" + ) + + def _on_channel_button(self, button, checked: bool) -> None: + if not checked: + return + mapping = {self.radio_fn: "fn", self.radio_fx: "fx", self.radio_fy: "fy"} + key = mapping.get(button) + if key is None: + return + if self._recording: + self._cancel_recording() + self._channel = key # type: ignore[assignment] + self._refresh_channel_badge() + self._refresh_table() + + def _refresh_enabled(self) -> None: + can_record = self._running and not self._recording + self.btn_record.setEnabled(can_record) + self.btn_record.setObjectName("dangerButton" if self._recording else "primaryButton") + self.btn_record.style().unpolish(self.btn_record) + self.btn_record.style().polish(self.btn_record) + self.weight_spin.setEnabled(not self._recording) + self.duration_spin.setEnabled(not self._recording) + for radio in (self.radio_fn, self.radio_fx, self.radio_fy): + radio.setEnabled(not self._recording) + self.btn_export.setEnabled(not self._recording) + if self._recording: + self.progress.setVisible(True) + record_hint = t("cal.record_disabled") if not self._running else "" + if not self._recording: + self.btn_record.setToolTip(record_hint) + + def _render_live_label(self) -> None: + colors = { + "fn": color(self._theme, "plot_fn"), + "fx": color(self._theme, "plot_fx"), + "fy": color(self._theme, "plot_fy"), + } + parts = [] + for key, value in ( + ("fn", self._live_fn), + ("fx", self._live_fx), + ("fy", self._live_fy), + ): + parts.append( + f"{key.upper()}" + f" {value:.3f}" + ) + self.live_label.setText( + f"" + f"{t('cal.live_raw_label')}  " + "   ".join(parts) + ) + + def _on_record_clicked(self) -> None: + if self._recording: + self._cancel_recording() + self.status_message.emit(t("cal.record_cancelled")) + return + if not self._running: + return + self._recording = True + self._record_samples = [] + self._record_duration_s = float(self.duration_spin.value()) + self._record_started = time.perf_counter() + self._record_deadline = self._record_started + self._record_duration_s + self.progress.setVisible(True) + self.progress.setValue(0) + self.progress.setFormat(f"0.0 / {self._record_duration_s:.1f} s") + self._refresh_enabled() + self.status_message.emit(t("cal.recording")) + + def _cancel_recording(self) -> None: + self._recording = False + self._record_samples = [] + self.progress.setValue(0) + self.progress.setFormat("") + self._refresh_enabled() + + def _finish_recording(self) -> None: + samples = list(self._record_samples) + self._recording = False + self._record_samples = [] + self.progress.setValue(1000) + self.progress.setFormat("") + self._refresh_enabled() + if len(samples) < 2: + self.status_message.emit(t("cal.record_failed_few")) + return + mean = sum(samples) / len(samples) + var = sum((x - mean) ** 2 for x in samples) / len(samples) + std = math.sqrt(var) + sample = CalSample( + weight_n=float(self.weight_spin.value()), + raw_mean=mean, + n=len(samples), + std=std, + ) + self._store.upsert(self._channel, sample) + self._refresh_table() + self.status_message.emit( + t("cal.record_ok", weight=f"{sample.weight_n:g}", raw=f"{sample.raw_mean:.3f}") + ) + + def _refresh_table(self) -> None: + rows = self._store.channel(self._channel) + self.table.setRowCount(len(rows)) + for r, sample in enumerate(rows): + values = ( + f"{sample.weight_n:g}", + f"{sample.raw_mean:.4f}", + str(sample.n), + f"{sample.std:.4f}", + ) + for c, text in enumerate(values): + item = QTableWidgetItem(text) + item.setFlags(item.flags() & ~Qt.ItemIsEditable) + if c != 2: + item.setTextAlignment(int(Qt.AlignRight | Qt.AlignVCenter)) + else: + item.setTextAlignment(int(Qt.AlignCenter)) + self.table.setItem(r, c, item) + self.empty_hint.setVisible(not rows) + + def _on_delete_row(self) -> None: + row = self.table.currentRow() + if row < 0: + return + self._store.remove_at(self._channel, row) + self._refresh_table() + + def _on_clear_channel(self) -> None: + self._store.clear(self._channel) + self._refresh_table() + + def _on_clear_all(self) -> None: + self._store.clear() + self._refresh_table() + + def _on_export(self) -> None: + meta = meta_from_sensor_json(self._config_ref) + meta["sensor_type"] = self._sensor_type + meta["sn"] = self.sn_edit.text().strip() + meta["record_duration_s"] = float(self.duration_spin.value()) + local_bundle = sensors_dir() / self._sensor_type + if not local_bundle.is_dir(): + local_bundle = None + try: + primary, local_path, warnings = export_force_calibration_v2( + self._store, + export_dir=calibration_export_dir(), + sensor_type=self._sensor_type, + meta=meta, + local_bundle_dir=local_bundle, + ) + except ValueError as exc: + QMessageBox.warning(self, t("panels.cal"), str(exc)) + return + except Exception as exc: + QMessageBox.warning(self, t("panels.cal"), t("cal.export_failed", error=str(exc))) + return + + msg = t("cal.export_ok", path=str(primary)) + if local_path is not None: + msg += "\n" + t("cal.export_local", path=str(local_path)) + if warnings: + msg += "\n" + t("cal.export_warnings", detail="; ".join(warnings)) + self.status_message.emit(msg) + # Prefer the bundle file for live apply so the next Sensor() also sees it. + apply_path = local_path if local_path is not None else primary + self.calibration_exported.emit(str(apply_path)) + QMessageBox.information(self, t("panels.cal"), msg) diff --git a/apps/studio/src/orisys-studio/ui/force_plots.py b/apps/studio/src/orisys-studio/ui/force_plots.py new file mode 100644 index 0000000..3292145 --- /dev/null +++ b/apps/studio/src/orisys-studio/ui/force_plots.py @@ -0,0 +1,288 @@ +"""FN / FX / FY force charts with live value labels.""" + +from __future__ import annotations + +import numpy as np +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QColor, QFont, QFontDatabase +from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout + +from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ, LOWPASS_CUTOFF_HZ +from orisys_app_shared.force import MetricHistory +from orisys_app_shared.force.history import DEFAULT_MAX_FPS +from orisys_app_shared.i18n import t +from orisys_app_shared.theme import color + +try: + import pyqtgraph as pg +except ImportError as exc: # pragma: no cover + raise ImportError("pyqtgraph is required for force charts") from exc + + +CHANNEL_KEYS = ("fn", "fx", "fy") +CHANNEL_LABELS = ("FN", "FX", "FY") +# Theme token that colors each channel curve / label, per index. +CHANNEL_TOKENS = ("plot_fn", "plot_fx", "plot_fy") + + +def _nice_ticks(lo: float, hi: float, count: int = 5) -> list[tuple[float, str]]: + if hi <= lo: + return [(lo, f"{lo:g}")] + step = (hi - lo) / max(1, count - 1) + return [(lo + i * step, f"{(lo + i * step):g}") for i in range(count)] + + +def format_force(value: float, unit: str) -> str: + """Render one force reading for the value cards.""" + if unit == "N": + return f"{value:.3f}" + return f"{value:.1f}" + + +def unit_display(unit: str) -> str: + return t("units.newton_short") if unit == "N" else t("units.raw_short") + + +def _mono_font(point_size: int = 9, *, bold: bool = False) -> QFont: + """Pick the first available monospace face for stable digit alignment.""" + families = set(QFontDatabase().families()) + chosen = "monospace" + for name in ("Consolas", "Cascadia Mono", "DejaVu Sans Mono", "Courier New"): + if name in families: + chosen = name + break + font = QFont(chosen, point_size) + font.setBold(bold) + return font + + +class ForcePlotsPanel(QFrame): + def __init__(self, parent=None, *, theme: str = "dark") -> None: + super().__init__(parent) + self.setObjectName("contentPanel") + self._theme = theme + self._fn_max = 20.0 + self._shear_max = 20.0 + self._fixed_newton = True + self._unit = "raw" + self._last_values: tuple[float, float, float] = (0.0, 0.0, 0.0) + self.history = MetricHistory( + channels=3, + window_sec=4.0, + sample_rate_hz=FORCE_SAMPLE_RATE_HZ, + lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ, + y_axis_min_span=0.5, + max_fps=DEFAULT_MAX_FPS, + ) + + root = QVBoxLayout(self) + root.setContentsMargins(10, 8, 10, 10) + root.setSpacing(6) + + header = QHBoxLayout() + header.setSpacing(6) + self.title = QLabel() + self.title.setObjectName("panelTitle") + header.addWidget(self.title) + header.addStretch(1) + self.unit_badge = QLabel() + self.unit_badge.setObjectName("unitBadge") + header.addWidget(self.unit_badge) + root.addLayout(header) + + self.plot_frame = QFrame() + self.plot_frame.setObjectName("cardPanel") + plot_layout = QVBoxLayout(self.plot_frame) + plot_layout.setContentsMargins(4, 4, 4, 4) + self.plot_widget = pg.GraphicsLayoutWidget() + self.plot_widget.ci.setSpacing(2) + plot_layout.addWidget(self.plot_widget) + root.addWidget(self.plot_frame, 1) + + self.plots = [] + self.curves = [] + pen = pg.mkPen(color(theme, CHANNEL_TOKENS[0]), width=2) + for i, _name in enumerate(CHANNEL_LABELS): + plot = self.plot_widget.addPlot(row=i, col=0) + plot.showGrid(x=True, y=True, alpha=0.25) + plot.setMouseEnabled(x=False, y=False) + if i > 0: + plot.setXLink(self.plots[0]) + curve = plot.plot(pen=pen) + self.plots.append(plot) + self.curves.append(curve) + + values_row = QHBoxLayout() + values_row.setSpacing(6) + self.value_cards: list[QFrame] = [] + self.value_labels: list[QLabel] = [] + self.channel_badges: list[QLabel] = [] + self.value_units: list[QLabel] = [] + for label in CHANNEL_LABELS: + card, badge, value_label, unit_label = self._build_value_card(label) + values_row.addWidget(card, 1) + self.value_cards.append(card) + self.channel_badges.append(badge) + self.value_labels.append(value_label) + self.value_units.append(unit_label) + root.addLayout(values_row) + + self.set_theme(theme) + self._apply_axis_mode() + self.retranslate() + + def _build_value_card(self, channel: str) -> tuple[QFrame, QLabel, QLabel, QLabel]: + card = QFrame() + card.setObjectName("cardPanel") + layout = QVBoxLayout(card) + layout.setContentsMargins(7, 4, 7, 4) + layout.setSpacing(1) + + top = QHBoxLayout() + top.setSpacing(6) + badge = QLabel(channel) + badge.setObjectName("unitBadge") + top.addWidget(badge) + top.addStretch(1) + unit_label = QLabel() + unit_label.setObjectName("hintText") + top.addWidget(unit_label) + + value_label = QLabel() + value_label.setObjectName("valueCard") + value_label.setFixedHeight(20) + value_label.setContentsMargins(6, 0, 6, 0) + value_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + value_label.setFont(_mono_font(9, bold=True)) + + layout.addLayout(top) + layout.addWidget(value_label) + return card, badge, value_label, unit_label + + def set_theme(self, theme: str) -> None: + self._theme = theme + bg = color(theme, "plot_bg") + text = color(theme, "text_primary") + self.plot_widget.setBackground(bg) + axis_pen = pg.mkPen(text) + for i, plot in enumerate(self.plots): + try: + plot.getViewBox().setBackgroundColor(QColor(bg)) + except Exception: + pass + for axis_name in ("left", "bottom"): + axis = plot.getAxis(axis_name) + axis.setPen(axis_pen) + axis.setTextPen(axis_pen) + plot.showGrid(x=True, y=True, alpha=0.25) + # Color each channel axis label to match its curve pen. + plot.setLabel("left", CHANNEL_LABELS[i], color=color(theme, CHANNEL_TOKENS[i])) + pens = tuple(color(theme, token) for token in CHANNEL_TOKENS) + for curve, pen in zip(self.curves, pens): + curve.setPen(pg.mkPen(pen, width=2)) + for badge, token in zip(self.channel_badges, CHANNEL_TOKENS): + badge.setStyleSheet( + f"QLabel#unitBadge {{ color: {color(theme, token)};" + f" background-color: {color(theme, 'card_bg')};" + f" border: 1px solid {color(theme, 'card_border')}; }}" + ) + for value_label, token in zip(self.value_labels, CHANNEL_TOKENS): + value_label.setStyleSheet( + f"QLabel#valueCard {{ color: {color(theme, token)}; }}" + ) + self._render_value_cards() + + def set_window_sec(self, sec: float) -> None: + self.history.set_window_sec(sec) + self._clear_curves() + self._apply_axis_mode() + + def set_axis_limits(self, fn_max: float, shear_max: float) -> None: + self._fn_max = max(0.1, float(fn_max)) + self._shear_max = max(0.1, float(shear_max)) + self._apply_axis_mode() + + def set_fixed_newton(self, enabled: bool) -> None: + self._fixed_newton = bool(enabled) + self._apply_axis_mode() + + def reset(self) -> None: + self.history.reset() + self._clear_curves() + self._last_values = (0.0, 0.0, 0.0) + self._unit = "raw" + self._render_value_cards() + self._apply_axis_mode() + + def push( + self, + timestamp: float, + fn: float, + fx: float, + fy: float, + *, + unit: str = "raw", + ) -> None: + self._unit = unit + self._last_values = (float(fn), float(fx), float(fy)) + use_fixed = self._uses_fixed_y() + plot_fn = max(0.0, float(fn)) if use_fixed else float(fn) + self.history.push(timestamp, plot_fn, float(fx), float(fy)) + self._render_value_cards() + state = self.history.state() + if state is None: + return + values = state.values.copy() + if use_fixed: + values[:, 0] = np.maximum(values[:, 0], 0.0) + for i, curve in enumerate(self.curves): + curve.setData(state.times, values[:, i]) + self.plots[i].setXRange(*state.x_range, padding=0.0) + if use_fixed: + ymin, ymax = self._fixed_y_range(i) + self.plots[i].setYRange(ymin, ymax, padding=0.0) + self.plots[i].getAxis("left").setTicks([_nice_ticks(ymin, ymax, count=5)]) + else: + self.plots[i].getAxis("left").setTicks(None) + self.plots[i].setYRange(*state.y_ranges[i], padding=0.0) + + def _uses_fixed_y(self) -> bool: + return bool(self._fixed_newton) and self._unit == "N" + + def _fixed_y_range(self, channel_index: int) -> tuple[float, float]: + if channel_index == 0: + return 0.0, self._fn_max + return -self._shear_max, self._shear_max + + def _apply_axis_mode(self) -> None: + # Preview fixed newton axes when newton mode is selected, even before samples. + preview_fixed = bool(self._fixed_newton) + use_fixed = self._uses_fixed_y() or (preview_fixed and self.history.sample_count == 0) + for i, plot in enumerate(self.plots): + if use_fixed: + ymin, ymax = self._fixed_y_range(i) + plot.setYRange(ymin, ymax, padding=0.0) + plot.getAxis("left").setTicks([_nice_ticks(ymin, ymax, count=5)]) + else: + plot.getAxis("left").setTicks(None) + for plot in self.plots: + plot.setXRange(0.0, self.history.window_sec, padding=0.0) + + def _clear_curves(self) -> None: + for curve in self.curves: + curve.setData([], []) + + def _render_value_cards(self) -> None: + fmt = "{value:.3f}" if self._unit == "N" else "{value:.1f}" + for value_label, unit_label, value in zip( + self.value_labels, self.value_units, self._last_values + ): + value_label.setText(fmt.format(value=value)) + unit_label.setText(unit_display(self._unit)) + self.unit_badge.setText(unit_display(self._unit)) + + def retranslate(self) -> None: + self.title.setText(t("panels.plots")) + for badge, label in zip(self.channel_badges, CHANNEL_LABELS): + badge.setText(label) + self._render_value_cards() diff --git a/apps/studio/src/orisys-studio/ui/mesh3d_panel.py b/apps/studio/src/orisys-studio/ui/mesh3d_panel.py new file mode 100644 index 0000000..7442a30 --- /dev/null +++ b/apps/studio/src/orisys-studio/ui/mesh3d_panel.py @@ -0,0 +1,35 @@ +"""Placeholder panel for finger 3D mesh view.""" + +from __future__ import annotations + +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QFrame, QLabel, QVBoxLayout + +from orisys_app_shared.i18n import t + + +class Mesh3dPanel(QFrame): + def __init__(self, parent=None) -> None: + super().__init__(parent) + self.setObjectName("contentPanel") + layout = QVBoxLayout(self) + + header = QVBoxLayout() + header.setSpacing(2) + self.title = QLabel() + self.title.setObjectName("panelTitle") + header.addWidget(self.title) + layout.addLayout(header) + + self.body = QLabel() + self.body.setObjectName("hintText") + self.body.setAlignment(Qt.AlignCenter) + self.body.setWordWrap(True) + layout.addWidget(self.body, 1) + + self.retranslate() + + def retranslate(self) -> None: + self.title.setText(t("panels.mesh3d")) + self.body.setText(t("panels.mesh3d_empty")) + self.body.setToolTip(t("panels.mesh3d_empty_tip")) diff --git a/apps/studio/src/orisys-studio/ui/plot_settings_dialog.py b/apps/studio/src/orisys-studio/ui/plot_settings_dialog.py new file mode 100644 index 0000000..198accf --- /dev/null +++ b/apps/studio/src/orisys-studio/ui/plot_settings_dialog.py @@ -0,0 +1,114 @@ +"""Modal dialog for force-plot window and Y-axis limits.""" + +from __future__ import annotations + +from PyQt5.QtWidgets import ( + QDialog, + QDialogButtonBox, + QDoubleSpinBox, + QFormLayout, + QHBoxLayout, + QLabel, + QPushButton, + QVBoxLayout, +) + +from orisys_app_shared.i18n import t + +DEFAULT_PLOT_WINDOW_SEC = 4.0 +DEFAULT_PLOT_FN_MAX = 20.0 +DEFAULT_PLOT_SHEAR_MAX = 20.0 + + +class PlotSettingsDialog(QDialog): + def __init__( + self, + parent=None, + *, + window_sec: float = DEFAULT_PLOT_WINDOW_SEC, + fn_max: float = DEFAULT_PLOT_FN_MAX, + shear_max: float = DEFAULT_PLOT_SHEAR_MAX, + units: str = "newton", + ) -> None: + super().__init__(parent) + self.setModal(True) + self.resize(360, 200) + + root = QVBoxLayout(self) + form = QFormLayout() + + self.window_label = QLabel() + self.window_spin = QDoubleSpinBox() + self.window_spin.setRange(1.0, 30.0) + self.window_spin.setSingleStep(1.0) + self.window_spin.setDecimals(0) + self.window_spin.setSuffix(" s") + self.window_spin.setValue(float(window_sec)) + + self.fn_max_label = QLabel() + self.fn_max_spin = QDoubleSpinBox() + self.fn_max_spin.setRange(1.0, 100.0) + self.fn_max_spin.setSingleStep(1.0) + self.fn_max_spin.setDecimals(0) + self.fn_max_spin.setSuffix(" N") + self.fn_max_spin.setValue(float(fn_max)) + + self.shear_max_label = QLabel() + self.shear_max_spin = QDoubleSpinBox() + self.shear_max_spin.setRange(1.0, 100.0) + self.shear_max_spin.setSingleStep(1.0) + self.shear_max_spin.setDecimals(0) + self.shear_max_spin.setSuffix(" N") + self.shear_max_spin.setValue(float(shear_max)) + + form.addRow(self.window_label, self.window_spin) + form.addRow(self.fn_max_label, self.fn_max_spin) + form.addRow(self.shear_max_label, self.shear_max_spin) + root.addLayout(form) + + newton = str(units or "newton").lower().startswith("n") + self.fn_max_spin.setEnabled(newton) + self.shear_max_spin.setEnabled(newton) + self.fn_max_label.setEnabled(newton) + self.shear_max_label.setEnabled(newton) + + buttons_row = QHBoxLayout() + self.btn_reset = QPushButton() + self.btn_reset.clicked.connect(self._reset_defaults) + buttons_row.addWidget(self.btn_reset) + buttons_row.addStretch(1) + self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + self.button_box.accepted.connect(self.accept) + self.button_box.rejected.connect(self.reject) + buttons_row.addWidget(self.button_box) + root.addLayout(buttons_row) + + self.retranslate() + + def values(self) -> tuple[float, float, float]: + return ( + float(self.window_spin.value()), + float(self.fn_max_spin.value()), + float(self.shear_max_spin.value()), + ) + + def _reset_defaults(self) -> None: + self.window_spin.setValue(DEFAULT_PLOT_WINDOW_SEC) + self.fn_max_spin.setValue(DEFAULT_PLOT_FN_MAX) + self.shear_max_spin.setValue(DEFAULT_PLOT_SHEAR_MAX) + + def retranslate(self) -> None: + self.setWindowTitle(t("dialog.plot_settings_title")) + self.window_label.setText(t("dialog.plot_window")) + self.window_spin.setToolTip(t("dialog.plot_window_tip")) + self.fn_max_label.setText(t("dialog.plot_fn_max")) + self.fn_max_spin.setToolTip(t("dialog.plot_fn_max_tip")) + self.shear_max_label.setText(t("dialog.plot_shear_max")) + self.shear_max_spin.setToolTip(t("dialog.plot_shear_max_tip")) + self.btn_reset.setText(t("dialog.reset_defaults")) + ok_btn = self.button_box.button(QDialogButtonBox.Ok) + cancel_btn = self.button_box.button(QDialogButtonBox.Cancel) + if ok_btn is not None: + ok_btn.setText(t("dialog.ok")) + if cancel_btn is not None: + cancel_btn.setText(t("dialog.cancel")) diff --git a/apps/studio/src/orisys-studio/ui/sensor_picker.py b/apps/studio/src/orisys-studio/ui/sensor_picker.py new file mode 100644 index 0000000..b781c0d --- /dev/null +++ b/apps/studio/src/orisys-studio/ui/sensor_picker.py @@ -0,0 +1,145 @@ +"""Sensor type + config picker with product badge.""" + +from __future__ import annotations + +from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtWidgets import ( + QComboBox, + QFormLayout, + QFrame, + QHBoxLayout, + QLabel, + QSizePolicy, + QVBoxLayout, + QWidget, +) + +from orisys_app_shared.i18n import t +from orisys_app_shared.sensors import list_configs_for_type, list_sensor_types + +THUMB_SIZE = 56 +CONTROL_HEIGHT = 30 +MAX_CONTROL_WIDTH = 220 + + +class SensorPickerWidget(QWidget): + sensor_type_changed = pyqtSignal(str) + config_changed = pyqtSignal(str) + + def __init__(self, parent=None) -> None: + super().__init__(parent) + layout = QHBoxLayout(self) + layout.setContentsMargins(8, 6, 8, 6) + layout.setSpacing(10) + + self.thumb = QFrame() + self.thumb.setObjectName("cardPanel") + self.thumb.setFixedSize(THUMB_SIZE, THUMB_SIZE) + thumb_layout = QVBoxLayout(self.thumb) + thumb_layout.setContentsMargins(2, 2, 2, 2) + thumb_layout.setSpacing(0) + self.thumb_label = QLabel() + self.thumb_label.setObjectName("unitBadge") + self.thumb_label.setAlignment(Qt.AlignCenter) + self.thumb_label.setWordWrap(False) + thumb_layout.addWidget(self.thumb_label) + + form = QFormLayout() + form.setContentsMargins(0, 0, 0, 0) + form.setHorizontalSpacing(8) + form.setVerticalSpacing(6) + form.setLabelAlignment(Qt.AlignRight | Qt.AlignVCenter) + form.setFieldGrowthPolicy(QFormLayout.ExpandingFieldsGrow) + + self.type_label = QLabel() + self.type_label.setObjectName("fieldLabel") + self.type_combo = QComboBox() + self.type_combo.setMinimumHeight(CONTROL_HEIGHT) + self.type_combo.setMaximumWidth(MAX_CONTROL_WIDTH) + self.type_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + for name in list_sensor_types(): + self.type_combo.addItem(name, name) + self.type_combo.currentIndexChanged.connect(self._on_type_index_changed) + + self.config_label = QLabel() + self.config_label.setObjectName("fieldLabel") + self.config_combo = QComboBox() + self.config_combo.setMinimumHeight(CONTROL_HEIGHT) + self.config_combo.setMaximumWidth(MAX_CONTROL_WIDTH) + self.config_combo.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) + self.config_combo.currentIndexChanged.connect(self._on_config_index_changed) + + form.addRow(self.type_label, self.type_combo) + form.addRow(self.config_label, self.config_combo) + + layout.addWidget(self.thumb) + layout.addLayout(form, 1) + self._reload_configs() + self._refresh_thumb() + self.retranslate() + + def sensor_type(self) -> str: + data = self.type_combo.currentData() + return str(data or self.type_combo.currentText() or "box") + + def config_name(self) -> str: + data = self.config_combo.currentData() + if data is None: + return str(self.config_combo.currentText() or "") + return str(data) + + def set_sensor_type(self, sensor_type: str) -> None: + wanted = str(sensor_type or "box") + idx = self.type_combo.findData(wanted) + if idx < 0: + idx = self.type_combo.findText(wanted) + if idx < 0: + idx = 0 + self.type_combo.blockSignals(True) + self.type_combo.setCurrentIndex(idx) + self.type_combo.blockSignals(False) + self._reload_configs() + self._refresh_thumb() + + def set_config_name(self, config_name: str) -> None: + wanted = str(config_name or "") + idx = self.config_combo.findData(wanted) + if idx < 0 and wanted: + idx = self.config_combo.findText(wanted) + if idx < 0: + idx = 0 + self.config_combo.blockSignals(True) + self.config_combo.setCurrentIndex(max(0, idx)) + self.config_combo.blockSignals(False) + + def set_picker_enabled(self, enabled: bool) -> None: + """Enable/disable both pickers (used while a session is running).""" + self.type_combo.setEnabled(bool(enabled)) + self.config_combo.setEnabled(bool(enabled)) + + def _reload_configs(self) -> None: + self.config_combo.blockSignals(True) + self.config_combo.clear() + for display, key in list_configs_for_type(self.sensor_type()): + self.config_combo.addItem(display, key) + if self.config_combo.count() == 0: + self.config_combo.addItem(self.sensor_type(), self.sensor_type()) + self.config_combo.blockSignals(False) + + def _refresh_thumb(self) -> None: + self.thumb_label.setText(self.sensor_type().upper()) + + def _on_type_index_changed(self, _index: int) -> None: + self._reload_configs() + self._refresh_thumb() + self.sensor_type_changed.emit(self.sensor_type()) + self.config_changed.emit(self.config_name()) + + def _on_config_index_changed(self, _index: int) -> None: + self.config_changed.emit(self.config_name()) + + def retranslate(self) -> None: + self.type_label.setText(t("sensor.type")) + self.config_label.setText(t("sensor.config")) + self.thumb.setToolTip(t("sensor.thumb_placeholder")) + self._refresh_thumb() diff --git a/apps/studio/src/orisys-studio/ui/toolbar.py b/apps/studio/src/orisys-studio/ui/toolbar.py new file mode 100644 index 0000000..1fa7849 --- /dev/null +++ b/apps/studio/src/orisys-studio/ui/toolbar.py @@ -0,0 +1,348 @@ +"""Top toolbar: source, camera/video, run/reset, units.""" + +from __future__ import annotations + +from pathlib import Path + +from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtWidgets import ( + QComboBox, + QFrame, + QHBoxLayout, + QLabel, + QLineEdit, + QPushButton, + QScrollArea, + QSizePolicy, + QSpinBox, + QStackedWidget, + QVBoxLayout, + QWidget, +) + +from orisys_app_shared.i18n import t + +CONTROL_HEIGHT = 30 +BUTTON_HEIGHT = 32 + + +def _separator() -> QFrame: + line = QFrame() + line.setObjectName("sectionSeparator") + line.setFrameShape(QFrame.VLine) + line.setFixedWidth(1) + line.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding) + return line + + +def _group(*widgets: QWidget) -> QWidget: + box = QWidget() + layout = QHBoxLayout(box) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(8) + for widget in widgets: + layout.addWidget(widget) + return box + + +class ToolbarWidget(QWidget): + search_clicked = pyqtSignal() + preview_toggled = pyqtSignal(bool) + camera_id_changed = pyqtSignal(int) + source_mode_changed = pyqtSignal(str) + browse_video_clicked = pyqtSignal() + run_toggled = pyqtSignal(bool) + reset_clicked = pyqtSignal() + units_changed = pyqtSignal(str) + + def __init__(self, parent=None) -> None: + super().__init__(parent) + self._run_tooltip_reason = "" + self._run_available = True + outer = QVBoxLayout(self) + outer.setContentsMargins(0, 0, 0, 0) + outer.setSpacing(0) + + self._scroll = QScrollArea() + self._scroll.setWidgetResizable(True) + self._scroll.setFrameShape(QFrame.NoFrame) + self._scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + outer.addWidget(self._scroll) + + content = QWidget() + self._scroll.setWidget(content) + layout = QHBoxLayout(content) + layout.setContentsMargins(2, 2, 2, 2) + layout.setSpacing(10) + + # --- source group ------------------------------------------------- + self.source_label = QLabel() + self.source_label.setObjectName("fieldLabel") + self.source_combo = QComboBox() + self.source_combo.setMinimumWidth(96) + self.source_combo.setMinimumHeight(CONTROL_HEIGHT) + self.source_combo.currentIndexChanged.connect(self._on_source_index_changed) + + self.source_stack = QStackedWidget() + cam_page = QWidget() + cam_layout = QHBoxLayout(cam_page) + cam_layout.setContentsMargins(0, 0, 0, 0) + cam_layout.setSpacing(8) + self.cam_id_label = QLabel() + self.cam_id_label.setObjectName("fieldLabel") + self.cam_id_spin = QSpinBox() + self.cam_id_spin.setRange(0, 20) + self.cam_id_spin.setValue(0) + self.cam_id_spin.setFixedWidth(72) + self.cam_id_spin.setMinimumHeight(CONTROL_HEIGHT) + self.cam_id_spin.valueChanged.connect(self.camera_id_changed.emit) + cam_layout.addWidget(self.cam_id_label) + cam_layout.addWidget(self.cam_id_spin) + + video_page = QWidget() + video_layout = QHBoxLayout(video_page) + video_layout.setContentsMargins(0, 0, 0, 0) + video_layout.setSpacing(8) + self.video_edit = QLineEdit() + self.video_edit.setMinimumWidth(180) + self.video_edit.setMinimumHeight(CONTROL_HEIGHT) + self.video_edit.setReadOnly(True) + self.btn_browse_video = QPushButton() + self.btn_browse_video.setMinimumHeight(BUTTON_HEIGHT) + self.btn_browse_video.clicked.connect(self.browse_video_clicked.emit) + video_layout.addWidget(self.video_edit, 1) + video_layout.addWidget(self.btn_browse_video) + + self.source_stack.addWidget(cam_page) + self.source_stack.addWidget(video_page) + + # --- action group ------------------------------------------------- + self.btn_search = QPushButton() + self.btn_search.setMinimumHeight(BUTTON_HEIGHT) + self.btn_search.clicked.connect(self.search_clicked.emit) + + self.btn_preview = QPushButton() + self.btn_preview.setMinimumHeight(BUTTON_HEIGHT) + self.btn_preview.setCheckable(True) + self.btn_preview.toggled.connect(self._on_preview_toggled) + + self.btn_run = QPushButton() + self.btn_run.setObjectName("primaryButton") + self.btn_run.setMinimumHeight(BUTTON_HEIGHT) + self.btn_run.setMinimumWidth(96) + self.btn_run.setCheckable(True) + self.btn_run.toggled.connect(self._on_run_toggled) + + self.btn_reset = QPushButton() + self.btn_reset.setMinimumHeight(BUTTON_HEIGHT) + self.btn_reset.setEnabled(False) + self.btn_reset.clicked.connect(self.reset_clicked.emit) + + # --- units group -------------------------------------------------- + self.units_label = QLabel() + self.units_label.setObjectName("fieldLabel") + self.units_combo = QComboBox() + self.units_combo.setFixedWidth(96) + self.units_combo.setMinimumHeight(CONTROL_HEIGHT) + self.units_combo.currentIndexChanged.connect(self._on_units_index_changed) + + layout.addWidget( + _group(self.source_label, self.source_combo, self.source_stack) + ) + layout.addWidget(_separator()) + layout.addWidget( + _group(self.btn_search, self.btn_preview, self.btn_run, self.btn_reset) + ) + layout.addWidget(_separator()) + layout.addWidget(_group(self.units_label, self.units_combo)) + layout.addStretch(1) + + self._populate_source_combo() + self._populate_units_combo() + self.retranslate() + self._apply_source_mode_ui() + + # ------------------------------------------------------------------ API + def source_mode(self) -> str: + data = self.source_combo.currentData() + return str(data or "camera") + + def set_source_mode(self, mode: str) -> None: + wanted = "video" if str(mode).lower().startswith("v") else "camera" + idx = self.source_combo.findData(wanted) + if idx < 0: + idx = 0 + self.source_combo.blockSignals(True) + self.source_combo.setCurrentIndex(idx) + self.source_combo.blockSignals(False) + self._apply_source_mode_ui() + + def camera_id(self) -> int: + return int(self.cam_id_spin.value()) + + def set_camera_id(self, value: int) -> None: + self.cam_id_spin.blockSignals(True) + self.cam_id_spin.setValue(int(value)) + self.cam_id_spin.blockSignals(False) + + def video_path(self) -> str: + return self.video_edit.text().strip() + + def set_video_path(self, path: str) -> None: + text = str(path or "").strip() + self.video_edit.setText(text) + self.video_edit.setToolTip(text) + + def units(self) -> str: + data = self.units_combo.currentData() + return str(data or "newton") + + def set_units(self, units: str) -> None: + wanted = "raw" if str(units).lower().startswith("r") else "newton" + idx = self.units_combo.findData(wanted) + if idx < 0: + idx = 0 + self.units_combo.blockSignals(True) + self.units_combo.setCurrentIndex(idx) + self.units_combo.blockSignals(False) + + def set_preview_checked(self, checked: bool) -> None: + self.btn_preview.blockSignals(True) + self.btn_preview.setChecked(bool(checked)) + self.btn_preview.blockSignals(False) + self.refresh_labels() + + def set_run_checked(self, checked: bool) -> None: + self.btn_run.blockSignals(True) + self.btn_run.setChecked(bool(checked)) + self.btn_run.blockSignals(False) + self.refresh_labels() + if not checked: + self.btn_reset.setEnabled(False) + + def set_reset_enabled(self, enabled: bool) -> None: + self.btn_reset.setEnabled(bool(enabled)) + + def set_camera_controls_enabled(self, enabled: bool) -> None: + self.btn_search.setEnabled(enabled and self.source_mode() == "camera") + self.cam_id_spin.setEnabled(enabled and self.source_mode() == "camera") + + def set_source_controls_enabled(self, enabled: bool) -> None: + self.source_combo.setEnabled(enabled) + self.btn_browse_video.setEnabled(enabled and self.source_mode() == "video") + self.set_camera_controls_enabled(enabled) + self.btn_preview.setEnabled(enabled and self.source_mode() == "camera") + + def set_run_enabled(self, enabled: bool) -> None: + self._run_available = bool(enabled) + self.btn_run.setEnabled(bool(enabled)) + self._apply_run_tooltip() + + def set_run_tooltip_reason(self, reason: str) -> None: + """Explain *why* Run is unavailable (shown in the button tooltip).""" + self._run_tooltip_reason = str(reason or "") + self._apply_run_tooltip() + + def active_source(self) -> str | int | None: + if self.source_mode() == "video": + path = self.video_path() + return path if path else None + return self.camera_id() + + def refresh_labels(self) -> None: + """Public refresh hook (kept separate from ``retranslate`` for reuse).""" + self._refresh_preview_label() + self._refresh_run_label() + + # ------------------------------------------------------------- internals + def _populate_source_combo(self) -> None: + self.source_combo.blockSignals(True) + self.source_combo.clear() + self.source_combo.addItem("", "camera") + self.source_combo.addItem("", "video") + self.source_combo.blockSignals(False) + + def _populate_units_combo(self) -> None: + current = self.units_combo.currentData() + self.units_combo.blockSignals(True) + self.units_combo.clear() + self.units_combo.addItem("", "newton") + self.units_combo.addItem("", "raw") + if current: + idx = self.units_combo.findData(current) + self.units_combo.setCurrentIndex(max(0, idx)) + self.units_combo.blockSignals(False) + + def _apply_source_mode_ui(self) -> None: + video = self.source_mode() == "video" + self.source_stack.setCurrentIndex(1 if video else 0) + self.btn_search.setEnabled(not video) + self.btn_preview.setEnabled(not video) + if video and self.btn_preview.isChecked(): + self.set_preview_checked(False) + + def _on_source_index_changed(self, _index: int) -> None: + self._apply_source_mode_ui() + self.source_mode_changed.emit(self.source_mode()) + + def _on_preview_toggled(self, checked: bool) -> None: + self._refresh_preview_label() + self.preview_toggled.emit(bool(checked)) + + def _on_run_toggled(self, checked: bool) -> None: + self._refresh_run_label() + if not checked: + self.btn_reset.setEnabled(False) + self.run_toggled.emit(bool(checked)) + + def _on_units_index_changed(self, _index: int) -> None: + self.units_changed.emit(self.units()) + + def _refresh_preview_label(self) -> None: + if self.btn_preview.isChecked(): + self.btn_preview.setText(t("toolbar.preview_stop")) + else: + self.btn_preview.setText(t("toolbar.preview")) + + def _refresh_run_label(self) -> None: + if self.btn_run.isChecked(): + self.btn_run.setText(t("toolbar.stop_label")) + else: + self.btn_run.setText(t("toolbar.run_label")) + self.btn_run.setObjectName( + "dangerButton" if self.btn_run.isChecked() else "primaryButton" + ) + self.btn_run.style().unpolish(self.btn_run) + self.btn_run.style().polish(self.btn_run) + self._apply_run_tooltip() + + def _apply_run_tooltip(self) -> None: + if self.btn_run.isChecked(): + self.btn_run.setToolTip(t("toolbar.stop")) + elif not self._run_available and self._run_tooltip_reason: + self.btn_run.setToolTip(self._run_tooltip_reason) + elif not self._run_available: + self.btn_run.setToolTip(t("toolbar.run_unavailable")) + else: + tip = t("toolbar.run") + if self._run_tooltip_reason: + tip = f"{tip} — {self._run_tooltip_reason}" + self.btn_run.setToolTip(tip) + + def retranslate(self) -> None: + self.source_label.setText(t("toolbar.source")) + self.source_combo.setItemText(0, t("toolbar.source_camera")) + self.source_combo.setItemText(1, t("toolbar.source_video")) + self.cam_id_label.setText(t("toolbar.cam_id")) + self.btn_browse_video.setText(t("toolbar.browse_video")) + path = self.video_path() + self.video_edit.setPlaceholderText( + Path(path).name if path else t("toolbar.video_placeholder") + ) + self.btn_search.setText(t("toolbar.search_cameras")) + self.refresh_labels() + self.btn_reset.setText(t("toolbar.reset")) + self.units_label.setText(t("toolbar.units")) + self.units_combo.setItemText(0, t("toolbar.units_newton")) + self.units_combo.setItemText(1, t("toolbar.units_raw")) diff --git a/apps/studio/tests/conftest.py b/apps/studio/tests/conftest.py new file mode 100644 index 0000000..d6248c8 --- /dev/null +++ b/apps/studio/tests/conftest.py @@ -0,0 +1,79 @@ +"""Shared fixtures for the customer app UI tests. + +The tests render real Qt widgets with the offscreen platform plugin, so they need +no display and no camera hardware. A single ``QApplication`` is shared by the +whole session (Qt forbids creating more than one). +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import pytest + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ: + _fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts" + if _fonts.is_dir(): + os.environ["QT_QPA_FONTDIR"] = str(_fonts) + +# ``tools/ui_check.py`` owns the palette rules; importing it keeps one source of +# truth between the pytest suite and the dependency-free checker. +_TOOLS_DIR = Path(__file__).resolve().parents[1] / "tools" +if _TOOLS_DIR.is_dir() and str(_TOOLS_DIR) not in sys.path: + sys.path.insert(0, str(_TOOLS_DIR)) + + +@pytest.fixture(scope="session") +def qt_app(): + from PyQt5.QtWidgets import QApplication + + app = QApplication.instance() or QApplication([]) + yield app + + +@pytest.fixture +def make_window(qt_app, tmp_path, monkeypatch): + """Build a ``CustomerMainWindow`` whose preference writes stay in tmp_path.""" + from orisys_app_shared import prefs as prefs_pkg + from orisys_app_shared import paths as paths_mod + from orisys_customer.app_window import CustomerMainWindow + + prefs_file = tmp_path / "preference.json" + monkeypatch.setattr(paths_mod, "preference_path", lambda: prefs_file) + monkeypatch.setattr(prefs_pkg.store, "preference_path", lambda: prefs_file) + + default_prefs = { + "language": "zh_CN", + "theme": "dark", + "sensor_type": "box", + "source_mode": "camera", + } + + created = [] + + def _make(**overrides): + data = dict(default_prefs) + data.update(overrides) + window = CustomerMainWindow(prefs=data) + created.append(window) + return window + + yield _make + + for window in created: + try: + window.close() + except Exception: + pass + + +def visible_descendants(widget): + """Yield every visible descendant widget of ``widget``.""" + for child in widget.findChildren(object): + if not hasattr(child, "isVisible") or not hasattr(child, "geometry"): + continue + if child.isVisible(): + yield child diff --git a/apps/studio/tests/test_locales.py b/apps/studio/tests/test_locales.py new file mode 100644 index 0000000..d4b6c5d --- /dev/null +++ b/apps/studio/tests/test_locales.py @@ -0,0 +1,76 @@ +"""Static checks: translation keys and preference defaults stay in sync.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +APP_ROOT = Path(__file__).resolve().parents[1] +SRC_DIR = APP_ROOT / "src" +LOCALES_DIR = APP_ROOT / "config" / "locales" +DEFAULTS_JSON = APP_ROOT / "config" / "preferences.default.json" + +REPO_ROOT = APP_ROOT.parents[1] +STORE_PY = REPO_ROOT / "shared" / "src" / "orisys_app_shared" / "prefs" / "store.py" + +_T_CALL = re.compile(r"""\bt\(\s*["']([A-Za-z0-9_.]+)["']""") +_DEFAULT_BLOCK = re.compile(r"_DEFAULTS[^=]*=\s*\{(?P.*?)\n\}", re.DOTALL) +_KEY = re.compile(r"""["']([A-Za-z0-9_]+)["']\s*:""") + + +def _load_locale(name: str) -> dict: + return json.loads((LOCALES_DIR / f"{name}.json").read_text(encoding="utf-8")) + + +def _flatten(data: dict, prefix: str = "") -> set[str]: + keys: set[str] = set() + for key, value in data.items(): + dotted = f"{prefix}{key}" + if isinstance(value, dict): + keys |= _flatten(value, f"{dotted}.") + else: + keys.add(dotted) + return keys + + +def _referenced_keys() -> set[str]: + found: set[str] = set() + for path in sorted(SRC_DIR.rglob("*.py")): + found |= set(_T_CALL.findall(path.read_text(encoding="utf-8"))) + return found + + +def test_locales_parity_and_used_keys(): + zh = _flatten(_load_locale("zh_CN")) + en = _flatten(_load_locale("en_US")) + + assert zh == en, { + "zh_only": sorted(zh - en), + "en_only": sorted(en - zh), + } + + referenced = _referenced_keys() + assert referenced, "no t() calls found — the source scan is broken" + missing = sorted(key for key in referenced if key not in zh) + assert not missing, f"t() keys missing from locale files: {missing}" + + +def test_preference_defaults_match_store_defaults(): + json_keys = set(json.loads(DEFAULTS_JSON.read_text(encoding="utf-8"))) + source = STORE_PY.read_text(encoding="utf-8") + match = _DEFAULT_BLOCK.search(source) + assert match, "could not locate _DEFAULTS in prefs/store.py" + store_keys = set(_KEY.findall(match.group("body"))) + + assert json_keys == store_keys, { + "json_only": sorted(json_keys - store_keys), + "store_only": sorted(store_keys - json_keys), + } + + +def test_locale_files_are_valid_utf8_json(): + for name in ("zh_CN", "en_US"): + data = _load_locale(name) + assert isinstance(data, dict) + assert data.get("app", {}).get("title") diff --git a/apps/studio/tests/test_ui_smoke.py b/apps/studio/tests/test_ui_smoke.py new file mode 100644 index 0000000..8e13efe --- /dev/null +++ b/apps/studio/tests/test_ui_smoke.py @@ -0,0 +1,235 @@ +"""Offscreen UI smoke tests: construction, layout adaptation, theme tokens.""" + +from __future__ import annotations + +import pytest + +from tests.conftest import visible_descendants + +RESOLUTIONS = ((1280, 720), (1600, 900), (1920, 1080)) + + +def _show(window, size): + window.resize(*size) + window.show() + from PyQt5.QtWidgets import QApplication + + QApplication.processEvents() + return window + + +@pytest.mark.parametrize(("theme", "sensor_type"), [ + ("dark", "box"), + ("light", "box"), + ("dark", "finger"), + ("light", "finger"), +]) +def test_window_builds_in_every_theme(make_window, theme, sensor_type): + window = _show(make_window(theme=theme, sensor_type=sensor_type), (1400, 880)) + assert window.windowTitle() + assert window.content_splitter.count() == 4 + + +@pytest.mark.parametrize("size", RESOLUTIONS) +def test_panels_fit_within_window(make_window, size): + window = _show( + make_window(sensor_type="finger", show_cal_panel=True), + size, + ) + sizes = window.content_splitter.sizes() + panels = [ + window.mesh3d_panel, + window.arrow_panel, + window.force_plots, + window.cal_panel, + ] + for panel, width in zip(panels, sizes): + assert panel.isVisible() + assert width >= panel.minimumWidth(), ( + f"{panel.objectName() or type(panel).__name__} too narrow: {width}" + ) + assert window.force_plots.width() >= window.force_plots.minimumWidth() + assert window.arrow_panel.width() >= window.arrow_panel.minimumWidth() + + +@pytest.mark.parametrize("size", RESOLUTIONS) +def test_children_stay_inside_their_parent(make_window, size): + window = _show(make_window(sensor_type="finger", show_cal_panel=True), size) + oversize = [] + for child in visible_descendants(window): + parent = child.parentWidget() + if parent is None or not parent.isVisible(): + continue + geometry = child.geometry() + if geometry.width() > parent.width() + 1 or geometry.height() > parent.height() + 1: + oversize.append( + f"{type(child).__name__} {geometry.width()}x{geometry.height()}" + f" > parent {parent.width()}x{parent.height()}" + ) + assert not oversize, oversize + + +def test_theme_tokens_have_identical_keys(): + from orisys_app_shared.theme import build_qss, load_colors + + dark = set(load_colors("dark")) + light = set(load_colors("light")) + assert dark == light, {"dark_only": sorted(dark - light), "light_only": sorted(light - dark)} + assert len(build_qss("dark")) > 1000 + assert len(build_qss("light")) > 1000 + + +def test_arrow_canvas_differs_from_accent(): + """The pure-color arrow canvas must not collide with the accent pen color.""" + from orisys_app_shared.theme import load_colors + + for theme in ("dark", "light"): + colors = load_colors(theme) + assert colors["arrow_canvas"] != colors["accent"], theme + + +def test_palette_rules_hold(): + """WCAG contrast and colour-separation rules from ``tools/ui_check.py``.""" + import ui_check + + assert ui_check.audit_palette() == [] + + +def test_flow_colormap_follows_the_theme_ramp(): + from orisys_app_shared.acquisition.arrows import ( + _lut_from_ramp, + flow_colormap, + hex_to_bgr, + ) + from orisys_app_shared.theme import load_colors + + for theme in ("dark", "light"): + stops = [s.strip() for s in load_colors(theme)["flow_ramp"].split(",")] + lut = flow_colormap(theme) + assert lut.shape == (256, 1, 3) + assert tuple(int(v) for v in lut[0, 0]) == hex_to_bgr(stops[0]) + assert tuple(int(v) for v in lut[-1, 0]) == hex_to_bgr(stops[-1]) + + # Malformed ramps must never raise; they fall back to the built-in ramp. + fallback = _lut_from_ramp("not,a,ramp") + assert fallback.shape == (256, 1, 3) + + +def test_brand_bar_renders_and_localizes(make_window): + from orisys_app_shared.i18n import t + + window = _show(make_window(language="zh_CN"), (1440, 880)) + bar = window.brand_bar + assert bar.height() >= 28 + assert bar.name.text() + assert bar.product.text() == t("app.title") + + zh_tagline = bar.tagline.text() + window._set_language("en_US") + assert bar.tagline.text() != zh_tagline + assert bar.name.text() == "ORISYS" + + logo = bar.logo.pixmap() + if logo is not None and not logo.isNull(): + image = logo.toImage() + alphas = [ + image.pixelColor(x, y).alpha() + for y in range(image.height()) + for x in range(image.width()) + ] + assert any(a < 32 for a in alphas) + assert any(a >= 200 for a in alphas) + + +def test_brand_bar_background_comes_from_tokens(make_window): + from orisys_app_shared.theme import load_colors + + window = _show(make_window(), (1440, 880)) + for theme in ("dark", "light"): + window._set_theme(theme) + colors = load_colors(theme) + assert colors["brand_bar_bg"] != colors["window_bg"], theme + assert window.brand_bar._theme == theme + + +def test_disabled_buttons_use_muted_text_not_hover_background(): + from orisys_app_shared.theme import build_qss, load_colors + + for theme in ("dark", "light"): + colors = load_colors(theme) + qss = build_qss(theme) + assert f"color: {colors['text_muted']}" in qss, theme + # The old rule painted disabled buttons with the brighter hover color. + assert f"background-color: {colors['button_hover']};\n border-color: {colors['panel_border']}" not in qss + + +def test_splitter_sizes_round_trip_through_prefs(make_window): + window = _show(make_window(), (1440, 880)) + window.content_splitter.setSizes([300, 420, 520, 0]) + window._persist_prefs() + stored = window.prefs["splitter_sizes"] + assert len(stored) == 4 + assert stored[2] > stored[0] + + +def test_status_chip_tracks_phase(make_window): + window = _show(make_window(), (1400, 880)) + assert window._session_phase == "idle" + window._set_phase("running") + assert window.status_chip.text() + assert "status_running" not in window.status_chip.text() + window._set_phase("nonexistent-phase") + assert window._session_phase == "idle" + + +def test_run_tooltip_explains_disabled_reason(make_window): + window = _show(make_window(source_mode="video", video_path=""), (1400, 880)) + assert not window.toolbar.btn_run.isEnabled() + assert window.toolbar.btn_run.toolTip() + + window.toolbar.set_video_path("C:/tmp/demo.mp4") + window._update_run_enabled() + assert window.toolbar.btn_run.isEnabled() + + +def test_toolbar_run_button_is_labelled(make_window): + window = _show(make_window(), (1400, 880)) + label = window.toolbar.btn_run.text() + assert label.strip() + assert label != "▶" + window.toolbar.set_run_checked(True) + assert window.toolbar.btn_run.text() != label + + +def test_theme_switch_updates_panels(make_window): + window = _show(make_window(), (1400, 880)) + window._set_theme("light") + assert window._theme == "light" + window._set_theme("dark") + assert window._theme == "dark" + + +def test_cal_panel_records_and_lists_samples(make_window): + window = _show(make_window(), (1400, 880)) + cal = window.cal_panel + cal.set_running(True) + assert cal.btn_record.isEnabled() + assert cal.btn_record.text() + + from orisys_app_shared.force.calibration_io import CalSample + + cal._store.upsert("fn", CalSample(weight_n=1.0, raw_mean=9.7, n=120, std=0.02)) + cal._refresh_table() + assert cal.table.rowCount() == 1 + assert not cal.empty_hint.isVisible() + + +def test_force_plots_render_demo_data(make_window): + window = _show(make_window(), (1440, 880)) + for i in range(60): + window.force_plots.push(i / 30.0, float(i), -1.0, 1.0, unit="N") + from PyQt5.QtWidgets import QApplication + + QApplication.processEvents() + assert window.force_plots.history.sample_count > 0 + assert all(label.text() for label in window.force_plots.value_labels) diff --git a/apps/studio/tools/ui_check.py b/apps/studio/tools/ui_check.py new file mode 100644 index 0000000..6117826 --- /dev/null +++ b/apps/studio/tools/ui_check.py @@ -0,0 +1,450 @@ +#!/usr/bin/env python3 +"""Dependency-free smoke run for the customer UI. + +``pytest`` is the canonical runner (``apps/customer/tests``), but this script +needs nothing beyond the app's own runtime dependencies, so it can be used in +minimal environments: + + python tools/ui_check.py + +It builds the real main window offscreen and asserts the layout, theming and +status-feedback invariants that the UI work depends on. +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +APP_ROOT = Path(__file__).resolve().parents[1] +REPO_ROOT = APP_ROOT.parents[1] +for _path in (APP_ROOT / "src", REPO_ROOT / "shared" / "src"): + if _path.is_dir() and str(_path) not in sys.path: + sys.path.insert(0, str(_path)) + +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ: + _fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts" + if _fonts.is_dir(): + os.environ["QT_QPA_FONTDIR"] = str(_fonts) +os.chdir(APP_ROOT) + +FAILURES: list[str] = [] +CHECKS = 0 + + +def check(condition: bool, message: str) -> None: + global CHECKS + CHECKS += 1 + if not condition: + FAILURES.append(message) + + +def section(name: str) -> None: + print(f"\n== {name} ==") + + +# -------------------------------------------------------------------------- +# Palette rules +# +# These are the WCAG / colour-separation invariants the theme tokens must +# satisfy. ``tests/test_ui_smoke.py`` imports :func:`audit_palette` from here so +# both runners share one source of truth. +# -------------------------------------------------------------------------- + +TEXT_MIN_RATIO = 4.5 +GRAPHIC_MIN_RATIO = 3.0 +VIZ_MIN_HUE_GAP = 90.0 +VIZ_MIN_LUMINANCE_RATIO = 1.2 +RAMP_BLEND_STOPS = 2 # the low stops may merge into the canvas + +TEXT_PAIRS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("text_primary", ( + "window_bg", "panel_bg", "card_bg", "toolbar_bg", "input_bg", + "table_header_bg", "table_row_alt", "menu_bg", "button_bg", + "table_selected", "scrollbar_bg", + )), + ("text_secondary", ( + "window_bg", "panel_bg", "card_bg", "toolbar_bg", + "table_header_bg", "button_bg", "input_bg", "menu_bg", + )), + ("text_muted", ("window_bg", "panel_bg", "card_bg", "toolbar_bg", "input_bg")), + ("hint_text", ("card_bg", "panel_bg", "window_bg", "input_bg")), + ("accent_text", ("accent", "accent_hover", "danger_fill", "danger_fill_hover")), + ("danger_text", ("danger_fill", "danger_fill_hover")), + ("danger", ("card_bg", "panel_bg")), + ("danger_hover", ("card_bg", "panel_bg")), + ("warning", ("card_bg", "panel_bg")), + ("info", ("card_bg", "panel_bg")), + ("success", ("card_bg", "panel_bg")), + ("status_idle", ("panel_bg",)), + ("status_connecting", ("panel_bg",)), + ("status_running", ("panel_bg",)), + ("status_error", ("panel_bg",)), + ("brand_bar_fg", ("brand_bar_bg",)), + ("brand_bar_fg_muted", ("brand_bar_bg",)), +) + +GRAPHIC_PAIRS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("plot_fn", ("plot_bg",)), + ("plot_fx", ("plot_bg",)), + ("plot_fy", ("plot_bg",)), + ("scrollbar_handle", ("scrollbar_bg",)), + ("scrollbar_handle_hover", ("scrollbar_bg",)), + ("accent", ("panel_bg", "window_bg", "toolbar_bg")), + ("accent_hover", ("panel_bg", "toolbar_bg")), + ("focus_ring", ("window_bg", "input_bg", "panel_bg", "card_bg")), + ("input_border", ("input_bg", "panel_bg", "card_bg", "toolbar_bg")), +) + +VIZ_KEYS = ("plot_fn", "plot_fx", "plot_fy") + +# Deliberate, documented exemptions (see apps/customer/docs/ui-palette.md): +# decorative 1px container outlines, and disabled states which WCAG 1.4.3 +# explicitly exempts. +DECORATIVE_PAIRS: tuple[tuple[str, tuple[str, ...]], ...] = ( + ("card_border", ("card_bg",)), + ("panel_border", ("panel_bg", "window_bg")), + ("plot_grid", ("plot_bg",)), + ("separator", ("toolbar_bg", "panel_bg")), +) + + +def _srgb_channel(value: int) -> float: + channel = value / 255.0 + if channel <= 0.03928: + return channel / 12.92 + return ((channel + 0.055) / 1.055) ** 2.4 + + +def _rgb(hex_color: str) -> tuple[int, int, int]: + text = str(hex_color).strip().lstrip("#") + return tuple(int(text[i : i + 2], 16) for i in (0, 2, 4)) # type: ignore[return-value] + + +def relative_luminance(hex_color: str) -> float: + red, green, blue = _rgb(hex_color) + return ( + 0.2126 * _srgb_channel(red) + + 0.7152 * _srgb_channel(green) + + 0.0722 * _srgb_channel(blue) + ) + + +def contrast_ratio(first: str, second: str) -> float: + high = relative_luminance(first) + low = relative_luminance(second) + if high < low: + high, low = low, high + return (high + 0.05) / (low + 0.05) + + +def hue_degrees(hex_color: str) -> float: + red, green, blue = (value / 255.0 for value in _rgb(hex_color)) + high, low = max(red, green, blue), min(red, green, blue) + delta = high - low + if delta == 0: + return 0.0 + if high == red: + return (60 * ((green - blue) / delta) + 360) % 360 + if high == green: + return 60 * ((blue - red) / delta) + 120 + return 60 * ((red - green) / delta) + 240 + + +def _hue_gap(first: str, second: str) -> float: + delta = abs(hue_degrees(first) - hue_degrees(second)) + return min(delta, 360 - delta) + + +def audit_palette(verbose: bool = False) -> list[str]: + """Return the list of palette-rule violations (empty when compliant).""" + from orisys_app_shared.theme import load_colors + + failures: list[str] = [] + themes = ("dark", "light") + palettes = {theme: load_colors(theme) for theme in themes} + + dark_keys, light_keys = set(palettes["dark"]), set(palettes["light"]) + if dark_keys != light_keys: + failures.append(f"token keys differ between themes: {sorted(dark_keys ^ light_keys)}") + + for theme, palette in palettes.items(): + if verbose: + print(f" -- {theme} --") + for key, value in sorted(palette.items()): + if key == "flow_ramp": + stops = [stop.strip() for stop in value.split(",") if stop.strip()] + if not stops: + failures.append(f"{theme}.flow_ramp is empty") + for stop in stops: + if not (stop.startswith("#") and len(stop) == 7): + failures.append(f"{theme}.flow_ramp stop {stop!r} is not #RRGGBB") + elif not (str(value).startswith("#") and len(str(value)) == 7): + failures.append(f"{theme}.{key} = {value!r} is not #RRGGBB") + + for key, backgrounds in TEXT_PAIRS: + for background in backgrounds: + ratio = contrast_ratio(palette[key], palette[background]) + if verbose: + print(f" text {key:19s} on {background:17s} {ratio:5.2f}") + if ratio < TEXT_MIN_RATIO: + failures.append( + f"{theme}: text {key} on {background} = {ratio:.2f} " + f"(need {TEXT_MIN_RATIO})" + ) + + for key, backgrounds in GRAPHIC_PAIRS: + for background in backgrounds: + ratio = contrast_ratio(palette[key], palette[background]) + if verbose: + print(f" graph {key:19s} on {background:17s} {ratio:5.2f}") + if ratio < GRAPHIC_MIN_RATIO: + failures.append( + f"{theme}: graphic {key} on {background} = {ratio:.2f} " + f"(need {GRAPHIC_MIN_RATIO})" + ) + + for index, first in enumerate(VIZ_KEYS): + for second in VIZ_KEYS[index + 1 :]: + gap = _hue_gap(palette[first], palette[second]) + high = max(relative_luminance(palette[first]), relative_luminance(palette[second])) + low = min(relative_luminance(palette[first]), relative_luminance(palette[second])) + ratio = (high + 0.05) / (low + 0.05) + if verbose: + print( + f" viz {first:19s} vs {second:17s} " + f"dHue={gap:5.1f} LrelRatio={ratio:4.2f}" + ) + if gap < VIZ_MIN_HUE_GAP and ratio < VIZ_MIN_LUMINANCE_RATIO: + failures.append( + f"{theme}: {first}/{second} are too similar " + f"(hue gap {gap:.0f} deg, luminance ratio {ratio:.2f})" + ) + + stops = [stop.strip() for stop in palette["flow_ramp"].split(",") if stop.strip()] + if len(stops) >= 2: + luminances = [relative_luminance(stop) for stop in stops] + if verbose: + print(f" ramp luminances {[round(value, 4) for value in luminances]}") + if not all( + luminances[i] < luminances[i + 1] for i in range(len(luminances) - 1) + ): + failures.append(f"{theme}: flow_ramp is not monotone in luminance") + for stop in stops[RAMP_BLEND_STOPS:]: + ratio = contrast_ratio(stop, palette["arrow_canvas"]) + if ratio < GRAPHIC_MIN_RATIO: + failures.append( + f"{theme}: flow_ramp stop {stop} on arrow_canvas = " + f"{ratio:.2f} (need {GRAPHIC_MIN_RATIO})" + ) + + for theme, palette in palettes.items(): + if palette["brand_bar_bg"] == palette["window_bg"]: + failures.append(f"{theme}: brand_bar_bg collides with window_bg") + if verbose: + print(f" -- {theme} documented exemptions --") + for key, backgrounds in DECORATIVE_PAIRS: + for background in backgrounds: + print( + f" exempt {key:19s} on {background:17s} " + f"{contrast_ratio(palette[key], palette[background]):5.2f}" + ) + + return failures + + +def main() -> int: + from PyQt5.QtWidgets import QApplication + + from orisys_app_shared.force.calibration_io import CalSample + from orisys_app_shared.i18n import set_locale, t + from orisys_app_shared.theme import build_qss, load_colors + from orisys_customer.app_window import CustomerMainWindow + + app = QApplication.instance() or QApplication(sys.argv) + set_locale("zh_CN") + + section("theme tokens") + dark = set(load_colors("dark")) + light = set(load_colors("light")) + check(dark == light, f"token keys differ: {sorted(dark ^ light)}") + for theme in ("dark", "light"): + qss = build_qss(theme) + check(len(qss) > 1000, f"{theme} qss suspiciously short") + check( + load_colors(theme)["arrow_canvas"] != load_colors(theme)["accent"], + f"{theme}: arrow canvas collides with accent", + ) + check("QProgressBar::chunk" in qss, f"{theme}: progress bar not styled") + check("QHeaderView::section" in qss, f"{theme}: table header not styled") + check("QRadioButton::indicator" in qss, f"{theme}: radio not styled") + check("QScrollBar::handle" in qss, f"{theme}: scrollbar not styled") + check("QFrame#brandBar" in qss, f"{theme}: brand bar not styled") + check("brand_bar_bg" not in qss, f"{theme}: qss leaked a token name") + + section("palette rules") + palette_failures = audit_palette(verbose=True) + for failure in palette_failures: + check(False, failure) + if not palette_failures: + check(True, "palette rules hold for both themes") + + scene_count = 0 + for theme in ("dark", "light"): + for sensor in ("box", "finger"): + section(f"window {theme}/{sensor}") + scene_count += 1 + app.setStyleSheet(build_qss(theme)) + window = CustomerMainWindow( + prefs={ + "language": "zh_CN", + "theme": theme, + "sensor_type": sensor, + "show_cal_panel": True, + } + ) + window.resize(1440, 880) + window.show() + app.processEvents() + + check(window.windowTitle() != "", "window title empty") + check( + window.status_chip.text() != "", + "status chip has no phase text", + ) + check( + window.toolbar.btn_run.text().strip() not in ("", "▶"), + f"run button is unlabelled: {window.toolbar.btn_run.text()!r}", + ) + + brand_bar = window.brand_bar + check(brand_bar.height() >= 28, "brand bar is too short to read") + check(brand_bar.name.text() != "", "brand bar wordmark empty") + check(brand_bar.tagline.text() != "", "brand bar tagline empty") + check(brand_bar.product.text() == t("app.title"), "brand bar product name wrong") + logo = brand_bar.logo.pixmap() + if logo is not None and not logo.isNull(): + image = logo.toImage() + pixels = [ + image.pixelColor(x, y).alpha() + for y in range(image.height()) + for x in range(image.width()) + ] + check(any(a < 32 for a in pixels), "brand logo lost its transparency") + check(any(a >= 200 for a in pixels), "brand logo renders empty") + else: + check(not brand_bar.logo.isVisible(), "hidden logo still shown") + + panels = [ + window.mesh3d_panel, + window.arrow_panel, + window.force_plots, + window.cal_panel, + ] + for panel, width in zip(panels, window.content_splitter.sizes()): + if panel.isVisible(): + check( + width >= panel.minimumWidth(), + f"{type(panel).__name__} width {width} < min {panel.minimumWidth()}", + ) + + check( + window.force_plots.width() >= window.force_plots.minimumWidth(), + "force plots narrower than its minimum", + ) + + # value cards must keep their text visible + for card, label in zip( + window.force_plots.value_cards, window.force_plots.value_labels + ): + check( + label.height() >= label.fontMetrics().height(), + "value card text is clipped", + ) + check(label.text() != "", "value card is empty") + + # demo data flows through the plots and the calibration table + for i in range(90): + window.force_plots.push(i / 30.0, float(i % 7), -1.0, 1.0, unit="N") + app.processEvents() + check( + window.force_plots.history.sample_count > 0, + "plots did not accept samples", + ) + + window.cal_panel.set_running(True) + check(window.cal_panel.btn_record.isEnabled(), "record button stays disabled") + window.cal_panel._store.upsert( + "fn", CalSample(weight_n=1.0, raw_mean=9.7, n=120, std=0.02) + ) + window.cal_panel._refresh_table() + check(window.cal_panel.table.rowCount() == 1, "calibration row missing") + check( + not window.cal_panel.empty_hint.isVisible(), + "empty hint still visible with rows", + ) + + window._set_phase("running") + check("status_running" not in window.status_chip.text(), "chip not localized") + window._set_theme("light" if theme == "dark" else "dark") + app.processEvents() + + window._persist_prefs() + check( + len(window.prefs.get("splitter_sizes", [])) == 4, + "splitter sizes not persisted", + ) + window.close() + app.processEvents() + + section("small window") + app.setStyleSheet(build_qss("dark")) + window = CustomerMainWindow( + prefs={ + "language": "en_US", + "theme": "dark", + "sensor_type": "finger", + "show_cal_panel": True, + "show_finger_3d": True, + } + ) + window.resize(1280, 720) + window.show() + app.processEvents() + for panel, width in zip( + [ + window.mesh3d_panel, + window.arrow_panel, + window.force_plots, + window.cal_panel, + ], + window.content_splitter.sizes(), + ): + if panel.isVisible(): + check(width >= panel.minimumWidth(), f"1280px: {type(panel).__name__} squeezed") + oversize = [] + for child in window.findChildren(object): + if not hasattr(child, "isVisible") or not child.isVisible(): + continue + if not hasattr(child, "geometry"): + continue + parent = child.parentWidget() + if parent is None or not parent.isVisible(): + continue + geometry = child.geometry() + if geometry.width() > parent.width() + 1 or geometry.height() > parent.height() + 1: + oversize.append(type(child).__name__) + check(not oversize, f"widgets overflow their parent: {oversize}") + window.close() + app.processEvents() + + print(f"\nscenes: {scene_count} checks: {CHECKS} failures: {len(FAILURES)}") + for failure in FAILURES: + print(f" FAIL: {failure}") + return 1 if FAILURES else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/apps/studio/tools/ui_snapshot.py b/apps/studio/tools/ui_snapshot.py new file mode 100644 index 0000000..1ba2f89 --- /dev/null +++ b/apps/studio/tools/ui_snapshot.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Render the customer UI offscreen to PNG files for visual review. + +The tool never opens a camera and never starts acquisition: it builds the real +main window, optionally injects deterministic demo data, then grabs the window. + +Examples +-------- + python tools/ui_snapshot.py --theme dark --out out_dark.png + python tools/ui_snapshot.py --theme light --sensor finger \ + --panels cal,3d --demo --size 1600x900 --out out_light.png +""" + +from __future__ import annotations + +import argparse +import math +import os +import sys +from pathlib import Path + +APP_ROOT = Path(__file__).resolve().parents[1] +REPO_ROOT = APP_ROOT.parents[1] +for _path in (APP_ROOT / "src", REPO_ROOT / "shared" / "src"): + if _path.is_dir() and str(_path) not in sys.path: + sys.path.insert(0, str(_path)) + +# Silent platform must be selected before QApplication exists. +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ: + fonts = Path(os.environ.get("WINDIR", r"C:\Windows")) / "Fonts" + if fonts.is_dir(): + os.environ["QT_QPA_FONTDIR"] = str(fonts) +os.chdir(APP_ROOT) + + +def _demo_data(window) -> None: + """Fill the plots, the calibration table and the arrow canvas.""" + plots = window.force_plots + for i in range(240): + t = i / 30.0 + fn = 6.0 + 5.0 * math.sin(t * 1.4) + fx = 4.0 * math.sin(t * 2.1 + 0.4) + fy = -3.0 + 2.5 * math.cos(t * 1.1) + plots.push(t, fn, fx, fy, unit="N") + + cal = window.cal_panel + cal.set_running(True) + cal.push_raw(9.81, -0.42, 0.10) + for weight, raw, std in ((1.0, 9.72, 0.031), (2.0, 19.51, 0.044), (5.0, 48.90, 0.061)): + from orisys_app_shared.force.calibration_io import CalSample + + cal._store.upsert("fn", CalSample(weight_n=weight, raw_mean=raw, n=180, std=std)) + cal._refresh_table() + + # Synthetic outward flow so the branded canvas and flow ramp are reviewable. + import numpy as np + + from orisys_app_shared.acquisition.arrows import ( + draw_arrow_display, + flow_colormap, + hex_to_bgr, + ) + from orisys_app_shared.theme import color + + from orisys_customer.app_window import _bgr_to_qimage + + size = 320 + grid_y, grid_x = np.mgrid[0:size, 0:size].astype(np.float32) + centre = size / 2.0 + flow = np.zeros((size, size, 2), np.float32) + flow[..., 0] = (grid_x - centre) * 0.10 + flow[..., 1] = (grid_y - centre) * 0.10 + blank = np.zeros((size, size, 3), np.uint8) + rendered = draw_arrow_display( + blank, + flow, + background="pure_color", + canvas_bgr=hex_to_bgr(color(window._theme, "arrow_canvas", "#0B1830")), + draw_style="finger" if window.sensor_picker.sensor_type() == "finger" else "box", + colormap=flow_colormap(window._theme), + ) + window.arrow_panel.show_frame(_bgr_to_qimage(rendered)) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--theme", choices=("dark", "light"), default="dark") + parser.add_argument("--lang", choices=("zh_CN", "en_US"), default="zh_CN") + parser.add_argument("--sensor", choices=("box", "mini", "finger"), default="box") + parser.add_argument( + "--panels", + default="", + help="comma list of optional panels to show: cal,3d", + ) + parser.add_argument("--size", default="1440x880", help="WxH window size") + parser.add_argument("--view", default="arrows_pure") + parser.add_argument("--demo", action="store_true", help="inject demo plot/table data") + parser.add_argument("--out", required=True, help="output PNG path") + args = parser.parse_args() + + try: + width, height = (int(part) for part in args.size.lower().split("x", 1)) + except ValueError: + parser.error("--size must look like 1440x880") + + panels = {part.strip().lower() for part in args.panels.split(",") if part.strip()} + + from PyQt5.QtWidgets import QApplication + + from orisys_app_shared.i18n import set_locale + from orisys_app_shared.theme import build_qss + from orisys_customer.app_window import CustomerMainWindow + + app = QApplication(sys.argv) + set_locale(args.lang) + app.setStyleSheet(build_qss(args.theme)) + + prefs = { + "language": args.lang, + "theme": args.theme, + "sensor_type": args.sensor, + "show_cal_panel": "cal" in panels, + "show_finger_3d": "3d" in panels, + "show_fps": True, + "image_view_mode": args.view, + } + window = CustomerMainWindow(prefs=prefs) + window.resize(width, height) + window.show() + app.processEvents() + + if args.demo: + _demo_data(window) + for _ in range(4): + app.processEvents() + + out = Path(args.out) + out.parent.mkdir(parents=True, exist_ok=True) + ok = window.grab().save(str(out)) + print(f"{'saved' if ok else 'FAILED'} {out} ({width}x{height}, theme={args.theme})") + window.close() + return 0 if ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/shared/src/orisys_app_shared/acquisition/__init__.py b/shared/src/orisys_app_shared/acquisition/__init__.py index d4ac82f..e60fcbf 100644 --- a/shared/src/orisys_app_shared/acquisition/__init__.py +++ b/shared/src/orisys_app_shared/acquisition/__init__.py @@ -13,6 +13,7 @@ __all__ = [ "SdkPacket", "depth_map_to_bgr", "draw_arrow_display", + "flow_colormap", "hex_to_bgr", "load_fixed_mode", "orisys_importable", @@ -28,6 +29,7 @@ _LAZY_ATTRS = { "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"), + "flow_colormap": ("orisys_app_shared.acquisition.arrows", "flow_colormap"), "hex_to_bgr": ("orisys_app_shared.acquisition.arrows", "hex_to_bgr"), } diff --git a/shared/src/orisys_app_shared/acquisition/arrows.py b/shared/src/orisys_app_shared/acquisition/arrows.py index 5600553..693545b 100644 --- a/shared/src/orisys_app_shared/acquisition/arrows.py +++ b/shared/src/orisys_app_shared/acquisition/arrows.py @@ -2,24 +2,26 @@ from __future__ import annotations +from functools import lru_cache from typing import Optional, Sequence import cv2 import numpy as np +from orisys_app_shared.theme import color + 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), -) +"""Fallback canvas colour (dark theme ``arrow_canvas``).""" + +FLOW_RAMP_FALLBACK = "#12315D,#6E4AD0,#FA02F3,#FF8A6B,#D8FF00" +"""Design-spec flow ramp: brand navy, violet, magenta, coral, lime.""" + +FLOW_IDLE_HEX = "#8A93A6" +"""Brand-neutral tint for the grid dots below the flow threshold.""" + +_LUT_STEPS = 256 +_RAMP_MIN_STOPS = 2 +_RAMP_MAX_STOPS = 8 def hex_to_bgr(hex_color: str, fallback: tuple[int, int, int] = ARROW_BG_BGR) -> tuple[int, int, int]: @@ -35,6 +37,62 @@ def hex_to_bgr(hex_color: str, fallback: tuple[int, int, int] = ARROW_BG_BGR) -> return (b, g, r) +_ARROW_DRAW_KW = dict( + threshold=2, + grid_spacing=20, + arrow_scale=0.5, + below_threshold_color=hex_to_bgr(FLOW_IDLE_HEX), +) +_FINGER_ARROW_DRAW_KW = dict( + threshold=0.4, + grid_spacing=10, + arrow_scale=1.0, + below_threshold_color=hex_to_bgr(FLOW_IDLE_HEX), +) + + +def _parse_ramp(ramp: str) -> list[tuple[int, int, int]]: + stops: list[tuple[int, int, int]] = [] + for token in str(ramp or "").split(","): + text = token.strip().lstrip("#") + if len(text) != 6: + continue + try: + stops.append((int(text[0:2], 16), int(text[2:4], 16), int(text[4:6], 16))) + except ValueError: + continue + return stops + + +@lru_cache(maxsize=8) +def _lut_from_ramp(ramp: str) -> np.ndarray: + """Build a 256-entry user colour table from ``#RRGGBB`` ramp stops. + + The table is BGR, matching how :func:`cv2.applyColorMap` consumes user + colormaps. Invalid input never raises: it falls back to + :data:`FLOW_RAMP_FALLBACK`. + """ + stops = _parse_ramp(ramp) + if not _RAMP_MIN_STOPS <= len(stops) <= _RAMP_MAX_STOPS: + stops = _parse_ramp(FLOW_RAMP_FALLBACK) + positions = np.linspace(0.0, 1.0, _LUT_STEPS) + span = np.linspace(0.0, 1.0, len(stops)) + lut = np.empty((_LUT_STEPS, 1, 3), dtype=np.uint8) + for channel in range(3): # source order: R, G, B + values = np.interp(positions, span, [stop[channel] for stop in stops]) + lut[:, 0, channel] = np.clip(np.round(values), 0, 255).astype(np.uint8) + return lut[:, :, ::-1].copy() + + +def flow_colormap(theme: str = "dark") -> np.ndarray: + """Return the branded flow-magnitude colour table for ``theme``. + + Cached on the resolved ramp string rather than the theme name, so a theme + switch is picked up as soon as :func:`build_qss` reloads the tokens. + """ + return _lut_from_ramp(color(theme, "flow_ramp", FLOW_RAMP_FALLBACK)) + + def _ensure_bgr(img: np.ndarray) -> np.ndarray: if img.ndim == 2: return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) @@ -53,19 +111,22 @@ def draw_arrow_display( canvas_bgr: Sequence[int] | None = None, draw_style: str = "box", valid_mask: Optional[np.ndarray] = None, + colormap: 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 + draw_kw = dict(_FINGER_ARROW_DRAW_KW if str(draw_style).lower().startswith("f") else _ARROW_DRAW_KW) + if colormap is not None: + draw_kw["colormap"] = colormap 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 + color_bgr = ARROW_BG_BGR if canvas_bgr is None else tuple(int(v) for v in canvas_bgr) + canvas[:, :] = color_bgr return orisys.util.draw_arrows( canvas, flow, valid_mask=valid_mask, **draw_kw ) @@ -74,8 +135,11 @@ def draw_arrow_display( 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.""" +def depth_map_to_bgr( + depth: Optional[np.ndarray], + colormap: Optional[np.ndarray] = None, +) -> Optional[np.ndarray]: + """Convert a float depth/divergence map to a colormap BGR image.""" if depth is None: return None arr = np.asarray(depth, dtype=np.float64) @@ -90,4 +154,6 @@ def depth_map_to_bgr(depth: Optional[np.ndarray]) -> Optional[np.ndarray]: 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) + return cv2.applyColorMap( + normalized, cv2.COLORMAP_JET if colormap is None else colormap + ) diff --git a/shared/src/orisys_app_shared/theme/colors_dark.json b/shared/src/orisys_app_shared/theme/colors_dark.json index 3c4bb0a..a7bb2f1 100644 --- a/shared/src/orisys_app_shared/theme/colors_dark.json +++ b/shared/src/orisys_app_shared/theme/colors_dark.json @@ -1,45 +1,51 @@ { - "window_bg": "#121316", - "panel_bg": "#1a1c20", - "panel_border": "#2a2d33", - "text_primary": "#e8eaed", - "text_secondary": "#9aa0a6", - "text_muted": "#6f757c", - "accent": "#4a62c0", - "accent_hover": "#5c74d0", - "accent_text": "#ffffff", - "toolbar_bg": "#1a1c20", - "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": "#15171a", - "plot_grid": "#2a2d33", - "arrow_canvas": "#121316", - "danger": "#ea4335", - "danger_hover": "#f2554a", - "danger_text": "#ffffff", - "warning": "#fbbc04", - "info": "#8ab4f8", - "success": "#34a853", - "status_idle": "#9aa0a6", - "status_connecting": "#fbbc04", - "status_running": "#34a853", - "status_error": "#ea4335", - "table_header_bg": "#23262b", - "table_row_alt": "#1e2126", - "table_selected": "#33406b", - "scrollbar_bg": "#1a1c20", - "scrollbar_handle": "#3a3f47", - "scrollbar_handle_hover": "#4a5058", - "card_bg": "#1e2126", - "card_border": "#32363d", - "separator": "#2a2d33", - "focus_ring": "#6b82d9", - "hint_text": "#7d838a" + "window_bg": "#0E1320", + "panel_bg": "#161C2A", + "panel_border": "#28303F", + "toolbar_bg": "#161C2A", + "card_bg": "#1B2231", + "card_border": "#2E3748", + "separator": "#28303F", + "input_bg": "#0B0F19", + "input_border": "#66739A", + "button_bg": "#28303F", + "button_hover": "#333D50", + "menu_bg": "#161C2A", + "menu_hover": "#28303F", + "table_header_bg": "#1F2635", + "table_row_alt": "#1A2130", + "table_selected": "#2C3A63", + "scrollbar_bg": "#161C2A", + "scrollbar_handle": "#627092", + "scrollbar_handle_hover": "#6E7C9E", + "plot_bg": "#11161F", + "plot_grid": "#28303F", + "plot_fn": "#8AA6F2", + "plot_fx": "#F45CC0", + "plot_fy": "#D8FF00", + "text_primary": "#E6EAF2", + "text_secondary": "#9AA4B8", + "text_muted": "#838FA6", + "hint_text": "#838FA6", + "accent": "#4D62BE", + "accent_hover": "#5770CE", + "accent_text": "#FFFFFF", + "focus_ring": "#7C92E8", + "danger": "#EE6B60", + "danger_hover": "#F0796E", + "danger_fill": "#CC4438", + "danger_fill_hover": "#BE3A31", + "danger_text": "#FFFFFF", + "warning": "#F0B429", + "info": "#8AA6F2", + "success": "#4FC08D", + "status_idle": "#9AA4B8", + "status_connecting": "#F0B429", + "status_running": "#4FC08D", + "status_error": "#EE6B60", + "brand_bar_bg": "#12315D", + "brand_bar_fg": "#FFFFFF", + "brand_bar_fg_muted": "#B9C8E4", + "arrow_canvas": "#0B1830", + "flow_ramp": "#0B1830,#6E4AD0,#FA02F3,#FF8A6B,#D8FF00" } diff --git a/shared/src/orisys_app_shared/theme/colors_light.json b/shared/src/orisys_app_shared/theme/colors_light.json index 5b6228a..66d5389 100644 --- a/shared/src/orisys_app_shared/theme/colors_light.json +++ b/shared/src/orisys_app_shared/theme/colors_light.json @@ -1,45 +1,51 @@ { - "window_bg": "#ECEFF4", + "window_bg": "#E1E6EF", "panel_bg": "#FFFFFF", - "panel_border": "#D0D5DE", - "text_primary": "#1A1C20", - "text_secondary": "#5F6368", - "text_muted": "#7C838C", - "accent": "#003893", - "accent_hover": "#2A4FA8", - "accent_text": "#ffffff", - "toolbar_bg": "#E8EBF0", - "button_bg": "#F4F6FA", - "button_hover": "#E8ECF5", + "panel_border": "#CFD7E4", + "toolbar_bg": "#EDF1F8", + "card_bg": "#F4F7FC", + "card_border": "#D5DCE8", + "separator": "#D8DFEA", "input_bg": "#FFFFFF", - "input_border": "#C5CAD3", + "input_border": "#7C8AA4", + "button_bg": "#F6F8FD", + "button_hover": "#E7EDF9", "menu_bg": "#FFFFFF", - "menu_hover": "#EEF1F7", + "menu_hover": "#EDF1F8", + "table_header_bg": "#EEF2F9", + "table_row_alt": "#F7F9FD", + "table_selected": "#D7E2FA", + "scrollbar_bg": "#E6EBF3", + "scrollbar_handle": "#78839A", + "scrollbar_handle_hover": "#6A7590", + "plot_bg": "#F8FAFD", + "plot_grid": "#DFE5EE", "plot_fn": "#003893", - "plot_fx": "#188038", - "plot_fy": "#E8710A", - "plot_bg": "#FAFBFD", - "plot_grid": "#E3E6EC", - "arrow_canvas": "#EEF1F7", - "danger": "#D93025", - "danger_hover": "#B3261C", - "danger_text": "#ffffff", - "warning": "#F9AB00", - "info": "#1A73E8", - "success": "#1B8A5A", - "status_idle": "#5F6368", - "status_connecting": "#F9AB00", - "status_running": "#1B8A5A", - "status_error": "#D93025", - "table_header_bg": "#F1F3F8", - "table_row_alt": "#F8F9FC", - "table_selected": "#DCE6FA", - "scrollbar_bg": "#EDEFF3", - "scrollbar_handle": "#C3C9D4", - "scrollbar_handle_hover": "#A8B0BE", - "card_bg": "#F7F9FC", - "card_border": "#DDE2EA", - "separator": "#D8DDE6", - "focus_ring": "#2A4FA8", - "hint_text": "#7C838C" + "plot_fx": "#B0178C", + "plot_fy": "#4E7A00", + "text_primary": "#141A24", + "text_secondary": "#4A5566", + "text_muted": "#5C6678", + "hint_text": "#5C6678", + "accent": "#003893", + "accent_hover": "#4D62BE", + "accent_text": "#FFFFFF", + "focus_ring": "#4D62BE", + "danger": "#C7362B", + "danger_hover": "#B93227", + "danger_fill": "#C7362B", + "danger_fill_hover": "#B93227", + "danger_text": "#FFFFFF", + "warning": "#8A5A00", + "info": "#3A55B8", + "success": "#1F7A4D", + "status_idle": "#4A5566", + "status_connecting": "#8A5A00", + "status_running": "#1F7A4D", + "status_error": "#C7362B", + "brand_bar_bg": "#12315D", + "brand_bar_fg": "#FFFFFF", + "brand_bar_fg_muted": "#B9C8E4", + "arrow_canvas": "#12315D", + "flow_ramp": "#12315D,#6E4AD0,#FA02F3,#FF8A6B,#D8FF00" } diff --git a/shared/src/orisys_app_shared/theme/qss.py b/shared/src/orisys_app_shared/theme/qss.py index 9a9fd4a..bb9a1a9 100644 --- a/shared/src/orisys_app_shared/theme/qss.py +++ b/shared/src/orisys_app_shared/theme/qss.py @@ -146,6 +146,35 @@ QToolTip {{ border-radius: 6px; padding: 5px 8px; }} +/* ---------- Brand bar ---------- */ +QFrame#brandBar {{ + background-color: {c['brand_bar_bg']}; + border: none; + border-radius: 8px; +}} +QLabel#brandName {{ + color: {c['brand_bar_fg']}; + font-size: 15px; + font-weight: 700; + letter-spacing: 1.5px; +}} +QLabel#brandTagline {{ + color: {c['brand_bar_fg_muted']}; + font-size: 11px; + letter-spacing: 0.5px; +}} +QLabel#brandProduct {{ + color: {c['brand_bar_fg']}; + font-size: 13px; + font-weight: 600; + letter-spacing: 0.3px; +}} +QFrame#brandBar QFrame#sectionSeparator {{ + background-color: {c['brand_bar_fg_muted']}; + border: none; + max-width: 1px; + min-width: 1px; +}} /* ---------- Buttons ---------- */ QPushButton {{ background-color: {c['button_bg']}; @@ -157,7 +186,6 @@ QPushButton {{ }} QPushButton:hover {{ background-color: {c['button_hover']}; - border-color: {c['accent_hover']}; }} QPushButton:pressed {{ background-color: {c['menu_hover']}; @@ -192,20 +220,20 @@ QPushButton#primaryButton:disabled {{ border-color: {c['panel_border']}; }} QPushButton#dangerButton {{ - background-color: {c['danger']}; + background-color: {c['danger_fill']}; color: {c['danger_text']}; - border: 1px solid {c['danger']}; + border: 1px solid {c['danger_fill']}; font-weight: 600; padding: 7px 18px; }} QPushButton#dangerButton:hover {{ - background-color: {c['danger_hover']}; - border-color: {c['danger_hover']}; + background-color: {c['danger_fill_hover']}; + border-color: {c['danger_fill_hover']}; }} QPushButton#dangerButton:pressed, QPushButton#dangerButton:checked {{ - background-color: {c['danger_hover']}; - border-color: {c['danger']}; + background-color: {c['danger_fill_hover']}; + border-color: {c['danger_fill']}; }} QPushButton#dangerButton:disabled {{ background-color: {c['panel_border']}; @@ -249,10 +277,10 @@ QComboBox, QLineEdit, QSpinBox, QDoubleSpinBox, QAbstractSpinBox {{ selection-color: {accent_text}; }} QComboBox:hover, QLineEdit:hover, QSpinBox:hover, QDoubleSpinBox:hover {{ - border-color: {c['accent_hover']}; + border-color: {c['focus_ring']}; }} QComboBox:focus, QLineEdit:focus, QSpinBox:focus, QDoubleSpinBox:focus {{ - border: 1px solid {c['accent']}; + border: 1px solid {c['focus_ring']}; }} QComboBox:disabled, QLineEdit:disabled, QSpinBox:disabled, QDoubleSpinBox:disabled {{ color: {c['text_muted']};