dsh ui improve: add brand bar
This commit is contained in:
@@ -68,6 +68,12 @@ python -m pytest
|
|||||||
On Windows the offscreen platform finds no fonts unless `QT_QPA_FONTDIR` points
|
On Windows the offscreen platform finds no fonts unless `QT_QPA_FONTDIR` points
|
||||||
at `C:\Windows\Fonts`; both tools set it automatically.
|
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
|
## Branding
|
||||||
|
|
||||||
Window / EXE icon: `shared/resources/branding/logo.png`
|
Window / EXE icon: `shared/resources/branding/logo.png`
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
@@ -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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "prototype",
|
||||||
|
"version": "1.0",
|
||||||
|
"camera": {
|
||||||
|
"resolution_raw": [1920, 1080],
|
||||||
|
"fps": 30,
|
||||||
|
"units": 15,
|
||||||
|
"units_used": 9,
|
||||||
|
"resolution_stitch": [365, 365],
|
||||||
|
"stitch_res_list": [365,263,123,63]
|
||||||
|
},
|
||||||
|
"stitcher": {
|
||||||
|
"min_area_frac": 0.01,
|
||||||
|
"max_area_frac": 0.05,
|
||||||
|
"max_aspect_ratio": 50,
|
||||||
|
"index": [1,2,3,6,7,8,11,12,13],
|
||||||
|
"margin": 10,
|
||||||
|
"nominal_ov_pos": 100,
|
||||||
|
"n": [3,3],
|
||||||
|
"row_tol": 0.6,
|
||||||
|
"box_size": [250, 250],
|
||||||
|
"blend":"numba",
|
||||||
|
"blend_list": ["numpy", "numba","none"]
|
||||||
|
},
|
||||||
|
"tracker": {
|
||||||
|
"adaptive": true,
|
||||||
|
"lower_threshold": 12.0,
|
||||||
|
"upper_threshold": 18.0,
|
||||||
|
"loop_closure_interval": 1,
|
||||||
|
"algorithm": "dis",
|
||||||
|
"dis_preset": "medium",
|
||||||
|
"dis_mode": 3,
|
||||||
|
"algorithm_list": ["raft", "dis"],
|
||||||
|
"hhd_downsample": 3
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"min_contour_area": 3000,
|
||||||
|
"flow_threshold":5,
|
||||||
|
"div_threshold":0.05,
|
||||||
|
"expansion_threshold": 0.01,
|
||||||
|
"kernel_size": [5,5],
|
||||||
|
"denoise_sigma": 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
Place the ORY-FINGER runtime asset bundle files in this directory before
|
||||||
|
building release wheels:
|
||||||
|
|
||||||
|
- `mesh.npz`
|
||||||
|
- `camera_intrinsics.npz`
|
||||||
|
- `world_to_cam.npy`
|
||||||
|
|
||||||
|
These binary assets are validated by the wheel build and are intentionally not
|
||||||
|
generated by the package build step.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,6 @@
|
|||||||
|
Place visualization camera assets in this directory before building release
|
||||||
|
wheels:
|
||||||
|
|
||||||
|
- `world_to_cam.npy`
|
||||||
|
|
||||||
|
The 3D dashboard uses this pose as the default view preset when available.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"name": "finger",
|
||||||
|
"sensor_json": "sensor.json",
|
||||||
|
"assets_dir": "assets/30fps-0703",
|
||||||
|
"viz_pose": "assets/visualization/world_to_cam.npy"
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "dd02-ov",
|
||||||
|
"type": "single_camera",
|
||||||
|
"version": "1.0",
|
||||||
|
"camera": {
|
||||||
|
"resolution_raw": [1280, 720],
|
||||||
|
"fps": 30,
|
||||||
|
"resolution_stitch": [280, 300],
|
||||||
|
"source_scale": 0.4,
|
||||||
|
"old":[672, 728, 1,0.5],
|
||||||
|
"work": [504, 546,1],
|
||||||
|
"work2": [420, 450,0.8],
|
||||||
|
"work3": [350, 375,4,0.7],
|
||||||
|
"work4": [280, 300,4,0.4,0.2]
|
||||||
|
},
|
||||||
|
"tracker": {
|
||||||
|
"adaptive": true,
|
||||||
|
"lower_threshold": 12.0,
|
||||||
|
"upper_threshold": 18.0,
|
||||||
|
"loop_closure_interval": 1,
|
||||||
|
"algorithm": "dis",
|
||||||
|
"dis_preset": "medium",
|
||||||
|
"dis_mode": 1,
|
||||||
|
"algorithm_list": ["raft", "dis"],
|
||||||
|
"hhd_downsample": 3
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"min_contour_area": 3000,
|
||||||
|
"flow_threshold": 0.2,
|
||||||
|
"div_threshold": 0.022,
|
||||||
|
"expansion_threshold": 0.01,
|
||||||
|
"kernel_size": [5, 5],
|
||||||
|
"denoise_sigma": 12
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"name": "prototype",
|
||||||
|
"version": "1.0",
|
||||||
|
"camera": {
|
||||||
|
"resolution_raw": [1920, 1080],
|
||||||
|
"fps": 30,
|
||||||
|
"units": 15,
|
||||||
|
"units_used": 9,
|
||||||
|
"resolution_stitch": [365, 365],
|
||||||
|
"stitch_res_list": [365,263,123,63]
|
||||||
|
},
|
||||||
|
"stitcher": {
|
||||||
|
"min_area_frac": 0.01,
|
||||||
|
"max_area_frac": 0.05,
|
||||||
|
"max_aspect_ratio": 50,
|
||||||
|
"index": [1,2,3,6,7,8,11,12,13],
|
||||||
|
"margin": 10,
|
||||||
|
"nominal_ov_pos": 100,
|
||||||
|
"n": [3,3],
|
||||||
|
"row_tol": 0.6,
|
||||||
|
"box_size": [250, 250],
|
||||||
|
"blend":"numba",
|
||||||
|
"blend_list": ["numpy", "numba","none"]
|
||||||
|
},
|
||||||
|
"tracker": {
|
||||||
|
"adaptive": true,
|
||||||
|
"lower_threshold": 12.0,
|
||||||
|
"upper_threshold": 18.0,
|
||||||
|
"loop_closure_interval": 1,
|
||||||
|
"algorithm": "dis",
|
||||||
|
"dis_preset": "medium",
|
||||||
|
"dis_mode": 3,
|
||||||
|
"algorithm_list": ["raft", "dis"],
|
||||||
|
"hhd_downsample": 3
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"min_contour_area": 3000,
|
||||||
|
"flow_threshold":5,
|
||||||
|
"div_threshold":0.05,
|
||||||
|
"expansion_threshold": 0.01,
|
||||||
|
"kernel_size": [5,5],
|
||||||
|
"denoise_sigma": 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,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
|
||||||
|
```
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "orisys-customer"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Orisys customer desktop app"
|
||||||
|
requires-python = ">=3.10"
|
||||||
|
authors = [{ name = "Orisys-Tech" }]
|
||||||
|
dependencies = [
|
||||||
|
"PyQt5>=5.15",
|
||||||
|
"orisys-app-shared>=0.1.0",
|
||||||
|
"orisys>=0.5",
|
||||||
|
"scipy>=1.10.1",
|
||||||
|
"pyqtgraph>=0.13",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
orisys-customer = "orisys_customer.main:main"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
package-dir = { "" = "src" }
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Launch the Orisys customer app shell."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
SRC = ROOT / "src"
|
||||||
|
SHARED_SRC = ROOT.parents[1] / "shared" / "src"
|
||||||
|
for path in (SRC, SHARED_SRC):
|
||||||
|
if path.is_dir() and str(path) not in sys.path:
|
||||||
|
sys.path.insert(0, str(path))
|
||||||
|
|
||||||
|
# Ensure path helpers resolve to this app root when launched via run.py
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.chdir(ROOT)
|
||||||
|
|
||||||
|
from orisys_customer.main import main
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
"""Orisys customer desktop application."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
|
REQUIRES_SHARED_API = "0.1"
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,56 @@
|
|||||||
|
"""Customer app entrypoint."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PyQt5.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from orisys_app_shared import API_VERSION, __version__ as shared_version
|
||||||
|
from orisys_app_shared.compat import check_shared_compat
|
||||||
|
from orisys_app_shared.i18n import set_locale
|
||||||
|
from orisys_app_shared.logging import init_logging, install_exception_hooks
|
||||||
|
from orisys_app_shared.paths import prefer_local_sensor_bundles
|
||||||
|
from orisys_app_shared.prefs import load_preferences
|
||||||
|
from orisys_app_shared.theme import build_qss
|
||||||
|
|
||||||
|
from orisys_customer import REQUIRES_SHARED_API, __version__ as app_version
|
||||||
|
from orisys_customer.app_window import CustomerMainWindow
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
init_logging("orisys-customer")
|
||||||
|
install_exception_hooks()
|
||||||
|
preferred = prefer_local_sensor_bundles()
|
||||||
|
if preferred is not None:
|
||||||
|
logger.info("Preferring local sensor bundles at %s", preferred)
|
||||||
|
check_shared_compat(REQUIRES_SHARED_API)
|
||||||
|
logger.info(
|
||||||
|
"Versions: app=%s shared=%s api=%s (requires %s)",
|
||||||
|
app_version,
|
||||||
|
shared_version,
|
||||||
|
API_VERSION,
|
||||||
|
REQUIRES_SHARED_API,
|
||||||
|
)
|
||||||
|
|
||||||
|
prefs = load_preferences()
|
||||||
|
language = str(prefs.get("language") or "zh_CN")
|
||||||
|
theme = str(prefs.get("theme") or "dark")
|
||||||
|
set_locale(language)
|
||||||
|
|
||||||
|
app = QApplication(sys.argv)
|
||||||
|
app.setApplicationName("Orisys Customer")
|
||||||
|
app.setApplicationVersion(app_version)
|
||||||
|
app.setStyleSheet(build_qss(theme))
|
||||||
|
|
||||||
|
window = CustomerMainWindow(prefs=prefs)
|
||||||
|
window.show()
|
||||||
|
logger.info("Customer shell started (theme=%s, language=%s)", theme, language)
|
||||||
|
return int(app.exec_())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""UI package."""
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""About dialog for Help > About."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PyQt5.QtCore import Qt
|
||||||
|
from PyQt5.QtGui import QPixmap
|
||||||
|
from PyQt5.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayout
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
from orisys_app_shared.paths import logo_topbar_path
|
||||||
|
|
||||||
|
from orisys_customer import __version__ as app_version
|
||||||
|
|
||||||
|
|
||||||
|
class AboutDialog(QDialog):
|
||||||
|
def __init__(self, parent=None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setWindowTitle(t("menu.about"))
|
||||||
|
self.setModal(True)
|
||||||
|
self.resize(420, 220)
|
||||||
|
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
row = QHBoxLayout()
|
||||||
|
logo = QLabel()
|
||||||
|
pix = QPixmap(str(logo_topbar_path()))
|
||||||
|
if not pix.isNull():
|
||||||
|
logo.setPixmap(pix.scaledToHeight(48, Qt.SmoothTransformation))
|
||||||
|
row.addWidget(logo)
|
||||||
|
row.addStretch(1)
|
||||||
|
root.addLayout(row)
|
||||||
|
|
||||||
|
self.body = QLabel()
|
||||||
|
self.body.setWordWrap(True)
|
||||||
|
self.body.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||||
|
root.addWidget(self.body)
|
||||||
|
|
||||||
|
close_btn = QPushButton("OK")
|
||||||
|
close_btn.clicked.connect(self.accept)
|
||||||
|
root.addWidget(close_btn, alignment=Qt.AlignRight)
|
||||||
|
self.retranslate()
|
||||||
|
|
||||||
|
def retranslate(self) -> None:
|
||||||
|
self.setWindowTitle(t("app.about_title"))
|
||||||
|
self.body.setText(t("app.about_body", app_version=app_version))
|
||||||
@@ -0,0 +1,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)
|
||||||
@@ -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"))
|
||||||
@@ -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"<span style='color:{colors[key]}; font-weight:600'>{key.upper()}</span>"
|
||||||
|
f" {value:.3f}"
|
||||||
|
)
|
||||||
|
self.live_label.setText(
|
||||||
|
f"<span style='color:{color(self._theme, 'text_secondary')}'>"
|
||||||
|
f"{t('cal.live_raw_label')}</span> " + " ".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)
|
||||||
@@ -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()
|
||||||
@@ -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"))
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
"""Modal dialog for force-plot window and Y-axis limits."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from PyQt5.QtWidgets import (
|
||||||
|
QDialog,
|
||||||
|
QDialogButtonBox,
|
||||||
|
QDoubleSpinBox,
|
||||||
|
QFormLayout,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLabel,
|
||||||
|
QPushButton,
|
||||||
|
QVBoxLayout,
|
||||||
|
)
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
|
||||||
|
DEFAULT_PLOT_WINDOW_SEC = 4.0
|
||||||
|
DEFAULT_PLOT_FN_MAX = 20.0
|
||||||
|
DEFAULT_PLOT_SHEAR_MAX = 20.0
|
||||||
|
|
||||||
|
|
||||||
|
class PlotSettingsDialog(QDialog):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent=None,
|
||||||
|
*,
|
||||||
|
window_sec: float = DEFAULT_PLOT_WINDOW_SEC,
|
||||||
|
fn_max: float = DEFAULT_PLOT_FN_MAX,
|
||||||
|
shear_max: float = DEFAULT_PLOT_SHEAR_MAX,
|
||||||
|
units: str = "newton",
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self.setModal(True)
|
||||||
|
self.resize(360, 200)
|
||||||
|
|
||||||
|
root = QVBoxLayout(self)
|
||||||
|
form = QFormLayout()
|
||||||
|
|
||||||
|
self.window_label = QLabel()
|
||||||
|
self.window_spin = QDoubleSpinBox()
|
||||||
|
self.window_spin.setRange(1.0, 30.0)
|
||||||
|
self.window_spin.setSingleStep(1.0)
|
||||||
|
self.window_spin.setDecimals(0)
|
||||||
|
self.window_spin.setSuffix(" s")
|
||||||
|
self.window_spin.setValue(float(window_sec))
|
||||||
|
|
||||||
|
self.fn_max_label = QLabel()
|
||||||
|
self.fn_max_spin = QDoubleSpinBox()
|
||||||
|
self.fn_max_spin.setRange(1.0, 100.0)
|
||||||
|
self.fn_max_spin.setSingleStep(1.0)
|
||||||
|
self.fn_max_spin.setDecimals(0)
|
||||||
|
self.fn_max_spin.setSuffix(" N")
|
||||||
|
self.fn_max_spin.setValue(float(fn_max))
|
||||||
|
|
||||||
|
self.shear_max_label = QLabel()
|
||||||
|
self.shear_max_spin = QDoubleSpinBox()
|
||||||
|
self.shear_max_spin.setRange(1.0, 100.0)
|
||||||
|
self.shear_max_spin.setSingleStep(1.0)
|
||||||
|
self.shear_max_spin.setDecimals(0)
|
||||||
|
self.shear_max_spin.setSuffix(" N")
|
||||||
|
self.shear_max_spin.setValue(float(shear_max))
|
||||||
|
|
||||||
|
form.addRow(self.window_label, self.window_spin)
|
||||||
|
form.addRow(self.fn_max_label, self.fn_max_spin)
|
||||||
|
form.addRow(self.shear_max_label, self.shear_max_spin)
|
||||||
|
root.addLayout(form)
|
||||||
|
|
||||||
|
newton = str(units or "newton").lower().startswith("n")
|
||||||
|
self.fn_max_spin.setEnabled(newton)
|
||||||
|
self.shear_max_spin.setEnabled(newton)
|
||||||
|
self.fn_max_label.setEnabled(newton)
|
||||||
|
self.shear_max_label.setEnabled(newton)
|
||||||
|
|
||||||
|
buttons_row = QHBoxLayout()
|
||||||
|
self.btn_reset = QPushButton()
|
||||||
|
self.btn_reset.clicked.connect(self._reset_defaults)
|
||||||
|
buttons_row.addWidget(self.btn_reset)
|
||||||
|
buttons_row.addStretch(1)
|
||||||
|
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
||||||
|
self.button_box.accepted.connect(self.accept)
|
||||||
|
self.button_box.rejected.connect(self.reject)
|
||||||
|
buttons_row.addWidget(self.button_box)
|
||||||
|
root.addLayout(buttons_row)
|
||||||
|
|
||||||
|
self.retranslate()
|
||||||
|
|
||||||
|
def values(self) -> tuple[float, float, float]:
|
||||||
|
return (
|
||||||
|
float(self.window_spin.value()),
|
||||||
|
float(self.fn_max_spin.value()),
|
||||||
|
float(self.shear_max_spin.value()),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _reset_defaults(self) -> None:
|
||||||
|
self.window_spin.setValue(DEFAULT_PLOT_WINDOW_SEC)
|
||||||
|
self.fn_max_spin.setValue(DEFAULT_PLOT_FN_MAX)
|
||||||
|
self.shear_max_spin.setValue(DEFAULT_PLOT_SHEAR_MAX)
|
||||||
|
|
||||||
|
def retranslate(self) -> None:
|
||||||
|
self.setWindowTitle(t("dialog.plot_settings_title"))
|
||||||
|
self.window_label.setText(t("dialog.plot_window"))
|
||||||
|
self.window_spin.setToolTip(t("dialog.plot_window_tip"))
|
||||||
|
self.fn_max_label.setText(t("dialog.plot_fn_max"))
|
||||||
|
self.fn_max_spin.setToolTip(t("dialog.plot_fn_max_tip"))
|
||||||
|
self.shear_max_label.setText(t("dialog.plot_shear_max"))
|
||||||
|
self.shear_max_spin.setToolTip(t("dialog.plot_shear_max_tip"))
|
||||||
|
self.btn_reset.setText(t("dialog.reset_defaults"))
|
||||||
|
ok_btn = self.button_box.button(QDialogButtonBox.Ok)
|
||||||
|
cancel_btn = self.button_box.button(QDialogButtonBox.Cancel)
|
||||||
|
if ok_btn is not None:
|
||||||
|
ok_btn.setText(t("dialog.ok"))
|
||||||
|
if cancel_btn is not None:
|
||||||
|
cancel_btn.setText(t("dialog.cancel"))
|
||||||
@@ -0,0 +1,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()
|
||||||
@@ -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"))
|
||||||
@@ -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
|
||||||
@@ -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<body>.*?)\n\}", re.DOTALL)
|
||||||
|
_KEY = re.compile(r"""["']([A-Za-z0-9_]+)["']\s*:""")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_locale(name: str) -> dict:
|
||||||
|
return json.loads((LOCALES_DIR / f"{name}.json").read_text(encoding="utf-8"))
|
||||||
|
|
||||||
|
|
||||||
|
def _flatten(data: dict, prefix: str = "") -> set[str]:
|
||||||
|
keys: set[str] = set()
|
||||||
|
for key, value in data.items():
|
||||||
|
dotted = f"{prefix}{key}"
|
||||||
|
if isinstance(value, dict):
|
||||||
|
keys |= _flatten(value, f"{dotted}.")
|
||||||
|
else:
|
||||||
|
keys.add(dotted)
|
||||||
|
return keys
|
||||||
|
|
||||||
|
|
||||||
|
def _referenced_keys() -> set[str]:
|
||||||
|
found: set[str] = set()
|
||||||
|
for path in sorted(SRC_DIR.rglob("*.py")):
|
||||||
|
found |= set(_T_CALL.findall(path.read_text(encoding="utf-8")))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def test_locales_parity_and_used_keys():
|
||||||
|
zh = _flatten(_load_locale("zh_CN"))
|
||||||
|
en = _flatten(_load_locale("en_US"))
|
||||||
|
|
||||||
|
assert zh == en, {
|
||||||
|
"zh_only": sorted(zh - en),
|
||||||
|
"en_only": sorted(en - zh),
|
||||||
|
}
|
||||||
|
|
||||||
|
referenced = _referenced_keys()
|
||||||
|
assert referenced, "no t() calls found — the source scan is broken"
|
||||||
|
missing = sorted(key for key in referenced if key not in zh)
|
||||||
|
assert not missing, f"t() keys missing from locale files: {missing}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_preference_defaults_match_store_defaults():
|
||||||
|
json_keys = set(json.loads(DEFAULTS_JSON.read_text(encoding="utf-8")))
|
||||||
|
source = STORE_PY.read_text(encoding="utf-8")
|
||||||
|
match = _DEFAULT_BLOCK.search(source)
|
||||||
|
assert match, "could not locate _DEFAULTS in prefs/store.py"
|
||||||
|
store_keys = set(_KEY.findall(match.group("body")))
|
||||||
|
|
||||||
|
assert json_keys == store_keys, {
|
||||||
|
"json_only": sorted(json_keys - store_keys),
|
||||||
|
"store_only": sorted(store_keys - json_keys),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_locale_files_are_valid_utf8_json():
|
||||||
|
for name in ("zh_CN", "en_US"):
|
||||||
|
data = _load_locale(name)
|
||||||
|
assert isinstance(data, dict)
|
||||||
|
assert data.get("app", {}).get("title")
|
||||||
@@ -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)
|
||||||
@@ -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())
|
||||||
@@ -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())
|
||||||
@@ -13,6 +13,7 @@ __all__ = [
|
|||||||
"SdkPacket",
|
"SdkPacket",
|
||||||
"depth_map_to_bgr",
|
"depth_map_to_bgr",
|
||||||
"draw_arrow_display",
|
"draw_arrow_display",
|
||||||
|
"flow_colormap",
|
||||||
"hex_to_bgr",
|
"hex_to_bgr",
|
||||||
"load_fixed_mode",
|
"load_fixed_mode",
|
||||||
"orisys_importable",
|
"orisys_importable",
|
||||||
@@ -28,6 +29,7 @@ _LAZY_ATTRS = {
|
|||||||
"orisys_importable": ("orisys_app_shared.acquisition.sdk_worker", "orisys_importable"),
|
"orisys_importable": ("orisys_app_shared.acquisition.sdk_worker", "orisys_importable"),
|
||||||
"depth_map_to_bgr": ("orisys_app_shared.acquisition.arrows", "depth_map_to_bgr"),
|
"depth_map_to_bgr": ("orisys_app_shared.acquisition.arrows", "depth_map_to_bgr"),
|
||||||
"draw_arrow_display": ("orisys_app_shared.acquisition.arrows", "draw_arrow_display"),
|
"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"),
|
"hex_to_bgr": ("orisys_app_shared.acquisition.arrows", "hex_to_bgr"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,24 +2,26 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
from typing import Optional, Sequence
|
from typing import Optional, Sequence
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
from orisys_app_shared.theme import color
|
||||||
|
|
||||||
ARROW_BG_BGR = (0x16, 0x13, 0x12)
|
ARROW_BG_BGR = (0x16, 0x13, 0x12)
|
||||||
_ARROW_DRAW_KW = dict(
|
"""Fallback canvas colour (dark theme ``arrow_canvas``)."""
|
||||||
threshold=2,
|
|
||||||
grid_spacing=20,
|
FLOW_RAMP_FALLBACK = "#12315D,#6E4AD0,#FA02F3,#FF8A6B,#D8FF00"
|
||||||
arrow_scale=0.5,
|
"""Design-spec flow ramp: brand navy, violet, magenta, coral, lime."""
|
||||||
below_threshold_color=(160, 160, 160),
|
|
||||||
)
|
FLOW_IDLE_HEX = "#8A93A6"
|
||||||
_FINGER_ARROW_DRAW_KW = dict(
|
"""Brand-neutral tint for the grid dots below the flow threshold."""
|
||||||
threshold=0.4,
|
|
||||||
grid_spacing=10,
|
_LUT_STEPS = 256
|
||||||
arrow_scale=1.0,
|
_RAMP_MIN_STOPS = 2
|
||||||
below_threshold_color=(160, 160, 160),
|
_RAMP_MAX_STOPS = 8
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def hex_to_bgr(hex_color: str, fallback: tuple[int, int, int] = ARROW_BG_BGR) -> tuple[int, int, int]:
|
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)
|
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:
|
def _ensure_bgr(img: np.ndarray) -> np.ndarray:
|
||||||
if img.ndim == 2:
|
if img.ndim == 2:
|
||||||
return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
|
return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
|
||||||
@@ -53,19 +111,22 @@ def draw_arrow_display(
|
|||||||
canvas_bgr: Sequence[int] | None = None,
|
canvas_bgr: Sequence[int] | None = None,
|
||||||
draw_style: str = "box",
|
draw_style: str = "box",
|
||||||
valid_mask: Optional[np.ndarray] = None,
|
valid_mask: Optional[np.ndarray] = None,
|
||||||
|
colormap: Optional[np.ndarray] = None,
|
||||||
) -> np.ndarray:
|
) -> np.ndarray:
|
||||||
"""Draw flow arrows on a solid canvas or over the processed frame."""
|
"""Draw flow arrows on a solid canvas or over the processed frame."""
|
||||||
base = _ensure_bgr(processed_frame)
|
base = _ensure_bgr(processed_frame)
|
||||||
if flow is None:
|
if flow is None:
|
||||||
return base
|
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:
|
try:
|
||||||
import orisys
|
import orisys
|
||||||
|
|
||||||
if background == "pure_color":
|
if background == "pure_color":
|
||||||
canvas = np.empty_like(base)
|
canvas = np.empty_like(base)
|
||||||
color = ARROW_BG_BGR if canvas_bgr is None else tuple(int(v) for v in canvas_bgr)
|
color_bgr = ARROW_BG_BGR if canvas_bgr is None else tuple(int(v) for v in canvas_bgr)
|
||||||
canvas[:, :] = color
|
canvas[:, :] = color_bgr
|
||||||
return orisys.util.draw_arrows(
|
return orisys.util.draw_arrows(
|
||||||
canvas, flow, valid_mask=valid_mask, **draw_kw
|
canvas, flow, valid_mask=valid_mask, **draw_kw
|
||||||
)
|
)
|
||||||
@@ -74,8 +135,11 @@ def draw_arrow_display(
|
|||||||
return base
|
return base
|
||||||
|
|
||||||
|
|
||||||
def depth_map_to_bgr(depth: Optional[np.ndarray]) -> Optional[np.ndarray]:
|
def depth_map_to_bgr(
|
||||||
"""Convert a float depth/divergence map to a JET colormap BGR image."""
|
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:
|
if depth is None:
|
||||||
return None
|
return None
|
||||||
arr = np.asarray(depth, dtype=np.float64)
|
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)
|
normalized = ((arr - lo) / (hi - lo) * 255.0).astype(np.uint8)
|
||||||
else:
|
else:
|
||||||
normalized = np.zeros(arr.shape, dtype=np.uint8)
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,45 +1,51 @@
|
|||||||
{
|
{
|
||||||
"window_bg": "#121316",
|
"window_bg": "#0E1320",
|
||||||
"panel_bg": "#1a1c20",
|
"panel_bg": "#161C2A",
|
||||||
"panel_border": "#2a2d33",
|
"panel_border": "#28303F",
|
||||||
"text_primary": "#e8eaed",
|
"toolbar_bg": "#161C2A",
|
||||||
"text_secondary": "#9aa0a6",
|
"card_bg": "#1B2231",
|
||||||
"text_muted": "#6f757c",
|
"card_border": "#2E3748",
|
||||||
"accent": "#4a62c0",
|
"separator": "#28303F",
|
||||||
"accent_hover": "#5c74d0",
|
"input_bg": "#0B0F19",
|
||||||
"accent_text": "#ffffff",
|
"input_border": "#66739A",
|
||||||
"toolbar_bg": "#1a1c20",
|
"button_bg": "#28303F",
|
||||||
"button_bg": "#2a2d33",
|
"button_hover": "#333D50",
|
||||||
"button_hover": "#3a3f47",
|
"menu_bg": "#161C2A",
|
||||||
"input_bg": "#0f1012",
|
"menu_hover": "#28303F",
|
||||||
"input_border": "#3a3f47",
|
"table_header_bg": "#1F2635",
|
||||||
"menu_bg": "#1a1c20",
|
"table_row_alt": "#1A2130",
|
||||||
"menu_hover": "#2a2d33",
|
"table_selected": "#2C3A63",
|
||||||
"plot_fn": "#8ab4f8",
|
"scrollbar_bg": "#161C2A",
|
||||||
"plot_fx": "#34a853",
|
"scrollbar_handle": "#627092",
|
||||||
"plot_fy": "#fbbc04",
|
"scrollbar_handle_hover": "#6E7C9E",
|
||||||
"plot_bg": "#15171a",
|
"plot_bg": "#11161F",
|
||||||
"plot_grid": "#2a2d33",
|
"plot_grid": "#28303F",
|
||||||
"arrow_canvas": "#121316",
|
"plot_fn": "#8AA6F2",
|
||||||
"danger": "#ea4335",
|
"plot_fx": "#F45CC0",
|
||||||
"danger_hover": "#f2554a",
|
"plot_fy": "#D8FF00",
|
||||||
"danger_text": "#ffffff",
|
"text_primary": "#E6EAF2",
|
||||||
"warning": "#fbbc04",
|
"text_secondary": "#9AA4B8",
|
||||||
"info": "#8ab4f8",
|
"text_muted": "#838FA6",
|
||||||
"success": "#34a853",
|
"hint_text": "#838FA6",
|
||||||
"status_idle": "#9aa0a6",
|
"accent": "#4D62BE",
|
||||||
"status_connecting": "#fbbc04",
|
"accent_hover": "#5770CE",
|
||||||
"status_running": "#34a853",
|
"accent_text": "#FFFFFF",
|
||||||
"status_error": "#ea4335",
|
"focus_ring": "#7C92E8",
|
||||||
"table_header_bg": "#23262b",
|
"danger": "#EE6B60",
|
||||||
"table_row_alt": "#1e2126",
|
"danger_hover": "#F0796E",
|
||||||
"table_selected": "#33406b",
|
"danger_fill": "#CC4438",
|
||||||
"scrollbar_bg": "#1a1c20",
|
"danger_fill_hover": "#BE3A31",
|
||||||
"scrollbar_handle": "#3a3f47",
|
"danger_text": "#FFFFFF",
|
||||||
"scrollbar_handle_hover": "#4a5058",
|
"warning": "#F0B429",
|
||||||
"card_bg": "#1e2126",
|
"info": "#8AA6F2",
|
||||||
"card_border": "#32363d",
|
"success": "#4FC08D",
|
||||||
"separator": "#2a2d33",
|
"status_idle": "#9AA4B8",
|
||||||
"focus_ring": "#6b82d9",
|
"status_connecting": "#F0B429",
|
||||||
"hint_text": "#7d838a"
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +1,51 @@
|
|||||||
{
|
{
|
||||||
"window_bg": "#ECEFF4",
|
"window_bg": "#E1E6EF",
|
||||||
"panel_bg": "#FFFFFF",
|
"panel_bg": "#FFFFFF",
|
||||||
"panel_border": "#D0D5DE",
|
"panel_border": "#CFD7E4",
|
||||||
"text_primary": "#1A1C20",
|
"toolbar_bg": "#EDF1F8",
|
||||||
"text_secondary": "#5F6368",
|
"card_bg": "#F4F7FC",
|
||||||
"text_muted": "#7C838C",
|
"card_border": "#D5DCE8",
|
||||||
"accent": "#003893",
|
"separator": "#D8DFEA",
|
||||||
"accent_hover": "#2A4FA8",
|
|
||||||
"accent_text": "#ffffff",
|
|
||||||
"toolbar_bg": "#E8EBF0",
|
|
||||||
"button_bg": "#F4F6FA",
|
|
||||||
"button_hover": "#E8ECF5",
|
|
||||||
"input_bg": "#FFFFFF",
|
"input_bg": "#FFFFFF",
|
||||||
"input_border": "#C5CAD3",
|
"input_border": "#7C8AA4",
|
||||||
|
"button_bg": "#F6F8FD",
|
||||||
|
"button_hover": "#E7EDF9",
|
||||||
"menu_bg": "#FFFFFF",
|
"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_fn": "#003893",
|
||||||
"plot_fx": "#188038",
|
"plot_fx": "#B0178C",
|
||||||
"plot_fy": "#E8710A",
|
"plot_fy": "#4E7A00",
|
||||||
"plot_bg": "#FAFBFD",
|
"text_primary": "#141A24",
|
||||||
"plot_grid": "#E3E6EC",
|
"text_secondary": "#4A5566",
|
||||||
"arrow_canvas": "#EEF1F7",
|
"text_muted": "#5C6678",
|
||||||
"danger": "#D93025",
|
"hint_text": "#5C6678",
|
||||||
"danger_hover": "#B3261C",
|
"accent": "#003893",
|
||||||
"danger_text": "#ffffff",
|
"accent_hover": "#4D62BE",
|
||||||
"warning": "#F9AB00",
|
"accent_text": "#FFFFFF",
|
||||||
"info": "#1A73E8",
|
"focus_ring": "#4D62BE",
|
||||||
"success": "#1B8A5A",
|
"danger": "#C7362B",
|
||||||
"status_idle": "#5F6368",
|
"danger_hover": "#B93227",
|
||||||
"status_connecting": "#F9AB00",
|
"danger_fill": "#C7362B",
|
||||||
"status_running": "#1B8A5A",
|
"danger_fill_hover": "#B93227",
|
||||||
"status_error": "#D93025",
|
"danger_text": "#FFFFFF",
|
||||||
"table_header_bg": "#F1F3F8",
|
"warning": "#8A5A00",
|
||||||
"table_row_alt": "#F8F9FC",
|
"info": "#3A55B8",
|
||||||
"table_selected": "#DCE6FA",
|
"success": "#1F7A4D",
|
||||||
"scrollbar_bg": "#EDEFF3",
|
"status_idle": "#4A5566",
|
||||||
"scrollbar_handle": "#C3C9D4",
|
"status_connecting": "#8A5A00",
|
||||||
"scrollbar_handle_hover": "#A8B0BE",
|
"status_running": "#1F7A4D",
|
||||||
"card_bg": "#F7F9FC",
|
"status_error": "#C7362B",
|
||||||
"card_border": "#DDE2EA",
|
"brand_bar_bg": "#12315D",
|
||||||
"separator": "#D8DDE6",
|
"brand_bar_fg": "#FFFFFF",
|
||||||
"focus_ring": "#2A4FA8",
|
"brand_bar_fg_muted": "#B9C8E4",
|
||||||
"hint_text": "#7C838C"
|
"arrow_canvas": "#12315D",
|
||||||
|
"flow_ramp": "#12315D,#6E4AD0,#FA02F3,#FF8A6B,#D8FF00"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -146,6 +146,35 @@ QToolTip {{
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 5px 8px;
|
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 ---------- */
|
/* ---------- Buttons ---------- */
|
||||||
QPushButton {{
|
QPushButton {{
|
||||||
background-color: {c['button_bg']};
|
background-color: {c['button_bg']};
|
||||||
@@ -157,7 +186,6 @@ QPushButton {{
|
|||||||
}}
|
}}
|
||||||
QPushButton:hover {{
|
QPushButton:hover {{
|
||||||
background-color: {c['button_hover']};
|
background-color: {c['button_hover']};
|
||||||
border-color: {c['accent_hover']};
|
|
||||||
}}
|
}}
|
||||||
QPushButton:pressed {{
|
QPushButton:pressed {{
|
||||||
background-color: {c['menu_hover']};
|
background-color: {c['menu_hover']};
|
||||||
@@ -192,20 +220,20 @@ QPushButton#primaryButton:disabled {{
|
|||||||
border-color: {c['panel_border']};
|
border-color: {c['panel_border']};
|
||||||
}}
|
}}
|
||||||
QPushButton#dangerButton {{
|
QPushButton#dangerButton {{
|
||||||
background-color: {c['danger']};
|
background-color: {c['danger_fill']};
|
||||||
color: {c['danger_text']};
|
color: {c['danger_text']};
|
||||||
border: 1px solid {c['danger']};
|
border: 1px solid {c['danger_fill']};
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 7px 18px;
|
padding: 7px 18px;
|
||||||
}}
|
}}
|
||||||
QPushButton#dangerButton:hover {{
|
QPushButton#dangerButton:hover {{
|
||||||
background-color: {c['danger_hover']};
|
background-color: {c['danger_fill_hover']};
|
||||||
border-color: {c['danger_hover']};
|
border-color: {c['danger_fill_hover']};
|
||||||
}}
|
}}
|
||||||
QPushButton#dangerButton:pressed,
|
QPushButton#dangerButton:pressed,
|
||||||
QPushButton#dangerButton:checked {{
|
QPushButton#dangerButton:checked {{
|
||||||
background-color: {c['danger_hover']};
|
background-color: {c['danger_fill_hover']};
|
||||||
border-color: {c['danger']};
|
border-color: {c['danger_fill']};
|
||||||
}}
|
}}
|
||||||
QPushButton#dangerButton:disabled {{
|
QPushButton#dangerButton:disabled {{
|
||||||
background-color: {c['panel_border']};
|
background-color: {c['panel_border']};
|
||||||
@@ -249,10 +277,10 @@ QComboBox, QLineEdit, QSpinBox, QDoubleSpinBox, QAbstractSpinBox {{
|
|||||||
selection-color: {accent_text};
|
selection-color: {accent_text};
|
||||||
}}
|
}}
|
||||||
QComboBox:hover, QLineEdit:hover, QSpinBox:hover, QDoubleSpinBox:hover {{
|
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 {{
|
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 {{
|
QComboBox:disabled, QLineEdit:disabled, QSpinBox:disabled, QDoubleSpinBox:disabled {{
|
||||||
color: {c['text_muted']};
|
color: {c['text_muted']};
|
||||||
|
|||||||
Reference in New Issue
Block a user