Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c10e579371 | |||
| 4a482e579f | |||
| 30ef50814f | |||
| a44eb2d65d | |||
| 3db55b2077 | |||
| 50122d19a2 | |||
| a1314a02cb | |||
| 5ef4d955fb | |||
| a9f75a868d | |||
| 92e0b95ca2 | |||
| 148573934f | |||
| 0d8b79b759 | |||
| 65dafdc84d | |||
| 2fd2891a47 | |||
| da8165cf03 | |||
| 9b3c5fe46c | |||
| e6a1e77a5b | |||
| 824544fc7a | |||
| 35e86ec1f7 | |||
| 0f4552cd4e | |||
| 73592f085f | |||
| 22cc59c402 | |||
| 9354d7be1a | |||
| 92da51217c | |||
| 817f830565 | |||
| f501fa93f3 | |||
| db228b6f57 | |||
| f16d2b3d84 | |||
| 85d700bc06 | |||
| 904c27125f | |||
| a2db648560 | |||
| 803a08fc78 | |||
| 705eaabcca |
+8
-1
@@ -15,7 +15,14 @@ venv/
|
||||
**/outputs/**
|
||||
!**/.gitkeep
|
||||
**/preference.json
|
||||
apps/customer/preference.json
|
||||
apps/studio/preference.json
|
||||
|
||||
# Tooling scratch (local pip/pytest caches)
|
||||
.piptmp/
|
||||
.pytestlib/
|
||||
.pytest_cache/
|
||||
shared/tests/_scratch/
|
||||
shared/tests/_scratch_*/
|
||||
|
||||
# IDE / OS
|
||||
.idea/
|
||||
|
||||
@@ -4,31 +4,81 @@ Desktop applications that consume the Orisys SDK.
|
||||
|
||||
| App | Status |
|
||||
|-----|--------|
|
||||
| `apps/customer` | Scaffold shell (theme / locale / prefs / logging) |
|
||||
| `apps/studio` | Scaffold shell (theme / locale / prefs / logging) |
|
||||
| `apps/internal` | Stub for future calibration & automated testing |
|
||||
|
||||
## Layout
|
||||
|
||||
- `shared/` — common paths, prefs, theme, i18n, logging
|
||||
- `apps/customer/config/` — locales, default prefs, editable `sensors/` copies
|
||||
- `apps/customer/outputs/` — generated logs and calibration exports (gitignored)
|
||||
- `build/` — Windows PyInstaller packaging (`build/customer/build.ps1`)
|
||||
- `apps/studio/config/` — locales, default prefs, editable `sensors/` copies
|
||||
- `apps/studio/outputs/` — generated logs and calibration exports (gitignored)
|
||||
- `build/` — Windows PyInstaller packaging (`build/studio/build.ps1`)
|
||||
|
||||
## Run (customer shell)
|
||||
## Run (studio shell)
|
||||
|
||||
```powershell
|
||||
cd D:\repo\orisys-app
|
||||
python -m pip install -e .\shared -e .\apps\customer
|
||||
python .\apps\customer\run.py
|
||||
python -m pip install -e .\shared -e .\apps\studio
|
||||
python .\apps\studio\run.py
|
||||
```
|
||||
|
||||
Or without install:
|
||||
|
||||
```powershell
|
||||
$env:PYTHONPATH = "D:\repo\orisys-app\shared\src;D:\repo\orisys-app\apps\customer\src"
|
||||
python D:\repo\orisys-app\apps\customer\run.py
|
||||
$env:PYTHONPATH = "D:\repo\orisys-app\shared\src;D:\repo\orisys-app\apps\studio\src"
|
||||
python D:\repo\orisys-app\apps\studio\run.py
|
||||
```
|
||||
|
||||
### Local `orisys` SDK
|
||||
|
||||
`orisys_app_shared.force` imports `orisys.calibration` at module scope, so the
|
||||
studio app cannot start against an SDK build that predates that module
|
||||
(the published 0.5.2 wheel does not include it). When working from a repo
|
||||
checkout, install the SDK from source instead of from an index:
|
||||
|
||||
```powershell
|
||||
python -m pip install .\..\orisys-sdk # same checkout that built this app
|
||||
```
|
||||
|
||||
If a pip build is blocked by the environment, the equivalent manual install is
|
||||
to copy `orisys-sdk/src/orisys/**/*.py` over `site-packages/orisys/` and delete
|
||||
the stale `__init__.cp310-win_amd64.pyd` (an extension module shadows the
|
||||
package `__init__.py`, which hides sibling modules such as `calibration.py`).
|
||||
|
||||
### Verifying UI changes
|
||||
|
||||
Both tools render the real main window with the offscreen Qt platform — no
|
||||
display, camera or sensor hardware required:
|
||||
|
||||
```powershell
|
||||
python .\apps\studio\tools\ui_check.py # layout/theme assertions
|
||||
python .\apps\studio\tools\ui_snapshot.py --theme light --demo `
|
||||
--sensor finger --panels cal,3d --size 1600x900 --out shot.png
|
||||
```
|
||||
|
||||
`ui_check.py` needs only the runtime dependencies. The `pytest` suite in
|
||||
`shared/tests` and `apps/studio/tests` covers the same ground plus locale and
|
||||
preference-schema parity:
|
||||
|
||||
```powershell
|
||||
pip install pytest
|
||||
python -m pytest
|
||||
```
|
||||
|
||||
On Windows the offscreen platform finds no fonts unless `QT_QPA_FONTDIR` points
|
||||
at `C:\Windows\Fonts`; both tools set it automatically.
|
||||
|
||||
`ui_check.py` also runs the palette rules (WCAG contrast, colour separation,
|
||||
ramp monotonicity) that guard the brand tokens; see
|
||||
[`apps/studio/docs/ui-palette.md`](apps/studio/docs/ui-palette.md) for the
|
||||
colour system, its relationship to the UI design spec, and every deliberate
|
||||
deviation from it.
|
||||
|
||||
`ui_check.py` includes a `record channel` section as well; see
|
||||
[`apps/studio/docs/record-channel.md`](apps/studio/docs/record-channel.md) for
|
||||
why recording and calibration sampling no longer hang off the UI refresh timer,
|
||||
and for the data-reliability fixes that came with it.
|
||||
|
||||
## Branding
|
||||
|
||||
Window / EXE icon: `shared/resources/branding/logo.png`
|
||||
@@ -38,9 +88,9 @@ Dark mode is the default. Logos currently sit on a black tile; a transparent (or
|
||||
|
||||
## Versioning
|
||||
|
||||
- **Customer app version** (`orisys_customer.__version__`) — shown in Help → About and used for releases.
|
||||
- **Studio app version** (`orisys_studio.__version__`) — shown in Help → About and used for releases.
|
||||
- **Shared package version** (`orisys_app_shared.__version__`) — internal library version.
|
||||
- **Shared API** (`orisys_app_shared.API_VERSION`) — compatibility tag. Customer declares `REQUIRES_SHARED_API`; startup logs a warning on mismatch.
|
||||
- **Shared API** (`orisys_app_shared.API_VERSION`) — compatibility tag. Studio declares `REQUIRES_SHARED_API`; startup logs a warning on mismatch.
|
||||
|
||||
## Deferred
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.0 MiB |
@@ -1,116 +0,0 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "Orisys App",
|
||||
"about_title": "About",
|
||||
"about_body": "Orisys customer desktop application\nVersion: {app_version}",
|
||||
"scaffold_hint": "Scaffold shell — acquisition coming later"
|
||||
},
|
||||
"menu": {
|
||||
"file": "File",
|
||||
"open_video": "Open video…",
|
||||
"reveal_config": "Reveal sensor config…",
|
||||
"view": "View",
|
||||
"theme": "Theme",
|
||||
"theme_dark": "Dark",
|
||||
"theme_light": "Light",
|
||||
"language": "Language",
|
||||
"lang_zh": "简体中文",
|
||||
"lang_en": "English",
|
||||
"calibration_panel": "Calibration panel",
|
||||
"finger_3d": "Finger 3D view",
|
||||
"show_fps": "Show FPS",
|
||||
"plot_settings": "Plot settings…",
|
||||
"rotate_arrows": "Rotate arrows 90°",
|
||||
"help": "Help",
|
||||
"about": "About"
|
||||
},
|
||||
"dialog": {
|
||||
"plot_settings_title": "Plot settings",
|
||||
"plot_window": "Time window",
|
||||
"plot_window_tip": "Force plot time window (seconds)",
|
||||
"plot_fn_max": "FN max",
|
||||
"plot_fn_max_tip": "Normal force axis max (Newton mode)",
|
||||
"plot_shear_max": "Shear max",
|
||||
"plot_shear_max_tip": "Shear force axis ±max for FX/FY (Newton mode)",
|
||||
"reset_defaults": "Reset to defaults",
|
||||
"ok": "OK",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"toolbar": {
|
||||
"source": "Source",
|
||||
"source_camera": "Camera",
|
||||
"source_video": "Video file",
|
||||
"search_cameras": "Search",
|
||||
"cam_id": "Cam ID",
|
||||
"browse_video": "Browse…",
|
||||
"video_placeholder": "No video selected",
|
||||
"preview": "Preview",
|
||||
"preview_stop": "Stop preview",
|
||||
"run": "Run",
|
||||
"stop": "Stop",
|
||||
"reset": "Reset",
|
||||
"units": "Units",
|
||||
"units_newton": "Newton",
|
||||
"units_raw": "Raw"
|
||||
},
|
||||
"sensor": {
|
||||
"type": "Sensor type",
|
||||
"config": "Config",
|
||||
"thumb_placeholder": "Thumbnail"
|
||||
},
|
||||
"panels": {
|
||||
"arrow": "2D arrows",
|
||||
"view_arrows_pure": "Arrows",
|
||||
"view_arrows_stitched": "Arrows / Captured",
|
||||
"view_depth": "Depth map",
|
||||
"view_mode_tip": "Image display mode while running",
|
||||
"plots": "Force Charts",
|
||||
"cal": "Calibration",
|
||||
"mesh3d": "3D mesh",
|
||||
"mesh3d_hint": "Finger 3D view placeholder — Open3D mesh coming later"
|
||||
},
|
||||
"cal": {
|
||||
"weight": "Weight (N)",
|
||||
"duration": "Record duration",
|
||||
"record": "Record",
|
||||
"delete_row": "Delete row",
|
||||
"clear_channel": "Clear channel",
|
||||
"clear_all": "Clear all",
|
||||
"export": "Export…",
|
||||
"sn": "SN",
|
||||
"sn_placeholder": "Serial number (optional)",
|
||||
"live_raw": "Live raw FN={fn} FX={fx} FY={fy}",
|
||||
"col_weight": "Weight (N)",
|
||||
"col_raw": "Raw mean",
|
||||
"col_n": "n",
|
||||
"col_std": "σ",
|
||||
"col_actions": "",
|
||||
"recording": "Recording calibration samples…",
|
||||
"record_ok": "Recorded weight={weight} raw={raw}",
|
||||
"record_failed_few": "Recording failed — too few samples",
|
||||
"export_ok": "Exported {path}",
|
||||
"export_local": "Also wrote {path}",
|
||||
"export_warnings": "Skipped: {detail}",
|
||||
"export_failed": "Export failed: {error}"
|
||||
},
|
||||
"status": {
|
||||
"ready": "Ready",
|
||||
"camera_none": "No cameras found",
|
||||
"camera_found": "Found {count} camera(s): {names}",
|
||||
"preview_started": "Preview started (camera {camera_id})",
|
||||
"preview_stopped": "Preview stopped",
|
||||
"preview_failed": "Preview failed: {error}",
|
||||
"connecting": "Connecting…",
|
||||
"run_started": "Running ({source})",
|
||||
"run_stopped": "Stopped",
|
||||
"run_failed": "Run failed: {error}",
|
||||
"video_ended": "Video ended",
|
||||
"camera_disconnected": "Camera disconnected",
|
||||
"reset_ok": "Tracker reset",
|
||||
"finger_run_unavailable": "Run is not available for this sensor type",
|
||||
"video_required": "Select a video file first",
|
||||
"config_missing": "Sensor config not found for {type}",
|
||||
"units_raw_fallback": "Calibration unavailable — showing raw units",
|
||||
"reveal_failed": "Could not reveal config: {error}"
|
||||
}
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "Orisys 应用",
|
||||
"about_title": "关于",
|
||||
"about_body": "Orisys 客户端桌面应用\n版本: {app_version}",
|
||||
"scaffold_hint": "脚手架界面 — 采集功能稍后加入"
|
||||
},
|
||||
"menu": {
|
||||
"file": "文件",
|
||||
"open_video": "打开视频…",
|
||||
"reveal_config": "在资源管理器中显示配置…",
|
||||
"view": "视图",
|
||||
"theme": "主题",
|
||||
"theme_dark": "深色",
|
||||
"theme_light": "浅色",
|
||||
"language": "语言",
|
||||
"lang_zh": "简体中文",
|
||||
"lang_en": "English",
|
||||
"calibration_panel": "标定面板",
|
||||
"finger_3d": "手指 3D 视图",
|
||||
"show_fps": "显示 FPS",
|
||||
"plot_settings": "绘图设置…",
|
||||
"rotate_arrows": "旋转箭头图 90°",
|
||||
"help": "帮助",
|
||||
"about": "关于"
|
||||
},
|
||||
"dialog": {
|
||||
"plot_settings_title": "绘图设置",
|
||||
"plot_window": "时间窗口",
|
||||
"plot_window_tip": "力曲线时间窗口(秒)",
|
||||
"plot_fn_max": "FN 上限",
|
||||
"plot_fn_max_tip": "法向力纵轴上限(牛顿模式)",
|
||||
"plot_shear_max": "剪切上限",
|
||||
"plot_shear_max_tip": "剪切力 FX/FY 纵轴 ±上限(牛顿模式)",
|
||||
"reset_defaults": "恢复默认",
|
||||
"ok": "确定",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"toolbar": {
|
||||
"source": "来源",
|
||||
"source_camera": "摄像机",
|
||||
"source_video": "视频文件",
|
||||
"search_cameras": "搜索",
|
||||
"cam_id": "摄像机",
|
||||
"browse_video": "浏览…",
|
||||
"video_placeholder": "未选择视频",
|
||||
"preview": "预览",
|
||||
"preview_stop": "停止预览",
|
||||
"run": "运行",
|
||||
"stop": "停止",
|
||||
"reset": "重置",
|
||||
"units": "单位",
|
||||
"units_newton": "牛顿",
|
||||
"units_raw": "原始"
|
||||
},
|
||||
"sensor": {
|
||||
"type": "传感器类型",
|
||||
"config": "配置",
|
||||
"thumb_placeholder": "缩略图"
|
||||
},
|
||||
"panels": {
|
||||
"arrow": "形变箭头",
|
||||
"view_arrows_pure": "箭头",
|
||||
"view_arrows_stitched": "箭头/采集图像",
|
||||
"view_depth": "深度图",
|
||||
"view_mode_tip": "运行时的图像显示模式",
|
||||
"plots": "力曲线图",
|
||||
"cal": "标定",
|
||||
"mesh3d": "三维网格",
|
||||
"mesh3d_hint": "手指三维视图占位 — Open3D 网格稍后加入"
|
||||
},
|
||||
"cal": {
|
||||
"weight": "砝码重量 (N)",
|
||||
"duration": "采集时长",
|
||||
"record": "记录",
|
||||
"delete_row": "删除行",
|
||||
"clear_channel": "清空当前通道",
|
||||
"clear_all": "清空全部",
|
||||
"export": "导出…",
|
||||
"sn": "序列号",
|
||||
"sn_placeholder": "序列号(可选)",
|
||||
"live_raw": "实时原始值 FN={fn} FX={fx} FY={fy}",
|
||||
"col_weight": "重量 (N)",
|
||||
"col_raw": "原始均值",
|
||||
"col_n": "n",
|
||||
"col_std": "σ",
|
||||
"col_actions": "",
|
||||
"recording": "正在采集标定样本…",
|
||||
"record_ok": "已记录 weight={weight} raw={raw}",
|
||||
"record_failed_few": "采集失败 — 样本过少",
|
||||
"export_ok": "已导出 {path}",
|
||||
"export_local": "同时写入 {path}",
|
||||
"export_warnings": "跳过: {detail}",
|
||||
"export_failed": "导出失败: {error}"
|
||||
},
|
||||
"status": {
|
||||
"ready": "就绪",
|
||||
"camera_none": "未发现摄像机",
|
||||
"camera_found": "发现 {count} 个摄像机: {names}",
|
||||
"preview_started": "预览已启动(摄像机 {camera_id})",
|
||||
"preview_stopped": "预览已停止",
|
||||
"preview_failed": "预览失败: {error}",
|
||||
"connecting": "正在连接…",
|
||||
"run_started": "运行中({source})",
|
||||
"run_stopped": "已停止",
|
||||
"run_failed": "启动失败: {error}",
|
||||
"video_ended": "视频已结束",
|
||||
"camera_disconnected": "摄像机已断开",
|
||||
"reset_ok": "追踪器已重置",
|
||||
"finger_run_unavailable": "此传感器类型暂不支持运行",
|
||||
"video_required": "请先选择视频文件",
|
||||
"config_missing": "未找到传感器配置: {type}",
|
||||
"units_raw_fallback": "无标定数据 — 显示原始单位",
|
||||
"reveal_failed": "无法打开配置位置: {error}"
|
||||
}
|
||||
}
|
||||
@@ -1,865 +0,0 @@
|
||||
"""Main window shell for the customer app."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from PyQt5.QtCore import Qt, QTimer
|
||||
from PyQt5.QtGui import QIcon, QImage
|
||||
from PyQt5.QtWidgets import (
|
||||
QAction,
|
||||
QActionGroup,
|
||||
QApplication,
|
||||
QFileDialog,
|
||||
QHBoxLayout,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QSplitter,
|
||||
QStatusBar,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.force import select_display_forces
|
||||
from orisys_app_shared.i18n import set_locale, t
|
||||
from orisys_app_shared.logging import report_error
|
||||
from orisys_app_shared.paths import logo_path, sensor_json_path
|
||||
from orisys_app_shared.prefs import save_preferences
|
||||
from orisys_app_shared.theme import build_qss, color
|
||||
|
||||
from orisys_customer.ui.about_dialog import AboutDialog
|
||||
from orisys_customer.ui.arrow_panel import ArrowPanel
|
||||
from orisys_customer.ui.cal_panel import CalPanel
|
||||
from orisys_customer.ui.force_plots import ForcePlotsPanel
|
||||
from orisys_customer.ui.mesh3d_panel import Mesh3dPanel
|
||||
from orisys_customer.ui.plot_settings_dialog import (
|
||||
DEFAULT_PLOT_FN_MAX,
|
||||
DEFAULT_PLOT_SHEAR_MAX,
|
||||
DEFAULT_PLOT_WINDOW_SEC,
|
||||
PlotSettingsDialog,
|
||||
)
|
||||
from orisys_customer.ui.sensor_picker import SensorPickerWidget
|
||||
from orisys_customer.ui.toolbar import ToolbarWidget
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RUNNABLE_TYPES = frozenset({"box", "mini", "finger"})
|
||||
ARROW_ROTATE_TYPES = frozenset({"box", "mini"})
|
||||
|
||||
|
||||
def _bgr_to_qimage(frame: np.ndarray) -> QImage:
|
||||
rgb = frame[:, :, ::-1].copy()
|
||||
h, w, ch = rgb.shape
|
||||
bytes_per_line = ch * w
|
||||
return QImage(rgb.data, w, h, bytes_per_line, QImage.Format_RGB888).copy()
|
||||
|
||||
|
||||
def _reveal_in_explorer(path: Path) -> None:
|
||||
path = path.resolve()
|
||||
if sys.platform == "win32":
|
||||
subprocess.run(["explorer", "/select,", str(path)], check=False)
|
||||
return
|
||||
if sys.platform == "darwin":
|
||||
subprocess.run(["open", "-R", str(path)], check=False)
|
||||
return
|
||||
folder = path.parent if path.is_file() else path
|
||||
subprocess.run(["xdg-open", str(folder)], check=False)
|
||||
|
||||
|
||||
class CustomerMainWindow(QMainWindow):
|
||||
def __init__(self, prefs: dict[str, Any] | None = None) -> None:
|
||||
super().__init__()
|
||||
self.prefs: dict[str, Any] = dict(prefs or {})
|
||||
self._theme = str(self.prefs.get("theme") or "dark")
|
||||
self._language = str(self.prefs.get("language") or "zh_CN")
|
||||
self._show_cal = bool(self.prefs.get("show_cal_panel", False))
|
||||
self._show_finger_3d = bool(self.prefs.get("show_finger_3d", True))
|
||||
self._show_fps = bool(self.prefs.get("show_fps", False))
|
||||
self._session_phase = "idle"
|
||||
self._running = False
|
||||
self._preview = None
|
||||
self._preview_timer = QTimer(self)
|
||||
self._preview_timer.setInterval(33)
|
||||
self._preview_timer.timeout.connect(self._on_preview_tick)
|
||||
self._run_timer = QTimer(self)
|
||||
self._run_timer.setInterval(16)
|
||||
self._run_timer.timeout.connect(self._on_run_tick)
|
||||
self._box_worker = None
|
||||
self._finger_worker = None
|
||||
self._active_worker = None
|
||||
self._active_sensor_type = "box"
|
||||
self._last_session_error = ""
|
||||
self._last_plot_packet_key: tuple[int, float] | None = None
|
||||
self._last_rendered_view_mode: str | None = None
|
||||
self._run_source_label = ""
|
||||
|
||||
icon = QIcon(str(logo_path()))
|
||||
if not icon.isNull():
|
||||
self.setWindowIcon(icon)
|
||||
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
root = QVBoxLayout(central)
|
||||
root.setContentsMargins(10, 10, 10, 10)
|
||||
root.setSpacing(8)
|
||||
|
||||
top = QHBoxLayout()
|
||||
self.toolbar = ToolbarWidget()
|
||||
self.sensor_picker = SensorPickerWidget()
|
||||
top.addWidget(self.toolbar, 2)
|
||||
top.addWidget(self.sensor_picker, 1)
|
||||
root.addLayout(top)
|
||||
|
||||
self.content_splitter = QSplitter(Qt.Horizontal)
|
||||
self.mesh3d_panel = Mesh3dPanel()
|
||||
self.arrow_panel = ArrowPanel()
|
||||
self.force_plots = ForcePlotsPanel(theme=self._theme)
|
||||
self.cal_panel = CalPanel()
|
||||
self.content_splitter.addWidget(self.mesh3d_panel)
|
||||
self.content_splitter.addWidget(self.arrow_panel)
|
||||
self.content_splitter.addWidget(self.force_plots)
|
||||
self.content_splitter.addWidget(self.cal_panel)
|
||||
root.addWidget(self.content_splitter, 1)
|
||||
|
||||
self.setStatusBar(QStatusBar())
|
||||
self.cal_panel.status_message.connect(self.statusBar().showMessage)
|
||||
self._build_menus()
|
||||
self._wire_toolbar()
|
||||
self._wire_sensor_picker()
|
||||
self._restore_prefs_into_ui()
|
||||
self._apply_panel_visibility()
|
||||
self._update_run_enabled()
|
||||
self.cal_panel.set_meta_context(
|
||||
sensor_type=self.sensor_picker.sensor_type(),
|
||||
config_ref=self._resolve_config_ref(),
|
||||
)
|
||||
self.retranslate()
|
||||
self._restore_geometry()
|
||||
self.resize(1280, 720)
|
||||
|
||||
@property
|
||||
def _preview_open(self) -> bool:
|
||||
return self._preview is not None and bool(self._preview.is_open)
|
||||
|
||||
def _get_preview(self):
|
||||
if self._preview is None:
|
||||
from orisys_app_shared.camera import RawPreviewSession
|
||||
|
||||
self._preview = RawPreviewSession()
|
||||
return self._preview
|
||||
|
||||
def _ensure_workers(self) -> None:
|
||||
if self._box_worker is not None and self._finger_worker is not None:
|
||||
return
|
||||
from orisys_app_shared.acquisition.finger_worker import FingerAcquisitionWorker
|
||||
from orisys_app_shared.acquisition.sdk_worker import SdkAcquisitionWorker
|
||||
|
||||
if self._box_worker is None:
|
||||
self._box_worker = SdkAcquisitionWorker(
|
||||
on_error=self._on_worker_error,
|
||||
on_status=self._on_worker_status,
|
||||
)
|
||||
if self._finger_worker is None:
|
||||
self._finger_worker = FingerAcquisitionWorker(
|
||||
on_error=self._on_worker_error,
|
||||
on_status=self._on_worker_status,
|
||||
)
|
||||
|
||||
def _wire_toolbar(self) -> None:
|
||||
self.toolbar.search_clicked.connect(self._on_search_cameras)
|
||||
self.toolbar.preview_toggled.connect(self._on_preview_toggled)
|
||||
self.toolbar.camera_id_changed.connect(self._on_camera_id_changed)
|
||||
self.toolbar.source_mode_changed.connect(self._on_source_mode_changed)
|
||||
self.toolbar.browse_video_clicked.connect(self._on_browse_video)
|
||||
self.toolbar.run_toggled.connect(self._on_run_toggled)
|
||||
self.toolbar.reset_clicked.connect(self._on_reset)
|
||||
self.toolbar.units_changed.connect(self._on_units_changed)
|
||||
|
||||
def _wire_sensor_picker(self) -> None:
|
||||
self.sensor_picker.sensor_type_changed.connect(self._on_sensor_type_changed)
|
||||
self.sensor_picker.config_changed.connect(self._on_config_changed)
|
||||
self.arrow_panel.view_mode_changed.connect(self._on_image_view_mode_changed)
|
||||
|
||||
def _restore_prefs_into_ui(self) -> None:
|
||||
raw_id = str(self.prefs.get("camera_id") or "0").strip()
|
||||
try:
|
||||
cam_id = int(raw_id)
|
||||
except ValueError:
|
||||
cam_id = 0
|
||||
self.toolbar.set_camera_id(cam_id)
|
||||
self.toolbar.set_source_mode(str(self.prefs.get("source_mode") or "camera"))
|
||||
self.toolbar.set_video_path(str(self.prefs.get("video_path") or ""))
|
||||
self.toolbar.set_units(str(self.prefs.get("units") or "newton"))
|
||||
self.arrow_panel.set_view_mode(str(self.prefs.get("image_view_mode") or "arrows_pure"))
|
||||
self.arrow_panel.set_fps_visible(self._show_fps)
|
||||
self.arrow_panel.set_rotation_deg(self.prefs.get("arrow_rotation_deg", 90))
|
||||
self._apply_plot_prefs_to_panel()
|
||||
|
||||
sensor_type = str(self.prefs.get("sensor_type") or "box")
|
||||
config_name = str(self.prefs.get("config_name") or "")
|
||||
self.sensor_picker.set_sensor_type(sensor_type)
|
||||
if config_name:
|
||||
self.sensor_picker.set_config_name(config_name)
|
||||
else:
|
||||
self.prefs["config_name"] = self.sensor_picker.config_name()
|
||||
self._apply_arrow_rotation_for_sensor()
|
||||
|
||||
def _build_menus(self) -> None:
|
||||
menu_bar = self.menuBar()
|
||||
|
||||
self.file_menu = menu_bar.addMenu("")
|
||||
self.act_open_video = QAction(self)
|
||||
self.act_open_video.triggered.connect(self._on_browse_video)
|
||||
self.file_menu.addAction(self.act_open_video)
|
||||
self.act_reveal_config = QAction(self)
|
||||
self.act_reveal_config.triggered.connect(self._on_reveal_config)
|
||||
self.file_menu.addAction(self.act_reveal_config)
|
||||
|
||||
self.view_menu = menu_bar.addMenu("")
|
||||
self.act_cal_panel = QAction(self)
|
||||
self.act_cal_panel.setCheckable(True)
|
||||
self.act_cal_panel.setChecked(self._show_cal)
|
||||
self.act_cal_panel.toggled.connect(self._on_cal_panel_toggled)
|
||||
self.view_menu.addAction(self.act_cal_panel)
|
||||
|
||||
self.act_finger_3d = QAction(self)
|
||||
self.act_finger_3d.setCheckable(True)
|
||||
self.act_finger_3d.setChecked(self._show_finger_3d)
|
||||
self.act_finger_3d.toggled.connect(self._on_finger_3d_toggled)
|
||||
self.view_menu.addAction(self.act_finger_3d)
|
||||
|
||||
self.act_show_fps = QAction(self)
|
||||
self.act_show_fps.setCheckable(True)
|
||||
self.act_show_fps.setChecked(self._show_fps)
|
||||
self.act_show_fps.toggled.connect(self._on_show_fps_toggled)
|
||||
self.view_menu.addAction(self.act_show_fps)
|
||||
|
||||
self.act_plot_settings = QAction(self)
|
||||
self.act_plot_settings.triggered.connect(self._on_plot_settings)
|
||||
self.view_menu.addAction(self.act_plot_settings)
|
||||
|
||||
self.act_rotate_arrows = QAction(self)
|
||||
self.act_rotate_arrows.triggered.connect(self._on_rotate_arrows)
|
||||
self.view_menu.addAction(self.act_rotate_arrows)
|
||||
self.view_menu.addSeparator()
|
||||
|
||||
self.theme_menu = self.view_menu.addMenu("")
|
||||
self.theme_group = QActionGroup(self)
|
||||
self.theme_group.setExclusive(True)
|
||||
self.act_theme_dark = QAction(self)
|
||||
self.act_theme_dark.setCheckable(True)
|
||||
self.act_theme_light = QAction(self)
|
||||
self.act_theme_light.setCheckable(True)
|
||||
self.theme_group.addAction(self.act_theme_dark)
|
||||
self.theme_group.addAction(self.act_theme_light)
|
||||
self.theme_menu.addAction(self.act_theme_dark)
|
||||
self.theme_menu.addAction(self.act_theme_light)
|
||||
self.act_theme_dark.triggered.connect(lambda: self._set_theme("dark"))
|
||||
self.act_theme_light.triggered.connect(lambda: self._set_theme("light"))
|
||||
|
||||
self.lang_menu = self.view_menu.addMenu("")
|
||||
self.lang_group = QActionGroup(self)
|
||||
self.lang_group.setExclusive(True)
|
||||
self.act_lang_zh = QAction(self)
|
||||
self.act_lang_zh.setCheckable(True)
|
||||
self.act_lang_en = QAction(self)
|
||||
self.act_lang_en.setCheckable(True)
|
||||
self.lang_group.addAction(self.act_lang_zh)
|
||||
self.lang_group.addAction(self.act_lang_en)
|
||||
self.lang_menu.addAction(self.act_lang_zh)
|
||||
self.lang_menu.addAction(self.act_lang_en)
|
||||
self.act_lang_zh.triggered.connect(lambda: self._set_language("zh_CN"))
|
||||
self.act_lang_en.triggered.connect(lambda: self._set_language("en_US"))
|
||||
|
||||
self.help_menu = menu_bar.addMenu("")
|
||||
self.act_about = QAction(self)
|
||||
self.act_about.triggered.connect(self._show_about)
|
||||
self.help_menu.addAction(self.act_about)
|
||||
|
||||
self._sync_menu_checks()
|
||||
|
||||
def _sync_menu_checks(self) -> None:
|
||||
self.act_theme_dark.setChecked(self._theme != "light")
|
||||
self.act_theme_light.setChecked(self._theme == "light")
|
||||
self.act_lang_zh.setChecked(self._language.startswith("zh"))
|
||||
self.act_lang_en.setChecked(not self._language.startswith("zh"))
|
||||
self.act_cal_panel.blockSignals(True)
|
||||
self.act_cal_panel.setChecked(self._show_cal)
|
||||
self.act_cal_panel.blockSignals(False)
|
||||
is_finger = self.sensor_picker.sensor_type() == "finger"
|
||||
self.act_finger_3d.setEnabled(is_finger)
|
||||
self.act_finger_3d.blockSignals(True)
|
||||
self.act_finger_3d.setChecked(bool(self._show_finger_3d) if is_finger else False)
|
||||
self.act_finger_3d.blockSignals(False)
|
||||
self._apply_arrow_rotation_for_sensor()
|
||||
|
||||
def _apply_panel_visibility(self) -> None:
|
||||
is_finger = self.sensor_picker.sensor_type() == "finger"
|
||||
show_3d = is_finger and bool(self._show_finger_3d)
|
||||
self.mesh3d_panel.setVisible(show_3d)
|
||||
self.cal_panel.setVisible(self._show_cal)
|
||||
self.act_finger_3d.setEnabled(is_finger)
|
||||
self.act_finger_3d.blockSignals(True)
|
||||
self.act_finger_3d.setChecked(show_3d)
|
||||
self.act_finger_3d.blockSignals(False)
|
||||
for i in range(self.content_splitter.count()):
|
||||
widget = self.content_splitter.widget(i)
|
||||
if widget is self.mesh3d_panel:
|
||||
self.content_splitter.setStretchFactor(i, 3 if show_3d else 0)
|
||||
elif widget is self.arrow_panel:
|
||||
self.content_splitter.setStretchFactor(i, 3)
|
||||
elif widget is self.force_plots:
|
||||
self.content_splitter.setStretchFactor(i, 3)
|
||||
elif widget is self.cal_panel:
|
||||
self.content_splitter.setStretchFactor(i, 2 if self._show_cal else 0)
|
||||
|
||||
def _update_run_enabled(self) -> None:
|
||||
sensor_ok = self.sensor_picker.sensor_type() in RUNNABLE_TYPES
|
||||
source_ok = True
|
||||
if self.toolbar.source_mode() == "video" and not self.toolbar.video_path():
|
||||
source_ok = False
|
||||
self.toolbar.set_run_enabled(self._running or (sensor_ok and source_ok))
|
||||
if not self._running:
|
||||
self.toolbar._refresh_run_label()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.setWindowTitle(t("app.title"))
|
||||
self.file_menu.setTitle(t("menu.file"))
|
||||
self.act_open_video.setText(t("menu.open_video"))
|
||||
self.act_reveal_config.setText(t("menu.reveal_config"))
|
||||
self.view_menu.setTitle(t("menu.view"))
|
||||
self.act_cal_panel.setText(t("menu.calibration_panel"))
|
||||
self.act_finger_3d.setText(t("menu.finger_3d"))
|
||||
self.act_show_fps.setText(t("menu.show_fps"))
|
||||
self.act_plot_settings.setText(t("menu.plot_settings"))
|
||||
self.act_rotate_arrows.setText(t("menu.rotate_arrows"))
|
||||
self.theme_menu.setTitle(t("menu.theme"))
|
||||
self.act_theme_dark.setText(t("menu.theme_dark"))
|
||||
self.act_theme_light.setText(t("menu.theme_light"))
|
||||
self.lang_menu.setTitle(t("menu.language"))
|
||||
self.act_lang_zh.setText(t("menu.lang_zh"))
|
||||
self.act_lang_en.setText(t("menu.lang_en"))
|
||||
self.help_menu.setTitle(t("menu.help"))
|
||||
self.act_about.setText(t("menu.about"))
|
||||
self.toolbar.retranslate()
|
||||
self.sensor_picker.retranslate()
|
||||
self.mesh3d_panel.retranslate()
|
||||
self.arrow_panel.retranslate()
|
||||
self.force_plots.retranslate()
|
||||
self.cal_panel.retranslate()
|
||||
if not self._preview_open and not self._running:
|
||||
self.statusBar().showMessage(t("status.ready"))
|
||||
self._update_run_enabled()
|
||||
|
||||
def _set_theme(self, theme: str) -> None:
|
||||
self._theme = theme
|
||||
self.prefs["theme"] = theme
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
app.setStyleSheet(build_qss(theme))
|
||||
self.force_plots.set_theme(theme)
|
||||
self._sync_menu_checks()
|
||||
self._persist_prefs()
|
||||
|
||||
def _set_language(self, language: str) -> None:
|
||||
self._language = language
|
||||
self.prefs["language"] = language
|
||||
set_locale(language)
|
||||
self.retranslate()
|
||||
self._sync_menu_checks()
|
||||
self._persist_prefs()
|
||||
|
||||
def _show_about(self) -> None:
|
||||
AboutDialog(self).exec_()
|
||||
|
||||
def _on_camera_id_changed(self, value: int) -> None:
|
||||
self.prefs["camera_id"] = str(int(value))
|
||||
self._persist_prefs()
|
||||
|
||||
def _on_source_mode_changed(self, mode: str) -> None:
|
||||
self.prefs["source_mode"] = mode
|
||||
self._persist_prefs()
|
||||
self._update_run_enabled()
|
||||
|
||||
def _on_units_changed(self, units: str) -> None:
|
||||
self.prefs["units"] = units
|
||||
self.force_plots.set_fixed_newton(str(units) == "newton")
|
||||
self._persist_prefs()
|
||||
|
||||
def _on_image_view_mode_changed(self, mode: str) -> None:
|
||||
self.prefs["image_view_mode"] = str(mode)
|
||||
self._last_rendered_view_mode = None
|
||||
self._persist_prefs()
|
||||
if self._running:
|
||||
if self._active_worker is not None:
|
||||
self._render_run_image(self._active_worker.latest())
|
||||
|
||||
def _on_plot_settings(self) -> None:
|
||||
dialog = PlotSettingsDialog(
|
||||
self,
|
||||
window_sec=float(self.prefs.get("plot_window_sec") or DEFAULT_PLOT_WINDOW_SEC),
|
||||
fn_max=float(self.prefs.get("plot_fn_max") or DEFAULT_PLOT_FN_MAX),
|
||||
shear_max=float(self.prefs.get("plot_shear_max") or DEFAULT_PLOT_SHEAR_MAX),
|
||||
units=self.toolbar.units(),
|
||||
)
|
||||
if dialog.exec_() != PlotSettingsDialog.Accepted:
|
||||
return
|
||||
window_sec, fn_max, shear_max = dialog.values()
|
||||
self.prefs["plot_window_sec"] = window_sec
|
||||
self.prefs["plot_fn_max"] = fn_max
|
||||
self.prefs["plot_shear_max"] = shear_max
|
||||
self.force_plots.set_window_sec(window_sec)
|
||||
self.force_plots.set_axis_limits(fn_max, shear_max)
|
||||
self._last_plot_packet_key = None
|
||||
self._persist_prefs()
|
||||
|
||||
def _apply_plot_prefs_to_panel(self) -> None:
|
||||
self.force_plots.set_window_sec(
|
||||
float(self.prefs.get("plot_window_sec") or DEFAULT_PLOT_WINDOW_SEC)
|
||||
)
|
||||
self.force_plots.set_axis_limits(
|
||||
float(self.prefs.get("plot_fn_max") or DEFAULT_PLOT_FN_MAX),
|
||||
float(self.prefs.get("plot_shear_max") or DEFAULT_PLOT_SHEAR_MAX),
|
||||
)
|
||||
self.force_plots.set_fixed_newton(self.toolbar.units() == "newton")
|
||||
|
||||
def _on_cal_panel_toggled(self, visible: bool) -> None:
|
||||
self._show_cal = bool(visible)
|
||||
self.prefs["show_cal_panel"] = self._show_cal
|
||||
self._apply_panel_visibility()
|
||||
self._persist_prefs()
|
||||
|
||||
def _on_finger_3d_toggled(self, visible: bool) -> None:
|
||||
if self.sensor_picker.sensor_type() != "finger":
|
||||
return
|
||||
self._show_finger_3d = bool(visible)
|
||||
self.prefs["show_finger_3d"] = self._show_finger_3d
|
||||
self._apply_panel_visibility()
|
||||
self._persist_prefs()
|
||||
|
||||
def _on_show_fps_toggled(self, visible: bool) -> None:
|
||||
self._show_fps = bool(visible)
|
||||
self.prefs["show_fps"] = self._show_fps
|
||||
self.arrow_panel.set_fps_visible(self._show_fps)
|
||||
if not self._show_fps:
|
||||
self.arrow_panel.set_fps(None)
|
||||
self._persist_prefs()
|
||||
|
||||
def _on_sensor_type_changed(self, sensor_type: str) -> None:
|
||||
self.prefs["sensor_type"] = str(sensor_type)
|
||||
self.prefs["config_name"] = self.sensor_picker.config_name()
|
||||
self.cal_panel.set_meta_context(
|
||||
sensor_type=str(sensor_type),
|
||||
config_ref=self._resolve_config_ref(),
|
||||
)
|
||||
self._apply_panel_visibility()
|
||||
self._apply_arrow_rotation_for_sensor()
|
||||
self._update_run_enabled()
|
||||
self._persist_prefs()
|
||||
|
||||
def _apply_arrow_rotation_for_sensor(self) -> None:
|
||||
enabled = self.sensor_picker.sensor_type() in ARROW_ROTATE_TYPES
|
||||
self.arrow_panel.set_rotation_enabled(enabled)
|
||||
self.act_rotate_arrows.setEnabled(enabled)
|
||||
|
||||
def _on_rotate_arrows(self) -> None:
|
||||
if self.sensor_picker.sensor_type() not in ARROW_ROTATE_TYPES:
|
||||
return
|
||||
deg = self.arrow_panel.rotate_90_ccw()
|
||||
self.prefs["arrow_rotation_deg"] = deg
|
||||
self._persist_prefs()
|
||||
|
||||
def _on_config_changed(self, config_name: str) -> None:
|
||||
self.prefs["config_name"] = str(config_name)
|
||||
self._persist_prefs()
|
||||
|
||||
def _on_browse_video(self) -> None:
|
||||
if self._running or self._preview_open:
|
||||
return
|
||||
start = self.toolbar.video_path() or str(Path.home())
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
t("menu.open_video"),
|
||||
start,
|
||||
"Video (*.mp4 *.avi *.mov *.mkv);;All (*)",
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
self.toolbar.set_source_mode("video")
|
||||
self.toolbar.set_video_path(path)
|
||||
self.prefs["source_mode"] = "video"
|
||||
self.prefs["video_path"] = path
|
||||
self._persist_prefs()
|
||||
self._update_run_enabled()
|
||||
|
||||
def _on_reveal_config(self) -> None:
|
||||
sensor_type = self.sensor_picker.sensor_type()
|
||||
path = sensor_json_path(sensor_type)
|
||||
if path is None:
|
||||
cfg = self.sensor_picker.config_name()
|
||||
candidate = Path(cfg)
|
||||
path = candidate if candidate.is_file() else None
|
||||
if path is None:
|
||||
self.statusBar().showMessage(t("status.config_missing", type=sensor_type))
|
||||
return
|
||||
try:
|
||||
_reveal_in_explorer(path)
|
||||
except Exception as exc:
|
||||
self.statusBar().showMessage(t("status.reveal_failed", error=str(exc)))
|
||||
|
||||
def _resolve_config_ref(self) -> str | None:
|
||||
sensor_type = self.sensor_picker.sensor_type()
|
||||
local = sensor_json_path(sensor_type)
|
||||
if local is not None:
|
||||
return str(local)
|
||||
cfg = self.sensor_picker.config_name()
|
||||
if cfg and Path(cfg).is_file():
|
||||
return cfg
|
||||
if sensor_type in RUNNABLE_TYPES:
|
||||
return sensor_type
|
||||
return None
|
||||
|
||||
def _on_search_cameras(self) -> None:
|
||||
if self._preview_open or self._running:
|
||||
return
|
||||
from orisys_app_shared.camera import list_cameras, pick_default_camera
|
||||
|
||||
preferred = str(self.toolbar.camera_id())
|
||||
self.statusBar().showMessage(t("toolbar.search_cameras") + "…")
|
||||
QApplication.processEvents()
|
||||
cameras = list_cameras()
|
||||
if not cameras:
|
||||
self.statusBar().showMessage(t("status.camera_none"))
|
||||
return
|
||||
names = ", ".join(cam.label() for cam in cameras)
|
||||
self.statusBar().showMessage(
|
||||
t("status.camera_found", count=len(cameras), names=names)
|
||||
)
|
||||
selected = pick_default_camera(cameras, preferred)
|
||||
if selected is not None:
|
||||
self.toolbar.set_camera_id(int(selected))
|
||||
self.prefs["camera_id"] = str(selected)
|
||||
self._persist_prefs()
|
||||
|
||||
def _on_preview_toggled(self, checked: bool) -> None:
|
||||
if checked:
|
||||
self._start_preview()
|
||||
else:
|
||||
self._stop_preview(announce=True)
|
||||
|
||||
def _start_preview(self) -> None:
|
||||
if self._running or self.toolbar.source_mode() != "camera":
|
||||
self.toolbar.set_preview_checked(False)
|
||||
return
|
||||
camera_id = self.toolbar.camera_id()
|
||||
try:
|
||||
self._get_preview().open(camera_id)
|
||||
except Exception as exc:
|
||||
msg = t("status.preview_failed", error=str(exc))
|
||||
self.statusBar().showMessage(msg)
|
||||
self.toolbar.set_preview_checked(False)
|
||||
self.arrow_panel.clear_preview(msg)
|
||||
return
|
||||
self.toolbar.set_source_controls_enabled(False)
|
||||
self.toolbar.btn_preview.setEnabled(True)
|
||||
self._update_run_enabled()
|
||||
self._preview_timer.start()
|
||||
self.statusBar().showMessage(t("status.preview_started", camera_id=camera_id))
|
||||
|
||||
def _stop_preview(self, *, announce: bool) -> None:
|
||||
self._preview_timer.stop()
|
||||
if self._preview is not None:
|
||||
self._preview.close()
|
||||
if not self._running:
|
||||
self.toolbar.set_source_controls_enabled(True)
|
||||
self.arrow_panel.clear_preview(t("app.scaffold_hint"))
|
||||
self.toolbar.set_preview_checked(False)
|
||||
self._update_run_enabled()
|
||||
if announce:
|
||||
self.statusBar().showMessage(t("status.preview_stopped"))
|
||||
|
||||
def _on_preview_tick(self) -> None:
|
||||
if self._preview is None:
|
||||
return
|
||||
frame = self._preview.read()
|
||||
if frame is None:
|
||||
self._stop_preview(announce=True)
|
||||
self.statusBar().showMessage(t("status.preview_failed", error="no frame"))
|
||||
return
|
||||
self.arrow_panel.show_frame(_bgr_to_qimage(frame))
|
||||
|
||||
def _on_run_toggled(self, checked: bool) -> None:
|
||||
if checked:
|
||||
self._start_run()
|
||||
else:
|
||||
self._stop_run(reason="user_stop")
|
||||
|
||||
def _start_run(self) -> None:
|
||||
from orisys_app_shared.acquisition.sdk_worker import orisys_importable
|
||||
|
||||
sensor_type = self.sensor_picker.sensor_type()
|
||||
if sensor_type not in RUNNABLE_TYPES:
|
||||
self.toolbar.set_run_checked(False)
|
||||
self.statusBar().showMessage(t("status.finger_run_unavailable"))
|
||||
return
|
||||
ok, detail = orisys_importable()
|
||||
if not ok:
|
||||
self.toolbar.set_run_checked(False)
|
||||
QMessageBox.warning(self, t("app.title"), detail)
|
||||
return
|
||||
config_ref = self._resolve_config_ref()
|
||||
if not config_ref:
|
||||
self.toolbar.set_run_checked(False)
|
||||
self.statusBar().showMessage(
|
||||
t("status.config_missing", type=sensor_type)
|
||||
)
|
||||
return
|
||||
source = self.toolbar.active_source()
|
||||
if source is None:
|
||||
self.toolbar.set_run_checked(False)
|
||||
self.statusBar().showMessage(t("status.video_required"))
|
||||
return
|
||||
|
||||
if self._preview_open:
|
||||
self._stop_preview(announce=False)
|
||||
|
||||
self.force_plots.reset()
|
||||
self._last_plot_packet_key = None
|
||||
self._last_rendered_view_mode = None
|
||||
self._last_session_error = ""
|
||||
self._run_source_label = str(source)
|
||||
self._active_sensor_type = sensor_type
|
||||
self._ensure_workers()
|
||||
self._active_worker = (
|
||||
self._finger_worker if sensor_type == "finger" else self._box_worker
|
||||
)
|
||||
try:
|
||||
self._active_worker.start(source, config_ref=config_ref)
|
||||
except Exception as exc:
|
||||
report_error(exc, message="Run failed")
|
||||
self.toolbar.set_run_checked(False)
|
||||
self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
|
||||
return
|
||||
|
||||
self._running = True
|
||||
self._session_phase = "connecting"
|
||||
self.cal_panel.set_meta_context(sensor_type=sensor_type, config_ref=config_ref)
|
||||
self.cal_panel.set_running(True)
|
||||
self.toolbar.set_source_controls_enabled(False)
|
||||
self.sensor_picker.type_combo.setEnabled(False)
|
||||
self.sensor_picker.config_combo.setEnabled(False)
|
||||
self.toolbar.set_reset_enabled(False)
|
||||
self._run_timer.start()
|
||||
self.statusBar().showMessage(t("status.connecting"))
|
||||
logger.info(
|
||||
"Connecting type=%s source=%s config=%s",
|
||||
sensor_type,
|
||||
source,
|
||||
config_ref,
|
||||
)
|
||||
|
||||
def _stop_run(self, *, reason: str = "user_stop") -> None:
|
||||
self._run_timer.stop()
|
||||
if self._active_worker is not None:
|
||||
self._active_worker.stop()
|
||||
self._running = False
|
||||
self._session_phase = "idle"
|
||||
self.cal_panel.set_running(False)
|
||||
self.toolbar.set_run_checked(False)
|
||||
self.toolbar.set_reset_enabled(False)
|
||||
self.toolbar.set_source_controls_enabled(True)
|
||||
self.sensor_picker.type_combo.setEnabled(True)
|
||||
self.sensor_picker.config_combo.setEnabled(True)
|
||||
self._update_run_enabled()
|
||||
keep_frame = reason in ("ended", "disconnected")
|
||||
if not keep_frame:
|
||||
self.arrow_panel.clear_preview(t("app.scaffold_hint"))
|
||||
else:
|
||||
self.arrow_panel.set_fps(None)
|
||||
if reason == "ended":
|
||||
self.statusBar().showMessage(t("status.video_ended"))
|
||||
elif reason == "disconnected":
|
||||
self.statusBar().showMessage(t("status.camera_disconnected"))
|
||||
elif reason == "failed":
|
||||
pass # caller sets status
|
||||
elif reason == "user_stop":
|
||||
self.statusBar().showMessage(t("status.run_stopped"))
|
||||
else:
|
||||
self.statusBar().showMessage(t("status.run_stopped"))
|
||||
|
||||
def _on_reset(self) -> None:
|
||||
if self._session_phase != "running" or self._active_worker is None:
|
||||
return
|
||||
try:
|
||||
self._active_worker.reset()
|
||||
self.force_plots.reset()
|
||||
self._last_plot_packet_key = None
|
||||
self.statusBar().showMessage(t("status.reset_ok"))
|
||||
except Exception as exc:
|
||||
self.statusBar().showMessage(t("status.run_failed", error=str(exc)))
|
||||
|
||||
def _on_worker_error(self, message: str) -> None:
|
||||
self._last_session_error = message
|
||||
logger.error("Acquisition error: %s", message)
|
||||
|
||||
def _on_worker_status(self, message: str) -> None:
|
||||
logger.info("%s", message)
|
||||
|
||||
def _on_run_tick(self) -> None:
|
||||
if self._active_worker is None:
|
||||
return
|
||||
if self._last_session_error:
|
||||
err = self._last_session_error
|
||||
self._last_session_error = ""
|
||||
self._active_worker.consume_end_reason()
|
||||
self._stop_run(reason="failed")
|
||||
self.statusBar().showMessage(t("status.run_failed", error=err))
|
||||
return
|
||||
|
||||
end_reason = self._active_worker.consume_end_reason()
|
||||
if end_reason in ("ended", "disconnected"):
|
||||
self._stop_run(reason=end_reason)
|
||||
return
|
||||
if end_reason == "error":
|
||||
self._stop_run(reason="failed")
|
||||
self.statusBar().showMessage(t("status.run_failed", error="acquisition error"))
|
||||
return
|
||||
|
||||
if not self._active_worker.is_running and self._running:
|
||||
leftover = self._active_worker.consume_end_reason()
|
||||
if leftover in ("ended", "disconnected"):
|
||||
self._stop_run(reason=leftover)
|
||||
elif leftover == "error":
|
||||
self._stop_run(reason="failed")
|
||||
self.statusBar().showMessage(
|
||||
t("status.run_failed", error="acquisition error")
|
||||
)
|
||||
else:
|
||||
self._stop_run(reason="user_stop")
|
||||
return
|
||||
|
||||
packet = self._active_worker.latest()
|
||||
if packet is None:
|
||||
return
|
||||
packet_key = (int(packet.session_id), float(packet.timestamp))
|
||||
is_new = packet_key != self._last_plot_packet_key
|
||||
|
||||
if self._session_phase == "connecting" and is_new:
|
||||
self._session_phase = "running"
|
||||
self.toolbar.set_reset_enabled(True)
|
||||
self.statusBar().showMessage(
|
||||
t("status.run_started", source=self._run_source_label)
|
||||
)
|
||||
|
||||
if is_new:
|
||||
self._last_plot_packet_key = packet_key
|
||||
display = select_display_forces(
|
||||
units_pref=self.toolbar.units(),
|
||||
force_calibrated=packet.force_calibrated,
|
||||
fn=packet.normal_force,
|
||||
fx=packet.shear_x,
|
||||
fy=packet.shear_y,
|
||||
fn_raw=packet.normal_force_raw,
|
||||
fx_raw=packet.shear_x_raw,
|
||||
fy_raw=packet.shear_y_raw,
|
||||
)
|
||||
if display.using_raw_fallback:
|
||||
self.statusBar().showMessage(t("status.units_raw_fallback"))
|
||||
self.force_plots.push(
|
||||
packet.timestamp,
|
||||
display.fn,
|
||||
display.fx,
|
||||
display.fy,
|
||||
unit=display.unit,
|
||||
)
|
||||
if self._show_cal:
|
||||
self.cal_panel.push_raw(
|
||||
packet.normal_force_raw,
|
||||
packet.shear_x_raw,
|
||||
packet.shear_y_raw,
|
||||
packet.timestamp,
|
||||
)
|
||||
|
||||
mode = self.arrow_panel.view_mode()
|
||||
if is_new or mode != self._last_rendered_view_mode:
|
||||
self._render_run_image(packet)
|
||||
if self._show_fps and is_new:
|
||||
self.arrow_panel.set_fps(packet.fps)
|
||||
|
||||
def _render_run_image(self, packet) -> None:
|
||||
if packet is None:
|
||||
return
|
||||
from orisys_app_shared.acquisition.arrows import (
|
||||
depth_map_to_bgr,
|
||||
draw_arrow_display,
|
||||
hex_to_bgr,
|
||||
)
|
||||
|
||||
mode = self.arrow_panel.view_mode()
|
||||
self._last_rendered_view_mode = mode
|
||||
draw_style = "finger" if self._active_sensor_type == "finger" else "box"
|
||||
if mode == "depth":
|
||||
depth_bgr = depth_map_to_bgr(packet.depth)
|
||||
frame = depth_bgr if depth_bgr is not None else packet.processed_frame
|
||||
elif mode == "arrows_stitched":
|
||||
frame = draw_arrow_display(
|
||||
packet.processed_frame,
|
||||
packet.flow,
|
||||
background="captured",
|
||||
draw_style=draw_style,
|
||||
valid_mask=packet.valid_mask,
|
||||
)
|
||||
else:
|
||||
canvas_bgr = hex_to_bgr(color(self._theme, "arrow_canvas", "#121316"))
|
||||
frame = draw_arrow_display(
|
||||
packet.processed_frame,
|
||||
packet.flow,
|
||||
background="pure_color",
|
||||
canvas_bgr=canvas_bgr,
|
||||
draw_style=draw_style,
|
||||
valid_mask=packet.valid_mask,
|
||||
)
|
||||
self.arrow_panel.show_frame(_bgr_to_qimage(frame))
|
||||
|
||||
def _restore_geometry(self) -> None:
|
||||
geo = self.prefs.get("window_geometry")
|
||||
if isinstance(geo, str) and geo:
|
||||
try:
|
||||
from PyQt5.QtCore import QByteArray
|
||||
|
||||
self.restoreGeometry(QByteArray.fromHex(geo.encode("ascii")))
|
||||
except Exception:
|
||||
logger.debug("Could not restore window geometry", exc_info=True)
|
||||
|
||||
def _persist_prefs(self) -> None:
|
||||
geo = self.saveGeometry().toHex().data().decode("ascii")
|
||||
self.prefs["window_geometry"] = geo
|
||||
self.prefs["theme"] = self._theme
|
||||
self.prefs["language"] = self._language
|
||||
self.prefs["camera_id"] = str(self.toolbar.camera_id())
|
||||
self.prefs["source_mode"] = self.toolbar.source_mode()
|
||||
self.prefs["video_path"] = self.toolbar.video_path()
|
||||
self.prefs["units"] = self.toolbar.units()
|
||||
self.prefs["image_view_mode"] = self.arrow_panel.view_mode()
|
||||
self.prefs["arrow_rotation_deg"] = self.arrow_panel.rotation_deg()
|
||||
self.prefs.setdefault("plot_window_sec", DEFAULT_PLOT_WINDOW_SEC)
|
||||
self.prefs.setdefault("plot_fn_max", DEFAULT_PLOT_FN_MAX)
|
||||
self.prefs.setdefault("plot_shear_max", DEFAULT_PLOT_SHEAR_MAX)
|
||||
self.prefs["show_cal_panel"] = bool(self._show_cal)
|
||||
self.prefs["show_finger_3d"] = bool(self._show_finger_3d)
|
||||
self.prefs["show_fps"] = bool(self._show_fps)
|
||||
self.prefs["sensor_type"] = self.sensor_picker.sensor_type()
|
||||
self.prefs["config_name"] = self.sensor_picker.config_name()
|
||||
save_preferences(self.prefs)
|
||||
|
||||
def closeEvent(self, event) -> None: # noqa: N802
|
||||
self._stop_preview(announce=False)
|
||||
self._stop_run(reason="user_stop")
|
||||
self._persist_prefs()
|
||||
logger.info("Customer shell closed; preferences saved")
|
||||
super().closeEvent(event)
|
||||
@@ -1,187 +0,0 @@
|
||||
"""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(320, 240)
|
||||
self.image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
self.image_label.setScaledContents(False)
|
||||
layout.addWidget(self.image_label, 1)
|
||||
self._placeholder = ""
|
||||
self._populate_view_combo()
|
||||
self.retranslate()
|
||||
|
||||
def view_mode(self) -> str:
|
||||
data = self.view_combo.currentData()
|
||||
return str(data or "arrows_pure")
|
||||
|
||||
def set_view_mode(self, mode: str) -> None:
|
||||
wanted = str(mode or "arrows_pure")
|
||||
if wanted not in VIEW_MODES:
|
||||
wanted = "arrows_pure"
|
||||
idx = self.view_combo.findData(wanted)
|
||||
if idx < 0:
|
||||
idx = 0
|
||||
self.view_combo.blockSignals(True)
|
||||
self.view_combo.setCurrentIndex(idx)
|
||||
self.view_combo.blockSignals(False)
|
||||
|
||||
def 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"))
|
||||
if not self.image_label.pixmap() or self.image_label.pixmap().isNull():
|
||||
self._placeholder = t("app.scaffold_hint")
|
||||
self.image_label.setText(self._placeholder)
|
||||
|
||||
def _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("app.scaffold_hint")
|
||||
self._placeholder = text
|
||||
self.image_label.setText(text)
|
||||
self.set_fps(None)
|
||||
@@ -1,338 +0,0 @@
|
||||
"""Force calibration sampling panel (FN / FX / FY)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QButtonGroup,
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from orisys_app_shared.force.calibration_io import (
|
||||
CalSample,
|
||||
CalSessionStore,
|
||||
ChannelKey,
|
||||
export_force_calibration_v2,
|
||||
meta_from_sensor_json,
|
||||
)
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import calibration_export_dir, sensors_dir
|
||||
|
||||
|
||||
class CalPanel(QFrame):
|
||||
status_message = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("contentPanel")
|
||||
self._store = CalSessionStore()
|
||||
self._channel: ChannelKey = "fn"
|
||||
self._running = False
|
||||
self._recording = False
|
||||
self._record_deadline = 0.0
|
||||
self._record_started = 0.0
|
||||
self._record_samples: list[float] = []
|
||||
self._live_fn = 0.0
|
||||
self._live_fx = 0.0
|
||||
self._live_fy = 0.0
|
||||
self._sensor_type = "box"
|
||||
self._config_ref: str | None = None
|
||||
self._record_duration_s = 7.0
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
self.title = QLabel()
|
||||
self.title.setObjectName("panelTitle")
|
||||
root.addWidget(self.title)
|
||||
|
||||
channel_row = QHBoxLayout()
|
||||
self.channel_group = QButtonGroup(self)
|
||||
self.radio_fn = QRadioButton("FN")
|
||||
self.radio_fx = QRadioButton("FX")
|
||||
self.radio_fy = QRadioButton("FY")
|
||||
self.radio_fn.setChecked(True)
|
||||
for i, radio in enumerate((self.radio_fn, self.radio_fx, self.radio_fy)):
|
||||
self.channel_group.addButton(radio, i)
|
||||
channel_row.addWidget(radio)
|
||||
channel_row.addStretch(1)
|
||||
self.channel_group.buttonToggled.connect(self._on_channel_button)
|
||||
root.addLayout(channel_row)
|
||||
|
||||
form = QFormLayout()
|
||||
self._weight_label = QLabel()
|
||||
self.weight_spin = QDoubleSpinBox()
|
||||
self.weight_spin.setRange(0.0, 1e6)
|
||||
self.weight_spin.setDecimals(4)
|
||||
self.weight_spin.setSingleStep(0.1)
|
||||
self.weight_spin.setValue(0.0)
|
||||
self._duration_label = QLabel()
|
||||
self.duration_spin = QDoubleSpinBox()
|
||||
self.duration_spin.setRange(5.0, 10.0)
|
||||
self.duration_spin.setDecimals(1)
|
||||
self.duration_spin.setSingleStep(0.5)
|
||||
self.duration_spin.setValue(7.0)
|
||||
self.duration_spin.setSuffix(" s")
|
||||
form.addRow(self._weight_label, self.weight_spin)
|
||||
form.addRow(self._duration_label, self.duration_spin)
|
||||
root.addLayout(form)
|
||||
|
||||
rec_row = QHBoxLayout()
|
||||
self.btn_record = QPushButton()
|
||||
self.btn_record.clicked.connect(self._on_record_clicked)
|
||||
rec_row.addWidget(self.btn_record)
|
||||
rec_row.addStretch(1)
|
||||
root.addLayout(rec_row)
|
||||
|
||||
self.progress = QProgressBar()
|
||||
self.progress.setRange(0, 1000)
|
||||
self.progress.setValue(0)
|
||||
self.progress.setTextVisible(True)
|
||||
root.addWidget(self.progress)
|
||||
|
||||
self.live_label = QLabel()
|
||||
self.live_label.setWordWrap(True)
|
||||
root.addWidget(self.live_label)
|
||||
|
||||
self.table = QTableWidget(0, 5)
|
||||
self.table.setSelectionBehavior(QAbstractItemView.SelectRows)
|
||||
self.table.setSelectionMode(QAbstractItemView.SingleSelection)
|
||||
self.table.setEditTriggers(QAbstractItemView.NoEditTriggers)
|
||||
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
root.addWidget(self.table, 1)
|
||||
|
||||
sn_row = QHBoxLayout()
|
||||
self._sn_label = QLabel()
|
||||
self.sn_edit = QLineEdit()
|
||||
self.sn_edit.setPlaceholderText("")
|
||||
sn_row.addWidget(self._sn_label)
|
||||
sn_row.addWidget(self.sn_edit, 1)
|
||||
root.addLayout(sn_row)
|
||||
|
||||
actions = QHBoxLayout()
|
||||
self.btn_delete = QPushButton()
|
||||
self.btn_clear_channel = QPushButton()
|
||||
self.btn_clear_all = QPushButton()
|
||||
self.btn_export = QPushButton()
|
||||
self.btn_delete.clicked.connect(self._on_delete_row)
|
||||
self.btn_clear_channel.clicked.connect(self._on_clear_channel)
|
||||
self.btn_clear_all.clicked.connect(self._on_clear_all)
|
||||
self.btn_export.clicked.connect(self._on_export)
|
||||
actions.addWidget(self.btn_delete)
|
||||
actions.addWidget(self.btn_clear_channel)
|
||||
actions.addWidget(self.btn_clear_all)
|
||||
actions.addStretch(1)
|
||||
actions.addWidget(self.btn_export)
|
||||
root.addLayout(actions)
|
||||
|
||||
self._refresh_enabled()
|
||||
self._refresh_table()
|
||||
self.retranslate()
|
||||
|
||||
def set_running(self, running: bool) -> None:
|
||||
self._running = bool(running)
|
||||
if not self._running and self._recording:
|
||||
self._cancel_recording()
|
||||
self._refresh_enabled()
|
||||
|
||||
def set_meta_context(self, *, sensor_type: str, config_ref: str | None) -> None:
|
||||
self._sensor_type = str(sensor_type or "box")
|
||||
self._config_ref = config_ref
|
||||
|
||||
def push_raw(self, fn: float, fx: float, fy: float, _timestamp: float | None = None) -> None:
|
||||
self._live_fn = float(fn)
|
||||
self._live_fx = float(fx)
|
||||
self._live_fy = float(fy)
|
||||
self._update_live_label()
|
||||
if not self._recording:
|
||||
return
|
||||
now = time.perf_counter()
|
||||
value = {"fn": self._live_fn, "fx": self._live_fx, "fy": self._live_fy}[self._channel]
|
||||
self._record_samples.append(float(value))
|
||||
span = max(1e-6, self._record_deadline - self._record_started)
|
||||
frac = min(1.0, max(0.0, (now - self._record_started) / span))
|
||||
self.progress.setValue(int(frac * 1000))
|
||||
self.progress.setFormat(f"{now - self._record_started:.1f} / {span:.1f} s")
|
||||
if now >= self._record_deadline:
|
||||
self._finish_recording()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.title.setText(t("panels.cal"))
|
||||
self._weight_label.setText(t("cal.weight"))
|
||||
self._duration_label.setText(t("cal.duration"))
|
||||
self.btn_record.setText(t("cal.record"))
|
||||
self.btn_delete.setText(t("cal.delete_row"))
|
||||
self.btn_clear_channel.setText(t("cal.clear_channel"))
|
||||
self.btn_clear_all.setText(t("cal.clear_all"))
|
||||
self.btn_export.setText(t("cal.export"))
|
||||
self._sn_label.setText(t("cal.sn"))
|
||||
self.sn_edit.setPlaceholderText(t("cal.sn_placeholder"))
|
||||
headers = (
|
||||
t("cal.col_weight"),
|
||||
t("cal.col_raw"),
|
||||
t("cal.col_n"),
|
||||
t("cal.col_std"),
|
||||
t("cal.col_actions"),
|
||||
)
|
||||
for i, text in enumerate(headers):
|
||||
item = self.table.horizontalHeaderItem(i)
|
||||
if item is None:
|
||||
item = QTableWidgetItem(text)
|
||||
self.table.setHorizontalHeaderItem(i, item)
|
||||
else:
|
||||
item.setText(text)
|
||||
self._update_live_label()
|
||||
self._refresh_enabled()
|
||||
|
||||
def _on_channel_button(self, button, checked: bool) -> None:
|
||||
if not checked:
|
||||
return
|
||||
mapping = {self.radio_fn: "fn", self.radio_fx: "fx", self.radio_fy: "fy"}
|
||||
key = mapping.get(button)
|
||||
if key is None:
|
||||
return
|
||||
if self._recording:
|
||||
self._cancel_recording()
|
||||
self._channel = key # type: ignore[assignment]
|
||||
self._refresh_table()
|
||||
|
||||
def _refresh_enabled(self) -> None:
|
||||
can_record = self._running and not self._recording
|
||||
self.btn_record.setEnabled(can_record)
|
||||
self.weight_spin.setEnabled(not self._recording)
|
||||
self.duration_spin.setEnabled(not self._recording)
|
||||
for radio in (self.radio_fn, self.radio_fx, self.radio_fy):
|
||||
radio.setEnabled(not self._recording)
|
||||
self.btn_export.setEnabled(not self._recording)
|
||||
|
||||
def _update_live_label(self) -> None:
|
||||
self.live_label.setText(
|
||||
t(
|
||||
"cal.live_raw",
|
||||
fn=f"{self._live_fn:.3f}",
|
||||
fx=f"{self._live_fx:.3f}",
|
||||
fy=f"{self._live_fy:.3f}",
|
||||
)
|
||||
)
|
||||
|
||||
def _on_record_clicked(self) -> None:
|
||||
if not self._running or self._recording:
|
||||
return
|
||||
self._recording = True
|
||||
self._record_samples = []
|
||||
self._record_duration_s = float(self.duration_spin.value())
|
||||
self._record_started = time.perf_counter()
|
||||
self._record_deadline = self._record_started + self._record_duration_s
|
||||
self.progress.setValue(0)
|
||||
self.progress.setFormat(f"0.0 / {self._record_duration_s:.1f} s")
|
||||
self._refresh_enabled()
|
||||
self.status_message.emit(t("cal.recording"))
|
||||
|
||||
def _cancel_recording(self) -> None:
|
||||
self._recording = False
|
||||
self._record_samples = []
|
||||
self.progress.setValue(0)
|
||||
self.progress.setFormat("")
|
||||
self._refresh_enabled()
|
||||
|
||||
def _finish_recording(self) -> None:
|
||||
samples = list(self._record_samples)
|
||||
self._recording = False
|
||||
self._record_samples = []
|
||||
self.progress.setValue(1000)
|
||||
self._refresh_enabled()
|
||||
if len(samples) < 2:
|
||||
self.status_message.emit(t("cal.record_failed_few"))
|
||||
return
|
||||
mean = sum(samples) / len(samples)
|
||||
var = sum((x - mean) ** 2 for x in samples) / len(samples)
|
||||
std = math.sqrt(var)
|
||||
sample = CalSample(
|
||||
weight_n=float(self.weight_spin.value()),
|
||||
raw_mean=mean,
|
||||
n=len(samples),
|
||||
std=std,
|
||||
)
|
||||
self._store.upsert(self._channel, sample)
|
||||
self._refresh_table()
|
||||
self.status_message.emit(
|
||||
t("cal.record_ok", weight=f"{sample.weight_n:g}", raw=f"{sample.raw_mean:.3f}")
|
||||
)
|
||||
|
||||
def _refresh_table(self) -> None:
|
||||
rows = self._store.channel(self._channel)
|
||||
self.table.setRowCount(len(rows))
|
||||
for r, sample in enumerate(rows):
|
||||
values = (
|
||||
f"{sample.weight_n:g}",
|
||||
f"{sample.raw_mean:.4f}",
|
||||
str(sample.n),
|
||||
f"{sample.std:.4f}",
|
||||
"",
|
||||
)
|
||||
for c, text in enumerate(values):
|
||||
item = QTableWidgetItem(text)
|
||||
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
|
||||
self.table.setItem(r, c, item)
|
||||
|
||||
def _on_delete_row(self) -> None:
|
||||
row = self.table.currentRow()
|
||||
if row < 0:
|
||||
return
|
||||
self._store.remove_at(self._channel, row)
|
||||
self._refresh_table()
|
||||
|
||||
def _on_clear_channel(self) -> None:
|
||||
self._store.clear(self._channel)
|
||||
self._refresh_table()
|
||||
|
||||
def _on_clear_all(self) -> None:
|
||||
self._store.clear()
|
||||
self._refresh_table()
|
||||
|
||||
def _on_export(self) -> None:
|
||||
meta = meta_from_sensor_json(self._config_ref)
|
||||
meta["sensor_type"] = self._sensor_type
|
||||
meta["sn"] = self.sn_edit.text().strip()
|
||||
meta["record_duration_s"] = float(self.duration_spin.value())
|
||||
local_bundle = sensors_dir() / self._sensor_type
|
||||
if not local_bundle.is_dir():
|
||||
local_bundle = None
|
||||
try:
|
||||
primary, local_path, warnings = export_force_calibration_v2(
|
||||
self._store,
|
||||
export_dir=calibration_export_dir(),
|
||||
sensor_type=self._sensor_type,
|
||||
meta=meta,
|
||||
local_bundle_dir=local_bundle,
|
||||
)
|
||||
except ValueError as exc:
|
||||
QMessageBox.warning(self, t("panels.cal"), str(exc))
|
||||
return
|
||||
except Exception as exc:
|
||||
QMessageBox.warning(self, t("panels.cal"), t("cal.export_failed", error=str(exc)))
|
||||
return
|
||||
|
||||
msg = t("cal.export_ok", path=str(primary))
|
||||
if local_path is not None:
|
||||
msg += "\n" + t("cal.export_local", path=str(local_path))
|
||||
if warnings:
|
||||
msg += "\n" + t("cal.export_warnings", detail="; ".join(warnings))
|
||||
self.status_message.emit(msg)
|
||||
QMessageBox.information(self, t("panels.cal"), msg)
|
||||
@@ -1,190 +0,0 @@
|
||||
"""FN / FX / FY force charts with live value labels."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtGui import QColor
|
||||
from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout
|
||||
|
||||
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ, LOWPASS_CUTOFF_HZ
|
||||
from orisys_app_shared.force import MetricHistory
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
try:
|
||||
import pyqtgraph as pg
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise ImportError("pyqtgraph is required for force charts") from exc
|
||||
|
||||
|
||||
def _nice_ticks(lo: float, hi: float, count: int = 5) -> list[tuple[float, str]]:
|
||||
if hi <= lo:
|
||||
return [(lo, f"{lo:g}")]
|
||||
step = (hi - lo) / max(1, count - 1)
|
||||
return [(lo + i * step, f"{(lo + i * step):g}") for i in range(count)]
|
||||
|
||||
|
||||
class ForcePlotsPanel(QFrame):
|
||||
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("contentPanel")
|
||||
self._theme = theme
|
||||
self._fn_max = 20.0
|
||||
self._shear_max = 20.0
|
||||
self._fixed_newton = True
|
||||
self._unit = "raw"
|
||||
self.history = MetricHistory(
|
||||
channels=3,
|
||||
window_sec=4.0,
|
||||
sample_rate_hz=FORCE_SAMPLE_RATE_HZ,
|
||||
lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ,
|
||||
y_axis_min_span=0.5,
|
||||
)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
self.title = QLabel()
|
||||
self.title.setObjectName("panelTitle")
|
||||
root.addWidget(self.title)
|
||||
|
||||
self.plot_widget = pg.GraphicsLayoutWidget()
|
||||
self.plots = []
|
||||
self.curves = []
|
||||
names = ("FN", "FX", "FY")
|
||||
for i, name in enumerate(names):
|
||||
plot = self.plot_widget.addPlot(row=i, col=0)
|
||||
plot.showGrid(x=True, y=True, alpha=0.25)
|
||||
plot.setLabel("left", name)
|
||||
plot.setMouseEnabled(x=False, y=False)
|
||||
if i > 0:
|
||||
plot.setXLink(self.plots[0])
|
||||
curve = plot.plot(pen=pg.mkPen("#8ab4f8", width=2))
|
||||
self.plots.append(plot)
|
||||
self.curves.append(curve)
|
||||
root.addWidget(self.plot_widget, 1)
|
||||
|
||||
values_row = QHBoxLayout()
|
||||
self.value_labels = []
|
||||
for _ in names:
|
||||
lab = QLabel("—")
|
||||
lab.setAlignment(Qt.AlignCenter)
|
||||
values_row.addWidget(lab)
|
||||
self.value_labels.append(lab)
|
||||
root.addLayout(values_row)
|
||||
self.set_theme(theme)
|
||||
self._apply_axis_mode()
|
||||
self.retranslate()
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
self._theme = theme
|
||||
bg = color(theme, "plot_bg", "#121316")
|
||||
text = color(theme, "text_primary", "#e8eaed")
|
||||
self.plot_widget.setBackground(bg)
|
||||
axis_pen = pg.mkPen(text)
|
||||
for plot in self.plots:
|
||||
try:
|
||||
plot.getViewBox().setBackgroundColor(QColor(bg))
|
||||
except Exception:
|
||||
pass
|
||||
for axis_name in ("left", "bottom"):
|
||||
axis = plot.getAxis(axis_name)
|
||||
axis.setPen(axis_pen)
|
||||
axis.setTextPen(axis_pen)
|
||||
plot.showGrid(x=True, y=True, alpha=0.25)
|
||||
label = plot.getAxis("left").labelText or ""
|
||||
if label:
|
||||
plot.setLabel("left", label, color=text)
|
||||
pens = (
|
||||
color(theme, "plot_fn", "#8ab4f8"),
|
||||
color(theme, "plot_fx", "#34a853"),
|
||||
color(theme, "plot_fy", "#fbbc04"),
|
||||
)
|
||||
for curve, pen in zip(self.curves, pens):
|
||||
curve.setPen(pg.mkPen(pen, width=2))
|
||||
|
||||
def set_window_sec(self, sec: float) -> None:
|
||||
self.history.set_window_sec(sec)
|
||||
self._clear_curves()
|
||||
self._apply_axis_mode()
|
||||
|
||||
def set_axis_limits(self, fn_max: float, shear_max: float) -> None:
|
||||
self._fn_max = max(0.1, float(fn_max))
|
||||
self._shear_max = max(0.1, float(shear_max))
|
||||
self._apply_axis_mode()
|
||||
|
||||
def set_fixed_newton(self, enabled: bool) -> None:
|
||||
self._fixed_newton = bool(enabled)
|
||||
self._apply_axis_mode()
|
||||
|
||||
def reset(self) -> None:
|
||||
self.history.reset()
|
||||
self._clear_curves()
|
||||
for lab in self.value_labels:
|
||||
lab.setText("—")
|
||||
self._apply_axis_mode()
|
||||
|
||||
def push(
|
||||
self,
|
||||
timestamp: float,
|
||||
fn: float,
|
||||
fx: float,
|
||||
fy: float,
|
||||
*,
|
||||
unit: str = "raw",
|
||||
) -> None:
|
||||
self._unit = unit
|
||||
use_fixed = self._uses_fixed_y()
|
||||
plot_fn = max(0.0, float(fn)) if use_fixed else float(fn)
|
||||
self.history.push(timestamp, plot_fn, float(fx), float(fy))
|
||||
self._update_value_labels(fn, fx, fy)
|
||||
state = self.history.state()
|
||||
if state is None:
|
||||
return
|
||||
values = state.values.copy()
|
||||
if use_fixed:
|
||||
values[:, 0] = np.maximum(values[:, 0], 0.0)
|
||||
for i, curve in enumerate(self.curves):
|
||||
curve.setData(state.times, values[:, i])
|
||||
self.plots[i].setXRange(*state.x_range, padding=0.0)
|
||||
if use_fixed:
|
||||
ymin, ymax = self._fixed_y_range(i)
|
||||
self.plots[i].setYRange(ymin, ymax, padding=0.0)
|
||||
self.plots[i].getAxis("left").setTicks([_nice_ticks(ymin, ymax, count=5)])
|
||||
else:
|
||||
self.plots[i].getAxis("left").setTicks(None)
|
||||
self.plots[i].setYRange(*state.y_ranges[i], padding=0.0)
|
||||
|
||||
def _uses_fixed_y(self) -> bool:
|
||||
return bool(self._fixed_newton) and self._unit == "N"
|
||||
|
||||
def _fixed_y_range(self, channel_index: int) -> tuple[float, float]:
|
||||
if channel_index == 0:
|
||||
return 0.0, self._fn_max
|
||||
return -self._shear_max, self._shear_max
|
||||
|
||||
def _apply_axis_mode(self) -> None:
|
||||
# Preview fixed newton axes when newton mode is selected, even before samples.
|
||||
preview_fixed = bool(self._fixed_newton)
|
||||
use_fixed = self._uses_fixed_y() or (preview_fixed and self.history.sample_count == 0)
|
||||
for i, plot in enumerate(self.plots):
|
||||
if use_fixed:
|
||||
ymin, ymax = self._fixed_y_range(i)
|
||||
plot.setYRange(ymin, ymax, padding=0.0)
|
||||
plot.getAxis("left").setTicks([_nice_ticks(ymin, ymax, count=5)])
|
||||
else:
|
||||
plot.getAxis("left").setTicks(None)
|
||||
for plot in self.plots:
|
||||
plot.setXRange(0.0, self.history.window_sec, padding=0.0)
|
||||
|
||||
def _clear_curves(self) -> None:
|
||||
for curve in self.curves:
|
||||
curve.setData([], [])
|
||||
|
||||
def _update_value_labels(self, fn: float, fx: float, fy: float) -> None:
|
||||
unit = self._unit
|
||||
fmt = "{name}: {value:.4f} {unit}" if unit == "N" else "{name}: {value:.2f} {unit}"
|
||||
for lab, name, value in zip(self.value_labels, ("FN", "FX", "FY"), (fn, fx, fy)):
|
||||
lab.setText(fmt.format(name=name, value=value, unit=unit))
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.title.setText(t("panels.plots"))
|
||||
@@ -1,114 +0,0 @@
|
||||
"""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"))
|
||||
@@ -1,261 +0,0 @@
|
||||
"""Top toolbar: source, camera/video, run/reset, units."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt5.QtCore import pyqtSignal
|
||||
from PyQt5.QtWidgets import (
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QSpinBox,
|
||||
QStackedWidget,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
|
||||
class ToolbarWidget(QWidget):
|
||||
search_clicked = pyqtSignal()
|
||||
preview_toggled = pyqtSignal(bool)
|
||||
camera_id_changed = pyqtSignal(int)
|
||||
source_mode_changed = pyqtSignal(str)
|
||||
browse_video_clicked = pyqtSignal()
|
||||
run_toggled = pyqtSignal(bool)
|
||||
reset_clicked = pyqtSignal()
|
||||
units_changed = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(8)
|
||||
|
||||
self.source_label = QLabel()
|
||||
self.source_combo = QComboBox()
|
||||
self.source_combo.currentIndexChanged.connect(self._on_source_index_changed)
|
||||
|
||||
self.source_stack = QStackedWidget()
|
||||
cam_page = QWidget()
|
||||
cam_layout = QHBoxLayout(cam_page)
|
||||
cam_layout.setContentsMargins(0, 0, 0, 0)
|
||||
cam_layout.setSpacing(8)
|
||||
self.cam_id_label = QLabel()
|
||||
self.cam_id_spin = QSpinBox()
|
||||
self.cam_id_spin.setRange(0, 20)
|
||||
self.cam_id_spin.setValue(0)
|
||||
self.cam_id_spin.setMinimumWidth(72)
|
||||
self.cam_id_spin.valueChanged.connect(self.camera_id_changed.emit)
|
||||
cam_layout.addWidget(self.cam_id_label)
|
||||
cam_layout.addWidget(self.cam_id_spin)
|
||||
|
||||
video_page = QWidget()
|
||||
video_layout = QHBoxLayout(video_page)
|
||||
video_layout.setContentsMargins(0, 0, 0, 0)
|
||||
video_layout.setSpacing(8)
|
||||
self.video_edit = QLineEdit()
|
||||
self.video_edit.setMinimumWidth(180)
|
||||
self.video_edit.setReadOnly(True)
|
||||
self.btn_browse_video = QPushButton()
|
||||
self.btn_browse_video.clicked.connect(self.browse_video_clicked.emit)
|
||||
video_layout.addWidget(self.video_edit, 1)
|
||||
video_layout.addWidget(self.btn_browse_video)
|
||||
|
||||
self.source_stack.addWidget(cam_page)
|
||||
self.source_stack.addWidget(video_page)
|
||||
|
||||
self.btn_search = QPushButton()
|
||||
self.btn_search.clicked.connect(self.search_clicked.emit)
|
||||
|
||||
self.btn_preview = QPushButton()
|
||||
self.btn_preview.setCheckable(True)
|
||||
self.btn_preview.toggled.connect(self._on_preview_toggled)
|
||||
|
||||
self.btn_run = QPushButton()
|
||||
self.btn_run.setCheckable(True)
|
||||
self.btn_run.toggled.connect(self._on_run_toggled)
|
||||
|
||||
self.btn_reset = QPushButton()
|
||||
self.btn_reset.setEnabled(False)
|
||||
self.btn_reset.clicked.connect(self.reset_clicked.emit)
|
||||
|
||||
self.units_label = QLabel()
|
||||
self.units_combo = QComboBox()
|
||||
self.units_combo.currentIndexChanged.connect(self._on_units_index_changed)
|
||||
|
||||
layout.addWidget(self.source_label)
|
||||
layout.addWidget(self.source_combo)
|
||||
layout.addWidget(self.source_stack, 1)
|
||||
layout.addWidget(self.btn_search)
|
||||
layout.addWidget(self.btn_preview)
|
||||
layout.addWidget(self.btn_run)
|
||||
layout.addWidget(self.btn_reset)
|
||||
layout.addWidget(self.units_label)
|
||||
layout.addWidget(self.units_combo)
|
||||
layout.addStretch(1)
|
||||
|
||||
self._populate_source_combo()
|
||||
self._populate_units_combo()
|
||||
self.retranslate()
|
||||
self._apply_source_mode_ui()
|
||||
|
||||
def source_mode(self) -> str:
|
||||
data = self.source_combo.currentData()
|
||||
return str(data or "camera")
|
||||
|
||||
def set_source_mode(self, mode: str) -> None:
|
||||
wanted = "video" if str(mode).lower().startswith("v") else "camera"
|
||||
idx = self.source_combo.findData(wanted)
|
||||
if idx < 0:
|
||||
idx = 0
|
||||
self.source_combo.blockSignals(True)
|
||||
self.source_combo.setCurrentIndex(idx)
|
||||
self.source_combo.blockSignals(False)
|
||||
self._apply_source_mode_ui()
|
||||
|
||||
def camera_id(self) -> int:
|
||||
return int(self.cam_id_spin.value())
|
||||
|
||||
def set_camera_id(self, value: int) -> None:
|
||||
self.cam_id_spin.blockSignals(True)
|
||||
self.cam_id_spin.setValue(int(value))
|
||||
self.cam_id_spin.blockSignals(False)
|
||||
|
||||
def video_path(self) -> str:
|
||||
return self.video_edit.text().strip()
|
||||
|
||||
def set_video_path(self, path: str) -> None:
|
||||
text = str(path or "").strip()
|
||||
self.video_edit.setText(text)
|
||||
if text:
|
||||
self.video_edit.setToolTip(text)
|
||||
else:
|
||||
self.video_edit.setToolTip("")
|
||||
|
||||
def units(self) -> str:
|
||||
data = self.units_combo.currentData()
|
||||
return str(data or "newton")
|
||||
|
||||
def set_units(self, units: str) -> None:
|
||||
wanted = "raw" if str(units).lower().startswith("r") else "newton"
|
||||
idx = self.units_combo.findData(wanted)
|
||||
if idx < 0:
|
||||
idx = 0
|
||||
self.units_combo.blockSignals(True)
|
||||
self.units_combo.setCurrentIndex(idx)
|
||||
self.units_combo.blockSignals(False)
|
||||
|
||||
def set_preview_checked(self, checked: bool) -> None:
|
||||
self.btn_preview.blockSignals(True)
|
||||
self.btn_preview.setChecked(bool(checked))
|
||||
self.btn_preview.blockSignals(False)
|
||||
self._refresh_preview_label()
|
||||
|
||||
def set_run_checked(self, checked: bool) -> None:
|
||||
self.btn_run.blockSignals(True)
|
||||
self.btn_run.setChecked(bool(checked))
|
||||
self.btn_run.blockSignals(False)
|
||||
self._refresh_run_label()
|
||||
if not checked:
|
||||
self.btn_reset.setEnabled(False)
|
||||
|
||||
def set_reset_enabled(self, enabled: bool) -> None:
|
||||
self.btn_reset.setEnabled(bool(enabled))
|
||||
|
||||
def set_camera_controls_enabled(self, enabled: bool) -> None:
|
||||
self.btn_search.setEnabled(enabled and self.source_mode() == "camera")
|
||||
self.cam_id_spin.setEnabled(enabled and self.source_mode() == "camera")
|
||||
|
||||
def set_source_controls_enabled(self, enabled: bool) -> None:
|
||||
self.source_combo.setEnabled(enabled)
|
||||
self.btn_browse_video.setEnabled(enabled and self.source_mode() == "video")
|
||||
self.set_camera_controls_enabled(enabled)
|
||||
self.btn_preview.setEnabled(enabled and self.source_mode() == "camera")
|
||||
|
||||
def set_run_enabled(self, enabled: bool) -> None:
|
||||
self.btn_run.setEnabled(bool(enabled))
|
||||
|
||||
def active_source(self) -> str | int | None:
|
||||
if self.source_mode() == "video":
|
||||
path = self.video_path()
|
||||
return path if path else None
|
||||
return self.camera_id()
|
||||
|
||||
def _populate_source_combo(self) -> None:
|
||||
self.source_combo.blockSignals(True)
|
||||
self.source_combo.clear()
|
||||
self.source_combo.addItem("", "camera")
|
||||
self.source_combo.addItem("", "video")
|
||||
self.source_combo.blockSignals(False)
|
||||
|
||||
def _populate_units_combo(self) -> None:
|
||||
current = self.units_combo.currentData()
|
||||
self.units_combo.blockSignals(True)
|
||||
self.units_combo.clear()
|
||||
self.units_combo.addItem("", "newton")
|
||||
self.units_combo.addItem("", "raw")
|
||||
if current:
|
||||
idx = self.units_combo.findData(current)
|
||||
self.units_combo.setCurrentIndex(max(0, idx))
|
||||
self.units_combo.blockSignals(False)
|
||||
|
||||
def _apply_source_mode_ui(self) -> None:
|
||||
video = self.source_mode() == "video"
|
||||
self.source_stack.setCurrentIndex(1 if video else 0)
|
||||
self.btn_search.setEnabled(not video)
|
||||
self.btn_preview.setEnabled(not video)
|
||||
if video and self.btn_preview.isChecked():
|
||||
self.set_preview_checked(False)
|
||||
|
||||
def _on_source_index_changed(self, _index: int) -> None:
|
||||
self._apply_source_mode_ui()
|
||||
self.source_mode_changed.emit(self.source_mode())
|
||||
|
||||
def _on_preview_toggled(self, checked: bool) -> None:
|
||||
self._refresh_preview_label()
|
||||
self.preview_toggled.emit(bool(checked))
|
||||
|
||||
def _on_run_toggled(self, checked: bool) -> None:
|
||||
self._refresh_run_label()
|
||||
if not checked:
|
||||
self.btn_reset.setEnabled(False)
|
||||
self.run_toggled.emit(bool(checked))
|
||||
|
||||
def _on_units_index_changed(self, _index: int) -> None:
|
||||
self.units_changed.emit(self.units())
|
||||
|
||||
def _refresh_preview_label(self) -> None:
|
||||
if self.btn_preview.isChecked():
|
||||
self.btn_preview.setText(t("toolbar.preview_stop"))
|
||||
else:
|
||||
self.btn_preview.setText(t("toolbar.preview"))
|
||||
|
||||
def _refresh_run_label(self) -> None:
|
||||
if self.btn_run.isChecked():
|
||||
self.btn_run.setText("■")
|
||||
self.btn_run.setToolTip(t("toolbar.stop"))
|
||||
else:
|
||||
self.btn_run.setText("▶")
|
||||
self.btn_run.setToolTip(t("toolbar.run"))
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.source_label.setText(t("toolbar.source"))
|
||||
self.source_combo.setItemText(0, t("toolbar.source_camera"))
|
||||
self.source_combo.setItemText(1, t("toolbar.source_video"))
|
||||
self.cam_id_label.setText(t("toolbar.cam_id"))
|
||||
self.btn_browse_video.setText(t("toolbar.browse_video"))
|
||||
path = self.video_path()
|
||||
self.video_edit.setPlaceholderText(
|
||||
Path(path).name if path else t("toolbar.video_placeholder")
|
||||
)
|
||||
self.btn_search.setText(t("toolbar.search_cameras"))
|
||||
self._refresh_preview_label()
|
||||
self._refresh_run_label()
|
||||
self.btn_reset.setText(t("toolbar.reset"))
|
||||
self.units_label.setText(t("toolbar.units"))
|
||||
self.units_combo.setItemText(0, t("toolbar.units_newton"))
|
||||
self.units_combo.setItemText(1, t("toolbar.units_raw"))
|
||||
@@ -0,0 +1,371 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "Orisys Studio",
|
||||
"about_title": "About",
|
||||
"about_body": "Orisys Studio desktop application\nVersion: {app_version}",
|
||||
"scaffold_hint": "Scaffold shell — acquisition coming later"
|
||||
},
|
||||
"brand": {
|
||||
"name": "ORISYS",
|
||||
"tagline": "DieDong Technology",
|
||||
"nav_live": "Live",
|
||||
"nav_live_tip": "Current view: live monitoring. Click to leave history browsing.",
|
||||
"nav_playback": "Replay",
|
||||
"nav_playback_tip": "Open a recording: video, curves and one shared cursor",
|
||||
"nav_calibration": "Calibration",
|
||||
"nav_calibration_tip": "Show the calibration panel"
|
||||
},
|
||||
"menu": {
|
||||
"file": "File",
|
||||
"open_video": "Open video…",
|
||||
"reveal_config": "Reveal sensor config…",
|
||||
"playback": "Open recording replay…",
|
||||
"playback_latest": "Replay most recent recording",
|
||||
"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",
|
||||
"settings": "Settings area",
|
||||
"plot_settings": "Plot settings…",
|
||||
"rotate_arrows": "Rotate arrows 90°",
|
||||
"recompute": "Recompute forces from the video",
|
||||
"recompute_tip": "Off: the charts read the recorded forces.csv. On: re-run the vision pipeline over the recorded video (an algorithm check, forward-only).",
|
||||
"run": "Run / Stop",
|
||||
"stop": "Cancel acquisition",
|
||||
"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)",
|
||||
"plot_fn_raw_max": "FN raw max",
|
||||
"plot_fn_raw_max_tip": "Normal-force axis max in raw (uncalibrated) mode. 0 = auto range on the data; the default is sized so idle jitter is invisible in the plot",
|
||||
"plot_shear_raw_max": "Shear raw max",
|
||||
"plot_shear_raw_max_tip": "Shear-force axis ±max for FX/FY in raw (uncalibrated) mode. 0 = auto range",
|
||||
"reset_defaults": "Reset to defaults",
|
||||
"plot_filters": "Filters (applied while acquiring; screen and record agree)",
|
||||
"filter_preset": "Preset",
|
||||
"filter_preset_tip": "A ready-made combination; editing any value below switches to Custom",
|
||||
"filter_preset_custom": "Custom",
|
||||
"filter_flow_ema": "Flow EMA α",
|
||||
"filter_flow_ema_tip": "Temporal smoothing of the optical flow. 1.00 = off. It changes what is *measured* (contact statistics and forces along with it), so two recordings with different α are not comparable — only use it when the flow jitter really has to go",
|
||||
"filter_median": "Median window",
|
||||
"filter_median_tip": "Sliding-median window over the force readings (samples). 1 = off. Removes single-sample spikes at the cost of about half a window of delay",
|
||||
"filter_lowpass": "Low-pass cutoff",
|
||||
"filter_lowpass_tip": "Low-pass cutoff for the force readings (Hz). 0 = off. Must stay below Nyquist (half the frame rate) or the device will not filter at all",
|
||||
"filter_order": "Low-pass order",
|
||||
"filter_order_tip": "Low-pass filter order (1–6). Steeper, with more phase distortion",
|
||||
"filter_rate": "Sample rate",
|
||||
"filter_rate_tip": "The rate the low-pass coefficients are designed for (Hz)",
|
||||
"filter_rate_auto": "Follow the measured frame rate",
|
||||
"filter_rate_auto_tip": "Design the low-pass for the device's measured rate; clear it to pin a number",
|
||||
"filter_measurement_note": "The flow EMA is a *measurement* setting: changing it makes this recording incomparable with earlier ones.",
|
||||
"filter_locked": "Filters cannot change while recording; this applies to the next session.",
|
||||
"filter_nyquist": "Note: a {cutoff} Hz cutoff is at or above the Nyquist frequency of {nyquist} Hz (sample rate {rate} Hz) — the device would not filter. Lower the cutoff or pin a higher sample rate.",
|
||||
"filter_off": "Not filtering (raw readings)",
|
||||
"ok": "OK",
|
||||
"cancel": "Cancel"
|
||||
},
|
||||
"toolbar": {
|
||||
"search_cameras": "Search",
|
||||
"cam_id": "Camera",
|
||||
"use_camera": "Use camera",
|
||||
"use_camera_tip": "Go back to acquiring from a camera (the data source is a video file)",
|
||||
"choose_video": "Choose a recorded video…",
|
||||
"choose_video_tip": "Open a recorded video: the 2D panel plays it and the charts read the forces.csv beside it",
|
||||
"preview": "Preview",
|
||||
"preview_stop": "Stop preview",
|
||||
"run": "Run",
|
||||
"stop": "Stop",
|
||||
"run_label": "▶ Run",
|
||||
"stop_label": "■ Stop",
|
||||
"cancel_label": "■ Cancel",
|
||||
"cancel_tip": "Abort the connection (always available while connecting, shortcut Esc)",
|
||||
"run_unavailable": "Not available yet",
|
||||
"run_disabled_video": "Select a video file first",
|
||||
"run_disabled_sensor": "Run is not available for this sensor type",
|
||||
"playback": "▶ Play",
|
||||
"playback_pause": "⏸ Pause",
|
||||
"playback_tip": "Play this recording at 1× (space bar does the same); dragging the cursor pauses",
|
||||
"playback_pause_tip": "Pause playback (dragging the cursor, box zoom or the wheel also pauses)",
|
||||
"playback_disabled": "Open a recorded video first (“Choose a recorded video…” at the top left)",
|
||||
"reset": "Reset",
|
||||
"units": "Units",
|
||||
"units_newton": "Newton",
|
||||
"units_raw": "Raw",
|
||||
"settings": "Settings",
|
||||
"settings_tip": "Show or hide the unit, record-mode and preview controls"
|
||||
},
|
||||
"units": {
|
||||
"newton_short": "N",
|
||||
"raw_short": "raw"
|
||||
},
|
||||
"sensor": {
|
||||
"type": "Sensor",
|
||||
"config": "Sensor config",
|
||||
"type_tip": "Pick the product/sensor: box, mini or finger. It decides which config files and algorithm parameters exist.",
|
||||
"config_tip": "The algorithm config file (config/*.json) this sensor uses. Switching the sensor switches the available configs with it.",
|
||||
"summary": "{type} · {config}",
|
||||
"dialog_title": "Sensor and calibration",
|
||||
"dialog_tip": "Pick the sensor type and config, and read the calibration in effect and the last exported coefficients",
|
||||
"calib_section": "Calibration in effect",
|
||||
"calib_version": "Version confirmed by the device",
|
||||
"calib_axes": "Calibrated axes",
|
||||
"calib_config": "Config",
|
||||
"calib_none": "the device has not confirmed a calibration",
|
||||
"export_section": "Last exported coefficients",
|
||||
"export_none": "No coefficients exported yet",
|
||||
"export_file": "file {name} ({size} KB, {time})",
|
||||
"export_identity": "file identity type {sensor_type} · SN {sn} · config {config}",
|
||||
"thumb_placeholder": "Product model"
|
||||
},
|
||||
"panels": {
|
||||
"arrow": "2D image",
|
||||
"view_arrows_pure": "Arrows",
|
||||
"view_arrows_stitched": "Arrows / Captured",
|
||||
"view_depth": "Depth map",
|
||||
"view_recorded": "Recorded image",
|
||||
"view_mode_tip": "Image display mode while running",
|
||||
"replay_pipeline": "Recorded video (raw camera image)",
|
||||
"replay_pipeline_tip": "Replay shows the recorded raw camera image with no live arrow overlay; to check the algorithm, use View → Recompute forces from the video",
|
||||
"replay_empty": "No recording open\nPress “Open recording…” below",
|
||||
"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",
|
||||
"depth_unavailable": "No depth data for this frame\n(not a depth of zero, and no silent fallback to the processed image)",
|
||||
"depth_scale": "Depth scale {low} – {high}",
|
||||
"arrow_scale": "Arrows: displacement ×{factor}",
|
||||
"arrow_scale_tip": "Display magnification of the relative displacement: the arrows are drawn from the algorithm output of the captured image, with the measured displacement multiplied by this factor and capped at 0.8 of the grid spacing (a capped arrow no longer shows its true factor).",
|
||||
"arrow_pipeline": "Captured image → algorithm output",
|
||||
"arrow_pipeline_tip": "Arrows are drawn over the algorithm output, not over the raw camera frame: points below the threshold get no arrow",
|
||||
"arrow_area_masked": "Valid area",
|
||||
"arrow_area_full": "Full frame",
|
||||
"depth_scale_tip": "Fixed for this session: the range is latched to the first frame that carried depth so frames stay comparable. Grey marks samples with no depth.",
|
||||
"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",
|
||||
"fn_name": "Normal force",
|
||||
"fx_name": "Shear X",
|
||||
"fy_name": "Shear Y",
|
||||
"window_readout": "{start} – {end} s / {total} s total",
|
||||
"window_tip": "Drag the highlighted region to move the window: this strip is the timeline of the whole session",
|
||||
"no_data": "—",
|
||||
"mode_follow": "Following live",
|
||||
"mode_follow_tip": "The charts follow the newest sample and the window scrolls with it",
|
||||
"mode_browse": "Browsing history",
|
||||
"mode_browse_tip": "Display paused, capture and recording continue; drag or scroll to move the window, “back to latest” returns to live",
|
||||
"mode_replay": "Replaying a recording (browsing)",
|
||||
"mode_replay_tip": "The charts read the open recording; capture and recording continue",
|
||||
"return_latest": "Back to latest",
|
||||
"return_latest_tip": "Leave history browsing and follow the newest data again",
|
||||
"overview": "Whole session",
|
||||
"overview_tip": "Fit the entire session; drag the overview strip to move the window",
|
||||
"box_zoom": "Box zoom",
|
||||
"box_zoom_tip": "Switch to rubber-band zoom (drag a region on the chart)",
|
||||
"settings": "Chart settings",
|
||||
"settings_tip": "Time window, axis ranges, and the acquisition filters (screen and record use the same data)",
|
||||
"selection": "Selection {duration}s / {samples} samples — {detail}",
|
||||
"selection_summary": "Selection {duration}s / {samples} samples",
|
||||
"selection_detail": "mean {mean} · σ {std}",
|
||||
"selection_peak_tip": "The large number is the selection's peak; the line under it is its mean",
|
||||
"selection_empty": "No valid samples in the selection",
|
||||
"value_unavailable": "n/a",
|
||||
"value_unavailable_tip": "No curve is loaded for {channel}, so it has no value in this unit (raw counts are never shown in its place)",
|
||||
"unit_toggle": "Switch unit",
|
||||
"unit_toggle_tip": "Click to switch the three curves and readouts between newton and raw units",
|
||||
"browse_only_tip": "Drag or scroll on the chart to browse history"
|
||||
},
|
||||
"playback": {
|
||||
"title": "Replay recording",
|
||||
"open": "Open a recorded video…",
|
||||
"video_filter": "Recorded video (*.mp4 *.avi *.mov *.mkv);;All files (*)",
|
||||
"no_video": "This recording has no usable video segment",
|
||||
"open_failed": "Could not open that recording: {error}",
|
||||
"none_found": "No recording found under outputs/recordings",
|
||||
"frame_dropped": "This frame never reached the video (the record queue overflowed)",
|
||||
"frame_unreadable": "This frame could not be decoded from the video",
|
||||
"warnings": "Replay notes: {detail}",
|
||||
"decimated": "This recording is long, so the curves are a decimated view ({shown} of {total} rows; intervals are read from disk on demand)",
|
||||
"warn_config_changed": "the config differs from recording time, so recomputing from the video may differ",
|
||||
"warn_sdk_changed": "the SDK version differs from recording time, so recomputing may differ",
|
||||
"warn_config_missing": "no config hash was recorded, so recomputation cannot be verified",
|
||||
"warn_rows_skipped": "some unparsable CSV rows were skipped",
|
||||
"warn_frames_dropped": "frames were lost to a full queue; only their index rows remain (shown as \"not recorded\")",
|
||||
"warn_out_of_order": "this recording was written by an older version: its rows are not in time order, so the replay re-sorts them and window edges may differ"
|
||||
},
|
||||
"record": {
|
||||
"label": "Record",
|
||||
"mode_video_data": "Video + data",
|
||||
"mode_data_only": "Data only",
|
||||
"mode_tip": "Video records the pre-processing camera input (no arrows or text overlays). MP4 is a lossy container, not a lossless copy of the raw data.",
|
||||
"start_label": "● Start recording",
|
||||
"stop_label": "■ Stop recording",
|
||||
"start_tip": "Record this experiment: video, force data and metadata into one directory",
|
||||
"stop_tip": "Stop recording and write session.json (always available)",
|
||||
"requires_run": "Press Run to start acquisition before recording",
|
||||
"started": "Recording started ({mode})",
|
||||
"start_failed": "Could not start recording: {error}",
|
||||
"finished": "Recording finished — {frames} frames / {segments} segment(s) in {path}",
|
||||
"finished_with_problems": "Recording finished with problems: {detail}",
|
||||
"closing": "Closing the recording files…",
|
||||
"failed_state": "The recording failed (see the summary and the log)",
|
||||
"alarm": "Recording problem: {detail}",
|
||||
"state_idle": "No recording",
|
||||
"state_recording": "Recording {duration}",
|
||||
"state_closing": "Closing files…",
|
||||
"state_error": "Write error ({count})",
|
||||
"state_done": "Last recording: {frames} frames",
|
||||
"dropped": "{count} dropped",
|
||||
"strip_idle": "This session has not started a recording yet",
|
||||
"strip_no_error": "none",
|
||||
"strip_no_path": "not created yet",
|
||||
"strip_tip": "Recording state: {state}\nFrames {frames} / segments {segments} / duration {duration}\nDropped {dropped} (data {dropped_data}, video {dropped_video}, gaps {dropped_gaps})\nWrite errors {errors}\nLast error: {detail}\nOutput directory {path}"
|
||||
},
|
||||
"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 coefficients…",
|
||||
"export_tip": "Write the SDK coefficient file only; the running device is untouched",
|
||||
"apply": "Apply to device",
|
||||
"apply_tip": "Push the last export to the running device (confirmed again)",
|
||||
"apply_no_export": "No exported coefficients yet — export them first",
|
||||
"save_record": "Save record…",
|
||||
"save_record_tip": "Save every point of this session with its full three-axis samples (audit data), independently of the coefficient file",
|
||||
"save_record_empty": "No calibration points to save yet",
|
||||
"save_record_ok": "Saved the calibration record to {path}\n{points} point(s), {samples} three-axis samples",
|
||||
"save_record_failed": "Could not save the calibration record: {error}",
|
||||
"open_record": "Open record…",
|
||||
"open_record_tip": "Reopen a saved calibration session to inspect every point's samples and diagnostics",
|
||||
"open_record_filter": "Calibration records (*.json)",
|
||||
"open_record_ok": "Loaded {path} ({points} point(s))",
|
||||
"open_record_device": "Recorded on: {device}",
|
||||
"open_record_warnings": "Note: {detail}",
|
||||
"open_record_failed": "Could not open the calibration record: {error}",
|
||||
"apply_mismatch_title": "Calibration does not match the target device",
|
||||
"apply_mismatch": "This coefficient file belongs to: {device}\nMismatch: {detail}\n\nCurrent target: {sensor_type} / {config}\n\nApplying it would load coefficients that were not measured on this device.",
|
||||
"apply_mismatch_skipped": "Apply cancelled (the calibration belongs to another device)",
|
||||
"sn": "SN",
|
||||
"sn_placeholder": "Serial number (optional)",
|
||||
"live_raw_label": "Live raw",
|
||||
"live_raw": "Live raw FN={fn} FX={fx} FY={fy}",
|
||||
"col_weight": "Reference load",
|
||||
"col_fn": "FN mean",
|
||||
"col_fx": "FX mean",
|
||||
"col_fy": "FY mean",
|
||||
"col_stability": "Stability",
|
||||
"col_state": "State",
|
||||
"col_raw": "Raw mean",
|
||||
"col_n": "n",
|
||||
"col_std": "σ",
|
||||
"state_ok": "OK",
|
||||
"state_excluded": "Excluded",
|
||||
"state_validation": "Validation",
|
||||
"state_exclude": "Exclude",
|
||||
"state_exclude_tip": "Drop the selected point from the fit and the export (kept on record, reversible)",
|
||||
"state_restore": "Restore",
|
||||
"mark_validation": "Mark validation",
|
||||
"mark_validation_tip": "Hold the selected point out of the fit and use it to test the fit",
|
||||
"excluded": "Point excluded from the fit",
|
||||
"restored": "Point restored",
|
||||
"marked_validation": "Point marked as a validation point",
|
||||
"unmarked_validation": "Validation mark removed",
|
||||
"fit_insufficient": "A fit needs at least 2 usable points (currently {count})",
|
||||
"fit_summary": "Exported model {channel}: {method} ({count} points / {breakpoints} breakpoints, max residual {residual} N)",
|
||||
"fit_validation": "Independent validation on {count} point(s), max deviation {error} N",
|
||||
"method_piecewise_linear": "piecewise linear",
|
||||
"method_linear": "linear",
|
||||
"point_title": "Calibration point {channel} @ {load} N",
|
||||
"point_channel": "Loaded axis",
|
||||
"point_direction": "Direction",
|
||||
"point_round": "Round",
|
||||
"point_duration": "Actual sampling time",
|
||||
"point_duration_value": "{actual} s (requested {requested} s)",
|
||||
"point_samples": "Samples",
|
||||
"point_samples_value": "{valid} valid / {total} total",
|
||||
"point_axes": "Three-axis mean ± std",
|
||||
"point_residual": "Fit residual",
|
||||
"col_actions": "",
|
||||
"recording": "Recording calibration samples…",
|
||||
"recording_stop_tip": "Click to cancel this calibration recording (always available)",
|
||||
"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}",
|
||||
"export_no_model": "Nothing to export: {detail}",
|
||||
"apply_title": "Apply calibration",
|
||||
"apply_confirm": "Apply the calibration just exported to the current device?\n\nSensor type: {sensor_type}\nConfig: {config}\n\nChoosing No keeps the exported file and leaves the running device untouched.",
|
||||
"apply_skipped": "Exported; not applied to the device",
|
||||
"apply_pending": "Requested calibration for {sensor_type}; waiting for the device to confirm…",
|
||||
"applied": "Device confirmed the calibration is in effect (version {version})",
|
||||
"applied_none": "Device confirmed: no calibration is loaded (the column stays empty)",
|
||||
"export_only": "Exported coefficients to {path} (not applied to the device)",
|
||||
"apply_no_target": "No device to apply to (run an acquisition first)"
|
||||
},
|
||||
"status": {
|
||||
"phase_idle": "Idle",
|
||||
"phase_connecting": "Connecting",
|
||||
"phase_running": "Acquiring",
|
||||
"phase_error": "Error",
|
||||
"rail_video_name": "Video file: {name}",
|
||||
"data_valid": "Data valid",
|
||||
"data_invalid": "{count} invalid sample(s)",
|
||||
"calibrated": "Calibrated",
|
||||
"calibrated_all": "Calibrated · 3 axes",
|
||||
"calibrated_axes": "Calibrated · {axes}",
|
||||
"uncalibrated": "Uncalibrated",
|
||||
"config_none": "no config",
|
||||
"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}",
|
||||
"connect_slow": "This is taking a while — click “Cancel” or press Esc to abort",
|
||||
"run_stopped": "Stopped",
|
||||
"run_stopping": "Stopping… (releasing the device)",
|
||||
"run_stopping_cancelled": "Connection cancelled (releasing the device)",
|
||||
"sample_invalid": "Invalid sample (#{count}): {reason} — marked invalid and excluded from the curves and from calibration",
|
||||
"samples_dropped": "Record buffer is full; the oldest {count} samples were released (the full session is no longer complete)",
|
||||
"run_failed": "Run failed: {error}",
|
||||
"filters_saved": "Filter settings saved: {describe} (applies to the next session)",
|
||||
"rail_camera_fps": "camera {fps}",
|
||||
"capture_slow": "The camera delivers less than the configured frame rate: {detail}. The recording keeps the real rate. This is a capture-side (camera/driver) matter that software cannot change: use a camera that really does deliver 30 fps — after checking that the cable or port is not slowing this one down — and measure again.",
|
||||
"video_ended": "Video ended",
|
||||
"camera_disconnected": "Camera disconnected",
|
||||
"reset_ok": "Tracker reset",
|
||||
"reset_pending": "Zeroing requested; waiting for the acquisition thread to confirm…",
|
||||
"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,371 @@
|
||||
{
|
||||
"app": {
|
||||
"title": "Orisys Studio",
|
||||
"about_title": "关于",
|
||||
"about_body": "Orisys Studio 桌面应用\n版本: {app_version}",
|
||||
"scaffold_hint": "脚手架界面 — 采集功能稍后加入"
|
||||
},
|
||||
"brand": {
|
||||
"name": "ORISYS",
|
||||
"tagline": "叠动科技",
|
||||
"nav_live": "实时监测",
|
||||
"nav_live_tip": "当前视图:实时监测。点击可退出历史浏览。",
|
||||
"nav_playback": "历史回放",
|
||||
"nav_playback_tip": "打开一段录制:视频、曲线与同一游标",
|
||||
"nav_calibration": "标定",
|
||||
"nav_calibration_tip": "显示标定面板"
|
||||
},
|
||||
"menu": {
|
||||
"file": "文件",
|
||||
"open_video": "打开视频…",
|
||||
"reveal_config": "在资源管理器中显示配置…",
|
||||
"playback": "打开录制回放…",
|
||||
"playback_latest": "回放最近一次录制",
|
||||
"view": "视图",
|
||||
"theme": "主题",
|
||||
"theme_dark": "深色",
|
||||
"theme_light": "浅色",
|
||||
"language": "语言",
|
||||
"lang_zh": "简体中文",
|
||||
"lang_en": "English",
|
||||
"calibration_panel": "标定面板",
|
||||
"finger_3d": "手指 3D 视图",
|
||||
"show_fps": "显示 FPS",
|
||||
"settings": "设置区",
|
||||
"plot_settings": "绘图设置…",
|
||||
"rotate_arrows": "旋转箭头图 90°",
|
||||
"recompute": "由视频重算力值",
|
||||
"recompute_tip": "关闭:曲线读录制的 forces.csv(当时测得的值)。打开:把录制的视频重新跑一遍视觉管线(算法复核,只能顺序前进)。",
|
||||
"run": "运行 / 停止",
|
||||
"stop": "取消采集",
|
||||
"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 纵轴 ±上限(牛顿模式)",
|
||||
"plot_fn_raw_max": "FN 原始上限",
|
||||
"plot_fn_raw_max_tip": "未标定(原始)模式下的法向力纵轴上限。0 = 按数据自动量程;默认值按实测峰值设定,使静止时的抖动在图中不可见",
|
||||
"plot_shear_raw_max": "原始剪切上限",
|
||||
"plot_shear_raw_max_tip": "未标定(原始)模式下的剪切力 FX/FY 纵轴 ±上限。0 = 自动量程",
|
||||
"reset_defaults": "恢复默认",
|
||||
"plot_filters": "滤波(作用于采集,界面与记录同步)",
|
||||
"filter_preset": "预设",
|
||||
"filter_preset_tip": "选择一档现成的滤波组合;改下面任一数值会变成“自定义”",
|
||||
"filter_preset_custom": "自定义",
|
||||
"filter_flow_ema": "光流 EMA α",
|
||||
"filter_flow_ema_tip": "光流的时间平滑。1.00 = 关。它改变的是“测量本身”(接触统计与力值都会变),两个 α 不同的录制不可比 —— 只有确实需要压光流抖动时才调",
|
||||
"filter_median": "中值窗口",
|
||||
"filter_median_tip": "力读数的滑动中值窗口(采样数)。1 = 关。去掉单点尖峰,代价是约半个窗口的延迟",
|
||||
"filter_lowpass": "低通截止",
|
||||
"filter_lowpass_tip": "力读数低通截止频率(Hz)。0 = 关。必须在奈奎斯特频率(帧率的一半)以下,否则设备不会滤",
|
||||
"filter_order": "低通阶数",
|
||||
"filter_order_tip": "低通滤波器阶数(1–6)。越高越陡,相位失真也越大",
|
||||
"filter_rate": "采样率",
|
||||
"filter_rate_tip": "低通系数按哪个采样率设计(Hz)",
|
||||
"filter_rate_auto": "自动跟随帧率",
|
||||
"filter_rate_auto_tip": "按设备实测帧率设计低通系数;取消可固定一个数值",
|
||||
"filter_measurement_note": "光流 EMA 属于“测量”设置:改了它,本次录制与之前的不可比。",
|
||||
"filter_locked": "录制中不能更改滤波;下一次采集生效。",
|
||||
"filter_nyquist": "注意:低通 {cutoff} Hz 已达到或超过奈奎斯特频率 {nyquist} Hz(采样率 {rate} Hz)——设备不会滤。请调低截止频率或固定更高的采样率。",
|
||||
"filter_off": "当前不滤波(原始读数)",
|
||||
"ok": "确定",
|
||||
"cancel": "取消"
|
||||
},
|
||||
"toolbar": {
|
||||
"search_cameras": "搜索",
|
||||
"cam_id": "摄像机",
|
||||
"use_camera": "使用摄像机",
|
||||
"use_camera_tip": "回到摄像机采集(当前的数据源是视频文件)",
|
||||
"choose_video": "选择录制视频…",
|
||||
"choose_video_tip": "打开一段录制的视频:2D 面板放视频,曲线读同目录的 forces.csv(原始记录值)",
|
||||
"preview": "预览",
|
||||
"preview_stop": "停止预览",
|
||||
"run": "运行",
|
||||
"stop": "停止",
|
||||
"run_label": "▶ 运行",
|
||||
"stop_label": "■ 停止",
|
||||
"cancel_label": "■ 取消连接",
|
||||
"cancel_tip": "中断连接(连接卡住时随时可用,快捷键 Esc)",
|
||||
"run_unavailable": "当前不可运行",
|
||||
"run_disabled_video": "请先选择一个视频文件",
|
||||
"run_disabled_sensor": "此传感器类型暂不支持运行",
|
||||
"playback": "▶ 回放",
|
||||
"playback_pause": "⏸ 暂停",
|
||||
"playback_tip": "按 1× 播放这段录制(空格键同效);拖动游标即暂停",
|
||||
"playback_pause_tip": "暂停播放(拖动游标、框选或滚轮也会暂停)",
|
||||
"playback_disabled": "先打开一段录制视频(左上「选择录制视频…」)",
|
||||
"reset": "重置",
|
||||
"units": "单位",
|
||||
"units_newton": "牛顿",
|
||||
"units_raw": "原始",
|
||||
"settings": "设置",
|
||||
"settings_tip": "展开或收起单位、记录模式与预览"
|
||||
},
|
||||
"units": {
|
||||
"newton_short": "N",
|
||||
"raw_short": "raw"
|
||||
},
|
||||
"sensor": {
|
||||
"type": "传感器",
|
||||
"config": "传感器配置",
|
||||
"type_tip": "选择产品/传感器:box、mini、finger。它决定可用的配置文件与算法参数。",
|
||||
"config_tip": "该传感器使用的算法配置文件(config/*.json)。切换传感器会同时切换可选配置。",
|
||||
"summary": "{type} · {config}",
|
||||
"dialog_title": "传感器与标定",
|
||||
"dialog_tip": "选择传感器类型与配置文件,并查看当前生效的标定与最近导出的系数",
|
||||
"calib_section": "当前生效的标定",
|
||||
"calib_version": "设备确认的版本",
|
||||
"calib_axes": "已标定轴",
|
||||
"calib_config": "配置",
|
||||
"calib_none": "设备尚未确认加载标定",
|
||||
"export_section": "最近导出的系数",
|
||||
"export_none": "还没有导出过系数",
|
||||
"export_file": "文件 {name}({size} KB,{time})",
|
||||
"export_identity": "文件身份 类型 {sensor_type} · SN {sn} · 配置 {config}",
|
||||
"thumb_placeholder": "产品型号"
|
||||
},
|
||||
"panels": {
|
||||
"arrow": "2D 图像",
|
||||
"view_arrows_pure": "箭头",
|
||||
"view_arrows_stitched": "箭头/采集图像",
|
||||
"view_depth": "深度图",
|
||||
"view_recorded": "原始图像",
|
||||
"view_mode_tip": "运行时的图像显示模式",
|
||||
"replay_pipeline": "录制视频(原始相机图像)",
|
||||
"replay_pipeline_tip": "回放显示的是录制下来的原始相机图像,不含实时箭头叠加;要复核算法请用「视图 → 由视频重算力值」",
|
||||
"replay_empty": "尚未打开录制\n点击下方「打开录制…」",
|
||||
"arrow_empty": "尚未采集图像\n请选择视频或摄像机后点击「运行」",
|
||||
"arrow_empty_tip": "运行或预览后在此显示形变箭头图",
|
||||
"depth_unavailable": "本帧没有深度数据\n(不是深度为零,也未回退显示普通形变图)",
|
||||
"depth_scale": "深度色标 {low} – {high}",
|
||||
"arrow_scale": "箭头按位移放大 ×{factor}",
|
||||
"arrow_scale_tip": "相对位移的显示放大倍率:箭头由原始采集图像经算法输出,长度按测量位移放大后绘制,上限为网格间距的 0.8 倍;触顶的箭头不再呈现真实倍率。",
|
||||
"arrow_pipeline": "原始图像 → 算法输出",
|
||||
"arrow_pipeline_tip": "箭头画在算法输出上,不是相机原始帧:阈值以下的区域不画箭头",
|
||||
"arrow_area_masked": "有效区域",
|
||||
"arrow_area_full": "全画幅",
|
||||
"depth_scale_tip": "本会话固定色标:色标范围锁定在首个有效深度帧,便于跨帧比较;灰色区域表示无深度数据",
|
||||
"plots": "力曲线图",
|
||||
"cal": "标定",
|
||||
"mesh3d": "三维网格",
|
||||
"mesh3d_empty": "手指三维视图占位 —\nOpen3D 网格稍后加入",
|
||||
"mesh3d_empty_tip": "手指传感器的三维网格预览"
|
||||
},
|
||||
"chart": {
|
||||
"fn": "FN",
|
||||
"fx": "FX",
|
||||
"fy": "FY",
|
||||
"fn_name": "法向力",
|
||||
"fx_name": "切向力 X",
|
||||
"fy_name": "切向力 Y",
|
||||
"window_readout": "{start} – {end} s / 全程 {total} s",
|
||||
"window_tip": "拖动高亮区域即可移动视窗;这是整段会话的时间轴",
|
||||
"no_data": "—",
|
||||
"mode_follow": "跟随实时",
|
||||
"mode_follow_tip": "曲线跟随最新样本,窗口随采集滚动",
|
||||
"mode_browse": "浏览历史",
|
||||
"mode_browse_tip": "显示暂停,采集与记录继续;拖动或滚轮改变窗口,点「返回最新」回到实时",
|
||||
"mode_replay": "回放录制(浏览)",
|
||||
"mode_replay_tip": "曲线读的是打开的录制;回放期间采集与记录继续",
|
||||
"return_latest": "返回最新",
|
||||
"return_latest_tip": "退出历史浏览,恢复跟随最新数据",
|
||||
"overview": "全程总览",
|
||||
"overview_tip": "把整个会话缩放到一屏;拖动下方总览条可移动视窗",
|
||||
"box_zoom": "框选",
|
||||
"box_zoom_tip": "切换为框选放大(拖动图表框选区域)",
|
||||
"settings": "曲线图设置",
|
||||
"settings_tip": "时间窗口、纵轴量程,以及采集端滤波(界面与记录用同一份数据)",
|
||||
"selection": "选区 {duration}s / {samples} 点 — {detail}",
|
||||
"selection_summary": "选区 {duration}s / {samples} 点",
|
||||
"selection_detail": "均 {mean} · σ {std}",
|
||||
"selection_peak_tip": "上方大号数字是选区的峰值;均值为选区均值",
|
||||
"selection_empty": "选区无有效样本",
|
||||
"value_unavailable": "不可用",
|
||||
"value_unavailable_tip": "{channel} 轴未加载标定曲线,因此没有该单位的数值(不会用原始值冒充)",
|
||||
"unit_toggle": "切换单位",
|
||||
"unit_toggle_tip": "点击在「牛顿」与「原始单位」之间切换三轴曲线与读数",
|
||||
"browse_only_tip": "拖动或滚轮即可进入历史浏览"
|
||||
},
|
||||
"playback": {
|
||||
"title": "录制回放",
|
||||
"open": "打开录制视频…",
|
||||
"video_filter": "录制的视频 (*.mp4 *.avi *.mov *.mkv);;所有文件 (*)",
|
||||
"no_video": "这段录制没有可用的视频分段",
|
||||
"open_failed": "无法打开该录制: {error}",
|
||||
"none_found": "outputs/recordings 下没有找到录制",
|
||||
"frame_dropped": "该帧未被写入视频(记录队列曾溢出)",
|
||||
"frame_unreadable": "该帧无法从视频中解码",
|
||||
"warnings": "回放提示: {detail}",
|
||||
"decimated": "该录制较长,曲线为降采样视图(显示 {shown} / 共 {total} 行;区间数据按需从磁盘读取)",
|
||||
"warn_config_changed": "配置文件与录制时不一致:由视频重算的结果可能与当时不同",
|
||||
"warn_sdk_changed": "SDK 版本与录制时不一致:由视频重算的结果可能不同",
|
||||
"warn_config_missing": "找不到配置哈希,无法校验重算一致性",
|
||||
"warn_rows_skipped": "有无法解析的 CSV 行被跳过",
|
||||
"warn_frames_dropped": "有帧在队列溢出时丢失,仅保留索引记录(回放显示为“该帧未录制”)",
|
||||
"warn_out_of_order": "该录制由旧版本写入,行序不是时间序;回放已按时间重排,区间边界可能与当时不同"
|
||||
},
|
||||
"record": {
|
||||
"label": "记录",
|
||||
"mode_video_data": "视频 + 数据",
|
||||
"mode_data_only": "仅数据",
|
||||
"mode_tip": "视频记录的是处理前的相机输入图像(不含箭头与文字叠加)。MP4 是有损封装,不等于无损原始数据。",
|
||||
"start_label": "● 开始记录",
|
||||
"stop_label": "■ 停止记录",
|
||||
"start_tip": "开始记录本实验:视频、力数据与元数据写入同一目录",
|
||||
"stop_tip": "停止记录并写入 session.json(随时可用)",
|
||||
"requires_run": "请先点击「运行」开始采集,再开始记录",
|
||||
"started": "已开始记录({mode})",
|
||||
"start_failed": "无法开始记录: {error}",
|
||||
"finished": "记录完成 — {frames} 帧 / {segments} 段,已写入 {path}",
|
||||
"finished_with_problems": "记录完成,但存在问题: {detail}",
|
||||
"closing": "正在关闭记录文件…",
|
||||
"failed_state": "记录已失败(详见摘要与日志)",
|
||||
"alarm": "记录异常: {detail}",
|
||||
"state_idle": "尚未记录",
|
||||
"state_recording": "记录中 {duration}",
|
||||
"state_closing": "正在写入…",
|
||||
"state_error": "写入异常({count})",
|
||||
"state_done": "上次记录 {frames} 帧",
|
||||
"dropped": "丢帧 {count}",
|
||||
"strip_idle": "本次会话还没有开始记录",
|
||||
"strip_no_error": "无",
|
||||
"strip_no_path": "尚未创建",
|
||||
"strip_tip": "记录状态: {state}\n帧 {frames} / 分段 {segments} / 时长 {duration}\n丢帧 {dropped}(数据 {dropped_data}、视频 {dropped_video}、空洞 {dropped_gaps})\n写入错误 {errors}\n最近错误: {detail}\n输出目录 {path}"
|
||||
},
|
||||
"cal": {
|
||||
"weight": "砝码重量 (N)",
|
||||
"duration": "采集时长",
|
||||
"record": "记录",
|
||||
"recording_stop": "停止",
|
||||
"record_disabled": "请先点击「运行」开始采集,再记录标定样本",
|
||||
"record_cancelled": "已取消本次采集",
|
||||
"empty_hint": "当前通道尚无样本:运行后选择通道并点击「记录」。",
|
||||
"delete_row": "删除行",
|
||||
"clear_channel": "清空通道",
|
||||
"clear_all": "全部清空",
|
||||
"export": "导出系数…",
|
||||
"export_tip": "只写出 SDK 标定系数文件,不改变运行中的设备",
|
||||
"apply": "应用到设备",
|
||||
"apply_tip": "把最近导出的系数下发到运行中的设备(需再次确认)",
|
||||
"apply_no_export": "还没有导出的系数文件,请先「导出系数」",
|
||||
"save_record": "保存标定记录…",
|
||||
"save_record_tip": "保存本次标定会话的全部点与三轴完整采样(审计数据),与系数文件相互独立",
|
||||
"save_record_empty": "还没有标定点,无法保存标定记录",
|
||||
"save_record_ok": "已保存标定记录 {path}\n{points} 个点,共 {samples} 条三轴采样",
|
||||
"save_record_failed": "保存标定记录失败: {error}",
|
||||
"open_record": "打开标定记录…",
|
||||
"open_record_tip": "重新打开已保存的标定会话,检查每个点的完整采样与诊断状态",
|
||||
"open_record_filter": "标定记录 (*.json)",
|
||||
"open_record_ok": "已载入 {path}({points} 个点)",
|
||||
"open_record_device": "记录设备: {device}",
|
||||
"open_record_warnings": "注意: {detail}",
|
||||
"open_record_failed": "打开标定记录失败: {error}",
|
||||
"apply_mismatch_title": "标定与目标设备不匹配",
|
||||
"apply_mismatch": "该标定文件属于: {device}\n不匹配项: {detail}\n\n当前目标: {sensor_type} / {config}\n\n继续应用会把不适用于本设备的系数写入运行中的设备。",
|
||||
"apply_mismatch_skipped": "已取消应用(标定身份与目标设备不匹配)",
|
||||
"sn": "序列号",
|
||||
"sn_placeholder": "序列号(可选)",
|
||||
"live_raw_label": "实时原始值",
|
||||
"live_raw": "实时原始值 FN={fn} FX={fx} FY={fy}",
|
||||
"col_weight": "参考载荷",
|
||||
"col_fn": "FN 均值",
|
||||
"col_fx": "FX 均值",
|
||||
"col_fy": "FY 均值",
|
||||
"col_stability": "稳定性",
|
||||
"col_state": "状态",
|
||||
"col_raw": "原始均值",
|
||||
"col_n": "n",
|
||||
"col_std": "σ",
|
||||
"state_ok": "正常",
|
||||
"state_excluded": "已排除",
|
||||
"state_validation": "验证点",
|
||||
"state_exclude": "排除",
|
||||
"state_exclude_tip": "把选中点排除出拟合与导出(记录保留,可恢复)",
|
||||
"state_restore": "恢复",
|
||||
"mark_validation": "设为验证点",
|
||||
"mark_validation_tip": "把选中点留作独立验证点:不参与拟合,用于检验拟合",
|
||||
"excluded": "该点已排除出拟合",
|
||||
"restored": "该点已恢复",
|
||||
"marked_validation": "该点已标记为验证点",
|
||||
"unmarked_validation": "该点已取消验证点标记",
|
||||
"fit_insufficient": "拟合需要至少 2 个有效点(当前 {count} 个)",
|
||||
"fit_summary": "导出模型 {channel}: {method}({count} 点 / {breakpoints} 个折点,最大残差 {residual} N)",
|
||||
"fit_validation": "独立验证 {count} 点,最大偏差 {error} N",
|
||||
"method_piecewise_linear": "分段线性",
|
||||
"method_linear": "线性",
|
||||
"point_title": "标定点 {channel} @ {load} N",
|
||||
"point_channel": "加载轴",
|
||||
"point_direction": "方向",
|
||||
"point_round": "轮次",
|
||||
"point_duration": "实际采样时长",
|
||||
"point_duration_value": "{actual} s(请求 {requested} s)",
|
||||
"point_samples": "样本数",
|
||||
"point_samples_value": "有效 {valid} / 共 {total}",
|
||||
"point_axes": "三轴均值 ± 标准差",
|
||||
"point_residual": "拟合残差",
|
||||
"col_actions": "",
|
||||
"recording": "正在采集标定样本…",
|
||||
"recording_stop_tip": "点击可取消本次标定采集(随时可用)",
|
||||
"record_ok": "已记录 weight={weight} raw={raw}",
|
||||
"record_failed_few": "采集失败 — 样本过少",
|
||||
"export_ok": "已导出 {path}",
|
||||
"export_local": "同时写入 {path}",
|
||||
"export_warnings": "跳过: {detail}",
|
||||
"export_failed": "导出失败: {error}",
|
||||
"export_no_model": "没有可导出的模型: {detail}",
|
||||
"apply_title": "应用标定",
|
||||
"apply_confirm": "将刚导出的标定应用到当前设备?\n\n设备类型: {sensor_type}\n配置: {config}\n\n选择「否」只保留导出文件,不改动运行中的设备。",
|
||||
"apply_skipped": "已导出,未应用到设备",
|
||||
"apply_pending": "已请求把标定应用到 {sensor_type},等待设备确认…",
|
||||
"applied": "设备已确认标定生效(版本 {version})",
|
||||
"applied_none": "设备已确认:当前没有加载标定(该项留空)",
|
||||
"export_only": "已导出系数 {path}(未应用到设备)",
|
||||
"apply_no_target": "没有可应用的设备(请先运行一次采集)"
|
||||
},
|
||||
"status": {
|
||||
"phase_idle": "空闲",
|
||||
"phase_connecting": "连接中",
|
||||
"phase_running": "采集中",
|
||||
"phase_error": "出错",
|
||||
"rail_video_name": "视频文件: {name}",
|
||||
"data_valid": "数据有效",
|
||||
"data_invalid": "无效样本 {count}",
|
||||
"calibrated": "已标定",
|
||||
"calibrated_all": "已标定 · 三轴",
|
||||
"calibrated_axes": "已标定 · {axes}",
|
||||
"uncalibrated": "未标定",
|
||||
"config_none": "未选配置",
|
||||
"camera_none": "未发现摄像机",
|
||||
"camera_found": "发现 {count} 个摄像机: {names}",
|
||||
"preview_started": "预览已启动(摄像机 {camera_id})",
|
||||
"preview_stopped": "预览已停止",
|
||||
"preview_failed": "预览失败: {error}",
|
||||
"connect_slow": "连接耗时较长,可点击「取消连接」或按 Esc 中断",
|
||||
"run_stopped": "已停止",
|
||||
"run_stopping": "正在停止…(正在释放设备)",
|
||||
"run_stopping_cancelled": "已取消连接(正在释放设备)",
|
||||
"sample_invalid": "采样无效(第 {count} 次):{reason} — 该样本已标记无效,未计入曲线与标定",
|
||||
"samples_dropped": "记录缓冲已达上限,最早的 {count} 个样本已被释放(全程记录不再完整)",
|
||||
"run_failed": "启动失败: {error}",
|
||||
"filters_saved": "滤波设置已保存:{describe}(下次采集生效)",
|
||||
"rail_camera_fps": "摄像头 {fps}",
|
||||
"capture_slow": "摄像头交付的帧率低于配置值:{detail}。记录会按实际帧率保存。这是采集端(摄像头/驱动)的问题,软件改不了:换一颗真能交付 30 fps 的摄像头,或先确认线材/端口没有把它降速,然后再测。",
|
||||
"video_ended": "视频已结束",
|
||||
"camera_disconnected": "摄像机已断开",
|
||||
"reset_ok": "追踪器已重置",
|
||||
"reset_pending": "已请求归零,等待采集线程确认…",
|
||||
"finger_run_unavailable": "此传感器类型暂不支持运行",
|
||||
"video_required": "请先选择视频文件",
|
||||
"config_missing": "未找到传感器配置: {type}",
|
||||
"units_raw_fallback": "无标定数据 — 显示原始单位",
|
||||
"reveal_failed": "无法打开配置位置: {error}"
|
||||
}
|
||||
}
|
||||
+8
-1
@@ -2,17 +2,24 @@
|
||||
"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,
|
||||
"plot_fn_raw_max": 1000000.0,
|
||||
"plot_shear_raw_max": 500000.0,
|
||||
"image_view_mode": "arrows_pure",
|
||||
"arrow_rotation_deg": 90
|
||||
"arrow_rotation_deg": 90,
|
||||
"settings_expanded": false,
|
||||
"view_preset": "custom",
|
||||
"splitter_sizes": [340, 480, 400]
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
# 记录通道与数据可靠性
|
||||
|
||||
本文记录 Studio 在"完整记录、数据可靠优先"这轮改动中的架构决定,以及针对架构评审
|
||||
第 8 节所列缺陷的修法与验证方式。
|
||||
|
||||
核心结论是:**标定采样、全程历史、无效样本隔离之所以会出问题,根因是同一处架构缺口
|
||||
—— 采集数据只有一个出口,就是 16 ms 的 UI 定时器。**
|
||||
|
||||
---
|
||||
|
||||
## 1. 改动前的问题链
|
||||
|
||||
```
|
||||
worker 线程 ──> self._latest(单槽,后写覆盖)
|
||||
│
|
||||
UI 定时器 _on_run_tick(16 ms)──┬──> 曲线历史(只保留窗口内样本)
|
||||
├──> 标定采样(且被 if self._show_cal 守卫)
|
||||
└──> 箭头/深度渲染
|
||||
```
|
||||
|
||||
由此派生出评审指出的多个缺陷,它们不是彼此独立的问题:
|
||||
|
||||
| 现象 | 机制 |
|
||||
|---|---|
|
||||
| 标定依赖面板可见 | `app_window.py` 中 `push_raw` 被 `if self._show_cal:` 守卫,而它是唯一检查录制截止时间的地方 |
|
||||
| 标定录制中无法停止 | `push_raw` 永不执行 → `_finish_recording` 永不调用 → `_recording` 恒为真 → 而 `_recording` 又让录制按钮禁用。**两次点击都无法脱困** |
|
||||
| 历史被窗口裁掉 | `MetricHistory._prune()` 主动丢弃 `t < t_end - window_sec` 的样本 |
|
||||
| 面板未刷新时丢数据 | 单槽 `_latest` 后写覆盖,UI 降帧即丢样本 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 现在的结构
|
||||
|
||||
```
|
||||
worker 线程 ──> self._latest(显示,可有损)
|
||||
│
|
||||
└──> _emit(packet) ──> 记录 sink(记录,不可丢)
|
||||
│
|
||||
app_window._on_packet_recorded(运行在采集线程,不碰 Qt)
|
||||
├──> SessionStore 全程存储
|
||||
└──> CalibrationRecorder.feed() 标定采样缓冲
|
||||
|
||||
UI 线程 _on_run_tick ──> 曲线显示(仍可降帧)
|
||||
└──> CalibrationRecorder.tick() 到点后结算(Qt 回调留在 UI 线程)
|
||||
```
|
||||
|
||||
分工原则:
|
||||
|
||||
- **`feed()` 在采集线程调用**,只往线程安全的缓冲里追加,绝不触碰 Qt 控件。因此
|
||||
隐藏标定面板、曲线重绘变慢、窗口后台化都不会中断记录。
|
||||
- **`tick()` 在 UI 线程调用**,负责判定截止时间、计算统计量并触发回调 —— Qt 信号与
|
||||
控件更新必须留在 UI 线程。
|
||||
- **sink 是普通 callable 列表**,某个 sink 抛异常会被记录并只影响该 packet,不会杀掉采集。
|
||||
|
||||
### 2.1 采集循环只认「新帧」,并据此定义帧率
|
||||
|
||||
`CaptureSource.read_latest` 返回的是**后台读取线程的最新一帧**:如果那一刻还没有新帧,
|
||||
它把**同一帧**再给一次。采集循环以前不做判断,于是同一帧被反复处理 —— 30 fps 的设备
|
||||
录出了 63 行/秒(且 `frame_id` 各不相同,看起来像真的),顶栏的 `packet.fps` 显示的是
|
||||
**我们自己的轮询速率**。
|
||||
|
||||
- `_capture_sequence(sensor)` 读 `capture_source.status.last_seq`:计数没变就是"还没有新
|
||||
帧",睡 `CAPTURE_REPEAT_POLL_S`(2 ms)后重试,**不进处理链、不发 packet**。
|
||||
只有带后台读取线程的源才有这个标记;视频文件是同步 `read_raw()`,每次调用都解码下一
|
||||
帧,因此它的计数与"重复"无关,直接忽略(返回 `None`,行为不变)。
|
||||
- `packet.fps` 改为**速率**:`(窗口内帧数 - 1) / (首末到达时刻之差)`,窗口 `FPS_WINDOW_S`
|
||||
= 1 s。按"1 秒内到达了多少帧"数数会系统性地偏大(窗口里正好装一秒,抖动就多算一帧)。
|
||||
- **等待新帧的间隔必须真的睡得着**:Windows 默认计时器分辨率 15.6 ms,`time.sleep(0.002)`
|
||||
实际睡 15.6 ms;两拍就跨过 30 fps 的 33 ms 周期,于是 30 fps 的设备被以 16.7 fps 消费
|
||||
(隔帧丢)。`_high_resolution_timer()` 在会话期间把分辨率提到 1 ms(结束即还原),
|
||||
且重复帧的等待取 `min(距下一帧的时间, 半个周期)`,已经到期时只 `sleep(0)` 让出而不是
|
||||
再睡一拍。实测:同样是 30 fps 的假设备,不带 1 ms 计时器时只能消费 21.6 fps,带上即 30 fps
|
||||
(`shared/tests/test_worker_frame_rate.py::test_a_30_fps_device_is_consumed_at_30_fps`)。
|
||||
- 这一层是"哪些帧算一帧"与"每秒多少帧"的唯一判据,记录、曲线、标定与 CSV 都跟着它走;
|
||||
它不改动 `packet.fps` 的语义之外的东西 —— **容器速率**仍然是传感器配置值(见 §6.1)。
|
||||
|
||||
`shared/tests/test_worker_frame_rate.py` 用一个假 SDK 驱动真实循环验证:同一帧返回 3 次
|
||||
时,packet 数 ≈ 交付帧数(去掉判据后是 11317 : 39),30 fps 的假设备必须被以 27–31.5 fps
|
||||
消费,且 `packet.fps` 与交付速率一致。
|
||||
|
||||
**文件源的时间轴是视频自己的**:`source` 是字符串(文件)时,`mono_t = frame_id × 1/fps`
|
||||
(用配置的容器速率),而不是墙钟。否则把 5 s 的录制以 60 fps 跑一遍会落在 0–2 s 的轴上,
|
||||
无法与被复核的 `forces.csv` 对齐。实时相机仍用单调时钟。
|
||||
|
||||
### 关键文件
|
||||
|
||||
| 文件 | 职责 |
|
||||
|---|---|
|
||||
| `shared/.../acquisition/sinks.py` | `RecordSinkMixin`:sink 注册与扇出,两个 worker 共用 |
|
||||
| `shared/.../force/session.py` | `SessionStore`:全程存储,内存有界,双单位 + 有效性 + 帧号 |
|
||||
| `shared/.../force/calibration_recorder.py` | 标定采样状态机,与 Qt 无关 |
|
||||
|
||||
---
|
||||
|
||||
## 3. SessionStore 的三条保证
|
||||
|
||||
1. **不是"把窗口调大"**:按定长 numpy 块追加,O(1) 写入;达到 `max_samples` 时释放最旧
|
||||
的整块并累加 `dropped`。`dropped` 由界面显式提示(`status.samples_dropped`),
|
||||
不允许静默丢数据。
|
||||
2. **同时保存 raw 与标定后数值**:每个样本存 `(fn,fx,fy)_raw` 与 `(fn,fx,fy)_N` 两套。
|
||||
切换显示单位时曲线从 store 重建,而不是把新单位的值追加到旧缓冲上。
|
||||
3. **无效样本标记而不是归零**:读取失败时存 NaN 且 `valid=False`;`iter_valid()` 只产出
|
||||
有效样本,因此标定统计不可能把一个失败读数当作 0 平均进去。
|
||||
|
||||
---
|
||||
|
||||
## 4. 针对评审第 8 节的修法
|
||||
|
||||
| # | 问题 | 修法 |
|
||||
|---|---|---|
|
||||
| 1 | 标定中无法点击停止 | `_refresh_enabled` 改为 `can_record = self._running`,录制中按钮保持可用并显示「停止」 |
|
||||
| 2 | 切换 raw/N 后历史混用单位 | 新增 `ForcePlotsPanel.set_units(unit, store)`:清空缓冲并从 `SessionStore` 按目标单位重建;时间原点保留,X 轴不会跳回 0 |
|
||||
| 3 | 异常被当成正常数据 | `read_forces_from_sensor` 不再吞异常:返回 `valid=False` + `invalid_reason` + NaN;`SdkPacket` 新增 `valid` / `invalid_reason` / `raw_available`;无效样本不进曲线、不进标定 |
|
||||
| 4 | 标定依赖面板可见 | 采样改由 worker sink 驱动;`_show_cal` 只影响可见性,不再作为采样入口 |
|
||||
| 5 | 历史 FN 被截断 | 去掉写入前的 `max(0.0, fn)` 夹值与显示前的 `np.maximum`;负值原样保留,越界由坐标轴范围裁剪(只影响绘制,不影响数据) |
|
||||
| 6 | 导出覆盖并自动应用标定 | 导出只写用户标定目录(`local_bundle_dir=None`),不再改写 `config/sensors/<type>/`;应用前弹窗显示目标设备类型与配置,且只下发给匹配的 worker |
|
||||
| 7 | 深度显示误导 | `depth_map_to_bgr` 支持 `value_range` 固定色标与 `invalid_mask`;色标在会话内锁定到首个有效深度帧并在面板头部显示数值范围;无深度时显示明确提示,**不再回退成普通形变图** |
|
||||
| 8 | 历史只保留窗口内样本 | 见 §3;`SessionStore` 与显示用的 `MetricHistory` 分离 |
|
||||
|
||||
另外修正了评审未列出的一处:RAW 通道不可用时,原代码把标定后的 N 值复制进 raw 字段
|
||||
(`fn_raw = fn`)。现改为 `raw_available=False` 且 raw 保持 NaN,避免"原始值"实际上是
|
||||
标定值。
|
||||
|
||||
---
|
||||
|
||||
## 5. 验证
|
||||
|
||||
```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\studio\src"
|
||||
cd D:\orisys_ws\orisys-app\apps\studio
|
||||
& $py tools/ui_check.py # 含 "record channel" 段落
|
||||
```
|
||||
|
||||
- `shared/tests/test_record_channel.py`:11 项,覆盖内存上界、双单位、无效隔离、
|
||||
跨线程 feed、截止时间结算、取消语义。
|
||||
- `shared/tests/test_experiment_recorder.py`:12 项,覆盖目录结构、CSV 列序与空值规则、
|
||||
帧号/时间基准一致、分段滚动与帧索引、仅数据模式、无损序列、队列溢出计数与报警、
|
||||
编码失败上报并停止录制、元数据字段、崩溃后仍留有元数据、`on_packet` 不阻塞。
|
||||
- `shared/tests/test_worker_cancellation.py`:4 项,覆盖非阻塞 stop、线程所有权、
|
||||
会话交接。
|
||||
- `tools/ui_check.py`:新增 `record channel` 与 `experiment record` 两段,断言记录通道
|
||||
在**完全没有 UI 定时器触发**的情况下仍收满样本、隐藏标定面板仍能采样、录制按钮是可用
|
||||
取消入口、深度缺失不回退、色标锁帧,以及通过界面录制的目录结构与事件序列。
|
||||
- `apps/studio/tests/test_ui_smoke.py`:上述不变量各有对应 pytest 用例,复用
|
||||
`ui_check._StuckWorker` 与 `ui_check._fake_packet`,保持单一来源。
|
||||
|
||||
### 已知环境限制
|
||||
|
||||
本机未安装 pytest,且沙箱拒绝写入工作区之外的路径,`conftest.py` 的 `tmp_path` 因此
|
||||
写 prefs 失败。上述 pytest 用例是用等价驱动脚本执行的(参数化用例除外);`ui_check.py`
|
||||
覆盖同样的不变量且可完整运行。
|
||||
|
||||
---
|
||||
|
||||
## 6. 实验录制(第一阶段剩余部分)
|
||||
|
||||
`shared/src/orisys_app_shared/record/experiment.py` 的 `ExperimentRecorder` 落盘格式:
|
||||
|
||||
```
|
||||
outputs/recordings/<UTC 时间戳>_session<NNNN>/
|
||||
session.json 元数据 + 事件 + 结束统计
|
||||
forces.csv 每帧一行
|
||||
frames.csv 帧索引映射(帧号 ↔ 视频分段)
|
||||
events.csv 带时间戳的操作事件
|
||||
raw_0001.mp4 处理前相机图像,按需分段
|
||||
frames/ 可选无损 PNG 序列
|
||||
```
|
||||
|
||||
`forces.csv` 列序固定为
|
||||
`session_id, frame_id, t_rel_s, FN_raw, FX_raw, FY_raw, FN_N, FX_N, FY_N, valid, calibration_id`。
|
||||
|
||||
几条硬性约束:
|
||||
|
||||
- **一次实验一个目录**,`session.json` 在**开始时先写一次**,因此即使进程崩溃也留有可识别
|
||||
的元数据;停止时再写一次并补上 `ended_at`、事件与统计。
|
||||
- **不静默丢数据**:`on_packet` 只把标量(以及开启视频时的一帧拷贝)放进**有界队列**,
|
||||
队列满则计数并报警,绝不阻塞采集;连续写失败达到 `MAX_CONSECUTIVE_ERRORS` 就停止录制
|
||||
并把 `failed` 置位,而不是假装录完了。
|
||||
- **帧号与时间统一**:`forces.csv` 与 `frames.csv` 共用 packet 的 `frame_id`,`t_rel_s`
|
||||
同源于首帧;`frames.csv` 额外记录采集墙钟时间、落在哪个分段、以及是否进过视频,
|
||||
这就是丢帧/分段场景下的重建依据。
|
||||
- **未标定或读取失败的单元格留空**,不写 0:`FN_N` 等列在无标定时为空,`valid=0` 的行
|
||||
所有数值列都为空。精度用 `repr(float)`(最短往返表示),界面小数位不影响落盘。
|
||||
- **MP4 是有损封装**:`session.json` 的 `video.lossy_note` 明确记录这一点,需要算法复现时
|
||||
可开启 `lossless_frames` 输出 PNG 序列。
|
||||
- `raw_frame` 在入队时**必须拷贝**:packet 里的帧可能复用 SDK 的采集缓冲,直接持有引用
|
||||
会让录制内容随后续帧改变。
|
||||
|
||||
### 6.1 录制速率与时长(视频 30 fps、时长实时递增)
|
||||
|
||||
`session.json` 的 `video.fps` 是**容器速率**,必须等于传感器的采集速率,取自传感器配置
|
||||
(`config/sensors/<type>/sensor.json` 的 `fps`,box 为 30),**不是** packet 里那个
|
||||
`fps` 字段 —— 后者是"本机每秒处理了多少帧"的实时速率(见 §2.1),开流瞬间的缓冲突发会
|
||||
让它高于配置值,用它写 30 fps 的容器就等于把回放播成了快进。这是"传感器 30 fps、录制
|
||||
结果 48 fps"的直接原因。
|
||||
|
||||
- `StudioMainWindow._video_fps()`:实时相机 → 传感器配置的 `fps`;视频文件源 → 实测值;
|
||||
实时相机的实测值另存 `video.measured_fps`,两者都进 `session.json`,对不上时看得见。
|
||||
- **视频按速率节流,CSV 一行不少**(`ExperimentRecorder._video_frame_due`):比
|
||||
`1/fps` 提前超过 `VIDEO_PACING_TOLERANCE`(0.5 个周期)到达的帧**只进
|
||||
`frames.csv` / `forces.csv`,不进视频**,并计入 `stats.resampled_video`;报告里会写成
|
||||
"N frame(s) were delivered faster than the 30 fps recording rate, so they are in
|
||||
frames.csv but not in the video"。这不是丢数据,是显式记账。
|
||||
- 节流表的调度基准取"上一帧排定时刻 + 周期"与"当前时间戳"的**较晚者**,所以卡顿之后不会
|
||||
用一串补帧追进度。
|
||||
- `stats()` 的 `duration_s` 在录制**进行中**就返回 `_last_t - _t0`(此前只有 `_finalise`
|
||||
才写,界面上的录制时长因此整场停在 00:00.0);`forces.csv` 的行数与时间轴不受影响。
|
||||
|
||||
### 已知环境限制
|
||||
|
||||
本机未安装 pytest,且沙箱拒绝写入工作区之外的路径,`conftest.py` 的 `tmp_path` 因此
|
||||
写 prefs 失败。上述 pytest 用例是用等价驱动脚本执行的(参数化用例除外);`ui_check.py`
|
||||
覆盖同样的不变量且可完整运行。录制相关的测试把临时目录放在
|
||||
`shared/tests/_scratch/`(已加入 `.gitignore`)以避开该限制。
|
||||
|
||||
---
|
||||
|
||||
## 7. 第一、二阶段评审 P1/P2 的整改(数据语义、终态、标定身份、深度重算)
|
||||
|
||||
评审脚本 `D:/orisys_ws/_review_phase12/reproduce.py` 的十条复现条件已全部翻转,改后
|
||||
输出见同目录 `reproduce_after.log`。以下按发现逐条说明改法。
|
||||
|
||||
### 7.1 记录语义(P1-1)
|
||||
|
||||
唯一转换入口 `shared/.../record/sample.py::to_record_sample`。规则:
|
||||
|
||||
1. 计算链失败(`valid=False` 或 `stale_forces=True`)的帧,六个力值**全部**记为不可用。
|
||||
SDK 的 `info.FNORMAL` 在计算失败时仍返回上一帧的数值,记录它等于伪造测量值。
|
||||
2. 只有**该轴确实加载了标定曲线**才写 N 值(`calibrated_axes` 按轴判断,而不是
|
||||
`force_calibrated` 这个传感器级标志)。未标定的轴留空。
|
||||
3. `mono_t` 是排序基准,墙钟只进元数据。`SdkPacket.mono_t/wall_t` 默认 `None` 而不是
|
||||
`0.0`:默认 0 会让所有帧的时间相同。
|
||||
4. 每行 `calibration_id` 取 **packet 自带版本**(采集线程在采集瞬间盖的哈希),
|
||||
会话级版本只作回退;一份 CSV 中途换标定也说得清。
|
||||
|
||||
### 7.2 记录终态与队列损失(P1-5、P1-6)
|
||||
|
||||
- 生命周期显式化为 `starting/recording/stopping/stopped/failed`;**写线程拥有终态与最终
|
||||
报告**,文件打开、写入、flush、close 全在异常边界内。连续写失败会立刻停止接收
|
||||
(`rejected_after_failure` 计数),`is_recording` 随之变假 —— 不会出现 failed=True 却
|
||||
仍在录制。`starting` 也算“录制中”,否则开始录制后立刻点停止会失效。
|
||||
- `stop(wait=True, timeout=…)` 超时返回 `completed=False` 的报告,**不写 `ended_at`**、
|
||||
不丢弃线程引用;未结束的 recorder 不允许再次 `start()`。
|
||||
- 队列按**字节预算**(`max_queue_bytes`,默认 512 MiB)而不是仅按条数:1920×1080 BGR
|
||||
排 2048 条副本约 11.9 GiB。预算用尽时**丢视频保数据行**;连条目队列都满时把数据行放进
|
||||
独立的 `_row_backlog`(默认 8192 行),因此视频溢出不再连带丢掉 CSV。
|
||||
- 真的丢掉的数据行会在 `frames.csv` 留下 `dropped=1` 的索引行,回放据此显示“该帧未录制”,
|
||||
而不是拿上一帧冒充。统计区分 `dropped_data`、`dropped_video`、`dropped_gaps`
|
||||
(已标记的缺口)与 `unrecorded_gaps`(连标记都放不下的)。
|
||||
|
||||
### 7.3 标定身份与生效确认(P1-7)
|
||||
|
||||
- 标定身份是**内容哈希**(`calibration_digest`,sha256),不是路径:同一路径可以重新导出
|
||||
不同系数。`Sensor` 打开时自带 bundle 标定,因此采集线程在**第一帧之前**就把实际加载的
|
||||
标定哈希报出来(`on_calibration_applied(path, version, frame_id)`),事件与每行版本都
|
||||
来自这次确认,而不是“已下发”的意图。
|
||||
- 事件在**发生时**打时间戳(`log_event(..., mono_t=…, frame_id=…)`),不再在写线程出队时
|
||||
重新计时。
|
||||
- 面板拆成三个独立动作:**保存标定记录 / 导出系数 / 应用到设备**。导出只写文件,
|
||||
应用需要再次确认并等设备确认后才记 `calibration_applied`。
|
||||
|
||||
### 7.4 标定诊断与实验明细(P1-2、P1-8)
|
||||
|
||||
- `CalibrationRecorder.fit` 用**将要导出的那个模型**(`build_export_model` + SDK 的
|
||||
`Calibration.convert`)计算残差与验证误差。分段线性模型会精确穿过它自己的标定点,
|
||||
所以训练残差恒为 0 —— 真正有意义的诊断是**留出验证点**的偏差。界面按“导出模型:分段线性
|
||||
(N 点 / M 个折点,最大残差)…”显示。
|
||||
- 验证点**不进入**构建(`CalPanel._rebuild_store` 跳过 `is_validation`),重复参考载荷按
|
||||
n 加权合并(`merge_samples`),不再“最后一个覆盖前面的”。
|
||||
- `CalibrationPoint.samples` 保存该点**全部**三轴采样(帧号、相对时间、三轴 raw、三轴 N、
|
||||
valid),连同排除/验证状态、方向、备注与实际时长一起写入独立的标定会话文件
|
||||
(`calibration_session_*.json`,`force/calibration_session.py`),并可用「打开标定记录」
|
||||
重新载入检查。界面上的降采样 trace 只是预览,不是审计数据。
|
||||
|
||||
### 7.5 深度重算引擎(已删除)
|
||||
|
||||
这一节记录的 `DepthRecomputer` / `depth_backends`(离线把录制视频喂回 SDK 重算深度、按
|
||||
帧号增量读、归零/预卷恢复算法状态、请求绑定帧号)**已整体删除**:用户明确不需要"为回放再
|
||||
录/再算一遍深度"这条路径,回放视图只放录制的原始相机图像。要复核算法的当前做法是
|
||||
「视图 → 由视频重算力值」(把录制视频跑一遍采集管线,见 `docs/shell-lifecycle.md` §2.1),
|
||||
它复用实时那条链路,因此不再需要一份离线复现实现。
|
||||
|
||||
`session.json` 里的深度块保留(它说明"深度没有保存"这一事实),但 `replay` 字段改为
|
||||
`"none"`;`RecordingReader.replay_warnings()` 不再报 `depth_recomputed`。
|
||||
|
||||
---
|
||||
|
||||
## 7. 同步回放与深度(不保存深度)
|
||||
|
||||
**决定:不保存深度。** 理由是体积:30 Hz、320×240 的 float32 深度每帧约 300 KB,
|
||||
一小时就是 ~16 GB;即使降频到 1 Hz 也要百兆级。所以 `session.json` 里明确写:
|
||||
|
||||
```json
|
||||
"depth": {
|
||||
"stored": false,
|
||||
"replay": "none",
|
||||
"quantity": "relative deformation / algorithm response, not a calibrated length",
|
||||
"method": "re-run the SDK over raw_*.mp4 with this config_ref",
|
||||
"config_sha256": "...",
|
||||
"sdk_version": "..."
|
||||
}
|
||||
```
|
||||
|
||||
因此 `RecordingReader.replay_warnings()` 会比对**当前配置文件哈希**与录制时的哈希、以及
|
||||
SDK 版本,「由视频重算」前这些提示必须让操作者看到(回放视图里放在来源摘要的 tooltip 与
|
||||
一条 6 秒通知里):
|
||||
|
||||
| 提示 | 含义 |
|
||||
|---|---|
|
||||
| `config_changed` | 配置文件已变,由视频重算的结果可能与当时不同 |
|
||||
| `sdk_changed` | SDK 版本已变,重算结果可能不同 |
|
||||
| `config_missing` | 缺少配置哈希,无法校验一致性 |
|
||||
| `rows_skipped` | 有无法解析的 CSV 行被跳过 |
|
||||
| `frames_dropped` | 有帧在队列溢出时丢失,只留索引行 |
|
||||
| `out_of_order` | 旧版本写入的行序不是时间序 |
|
||||
|
||||
### 回放界面
|
||||
|
||||
回放**与实时是同一套界面**:`ui/playback_dialog.py`、`ui/replay_bar.py` 与深度重算引擎都
|
||||
已删除。`replay.py::ReplayController` 只读录制并发信号,主窗口把它接到同一套面板上——
|
||||
状态轨的主按钮在回放视图里变成「▶ 回放 / ⏸ 暂停」(`toolbar.set_run_role`),来源槽换成
|
||||
「选择录制视频…」。布局、单位、看点与关闭流程见 `docs/shell-lifecycle.md` §2
|
||||
(含「由视频重算」复核模式)。
|
||||
|
||||
- **一个游标驱动三处**:移动位置同时更新视频帧与三张曲线的游标线;并且通过 `cursor_moved`
|
||||
信号同步到主界面的曲线面板。
|
||||
- 单位可切换(raw/N),数据直接来自录制目录的 `forces.csv`。
|
||||
- **回放按录制实际有的单位打开**:录制里 `FN_N` 等列全空(没有标定)时退回 raw,
|
||||
否则选 N 会画出三张空图;`raw` 一定存在(那是设备测到的值)。
|
||||
- **数值卡读的是游标处的那一帧**(`ForcePlotsPanel._readout_at_cursor`):浏览历史或回放
|
||||
时,三个大数字是播放头所指的样本,而不是实时采集的最后一个样本 —— 否则一张 7.5e5
|
||||
原始计数的曲线旁边会写着实时的 `0.0`,看起来就像"回放根本没打开"。
|
||||
实时视图仍是"最新样本就是游标",所以跟随状态下不额外读盘。
|
||||
- 帧未被写入视频时(记录队列曾溢出)明确提示,而不是显示上一帧冒充当前帧。
|
||||
- **解码器常开**(`RecordingReader.video_frame_at`):以前每帧重开文件 + seek,1080p 下
|
||||
每帧上百毫秒,30 fps 播放时视频根本不动;现在按分段缓存解码器、顺序帧 `read()` 步进
|
||||
(实测 0.5 ms/帧),跳转才 seek。
|
||||
|
||||
两个必须知晓的边界:
|
||||
|
||||
1. SDK 打开视频逐帧步进**可能长时间阻塞**,且无法取消。因此用守护线程:窗口能关、
|
||||
进程能退,但那个线程会一直挂到进程结束。用线程池(非守护)会让**整个进程无法退出**,
|
||||
这是实测踩到的。
|
||||
2. 重算失败一律显示原因(SDK 不可用/未记录配置/配置路径不存在/无视频分段/
|
||||
帧不在视频中),**绝不用其它图像顶替**。
|
||||
|
||||
---
|
||||
|
||||
## 8. 长历史浏览:按需读盘而不是整段载入
|
||||
|
||||
`record/index.py` 的 `RecordingIndex` 是回放的数据源索引:
|
||||
|
||||
- 流式扫一遍 `forces.csv`,只把**小东西**留在内存:每行一个时间(float64)、一个
|
||||
valid 标志、一个帧号,以及每 `CHECKPOINT_ROWS`(4096)行的字节偏移。500k 行约
|
||||
6.5 MB(`SessionStore` 的默认上限正好对应 30 Hz 下约 4.6 小时)。
|
||||
- `row_range(t0, t1)` 用二分定位行区间,`read_rows(start, stop)` 先 `seek` 到最近的
|
||||
检查点再向前扫描——读一个 10 秒区间就是 10 秒的解析量,不是整段。
|
||||
区间会额外带上 `t0` 之前的那一行,绘制时才能画出跨过左边界的线段。
|
||||
- `overview(buckets)` 在**同一次流式扫描**里把每行折进所属桶,得到每桶每轴的
|
||||
min/max/count。全程色带因此不会漏掉某桶里的尖峰(按行抽样会漏)。
|
||||
|
||||
`ReadingReader` 相应变化:
|
||||
|
||||
| 方法 | 作用 |
|
||||
|---|---|
|
||||
| `info()` | 行数超过 `max_rows`(默认 200k)时给出**降采样视图**,并置 `decimated=True`;`duration_s` 始终取自帧索引,不取自视图 |
|
||||
| `window(t0, t1, max_points=…)` | 区间精确读取(可再降采样),尖峰按轴保留 |
|
||||
| `overview(unit, buckets)` | 全程包络,按 (unit, buckets) 缓存 |
|
||||
| `statistics(t0, t1, unit)` | 只算该区间的统计量 |
|
||||
|
||||
`force/source.py` 把两种来源统一成同一接口:`StoreCurveSource`(实时内存块)与
|
||||
`RecordingCurveSource`(磁盘区间 + 缓存包络),都提供
|
||||
`extent() / window() / envelope() / statistics() / duration_s()`;`ForcePlotsPanel`
|
||||
只认这个接口,因此同一套浏览逻辑既能看内存会话也能看磁盘录制。
|
||||
|
||||
回放窗口在曲线被降采样时会明确写出「显示 N / 共 M 行」,而不是让操作员自己数点。
|
||||
|
||||
> 未做的验收项:真实分辨率与目标 ARM 板上的最大 RSS、积压与丢失测试(需要硬件),
|
||||
> 以及选中时间段的 CSV 导出。
|
||||
|
||||
---
|
||||
|
||||
## 9. 界面上的记录状态条
|
||||
|
||||
录制过程对操作员可见的部分,集中在顶栏状态行右侧的一个记录状态条
|
||||
(`apps/studio/src/orisys_studio/ui/record_status.py`)。它回答按钮本身答不了的四个问题:
|
||||
|
||||
| 显示 | 判据 | 颜色令牌 |
|
||||
|---|---|---|
|
||||
| `尚未记录` | 本会话从未开始记录(`recording.is_recording` 与 `last_report` 都为空) | `status_idle` |
|
||||
| `记录中 mm:ss.d` | `recording.is_recording`;时长取自 `recorder.stats().duration_s` | `status_running` |
|
||||
| `正在写入…` | `recording.report_pending`:写入线程还没关完文件(**不**当成已完成) | `status_connecting` |
|
||||
| `上次记录 N 帧` | 已拿到终态报告且 `failed`/`write_errors` 为 0 | `status_idle` |
|
||||
| `写入异常(N)` | `stats.failed` 或 `stats.write_errors > 0` | `status_error` |
|
||||
|
||||
丢帧不是单独一行,而是追加在状态后面(`丢帧 N`),并把整条状态提到 `warning` 色;
|
||||
N 是 `dropped_data + dropped_video + dropped_gaps + unrecorded_gaps` —— 只报前两个
|
||||
队列溢出计数会低估真实损失。tooltip 里分项列出帧数、分段、时长、四类丢帧、写入错误、
|
||||
最近一次错误与输出目录,因此状态条本身可以保持一行。
|
||||
|
||||
读数入口是 `StudioMainWindow._record_stats()`:它在 UI 线程上返回
|
||||
`(state, stats, directory)`,是**唯一**读记录通道的地方。`ExperimentRecorder.stats()`
|
||||
返回加锁快照,不会被写入线程阻塞;`tools/ui_snapshot.py --scene recording` 就是替换这
|
||||
一个接缝来渲染「记录中」画面,既不写文件也不开设备。
|
||||
|
||||
刷新时机:`_on_run_tick`(16 ms 定时器)与每次状态跃迁(开始/停止记录、拿到终态报告、
|
||||
阶段变化)。控件只在文本或颜色令牌真的变化时才 `setText`/`setStyleSheet`,因此 60 Hz
|
||||
下不会反复重排顶栏。
|
||||
|
||||
---
|
||||
|
||||
## 10. 尚未完成(第三阶段及以后)
|
||||
|
||||
- PyOpenGL 三维显示(`mesh3d_panel.py` 目前仍是占位符,不是已有实现)。
|
||||
- CSV 的「导出选中时间段」(整段导出已随录制落盘;区间导出待做)。
|
||||
- 标定的稳定后采样(当前按固定时长采样,稳定性以 σ 呈现)。
|
||||
- 在目标 ARM 板上用真实分辨率测长时录制的最大 RSS、队列积压与丢失。
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
# Studio 外壳:视图与关闭流程
|
||||
|
||||
这份文档记录主窗口的三件"外壳"行为:三个顶层视图如何切换面板、回放如何并入主窗口
|
||||
(含可拖拽进度条),以及窗口关闭后进程如何回到命令行。它们都不属于记录通道,但都会
|
||||
直接影响可用性。
|
||||
|
||||
---
|
||||
|
||||
## 1. 三个顶层视图 = 同一窗口的三套布局
|
||||
|
||||
品牌轨上的三个导航项不打开新窗口,而是切换主窗口的布局
|
||||
(`StudioMainWindow._apply_view_preset`,预设在 `VIEW_PRESETS` / `VIEW_SPLITTER_SIZES`):
|
||||
|
||||
| 视图 | 面板 | 曲线 | 比例(3D,2D,曲线,标定) |
|
||||
|---|---|---|---|
|
||||
| 实时监测 `live` | 2D 图像 + 力曲线(+ 手指三维) | 跟随实时 | `0, 360, 1080, 0` |
|
||||
| 历史回放 `replay` | 2D 图像 + 力曲线(**与实时同布局**) | 读打开的录制;未打开时仍是实时跟随 | `0, 360, 1080, 0` |
|
||||
| 标定 `calibration` | 标定面板 + 力曲线 | 不变 | `0, 0, 560, 880` |
|
||||
| 自由 `custom`(启动默认) | 由视图菜单决定 | 不变 | 用户在分割条上摆的位置 |
|
||||
|
||||
- **为什么标定视图给标定面板 880 px**:六列诊断表头加九个动作按钮,在 300–400 px 的
|
||||
三栏布局里必然被挤压。标定面板自身还有纵向滚动区,窗口再矮也够得到「应用到设备」。
|
||||
- **`custom` 不点亮任何导航项**:手工摆出来的布局不属于三个视图中的任何一个。
|
||||
- **`视图 → 标定面板` 与「标定」导航是同一个开关**:勾上进入标定视图,取消回到实时视图。
|
||||
- **布局菜单里的「自由布局 / 二维模式」已删除**:导航视图 + 分割条已经覆盖它们,而
|
||||
「深度图 + 曲线」由 2D 面板自带的「深度图」模式一键完成;`layout_preset` 偏好一并移除。
|
||||
|
||||
### 1.1 采集来源随视图,不再是「来源」下拉
|
||||
|
||||
`ToolbarWidget` 的状态轨上有一个来源槽(`source_slot`):
|
||||
|
||||
| 情形 | 轨上显示 |
|
||||
|---|---|
|
||||
| 实时监测 + 摄像机 | `摄像机 [编号] [搜索]` |
|
||||
| 实时监测 + 视频文件 | `使用摄像机` 按钮 + 摘要里的 `视频文件: xxx.mp4` |
|
||||
| 历史回放 | 「选择录制视频…」按钮 + 摘要里的视频名 |
|
||||
|
||||
「用视频文件跑一次采集」仍从「文件 → 打开视频…」进入,`source_mode` / `video_path`
|
||||
偏好保留,但不再有一个需要先选「来源」才能用的下拉。
|
||||
|
||||
### 1.2 传感器与标定共用一个弹窗
|
||||
|
||||
轨上只有一个摘要按钮(`BOX · box/sensor.json`,`ToolbarWidget.btn_sensor`);
|
||||
点击打开 `apps/studio/src/orisys_studio/ui/sensor_dialog.py::SensorDialog`:
|
||||
|
||||
- 传感器类型 / 传感器配置两个下拉(原 `SensorPickerWidget`,仍是窗口的属性,
|
||||
因此 `sensor_type()` / `config_name()` 与信号语义不变);
|
||||
- 「当前生效的标定」:设备确认的版本(`_active_calibration_id`)与已标定轴;
|
||||
- 「最近导出的系数」:文件、大小、时间与文件身份(类型 / SN / 配置,来自
|
||||
`calibration_identity()`);导出/应用按钮转调 `CalPanel.export_coefficients()` /
|
||||
`apply_coefficients()`,实现只有一份。
|
||||
|
||||
弹窗常驻一个实例、每次打开 `refresh()`,所以设备刚确认的标定立刻可见。
|
||||
|
||||
---
|
||||
|
||||
## 2. 回放与实时是同一套界面(只换来源)
|
||||
|
||||
「历史回放」不另开窗口,**也不另起一套控件**:它与「实时监测」是同一套布局、同一条状态轨、
|
||||
同一套图表手势。**唯一的差别是左上的来源槽**——实时是摄像机,回放是一段录制视频。
|
||||
`ui/replay_bar.py`(进度条 + 录制目录选择)与 `PlaybackDialog` 都已删除。
|
||||
|
||||
- `apps/studio/src/orisys_studio/replay.py::ReplayController(QObject)`:录制读取、游标、
|
||||
回放提示(降采样 / 跨版本)。它不画任何东西,只发信号:`position_changed` /
|
||||
`frames_changed` / `status_changed` / `recording_changed`。深度重算引擎
|
||||
(`DepthRecomputer` / `depth_backends`)已随回放条一起删除——用户不需要这条回放路径,
|
||||
要复核算法就用「由视频重算」。
|
||||
- **选视频即选录制**:状态轨的「选择录制视频…」(`toolbar.btn_replay_video`)与
|
||||
「文件 → 打开视频…」都走 `_load_replay_video(path)`——视频所在目录就是录制目录,
|
||||
于是曲线自动配对同目录的 `forces.csv`。视频与 CSV 仍然同时记录(录制格式不变)。
|
||||
- 曲线:`ForcePlotsPanel.set_recording_source(reader)` 用 `RecordingCurveSource` 直接读
|
||||
录制目录(区间按需读盘 + 缓存包络),所以全程总览、拖动、缩放、框选都在同一套曲线上;
|
||||
表头显示「回放录制(浏览)」,`返回最新` 在录制模式下禁用。
|
||||
- **回放按录制实际有的单位打开**(`_replay_unit_for`):`FN_N` 等列全空的录制退回 raw,
|
||||
否则在 newton 偏好下会画出三张空图、每张数值卡一个横杠——看起来就是"回放没加载"。
|
||||
表头的单位芯片是唯一的单位开关(`_set_display_unit` 同时告诉曲线、工具栏与控制器)。
|
||||
- 2D 面板:`ArrowPanel.set_source_kind("replay")` 把视图下拉换成「原始图像」一项
|
||||
(`REPLAY_VIEW_MODES`;录制里没有光流,也没有存储深度),页脚显示「录制视频(原始相机
|
||||
图像)」并隐藏放大倍率与有效区域。
|
||||
- **游标就是播放头,运输控制就是状态轨上那颗主按钮**:回放视图里
|
||||
`toolbar.set_run_role("playback")` 把它换成「▶ 回放」/「⏸ 暂停」(实时视图仍是「运行」),
|
||||
以 1× 推进游标,到末尾自动停;拖动游标、框选、滚轮平移或拖总览条都会**自动暂停**
|
||||
(`ForcePlotsPanel.timeline_taken_over` → `_pause_playback`)。空格键同效。没有独立进度条,
|
||||
曲线表头也不再放第二个播放按钮(一个运输控制,不会两处打架)。
|
||||
- **视频解码器常开**:`RecordingReader.video_frame_at` 以前每帧 `VideoCapture` 重开 + seek
|
||||
(小片段 10 ms,1080p H.264 上百 ms),30 fps 播放时每一拍都卡在 UI 线程上,视频看起来
|
||||
根本不动。现在按分段缓存一个解码器,**顺序帧用 `read()` 步进**(实测 0.5 ms/帧),
|
||||
跳转才 seek,`RecordingReader.close()` 负责释放。
|
||||
- **数值卡读游标处的那一帧**(`ForcePlotsPanel._readout_at_cursor`)。浏览历史或回放时,
|
||||
三个大数字是播放头所指的样本;跟随实时时"最新样本就是游标",不额外读盘。旧写法在回放
|
||||
时显示实时采集的最后一个样本,于是一张 7.5e5 原始计数的曲线旁边写着实时的 `0.0`。
|
||||
- **2D 面板归当前视图的来源所有**(`_replay_owns_image()`):回放视图打开录制后,采集线程
|
||||
仍然在跑,但 `_on_run_tick` / `_on_preview_tick` 不再把实时帧画进 2D 面板,
|
||||
`_render_run_image` 直接返回。否则回放画面会被摄像头帧一帧一帧盖掉("回放时好像用的是
|
||||
摄像头")。切回实时视图才把面板交还给实时帧。
|
||||
- **回放提示不占位置**:降采样 / 跨版本等说明放在来源摘要的 tooltip 里(状态栏只留一次性
|
||||
通知,见 §2.2),打开录制时另发一条 6 秒通知。
|
||||
- 离开回放视图:曲线挂回实时 store 并恢复跟随,来源槽换回摄像机,**已打开的录制保留**,
|
||||
摘要里的视频名**不再出现**(这正是"视频文件名残留在实时检测里"的修法)。
|
||||
- 文件菜单两条入口(`打开录制视频…` / `回放最近一次录制`)都只是切到回放视图并加载录制。
|
||||
|
||||
### 2.1 「由视频重算」:算法复核模式
|
||||
|
||||
`视图 → 由视频重算力值`(`act_recompute`)把曲线的来源从**录制的 CSV** 换成**把录制的视频
|
||||
重新跑一遍视觉管线**:
|
||||
|
||||
| | 默认(关闭) | 打开 |
|
||||
|---|---|---|
|
||||
| 曲线数据 | 录制目录的 `forces.csv`(当时测得的值) | 管线在视频上重算出的样本(进 `SessionStore`) |
|
||||
| 2D 面板 | 录制视频在游标处的那一帧 | 管线正在处理的帧 |
|
||||
| 时间轴 | 任意定位(游标、框选、总览条、播放) | 只能顺序前进(管线是有状态的),播放按钮即开始/暂停这一遍 |
|
||||
| 用途 | 看当时的记录 | 复核算法版本(mp4 有损,数值与原记录会有差异) |
|
||||
|
||||
打开它时会立即开始这一遍(`_on_recompute_toggled` → `force_plots.set_playing(True)` →
|
||||
`_on_transport_toggled` → `_start_run`,只有一条启动路径,避免按钮与菜单各启动一次);
|
||||
关闭时停止该遍并挂回 CSV。`_replay_owns_image()` 在这一遍运行期间为 False,2D 面板交给
|
||||
管线自己画。
|
||||
|
||||
`apps/studio/tools/ui_snapshot.py --scene replay` 会合成一段短录制并打开它,用于人工复核
|
||||
统一后的回放布局(不写业务数据,也不需要硬件)。
|
||||
|
||||
### 2.2 状态栏与来源摘要的分工
|
||||
|
||||
- 状态轨的来源槽按视图切换:实时 = 摄像机编号 + 搜索;回放 = 「选择录制视频…」。
|
||||
`source_summary` 在回放视图里写「视频文件: xxx.mp4」,在实时视图里**只**写帧率——
|
||||
视频名出现在实时视图里就是残留。
|
||||
- 底部状态栏只放一次性通知(见 §1 表格与 `_NoticeBar`),回放提示与完整路径放在来源摘要的
|
||||
tooltip 里。
|
||||
|
||||
### 曲线缩放与默认模式
|
||||
|
||||
- 三条曲线在**所有**模式下 `mouseEnabled(x=True)`:滚轮缩放、左键平移。手势信号用
|
||||
pyqtgraph 的 `sigRangeChangedManually`(只有真实滚轮/拖动才会发),不是
|
||||
`sigXRangeChanged`——后者在窗口缩放沿 X 轴联动传播时也会发,会把"缩放窗口"误判成
|
||||
"用户开始浏览"。y 轴仍按配置的固定量程。
|
||||
- 浏览模式下共享光标线可拖动,拖动即移动播放头(回放视图里就是回放位置)。
|
||||
- 启动、新一次运行、切换单位/传感器、切回「实时监测」都保证回到「跟随实时」;
|
||||
只有打开录制后的回放视图才处于浏览状态。
|
||||
|
||||
### 曲线表头:动作是图标,单位是开关
|
||||
|
||||
表头右侧三个动作(`ForcePlotsPanel._header_button`)只画符号、不写字,符号由
|
||||
`apps/studio/src/orisys_studio/ui/icons.py` 在运行时用当前主题的颜色绘制
|
||||
(`zoom_fit` 取景框+角标、`box_zoom` 虚线框、`latest` 箭头进入竖条),因此**没有新增色值**,
|
||||
换主题时按令牌重新着色,也不会出现位图在不同底色上发灰:
|
||||
|
||||
| 动作 | 符号 | 文字(tooltip / 无障碍名) |
|
||||
|---|---|---|
|
||||
| 全程总览 | `zoom_fit` | `chart.overview` |
|
||||
| 框选 | `box_zoom` | `chart.box_zoom` |
|
||||
| 返回最新 | `latest` | `chart.return_latest` |
|
||||
|
||||
- **符号不替代语义**:每个按钮都带 tooltip 与 `accessibleName`,`ui_check.py` 的
|
||||
`chart header and unit chip` 段逐项断言 `text()==""` 且这三样齐全——只有符号没有说明
|
||||
等于把动作藏起来。全程总览**区域名**(`window_caption`)仍是文字,它标注的是一块区域。
|
||||
- **选区统计在数值卡上,不在下方**:框选/缩放出一段窗口后,每张卡的大号数字变成该窗口的
|
||||
**峰值**、下方小字是该窗口的**均值 · σ**(`chart.selection_detail`),卡片的 tooltip 说明
|
||||
大数字是峰值;曲线下方只剩「选区 4.13 s / 183 点」(完整的一行仍在它的 tooltip 里)。
|
||||
以前把三通道的峰/均/σ 塞在一行 `QLabel` 里,中文长句直接把面板顶出右边界。
|
||||
窗口等于整个会话(「全程总览」)不算选区——那是默认适配,此时卡片回到"游标处样本"。
|
||||
- **单位芯片是可点的按钮**(`QPushButton#unitBadge`,不再是 `QLabel`):点一下就切换
|
||||
`N` ⇄ `raw`,同一条命令(`_set_display_unit`)同时改曲线、数值卡与设置区下拉,三处永远
|
||||
一致。旧写法只有「raw/N」两个字,看起来像只读状态。
|
||||
- 箭头图页脚的放大倍率写成「箭头按位移放大 ×2」,不再只有一个「×2」。
|
||||
|
||||
### 状态栏是一条通知行,不是第二条状态行
|
||||
|
||||
底部 `QStatusBar` 只承载**一次性通知**(录制结束/失败、标定已应用、连接慢、错误原因):
|
||||
|
||||
- **没有常驻控件**。以前右下角有一条 `配置 sensor.json`(`status.device_label`),而状态轨
|
||||
的传感器摘要按钮已经写着 `MINI · sensor.json`,右下角是同一件事实的第二遍。
|
||||
- **消息会过期**(`_NoticeBar.DEFAULT_TIMEOUT_MS` = 6 s)。Qt 的 `showMessage(text)` 默认
|
||||
`msecs=0`,意思是"永不消失",于是会话刚开始那句「运行中(0)」一直挂到退出;只读状态
|
||||
的读法应该在顶栏(阶段芯片 + 来源槽),所以子类把"没有超时"一律换成 6 秒。
|
||||
- **与状态轨重复的两条提示被删掉**:`status.run_started`(「运行中(0)」)与
|
||||
`status.connecting`(「正在连接…」)在轨上分别有「采集中」芯片与相机编号、以及
|
||||
「连接中」芯片。
|
||||
- **逐帧重复的通告只发一次**:`status.units_raw_fallback`(无标定 → 显示原始单位)以前每
|
||||
一帧都发一次,等于永久占住通知行;现在每次运行只提示一次。
|
||||
|
||||
### 单位切换是"开关",不是"重新解释"
|
||||
|
||||
曲线缓冲区里的每个数都带着它被测量时的单位。设备在头几帧还没确认标定时,显示会退到
|
||||
raw;标定一到位就切到 N —— 这一次切换**丢弃**缓冲区里的旧单位样本(会话存储仍然保留
|
||||
它们,`RecordingCurveSource` / `SessionStore` 可重建),而不是把 raw 计数画到牛顿轴上。
|
||||
旧写法只把 `_unit` 记成新单位、样本原样留着,于是每次开始采集的第一个画面都是一根
|
||||
~1e5 的尖峰。触发切换的那一帧本身仍然会刷新数值卡(早期实现里它被 `return` 吃掉了),
|
||||
而浏览/回放时卡片改为按新单位重读游标处的样本。
|
||||
|
||||
同样地,`_start_run` 现在**先** `session_store.reset()` 再 `force_plots.set_store(...)`:
|
||||
反过来的话新会话开局会带着上一次会话的尾巴。
|
||||
|
||||
切换单位后(`set_units`)立即按新单位重读游标处的样本,因为浏览/回放时数值卡说的是游标,
|
||||
不是实时样本——否则换一下单位,卡片会短暂显示上一次实时读数。
|
||||
|
||||
---
|
||||
|
||||
## 3. 关闭流程:窗口关掉后进程必须回到命令行
|
||||
|
||||
关闭顺序(`StudioMainWindow.closeEvent` → `main()`):
|
||||
|
||||
1. `closeEvent`:
|
||||
- 先摘掉记录通道的 sink,避免正在收尾的采集线程继续持有窗口;
|
||||
- `_stop_run(reason="user_stop", wait=True)`:阻塞地释放设备;
|
||||
- `_stop_recording(wait=True)`:写出 `session.json` 并关闭 MP4/CSV;
|
||||
- `_persist_prefs()`:保存窗口几何、分割比例、视图与各项开关。
|
||||
2. `app.exec_()` 返回后,`main()` **立刻结束进程**:
|
||||
|
||||
```python
|
||||
code = int(app.exec_())
|
||||
logging.shutdown()
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
stream.flush()
|
||||
except Exception:
|
||||
pass
|
||||
os._exit(code)
|
||||
```
|
||||
|
||||
**为什么用 `os._exit`**:相机 SDK 与 OpenCV 各自持有原生线程与句柄,正常返回会在它们之上
|
||||
再跑一遍解释器收尾(模块销毁、DLL 卸载),进程就卡在那里——症状正是"命令行不会完全退出"。
|
||||
所有需要落盘的东西在 `closeEvent` 与 `logging.shutdown()` 里已经写完,这里跳过的只是
|
||||
"已经没有东西可写的库"的析构。
|
||||
|
||||
`tools/ui_check.py` 的 `clean shutdown` 段在子进程里跑真实的 `main()`,用定时器关掉窗口
|
||||
并断言进程在 45 秒内以 0 返回(实测约 2 秒),所以这条路径有回归保护。
|
||||
|
||||
---
|
||||
|
||||
## 4. 复核方式
|
||||
|
||||
```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\studio\src"
|
||||
cd D:\orisys_ws\orisys-app\apps\studio
|
||||
|
||||
& $py tools/ui_check.py # 含 top area / views / instrument panels / replay / clean shutdown
|
||||
& $py tools/ui_snapshot.py --demo --scene replay --out outputs/_snap_replay.png
|
||||
& $py tools/ui_snapshot.py --demo --preset calibration --out outputs/_snap_calibration.png
|
||||
```
|
||||
@@ -0,0 +1,273 @@
|
||||
# Orisys Studio 配色规范
|
||||
|
||||
本文件记录客户端的品牌配色体系、它与 UI 设计稿的关系,以及每一处有意偏离设计稿
|
||||
的理由。所有色值都存放在主题令牌文件里,控件代码中**不允许出现十六进制颜色**:
|
||||
|
||||
```
|
||||
orisys-app/shared/src/orisys_app_shared/theme/colors_dark.json
|
||||
orisys-app/shared/src/orisys_app_shared/theme/colors_light.json
|
||||
```
|
||||
|
||||
规则由 `apps/studio/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) |
|
||||
|
||||
### 1.1 三轴分类色(蓝 / 橙 / 绿)
|
||||
|
||||
界面改版把三个力通道统一成"蓝 = 法向、橙 = 切向 X、绿 = 切向 Y"。它们是
|
||||
**分类色**(三条曲线各自一个身份),与 §4 的箭头色带(顺序色带)是两件事,因此落在
|
||||
`plot_fn` / `plot_fx` / `plot_fy` 三个令牌上。这三个令牌同时被实时曲线、录制回放、
|
||||
标定面板与标定点详情对话框引用,换值即全局统一。
|
||||
|
||||
| 主题 | `plot_fn`(法向,蓝) | `plot_fx`(切向 X,橙) | `plot_fy`(切向 Y,绿) |
|
||||
|---|---|---|---|
|
||||
| 深色 | `#6FA8FF` | `#FF9A4D` | `#7CE0A0` |
|
||||
| 浅色 | `#003893`(品牌藏青,见 §3.1) | `#B45309` | `#2E7D32` |
|
||||
|
||||
浅色主题的 FN 保持品牌藏青 `#003893`(与 `accent` 同值),这样品牌蓝在三轴里仍是
|
||||
"主通道";橙色与绿色在浅底上取深色相,才够对比度。深色主题的霓虹洋红/柠檬绿被替换,
|
||||
原因是它们是**色带**用色:作为分类色时洋红与橙的色相差偏小,而柠檬绿在浅底上不可用
|
||||
(§2.1)。替换后三个通道在灰度打印下也靠亮度分开,不再依赖单一色相。
|
||||
|
||||
---
|
||||
|
||||
## 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 / 4.80 / 4.90,
|
||||
`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` 对 `plot_bg` 分别 7.53 / 8.62 / 11.25,`focus_ring` 对 `input_bg` 6.51。
|
||||
|
||||
语义色(`danger` / `warning` / `success` / `status_*`)额外对 `toolbar_bg` 审计:它们会
|
||||
出现在顶栏状态行上(记录状态、数据有效性、阶段指示),实测浅色 4.65–6.66、深色 5.60–9.13,
|
||||
全部 ≥4.5。在这一步之前它们只在 `panel_bg` / `card_bg` 上被检查,顶栏是一个未经审计的底色。
|
||||
|
||||
三通道可分辨性(色相差 / 相对亮度比):
|
||||
|
||||
| 主题 | FN vs FX | FN vs FY | FX vs FY |
|
||||
|---|---|---|---|
|
||||
| 浅色 | 168.8° / 2.10 | 94.1° / 2.06 | 97.1° / 1.02 |
|
||||
| 深色 | 169.7° / 1.14 | 74.7° / 1.49 | 115.6° / 1.31 |
|
||||
|
||||
规则是"色相差 ≥90° **或** 相对亮度比 ≥1.2",两主题均满足(深色 FN vs FY 靠 1.49 的亮度比
|
||||
过关,浅色 FX vs FY 靠 97.1° 的色相差过关)。颜色在任何情况下都不是唯一编码:每个曲线有
|
||||
文字标签,每张数值卡有同色通道徽标与通道名。
|
||||
|
||||
---
|
||||
|
||||
## 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. 顶栏:一件仪器只有一条读数线
|
||||
|
||||
顶部由两条细轨组成,合计在 1440×880 下不超过 78 px(`ui_check.py` 的 `top area` 段与
|
||||
`tests/test_ui_smoke.py::test_top_area_stays_compact` 都断言这一点):
|
||||
|
||||
| 轨 | 高度 | 承载 |
|
||||
|---|---|---|
|
||||
| 品牌轨(`QFrame#brandBar`) | 28 px | logo + 字标 + 三个顶层视图(实时监测 / 历史回放 / 标定)+ 右侧产品名 |
|
||||
| 状态轨(`ToolbarWidget` 的 rail) | ~30 px | **传感器摘要按钮**、阶段、**当前视图的来源选择**(摄像机编号 + 搜索 / 使用摄像机)、标定状态、数据有效性、记录状态、记录/运行/重置/设置 |
|
||||
| 设置区(默认收起) | 0 / ~40 px | 单位、记录模式、预览 |
|
||||
| ~~回放条~~(已取消) | 0 px | 回放视图与实时同布局:运输控制就是状态轨的主按钮(实时=运行,回放=回放/暂停) |
|
||||
|
||||
四点规则:
|
||||
|
||||
1. **传感器区是一个摘要按钮,不是两个下拉**。轨上只有 `BOX · box/sensor.json`;类型、配置
|
||||
与「当前生效的标定 / 最近导出的系数」都在它打开的 `SensorDialog` 里(见
|
||||
`docs/shell-lifecycle.md` §1.2)。摘要按钮属于状态轨,因此文本必须完整显示(见第 5 条)。
|
||||
2. **来源选择随视图**:实时视图显示摄像机编号 + 搜索;回放视图显示「选择录制视频…」,视频名
|
||||
写在来源摘要里(只在回放视图)。没有「来源」下拉,因此没有"先选来源才能用"的状态。
|
||||
3. **导航按钮在品牌底上取品牌前景色**。`navButton` 用 `brand_bar_fg_muted`,当前视图用
|
||||
`brand_bar_fg` + `navActive="true"`;两者都在 §2 的品牌底对比度审计里(7.67 / 12.94),
|
||||
不新增色值。
|
||||
4. **状态轨上的语义色一律算在 `toolbar_bg` 上审计**。记录状态(`status_idle` /
|
||||
`status_running` / `status_connecting` / `status_error`)、丢帧提示(`warning`)与数据
|
||||
有效性(`success` / `warning`)此前只检查 `panel_bg`,而它们实际画在顶栏底色上。
|
||||
5. **同一时刻只有一个实心按钮**(`ToolbarWidget._refresh_emphasis`):
|
||||
|
||||
| 状态 | 实心 | 描边 |
|
||||
|---|---|---|
|
||||
| 空闲 | `运行`(`primaryButton`) | — |
|
||||
| 连接中 | `取消连接`(`dangerButton`) | — |
|
||||
| 采集中 | `开始记录`(`primaryButton`) | `停止`(`dangerOutline`) |
|
||||
| 记录中 | `停止记录`(`dangerButton`) | `停止`(`dangerOutline`) |
|
||||
|
||||
`dangerOutline`:透明底 + `danger` 文字与描边,hover/pressed 才填充 `danger_fill`;
|
||||
`:checked`(即"采集正在跑")仍保持描边——否则一圈红边会在会话开始后悄悄变成整块红色。
|
||||
|
||||
6. **状态轨上的事实不得被压缩**(`ui_check.rail_squeezed`)。中文、英文、1280 与 1440
|
||||
四种组合下,轨上的按钮与下拉都必须显示完整文本;只有带 tooltip 的 `ShrinkableLabel`
|
||||
允许在窗口极窄时被裁切。这也是"记录模式"下拉被挪进设置区的原因:英文的
|
||||
`Start recording` + `Sensor config` 与它挤在同一行时,第一个被压缩的就是按钮文字。
|
||||
|
||||
7. **曲线表头只有符号,符号的颜色仍来自令牌**。全程总览 / 框选 / 返回最新三个按钮是
|
||||
纯图标(`QPushButton[iconOnly="true"]`),图标由 `ui/icons.py` 用 `text_secondary`
|
||||
实时绘制、切主题时按新令牌重画,因此配色审计的结论对它们同样成立,也不需要新增色值。
|
||||
单位芯片 `QPushButton#unitBadge` 用 `card_bg` 底 + `card_border` 描边 + `text_secondary`
|
||||
文字,hover 才换成 `text_primary` 文字与 `accent` 描边,它是"可点的状态"而不是实心主
|
||||
按钮,所以不参与 §6.5 的"同一时刻只有一个实心按钮"计数(`QLabel#unitBadge` 仍是通道
|
||||
徽标与传感器缩略图的样式,两者靠控件类型区分)。
|
||||
|
||||
8. **数值卡的小字用等宽体,且不会把面板顶宽**。选区统计(均值 · σ)画在数值卡内的 9pt
|
||||
等宽小字上,与 16pt 的大号数字同一列;底部状态栏不再出现长句,因此曲线面板的宽度由
|
||||
三个数值卡决定,而不是由一行统计文字决定。`ui_check.py` 的 `session browsing` 段断言
|
||||
该小字 `width() >= sizeHint().width()`(中文与英文都不裁切)。
|
||||
|
||||
9. **两个视图共用一条状态轨**:来源槽是唯一的差别(实时=摄像机、回放=「选择录制视频…」),
|
||||
因此轨上的文字宽度预算对两个视图是同一个。回放的视频名写在来源摘要里,**只在回放视图**
|
||||
(`_refresh_status_rail` 按 `_view_preset` 决定),中英与 1280/1440 都由
|
||||
`ui_check.rail_squeezed` 覆盖。
|
||||
|
||||
---
|
||||
|
||||
## 7. 三个顶层视图 = 同一窗口的三套布局
|
||||
|
||||
导航不再打开对话框,而是切换主窗口的布局(`StudioMainWindow._apply_view_preset`):
|
||||
|
||||
| 视图 | 面板 | 曲线模式 | 分割比例(3D, 2D, 曲线, 标定) |
|
||||
|---|---|---|---|
|
||||
| 实时监测 | 2D 图像 + 力曲线(+ 手指三维) | 跟随实时 | `0, 360, 1080, 0` |
|
||||
| 历史回放 | 2D 图像 + 力曲线 | 读打开的录制(全程浏览) | `0, 360, 1080, 0`(与实时相同) |
|
||||
| 标定 | 标定面板 + 力曲线 | 不变 | `0, 0, 560, 880` |
|
||||
| 自由(`custom`,启动默认) | 由视图菜单决定 | 不变 | 用户在分割条上摆的位置 |
|
||||
|
||||
- 标定视图给标定面板 880 px:六列诊断表头与九个动作按钮不再被挤压;标定面板自身还有
|
||||
纵向滚动区,窗口再矮也够得到「应用到设备」。
|
||||
- 标定面板列宽改为 `ResizeToContents`:诊断列头宁可让表格横向滚动,也不截成
|
||||
「FN 均」这种看不懂的缩写。
|
||||
- 2D 图像区域固定为所显示图像的宽高比(`AspectImageLabel`)。横向分割器**不会**向子控件
|
||||
询问 `heightForWidth`,所以宽高比由 `ArrowPanel.resizeEvent` 主动施加;box 传感器的
|
||||
画面旋转 90° 显示,因此其显示宽高比是 9:16 而不是 16:9。
|
||||
- `视图 → 标定面板` 与「标定」导航是同一个开关:勾上进入标定视图,取消回到实时视图,
|
||||
不会留下"面板已隐藏但导航还亮着"的矛盾状态。
|
||||
|
||||
---
|
||||
|
||||
## 8. 复核方式
|
||||
|
||||
```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\studio\src"
|
||||
cd D:\orisys_ws\orisys-app\apps\studio
|
||||
|
||||
& $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
|
||||
& $py tools/ui_snapshot.py --theme dark --lang zh_CN --demo --scene recording --out outputs/_snap_recording.png
|
||||
& $py tools/ui_snapshot.py --theme dark --lang zh_CN --demo --preset calibration --out outputs/_snap_calibration.png
|
||||
```
|
||||
|
||||
视图切换与关闭流程另见 `apps/studio/docs/shell-lifecycle.md`。
|
||||
|
||||
@@ -3,9 +3,9 @@ requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "orisys-customer"
|
||||
name = "orisys-studio"
|
||||
version = "0.1.0"
|
||||
description = "Orisys customer desktop app"
|
||||
description = "Orisys Studio desktop app"
|
||||
requires-python = ">=3.10"
|
||||
authors = [{ name = "Orisys-Tech" }]
|
||||
dependencies = [
|
||||
@@ -17,7 +17,7 @@ dependencies = [
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
orisys-customer = "orisys_customer.main:main"
|
||||
orisys-studio = "orisys_studio.main:main"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = { "" = "src" }
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Launch the Orisys customer app shell."""
|
||||
"""Launch the Orisys Studio app shell."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -18,7 +18,7 @@ import os
|
||||
|
||||
os.chdir(ROOT)
|
||||
|
||||
from orisys_customer.main import main
|
||||
from orisys_studio.main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
"""Orisys customer desktop application."""
|
||||
"""Orisys Studio desktop application."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
REQUIRES_SHARED_API = "0.1"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,130 @@
|
||||
"""Calibration service: the panel's operations, without the panel.
|
||||
|
||||
The panel shows a table and emits status messages; everything that touches the
|
||||
file system or a device identity happens here:
|
||||
|
||||
* preparing the session once (store, diagnostics and export share one model);
|
||||
* saving the audit record, reopening one, and exporting the coefficients;
|
||||
* keeping a reopened record's **own** device identity instead of restamping it
|
||||
with whatever device happens to be connected.
|
||||
|
||||
This is deliberately Qt-free so it can be tested without a display.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from orisys_app_shared.force.calibration_io import (
|
||||
CHANNEL_KEYS,
|
||||
CalSessionStore,
|
||||
export_force_calibration_v2,
|
||||
meta_from_sensor_json,
|
||||
)
|
||||
from orisys_app_shared.force.calibration_session import (
|
||||
CalibrationSessionRecord,
|
||||
read_calibration_session,
|
||||
session_filename,
|
||||
write_calibration_session,
|
||||
)
|
||||
from orisys_app_shared.paths import calibration_export_dir
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CalibrationService:
|
||||
"""Owns the calibration session's files and its prepared model."""
|
||||
|
||||
def __init__(self, *, store: Optional[CalSessionStore] = None) -> None:
|
||||
self.store = store or CalSessionStore()
|
||||
#: The one prepared model the panel displays and exports.
|
||||
self.prepared: Any = None
|
||||
#: Device identity of a reopened record, while it is open.
|
||||
self.record_identity: Optional[dict] = None
|
||||
#: The record currently open, if any.
|
||||
self.open_record: Optional[CalibrationSessionRecord] = None
|
||||
|
||||
# --------------------------------------------------------------- identity
|
||||
def export_meta(
|
||||
self,
|
||||
*,
|
||||
sensor_type: str,
|
||||
config_ref: str | None,
|
||||
sn: str = "",
|
||||
duration_s: float = 0.0,
|
||||
) -> dict:
|
||||
"""The identity/pipeline metadata an export would carry.
|
||||
|
||||
While a saved record is open its **own** device identity wins: reopening
|
||||
device A's calibration on a machine connected to device B must keep saying
|
||||
A, or the evidence is silently restamped with B's serial number.
|
||||
"""
|
||||
meta = meta_from_sensor_json(config_ref)
|
||||
meta["sensor_type"] = str(sensor_type or "box")
|
||||
meta["sn"] = str(sn or "")
|
||||
meta["record_duration_s"] = float(duration_s or 0.0)
|
||||
identity = self.record_identity
|
||||
if identity:
|
||||
meta["sensor_type"] = identity.get("sensor_type") or meta["sensor_type"]
|
||||
meta["sn"] = identity.get("sn") or meta["sn"]
|
||||
meta["config_ref"] = identity.get("config_ref") or meta.get("config_ref")
|
||||
meta["source_record"] = str(identity.get("path") or "")
|
||||
meta["source_created_at"] = str(identity.get("created_at") or "")
|
||||
return meta
|
||||
|
||||
def clear_record_identity(self) -> None:
|
||||
"""A new run is the live device's context again."""
|
||||
self.record_identity = None
|
||||
self.open_record = None
|
||||
|
||||
# ------------------------------------------------------------ preparation
|
||||
def prepare(self, recorder, meta: dict) -> Any:
|
||||
"""Prepare the session once: store, diagnostics and export share it."""
|
||||
self.prepared = None
|
||||
self.store.clear()
|
||||
if recorder is None:
|
||||
return None
|
||||
prepared = recorder.prepared(meta=meta)
|
||||
self.prepared = prepared
|
||||
for key in CHANNEL_KEYS:
|
||||
self.store.set_channel(key, prepared.channel_samples(key))
|
||||
# The residuals belong to the points, because the table shows them.
|
||||
for point in recorder.all_points():
|
||||
value = prepared.residuals.get(id(point))
|
||||
point.residual_n = float("nan") if value is None else float(value)
|
||||
return prepared
|
||||
|
||||
# ------------------------------------------------------------------- files
|
||||
def save_record(self, recorder, meta: dict) -> Path:
|
||||
"""Write the audit record. Returns the written path."""
|
||||
points = recorder.all_points()
|
||||
path = calibration_export_dir() / session_filename(meta.get("sensor_type"))
|
||||
return write_calibration_session(path, points, meta=meta)
|
||||
|
||||
def load_record(self, path: str | Path) -> CalibrationSessionRecord:
|
||||
"""Reopen a saved session and remember whose record it is."""
|
||||
record = read_calibration_session(path)
|
||||
identity = record.identity()
|
||||
self.record_identity = {**identity, "path": str(record.path)}
|
||||
self.open_record = record
|
||||
return record
|
||||
|
||||
def export_coefficients(
|
||||
self,
|
||||
*,
|
||||
sensor_type: str,
|
||||
export_dir: str | Path | None = None,
|
||||
local_bundle_dir: str | Path | None = None,
|
||||
) -> tuple[Path, Path | None, list[str]]:
|
||||
"""Serialise the prepared model. Raises ValueError when there is none."""
|
||||
if self.prepared is None or not getattr(self.prepared, "ok", False):
|
||||
warnings = list(getattr(self.prepared, "warnings", []) or [])
|
||||
raise ValueError("; ".join(warnings) or "no complete channels")
|
||||
return export_force_calibration_v2(
|
||||
self.prepared,
|
||||
export_dir=export_dir or calibration_export_dir(),
|
||||
sensor_type=str(sensor_type or "box"),
|
||||
local_bundle_dir=local_bundle_dir,
|
||||
)
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Customer app entrypoint."""
|
||||
"""Studio app entrypoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
@@ -15,14 +16,14 @@ 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
|
||||
from orisys_studio import REQUIRES_SHARED_API, __version__ as app_version
|
||||
from orisys_studio.app_window import StudioMainWindow
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
init_logging("orisys-customer")
|
||||
init_logging("orisys-studio")
|
||||
install_exception_hooks()
|
||||
preferred = prefer_local_sensor_bundles()
|
||||
if preferred is not None:
|
||||
@@ -42,14 +43,31 @@ def main() -> int:
|
||||
set_locale(language)
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("Orisys Customer")
|
||||
app.setApplicationName("Orisys Studio")
|
||||
app.setApplicationVersion(app_version)
|
||||
app.setStyleSheet(build_qss(theme))
|
||||
|
||||
window = CustomerMainWindow(prefs=prefs)
|
||||
window = StudioMainWindow(prefs=prefs)
|
||||
window.show()
|
||||
logger.info("Customer shell started (theme=%s, language=%s)", theme, language)
|
||||
return int(app.exec_())
|
||||
logger.info("Studio shell started (theme=%s, language=%s)", theme, language)
|
||||
code = int(app.exec_())
|
||||
|
||||
# Leave the process immediately.
|
||||
#
|
||||
# The device SDK and OpenCV keep *native* threads and handles of their own;
|
||||
# a normal return would run interpreter shutdown on top of them, and the
|
||||
# process then sat there after the window closed ("the console never came
|
||||
# back"). Everything that must be durable was already flushed by
|
||||
# ``StudioMainWindow.closeEvent`` and by the log handlers below, so the only
|
||||
# thing skipped here is teardown of libraries that no longer have anything
|
||||
# to write.
|
||||
logging.shutdown()
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
try:
|
||||
stream.flush()
|
||||
except Exception: # noqa: BLE001 - a closed stream must not block exit
|
||||
pass
|
||||
os._exit(code)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -0,0 +1,267 @@
|
||||
"""The record channel: one experiment directory, one session store, one sampler.
|
||||
|
||||
This is the coordination the main window used to do inline — and it is deliberately
|
||||
free of Qt. Three consumers are fed from the same acquisition packet:
|
||||
|
||||
* the :class:`ExperimentRecorder`, which writes the directory;
|
||||
* the whole-session store the charts and the browser read;
|
||||
* the calibration sampler (``CalibrationRecorder``), whose lifecycle must not
|
||||
depend on the calibration panel being visible.
|
||||
|
||||
The window keeps the buttons, the menus and the status messages; the decisions
|
||||
about *what* is recorded, *how* it is named and *what actually happened* live
|
||||
here, where they can be tested without a display.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from orisys_app_shared.record import ExperimentRecorder, RecordConfig, RecordReport
|
||||
from orisys_app_shared.record.calibration_snapshot import store_calibration_snapshot
|
||||
from orisys_app_shared.record.sample import to_record_sample
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RecordingCoordinator:
|
||||
"""Owns the recorder and the per-packet fan-out."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
session_store,
|
||||
cal_recorder,
|
||||
recorder: Optional[ExperimentRecorder] = None,
|
||||
) -> None:
|
||||
self.session_store = session_store
|
||||
self.cal_recorder = cal_recorder
|
||||
self.recorder = recorder or ExperimentRecorder()
|
||||
#: Confirmed calibration changes: ``(path, version, frame_id, mono_t, session)``.
|
||||
self._calibration_events: list[tuple[str, str, int, float, int]] = []
|
||||
#: Confirmed zeroings: ``(frame_id, mono_t, session)``.
|
||||
self._reset_events: list[tuple[int, float, int]] = []
|
||||
#: Raised by the writer thread; drained on the UI thread.
|
||||
self._alarms: list[str] = []
|
||||
#: A stop was requested and the writer has not closed its files yet.
|
||||
self.report_pending = False
|
||||
self.last_report: Optional[RecordReport] = None
|
||||
self._active_calibration_id = ""
|
||||
self._record_fps = 30.0
|
||||
self.recorder.set_alarm_handler(self._on_alarm)
|
||||
|
||||
# ------------------------------------------------------------- acquisition
|
||||
def on_packet(self, packet):
|
||||
"""Runs on the **acquisition thread**. Must stay cheap and Qt-free.
|
||||
|
||||
The packet is converted exactly once, by the same function the CSV writer
|
||||
uses, so the store and the file can never disagree about whether a value
|
||||
is a measurement. Returns the converted record, which the caller may use
|
||||
for its own (UI-side) bookkeeping.
|
||||
"""
|
||||
record = to_record_sample(
|
||||
packet, calibration_version=self._active_calibration_id
|
||||
)
|
||||
from orisys_app_shared.force.session import SessionSample
|
||||
|
||||
sample = SessionSample.from_record(record)
|
||||
self.session_store.append(sample)
|
||||
self.cal_recorder.feed(sample)
|
||||
# Video + CSV + metadata share this one record path.
|
||||
self.recorder.on_packet(packet)
|
||||
fps = float(getattr(packet, "fps", 0.0) or 0.0)
|
||||
if fps > 0.0:
|
||||
self._record_fps = fps
|
||||
return record
|
||||
|
||||
def take_preroll(self, worker) -> list:
|
||||
"""Frame history from the active worker, if it keeps one."""
|
||||
take = getattr(worker, "take_preroll", None)
|
||||
if not callable(take):
|
||||
return []
|
||||
try:
|
||||
return list(take())
|
||||
except Exception: # noqa: BLE001 - the history is optional, not the record
|
||||
logger.exception("Could not take the pre-roll buffer")
|
||||
return []
|
||||
|
||||
def set_active_calibration(self, version: str) -> None:
|
||||
"""The calibration the record should name until the device says otherwise."""
|
||||
self._active_calibration_id = str(version or "")
|
||||
|
||||
@property
|
||||
def active_calibration(self) -> str:
|
||||
return self._active_calibration_id
|
||||
|
||||
@property
|
||||
def record_fps(self) -> float:
|
||||
return self._record_fps
|
||||
|
||||
# ------------------------------------------------------------------ events
|
||||
def note_calibration_applied(
|
||||
self, path: str, version: str, frame_id: int, mono_t: float, session: int
|
||||
) -> None:
|
||||
"""Called from the acquisition thread once coefficients are loaded."""
|
||||
self._calibration_events.append(
|
||||
(str(path), str(version), int(frame_id), float(mono_t), int(session))
|
||||
)
|
||||
|
||||
def note_reset(self, frame_id: int, mono_t: float, session: int) -> None:
|
||||
"""Called from the acquisition thread once a zeroing took effect."""
|
||||
self._reset_events.append((int(frame_id), float(mono_t), int(session)))
|
||||
|
||||
def drain_calibration_events(self) -> list[dict]:
|
||||
"""Publish confirmed calibrations. Returns what the UI should show."""
|
||||
published: list[dict] = []
|
||||
while self._calibration_events:
|
||||
path, version, frame_id, mono_t, session = self._calibration_events.pop(0)
|
||||
# The version is the calibration's content hash: a path can be
|
||||
# re-exported with different coefficients, a hash cannot.
|
||||
self._active_calibration_id = version
|
||||
self.session_store.set_calibration_id(version)
|
||||
self.recorder.set_calibration_version(version)
|
||||
snapshot = self.snapshot_calibration(path, version, mono_t, frame_id)
|
||||
self.recorder.log_event(
|
||||
"calibration_applied",
|
||||
f"{path} ({version[:12] or 'unhashed'})"
|
||||
+ (f" -> {snapshot}" if snapshot else ""),
|
||||
mono_t=mono_t,
|
||||
frame_id=frame_id,
|
||||
)
|
||||
published.append(
|
||||
{
|
||||
"path": path,
|
||||
"version": version,
|
||||
"frame_id": frame_id,
|
||||
"mono_t": mono_t,
|
||||
"session": session,
|
||||
"snapshot": snapshot,
|
||||
}
|
||||
)
|
||||
return published
|
||||
|
||||
def drain_reset_events(self) -> list[dict]:
|
||||
"""Publish confirmed zeroings."""
|
||||
published: list[dict] = []
|
||||
while self._reset_events:
|
||||
frame_id, mono_t, session = self._reset_events.pop(0)
|
||||
self.recorder.log_event(
|
||||
"zeroing",
|
||||
"tracker reset (applied by the acquisition thread)",
|
||||
mono_t=mono_t,
|
||||
frame_id=frame_id,
|
||||
)
|
||||
published.append(
|
||||
{"frame_id": frame_id, "mono_t": mono_t, "session": session}
|
||||
)
|
||||
return published
|
||||
|
||||
def snapshot_calibration(
|
||||
self, path: str, version: str, mono_t: float, frame_id: int
|
||||
) -> str:
|
||||
"""Copy the applied coefficients into the recording. Returns its name."""
|
||||
directory = self.recorder.directory
|
||||
if directory is None or not path or not Path(path).is_file():
|
||||
return ""
|
||||
try:
|
||||
name = store_calibration_snapshot(directory, Path(path), version)
|
||||
except Exception: # noqa: BLE001 - the record continues without it
|
||||
logger.exception("Could not snapshot the applied calibration")
|
||||
return ""
|
||||
self.recorder.note_calibration_snapshot(
|
||||
version=version, name=name, frame_id=frame_id, mono_t=mono_t
|
||||
)
|
||||
return name
|
||||
|
||||
# ------------------------------------------------------------------ alarms
|
||||
def _on_alarm(self, message: str) -> None:
|
||||
"""Called from the writer thread. Queue it; never touch Qt here."""
|
||||
logger.error("Recording alarm: %s", message)
|
||||
self._alarms.append(str(message))
|
||||
|
||||
def drain_alarms(self) -> list[str]:
|
||||
drained, self._alarms = self._alarms, []
|
||||
return drained
|
||||
|
||||
# --------------------------------------------------------------- lifecycle
|
||||
def start(
|
||||
self,
|
||||
*,
|
||||
root: Path,
|
||||
config: RecordConfig,
|
||||
metadata: dict[str, Any],
|
||||
calibration_id: str = "",
|
||||
calibration_version: str = "",
|
||||
preroll=(),
|
||||
) -> Path:
|
||||
"""Start a recording. Raises when the directory cannot be created."""
|
||||
directory = self.recorder.start(
|
||||
root=root,
|
||||
config=config,
|
||||
session_id=int(self.session_store.session_id),
|
||||
metadata=metadata,
|
||||
calibration_id=calibration_id,
|
||||
calibration_version=calibration_version,
|
||||
preroll=preroll,
|
||||
)
|
||||
self.last_report = None
|
||||
self.report_pending = False
|
||||
return Path(directory)
|
||||
|
||||
def stop(self, *, wait: bool = True) -> Optional[RecordReport]:
|
||||
"""Stop and return the report, or ``None`` when nothing was started."""
|
||||
if self.recorder.directory is None and self.recorder.report is None:
|
||||
self.report_pending = False
|
||||
return None
|
||||
if self.recorder.is_recording:
|
||||
self.recorder.log_event("record_stopped", "")
|
||||
report = self.recorder.stop(wait=wait)
|
||||
self.publish(report)
|
||||
return report
|
||||
|
||||
def publish(self, report: Optional[RecordReport]) -> None:
|
||||
"""Record a report; a completed one becomes ``last_report``."""
|
||||
if report is None:
|
||||
return
|
||||
if not report.completed:
|
||||
# The writer is still closing; the UI timer publishes the real one.
|
||||
self.report_pending = True
|
||||
return
|
||||
self.report_pending = False
|
||||
self.last_report = report
|
||||
|
||||
def poll(self) -> Optional[RecordReport]:
|
||||
"""The terminal report, once the writer has actually closed the files."""
|
||||
if not self.report_pending:
|
||||
return None
|
||||
report = self.recorder.report
|
||||
if report is None:
|
||||
return None
|
||||
self.publish(report)
|
||||
return report
|
||||
|
||||
@property
|
||||
def is_recording(self) -> bool:
|
||||
return self.recorder.is_recording
|
||||
|
||||
@property
|
||||
def directory(self):
|
||||
return self.recorder.directory
|
||||
|
||||
|
||||
def record_config_from_prefs(
|
||||
prefs: dict,
|
||||
*,
|
||||
mode: str,
|
||||
fps: float,
|
||||
on_problem: Optional[Callable[[str], None]] = None,
|
||||
) -> RecordConfig:
|
||||
"""Build the record config the toolbar/prefs describe."""
|
||||
return RecordConfig(
|
||||
video=(mode == "video_data"),
|
||||
lossless_frames=bool(prefs.get("record_lossless_frames", False)),
|
||||
fps=float(fps),
|
||||
segment_seconds=float(prefs.get("record_segment_seconds", 300.0)),
|
||||
)
|
||||
@@ -0,0 +1,216 @@
|
||||
"""Replay engine: one recording, one cursor, one frame at a time.
|
||||
|
||||
This is the headless half of the replay view. It owns the recording reader and
|
||||
the cursor and it *shows* nothing: the main window renders the frame into the 2D
|
||||
panel, the curves read the recording through
|
||||
:class:`~orisys_app_shared.force.source.RecordingCurveSource`, and the transport
|
||||
on the chart header drives :meth:`set_position`.
|
||||
|
||||
There is no depth recomputation here: depth is not stored with a recording, the
|
||||
replay view shows the recorded frames, and re-running the pipeline over a video
|
||||
is the acquisition path's job (see the studio's "recompute" mode).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
from PyQt5.QtCore import QObject, pyqtSignal
|
||||
from PyQt5.QtGui import QImage
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.record import RecordingReader
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _bgr_to_qimage(frame: np.ndarray) -> QImage:
|
||||
rgb = frame[:, :, ::-1].copy()
|
||||
height, width, channels = rgb.shape
|
||||
return QImage(rgb.data, width, height, channels * width, QImage.Format_RGB888).copy()
|
||||
|
||||
|
||||
def _sdk_version() -> str:
|
||||
"""The SDK version in use, or "" when the SDK is not importable."""
|
||||
try:
|
||||
import orisys
|
||||
|
||||
return str(getattr(orisys, "__version__", "") or "")
|
||||
except Exception: # noqa: BLE001 - a missing SDK is a warning, not a crash
|
||||
logger.debug("Could not read the SDK version", exc_info=True)
|
||||
return ""
|
||||
|
||||
|
||||
class ReplayController(QObject):
|
||||
"""Open one recording and answer "what is at t?" for every view."""
|
||||
|
||||
#: The cursor moved; the chart cursor and the 2D panel follow.
|
||||
position_changed = pyqtSignal(float)
|
||||
#: The frame under the cursor changed.
|
||||
frames_changed = pyqtSignal()
|
||||
#: The notes about this recording, or the loaded recording, changed.
|
||||
status_changed = pyqtSignal()
|
||||
#: A recording was opened (True) or the previous one was dropped (False).
|
||||
recording_changed = pyqtSignal(bool)
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self._reader: RecordingReader | None = None
|
||||
self._unit = "raw"
|
||||
self._position = 0.0
|
||||
#: The frame cache: one video image per cursor position.
|
||||
self._video_position: float | None = None
|
||||
self._video_qimage: QImage | None = None
|
||||
self._video_reason = ""
|
||||
self._warnings = ""
|
||||
|
||||
# ------------------------------------------------------------- recording
|
||||
def is_loaded(self) -> bool:
|
||||
return self._reader is not None
|
||||
|
||||
def directory(self):
|
||||
return self._reader.directory if self._reader is not None else None
|
||||
|
||||
def reader(self):
|
||||
return self._reader
|
||||
|
||||
def info(self):
|
||||
return self._reader.info() if self._reader is not None else None
|
||||
|
||||
def source(self):
|
||||
"""The curve source for the charts, or None when nothing is open."""
|
||||
if self._reader is None:
|
||||
return None
|
||||
from orisys_app_shared.force.source import RecordingCurveSource
|
||||
|
||||
return RecordingCurveSource(self._reader)
|
||||
|
||||
def open_recording(self, directory) -> bool:
|
||||
"""Open a recording directory."""
|
||||
self._release_reader()
|
||||
try:
|
||||
self._reader = RecordingReader(directory)
|
||||
self._reader.info()
|
||||
except Exception: # noqa: BLE001 - reported through the status text
|
||||
logger.exception("Could not open recording %s", directory)
|
||||
self._reader = None
|
||||
self._reset_frames()
|
||||
self.recording_changed.emit(False)
|
||||
self.status_changed.emit()
|
||||
return False
|
||||
self._reset_frames()
|
||||
self._position = 0.0
|
||||
self.recording_changed.emit(True)
|
||||
self._refresh_warnings()
|
||||
self.set_position(0.0)
|
||||
return True
|
||||
|
||||
def close(self) -> None:
|
||||
"""Drop the recording (the decoder is released with it)."""
|
||||
self._release_reader()
|
||||
self._reset_frames()
|
||||
self.recording_changed.emit(False)
|
||||
self.status_changed.emit()
|
||||
|
||||
def _release_reader(self) -> None:
|
||||
reader, self._reader = self._reader, None
|
||||
if reader is not None:
|
||||
try:
|
||||
reader.close()
|
||||
except Exception: # noqa: BLE001 - releasing is best effort
|
||||
logger.debug("Could not close the recording reader", exc_info=True)
|
||||
|
||||
def _reset_frames(self) -> None:
|
||||
self._video_position = None
|
||||
self._video_qimage = None
|
||||
self._video_reason = ""
|
||||
|
||||
# ---------------------------------------------------------------- cursor
|
||||
def position(self) -> float:
|
||||
return self._position
|
||||
|
||||
def duration_s(self) -> float:
|
||||
if self._reader is None:
|
||||
return 0.0
|
||||
return float(self._reader.info().duration_s)
|
||||
|
||||
def set_position(self, t_rel_s: float) -> None:
|
||||
"""Move the single cursor: the video frame and the charts follow it."""
|
||||
if self._reader is None:
|
||||
return
|
||||
duration = self.duration_s()
|
||||
self._position = max(0.0, min(float(t_rel_s), duration if duration > 0 else 0.0))
|
||||
self._refresh_video()
|
||||
self.position_changed.emit(self._position)
|
||||
self.frames_changed.emit()
|
||||
|
||||
def unit(self) -> str:
|
||||
return self._unit
|
||||
|
||||
def set_unit(self, unit: str) -> str:
|
||||
"""Show the recording in ``unit``; the charts and the rail share one.
|
||||
|
||||
Opening a recording that carries no calibrated axis in newton mode would
|
||||
draw three empty charts, so the caller picks the unit from what the
|
||||
recording actually has and tells every view about it here.
|
||||
"""
|
||||
self._unit = "N" if str(unit).upper().startswith("N") else "raw"
|
||||
return self._unit
|
||||
|
||||
# ----------------------------------------------------------------- frames
|
||||
def video_image(self) -> QImage | None:
|
||||
self._refresh_video()
|
||||
return self._video_qimage
|
||||
|
||||
def video_message(self) -> str:
|
||||
self._refresh_video()
|
||||
return self._video_reason
|
||||
|
||||
def _refresh_video(self) -> None:
|
||||
if self._reader is None:
|
||||
self._video_position = None
|
||||
self._video_qimage = None
|
||||
self._video_reason = t("playback.none_found")
|
||||
return
|
||||
if self._video_position == self._position:
|
||||
return
|
||||
self._video_position = self._position
|
||||
image, frame = self._reader.video_frame_at(self._position)
|
||||
if image is None:
|
||||
self._video_qimage = None
|
||||
self._video_reason = (
|
||||
t("playback.frame_dropped")
|
||||
if frame is not None and not frame.in_video
|
||||
else t("playback.frame_unreadable")
|
||||
)
|
||||
return
|
||||
self._video_reason = ""
|
||||
self._video_qimage = _bgr_to_qimage(image)
|
||||
|
||||
# ---------------------------------------------------------------- notes
|
||||
def warnings_text(self) -> str:
|
||||
return self._warnings
|
||||
|
||||
def _refresh_warnings(self) -> None:
|
||||
self._warnings = ""
|
||||
if self._reader is None:
|
||||
return
|
||||
info = self._reader.info()
|
||||
codes = self._reader.replay_warnings(sdk_version=_sdk_version())
|
||||
text = ""
|
||||
if codes:
|
||||
text = t(
|
||||
"playback.warnings",
|
||||
detail=", ".join(t(f"playback.warn_{code}") for code in codes),
|
||||
)
|
||||
if info.decimated:
|
||||
# The curves are a view, not every sample: say so rather than let the
|
||||
# operator count points.
|
||||
note = t(
|
||||
"playback.decimated",
|
||||
shown=len(info.times),
|
||||
total=info.total_rows,
|
||||
)
|
||||
text = f"{text} {note}" if text else note
|
||||
self._warnings = text
|
||||
+1
-1
@@ -9,7 +9,7 @@ from PyQt5.QtWidgets import QDialog, QHBoxLayout, QLabel, QPushButton, QVBoxLayo
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import logo_topbar_path
|
||||
|
||||
from orisys_customer import __version__ as app_version
|
||||
from orisys_studio import __version__ as app_version
|
||||
|
||||
|
||||
class AboutDialog(QDialog):
|
||||
@@ -0,0 +1,426 @@
|
||||
"""2D image panel: arrows / depth view with mode selector."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtCore import QSize, Qt, pyqtSignal
|
||||
from PyQt5.QtGui import QImage, QPainter, QPixmap, QTransform
|
||||
from PyQt5.QtWidgets import (
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
from orisys_studio.ui.fit_text import ShrinkableLabel
|
||||
|
||||
VIEW_MODES = ("arrows_pure", "arrows_stitched", "depth")
|
||||
#: While replaying a recording there is no optical flow to draw and no depth is
|
||||
#: stored: the panel shows the recorded frames, and nothing else.
|
||||
REPLAY_VIEW_MODES = ("recorded",)
|
||||
_VIEW_LABELS = {
|
||||
"arrows_pure": "panels.view_arrows_pure",
|
||||
"arrows_stitched": "panels.view_arrows_stitched",
|
||||
"depth": "panels.view_depth",
|
||||
"recorded": "panels.view_recorded",
|
||||
}
|
||||
_ROTATION_STEPS = (0, 90, 180, 270)
|
||||
_COLORBAR_WIDTH = 120
|
||||
_COLORBAR_HEIGHT = 10
|
||||
#: Sensor output is 1920x1080; used until the first frame states its own shape.
|
||||
DEFAULT_FRAME_ASPECT = 16.0 / 9.0
|
||||
_MIN_ASPECT = 0.2
|
||||
_MAX_ASPECT = 5.0
|
||||
#: Header + footer + margins, i.e. what the image must leave room for.
|
||||
_IMAGE_CHROME_HEIGHT = 96
|
||||
#: Smallest usable image area. Kept as a constant because ``setFixedHeight``
|
||||
#: overwrites ``minimumHeight``, so the floor cannot be read back from the label.
|
||||
_IMAGE_MIN_HEIGHT = 120
|
||||
|
||||
|
||||
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 AspectImageLabel(QLabel):
|
||||
"""Image area whose aspect ratio follows the frame it displays.
|
||||
|
||||
A free-growing label turned a 16:9 frame into a tall letterbox with wide
|
||||
empty bands. Here the height is derived from the width and the frame's own
|
||||
aspect, so the 2D view always has the shape of the picture it shows.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self._aspect = DEFAULT_FRAME_ASPECT
|
||||
policy = QSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
|
||||
policy.setHeightForWidth(True)
|
||||
self.setSizePolicy(policy)
|
||||
self.setMinimumSize(200, _IMAGE_MIN_HEIGHT)
|
||||
|
||||
def aspect(self) -> float:
|
||||
return self._aspect
|
||||
|
||||
def set_aspect(self, aspect: float) -> None:
|
||||
try:
|
||||
value = float(aspect)
|
||||
except (TypeError, ValueError):
|
||||
return
|
||||
if not (_MIN_ASPECT <= value <= _MAX_ASPECT):
|
||||
return
|
||||
if abs(value - self._aspect) < 1e-6:
|
||||
return
|
||||
self._aspect = value
|
||||
self.updateGeometry()
|
||||
|
||||
def hasHeightForWidth(self) -> bool: # noqa: N802 - Qt naming
|
||||
return True
|
||||
|
||||
def heightForWidth(self, width: int) -> int: # noqa: N802 - Qt naming
|
||||
return max(1, int(round(int(width) / self._aspect)))
|
||||
|
||||
|
||||
class ColorBarLabel(QLabel):
|
||||
"""The depth view's colour strip, painted from the theme's flow ramp.
|
||||
|
||||
It is generated from the *same* lookup table ``depth_map_to_bgr`` uses, so
|
||||
the legend can never disagree with the picture it explains.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
||||
super().__init__(parent)
|
||||
self._theme = str(theme)
|
||||
self.setObjectName("colorBar")
|
||||
self.setFixedHeight(_COLORBAR_HEIGHT)
|
||||
self.setMinimumWidth(_COLORBAR_WIDTH)
|
||||
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
self.setVisible(False)
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
self._theme = str(theme)
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event) -> None: # noqa: N802 - Qt naming
|
||||
from orisys_app_shared.acquisition.arrows import flow_colormap
|
||||
|
||||
# (256, 1, 3) BGR -> one RGB scanline. The bytes object stays alive for
|
||||
# the whole call, which is what QImage's borrowed buffer needs.
|
||||
rgb = flow_colormap(self._theme)[:, 0, ::-1].copy().tobytes()
|
||||
image = QImage(rgb, 256, 1, 256 * 3, QImage.Format_RGB888)
|
||||
painter = QPainter(self)
|
||||
painter.drawImage(self.rect(), image)
|
||||
painter.end()
|
||||
|
||||
|
||||
class ArrowPanel(QFrame):
|
||||
view_mode_changed = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("contentPanel")
|
||||
self._theme = str(theme)
|
||||
layout = QVBoxLayout(self)
|
||||
|
||||
header = QHBoxLayout()
|
||||
self.title = QLabel()
|
||||
self.title.setObjectName("panelTitle")
|
||||
self._rotation_deg = 90
|
||||
self._rotation_enabled = True
|
||||
self._source_image: QImage | None = None
|
||||
self._depth_scale_text = ""
|
||||
self._arrow_scale = 2.0
|
||||
self._valid_area = False
|
||||
self._source_kind = "live"
|
||||
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.view_combo)
|
||||
layout.addLayout(header)
|
||||
|
||||
self.image_label = AspectImageLabel()
|
||||
self.image_label.setAlignment(Qt.AlignCenter)
|
||||
self.image_label.setScaledContents(False)
|
||||
self.image_label.setWordWrap(True)
|
||||
# The image keeps the frame's shape and is centred in whatever height is
|
||||
# left, instead of stretching to fill a panel with a different aspect.
|
||||
layout.addStretch(1)
|
||||
layout.addWidget(self.image_label)
|
||||
layout.addStretch(1)
|
||||
|
||||
layout.addLayout(self._build_footer())
|
||||
|
||||
self._placeholder = ""
|
||||
self._populate_view_combo()
|
||||
self.retranslate()
|
||||
self._refresh_footer_visibility()
|
||||
self._sync_aspect()
|
||||
|
||||
def _build_footer(self) -> QHBoxLayout:
|
||||
"""What the current view means, in one line under the canvas."""
|
||||
footer = QHBoxLayout()
|
||||
footer.setSpacing(10)
|
||||
|
||||
def annotation() -> QLabel:
|
||||
label = ShrinkableLabel()
|
||||
label.setObjectName("hintText")
|
||||
return label
|
||||
|
||||
self.scale_label = annotation()
|
||||
self.pipeline_label = annotation()
|
||||
self.area_label = annotation()
|
||||
# Shows the fixed depth colour-scale range, so two frames can be
|
||||
# compared instead of the scale silently re-fitting every frame.
|
||||
self.depth_scale_label = QLabel()
|
||||
self.depth_scale_label.setObjectName("panelMeta")
|
||||
self.depth_scale_label.setVisible(False)
|
||||
self.colorbar = ColorBarLabel(theme=self._theme)
|
||||
|
||||
footer.addWidget(self.scale_label)
|
||||
footer.addWidget(self.pipeline_label)
|
||||
footer.addWidget(self.area_label)
|
||||
footer.addStretch(1)
|
||||
footer.addWidget(self.colorbar)
|
||||
footer.addWidget(self.depth_scale_label)
|
||||
return footer
|
||||
|
||||
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)
|
||||
self._refresh_footer_visibility()
|
||||
|
||||
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._sync_aspect()
|
||||
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._sync_aspect()
|
||||
self._repaint_source()
|
||||
|
||||
def _sync_aspect(self) -> None:
|
||||
"""Re-derive the image area's shape from the frame (or from the default).
|
||||
|
||||
Rotating the picture swaps which side is long, so the area has to follow
|
||||
the rotation as well as the frame.
|
||||
"""
|
||||
image = self._source_image
|
||||
if image is not None and not image.isNull():
|
||||
self._set_display_aspect(image.width(), image.height())
|
||||
return
|
||||
self.image_label.set_aspect(DEFAULT_FRAME_ASPECT)
|
||||
if self._effective_rotation_deg() % 180:
|
||||
self.image_label.set_aspect(1.0 / DEFAULT_FRAME_ASPECT)
|
||||
self._sync_image_height()
|
||||
|
||||
def _set_display_aspect(self, frame_w: int, frame_h: int) -> None:
|
||||
if frame_w <= 0 or frame_h <= 0:
|
||||
return
|
||||
aspect = frame_w / frame_h
|
||||
if self._effective_rotation_deg() % 180:
|
||||
aspect = 1.0 / aspect
|
||||
self.image_label.set_aspect(aspect)
|
||||
self._sync_image_height()
|
||||
|
||||
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_arrow_scale(self, factor: float) -> None:
|
||||
"""Show how much the drawn arrows are magnified relative to displacement."""
|
||||
try:
|
||||
self._arrow_scale = float(factor)
|
||||
except (TypeError, ValueError):
|
||||
self._arrow_scale = 1.0
|
||||
self._refresh_scale_label()
|
||||
|
||||
def set_valid_area(self, present: bool) -> None:
|
||||
"""Say whether the flow grid is masked to the configured valid area."""
|
||||
self._valid_area = bool(present)
|
||||
self.area_label.setText(
|
||||
t("panels.arrow_area_masked") if self._valid_area else t("panels.arrow_area_full")
|
||||
)
|
||||
|
||||
def set_source_kind(self, kind: str) -> None:
|
||||
"""Switch between live frames and a recording being replayed.
|
||||
|
||||
A recording has no optical flow to draw, so the arrow entries disappear
|
||||
rather than offering a view that would be a lie.
|
||||
"""
|
||||
wanted = "replay" if str(kind) == "replay" else "live"
|
||||
if wanted == self._source_kind:
|
||||
return
|
||||
self._source_kind = wanted
|
||||
self._populate_view_combo()
|
||||
self._refresh_pipeline_label()
|
||||
self._refresh_footer_visibility()
|
||||
if not self.image_label.pixmap() or self.image_label.pixmap().isNull():
|
||||
self._placeholder = self._empty_message()
|
||||
self.image_label.setText(self._placeholder)
|
||||
|
||||
def source_kind(self) -> str:
|
||||
return self._source_kind
|
||||
|
||||
def view_modes(self) -> tuple[str, ...]:
|
||||
return REPLAY_VIEW_MODES if self._source_kind == "replay" else VIEW_MODES
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
self._theme = str(theme)
|
||||
self.colorbar.set_theme(self._theme)
|
||||
|
||||
def set_depth_scale(self, text: str | None) -> None:
|
||||
"""Show the fixed depth colour-scale range (``None`` hides it)."""
|
||||
self._depth_scale_text = str(text or "")
|
||||
self.depth_scale_label.setText(self._depth_scale_text)
|
||||
self.depth_scale_label.setToolTip(t("panels.depth_scale_tip"))
|
||||
self._refresh_footer_visibility()
|
||||
|
||||
def _refresh_scale_label(self) -> None:
|
||||
self.scale_label.setText(
|
||||
t("panels.arrow_scale", factor=f"{self._arrow_scale:g}")
|
||||
)
|
||||
self.scale_label.setToolTip(t("panels.arrow_scale_tip"))
|
||||
|
||||
def _refresh_footer_visibility(self) -> None:
|
||||
"""Arrow annotations and the depth legend are mutually exclusive."""
|
||||
depth = self.view_mode() == "depth"
|
||||
replay = self._source_kind == "replay"
|
||||
# The magnification and the valid area describe the live flow drawing;
|
||||
# neither exists for a recorded frame.
|
||||
self.scale_label.setVisible(not depth and not replay)
|
||||
self.pipeline_label.setVisible(not depth)
|
||||
self.area_label.setVisible(not depth and not replay)
|
||||
self.colorbar.setVisible(depth)
|
||||
self.depth_scale_label.setVisible(depth and bool(self._depth_scale_text))
|
||||
|
||||
def _populate_view_combo(self) -> None:
|
||||
current = self.view_combo.currentData()
|
||||
self.view_combo.blockSignals(True)
|
||||
self.view_combo.clear()
|
||||
for mode in self.view_modes():
|
||||
self.view_combo.addItem(t(_VIEW_LABELS[mode]), mode)
|
||||
if current:
|
||||
idx = self.view_combo.findData(current)
|
||||
if idx >= 0:
|
||||
self.view_combo.setCurrentIndex(idx)
|
||||
self.view_combo.blockSignals(False)
|
||||
|
||||
def _on_view_index_changed(self, _index: int) -> None:
|
||||
self._refresh_footer_visibility()
|
||||
self.view_mode_changed.emit(self.view_mode())
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.title.setText(t("panels.arrow"))
|
||||
for i, mode in enumerate(self.view_modes()):
|
||||
if i < self.view_combo.count():
|
||||
self.view_combo.setItemText(i, t(_VIEW_LABELS[mode]))
|
||||
self.view_combo.setToolTip(t("panels.view_mode_tip"))
|
||||
self.image_label.setToolTip(t("panels.arrow_empty_tip"))
|
||||
self._refresh_pipeline_label()
|
||||
self._refresh_scale_label()
|
||||
self.set_valid_area(self._valid_area)
|
||||
if not self.image_label.pixmap() or self.image_label.pixmap().isNull():
|
||||
self._placeholder = self._empty_message()
|
||||
self.image_label.setText(self._placeholder)
|
||||
|
||||
def _refresh_pipeline_label(self) -> None:
|
||||
if self._source_kind == "replay":
|
||||
self.pipeline_label.setText(t("panels.replay_pipeline"))
|
||||
self.pipeline_label.setToolTip(t("panels.replay_pipeline_tip"))
|
||||
return
|
||||
self.pipeline_label.setText(t("panels.arrow_pipeline"))
|
||||
self.pipeline_label.setToolTip(t("panels.arrow_pipeline_tip"))
|
||||
|
||||
def _empty_message(self) -> str:
|
||||
if self._source_kind == "replay":
|
||||
return t("panels.replay_empty")
|
||||
return t("panels.arrow_empty")
|
||||
|
||||
def _effective_rotation_deg(self) -> int:
|
||||
if not self._rotation_enabled:
|
||||
return 0
|
||||
return self._rotation_deg
|
||||
|
||||
def resizeEvent(self, event) -> None: # noqa: N802 - Qt naming
|
||||
super().resizeEvent(event)
|
||||
self._sync_image_height()
|
||||
|
||||
def _sync_image_height(self) -> None:
|
||||
"""Give the image area the height its own aspect ratio asks for.
|
||||
|
||||
A horizontal splitter hands its children the full available height and
|
||||
never asks them for ``heightForWidth``, so the aspect ratio has to be
|
||||
applied here or the 16:9 frame stretches into a tall letterbox.
|
||||
"""
|
||||
label = self.image_label
|
||||
wanted = label.heightForWidth(max(1, label.width()))
|
||||
room = max(_IMAGE_MIN_HEIGHT, self.height() - _IMAGE_CHROME_HEIGHT)
|
||||
height = max(_IMAGE_MIN_HEIGHT, min(wanted, room))
|
||||
if label.maximumHeight() != height:
|
||||
label.setFixedHeight(height)
|
||||
|
||||
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)
|
||||
# The area takes the shape of the *displayed* picture.
|
||||
self._set_display_aspect(image.width(), image.height())
|
||||
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)
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Brand bar shown at the top of the studio shell.
|
||||
|
||||
A slim deep-navy rail carrying the corporate logo lockup, the product name and
|
||||
the three top-level views (live / replay / calibration). 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.
|
||||
|
||||
The rail is deliberately thin (``BAR_HEIGHT``): the instrument's own state lives
|
||||
in the status rail below it, not here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtGui import QColor, QPainter, QPixmap
|
||||
from PyQt5.QtWidgets import QFrame, QHBoxLayout, QLabel, QPushButton, 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 = 16
|
||||
BAR_HEIGHT = 28
|
||||
DIVIDER_HEIGHT = 14
|
||||
|
||||
|
||||
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: lockup and top-level view switcher, nothing else."""
|
||||
|
||||
#: The user asked for the live view.
|
||||
live_requested = pyqtSignal()
|
||||
#: The user asked for the history/replay view.
|
||||
playback_requested = pyqtSignal()
|
||||
#: The user asked for the calibration view.
|
||||
calibration_requested = pyqtSignal()
|
||||
|
||||
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(10, 0, 10, 0)
|
||||
layout.setSpacing(8)
|
||||
|
||||
self.logo = QLabel()
|
||||
self.logo.setObjectName("brandLogo")
|
||||
self.logo.setFixedSize(LOGO_SIZE, LOGO_SIZE)
|
||||
self.logo.setAlignment(Qt.AlignCenter)
|
||||
layout.addWidget(self.logo)
|
||||
|
||||
# The lockup keeps its two labels (the product's tagline stays available
|
||||
# as a tooltip), but the rail is a single line now.
|
||||
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")
|
||||
self.tagline.setVisible(False)
|
||||
lockup.addWidget(self.name)
|
||||
lockup.addWidget(self.tagline)
|
||||
layout.addLayout(lockup)
|
||||
|
||||
self.btn_live = self._nav_button()
|
||||
self.btn_playback = self._nav_button()
|
||||
self.btn_calibration = self._nav_button()
|
||||
self.btn_live.clicked.connect(lambda _checked=False: self.live_requested.emit())
|
||||
self.btn_playback.clicked.connect(lambda _checked=False: self.playback_requested.emit())
|
||||
self.btn_calibration.clicked.connect(
|
||||
lambda _checked=False: self.calibration_requested.emit()
|
||||
)
|
||||
layout.addSpacing(4)
|
||||
layout.addWidget(self.btn_live)
|
||||
layout.addWidget(self.btn_playback)
|
||||
layout.addWidget(self.btn_calibration)
|
||||
|
||||
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()
|
||||
self.set_active_view("live")
|
||||
|
||||
def _nav_button(self) -> QPushButton:
|
||||
button = QPushButton()
|
||||
button.setObjectName("navButton")
|
||||
button.setCursor(Qt.PointingHandCursor)
|
||||
button.setFlat(True)
|
||||
button.setCheckable(True)
|
||||
return button
|
||||
|
||||
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_active_view(self, view: str | None) -> None:
|
||||
"""Mark which top-level view is on screen.
|
||||
|
||||
``None`` leaves all three unmarked: a hand-arranged layout is not one of
|
||||
the three views, and pretending otherwise would misreport the state.
|
||||
"""
|
||||
active = str(view or "")
|
||||
for button, name in (
|
||||
(self.btn_live, "live"),
|
||||
(self.btn_playback, "replay"),
|
||||
(self.btn_calibration, "calibration"),
|
||||
):
|
||||
is_active = name == active
|
||||
button.setChecked(is_active)
|
||||
button.setProperty("navActive", "true" if is_active else "false")
|
||||
button.style().unpolish(button)
|
||||
button.style().polish(button)
|
||||
|
||||
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(f"{t('app.title')} — {t('brand.tagline')}")
|
||||
self.btn_live.setText(t("brand.nav_live"))
|
||||
self.btn_live.setToolTip(t("brand.nav_live_tip"))
|
||||
self.btn_playback.setText(t("brand.nav_playback"))
|
||||
self.btn_playback.setToolTip(t("brand.nav_playback_tip"))
|
||||
self.btn_calibration.setText(t("brand.nav_calibration"))
|
||||
self.btn_calibration.setToolTip(t("brand.nav_calibration_tip"))
|
||||
@@ -0,0 +1,792 @@
|
||||
"""Force calibration sampling panel (FN / FX / FY)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtGui import QColor, QFont, QFontDatabase
|
||||
from PyQt5.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QButtonGroup,
|
||||
QDoubleSpinBox,
|
||||
QFileDialog,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QHeaderView,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.force.calibration_io import (
|
||||
CalSample,
|
||||
CalSessionStore,
|
||||
ChannelKey,
|
||||
meta_from_sensor_json,
|
||||
)
|
||||
from orisys_app_shared.force.calibration_session import (
|
||||
session_filename,
|
||||
)
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.paths import calibration_export_dir
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
from orisys_studio.calibration_service import CalibrationService
|
||||
from orisys_studio.ui.cal_point_dialog import CalibrationPointDialog
|
||||
|
||||
CHANNEL_LABELS = ("fn", "fx", "fy")
|
||||
|
||||
#: Diagnostic columns, in display order: reference load, the three axis means,
|
||||
#: a stability measure and the point's state.
|
||||
DIAGNOSTIC_COLUMNS = ("load", "fn", "fx", "fy", "stability", "state")
|
||||
|
||||
|
||||
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)
|
||||
#: Coefficients were written to disk. It does **not** mean they were applied.
|
||||
calibration_exported = pyqtSignal(str)
|
||||
#: The operator asked to push the last export into the running device.
|
||||
apply_requested = pyqtSignal(str)
|
||||
|
||||
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("contentPanel")
|
||||
self._theme = theme
|
||||
#: The files, the identity and the prepared model live in the service;
|
||||
#: this panel shows them and emits status messages.
|
||||
self._service = CalibrationService()
|
||||
self._store = self._service.store
|
||||
self._channel: ChannelKey = "fn"
|
||||
self._running = False
|
||||
#: Set by :meth:`bind_recorder`. Sampling lives there, not here, so the
|
||||
#: lifecycle no longer depends on this panel being visible.
|
||||
self._recorder = None
|
||||
#: Last successfully written coefficients file; applying is explicit.
|
||||
self._last_export_path = ""
|
||||
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._mono = _mono_font()
|
||||
self._mono_font = QFont(self._mono, 10)
|
||||
|
||||
# Everything below the title scrolls: this panel has more content than
|
||||
# a short window can show (a six-column table, the fit summary, the SN
|
||||
# field and nine actions), and a clipped button is a lost function.
|
||||
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.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||||
self.scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||||
content = QWidget()
|
||||
self.scroll.setWidget(content)
|
||||
outer.addWidget(self.scroll)
|
||||
|
||||
root = QVBoxLayout(content)
|
||||
root.setContentsMargins(9, 9, 9, 9)
|
||||
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)
|
||||
|
||||
# Diagnostic table: the loaded axis plus the two crosstalk axes, a
|
||||
# stability measure and the point's state.
|
||||
self.table = QTableWidget(0, len(DIAGNOSTIC_COLUMNS))
|
||||
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()
|
||||
# Every column takes the width its own header and cells need: eliding a
|
||||
# diagnostic column ("FN 均值" -> "FN 均") hides the meaning, so a narrow
|
||||
# panel scrolls instead.
|
||||
header.setSectionResizeMode(QHeaderView.ResizeToContents)
|
||||
header.setMinimumSectionSize(64)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
self.table.verticalHeader().setDefaultSectionSize(26)
|
||||
self.table.setMinimumHeight(150)
|
||||
self.table.itemSelectionChanged.connect(self._on_row_selected)
|
||||
self.table.doubleClicked.connect(self._on_row_activated)
|
||||
root.addWidget(self.table, 1)
|
||||
|
||||
self.fit_label = QLabel()
|
||||
self.fit_label.setObjectName("hintText")
|
||||
self.fit_label.setWordWrap(True)
|
||||
root.addWidget(self.fit_label)
|
||||
|
||||
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)
|
||||
|
||||
self.btn_delete = QPushButton()
|
||||
self.btn_clear_channel = QPushButton()
|
||||
self.btn_clear_all = QPushButton()
|
||||
self.btn_toggle_excluded = QPushButton()
|
||||
self.btn_toggle_validation = QPushButton()
|
||||
self.btn_open_record = QPushButton()
|
||||
self.btn_save_record = QPushButton()
|
||||
self.btn_export = QPushButton()
|
||||
self.btn_apply = QPushButton()
|
||||
self.btn_apply.setObjectName("primaryButton")
|
||||
for button in (
|
||||
self.btn_delete,
|
||||
self.btn_clear_channel,
|
||||
self.btn_clear_all,
|
||||
self.btn_toggle_excluded,
|
||||
self.btn_toggle_validation,
|
||||
self.btn_open_record,
|
||||
self.btn_save_record,
|
||||
):
|
||||
# The action row must survive a narrow three-panel layout.
|
||||
button.setProperty("compact", "true")
|
||||
button.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
self.btn_toggle_excluded.clicked.connect(self._on_toggle_excluded)
|
||||
self.btn_toggle_validation.clicked.connect(self._on_toggle_validation)
|
||||
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_open_record.clicked.connect(self._on_open_record)
|
||||
self.btn_save_record.clicked.connect(self._on_save_record)
|
||||
self.btn_export.clicked.connect(self.export_coefficients)
|
||||
self.btn_apply.clicked.connect(self.apply_coefficients)
|
||||
# Two rows, not nine buttons in a row: in one line every label was
|
||||
# squeezed until it was unreadable ("排 除 / 设 为 验 证 点").
|
||||
selection_actions = QHBoxLayout()
|
||||
selection_actions.setSpacing(6)
|
||||
selection_actions.addWidget(self.btn_toggle_excluded)
|
||||
selection_actions.addWidget(self.btn_toggle_validation)
|
||||
selection_actions.addWidget(self.btn_delete)
|
||||
selection_actions.addWidget(self.btn_clear_channel)
|
||||
selection_actions.addWidget(self.btn_clear_all)
|
||||
selection_actions.addStretch(1)
|
||||
root.addLayout(selection_actions)
|
||||
|
||||
file_actions = QHBoxLayout()
|
||||
file_actions.setSpacing(6)
|
||||
file_actions.addStretch(1)
|
||||
file_actions.addWidget(self.btn_open_record)
|
||||
file_actions.addWidget(self.btn_save_record)
|
||||
file_actions.addWidget(self.btn_export)
|
||||
file_actions.addWidget(self.btn_apply)
|
||||
root.addLayout(file_actions)
|
||||
|
||||
self._refresh_enabled()
|
||||
self._refresh_table()
|
||||
self.set_theme(theme)
|
||||
self.retranslate()
|
||||
|
||||
# ------------------------------------------------------------------ API
|
||||
@property
|
||||
def _prepared(self):
|
||||
return self._service.prepared
|
||||
|
||||
@property
|
||||
def _record_identity(self):
|
||||
return self._service.record_identity
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
self._theme = theme
|
||||
self._render_live_label()
|
||||
|
||||
# --------------------------------------------------------- recorder link
|
||||
def bind_recorder(self, recorder) -> None:
|
||||
"""Attach the sampling state machine that owns the recording lifecycle."""
|
||||
self._recorder = recorder
|
||||
|
||||
@property
|
||||
def _recording(self) -> bool:
|
||||
return bool(self._recorder is not None and self._recorder.is_recording)
|
||||
|
||||
def set_running(self, running: bool) -> None:
|
||||
self._running = bool(running)
|
||||
if not self._running and self._recording:
|
||||
self._recorder.cancel()
|
||||
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
|
||||
# A new run is the live device's context again.
|
||||
self._service.clear_record_identity()
|
||||
|
||||
def set_live(self, fn: float, fx: float, fy: float) -> None:
|
||||
"""Update the live readout. Sampling is handled by the recorder."""
|
||||
self._live_fn = float(fn)
|
||||
self._live_fx = float(fx)
|
||||
self._live_fy = float(fy)
|
||||
self._render_live_label()
|
||||
|
||||
def set_progress(self, fraction: float, elapsed_s: float, total_s: float) -> None:
|
||||
self.progress.setVisible(True)
|
||||
self.progress.setValue(max(0, min(1000, int(fraction * 1000))))
|
||||
self.progress.setFormat(f"{elapsed_s:.1f} / {total_s:.1f} s")
|
||||
|
||||
def add_point(self, point) -> None:
|
||||
"""Record a finished calibration point (three axes captured)."""
|
||||
self._rebuild_store()
|
||||
self._refresh_table()
|
||||
stats = point.loaded_stats()
|
||||
if point.status == "few_samples":
|
||||
self.status_message.emit(t("cal.record_failed_few"))
|
||||
return
|
||||
self.status_message.emit(
|
||||
t("cal.record_ok", weight=f"{point.reference_load_n:g}", raw=f"{stats.mean:.3f}")
|
||||
)
|
||||
|
||||
def push_raw(self, fn: float, fx: float, fy: float, _timestamp: float | None = None) -> None:
|
||||
"""Back-compat shim: live readout only, sampling lives in the recorder."""
|
||||
self.set_live(fn, fx, fy)
|
||||
|
||||
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_open_record.setText(t("cal.open_record"))
|
||||
self.btn_save_record.setText(t("cal.save_record"))
|
||||
self.btn_export.setText(t("cal.export"))
|
||||
self.btn_apply.setText(t("cal.apply"))
|
||||
self.btn_open_record.setToolTip(t("cal.open_record_tip"))
|
||||
self.btn_save_record.setToolTip(t("cal.save_record_tip"))
|
||||
self.btn_export.setToolTip(t("cal.export_tip"))
|
||||
self.btn_apply.setToolTip(t("cal.apply_tip"))
|
||||
self.btn_toggle_excluded.setText(t("cal.state_exclude"))
|
||||
self.btn_toggle_validation.setText(t("cal.mark_validation"))
|
||||
self.btn_toggle_excluded.setToolTip(t("cal.state_exclude_tip"))
|
||||
self.btn_toggle_validation.setToolTip(t("cal.mark_validation_tip"))
|
||||
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_fn"),
|
||||
t("cal.col_fx"),
|
||||
t("cal.col_fy"),
|
||||
t("cal.col_stability"),
|
||||
t("cal.col_state"),
|
||||
)
|
||||
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()
|
||||
self._refresh_table()
|
||||
|
||||
# ------------------------------------------------------------- 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._recorder.cancel()
|
||||
self._channel = key # type: ignore[assignment]
|
||||
self._refresh_channel_badge()
|
||||
self._refresh_table()
|
||||
|
||||
def _refresh_enabled(self) -> None:
|
||||
# While recording the button must stay **enabled**: it is the cancel
|
||||
# affordance. Disabling it here was why a recording could not be stopped.
|
||||
can_record = self._running
|
||||
self.btn_record.setEnabled(can_record)
|
||||
self.btn_record.setText(
|
||||
t("cal.recording_stop") if self._recording else t("cal.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)
|
||||
# Saving the audit record is independent of the coefficients, and applying
|
||||
# is only meaningful once coefficients exist.
|
||||
self.btn_save_record.setEnabled(not self._recording and self._has_points())
|
||||
self.btn_open_record.setEnabled(not self._recording)
|
||||
self.btn_apply.setEnabled(bool(self._last_export_path) and 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)
|
||||
else:
|
||||
self.btn_record.setToolTip(t("cal.recording_stop_tip"))
|
||||
|
||||
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._recorder is None:
|
||||
return
|
||||
if self._recording:
|
||||
self._recorder.cancel()
|
||||
return
|
||||
if not self._running:
|
||||
return
|
||||
duration = float(self.duration_spin.value())
|
||||
self._recorder.start(
|
||||
channel=self._channel,
|
||||
reference_load_n=float(self.weight_spin.value()),
|
||||
duration_s=duration,
|
||||
direction="load",
|
||||
)
|
||||
self.progress.setVisible(True)
|
||||
self.progress.setValue(0)
|
||||
self.progress.setFormat(f"0.0 / {duration:.1f} s")
|
||||
self._refresh_enabled()
|
||||
self.status_message.emit(t("cal.recording"))
|
||||
|
||||
def on_record_cancelled(self) -> None:
|
||||
"""Called by the window when the recorder was cancelled."""
|
||||
self.progress.setValue(0)
|
||||
self.progress.setFormat("")
|
||||
self._refresh_enabled()
|
||||
self.status_message.emit(t("cal.record_cancelled"))
|
||||
|
||||
def on_record_finished(self, point) -> None:
|
||||
self.progress.setValue(1000)
|
||||
self.progress.setFormat("")
|
||||
self._refresh_enabled()
|
||||
self.add_point(point)
|
||||
|
||||
def poll_recorder(self) -> None:
|
||||
"""Mirror the recorder's progress; the recorder owns the deadline."""
|
||||
if not self._recording:
|
||||
return
|
||||
self.set_progress(
|
||||
self._recorder.progress(),
|
||||
self._recorder.elapsed_s(),
|
||||
self._recorder.requested_duration_s,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------ diagnostics
|
||||
def _diagnostic_points(self):
|
||||
"""The recorder's points for the active channel, or the export store."""
|
||||
if self._recorder is not None:
|
||||
return self._recorder.points_for(self._channel)
|
||||
return []
|
||||
|
||||
def _current_point(self):
|
||||
row = self.table.currentRow()
|
||||
points = self._diagnostic_points()
|
||||
if 0 <= row < len(points):
|
||||
return points[row]
|
||||
return None
|
||||
|
||||
def _refresh_table(self) -> None:
|
||||
points = self._diagnostic_points()
|
||||
self.table.setRowCount(len(points))
|
||||
for row, point in enumerate(points):
|
||||
values = (
|
||||
f"{point.reference_load_n:g}",
|
||||
self._axis_cell(point, "fn"),
|
||||
self._axis_cell(point, "fx"),
|
||||
self._axis_cell(point, "fy"),
|
||||
self._stability_cell(point),
|
||||
t(point.state_key()),
|
||||
)
|
||||
for column, text in enumerate(values):
|
||||
item = QTableWidgetItem(text)
|
||||
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
|
||||
if column in (0, 1, 2, 3):
|
||||
item.setTextAlignment(int(Qt.AlignRight | Qt.AlignVCenter))
|
||||
else:
|
||||
item.setTextAlignment(int(Qt.AlignCenter))
|
||||
if point.excluded:
|
||||
item.setForeground(QColor(color(self._theme, "text_muted")))
|
||||
elif point.is_validation:
|
||||
item.setForeground(QColor(color(self._theme, "info")))
|
||||
self.table.setItem(row, column, item)
|
||||
self.empty_hint.setVisible(not points)
|
||||
self._refresh_fit_label()
|
||||
|
||||
@staticmethod
|
||||
def _axis_cell(point, channel: str) -> str:
|
||||
value = point.axis_mean(channel)
|
||||
if not np.isfinite(value):
|
||||
return "—"
|
||||
if channel == point.channel and np.isfinite(point.residual_n):
|
||||
# Residual of the fit on the same cell as the value it came from.
|
||||
return f"{value:.4f} ({point.residual_n:+.4f})"
|
||||
return f"{value:.4f}"
|
||||
|
||||
@staticmethod
|
||||
def _stability_cell(point) -> str:
|
||||
stats = point.loaded_stats()
|
||||
if not np.isfinite(stats.std):
|
||||
return "—"
|
||||
return f"σ {stats.std:.4f} / n {stats.n}"
|
||||
|
||||
def _refresh_fit_label(self) -> None:
|
||||
if self._recorder is None:
|
||||
self.fit_label.setText("")
|
||||
return
|
||||
# Same prepared result the store and the export use — never a second one.
|
||||
fit = self._recorder.fit(self._channel, prepared=self._prepared)
|
||||
if not fit.usable:
|
||||
self.fit_label.setText(t("cal.fit_insufficient", count=fit.n_points))
|
||||
return
|
||||
# The method and the breakpoint count describe the model that export and
|
||||
# the device use; the least-squares pair is only an equivalent summary.
|
||||
text = t(
|
||||
"cal.fit_summary",
|
||||
channel=self._channel.upper(),
|
||||
method=t(f"cal.method_{fit.method}"),
|
||||
count=fit.n_points,
|
||||
breakpoints=fit.breakpoints,
|
||||
residual=f"{fit.max_residual_n:.4f}",
|
||||
)
|
||||
if fit.validation_points:
|
||||
text += " " + t(
|
||||
"cal.fit_validation",
|
||||
error=f"{fit.validation_error_n:.4f}",
|
||||
count=fit.validation_points,
|
||||
)
|
||||
self.fit_label.setText(text)
|
||||
|
||||
def _on_row_selected(self) -> None:
|
||||
point = self._current_point()
|
||||
self.btn_toggle_excluded.setEnabled(point is not None)
|
||||
self.btn_toggle_validation.setEnabled(point is not None)
|
||||
if point is not None:
|
||||
state = t("cal.state_restore") if point.excluded else t("cal.state_exclude")
|
||||
self.btn_toggle_excluded.setText(state)
|
||||
|
||||
def point_dialog(self, point):
|
||||
"""The diagnostic view for a point, rebuilt from its samples if needed.
|
||||
|
||||
A reopened record carries the full sample series, so the three-axis view
|
||||
can always be produced — waiting for a cached trace would have made
|
||||
exactly the reopened points (the ones worth auditing) unopenable.
|
||||
"""
|
||||
if point is None or not (point.trace or point.samples):
|
||||
return None
|
||||
if not point.trace:
|
||||
from orisys_app_shared.force.calibration_session import (
|
||||
restore_diagnostics,
|
||||
)
|
||||
|
||||
restore_diagnostics(point)
|
||||
if not point.trace:
|
||||
return None
|
||||
return CalibrationPointDialog(point, theme=self._theme, parent=self)
|
||||
|
||||
def _on_row_activated(self) -> None:
|
||||
"""Double click a row to inspect that point's three-axis curve."""
|
||||
dialog = self.point_dialog(self._current_point())
|
||||
if dialog is not None:
|
||||
dialog.exec_()
|
||||
|
||||
def _on_toggle_excluded(self) -> None:
|
||||
point = self._current_point()
|
||||
if point is None or self._recorder is None:
|
||||
return
|
||||
# Pass the point itself: a table row index would resolve against the
|
||||
# recorder's cross-channel list and could hit the wrong point.
|
||||
self._recorder.set_excluded(point, not point.excluded)
|
||||
self._rebuild_store()
|
||||
self._refresh_table()
|
||||
self.status_message.emit(
|
||||
t("cal.excluded" if point.excluded else "cal.restored")
|
||||
)
|
||||
|
||||
def _on_toggle_validation(self) -> None:
|
||||
point = self._current_point()
|
||||
if point is None or self._recorder is None:
|
||||
return
|
||||
self._recorder.set_validation(point, not point.is_validation)
|
||||
self._rebuild_store()
|
||||
self._refresh_table()
|
||||
self.status_message.emit(
|
||||
t("cal.marked_validation" if point.is_validation else "cal.unmarked_validation")
|
||||
)
|
||||
|
||||
def _export_meta(self) -> dict:
|
||||
"""The identity/pipeline metadata the export would carry."""
|
||||
return self._service.export_meta(
|
||||
sensor_type=self._sensor_type,
|
||||
config_ref=self._config_ref,
|
||||
sn=self.sn_edit.text().strip(),
|
||||
duration_s=float(self.duration_spin.value()),
|
||||
)
|
||||
|
||||
def _rebuild_store(self) -> None:
|
||||
"""Prepare the session once: store, diagnostics and export share it.
|
||||
|
||||
The panel used to filter, aggregate and build its own model while
|
||||
``CalibrationRecorder.fit`` prepared a second one for the table, so the
|
||||
displayed residual and the exported curve could describe different
|
||||
models. Both now read :class:`PreparedCalibration`, built by the service.
|
||||
"""
|
||||
self._service.prepare(self._recorder, self._export_meta())
|
||||
|
||||
def _on_delete_row(self) -> None:
|
||||
point = self._current_point()
|
||||
if point is None or self._recorder is None:
|
||||
return
|
||||
# Deleting is an exclusion: the recorded evidence stays, but it leaves
|
||||
# the fit and the export.
|
||||
self._recorder.set_excluded(point, True)
|
||||
self._rebuild_store()
|
||||
self._refresh_table()
|
||||
|
||||
def _on_clear_channel(self) -> None:
|
||||
if self._recorder is not None:
|
||||
self._recorder.clear_points(self._channel)
|
||||
self._rebuild_store()
|
||||
self._refresh_table()
|
||||
|
||||
def _on_clear_all(self) -> None:
|
||||
if self._recorder is not None:
|
||||
self._recorder.clear_points()
|
||||
self._rebuild_store()
|
||||
self._refresh_table()
|
||||
|
||||
def export_coefficients(self) -> None:
|
||||
"""Write the prepared model to the user's calibration directory.
|
||||
|
||||
Public because the sensor dialog offers the same action: one
|
||||
implementation, reachable from both places.
|
||||
"""
|
||||
# Re-prepare first: the SN and the requested duration may have changed,
|
||||
# and the file must be exactly the model the panel is showing.
|
||||
self._rebuild_store()
|
||||
self._refresh_fit_label()
|
||||
if self._prepared is None or not self._prepared.ok:
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
t("panels.cal"),
|
||||
t("cal.export_no_model", detail="; ".join(self._warnings())),
|
||||
)
|
||||
return
|
||||
try:
|
||||
# Write into the user's calibration directory only. Overwriting the
|
||||
# device bundle under config/sensors/<type>/ is now an explicit,
|
||||
# separate step so that exporting cannot silently change what the
|
||||
# next Sensor() will load.
|
||||
primary, _local_path, warnings = self._service.export_coefficients(
|
||||
sensor_type=self._sensor_type,
|
||||
local_bundle_dir=None,
|
||||
)
|
||||
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
|
||||
|
||||
self._last_export_path = str(primary)
|
||||
self._refresh_enabled()
|
||||
msg = t("cal.export_ok", path=str(primary))
|
||||
if warnings:
|
||||
msg += "\n" + t("cal.export_warnings", detail="; ".join(warnings))
|
||||
self.status_message.emit(msg)
|
||||
# The window records which coefficients were exported. Applying them is a
|
||||
# separate button, so exporting cannot silently change a running device.
|
||||
self.calibration_exported.emit(str(primary))
|
||||
QMessageBox.information(self, t("panels.cal"), msg)
|
||||
|
||||
def _warnings(self) -> list[str]:
|
||||
return list(getattr(self._prepared, "warnings", []) or [])
|
||||
|
||||
def apply_coefficients(self) -> None:
|
||||
"""Ask for the last export to be pushed into the running device."""
|
||||
if not self._last_export_path:
|
||||
self.status_message.emit(t("cal.apply_no_export"))
|
||||
return
|
||||
self.apply_requested.emit(self._last_export_path)
|
||||
|
||||
def _on_save_record(self) -> None:
|
||||
"""Write the session's audit record: every point and every sample."""
|
||||
if self._recorder is None:
|
||||
return
|
||||
points = self._recorder.all_points()
|
||||
if not points:
|
||||
self.status_message.emit(t("cal.save_record_empty"))
|
||||
return
|
||||
# ``_export_meta`` keeps a reopened record's own device identity.
|
||||
meta = self._export_meta()
|
||||
try:
|
||||
written = self._service.save_record(self._recorder, meta)
|
||||
except Exception as exc: # noqa: BLE001 - reported to the operator
|
||||
QMessageBox.warning(
|
||||
self, t("panels.cal"), t("cal.save_record_failed", error=str(exc))
|
||||
)
|
||||
return
|
||||
samples = sum(len(point.samples) for point in points)
|
||||
msg = t("cal.save_record_ok", path=str(written), points=len(points), samples=samples)
|
||||
self.status_message.emit(msg)
|
||||
QMessageBox.information(self, t("panels.cal"), msg)
|
||||
|
||||
def _on_open_record(self, path: str | None = None) -> None:
|
||||
"""Reopen a saved session: its points **and** its device identity."""
|
||||
if self._recorder is None:
|
||||
return
|
||||
if path is None:
|
||||
path, _filter = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
t("cal.open_record"),
|
||||
str(calibration_export_dir()),
|
||||
t("cal.open_record_filter"),
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
record = self._service.load_record(path)
|
||||
except Exception as exc: # noqa: BLE001 - reported to the operator
|
||||
QMessageBox.warning(
|
||||
self, t("panels.cal"), t("cal.open_record_failed", error=str(exc))
|
||||
)
|
||||
return
|
||||
# Show the record's own SN: it is what a later save or export will carry,
|
||||
# so the operator must not be looking at the connected device's number.
|
||||
if record.identity().get("sn"):
|
||||
self.sn_edit.setText(record.identity()["sn"])
|
||||
self._recorder.load_points(record.points, replace=True)
|
||||
self._rebuild_store()
|
||||
self._refresh_table()
|
||||
self._refresh_enabled()
|
||||
msg = t("cal.open_record_ok", path=record.path.name, points=len(record.points))
|
||||
msg += " " + t("cal.open_record_device", device=record.describe())
|
||||
if record.warnings:
|
||||
msg += "\n" + t("cal.open_record_warnings", detail="; ".join(record.warnings))
|
||||
self.status_message.emit(msg)
|
||||
|
||||
def _has_points(self) -> bool:
|
||||
return bool(self._recorder is not None and self._recorder.all_points())
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Inspect one calibration point: its three-axis trace and its diagnostics.
|
||||
|
||||
Kept in a dialog rather than inline so the calibration panel can stay narrow in
|
||||
the three-panel layout, while a single point can still be examined in full.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtGui import QFont, QFontDatabase
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFormLayout,
|
||||
QLabel,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from orisys_app_shared.force import NA
|
||||
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 the calibration point dialog") from exc
|
||||
|
||||
TRACE_TOKENS = ("plot_fn", "plot_fx", "plot_fy")
|
||||
TRACE_KEYS = ("fn", "fx", "fy")
|
||||
|
||||
|
||||
def _mono_font(point_size: int = 9) -> QFont:
|
||||
families = set(QFontDatabase().families())
|
||||
chosen = "monospace"
|
||||
for name in ("Consolas", "Cascadia Mono", "DejaVu Sans Mono", "Courier New"):
|
||||
if name in families:
|
||||
chosen = name
|
||||
break
|
||||
return QFont(chosen, point_size)
|
||||
|
||||
|
||||
class CalibrationPointDialog(QDialog):
|
||||
"""One recorded point: all three raw traces plus its fit residual."""
|
||||
|
||||
def __init__(self, point, *, theme: str = "dark", parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self._point = point
|
||||
self._theme = theme
|
||||
self.setWindowTitle(
|
||||
t(
|
||||
"cal.point_title",
|
||||
channel=point.channel.upper(),
|
||||
load=f"{point.reference_load_n:g}",
|
||||
)
|
||||
)
|
||||
self.resize(560, 420)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setSpacing(8)
|
||||
|
||||
form = QFormLayout()
|
||||
form.setHorizontalSpacing(10)
|
||||
form.setVerticalSpacing(4)
|
||||
self._add_row(form, "cal.point_channel", point.channel.upper())
|
||||
self._add_row(form, "cal.point_direction", point.direction)
|
||||
self._add_row(form, "cal.point_round", str(point.round_index))
|
||||
self._add_row(
|
||||
form,
|
||||
"cal.point_duration",
|
||||
t(
|
||||
"cal.point_duration_value",
|
||||
actual=f"{point.duration_s:.2f}",
|
||||
requested=f"{point.requested_duration_s:.2f}",
|
||||
),
|
||||
)
|
||||
self._add_row(
|
||||
form,
|
||||
"cal.point_samples",
|
||||
t("cal.point_samples_value", valid=point.n_valid, total=point.n_total),
|
||||
)
|
||||
stats = []
|
||||
for key in TRACE_KEYS:
|
||||
axis = point.raw.get(key)
|
||||
if axis is None or not np.isfinite(axis.mean):
|
||||
stats.append(f"{key.upper()} —")
|
||||
continue
|
||||
stats.append(f"{key.upper()} {axis.mean:.4f} ± {axis.std:.4f}")
|
||||
value = QLabel(" ".join(stats))
|
||||
value.setFont(_mono_font())
|
||||
self._add_row(form, "cal.point_axes", "")
|
||||
form.addRow(value)
|
||||
residual = point.residual_n
|
||||
self._add_row(
|
||||
form,
|
||||
"cal.point_residual",
|
||||
"—" if not np.isfinite(residual) else f"{residual:+.4f}",
|
||||
)
|
||||
root.addLayout(form)
|
||||
|
||||
self.plot_widget = pg.GraphicsLayoutWidget()
|
||||
self.plot_widget.setBackground(color(theme, "plot_bg"))
|
||||
root.addWidget(self.plot_widget, 1)
|
||||
self._build_traces()
|
||||
|
||||
buttons = QDialogButtonBox(QDialogButtonBox.Close)
|
||||
buttons.rejected.connect(self.reject)
|
||||
buttons.accepted.connect(self.accept)
|
||||
root.addWidget(buttons)
|
||||
|
||||
def _add_row(self, form: QFormLayout, key: str, value: str) -> None:
|
||||
label = QLabel(t(key))
|
||||
label.setObjectName("fieldLabel")
|
||||
field = QLabel(str(value))
|
||||
form.addRow(label, field)
|
||||
|
||||
def _build_traces(self) -> None:
|
||||
"""One row per axis, stacked, sharing the sample axis."""
|
||||
pen_axis = pg.mkPen(color(self._theme, "text_secondary"))
|
||||
first = None
|
||||
for row, key in enumerate(TRACE_KEYS):
|
||||
plot = self.plot_widget.addPlot(row=row, col=0)
|
||||
plot.showGrid(x=True, y=True, alpha=0.25)
|
||||
plot.setMouseEnabled(x=True, y=False)
|
||||
plot.getAxis("left").setPen(pen_axis)
|
||||
plot.getAxis("left").setTextPen(pen_axis)
|
||||
for axis_name in ("left", "bottom"):
|
||||
plot.getAxis(axis_name).setPen(pen_axis)
|
||||
plot.getAxis(axis_name).setTextPen(pen_axis)
|
||||
plot.setLabel(
|
||||
"left", key.upper(), color=color(self._theme, TRACE_TOKENS[row])
|
||||
)
|
||||
if first is None:
|
||||
first = plot
|
||||
else:
|
||||
plot.setXLink(first)
|
||||
self.plot_widget.ci.layout.setRowStretchFactor(row, 1)
|
||||
values = self._point.trace.get(key)
|
||||
if not values:
|
||||
continue
|
||||
step = self._point.trace_dt_s or 0.01
|
||||
times = np.arange(len(values), dtype=float) * step
|
||||
plot.plot(
|
||||
times,
|
||||
np.asarray(values, dtype=float),
|
||||
pen=pg.mkPen(color(self._theme, TRACE_TOKENS[row]), width=2),
|
||||
)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Labels that may be squeezed below their text width.
|
||||
|
||||
A ``QLabel`` reports its text width as a *minimum*, so one long annotation can
|
||||
decide the minimum width of a whole panel (or of the top rail) and push its
|
||||
neighbours out of view. These labels keep their size hint — they ask for the
|
||||
space their text needs — but allow the layout to shrink them, at which point Qt
|
||||
clips the text and the tooltip remains the way to read it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtCore import QSize
|
||||
from PyQt5.QtWidgets import QLabel, QSizePolicy
|
||||
|
||||
|
||||
class ShrinkableLabel(QLabel):
|
||||
"""A label whose minimum width is 0 (the text is clipped when squeezed)."""
|
||||
|
||||
def __init__(self, text: str = "", parent=None) -> None:
|
||||
super().__init__(text, parent)
|
||||
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
|
||||
self.setMinimumWidth(0)
|
||||
|
||||
def minimumSizeHint(self) -> QSize: # noqa: N802 - Qt naming
|
||||
hint = super().minimumSizeHint()
|
||||
return QSize(0, hint.height())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,135 @@
|
||||
"""Small monochrome icons, painted at runtime from the theme tokens.
|
||||
|
||||
The app ships no icon assets beyond its logo, and Qt's built-in icons follow the
|
||||
platform's own look. These three chart-header actions are therefore drawn here:
|
||||
a transparent background and a pen colour taken from the theme, so the header
|
||||
stays readable in both themes without a single hex literal in widget code.
|
||||
|
||||
The cache is keyed on the *resolved colour*, not on the theme name: a theme
|
||||
switch therefore produces new pixmaps, and two themes that share a colour share
|
||||
the pixmap.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from PyQt5.QtCore import QRectF, Qt
|
||||
from PyQt5.QtGui import QColor, QIcon, QPainter, QPen, QPixmap
|
||||
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
#: Names of the icons this module can paint.
|
||||
NAMES = ("zoom_fit", "box_zoom", "latest", "settings")
|
||||
|
||||
ICON_SIZE = 16
|
||||
_PEN_WIDTH = 1.4
|
||||
|
||||
|
||||
def _paint_zoom_fit(painter: QPainter, size: int) -> None:
|
||||
"""Four corner brackets around a small frame: "fit this to the screen"."""
|
||||
inset = 2.0
|
||||
arm = size * 0.28
|
||||
far = size - inset
|
||||
for x, y, dx, dy in (
|
||||
(inset, inset, 1, 1),
|
||||
(far, inset, -1, 1),
|
||||
(inset, far, 1, -1),
|
||||
(far, far, -1, -1),
|
||||
):
|
||||
painter.drawLine(
|
||||
int(round(x)), int(round(y)), int(round(x + dx * arm)), int(round(y))
|
||||
)
|
||||
painter.drawLine(
|
||||
int(round(x)), int(round(y)), int(round(x)), int(round(y + dy * arm))
|
||||
)
|
||||
side = size * 0.375
|
||||
painter.drawRect(QRectF((size - side) / 2.0, (size - side) / 2.0, side, side))
|
||||
|
||||
|
||||
def _paint_box_zoom(painter: QPainter, size: int) -> None:
|
||||
"""A dashed frame: drag a region to zoom into it."""
|
||||
pen = painter.pen()
|
||||
pen.setStyle(Qt.DashLine)
|
||||
pen.setDashPattern([2.2, 1.6])
|
||||
painter.setPen(pen)
|
||||
painter.drawRect(QRectF(2.0, 2.0, size - 4.0, size - 4.0))
|
||||
|
||||
|
||||
def _paint_latest(painter: QPainter, size: int) -> None:
|
||||
"""An arrow running into a bar: jump to the newest sample."""
|
||||
top = 3.0
|
||||
bottom = size - 3.0
|
||||
mid = size / 2.0
|
||||
bar_x = size - 3.0
|
||||
draw_to = bar_x - 2.5
|
||||
painter.drawLine(int(round(bar_x)), int(round(top)), int(round(bar_x)), int(round(bottom)))
|
||||
painter.drawLine(2, int(round(mid)), int(round(draw_to)), int(round(mid)))
|
||||
head = size * 0.26
|
||||
painter.drawLine(
|
||||
int(round(draw_to)), int(round(mid)),
|
||||
int(round(draw_to - head)), int(round(mid - head)),
|
||||
)
|
||||
painter.drawLine(
|
||||
int(round(draw_to)), int(round(mid)),
|
||||
int(round(draw_to - head)), int(round(mid + head)),
|
||||
)
|
||||
|
||||
|
||||
def _paint_settings(painter: QPainter, size: int) -> None:
|
||||
"""A cog: eight teeth around a ring, with the hole left open."""
|
||||
import math
|
||||
|
||||
centre = size / 2.0
|
||||
ring = size * 0.20
|
||||
tooth = size * 0.34
|
||||
painter.drawEllipse(QRectF(centre - ring, centre - ring, ring * 2, ring * 2))
|
||||
for index in range(8):
|
||||
angle = index * math.pi / 4.0
|
||||
dx, dy = math.cos(angle), math.sin(angle)
|
||||
painter.drawLine(
|
||||
int(round(centre + dx * ring)), int(round(centre + dy * ring)),
|
||||
int(round(centre + dx * tooth)), int(round(centre + dy * tooth)),
|
||||
)
|
||||
|
||||
|
||||
_PAINTERS = {
|
||||
"zoom_fit": _paint_zoom_fit,
|
||||
"box_zoom": _paint_box_zoom,
|
||||
"latest": _paint_latest,
|
||||
"settings": _paint_settings,
|
||||
}
|
||||
|
||||
|
||||
@lru_cache(maxsize=32)
|
||||
def pixmap(name: str, tint: str, size: int = ICON_SIZE) -> QPixmap:
|
||||
"""One monochrome icon, drawn in ``tint`` (a ``#RRGGBB`` token value)."""
|
||||
canvas = QPixmap(size, size)
|
||||
canvas.fill(Qt.transparent)
|
||||
paint = _PAINTERS.get(str(name))
|
||||
if paint is None:
|
||||
return canvas
|
||||
painter = QPainter(canvas)
|
||||
painter.setRenderHint(QPainter.Antialiasing, True)
|
||||
pen = QPen()
|
||||
pen.setWidthF(_PEN_WIDTH)
|
||||
pen.setCapStyle(Qt.RoundCap)
|
||||
pen.setJoinStyle(Qt.RoundJoin)
|
||||
pen.setColor(QColor(tint))
|
||||
painter.setPen(pen)
|
||||
painter.setBrush(Qt.NoBrush)
|
||||
try:
|
||||
paint(painter, size)
|
||||
finally:
|
||||
painter.end()
|
||||
return canvas
|
||||
|
||||
|
||||
def icon(name: str, theme: str, *, token: str = "text_secondary") -> QIcon:
|
||||
"""The named icon in the theme's colour for ``token``."""
|
||||
return QIcon(pixmap(name, color(theme, token)))
|
||||
|
||||
|
||||
def clear() -> None:
|
||||
"""Drop the cached pixmaps (used by tests and by a theme rebuild)."""
|
||||
pixmap.cache_clear()
|
||||
+10
-2
@@ -13,15 +13,23 @@ class Mesh3dPanel(QFrame):
|
||||
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.title)
|
||||
layout.addWidget(self.body, 1)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.title.setText(t("panels.mesh3d"))
|
||||
self.body.setText(t("panels.mesh3d_hint"))
|
||||
self.body.setText(t("panels.mesh3d_empty"))
|
||||
self.body.setToolTip(t("panels.mesh3d_empty_tip"))
|
||||
@@ -0,0 +1,392 @@
|
||||
"""Modal dialog for the force-plot window, Y-axis limits and the filters.
|
||||
|
||||
Two things live here because they are answered together, at the chart: how much
|
||||
time and range the curves show, and what the acquisition is allowed to filter.
|
||||
The filter half is a *request*: it is handed to the SDK through
|
||||
``FilterSettings.apply_to`` at the start of the next session, and what the device
|
||||
reports as done is what the record will say (see ``force/filters.py``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QDoubleSpinBox,
|
||||
QFormLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSpinBox,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
|
||||
from orisys_app_shared.force.filters import (
|
||||
SUGGESTED_LOWPASS_HZ,
|
||||
FilterSettings,
|
||||
available_presets,
|
||||
)
|
||||
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
|
||||
# Raw (uncalibrated) HHD units: sized from measured peaks so the idle jitter
|
||||
# stays sub-pixel while a full contact still fits (see prefs store).
|
||||
DEFAULT_PLOT_FN_RAW_MAX = 1000000.0
|
||||
DEFAULT_PLOT_SHEAR_RAW_MAX = 500000.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,
|
||||
fn_raw_max: float = DEFAULT_PLOT_FN_RAW_MAX,
|
||||
shear_raw_max: float = DEFAULT_PLOT_SHEAR_RAW_MAX,
|
||||
units: str = "newton",
|
||||
filters: FilterSettings | None = None,
|
||||
filters_locked: bool = False,
|
||||
sample_rate_hz: float = FORCE_SAMPLE_RATE_HZ,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setModal(True)
|
||||
self.resize(420, 520)
|
||||
self._sample_rate_hz = float(sample_rate_hz)
|
||||
#: True while the widgets are being filled in from a preset, so the
|
||||
#: "edited -> custom" handler does not fire on our own writes.
|
||||
self._syncing_filters = False
|
||||
|
||||
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))
|
||||
|
||||
# Raw limits: 0 means "auto range on the data".
|
||||
self.fn_raw_max_label = QLabel()
|
||||
self.fn_raw_max_spin = QDoubleSpinBox()
|
||||
self.fn_raw_max_spin.setRange(0.0, 1e9)
|
||||
self.fn_raw_max_spin.setSingleStep(50000.0)
|
||||
self.fn_raw_max_spin.setDecimals(0)
|
||||
self.fn_raw_max_spin.setValue(float(fn_raw_max))
|
||||
|
||||
self.shear_raw_max_label = QLabel()
|
||||
self.shear_raw_max_spin = QDoubleSpinBox()
|
||||
self.shear_raw_max_spin.setRange(0.0, 1e9)
|
||||
self.shear_raw_max_spin.setSingleStep(50000.0)
|
||||
self.shear_raw_max_spin.setDecimals(0)
|
||||
self.shear_raw_max_spin.setValue(float(shear_raw_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)
|
||||
form.addRow(self.fn_raw_max_label, self.fn_raw_max_spin)
|
||||
form.addRow(self.shear_raw_max_label, self.shear_raw_max_spin)
|
||||
root.addLayout(form)
|
||||
|
||||
newton = str(units or "newton").lower().startswith("n")
|
||||
raw_widgets = (
|
||||
self.fn_raw_max_spin,
|
||||
self.shear_raw_max_spin,
|
||||
self.fn_raw_max_label,
|
||||
self.shear_raw_max_label,
|
||||
)
|
||||
for widget in (self.fn_max_spin, self.shear_max_spin, self.fn_max_label,
|
||||
self.shear_max_label):
|
||||
widget.setEnabled(newton)
|
||||
for widget in raw_widgets:
|
||||
widget.setEnabled(not newton)
|
||||
|
||||
self._build_filter_section(root, filters or FilterSettings(), filters_locked)
|
||||
|
||||
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()
|
||||
|
||||
# ---------------------------------------------------------------- filters
|
||||
def _build_filter_section(
|
||||
self, root: QVBoxLayout, filters: FilterSettings, locked: bool
|
||||
) -> None:
|
||||
"""The filter half: a preset, its values, and what they cost."""
|
||||
self.filters_group = QGroupBox()
|
||||
group_form = QFormLayout(self.filters_group)
|
||||
|
||||
# The preset is shown by its own name — ``balanced`` is the string that
|
||||
# goes into ``sensor.json``, so the dialog and the config use one word for
|
||||
# one thing.
|
||||
self.preset_combo = QComboBox()
|
||||
self._preset_names = list(available_presets())
|
||||
for name in self._preset_names:
|
||||
self.preset_combo.addItem(name)
|
||||
self.preset_combo.addItem(t("dialog.filter_preset_custom"))
|
||||
self.preset_combo.currentIndexChanged.connect(self._on_preset_chosen)
|
||||
|
||||
self.flow_ema_spin = QDoubleSpinBox()
|
||||
self.flow_ema_spin.setRange(0.05, 1.0)
|
||||
self.flow_ema_spin.setSingleStep(0.05)
|
||||
self.flow_ema_spin.setDecimals(2)
|
||||
self.flow_ema_spin.setValue(float(filters.flow_ema_alpha))
|
||||
|
||||
self.median_spin = QSpinBox()
|
||||
self.median_spin.setRange(1, 31)
|
||||
self.median_spin.setValue(int(filters.median_window))
|
||||
|
||||
self.lowpass_spin = QDoubleSpinBox()
|
||||
self.lowpass_spin.setRange(0.0, 60.0)
|
||||
self.lowpass_spin.setSingleStep(1.0)
|
||||
self.lowpass_spin.setDecimals(1)
|
||||
self.lowpass_spin.setSuffix(" Hz")
|
||||
self.lowpass_spin.setValue(float(filters.lowpass_hz))
|
||||
|
||||
self.lowpass_order_spin = QSpinBox()
|
||||
self.lowpass_order_spin.setRange(1, 6)
|
||||
self.lowpass_order_spin.setValue(int(filters.lowpass_order))
|
||||
|
||||
self.rate_auto_check = QCheckBox()
|
||||
self.rate_auto_check.setChecked(str(filters.sample_rate_hz) == "auto")
|
||||
self.rate_spin = QDoubleSpinBox()
|
||||
self.rate_spin.setRange(1.0, 240.0)
|
||||
self.rate_spin.setSingleStep(1.0)
|
||||
self.rate_spin.setDecimals(1)
|
||||
self.rate_spin.setSuffix(" Hz")
|
||||
rate = filters.sample_rate_hz
|
||||
self.rate_spin.setValue(
|
||||
float(rate) if str(rate) != "auto" else float(FORCE_SAMPLE_RATE_HZ)
|
||||
)
|
||||
self.rate_spin.setEnabled(not self.rate_auto_check.isChecked())
|
||||
self.rate_auto_check.toggled.connect(
|
||||
lambda checked: self.rate_spin.setEnabled(not checked)
|
||||
)
|
||||
|
||||
self.filter_preset_label = QLabel()
|
||||
self.flow_ema_label = QLabel()
|
||||
self.median_label = QLabel()
|
||||
self.lowpass_label = QLabel()
|
||||
self.lowpass_order_label = QLabel()
|
||||
self.rate_label = QLabel()
|
||||
|
||||
group_form.addRow(self.filter_preset_label, self.preset_combo)
|
||||
group_form.addRow(self.flow_ema_label, self.flow_ema_spin)
|
||||
group_form.addRow(self.median_label, self.median_spin)
|
||||
group_form.addRow(self.lowpass_label, self.lowpass_spin)
|
||||
group_form.addRow(self.lowpass_order_label, self.lowpass_order_spin)
|
||||
group_form.addRow(self.rate_auto_check, self.rate_spin)
|
||||
|
||||
for widget in (self.flow_ema_spin, self.median_spin, self.lowpass_spin,
|
||||
self.rate_spin, self.rate_auto_check):
|
||||
widget.setToolTip("") # filled in by retranslate()
|
||||
self.flow_ema_spin.valueChanged.connect(self._on_filter_value_edited)
|
||||
self.median_spin.valueChanged.connect(self._on_filter_value_edited)
|
||||
self.lowpass_spin.valueChanged.connect(self._on_filter_value_edited)
|
||||
self.lowpass_order_spin.valueChanged.connect(self._on_filter_value_edited)
|
||||
self.rate_spin.valueChanged.connect(self._on_filter_value_edited)
|
||||
self.rate_auto_check.toggled.connect(self._on_filter_value_edited)
|
||||
|
||||
# Start on the preset the settings came from, or on "custom" when they
|
||||
# are a shape no preset has (a config edited by hand, a read-back state).
|
||||
# A named preset also *means* its values: showing the name next to
|
||||
# different numbers would be the same lie the config file used to tell.
|
||||
if filters.preset in self._preset_names:
|
||||
self._on_preset_chosen(self._preset_names.index(filters.preset))
|
||||
self._show_preset(filters.preset)
|
||||
|
||||
self.filter_note = QLabel()
|
||||
self.filter_note.setObjectName("hintText")
|
||||
self.filter_note.setWordWrap(True)
|
||||
self.filter_warning = QLabel()
|
||||
self.filter_warning.setObjectName("warnText")
|
||||
self.filter_warning.setWordWrap(True)
|
||||
root.addWidget(self.filters_group)
|
||||
root.addWidget(self.filter_note)
|
||||
root.addWidget(self.filter_warning)
|
||||
|
||||
self._filters_locked = bool(locked)
|
||||
self.filters_group.setEnabled(not self._filters_locked)
|
||||
self._refresh_filter_note()
|
||||
|
||||
def _show_preset(self, name: str | None) -> None:
|
||||
"""Point the combo at a preset (or at "custom") without re-applying it."""
|
||||
index = self._preset_names.index(name) if name in self._preset_names else -1
|
||||
self._syncing_filters = True
|
||||
try:
|
||||
self.preset_combo.setCurrentIndex(
|
||||
index if index >= 0 else self.preset_combo.count() - 1
|
||||
)
|
||||
finally:
|
||||
self._syncing_filters = False
|
||||
|
||||
def _on_preset_chosen(self, index: int) -> None:
|
||||
if self._syncing_filters:
|
||||
return
|
||||
if not (0 <= index < len(self._preset_names)):
|
||||
return # "custom": leave the numbers as the operator left them
|
||||
values = available_presets()[self._preset_names[index]]
|
||||
self._syncing_filters = True
|
||||
try:
|
||||
self.flow_ema_spin.setValue(float(values.get("flow_ema_alpha", 1.0)))
|
||||
self.median_spin.setValue(int(values.get("force_median_window", 1)))
|
||||
self.lowpass_spin.setValue(float(values.get("force_lowpass_cutoff_hz", 0.0)))
|
||||
finally:
|
||||
self._syncing_filters = False
|
||||
self._refresh_filter_note()
|
||||
|
||||
def _on_filter_value_edited(self, *_args) -> None:
|
||||
if self._syncing_filters:
|
||||
return
|
||||
# Any hand edit is no longer the preset the combo used to name.
|
||||
self._show_preset(None)
|
||||
self._refresh_filter_note()
|
||||
|
||||
def selected_preset(self) -> str | None:
|
||||
"""The preset the values still are, or ``None`` for a custom shape."""
|
||||
index = self.preset_combo.currentIndex()
|
||||
if 0 <= index < len(self._preset_names):
|
||||
return self._preset_names[index]
|
||||
return None
|
||||
|
||||
def filter_values(self) -> FilterSettings:
|
||||
rate: float | str = "auto"
|
||||
if not self.rate_auto_check.isChecked():
|
||||
rate = float(self.rate_spin.value())
|
||||
# ``show_unfiltered`` is deliberately not offered here yet: the second
|
||||
# (unfiltered) buffer is not wired to the cards or the CSV, and a switch
|
||||
# that promises a curve it does not draw is worse than no switch. The
|
||||
# field is kept in the settings (and in the record) until it lands.
|
||||
return FilterSettings(
|
||||
flow_ema_alpha=float(self.flow_ema_spin.value()),
|
||||
median_window=int(self.median_spin.value()),
|
||||
lowpass_hz=float(self.lowpass_spin.value()),
|
||||
lowpass_order=int(self.lowpass_order_spin.value()),
|
||||
sample_rate_hz=rate,
|
||||
show_unfiltered=False,
|
||||
preset=self.selected_preset(),
|
||||
).normalised()
|
||||
|
||||
def _refresh_filter_note(self) -> None:
|
||||
"""Say what these numbers amount to, and where they would not work."""
|
||||
if not hasattr(self, "filter_note"):
|
||||
return # still being built
|
||||
settings = self.filter_values()
|
||||
rate = self._sample_rate_hz
|
||||
if str(settings.sample_rate_hz) != "auto":
|
||||
rate = float(settings.sample_rate_hz)
|
||||
issue = settings.nyquist_issue(rate)
|
||||
self.filter_warning.setText(
|
||||
""
|
||||
if issue is None
|
||||
else t(
|
||||
"dialog.filter_nyquist",
|
||||
cutoff=f"{issue['cutoff_hz']:g}",
|
||||
nyquist=f"{issue['nyquist_hz']:g}",
|
||||
rate=f"{issue['rate_hz']:g}",
|
||||
)
|
||||
)
|
||||
self.filter_warning.setVisible(issue is not None)
|
||||
if self._filters_locked:
|
||||
self.filter_note.setText(t("dialog.filter_locked"))
|
||||
elif settings.flow_ema_on:
|
||||
self.filter_note.setText(t("dialog.filter_measurement_note"))
|
||||
elif not settings.active():
|
||||
self.filter_note.setText(t("dialog.filter_off"))
|
||||
else:
|
||||
self.filter_note.setText("")
|
||||
|
||||
def values(self) -> tuple[float, float, float, float, float]:
|
||||
return (
|
||||
float(self.window_spin.value()),
|
||||
float(self.fn_max_spin.value()),
|
||||
float(self.shear_max_spin.value()),
|
||||
float(self.fn_raw_max_spin.value()),
|
||||
float(self.shear_raw_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)
|
||||
self.fn_raw_max_spin.setValue(DEFAULT_PLOT_FN_RAW_MAX)
|
||||
self.shear_raw_max_spin.setValue(DEFAULT_PLOT_SHEAR_RAW_MAX)
|
||||
# "Reset" means the shipped defaults, and those filter nothing — the same
|
||||
# answer the dialog gives for a preference file that says nothing.
|
||||
self.flow_ema_spin.setValue(1.0)
|
||||
self.median_spin.setValue(1)
|
||||
self.lowpass_spin.setValue(0.0)
|
||||
self.lowpass_order_spin.setValue(2)
|
||||
self.rate_auto_check.setChecked(True)
|
||||
self._show_preset("off")
|
||||
self._refresh_filter_note()
|
||||
|
||||
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.fn_raw_max_label.setText(t("dialog.plot_fn_raw_max"))
|
||||
self.fn_raw_max_spin.setToolTip(t("dialog.plot_fn_raw_max_tip"))
|
||||
self.shear_raw_max_label.setText(t("dialog.plot_shear_raw_max"))
|
||||
self.shear_raw_max_spin.setToolTip(t("dialog.plot_shear_raw_max_tip"))
|
||||
self.btn_reset.setText(t("dialog.reset_defaults"))
|
||||
self.filters_group.setTitle(t("dialog.plot_filters"))
|
||||
self.filter_preset_label.setText(t("dialog.filter_preset"))
|
||||
self.preset_combo.setToolTip(t("dialog.filter_preset_tip"))
|
||||
self.preset_combo.setItemText(self.preset_combo.count() - 1, t("dialog.filter_preset_custom"))
|
||||
self.flow_ema_label.setText(t("dialog.filter_flow_ema"))
|
||||
self.flow_ema_spin.setToolTip(t("dialog.filter_flow_ema_tip"))
|
||||
self.median_label.setText(t("dialog.filter_median"))
|
||||
self.median_spin.setToolTip(t("dialog.filter_median_tip"))
|
||||
self.lowpass_label.setText(t("dialog.filter_lowpass"))
|
||||
self.lowpass_spin.setToolTip(t("dialog.filter_lowpass_tip"))
|
||||
self.lowpass_order_label.setText(t("dialog.filter_order"))
|
||||
self.lowpass_order_spin.setToolTip(t("dialog.filter_order_tip"))
|
||||
self.rate_auto_check.setText(t("dialog.filter_rate_auto"))
|
||||
self.rate_auto_check.setToolTip(t("dialog.filter_rate_auto_tip"))
|
||||
self.rate_label.setText(t("dialog.filter_rate"))
|
||||
self.rate_spin.setToolTip(t("dialog.filter_rate_tip"))
|
||||
self._refresh_filter_note()
|
||||
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,204 @@
|
||||
"""Record status strip: one truthful, always-visible readout of the record channel.
|
||||
|
||||
The strip answers four questions the record button alone cannot:
|
||||
|
||||
* is this session *acquiring* or also *recording* (the phase rail says the first);
|
||||
* for how long has the recording been running;
|
||||
* did the writer drop anything;
|
||||
* did a write fail.
|
||||
|
||||
All of that comes from :class:`~orisys_app_shared.record.experiment.RecordStats`,
|
||||
which the recorder exposes as a locked snapshot, so the UI can read it on its own
|
||||
timer without touching the writer thread.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtWidgets import QLabel
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
#: The recording channel is idle, never started in this session.
|
||||
IDLE = "idle"
|
||||
#: A recording is running; the elapsed time ticks every UI frame.
|
||||
RECORDING = "recording"
|
||||
#: The writer is still closing its files — never present that as finished.
|
||||
CLOSING = "closing"
|
||||
#: A write failed, or frames were rejected after a failure.
|
||||
ERROR = "error"
|
||||
#: A finished recording (of this session) is being summarised.
|
||||
DONE = "done"
|
||||
|
||||
STATES = (IDLE, RECORDING, CLOSING, ERROR, DONE)
|
||||
|
||||
#: Which theme token colours the chip in each state.
|
||||
_STATE_TOKENS = {
|
||||
IDLE: "status_idle",
|
||||
RECORDING: "status_running",
|
||||
CLOSING: "status_connecting",
|
||||
ERROR: "status_error",
|
||||
DONE: "status_idle",
|
||||
}
|
||||
|
||||
_STATE_KEYS = {
|
||||
IDLE: "record.state_idle",
|
||||
RECORDING: "record.state_recording",
|
||||
CLOSING: "record.state_closing",
|
||||
ERROR: "record.state_error",
|
||||
DONE: "record.state_done",
|
||||
}
|
||||
|
||||
|
||||
def format_elapsed(seconds: object) -> str:
|
||||
"""``mm:ss.d`` for a duration in seconds; never negative, never raising."""
|
||||
try:
|
||||
value = max(0.0, float(seconds)) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
value = 0.0
|
||||
minutes, rest = divmod(value, 60.0)
|
||||
return f"{int(minutes):02d}:{rest:04.1f}"
|
||||
|
||||
|
||||
class RecordStatusChip(QLabel):
|
||||
"""Compact record-state chip (text + colour), stable under 60 Hz updates."""
|
||||
|
||||
def __init__(self, parent=None, *, theme: str = "dark") -> None:
|
||||
super().__init__(parent)
|
||||
self._theme = str(theme)
|
||||
self._state = IDLE
|
||||
self._duration_s = 0.0
|
||||
self._frames = 0
|
||||
self._segments = 0
|
||||
self._dropped = 0
|
||||
self._dropped_data = 0
|
||||
self._dropped_video = 0
|
||||
self._dropped_gaps = 0
|
||||
self._errors = 0
|
||||
self._detail = ""
|
||||
self._directory = ""
|
||||
self._token = ""
|
||||
self._tooltip_cache = ""
|
||||
self.setObjectName("recordChip")
|
||||
self._refresh()
|
||||
|
||||
# ------------------------------------------------------------------ API
|
||||
def state(self) -> str:
|
||||
return self._state
|
||||
|
||||
def stats(self) -> dict:
|
||||
"""The raw numbers behind the current text (used by tooltips/tests)."""
|
||||
return {
|
||||
"state": self._state,
|
||||
"duration_s": self._duration_s,
|
||||
"frames": self._frames,
|
||||
"segments": self._segments,
|
||||
"dropped": self._dropped,
|
||||
"dropped_data": self._dropped_data,
|
||||
"dropped_video": self._dropped_video,
|
||||
"dropped_gaps": self._dropped_gaps,
|
||||
"errors": self._errors,
|
||||
"detail": self._detail,
|
||||
"directory": self._directory,
|
||||
}
|
||||
|
||||
def set_state(
|
||||
self,
|
||||
state: str,
|
||||
*,
|
||||
duration_s: float = 0.0,
|
||||
frames: int = 0,
|
||||
segments: int = 0,
|
||||
dropped: int = 0,
|
||||
dropped_data: int = 0,
|
||||
dropped_video: int = 0,
|
||||
dropped_gaps: int = 0,
|
||||
errors: int = 0,
|
||||
detail: str = "",
|
||||
directory: str = "",
|
||||
) -> None:
|
||||
"""Update every number the strip can show, then repaint if it changed."""
|
||||
self._state = state if state in STATES else IDLE
|
||||
self._duration_s = float(duration_s or 0.0)
|
||||
self._frames = int(frames or 0)
|
||||
self._segments = int(segments or 0)
|
||||
self._dropped = int(dropped or 0)
|
||||
self._dropped_data = int(dropped_data or 0)
|
||||
self._dropped_video = int(dropped_video or 0)
|
||||
self._dropped_gaps = int(dropped_gaps or 0)
|
||||
self._errors = int(errors or 0)
|
||||
self._detail = str(detail or "")
|
||||
self._directory = str(directory or "")
|
||||
self._refresh()
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
self._theme = str(theme)
|
||||
self._refresh()
|
||||
|
||||
def set_directory(self, directory: str) -> None:
|
||||
"""Where the recording is being written (shown in the tooltip)."""
|
||||
self._directory = str(directory or "")
|
||||
self._refresh()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self._refresh()
|
||||
|
||||
# ------------------------------------------------------------- internals
|
||||
def _text_for_state(self) -> str:
|
||||
if self._state == RECORDING:
|
||||
return t("record.state_recording", duration=format_elapsed(self._duration_s))
|
||||
if self._state == CLOSING:
|
||||
return t("record.state_closing")
|
||||
if self._state == ERROR:
|
||||
return t("record.state_error", count=self._errors)
|
||||
if self._state == DONE:
|
||||
return t("record.state_done", frames=self._frames)
|
||||
return t("record.state_idle")
|
||||
|
||||
def _tooltip(self) -> str:
|
||||
if self._state == IDLE:
|
||||
return t("record.strip_idle")
|
||||
return t(
|
||||
"record.strip_tip",
|
||||
state=self._text_for_state(),
|
||||
frames=self._frames,
|
||||
segments=self._segments,
|
||||
duration=format_elapsed(self._duration_s),
|
||||
dropped=self._dropped,
|
||||
dropped_data=self._dropped_data,
|
||||
dropped_video=self._dropped_video,
|
||||
dropped_gaps=self._dropped_gaps,
|
||||
errors=self._errors,
|
||||
detail=self._detail or t("record.strip_no_error"),
|
||||
path=self._directory or t("record.strip_no_path"),
|
||||
)
|
||||
|
||||
def _leading_token(self) -> str:
|
||||
# Anything dropped is worth warning about even while the state is green;
|
||||
# a write failure outranks it.
|
||||
if self._state == ERROR:
|
||||
return _STATE_TOKENS[ERROR]
|
||||
if self._dropped > 0:
|
||||
return "warning"
|
||||
return _STATE_TOKENS[self._state]
|
||||
|
||||
def _refresh(self) -> None:
|
||||
text = self._text_for_state()
|
||||
if self._dropped > 0:
|
||||
text = f"{text} · {t('record.dropped', count=self._dropped)}"
|
||||
# ``setText`` on every frame would re-layout the rail 60 times a second.
|
||||
if text != self.text():
|
||||
self.setText(text)
|
||||
tooltip = self._tooltip()
|
||||
if tooltip != self._tooltip_cache:
|
||||
self._tooltip_cache = tooltip
|
||||
self.setToolTip(tooltip)
|
||||
token = self._leading_token()
|
||||
if token != self._token:
|
||||
# Re-parsing a stylesheet per frame is measurable; the token only
|
||||
# changes when the state (or the theme) does.
|
||||
self._token = token
|
||||
self.setStyleSheet(
|
||||
f"QLabel#recordChip {{ color: {color(self._theme, token)};"
|
||||
" background: transparent; }"
|
||||
)
|
||||
@@ -0,0 +1,203 @@
|
||||
"""Sensor and calibration dialog.
|
||||
|
||||
The status rail shows one summary button (``BOX · box/sensor.json``): which
|
||||
product and config the window is configured for is worth reading at a glance,
|
||||
but the pickers themselves — and the calibration that belongs to them — need
|
||||
more room than a 26 px rail can give.
|
||||
|
||||
The calibration half is read-only on purpose: it states which calibration the
|
||||
*device* confirmed, which axes it covers, and what the last exported
|
||||
coefficients file says it belongs to. Exporting and applying stay single-entry
|
||||
actions (they are the calibration panel's own methods), so there is exactly one
|
||||
implementation of each.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from PyQt5.QtWidgets import (
|
||||
QDialog,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QVBoxLayout,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
|
||||
from orisys_studio.ui.fit_text import ShrinkableLabel
|
||||
|
||||
|
||||
class SensorDialog(QDialog):
|
||||
"""Sensor type / config picker plus the calibration facts that follow it."""
|
||||
|
||||
def __init__(self, *, window, picker, theme: str = "dark") -> None:
|
||||
super().__init__(window)
|
||||
self._window = window
|
||||
self._theme = str(theme)
|
||||
self.picker = picker
|
||||
self.setObjectName("sensorDialog")
|
||||
self.setModal(False)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setSpacing(10)
|
||||
|
||||
self.picker_row = QHBoxLayout()
|
||||
self.picker_row.addWidget(self.picker)
|
||||
self.picker_row.addStretch(1)
|
||||
root.addLayout(self.picker_row)
|
||||
|
||||
root.addWidget(self._section_rule())
|
||||
self.calib_title = QLabel()
|
||||
self.calib_title.setObjectName("panelTitle")
|
||||
root.addWidget(self.calib_title)
|
||||
self.version_label = ShrinkableLabel()
|
||||
self.version_label.setObjectName("panelMeta")
|
||||
self.axes_label = ShrinkableLabel()
|
||||
self.axes_label.setObjectName("panelMeta")
|
||||
self.config_label = ShrinkableLabel()
|
||||
self.config_label.setObjectName("panelMeta")
|
||||
for label in (self.version_label, self.axes_label):
|
||||
root.addWidget(label)
|
||||
config_row = QHBoxLayout()
|
||||
config_row.addWidget(self.config_label)
|
||||
self.btn_reveal = QPushButton()
|
||||
self.btn_reveal.setProperty("compact", "true")
|
||||
self.btn_reveal.clicked.connect(self._window._on_reveal_config)
|
||||
config_row.addWidget(self.btn_reveal)
|
||||
config_row.addStretch(1)
|
||||
root.addLayout(config_row)
|
||||
|
||||
root.addWidget(self._section_rule())
|
||||
self.export_title = QLabel()
|
||||
self.export_title.setObjectName("panelTitle")
|
||||
root.addWidget(self.export_title)
|
||||
self.export_label = ShrinkableLabel()
|
||||
self.export_label.setObjectName("panelMeta")
|
||||
self.identity_label = ShrinkableLabel()
|
||||
self.identity_label.setObjectName("panelMeta")
|
||||
self.export_label.setWordWrap(True)
|
||||
self.identity_label.setWordWrap(True)
|
||||
root.addWidget(self.export_label)
|
||||
root.addWidget(self.identity_label)
|
||||
|
||||
actions = QHBoxLayout()
|
||||
actions.setSpacing(6)
|
||||
self.btn_export = QPushButton()
|
||||
self.btn_export.clicked.connect(self._on_export)
|
||||
self.btn_apply = QPushButton()
|
||||
self.btn_apply.clicked.connect(self._on_apply)
|
||||
self.btn_close = QPushButton()
|
||||
self.btn_close.setProperty("compact", "true")
|
||||
self.btn_close.clicked.connect(self.close)
|
||||
actions.addWidget(self.btn_export)
|
||||
actions.addWidget(self.btn_apply)
|
||||
actions.addStretch(1)
|
||||
actions.addWidget(self.btn_close)
|
||||
root.addLayout(actions)
|
||||
|
||||
# The picker's own changes change the calibration context, so the dialog
|
||||
# follows them while it is open.
|
||||
self.picker.sensor_type_changed.connect(lambda _value: self.refresh())
|
||||
self.picker.config_changed.connect(lambda _value: self.refresh())
|
||||
|
||||
self.retranslate()
|
||||
self.refresh()
|
||||
|
||||
@staticmethod
|
||||
def _section_rule() -> QFrame:
|
||||
rule = QFrame()
|
||||
rule.setObjectName("sectionSeparator")
|
||||
rule.setFrameShape(QFrame.HLine)
|
||||
rule.setFixedHeight(1)
|
||||
return rule
|
||||
|
||||
# ------------------------------------------------------------------ actions
|
||||
def _on_export(self) -> None:
|
||||
self._window.cal_panel.export_coefficients()
|
||||
self.refresh()
|
||||
|
||||
def _on_apply(self) -> None:
|
||||
self._window.cal_panel.apply_coefficients()
|
||||
self.refresh()
|
||||
|
||||
# ------------------------------------------------------------------ content
|
||||
def refresh(self) -> None:
|
||||
"""Re-read the shell's calibration state. Called on open and on change."""
|
||||
window = self._window
|
||||
version = str(getattr(window, "_active_calibration_id", "") or "")
|
||||
self.version_label.setText(
|
||||
f"{t('sensor.calib_version')}: {version or t('sensor.calib_none')}"
|
||||
)
|
||||
axes = ""
|
||||
summary = getattr(window, "_calibration_summary", None)
|
||||
if callable(summary):
|
||||
axes = str(summary())
|
||||
self.axes_label.setText(f"{t('sensor.calib_axes')}: {axes or t('sensor.calib_none')}")
|
||||
config_ref = ""
|
||||
resolve = getattr(window, "_resolve_config_ref", None)
|
||||
if callable(resolve):
|
||||
config_ref = str(resolve() or "")
|
||||
self.config_label.setText(
|
||||
f"{t('sensor.calib_config')}: "
|
||||
f"{Path(config_ref).name if config_ref else t('status.config_none')}"
|
||||
)
|
||||
self.btn_reveal.setEnabled(bool(config_ref))
|
||||
|
||||
export = str(getattr(window, "_last_calibration_export", "") or "")
|
||||
if not export:
|
||||
self.export_label.setText(t("sensor.export_none"))
|
||||
self.identity_label.setText("")
|
||||
self.btn_apply.setEnabled(False)
|
||||
return
|
||||
path = Path(export)
|
||||
self.export_label.setText(
|
||||
t(
|
||||
"sensor.export_file",
|
||||
name=path.name,
|
||||
size=f"{path.stat().st_size / 1024:.1f}" if path.is_file() else "?",
|
||||
time=(
|
||||
datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
|
||||
if path.is_file()
|
||||
else "?"
|
||||
),
|
||||
)
|
||||
)
|
||||
identity = {}
|
||||
read_identity = getattr(window, "calibration_identity", None)
|
||||
if callable(read_identity):
|
||||
identity = dict(read_identity(export) or {})
|
||||
self.identity_label.setText(
|
||||
t(
|
||||
"sensor.export_identity",
|
||||
sensor_type=identity.get("sensor_type") or "?",
|
||||
sn=identity.get("sn") or "?",
|
||||
config=Path(str(identity.get("config_ref") or "?")).name,
|
||||
)
|
||||
)
|
||||
self.btn_apply.setEnabled(True)
|
||||
|
||||
def retranslate(self) -> None:
|
||||
self.setWindowTitle(t("sensor.dialog_title"))
|
||||
self.calib_title.setText(t("sensor.calib_section"))
|
||||
self.export_title.setText(t("sensor.export_section"))
|
||||
self.btn_reveal.setText(t("menu.reveal_config"))
|
||||
self.btn_export.setText(t("cal.export"))
|
||||
self.btn_export.setToolTip(t("cal.export_tip"))
|
||||
self.btn_apply.setText(t("cal.apply"))
|
||||
self.btn_apply.setToolTip(t("cal.apply_tip"))
|
||||
self.btn_close.setText(t("dialog.cancel"))
|
||||
self.picker.retranslate()
|
||||
self.refresh()
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
self._theme = str(theme)
|
||||
|
||||
def showEvent(self, event) -> None: # noqa: N802 - Qt naming
|
||||
# Every open re-reads the shell: the device may have confirmed a
|
||||
# calibration since the dialog was last shown.
|
||||
self.refresh()
|
||||
super().showEvent(event)
|
||||
+56
-26
@@ -1,13 +1,32 @@
|
||||
"""Sensor type + config picker with thumbnail placeholder."""
|
||||
"""Sensor type + config picker.
|
||||
|
||||
One row, because it lives at the left of the status rail: a badge naming the
|
||||
selected product, then the product/sensor choice and the config file that
|
||||
belongs to it. The order matters — picking ``box`` / ``mini`` / ``finger`` is
|
||||
what decides which configs exist, so the two sit next to each other with their
|
||||
own labels and tooltips instead of a bare "配置" combo.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtWidgets import QComboBox, QFrame, QHBoxLayout, QLabel, QVBoxLayout, QWidget
|
||||
from PyQt5.QtWidgets import (
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QSizePolicy,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.sensors import list_configs_for_type, list_sensor_types
|
||||
|
||||
CONTROL_HEIGHT = 30
|
||||
#: Both combos stay narrow: this row shares one line with the session state.
|
||||
TYPE_WIDTH = 76
|
||||
CONFIG_WIDTH = 120
|
||||
BADGE_WIDTH = 46
|
||||
|
||||
|
||||
class SensorPickerWidget(QWidget):
|
||||
sensor_type_changed = pyqtSignal(str)
|
||||
@@ -17,39 +36,44 @@ class SensorPickerWidget(QWidget):
|
||||
super().__init__(parent)
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(8)
|
||||
layout.setSpacing(6)
|
||||
|
||||
self.thumb = QFrame()
|
||||
self.thumb.setObjectName("contentPanel")
|
||||
self.thumb.setFixedSize(72, 72)
|
||||
thumb_layout = QVBoxLayout(self.thumb)
|
||||
#: The product badge that used to sit above the combos. Kept as an
|
||||
#: attribute: it states which sensor the whole window is configured for.
|
||||
self.thumb_label = QLabel()
|
||||
self.thumb_label.setObjectName("unitBadge")
|
||||
self.thumb_label.setAlignment(Qt.AlignCenter)
|
||||
self.thumb_label.setWordWrap(True)
|
||||
thumb_layout.addWidget(self.thumb_label)
|
||||
# A minimum, not a fixed width: "FINGER" must not be truncated to "INGE".
|
||||
self.thumb_label.setMinimumWidth(BADGE_WIDTH)
|
||||
self.thumb_label.setProperty("rail", "true")
|
||||
# The old name, kept so callers/tests that hide or tooltip the product
|
||||
# badge keep working.
|
||||
self.thumb = self.thumb_label
|
||||
|
||||
form = QVBoxLayout()
|
||||
form.setSpacing(4)
|
||||
type_row = QHBoxLayout()
|
||||
self.type_label = QLabel()
|
||||
self.type_label.setObjectName("fieldLabel")
|
||||
self.type_combo = QComboBox()
|
||||
self.type_combo.setMinimumWidth(TYPE_WIDTH)
|
||||
self.type_combo.setProperty("rail", "true")
|
||||
self.type_combo.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
for name in list_sensor_types():
|
||||
self.type_combo.addItem(name, name)
|
||||
self.type_combo.currentIndexChanged.connect(self._on_type_index_changed)
|
||||
type_row.addWidget(self.type_label)
|
||||
type_row.addWidget(self.type_combo, 1)
|
||||
|
||||
config_row = QHBoxLayout()
|
||||
self.config_label = QLabel()
|
||||
self.config_label.setObjectName("fieldLabel")
|
||||
self.config_combo = QComboBox()
|
||||
self.config_combo.setMinimumWidth(CONFIG_WIDTH)
|
||||
self.config_combo.setProperty("rail", "true")
|
||||
self.config_combo.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed)
|
||||
self.config_combo.currentIndexChanged.connect(self._on_config_index_changed)
|
||||
config_row.addWidget(self.config_label)
|
||||
config_row.addWidget(self.config_combo, 1)
|
||||
|
||||
form.addLayout(type_row)
|
||||
form.addLayout(config_row)
|
||||
layout.addWidget(self.thumb)
|
||||
layout.addLayout(form, 1)
|
||||
layout.addWidget(self.thumb_label)
|
||||
layout.addWidget(self.type_label)
|
||||
layout.addWidget(self.type_combo)
|
||||
layout.addWidget(self.config_label)
|
||||
layout.addWidget(self.config_combo, 1)
|
||||
|
||||
self._reload_configs()
|
||||
self._refresh_thumb()
|
||||
self.retranslate()
|
||||
@@ -88,6 +112,11 @@ class SensorPickerWidget(QWidget):
|
||||
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()
|
||||
@@ -98,8 +127,7 @@ class SensorPickerWidget(QWidget):
|
||||
self.config_combo.blockSignals(False)
|
||||
|
||||
def _refresh_thumb(self) -> None:
|
||||
name = self.sensor_type().upper()
|
||||
self.thumb_label.setText(name)
|
||||
self.thumb_label.setText(self.sensor_type().upper())
|
||||
|
||||
def _on_type_index_changed(self, _index: int) -> None:
|
||||
self._reload_configs()
|
||||
@@ -113,7 +141,9 @@ class SensorPickerWidget(QWidget):
|
||||
def retranslate(self) -> None:
|
||||
self.type_label.setText(t("sensor.type"))
|
||||
self.config_label.setText(t("sensor.config"))
|
||||
if not self.thumb_label.text().strip():
|
||||
self.thumb_label.setText(t("sensor.thumb_placeholder"))
|
||||
else:
|
||||
self.type_label.setToolTip(t("sensor.type_tip"))
|
||||
self.config_label.setToolTip(t("sensor.config_tip"))
|
||||
self.type_combo.setToolTip(t("sensor.type_tip"))
|
||||
self.config_combo.setToolTip(t("sensor.config_tip"))
|
||||
self.thumb_label.setToolTip(t("sensor.thumb_placeholder"))
|
||||
self._refresh_thumb()
|
||||
@@ -0,0 +1,701 @@
|
||||
"""Top of the studio shell: status rail and a collapsible settings area.
|
||||
|
||||
The rail is what an operator watches while a session runs — phase, device,
|
||||
calibration, data validity and the record channel — plus the three actions that
|
||||
must never be more than one click away (record, run/stop, reset).
|
||||
|
||||
Everything that is *configuration* rather than state — source, camera number,
|
||||
video file, units and the sensor/config pickers — lives in the settings area,
|
||||
collapsed by default. Both live inside one scroll area so a narrow window
|
||||
scrolls sideways instead of squeezing the controls into an unreadable row.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtCore import Qt, pyqtSignal
|
||||
from PyQt5.QtWidgets import (
|
||||
QComboBox,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
from orisys_studio.ui.fit_text import ShrinkableLabel
|
||||
from orisys_studio.ui.record_status import RecordStatusChip
|
||||
|
||||
CONTROL_HEIGHT = 30
|
||||
BUTTON_HEIGHT = 32
|
||||
|
||||
#: Recording modes, in combo order. ``value`` is what the app consumes.
|
||||
RECORD_MODES = (("video_data", "record.mode_video_data"), ("data_only", "record.mode_data_only"))
|
||||
|
||||
|
||||
def _separator() -> QFrame:
|
||||
line = QFrame()
|
||||
line.setObjectName("sectionSeparator")
|
||||
line.setFrameShape(QFrame.VLine)
|
||||
line.setFixedWidth(1)
|
||||
line.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Expanding)
|
||||
return line
|
||||
|
||||
|
||||
def _rail(widget: QWidget) -> QWidget:
|
||||
"""Mark a control as belonging to the thin status rail.
|
||||
|
||||
The rail's height is the app's first visual weight statement: without this
|
||||
property every button would keep the dialog padding and the rail would grow
|
||||
to twice its content.
|
||||
"""
|
||||
widget.setProperty("rail", "true")
|
||||
return widget
|
||||
|
||||
|
||||
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)
|
||||
#: The sensor summary button: opens the sensor and calibration dialog.
|
||||
sensor_clicked = pyqtSignal()
|
||||
#: "Back to the camera" while a video file is the run source.
|
||||
use_camera_clicked = pyqtSignal()
|
||||
#: Replay view: pick the recorded video to play.
|
||||
replay_video_clicked = pyqtSignal()
|
||||
run_toggled = pyqtSignal(bool)
|
||||
reset_clicked = pyqtSignal()
|
||||
units_changed = pyqtSignal(str)
|
||||
record_toggled = pyqtSignal(bool)
|
||||
#: The settings area was expanded or collapsed by the user.
|
||||
settings_toggled = pyqtSignal(bool)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
*,
|
||||
theme: str = "dark",
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._theme = str(theme)
|
||||
self._run_tooltip_reason = ""
|
||||
self._run_available = True
|
||||
self._run_phase = "idle"
|
||||
self._settings_expanded = False
|
||||
#: "camera" (the default) or "video": an offline acquisition run fed from
|
||||
#: a video file. There is no source *combobox* any more — the view
|
||||
#: decides which picker is on screen, and the file menu switches modes.
|
||||
self._source_mode = "camera"
|
||||
self._source_view = "live"
|
||||
self._source_controls_enabled = True
|
||||
self._video_path = ""
|
||||
#: ``acquire`` while measuring, ``playback`` while replaying a recording.
|
||||
self._run_role = "acquire"
|
||||
|
||||
outer = QVBoxLayout(self)
|
||||
outer.setContentsMargins(2, 2, 2, 2)
|
||||
outer.setSpacing(4)
|
||||
|
||||
# The rail is always visible and never scrolls: it is one instrument
|
||||
# line, and a scroll area around it would reserve a scrollbar's height
|
||||
# (30 px in the Fusion metrics) even when nothing scrolls.
|
||||
outer.addLayout(self._build_rail())
|
||||
|
||||
# Only the settings area scrolls; it is the part that can outgrow a
|
||||
# narrow window.
|
||||
self._settings_scroll = QScrollArea()
|
||||
self._settings_scroll.setWidgetResizable(True)
|
||||
self._settings_scroll.setFrameShape(QFrame.NoFrame)
|
||||
self._settings_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
self._settings_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
|
||||
self.settings_page = self._build_settings()
|
||||
self._settings_scroll.setWidget(self.settings_page)
|
||||
outer.addWidget(self._settings_scroll)
|
||||
|
||||
self._populate_units_combo()
|
||||
self._populate_record_modes()
|
||||
self.retranslate()
|
||||
self._apply_source_mode_ui()
|
||||
self.set_settings_expanded(False)
|
||||
|
||||
# --------------------------------------------------------------- building
|
||||
def _build_rail(self) -> QHBoxLayout:
|
||||
"""Sensor, phase, capture source, calibration, validity, record, actions.
|
||||
|
||||
The sensor choice is one summary button (the pickers themselves live in
|
||||
the sensor dialog), and the capture source is the picker the *current
|
||||
view* needs: a camera id while acquiring, a video file while running
|
||||
from a file. Neither is a "source mode" combobox to be discovered.
|
||||
"""
|
||||
rail = QHBoxLayout()
|
||||
rail.setSpacing(8)
|
||||
|
||||
self.btn_sensor = QPushButton()
|
||||
self.btn_sensor.setObjectName("ghostButton")
|
||||
self.btn_sensor.setProperty("compact", "true")
|
||||
self.btn_sensor.setMinimumHeight(BUTTON_HEIGHT)
|
||||
self.btn_sensor.clicked.connect(self.sensor_clicked.emit)
|
||||
|
||||
self.status_chip = QLabel()
|
||||
self.status_chip.setObjectName("statusChip")
|
||||
self.status_chip.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Preferred)
|
||||
self.source_summary = ShrinkableLabel()
|
||||
self.source_summary.setObjectName("panelMeta")
|
||||
self.calibration_label = ShrinkableLabel()
|
||||
self.calibration_label.setObjectName("panelMeta")
|
||||
self.data_state = ShrinkableLabel()
|
||||
self.data_state.setObjectName("panelMeta")
|
||||
self.data_state.setVisible(False)
|
||||
self.record_chip = RecordStatusChip(theme=self._theme)
|
||||
|
||||
# --- capture source (one of the two groups is visible) -------------
|
||||
self.source_slot = QWidget()
|
||||
slot = QHBoxLayout(self.source_slot)
|
||||
slot.setContentsMargins(0, 0, 0, 0)
|
||||
slot.setSpacing(6)
|
||||
self.camera_group = QWidget()
|
||||
cam_layout = QHBoxLayout(self.camera_group)
|
||||
cam_layout.setContentsMargins(0, 0, 0, 0)
|
||||
cam_layout.setSpacing(6)
|
||||
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(64)
|
||||
self.cam_id_spin.setProperty("rail", "true")
|
||||
self.cam_id_spin.valueChanged.connect(self.camera_id_changed.emit)
|
||||
self.btn_search = QPushButton()
|
||||
self.btn_search.setProperty("compact", "true")
|
||||
self.btn_search.setMinimumHeight(BUTTON_HEIGHT)
|
||||
self.btn_search.clicked.connect(self.search_clicked.emit)
|
||||
cam_layout.addWidget(self.cam_id_label)
|
||||
cam_layout.addWidget(self.cam_id_spin)
|
||||
cam_layout.addWidget(self.btn_search)
|
||||
self.video_group = QWidget()
|
||||
video_layout = QHBoxLayout(self.video_group)
|
||||
video_layout.setContentsMargins(0, 0, 0, 0)
|
||||
video_layout.setSpacing(6)
|
||||
self.btn_use_camera = QPushButton()
|
||||
self.btn_use_camera.setProperty("compact", "true")
|
||||
self.btn_use_camera.setMinimumHeight(BUTTON_HEIGHT)
|
||||
self.btn_use_camera.clicked.connect(self.use_camera_clicked.emit)
|
||||
video_layout.addWidget(self.btn_use_camera)
|
||||
# The replay view's source is a recorded video: one action in the same
|
||||
# slot the live view uses for its camera. The file itself is named by the
|
||||
# summary next to it, so the rail reads the same way in both views.
|
||||
self.replay_group = QWidget()
|
||||
replay_layout = QHBoxLayout(self.replay_group)
|
||||
replay_layout.setContentsMargins(0, 0, 0, 0)
|
||||
replay_layout.setSpacing(6)
|
||||
self.btn_replay_video = QPushButton()
|
||||
self.btn_replay_video.setProperty("compact", "true")
|
||||
self.btn_replay_video.setMinimumHeight(BUTTON_HEIGHT)
|
||||
self.btn_replay_video.clicked.connect(self.replay_video_clicked.emit)
|
||||
replay_layout.addWidget(self.btn_replay_video)
|
||||
slot.addWidget(self.camera_group)
|
||||
slot.addWidget(self.video_group)
|
||||
slot.addWidget(self.replay_group)
|
||||
|
||||
self.btn_record = QPushButton()
|
||||
self.btn_record.setObjectName("primaryButton")
|
||||
self.btn_record.setMinimumHeight(BUTTON_HEIGHT)
|
||||
self.btn_record.setMinimumWidth(96)
|
||||
self.btn_record.setCheckable(True)
|
||||
self.btn_record.setEnabled(False)
|
||||
self.btn_record.toggled.connect(self._on_record_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)
|
||||
|
||||
self.btn_settings = QPushButton()
|
||||
self.btn_settings.setObjectName("ghostButton")
|
||||
self.btn_settings.setProperty("compact", "true")
|
||||
self.btn_settings.setMinimumHeight(BUTTON_HEIGHT)
|
||||
self.btn_settings.setCheckable(True)
|
||||
self.btn_settings.toggled.connect(self._on_settings_toggled)
|
||||
|
||||
rail.addWidget(_rail(self.btn_sensor))
|
||||
rail.addWidget(_separator())
|
||||
rail.addWidget(self.status_chip)
|
||||
rail.addWidget(_separator())
|
||||
rail.addWidget(self.source_slot)
|
||||
rail.addWidget(self.source_summary)
|
||||
rail.addWidget(self.calibration_label)
|
||||
rail.addWidget(self.data_state)
|
||||
rail.addStretch(1)
|
||||
rail.addWidget(self.record_chip)
|
||||
rail.addWidget(_rail(self.btn_record))
|
||||
rail.addWidget(_rail(self.btn_run))
|
||||
rail.addWidget(_rail(self.btn_reset))
|
||||
rail.addWidget(_rail(self.btn_settings))
|
||||
return rail
|
||||
|
||||
def _build_settings(self) -> QWidget:
|
||||
"""Units, record mode and preview.
|
||||
|
||||
The capture source is on the rail (see ``_build_rail``) because it is
|
||||
what the current view is *about*, not a setting to dig for.
|
||||
"""
|
||||
self.settings_page = QWidget()
|
||||
page = QVBoxLayout(self.settings_page)
|
||||
page.setContentsMargins(0, 2, 0, 0)
|
||||
page.setSpacing(6)
|
||||
|
||||
self.btn_preview = QPushButton()
|
||||
self.btn_preview.setMinimumHeight(BUTTON_HEIGHT)
|
||||
self.btn_preview.setCheckable(True)
|
||||
self.btn_preview.toggled.connect(self._on_preview_toggled)
|
||||
|
||||
# --- 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)
|
||||
|
||||
# --- record mode group --------------------------------------------
|
||||
# "What will be recorded" is a per-recording setting, not live state:
|
||||
# it lives here so the rail's labels stay readable at 1280 in both
|
||||
# languages (the record *state* is on the rail, next to the button).
|
||||
self.record_label = QLabel()
|
||||
self.record_label.setObjectName("fieldLabel")
|
||||
self.record_mode_combo = QComboBox()
|
||||
self.record_mode_combo.setMinimumWidth(120)
|
||||
self.record_mode_combo.setMinimumHeight(CONTROL_HEIGHT)
|
||||
|
||||
top_row = QHBoxLayout()
|
||||
top_row.setSpacing(10)
|
||||
top_row.addWidget(self.btn_preview)
|
||||
top_row.addWidget(_separator())
|
||||
top_row.addWidget(_group(self.units_label, self.units_combo))
|
||||
top_row.addWidget(_separator())
|
||||
top_row.addWidget(_group(self.record_label, self.record_mode_combo))
|
||||
top_row.addStretch(1)
|
||||
page.addLayout(top_row)
|
||||
|
||||
return self.settings_page
|
||||
|
||||
# ------------------------------------------------------------------ API
|
||||
def settings_expanded(self) -> bool:
|
||||
return bool(self._settings_expanded)
|
||||
|
||||
def set_settings_expanded(self, expanded: bool) -> None:
|
||||
self._settings_expanded = bool(expanded)
|
||||
self.btn_settings.blockSignals(True)
|
||||
self.btn_settings.setChecked(self._settings_expanded)
|
||||
self.btn_settings.blockSignals(False)
|
||||
self._settings_scroll.setVisible(self._settings_expanded)
|
||||
self._apply_settings_button_style()
|
||||
|
||||
def set_theme(self, theme: str) -> None:
|
||||
self._theme = str(theme)
|
||||
self.record_chip.set_theme(self._theme)
|
||||
|
||||
def set_sensor_summary(self, text: str) -> None:
|
||||
"""The rail's sensor button text (``BOX · box/sensor.json``)."""
|
||||
self.btn_sensor.setText(str(text or ""))
|
||||
self.btn_sensor.setToolTip(t("sensor.dialog_tip"))
|
||||
|
||||
def set_source_summary(self, text: str) -> None:
|
||||
"""Extra detail about the capture source (fps, video/recording name)."""
|
||||
self.source_summary.setText(str(text or ""))
|
||||
# The rail may squeeze this label; the tooltip always carries it whole.
|
||||
self.source_summary.setToolTip(self.source_summary.text())
|
||||
self.source_summary.setVisible(bool(text))
|
||||
|
||||
def set_source_summary_tip(self, text: str) -> None:
|
||||
"""What the summary cannot fit: the full path, and any replay notes."""
|
||||
self.source_summary.setToolTip(str(text or ""))
|
||||
|
||||
def set_calibration_summary(self, text: str) -> None:
|
||||
self.calibration_label.setText(str(text or ""))
|
||||
self.calibration_label.setToolTip(self.calibration_label.text())
|
||||
self.calibration_label.setVisible(bool(text))
|
||||
|
||||
def set_data_state(self, text: str, token: str = "text_secondary") -> None:
|
||||
"""Show/clear the validity readout, coloured by a theme token."""
|
||||
text = str(text or "")
|
||||
self.data_state.setText(text)
|
||||
self.data_state.setToolTip(text)
|
||||
self.data_state.setVisible(bool(text))
|
||||
if text:
|
||||
self.data_state.setStyleSheet(
|
||||
f"QLabel#panelMeta {{ color: {color(self._theme, token)};"
|
||||
" background: transparent; }"
|
||||
)
|
||||
|
||||
def set_record_state(self, *args, **kwargs) -> None:
|
||||
"""Delegate the record strip to :class:`RecordStatusChip`."""
|
||||
self.record_chip.set_state(*args, **kwargs)
|
||||
|
||||
def source_mode(self) -> str:
|
||||
"""``camera`` or ``video``: what a run would acquire from."""
|
||||
return self._source_mode
|
||||
|
||||
def set_source_mode(self, mode: str) -> None:
|
||||
self._source_mode = "video" if str(mode).lower().startswith("v") else "camera"
|
||||
self._apply_source_mode_ui()
|
||||
|
||||
def set_source_view(self, view: str) -> None:
|
||||
"""Which picker the current view needs (``live`` or ``replay``)."""
|
||||
wanted = "replay" if str(view) == "replay" else "live"
|
||||
if wanted == self._source_view:
|
||||
return
|
||||
self._source_view = wanted
|
||||
self._apply_source_mode_ui()
|
||||
|
||||
def set_run_role(self, role: str) -> None:
|
||||
"""What the primary button does in this view: acquire, or play back."""
|
||||
wanted = "playback" if str(role) == "playback" else "acquire"
|
||||
if wanted == self._run_role:
|
||||
return
|
||||
self._run_role = wanted
|
||||
# A view switch never carries the other view's running state across.
|
||||
if self.btn_run.isChecked():
|
||||
self.btn_run.blockSignals(True)
|
||||
self.btn_run.setChecked(False)
|
||||
self.btn_run.blockSignals(False)
|
||||
self._refresh_run_label()
|
||||
|
||||
def run_role(self) -> str:
|
||||
return self._run_role
|
||||
|
||||
def source_view(self) -> str:
|
||||
return self._source_view
|
||||
|
||||
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 str(self._video_path or "")
|
||||
|
||||
def set_video_path(self, path: str) -> None:
|
||||
self._video_path = str(path or "").strip()
|
||||
self._apply_source_mode_ui()
|
||||
|
||||
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:
|
||||
live = self._source_view == "live"
|
||||
camera = self.source_mode() == "camera"
|
||||
allowed = bool(enabled) and live
|
||||
self.btn_search.setEnabled(allowed and camera)
|
||||
self.cam_id_spin.setEnabled(allowed and camera)
|
||||
self.btn_use_camera.setEnabled(allowed and not camera)
|
||||
# The replay's video picker belongs to the replay view, and follows the
|
||||
# same rule as the camera picker: not while a session is running.
|
||||
self.btn_replay_video.setEnabled(bool(enabled) and not live)
|
||||
|
||||
def set_source_controls_enabled(self, enabled: bool) -> None:
|
||||
self._source_controls_enabled = bool(enabled)
|
||||
self.set_camera_controls_enabled(self._source_controls_enabled)
|
||||
self.btn_preview.setEnabled(
|
||||
self._source_controls_enabled
|
||||
and self._source_view == "live"
|
||||
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 set_run_phase(self, phase: str) -> None:
|
||||
"""Track the session phase so Stop can advertise itself while connecting."""
|
||||
self._run_phase = str(phase or "idle")
|
||||
self.refresh_labels()
|
||||
|
||||
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_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 _populate_record_modes(self) -> None:
|
||||
current = self.record_mode_combo.currentData()
|
||||
self.record_mode_combo.blockSignals(True)
|
||||
self.record_mode_combo.clear()
|
||||
for value, _key in RECORD_MODES:
|
||||
self.record_mode_combo.addItem("", value)
|
||||
if current:
|
||||
idx = self.record_mode_combo.findData(current)
|
||||
self.record_mode_combo.setCurrentIndex(max(0, idx))
|
||||
self.record_mode_combo.blockSignals(False)
|
||||
|
||||
def record_mode(self) -> str:
|
||||
return str(self.record_mode_combo.currentData() or "video_data")
|
||||
|
||||
def set_record_enabled(self, enabled: bool) -> None:
|
||||
"""Enable the record control.
|
||||
|
||||
While a recording is running this must stay enabled — it is the only
|
||||
way to stop it.
|
||||
"""
|
||||
self.btn_record.setEnabled(bool(enabled))
|
||||
self.record_mode_combo.setEnabled(bool(enabled) and not self.btn_record.isChecked())
|
||||
|
||||
def set_record_checked(self, checked: bool) -> None:
|
||||
self.btn_record.blockSignals(True)
|
||||
self.btn_record.setChecked(bool(checked))
|
||||
self.btn_record.blockSignals(False)
|
||||
self._refresh_record_label()
|
||||
|
||||
def set_record_status(self, text: str) -> None:
|
||||
"""Publish where the recording is being written (shown in the tooltip)."""
|
||||
self.record_chip.set_directory(str(text or ""))
|
||||
|
||||
def _on_record_toggled(self, checked: bool) -> None:
|
||||
self._refresh_record_label()
|
||||
self.record_mode_combo.setEnabled(not checked)
|
||||
self.record_toggled.emit(bool(checked))
|
||||
|
||||
def _on_settings_toggled(self, checked: bool) -> None:
|
||||
self._settings_expanded = bool(checked)
|
||||
self._settings_scroll.setVisible(self._settings_expanded)
|
||||
self._apply_settings_button_style()
|
||||
self.settings_toggled.emit(self._settings_expanded)
|
||||
|
||||
def _apply_settings_button_style(self) -> None:
|
||||
self.btn_settings.setProperty(
|
||||
"settingsOpen", "true" if self._settings_expanded else "false"
|
||||
)
|
||||
self.btn_settings.style().unpolish(self.btn_settings)
|
||||
self.btn_settings.style().polish(self.btn_settings)
|
||||
|
||||
def _refresh_record_label(self) -> None:
|
||||
if self.btn_record.isChecked():
|
||||
self.btn_record.setText(t("record.stop_label"))
|
||||
else:
|
||||
self.btn_record.setText(t("record.start_label"))
|
||||
self.btn_record.setToolTip(
|
||||
t("record.stop_tip") if self.btn_record.isChecked() else t("record.start_tip")
|
||||
)
|
||||
self._refresh_emphasis()
|
||||
|
||||
def _apply_source_mode_ui(self) -> None:
|
||||
"""Show the picker the current view needs, and nothing else.
|
||||
|
||||
Live + camera: the camera id and its search button. Live + video: a
|
||||
one-click way back to the camera (that source is the recompute pass's).
|
||||
Replay: the recorded video, which is the one difference between the two
|
||||
views' rails.
|
||||
"""
|
||||
live = self._source_view == "live"
|
||||
video = self.source_mode() == "video"
|
||||
self.camera_group.setVisible(live and not video)
|
||||
self.video_group.setVisible(live and video)
|
||||
self.replay_group.setVisible(not live)
|
||||
self.source_slot.setVisible(True)
|
||||
self.set_source_controls_enabled(self._source_controls_enabled)
|
||||
if video and self.btn_preview.isChecked():
|
||||
self.set_preview_checked(False)
|
||||
self._refresh_source_tooltips()
|
||||
|
||||
def _refresh_source_tooltips(self) -> None:
|
||||
path = self.video_path()
|
||||
self.source_summary.setToolTip(path or self.source_summary.text())
|
||||
self.btn_use_camera.setToolTip(t("toolbar.use_camera_tip"))
|
||||
self.btn_replay_video.setToolTip(t("toolbar.choose_video_tip"))
|
||||
|
||||
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._run_role == "playback":
|
||||
# The primary action of the replay view is playing the recording —
|
||||
# the same button, doing what this view is for.
|
||||
self.btn_run.setText(
|
||||
t("toolbar.playback_pause") if self.btn_run.isChecked() else t("toolbar.playback")
|
||||
)
|
||||
self._refresh_emphasis()
|
||||
self._apply_run_tooltip()
|
||||
return
|
||||
if self.btn_run.isChecked():
|
||||
# While connecting, cancel is the action the user actually needs —
|
||||
# a device open can hang, and this is the way out of it.
|
||||
if self._run_phase == "connecting":
|
||||
self.btn_run.setText(t("toolbar.cancel_label"))
|
||||
else:
|
||||
self.btn_run.setText(t("toolbar.stop_label"))
|
||||
else:
|
||||
self.btn_run.setText(t("toolbar.run_label"))
|
||||
self._refresh_emphasis()
|
||||
self._apply_run_tooltip()
|
||||
|
||||
def _refresh_emphasis(self) -> None:
|
||||
"""Exactly one *filled* button at a time.
|
||||
|
||||
The complaint this rule answers is a red Stop dominating a top area in
|
||||
which nothing was happening. Stop stays red and one click away, but only
|
||||
the action of the moment is filled:
|
||||
|
||||
* not recording yet → the record action is the filled primary;
|
||||
* recording → stopping the recording is the filled danger;
|
||||
* connecting → cancel is the filled danger (it is the way out of a hang).
|
||||
"""
|
||||
recording = self.btn_record.isChecked()
|
||||
# The phase counts as running even if the button has not been repainted
|
||||
# yet: the rail must describe the session, not the widget's lag.
|
||||
running = self.btn_run.isChecked() or self._run_phase in ("connecting", "running")
|
||||
if self._run_role == "playback":
|
||||
# The transport is the replay view's own action, not a session stop:
|
||||
# filled while it plays, an outline when idle, and never red.
|
||||
self.btn_record.setObjectName(
|
||||
"dangerButton" if recording else "primaryButton"
|
||||
)
|
||||
self.btn_run.setObjectName("primaryButton" if running else "ghostButton")
|
||||
elif recording:
|
||||
self.btn_record.setObjectName("dangerButton")
|
||||
self.btn_run.setObjectName("dangerOutline")
|
||||
else:
|
||||
self.btn_record.setObjectName("primaryButton")
|
||||
if running and self._run_phase != "connecting":
|
||||
self.btn_run.setObjectName("dangerOutline")
|
||||
elif running:
|
||||
self.btn_run.setObjectName("dangerButton")
|
||||
else:
|
||||
self.btn_run.setObjectName("primaryButton")
|
||||
for button in (self.btn_record, self.btn_run):
|
||||
button.style().unpolish(button)
|
||||
button.style().polish(button)
|
||||
|
||||
def _apply_run_tooltip(self) -> None:
|
||||
if self._run_role == "playback":
|
||||
key = "toolbar.playback_pause_tip" if self.btn_run.isChecked() else "toolbar.playback_tip"
|
||||
if not self._run_available and self._run_tooltip_reason:
|
||||
self.btn_run.setToolTip(self._run_tooltip_reason)
|
||||
else:
|
||||
self.btn_run.setToolTip(t(key))
|
||||
return
|
||||
if self.btn_run.isChecked():
|
||||
if self._run_phase == "connecting":
|
||||
self.btn_run.setToolTip(t("toolbar.cancel_tip"))
|
||||
else:
|
||||
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.cam_id_label.setText(t("toolbar.cam_id"))
|
||||
self.btn_search.setText(t("toolbar.search_cameras"))
|
||||
self.btn_use_camera.setText(t("toolbar.use_camera"))
|
||||
self.btn_replay_video.setText(t("toolbar.choose_video"))
|
||||
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"))
|
||||
self._refresh_source_tooltips()
|
||||
for index, (value, key) in enumerate(RECORD_MODES):
|
||||
if index < self.record_mode_combo.count():
|
||||
self.record_mode_combo.setItemText(index, t(key))
|
||||
self.record_mode_combo.setToolTip(t("record.mode_tip"))
|
||||
self.btn_settings.setText(t("toolbar.settings"))
|
||||
self.btn_settings.setToolTip(t("toolbar.settings_tip"))
|
||||
self.record_chip.retranslate()
|
||||
self._refresh_record_label()
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Shared fixtures for the studio 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 ``StudioMainWindow`` 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_studio.app_window import StudioMainWindow
|
||||
|
||||
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 = StudioMainWindow(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,119 @@
|
||||
"""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")
|
||||
|
||||
|
||||
#: Sequences that only ever appear when UTF-8 text was decoded as a single-byte
|
||||
#: codepage and then re-encoded. A ``Get-Content -Raw | Set-Content`` round trip
|
||||
#: in Windows PowerShell does exactly that and silently mangles every non-ASCII
|
||||
#: string, which is how the Chinese UI once ended up unreadable.
|
||||
#:
|
||||
#: Written as escapes, with no literal examples in the comments, so that this
|
||||
#: guard cannot itself be corrupted by the same mistake.
|
||||
_MOJIBAKE = (
|
||||
"\u00c3",
|
||||
"\u00c2",
|
||||
"\u00e2\u20ac",
|
||||
"\u00e3\u20ac",
|
||||
"\u00ef\u00bf\u00bd",
|
||||
)
|
||||
|
||||
|
||||
def test_locale_files_are_utf8_without_bom_and_not_mojibake():
|
||||
for name in ("zh_CN", "en_US"):
|
||||
path = LOCALES_DIR / f"{name}.json"
|
||||
raw = path.read_bytes()
|
||||
assert not raw.startswith(b"\xef\xbb\xbf"), f"{name}.json starts with a BOM"
|
||||
text = raw.decode("utf-8") # raises if it is not valid UTF-8
|
||||
|
||||
for sequence in _MOJIBAKE:
|
||||
assert sequence not in text, (
|
||||
f"{name}.json contains {sequence!r}: the file was decoded with the "
|
||||
"wrong encoding. Rewrite it with an explicit UTF-8 writer instead "
|
||||
"of a PowerShell Get-Content/Set-Content round trip."
|
||||
)
|
||||
|
||||
|
||||
def test_chinese_locale_actually_contains_chinese():
|
||||
"""A corrupted zh_CN.json still parses as JSON, so check the content."""
|
||||
data = _load_locale("zh_CN")
|
||||
for key in ("menu/theme_dark", "app/about_title", "chart/mode_follow"):
|
||||
node = data
|
||||
for part in key.split("/"):
|
||||
node = node[part]
|
||||
assert any("\u4e00" <= char <= "\u9fff" for char in node), (
|
||||
f"zh_CN.{key} has no Chinese characters: {node!r}"
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,304 @@
|
||||
"""Ask each camera what it can actually deliver, without the app in the way.
|
||||
|
||||
The studio's sensor configs pin the capture at 1920x1080 @ 30 fps (the tile grid
|
||||
is 5x3 tiles of 384x360, so the size is structural), and nothing downstream can
|
||||
raise a frame rate the driver never offered. When a session runs at ~15-17 fps,
|
||||
there are three questions to separate, and this script answers all three:
|
||||
|
||||
1. what does the driver *report* for a given request?
|
||||
2. what does it actually **deliver**? (Measured from 60 reads. On DSHOW the
|
||||
reported figure is a liar: ``CAP_PROP_FPS`` echoes back whatever was set, so a
|
||||
camera that advertises 30 and delivers 15 reports "30.0".)
|
||||
3. does the delivered rate follow the request? The sweep at the end asks for
|
||||
60/30/20/15/10 fps at one format. A rate that *halves* with the request means
|
||||
the request path is wrong; a rate that sticks at the same number whatever is
|
||||
asked means that number is the hardware's ceiling.
|
||||
|
||||
Run it with the venv interpreter, with the app closed (two processes cannot own
|
||||
the same camera)::
|
||||
|
||||
python apps/studio/tools/camera_probe.py # every camera
|
||||
python apps/studio/tools/camera_probe.py --index 0 # just one
|
||||
python apps/studio/tools/camera_probe.py --samples 120 # longer measurement
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
import time
|
||||
|
||||
import cv2
|
||||
|
||||
#: What the sensor bundles ask for, and the alternatives worth trying when the
|
||||
#: delivered rate falls short of the requested one.
|
||||
REQUESTS: tuple[tuple[str, int, int, float], ...] = (
|
||||
("MJPG", 1920, 1080, 30.0),
|
||||
("MJPG", 1920, 1080, 0.0), # "whatever the driver prefers at this size"
|
||||
("MJPG", 1280, 720, 30.0),
|
||||
("YUYV", 1920, 1080, 30.0),
|
||||
("YUYV", 1280, 720, 30.0),
|
||||
)
|
||||
|
||||
BACKENDS: tuple[tuple[str, int], ...] = (
|
||||
("DSHOW", cv2.CAP_DSHOW),
|
||||
("MSMF", cv2.CAP_MSMF),
|
||||
)
|
||||
|
||||
#: Frame rates asked for at one fixed format, to see whether the delivered rate
|
||||
#: follows the request or ignores it.
|
||||
SWEEP_RATES: tuple[float, ...] = (60.0, 30.0, 20.0, 15.0, 10.0)
|
||||
|
||||
#: Sizes asked for at one fixed rate. This is what separates the two remaining
|
||||
#: explanations for a low rate: "the link cannot carry the pixels" (smaller
|
||||
#: frames go faster) from "this camera is a 15 fps device" (the rate does not
|
||||
#: move at all).
|
||||
SWEEP_SIZES: tuple[tuple[int, int], ...] = (
|
||||
(1920, 1080),
|
||||
(1280, 720),
|
||||
(960, 540),
|
||||
(848, 480),
|
||||
(640, 480),
|
||||
)
|
||||
|
||||
SAMPLES = 60
|
||||
#: A request is "missed" when the measured rate is below this fraction of it.
|
||||
TOLERANCE = 0.9
|
||||
|
||||
|
||||
def _fourcc_code(text: str) -> int:
|
||||
return cv2.VideoWriter_fourcc(*text)
|
||||
|
||||
|
||||
def _decode(code: float) -> str:
|
||||
value = int(code)
|
||||
if value <= 0:
|
||||
return "-"
|
||||
letters = "".join(chr((value >> (8 * index)) & 0xFF) for index in range(4))
|
||||
return letters if letters.isprintable() else f"0x{value:08x}"
|
||||
|
||||
|
||||
def probe(
|
||||
index: int,
|
||||
backend_name: str,
|
||||
backend: int,
|
||||
request: tuple[str, int, int, float],
|
||||
*,
|
||||
samples: int = SAMPLES,
|
||||
) -> dict | None:
|
||||
"""Open, ask for one format, and measure what actually comes out."""
|
||||
fourcc, width, height, fps = request
|
||||
cap = cv2.VideoCapture(index, backend)
|
||||
if not cap.isOpened():
|
||||
cap.release()
|
||||
return None
|
||||
try:
|
||||
cap.set(cv2.CAP_PROP_FOURCC, _fourcc_code(fourcc))
|
||||
cap.set(cv2.CAP_PROP_FRAME_WIDTH, float(width))
|
||||
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, float(height))
|
||||
if fps > 0:
|
||||
cap.set(cv2.CAP_PROP_FPS, float(fps))
|
||||
cap.set(cv2.CAP_PROP_FOURCC, _fourcc_code(fourcc))
|
||||
for _ in range(5): # let the format settle before believing anything
|
||||
cap.grab()
|
||||
started = time.perf_counter()
|
||||
taken = 0
|
||||
for _ in range(samples):
|
||||
ok, frame = cap.read()
|
||||
if not ok or frame is None:
|
||||
break
|
||||
taken += 1
|
||||
span = time.perf_counter() - started
|
||||
got_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
got_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
reported = cap.get(cv2.CAP_PROP_FPS)
|
||||
got_cc = _decode(cap.get(cv2.CAP_PROP_FOURCC))
|
||||
finally:
|
||||
cap.release()
|
||||
return {
|
||||
"asked": (fourcc, width, height, fps),
|
||||
"backend": backend_name,
|
||||
"fourcc": got_cc,
|
||||
"width": got_w,
|
||||
"height": got_h,
|
||||
"reported_fps": reported,
|
||||
"measured_fps": (taken / span) if span > 0 else 0.0,
|
||||
"taken": taken,
|
||||
"samples": samples,
|
||||
}
|
||||
|
||||
|
||||
def format_row(result: dict) -> str:
|
||||
fourcc, width, height, fps = result["asked"]
|
||||
asked = f"{fourcc} {width}x{height}" + (f"@{fps:g}" if fps else "@auto")
|
||||
measured = result["measured_fps"]
|
||||
missed = ""
|
||||
if fps > 0 and measured < fps * TOLERANCE:
|
||||
missed = " <-- misses the request"
|
||||
return (
|
||||
f" {asked:<20} -> {result['fourcc']} {result['width']}x{result['height']} "
|
||||
f"reported {result['reported_fps']:5.1f} measured {measured:5.1f} fps "
|
||||
f"({result['taken']}/{result['samples']}) [{result['backend']}]{missed}"
|
||||
)
|
||||
|
||||
|
||||
def sweep(index: int, backend_name: str, backend: int, size: tuple[int, int],
|
||||
*, samples: int = SAMPLES) -> list[dict]:
|
||||
"""Ask for a range of rates at one format: does the delivery follow?"""
|
||||
width, height = size
|
||||
rows = []
|
||||
for rate in SWEEP_RATES:
|
||||
result = probe(
|
||||
index, backend_name, backend, ("MJPG", width, height, rate),
|
||||
samples=samples,
|
||||
)
|
||||
if result is not None:
|
||||
rows.append(result)
|
||||
return rows
|
||||
|
||||
|
||||
def size_sweep(index: int, backend_name: str, backend: int, fps: float = 30.0,
|
||||
*, samples: int = SAMPLES) -> list[dict]:
|
||||
"""Ask for one rate at shrinking sizes: bandwidth, or a fixed ceiling?
|
||||
|
||||
A link that cannot carry the pixels gets faster as the frames get smaller. A
|
||||
camera whose own rate is 15 fps delivers 15 whatever it is asked for.
|
||||
"""
|
||||
rows = []
|
||||
for width, height in SWEEP_SIZES:
|
||||
result = probe(
|
||||
index, backend_name, backend, ("MJPG", width, height, fps),
|
||||
samples=samples,
|
||||
)
|
||||
if result is not None:
|
||||
rows.append(result)
|
||||
return rows
|
||||
|
||||
|
||||
def check_format(
|
||||
index: int,
|
||||
*,
|
||||
request: tuple[str, int, int, float] = REQUESTS[0],
|
||||
samples: int = 120,
|
||||
) -> int:
|
||||
"""Acceptance mode: does this camera deliver the bundle's format?
|
||||
|
||||
One line, one exit code, for going through units in turn. The request is the
|
||||
one the sensor bundles make (MJPG 1920x1080@30); the verdict is the *measured*
|
||||
rate, because the reported one is an echo.
|
||||
"""
|
||||
fourcc, width, height, fps = request
|
||||
for backend_name, backend in BACKENDS:
|
||||
result = probe(index, backend_name, backend, request, samples=samples)
|
||||
if result is None:
|
||||
continue
|
||||
measured = result["measured_fps"]
|
||||
asked = f"{fourcc} {width}x{height}@{fps:g}"
|
||||
if measured >= fps * TOLERANCE:
|
||||
print(f"PASS camera {index}: {asked} -> {measured:.1f} fps "
|
||||
f"[{backend_name}, {result['fourcc']}]")
|
||||
return 0
|
||||
print(
|
||||
f"FAIL camera {index}: {asked} -> {measured:.1f} fps "
|
||||
f"(reported {result['reported_fps']:.1f}, {result['fourcc']}, "
|
||||
f"{backend_name})"
|
||||
)
|
||||
return 1
|
||||
print(f"No camera answered on index {index} (is the app closed?).")
|
||||
return 1
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--index", type=int, default=None, help="camera index to probe")
|
||||
parser.add_argument("--max-index", type=int, default=4, help="highest index to try")
|
||||
parser.add_argument("--samples", type=int, default=SAMPLES, help="frames per row")
|
||||
parser.add_argument(
|
||||
"--check",
|
||||
action="store_true",
|
||||
help="acceptance mode: measure the bundle's format once, exit 0 when it "
|
||||
"delivers it (use this to go through units)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.check:
|
||||
return check_format(args.index if args.index is not None else 0,
|
||||
samples=args.samples)
|
||||
|
||||
indices = [args.index] if args.index is not None else list(range(args.max_index + 1))
|
||||
found = 0
|
||||
for index in indices:
|
||||
header = False
|
||||
first_size: tuple[int, int] | None = None
|
||||
first_backend: tuple[str, int] | None = None
|
||||
for backend_name, backend in BACKENDS:
|
||||
for request in REQUESTS:
|
||||
result = probe(index, backend_name, backend, request, samples=args.samples)
|
||||
if result is None:
|
||||
continue
|
||||
if not header:
|
||||
print(f"\ncamera {index}:")
|
||||
header = True
|
||||
print(format_row(result))
|
||||
if first_size is None and request[0] == "MJPG" and request[3] > 0:
|
||||
first_size = (request[1], request[2])
|
||||
first_backend = (backend_name, backend)
|
||||
if header and first_size is not None and first_backend is not None:
|
||||
print(
|
||||
f"\n sweep at MJPG {first_size[0]}x{first_size[1]} "
|
||||
f"[{first_backend[0]}] -- does the delivered rate follow the request?"
|
||||
)
|
||||
rows = sweep(index, first_backend[0], first_backend[1], first_size,
|
||||
samples=args.samples)
|
||||
for result in rows:
|
||||
asked = result["asked"][3]
|
||||
measured = result["measured_fps"]
|
||||
verdict = ""
|
||||
if asked > 0 and measured > 0:
|
||||
ratio = measured / asked
|
||||
if ratio < 0.35:
|
||||
verdict = f" -> {ratio:.2f}x the request"
|
||||
elif ratio > 0.9:
|
||||
verdict = " -> follows the request"
|
||||
print(
|
||||
f" asked {asked:5.1f} fps -> measured {measured:5.1f} fps"
|
||||
f"{verdict}"
|
||||
)
|
||||
rates = [r["measured_fps"] for r in rows if r["measured_fps"] > 0]
|
||||
if rates:
|
||||
print(
|
||||
f" ceiling seen here: {max(rates):.1f} fps "
|
||||
f"(a ceiling that does not move with the request is the "
|
||||
f"hardware's, not the request's)"
|
||||
)
|
||||
print(
|
||||
f"\n sizes at MJPG@{30:g} fps [{first_backend[0]}] -- bandwidth, "
|
||||
f"or a fixed ceiling?"
|
||||
)
|
||||
for result in size_sweep(
|
||||
index, first_backend[0], first_backend[1], samples=args.samples
|
||||
):
|
||||
width, height = result["asked"][1], result["asked"][2]
|
||||
print(
|
||||
f" {width}x{height:<5} -> measured {result['measured_fps']:5.1f} fps"
|
||||
)
|
||||
print(
|
||||
" smaller frames going faster means the link is the limit; the "
|
||||
"same number at every size means the camera is"
|
||||
)
|
||||
if header:
|
||||
found += 1
|
||||
|
||||
if not found:
|
||||
print("No camera answered on indices 0..%d." % args.max_index)
|
||||
print(
|
||||
"That is expected on a machine with no camera attached. On the machine "
|
||||
"with the sensor, run it with the app closed: two processes cannot own "
|
||||
"the same camera."
|
||||
)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,341 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Render the studio 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
|
||||
python tools/ui_snapshot.py --demo --scene recording --out out_recording.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", APP_ROOT / "tools"):
|
||||
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 _replay_data(window, app) -> Path:
|
||||
"""Synthesize one short recording (with visible frames) and open it."""
|
||||
import numpy as np
|
||||
|
||||
from orisys_app_shared.record import ExperimentRecorder, RecordConfig
|
||||
|
||||
from ui_check import _fake_packet
|
||||
|
||||
root = APP_ROOT / "outputs" / "_snapshot_replay"
|
||||
if root.exists():
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(root, ignore_errors=True)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
recorder = ExperimentRecorder(RecordConfig(video=True, fps=30.0, segment_seconds=30.0))
|
||||
recording = Path(
|
||||
recorder.start(
|
||||
root=root,
|
||||
session_id=1,
|
||||
metadata={
|
||||
"device": {
|
||||
"sensor_type": "box",
|
||||
"config_ref": str(root / "sensor.json"),
|
||||
"config_sha256": "snapshot",
|
||||
},
|
||||
"versions": {"sdk": "snapshot"},
|
||||
"depth": {"stored": False, "replay": "recompute"},
|
||||
},
|
||||
)
|
||||
)
|
||||
# A recognisable frame: a horizontal gradient with a bright bar, so the
|
||||
# replay's "recorded image" view is reviewable instead of black.
|
||||
height, width = 108, 192
|
||||
frame = np.zeros((height, width, 3), np.uint8)
|
||||
frame[:, :, 1] = np.linspace(20, 200, width, dtype=np.uint8)[None, :]
|
||||
frame[height // 3 : 2 * height // 3, :, 2] = 180
|
||||
for index in range(150):
|
||||
recorder.on_packet(_fake_packet(index, frame=frame))
|
||||
recorder.stop()
|
||||
return recording
|
||||
|
||||
|
||||
def _run_scene(window, app, scene: str):
|
||||
"""Drive the window into a scene that would otherwise need hardware.
|
||||
|
||||
``recording`` mocks the record channel's stats through the same single seam
|
||||
the app reads them from (``_record_stats``), so nothing is written to disk
|
||||
and no device is opened: the render is of the real widgets, not of a mockup.
|
||||
``replay`` writes one short synthetic recording and opens it in the replay
|
||||
view, which is the only way to review the progress bar without a session.
|
||||
"""
|
||||
from ui_check import _fake_packet, _StuckWorker
|
||||
|
||||
if scene == "replay":
|
||||
window.brand_bar.btn_playback.click()
|
||||
for _ in range(4):
|
||||
app.processEvents()
|
||||
recording = _replay_data(window, app)
|
||||
window._load_recording(recording)
|
||||
# Park the playhead where the bar and the charts show a real position.
|
||||
window.replay.set_position(window.replay.duration_s() * 0.4)
|
||||
window.arrow_panel.set_view_mode("recorded")
|
||||
for _ in range(6):
|
||||
app.processEvents()
|
||||
return
|
||||
|
||||
stuck = _StuckWorker()
|
||||
window._ensure_workers = lambda: None
|
||||
window._box_worker = stuck
|
||||
window._finger_worker = stuck
|
||||
window.toolbar.btn_run.setChecked(True)
|
||||
for _ in range(4):
|
||||
app.processEvents()
|
||||
if scene != "recording":
|
||||
return
|
||||
|
||||
# Real packets so the phase reaches ``running`` and the rail holds real
|
||||
# values; the recording itself is the only mocked part.
|
||||
for index in range(300):
|
||||
stuck.emit(_fake_packet(index))
|
||||
window._on_run_tick()
|
||||
for _ in range(4):
|
||||
app.processEvents()
|
||||
window._run_timer.stop()
|
||||
|
||||
from orisys_app_shared.record.experiment import RecordStats
|
||||
|
||||
from orisys_studio.ui import record_status
|
||||
|
||||
directory = "20240101T101500Z_session0001_a1b2c3d4e5f6"
|
||||
window.toolbar.set_record_checked(True)
|
||||
window.toolbar.set_record_status(directory)
|
||||
window._record_stats = lambda: (
|
||||
record_status.RECORDING,
|
||||
RecordStats(
|
||||
duration_s=12.4,
|
||||
frames=372,
|
||||
segments=1,
|
||||
dropped_data=3,
|
||||
bytes_written=48_000_000,
|
||||
),
|
||||
directory,
|
||||
)
|
||||
window._refresh_record_state()
|
||||
# A visible window inside the session, like the mock's 60-65 s of 0-65 s.
|
||||
first, last = window.session_store.extent()
|
||||
if last > first:
|
||||
window.force_plots.set_window(max(first, last - 6.0), last)
|
||||
for _ in range(4):
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def _demo_data(window) -> None:
|
||||
"""Fill the plots, the calibration table and the arrow canvas."""
|
||||
from orisys_app_shared.force.session import SessionSample, SessionStore
|
||||
|
||||
plots = window.force_plots
|
||||
# The panel reads a *selection* out of the attached source, so the demo needs
|
||||
# a real session store behind it (a store-less panel can only follow live).
|
||||
store = SessionStore()
|
||||
plots.set_store(store)
|
||||
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)
|
||||
store.append(
|
||||
SessionSample(
|
||||
frame_id=i,
|
||||
t_rel_s=t,
|
||||
fn_raw=fn * 7.9 + 9.0,
|
||||
fx_raw=fx * 7.9,
|
||||
fy_raw=fy * 7.9,
|
||||
fn_n=fn,
|
||||
fx_n=fx,
|
||||
fy_n=fy,
|
||||
calibrated_axes=("fn", "fx", "fy"),
|
||||
)
|
||||
)
|
||||
plots.push(t, fn, fx, fy, unit="N")
|
||||
|
||||
# The overview reads the *source*, and ``push`` only rebuilds it once a second
|
||||
# (the demo fills a whole session in a few milliseconds), so build it once here.
|
||||
plots._overview_dirty = True
|
||||
plots._last_overview_at = 0.0
|
||||
plots._sync_overview()
|
||||
|
||||
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_studio.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(
|
||||
"--preset",
|
||||
choices=("custom", "live", "replay", "calibration"),
|
||||
default="custom",
|
||||
help="top-level view to render (the same layouts the brand rail switches)",
|
||||
)
|
||||
parser.add_argument("--demo", action="store_true", help="inject demo plot/table data")
|
||||
parser.add_argument(
|
||||
"--scene",
|
||||
choices=("idle", "connecting", "recording", "replay"),
|
||||
default="idle",
|
||||
help=(
|
||||
"connecting renders the Stop/cancel state of a hung device open; "
|
||||
"recording renders the record strip of a running recording "
|
||||
"(mocked stats: no file is written, no device is opened); "
|
||||
"replay writes one short synthetic recording and opens it in the "
|
||||
"replay view so the progress bar can be reviewed"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--select",
|
||||
default="",
|
||||
help=(
|
||||
"A,B — browse the live demo session inside that window so the "
|
||||
"selection readout (cards = peak / mean · σ) can be reviewed"
|
||||
),
|
||||
)
|
||||
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")
|
||||
|
||||
selection = None
|
||||
if args.select:
|
||||
try:
|
||||
start, end = (float(part) for part in args.select.split(",", 1))
|
||||
except ValueError:
|
||||
parser.error("--select must look like 2,6")
|
||||
selection = (start, end)
|
||||
|
||||
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_studio.app_window import StudioMainWindow
|
||||
|
||||
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,
|
||||
"view_preset": args.preset,
|
||||
}
|
||||
window = StudioMainWindow(prefs=prefs)
|
||||
window.resize(width, height)
|
||||
window.show()
|
||||
app.processEvents()
|
||||
|
||||
if args.demo and args.scene == "replay":
|
||||
pass # the recording is the demo data
|
||||
|
||||
if args.scene == "connecting":
|
||||
# A device open that never returns: the state the Stop button must
|
||||
# survive. ``ui_check._StuckWorker`` drives the same window state.
|
||||
_run_scene(window, app, "connecting")
|
||||
|
||||
if args.scene == "recording":
|
||||
_run_scene(window, app, "recording")
|
||||
|
||||
if args.scene == "replay":
|
||||
_run_scene(window, app, "replay")
|
||||
|
||||
if args.demo and args.scene != "replay":
|
||||
_demo_data(window)
|
||||
for _ in range(4):
|
||||
app.processEvents()
|
||||
|
||||
if selection is not None:
|
||||
# A selection is a browse window narrower than the session: the value
|
||||
# cards state its peak and mean · σ, the strip only its size.
|
||||
window.force_plots.set_window(*selection)
|
||||
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())
|
||||
+14
-14
@@ -1,19 +1,19 @@
|
||||
# Build
|
||||
|
||||
Windows packaging for the customer desktop app.
|
||||
Windows packaging for the studio desktop app.
|
||||
|
||||
## Layout
|
||||
|
||||
```text
|
||||
build/
|
||||
customer/
|
||||
studio/
|
||||
entry.py # frozen entry (no source chdir)
|
||||
orisys-customer.spec # PyInstaller onedir spec
|
||||
orisys-studio.spec # PyInstaller onedir spec
|
||||
build.ps1 # create/reuse .venv-build and build
|
||||
_pyinstaller/ # PyInstaller workpath (gitignored)
|
||||
dist/
|
||||
OrisysCustomer/
|
||||
OrisysCustomer.exe
|
||||
OrisysStudio/
|
||||
OrisysStudio.exe
|
||||
config/ # lean locales + sensors (no finger visualization/)
|
||||
_internal/ # Python, PyQt5, opencv-headless, orisys, …
|
||||
```
|
||||
@@ -26,7 +26,7 @@ dist/
|
||||
|
||||
## Build
|
||||
|
||||
From `build\customer` (recommended):
|
||||
From `build\studio` (recommended):
|
||||
|
||||
```bat
|
||||
build.bat
|
||||
@@ -37,7 +37,7 @@ build.bat -SkipInstall
|
||||
Or from repo root:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\build\customer\build.ps1
|
||||
powershell -ExecutionPolicy Bypass -File .\build\studio\build.ps1
|
||||
```
|
||||
|
||||
`build.bat` always resolves `build.ps1` next to itself (cwd-independent) and forwards flags.
|
||||
@@ -49,7 +49,7 @@ Flags:
|
||||
| `-Clean` | Pass `--clean` to PyInstaller (use after dep/spec changes) |
|
||||
| `-SkipInstall` | Reuse packages already in `.venv-build` (faster iteration) |
|
||||
|
||||
Output: `dist\OrisysCustomer\OrisysCustomer.exe`
|
||||
Output: `dist\OrisysStudio\OrisysStudio.exe`
|
||||
|
||||
## Dependency notes
|
||||
|
||||
@@ -59,22 +59,22 @@ Output: `dist\OrisysCustomer\OrisysCustomer.exe`
|
||||
|
||||
## Size / startup
|
||||
|
||||
- **onedir** (not onefile): faster cold start; ship the whole `OrisysCustomer` folder.
|
||||
- **onedir** (not onefile): faster cold start; ship the whole `OrisysStudio` folder.
|
||||
- Finger `assets/visualization/` is stripped from the shipped `config/` (~11 MB saved).
|
||||
- Customer shell defers OpenCV / acquisition imports until Preview or Run.
|
||||
- Studio shell defers OpenCV / acquisition imports until Preview or Run.
|
||||
|
||||
Expect hundreds of MB total (Qt + numpy/scipy/numba + OpenCV). Without Open3D this stays smaller than a full `orisys[gui]` bundle.
|
||||
|
||||
## Verification
|
||||
|
||||
1. Launch `OrisysCustomer.exe` on the build machine (and ideally a clean PC without Python).
|
||||
1. Launch `OrisysStudio.exe` on the build machine (and ideally a clean PC without Python).
|
||||
2. Theme / locale / prefs work; `preference.json` and `outputs\` appear next to the EXE.
|
||||
3. Camera preview + box/mini/finger Run (video) with image modes and force plots.
|
||||
4. Confirm no `open3d*` files under `dist\OrisysCustomer`.
|
||||
4. Confirm no `open3d*` files under `dist\OrisysStudio`.
|
||||
|
||||
## Editable run (unchanged)
|
||||
|
||||
```powershell
|
||||
$env:PYTHONPATH = "D:\repo\orisys-app\shared\src;D:\repo\orisys-app\apps\customer\src"
|
||||
python D:\repo\orisys-app\apps\customer\run.py
|
||||
$env:PYTHONPATH = "D:\repo\orisys-app\shared\src;D:\repo\orisys-app\apps\studio\src"
|
||||
python D:\repo\orisys-app\apps\studio\run.py
|
||||
```
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
"""Frozen entry for Orisys Customer (no source-tree chdir)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_customer.main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,6 +1,6 @@
|
||||
@echo off
|
||||
setlocal EnableExtensions
|
||||
REM Build Orisys Customer EXE. Works from any cwd; forwards args to build.ps1
|
||||
REM Build Orisys Studio EXE. Works from any cwd; forwards args to build.ps1
|
||||
REM Examples:
|
||||
REM build.bat
|
||||
REM build.bat -Clean
|
||||
@@ -1,7 +1,7 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Build Orisys Customer Windows onedir EXE with PyInstaller.
|
||||
Build Orisys Studio Windows onedir EXE with PyInstaller.
|
||||
|
||||
.PARAMETER Clean
|
||||
Pass --clean to PyInstaller (slower; use when deps/spec change).
|
||||
@@ -19,7 +19,7 @@ $ErrorActionPreference = "Stop"
|
||||
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
|
||||
$VenvDir = Join-Path $RepoRoot ".venv-build"
|
||||
$Python = Join-Path $VenvDir "Scripts\python.exe"
|
||||
$Spec = Join-Path $PSScriptRoot "orisys-customer.spec"
|
||||
$Spec = Join-Path $PSScriptRoot "orisys-studio.spec"
|
||||
$WorkPath = Join-Path $RepoRoot "build\_pyinstaller"
|
||||
$DistPath = Join-Path $RepoRoot "dist"
|
||||
|
||||
@@ -31,11 +31,11 @@ if (-not (Test-Path $Python)) {
|
||||
}
|
||||
|
||||
if (-not $SkipInstall) {
|
||||
Write-Host "Installing build dependencies (editable shared + customer + orisys + pyinstaller)…"
|
||||
Write-Host "Installing build dependencies (editable shared + studio + orisys + pyinstaller)…"
|
||||
& $Python -m pip install --upgrade pip
|
||||
& $Python -m pip install "pyinstaller>=6.0"
|
||||
& $Python -m pip install -e (Join-Path $RepoRoot "shared")
|
||||
& $Python -m pip install -e (Join-Path $RepoRoot "apps\customer")
|
||||
& $Python -m pip install -e (Join-Path $RepoRoot "apps\studio")
|
||||
# orisys may already be installed; ensure present without [gui]/open3d
|
||||
& $Python -m pip install "orisys>=0.5"
|
||||
|
||||
@@ -73,12 +73,12 @@ if ($LASTEXITCODE -ne 0) {
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
|
||||
$OutDir = Join-Path $DistPath "OrisysCustomer"
|
||||
$OutDir = Join-Path $DistPath "OrisysStudio"
|
||||
Write-Host ""
|
||||
Write-Host "Build complete: $OutDir"
|
||||
|
||||
# Lean config beside EXE (exclude finger visualization assets)
|
||||
$ConfigSrc = Join-Path $RepoRoot "apps\customer\config"
|
||||
$ConfigSrc = Join-Path $RepoRoot "apps\studio\config"
|
||||
$ConfigDst = Join-Path $OutDir "config"
|
||||
if (Test-Path $ConfigSrc) {
|
||||
Write-Host "Copying lean config to $ConfigDst …"
|
||||
@@ -96,7 +96,7 @@ if (Test-Path $ConfigSrc) {
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "Launch: $(Join-Path $OutDir 'OrisysCustomer.exe')"
|
||||
Write-Host "Launch: $(Join-Path $OutDir 'OrisysStudio.exe')"
|
||||
|
||||
# Size summary
|
||||
if (Test-Path $OutDir) {
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Frozen entry for Orisys Studio (no source-tree chdir)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from orisys_studio.main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- mode: python ; coding: utf-8 -*-
|
||||
"""PyInstaller onedir spec for Orisys Customer (Windows)."""
|
||||
"""PyInstaller onedir spec for Orisys Studio (Windows)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -57,7 +57,7 @@ icon = str(icon_path) if icon_path.is_file() else None
|
||||
a = Analysis(
|
||||
[str(ENTRY)],
|
||||
pathex=[
|
||||
str(REPO_ROOT / "apps" / "customer" / "src"),
|
||||
str(REPO_ROOT / "apps" / "studio" / "src"),
|
||||
str(REPO_ROOT / "shared" / "src"),
|
||||
],
|
||||
binaries=binaries,
|
||||
@@ -105,7 +105,7 @@ exe = EXE(
|
||||
a.scripts,
|
||||
[],
|
||||
exclude_binaries=True,
|
||||
name="OrisysCustomer",
|
||||
name="OrisysStudio",
|
||||
debug=False,
|
||||
bootloader_ignore_signals=False,
|
||||
strip=False,
|
||||
@@ -127,5 +127,5 @@ coll = COLLECT(
|
||||
strip=False,
|
||||
upx=False,
|
||||
upx_exclude=[],
|
||||
name="OrisysCustomer",
|
||||
name="OrisysStudio",
|
||||
)
|
||||
@@ -0,0 +1,367 @@
|
||||
"""Studio 3D v1 design contracts, 2026-09-14.
|
||||
|
||||
Specification only: split into the proposed modules when implementing.
|
||||
No rendering, asset loading, decryption or SDK changes are implemented here.
|
||||
All published NumPy arrays must be C-contiguous and read-only (frozen dataclasses
|
||||
alone do not freeze ndarray contents). Shapes are contractual, not type-checked
|
||||
by NDArray. See studio-3d-design.md for threading and validation requirements.
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Generic, Literal, Mapping, Protocol, TypeAlias, TypeVar
|
||||
from concurrent.futures import Executor
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
from PyQt5.QtCore import QEvent, pyqtBoundSignal
|
||||
from PyQt5.QtGui import QMouseEvent, QOpenGLContext, QWheelEvent
|
||||
from PyQt5.QtWidgets import QOpenGLWidget, QWidget
|
||||
from orisys_app_shared.acquisition.models import SdkPacket
|
||||
from orisys.finger.runtime import FingerRuntime
|
||||
|
||||
F32: TypeAlias = NDArray[np.float32]
|
||||
F64: TypeAlias = NDArray[np.float64]
|
||||
I32: TypeAlias = NDArray[np.int32]
|
||||
U32: TypeAlias = NDArray[np.uint32]
|
||||
Mask: TypeAlias = NDArray[np.bool_]
|
||||
Role: TypeAlias = Literal['sensing', 'housing', 'reference']
|
||||
Quality: TypeAlias = Literal['calibrated', 'approximate', 'display_only']
|
||||
Profile: TypeAlias = Literal['desktop33', 'gles30']
|
||||
OfferResult: TypeAlias = Literal['accepted', 'replaced', 'stale', 'disabled', 'invalid']
|
||||
SceneState: TypeAlias = Literal['disabled', 'loading', 'ready', 'live', 'no_data', 'suspended', 'unavailable']
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Diagnostic:
|
||||
code: str # Stable machine-readable code, e.g. geometry_mismatch.
|
||||
severity: Literal['info', 'warning', 'error']
|
||||
message: str # Human-readable, never contains keys or decrypted asset data.
|
||||
details: Mapping[str, str]
|
||||
|
||||
|
||||
class ThreeDError(Exception):
|
||||
"""Boundary exception; GUI converts to diagnostic and keeps 2D operational."""
|
||||
diagnostic: Diagnostic
|
||||
def __init__(self, diagnostic: Diagnostic) -> None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FrameIdentity:
|
||||
source_id: str # Live stream UUID or recording UUID; not just a filename.
|
||||
session_id: int
|
||||
frame_id: int
|
||||
t_rel_s: float # Acquisition-relative monotonic time, never wall-clock for ordering.
|
||||
generation: int # Increment on activation/source/geometry change.
|
||||
request_id: int # Replay cursor request; 0 for ordinary live frames.
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FieldDescriptor:
|
||||
quantity: str # Examples: flow_magnitude, signed_normal_displacement, depth_response.
|
||||
unit: str # px, m, or explicit algorithm-response unit; never inferred from shape.
|
||||
quality: Quality
|
||||
signed: bool
|
||||
calibration_id: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SurfaceFieldFrame:
|
||||
identity: FrameIdentity
|
||||
geometry_id: str
|
||||
field_domain_id: str # Includes geometry, UV shape, crop/scale/flip convention.
|
||||
scalar: F32 # [H,W]; measured/displayed quantity, not RGB.
|
||||
scalar_descriptor: FieldDescriptor
|
||||
valid: Mask # [H,W]; false or non-finite scalar means unavailable, not zero.
|
||||
displacement_sensor_m: F32 | None # [H,W,3]; base displacement BEFORE display gain.
|
||||
displacement_quality: Quality | None
|
||||
diagnostic: Diagnostic | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Material:
|
||||
base_color_linear: tuple[float, float, float, float]
|
||||
shading: Literal['unlit_scalar', 'diffuse']
|
||||
double_sided: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MeshPart:
|
||||
part_id: str
|
||||
role: Role
|
||||
positions_sensor_m: F32 # [N,3], baked coordinates.
|
||||
normals_sensor: F32 # [N,3], finite unit normals.
|
||||
triangles: U32 # [M,3], in [0,N).
|
||||
material: Material
|
||||
lod_id: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SurfaceBinding:
|
||||
part_id: str # The sensing part only.
|
||||
geometry_id: str
|
||||
field_domain_id: str
|
||||
shape_hw: tuple[int, int]
|
||||
sample_indices: I32 # [N,4], row-major y*W+x; unused index=-1 with weight=0.
|
||||
sample_weights: F32 # [N,4], nonnegative, used weights sum to one.
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CameraPreset:
|
||||
name: str
|
||||
eye_sensor_m: tuple[float, float, float]
|
||||
target_sensor_m: tuple[float, float, float]
|
||||
up_sensor: tuple[float, float, float]
|
||||
vertical_fov_rad: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExpectedGeometry:
|
||||
sensor_model: str
|
||||
hardware_revision: str
|
||||
geometry_id: str
|
||||
field_domain_id: str
|
||||
shape_hw: tuple[int, int]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SceneAssets:
|
||||
asset_id: str
|
||||
asset_version: str
|
||||
asset_hash: str # Digest of canonical manifest/content, not encryption nonce.
|
||||
geometry_id: str
|
||||
parts: tuple[MeshPart, ...]
|
||||
binding: SurfaceBinding # v1 supports exactly one sensing part.
|
||||
camera_presets: tuple[CameraPreset, ...]
|
||||
bounds_sensor_m: F32 # [2,3]: min, max.
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ColorScale:
|
||||
descriptor: FieldDescriptor
|
||||
lower: float # Finite; lower < upper, in descriptor.unit.
|
||||
upper: float
|
||||
lut_id: str # Approved sequential/diverging LUT.
|
||||
mode: Literal['fixed', 'automatic']
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DisplaySettings:
|
||||
display_gain: float # Finite positive; affects only dynamic positions.
|
||||
deform_enabled: bool
|
||||
color_scale: ColorScale
|
||||
housing_visible: bool
|
||||
max_fps: int # Display limit; never forwarded as camera/recording FPS.
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VertexFields:
|
||||
scalar: F32 # [N], unscaled scientific value.
|
||||
valid: Mask # [N]; any positive-weight invalid sample invalidates the vertex.
|
||||
displacement_sensor_m: F32 | None # [N,3].
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RenderFrame:
|
||||
identity: FrameIdentity
|
||||
asset_hash: str
|
||||
geometry_id: str
|
||||
sensing_part_id: str
|
||||
positions_sensor_m: F32 # [N,3], display displacement already applied.
|
||||
normals_sensor: F32 # [N,3], valid even at vertices excluded by mask.
|
||||
scalar: F32 # [N], BEFORE display gain.
|
||||
valid: Mask # [N].
|
||||
scalar_descriptor: FieldDescriptor
|
||||
diagnostics: tuple[Diagnostic, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CameraMatrices:
|
||||
view: F32 # [4,4]; p_camera = view @ p_sensor.
|
||||
projection: F32 # [4,4]; p_clip = projection @ p_camera.
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GLCapabilities:
|
||||
profile: Profile
|
||||
vendor: str
|
||||
renderer: str
|
||||
gl_version: str
|
||||
glsl_version: str
|
||||
qt_platform: str
|
||||
software_renderer: bool
|
||||
max_texture_size: int
|
||||
diagnostics: tuple[Diagnostic, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RenderStats:
|
||||
displayed_frames: int
|
||||
superseded_frames: int
|
||||
compose_ms: float
|
||||
upload_ms: float # CPU submission time unless a separate GPU timer is used.
|
||||
paint_ms: float
|
||||
estimated_gpu_bytes: int
|
||||
|
||||
|
||||
class KeyProvider(Protocol):
|
||||
def get_content_key(self, key_id: str) -> bytes:
|
||||
"""Return authorized content key, or raise ThreeDError; never log it."""
|
||||
...
|
||||
|
||||
|
||||
class AssetLoader(Protocol):
|
||||
def load(self, path: Path, expected: ExpectedGeometry, keys: KeyProvider | None = None) -> SceneAssets:
|
||||
"""CPU/IO thread. Authenticate and validate before publishing assets."""
|
||||
...
|
||||
def validate(self, assets: SceneAssets, expected: ExpectedGeometry) -> tuple[Diagnostic, ...]: ...
|
||||
|
||||
|
||||
class LegacyFingerImporter(Protocol):
|
||||
def convert(self, export_dir: Path, map_path: Path, *,
|
||||
transform_sensor_from_source: F64, meters_per_source_unit: float) -> SceneAssets:
|
||||
"""Offline. Scale source lengths to meters, then apply a rigid transform.
|
||||
|
||||
transform translation is meters; its rotation contains no extra scale.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompileOptions:
|
||||
asset_id: str
|
||||
version: str
|
||||
expected: ExpectedGeometry
|
||||
transform_sensor_from_glb: F64 # [4,4]; GLB lengths are already meters.
|
||||
max_surface_vertices: int
|
||||
max_housing_triangles: int
|
||||
signing_key_reference: str # Build environment reference, not private key bytes.
|
||||
encryption_key_id: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompileReport:
|
||||
output: Path
|
||||
asset_hash: str
|
||||
diagnostics: tuple[Diagnostic, ...]
|
||||
part_vertex_counts: Mapping[str, int]
|
||||
part_triangle_counts: Mapping[str, int]
|
||||
|
||||
|
||||
class SceneCompiler(Protocol):
|
||||
def compile(self, surface: SceneAssets, housing_glb: Path | None, output: Path,
|
||||
options: CompileOptions) -> CompileReport: ...
|
||||
|
||||
|
||||
class FingerFrameAdapter(Protocol):
|
||||
def snapshot(self, packet: SdkPacket, runtime: FingerRuntime,
|
||||
identity: FrameIdentity) -> SurfaceFieldFrame | None:
|
||||
"""Acquisition thread only. Copy/own one frame; no GL and no GUI calls."""
|
||||
...
|
||||
|
||||
|
||||
class BoxFrameAdapter(Protocol):
|
||||
def snapshot(self, packet: SdkPacket, identity: FrameIdentity,
|
||||
domain: ExpectedGeometry) -> SurfaceFieldFrame | None: ...
|
||||
|
||||
|
||||
class SurfaceSampler(Protocol):
|
||||
def sample(self, frame: SurfaceFieldFrame, binding: SurfaceBinding) -> VertexFields: ...
|
||||
|
||||
|
||||
class SceneComposer(Protocol):
|
||||
def compose(self, assets: SceneAssets, frame: SurfaceFieldFrame,
|
||||
settings: DisplaySettings) -> RenderFrame:
|
||||
"""CPU worker. Validate identity/units, sample, deform, update normals."""
|
||||
...
|
||||
def rest_frame(self, assets: SceneAssets, identity: FrameIdentity) -> RenderFrame:
|
||||
"""Static reference; all measurement validity false, scalar unavailable."""
|
||||
...
|
||||
|
||||
|
||||
T = TypeVar('T')
|
||||
class LatestFrameMailbox(Generic[T]):
|
||||
def __init__(self) -> None: ...
|
||||
def offer(self, frame: T) -> OfferResult:
|
||||
"""Thread-safe capacity-one replacement, constant-time under the lock."""
|
||||
...
|
||||
def take(self) -> T | None: ...
|
||||
def clear(self) -> None: ...
|
||||
@property
|
||||
def superseded_frames(self) -> int: ...
|
||||
|
||||
|
||||
class OrbitCamera(Protocol):
|
||||
def fit(self, bounds_sensor_m: F32, aspect: float) -> None: ...
|
||||
def orbit(self, delta_px: tuple[float, float], viewport: tuple[int, int]) -> None: ...
|
||||
def pan(self, delta_px: tuple[float, float], viewport: tuple[int, int]) -> None: ...
|
||||
def zoom(self, wheel_steps: float) -> None: ...
|
||||
def set_preset(self, preset: CameraPreset) -> None: ...
|
||||
def matrices(self, viewport: tuple[int, int]) -> CameraMatrices: ...
|
||||
|
||||
|
||||
def configure_surface_format(profile: Profile) -> None:
|
||||
"""Before QApplication and before importing a platform-specific GL binding."""
|
||||
...
|
||||
|
||||
|
||||
class GLDispatch(Protocol):
|
||||
def bind_current_context(self, context: QOpenGLContext) -> GLCapabilities:
|
||||
"""GUI thread; Qt owns the current context and native surface."""
|
||||
...
|
||||
|
||||
|
||||
class GLRenderer(Protocol):
|
||||
def initialize(self, capabilities: GLCapabilities) -> None: ...
|
||||
def upload_scene(self, assets: SceneAssets) -> None: ...
|
||||
def update_surface(self, frame: RenderFrame) -> None: ...
|
||||
def draw(self, camera: CameraMatrices, settings: DisplaySettings,
|
||||
framebuffer: int, viewport: tuple[int, int]) -> RenderStats: ...
|
||||
def release(self) -> None:
|
||||
"""Idempotent; correct owning context MUST be current."""
|
||||
...
|
||||
|
||||
|
||||
class Sensor3DWidget(QOpenGLWidget):
|
||||
capabilitiesChanged: pyqtBoundSignal # Payload: GLCapabilities.
|
||||
diagnosticRaised: pyqtBoundSignal # Payload: Diagnostic.
|
||||
renderStatsChanged: pyqtBoundSignal # Payload: RenderStats; rate-limited.
|
||||
def __init__(self, renderer: GLRenderer, camera: OrbitCamera,
|
||||
parent: QWidget | None = None) -> None: ...
|
||||
def set_scene(self, assets: SceneAssets) -> None:
|
||||
"""GUI: retain CPU data, schedule upload when context is current."""
|
||||
...
|
||||
def present(self, frame: RenderFrame) -> None: ...
|
||||
def set_settings(self, settings: DisplaySettings) -> None: ...
|
||||
def initializeGL(self) -> None: ...
|
||||
def paintGL(self) -> None: ...
|
||||
def resizeGL(self, w: int, h: int) -> None: ...
|
||||
def release_resources(self) -> None: ...
|
||||
def mousePressEvent(self, event: QMouseEvent) -> None: ...
|
||||
def mouseMoveEvent(self, event: QMouseEvent) -> None: ...
|
||||
def wheelEvent(self, event: QWheelEvent) -> None: ...
|
||||
def hideEvent(self, event: QEvent) -> None: ...
|
||||
def showEvent(self, event: QEvent) -> None: ...
|
||||
|
||||
|
||||
class ThreeDCoordinator:
|
||||
def __init__(self, widget: Sensor3DWidget, composer: SceneComposer,
|
||||
executor: Executor, max_fps: int = 30) -> None: ...
|
||||
def activate(self, assets: SceneAssets, source_id: str) -> int: ...
|
||||
def submit(self, frame: SurfaceFieldFrame) -> OfferResult: ...
|
||||
def tick(self) -> None: ...
|
||||
def set_visible(self, visible: bool) -> None: ...
|
||||
def set_settings(self, settings: DisplaySettings) -> None: ...
|
||||
def close(self) -> None: ...
|
||||
@property
|
||||
def state(self) -> SceneState: ...
|
||||
@property
|
||||
def generation(self) -> int: ...
|
||||
|
||||
|
||||
class ThreeDPanel(QWidget):
|
||||
"""UI-only controls; no asset IO, SDK reads, or direct GL calls."""
|
||||
def __init__(self, coordinator: ThreeDCoordinator, widget: Sensor3DWidget,
|
||||
parent: QWidget | None = None) -> None: ...
|
||||
def set_diagnostic(self, diagnostic: Diagnostic | None) -> None: ...
|
||||
def set_state(self, state: SceneState) -> None: ...
|
||||
def set_render_stats(self, stats: RenderStats) -> None: ...
|
||||
@@ -0,0 +1,425 @@
|
||||
# Studio 三维显示架构与实施规范
|
||||
|
||||
版本:1.0 提案;日期:2026-09-14。目标读者:Studio、SDK、Blender 资产、ARM 平台和测试工程师。
|
||||
|
||||
本文的“必须”是接口正确性要求,“建议”是第一版实现选择,“待验证”不是已获硬件验收。本文配套 `studio-3d-contracts.pyi` 是接口骨架,不是可运行的三维功能。
|
||||
|
||||
## 1. 决策与实施范围
|
||||
|
||||
**推荐现有 PyQt5 的 QOpenGLWidget + PyOpenGL,使用现代着色器和 GPU 缓冲区;Open3D 保留为离线几何处理和开发验证工具。** 不为三维显示更换整个 Studio UI 技术栈。
|
||||
|
||||
目标为 Jetson Orin Nano、QCS6490、Ubuntu。目前没有两块实际部署板的 Ubuntu/BSP、Qt 构建方式、窗口系统和驱动版本;这不阻碍接口设计,但禁止承诺“装好 pip 包即可兼容 ARM”。
|
||||
|
||||
| 方案 | 适合的职责 | 对本项目的判断 |
|
||||
|---|---|---|
|
||||
| QOpenGLWidget + PyOpenGL | 嵌入现有 Qt、动态网格、固定色标、分层渲染 | 首选;自己维护相机、shader、资源生命周期,工作量可控 |
|
||||
| Open3D | 点云、配准、网格处理、离线算法验证 | 不作为 Studio 运行时必需依赖;其 GUI 与 ARM 图形栈有额外限制 |
|
||||
| Qt 自身 OpenGL 封装 | Qt 上下文与资源管理;可作为 GL 调用适配实现 | 若 PyOpenGL 与目标平台 EGL 函数加载不匹配,在 GLDispatch 内替换绑定;需验证实际 PyQt5 暴露的接口 |
|
||||
| 更换完整场景引擎/Qt 大版本 | 重型场景、复杂材质、动画 | 第一版需求不足以抵消迁移成本,暂不做 |
|
||||
|
||||
Open3D 官方将 ARM64 支持标为实验性;其支持矩阵区分 Linux 桌面 OpenGL 与 OpenGL ES,后者不支持其 Visualizer/GUI。新渲染模块的文档另要求 OpenGL 4.1。不能把 Jetson 上的运行结果等同于 QCS6490,也不能把 Python 包可安装当作 GUI 可用。[Open3D ARM 支持](https://www.open3d.org/docs/release/arm.html)、[渲染要求](https://www.open3d.org/docs/release/tutorial/visualization/cpu_rendering.html)
|
||||
|
||||
第一版包括:静态完整外观、动态感知表面、旋转/缩放/平移/复位/预设视角、固定或手动色标、图例、显示倍率、外壳显隐、性能与不可用提示。透明外壳、阴影、PBR、多重后处理、局部矢量和三维拾取列为后续项。
|
||||
|
||||
## 2. 当前代码与资产事实
|
||||
|
||||
代码检查基于当前工作区;审查期间 app HEAD 已更新为 `0d8b79b`,另有滤波相关未提交修改。本提案不覆盖这些修改,不要求恢复已经删除的旧 `record/depth.py`。
|
||||
|
||||
| 现有文件/类 | 当前内容 | 复用方式 |
|
||||
|---|---|---|
|
||||
| `orisys-sdk/src/orisys/finger/exports.py` / `FingerExportPaths`、`load_finger_export_bundle()` | 寻找并加载 Blender 的网格、内参、位姿 | 保持 SDK 兼容;不要将显示外壳塞进算法网格 |
|
||||
| `orisys-sdk/src/orisys/unroll/precompute.py` / `load_exported_data()` | 加载 vertices/faces/逐角 UV/K/world_to_cam | 离线导入与旧资产适配 |
|
||||
| `orisys-sdk/src/orisys/finger/mesh.py` / `build_uv_surface_mesh()` | 从 UV `world_xyz` 构建表面,保存像素采样位置 | 复用几何算法;第一版在加载阶段构建,不每帧重新建拓扑 |
|
||||
| 同文件 `sample_field_at_mesh_vertices()`、`lift_uv_flow_to_3d()` | 逐顶点采样与光流切向映射 | 封装在 Finger 适配器,不依赖 Open3D viewer |
|
||||
| 同文件 `lift_uv_deformation_to_3d()`、`deform_mesh_vertices()` | 相对形变示意;存在 normal_gain、近似 pixel_to_world 与显示倍率 | 只能在明确标记 approximate 的通道使用 |
|
||||
| `orisys-sdk/src/orisys/finger/viewer.py` | Open3D 网格创建与更新 | 留给 SDK 示例,Studio 不导入 |
|
||||
| `orisys-sdk/examples/viz/mesh_camera.py` | 观察相机;含额外 X/Z 旋转 | 作为迁移对照;不能将旋转常量散落到 Studio shader |
|
||||
| `orisys-app/shared/src/orisys_app_shared/acquisition/models.py` / `SdkPacket` | flow、depth、valid_mask、会话/帧身份 | 新增一个可选三维字段或独立显示订阅,避免多个散乱字段 |
|
||||
| `orisys-app/apps/studio/src/orisys_studio/replay.py` | 当前回放视频和力,不保存/直接重算深度;重算属于采集入口 | 普通历史回放不能假装拥有三维测量数据 |
|
||||
|
||||
实际读取资产结构(`np.load(..., allow_pickle=False)`):
|
||||
|
||||
| 资产目录 | 内容 | 实际结构 |
|
||||
|---|---|---|
|
||||
| `sensors/finger/assets/30fps-0703` | `mesh.npz` | vertices float32[1032,3];faces int32[1974,3];uv_data float32[5922,2];loop_to_vertex int32[5922] |
|
||||
| 同目录 | `camera_intrinsics.npz` / `world_to_cam.npy` | K float64[3,3];宽高 int32 标量;位姿 float32[4,4] |
|
||||
| 同目录 | `pixel_to_xyz.npz` | xyz_map float32[720,1280,3] |
|
||||
| 同目录 | `unroll_map_uv_s0.4.npz` | map_x/map_y/valid_mask 为 [300,280];world_xyz 为 [300,280,3];含 source_scale、flip_v、projection_K |
|
||||
| `sensors/finger/assets/visualization` | `mesh.npz` | vertices[1005,3]、faces[1921,3],与运行网格不是同一个拓扑 |
|
||||
| 同目录 | `pixel_to_xyz.npz` / `flatten_remap.npz` | xyz_map[500,500,3];展平映射及 flat_xyz[1024,1024,3] |
|
||||
|
||||
**不能把 1032 个导出顶点当作最终实时渲染顶点数。** 当前 UV 网格函数是每个有效 UV 像素生成一个顶点,300×280 的网格在全有效时上限为 84,000 顶点;实际数量需要按 valid_mask 统计。两个资产目录也不能按数组下标直接拼接。
|
||||
|
||||
`uv_data` 为每个面角的数据,不能当作每个顶点的一份 UV。UV 接缝处若一个几何顶点拥有多个 UV,显示导出必须拆成多个 render vertex,并保留采样映射;不得强行平均 UV。
|
||||
|
||||
## 3. 坐标、单位与测量含义
|
||||
|
||||
### 3.1 坐标契约
|
||||
|
||||
新增场景的公共坐标定义为 `sensor`:右手系、长度单位米。X/Y/Z 的物理方向由资产 manifest 明确描述,并与力轴约定核对;现有 Blender 文件不能在没有证据时自动认定为米或自动认定 +Z 为传感器长轴。
|
||||
|
||||
统一记号 `T_A_from_B`:列向量满足 `p_A = T_A_from_B @ p_B`。平移在最后一列。JSON 矩阵按行序列化;上传 shader 时显式按列主序打包,GLES 使用 transpose=false,不让 NumPy 存储布局决定坐标含义。
|
||||
|
||||
转换分成三种:
|
||||
|
||||
1. **算法相机**:原始 `K`、畸变、`world_to_cam`、裁剪/缩放/flip_v,服务像素到曲面的映射,保留原有精度和版本。
|
||||
2. **资产坐标**:Blender/glTF/旧 NPZ 到 sensor 的转换。离线发布时一次性烘焙到网格、法线和映射;运行时不再重复应用同一转换。
|
||||
3. **观察相机**:用户 orbit/pan/zoom,仅改变 view/projection,不改变算法输入、标定和力轴。默认观察相机是显示资产的一部分,不自动等同于真实摄像头。
|
||||
|
||||
glTF 使用右手系、Y 向上、长度米;仍需显式转换到本项目的 sensor 轴约定。负缩放或坐标翻转要同步修正三角形绕序;法线使用逆转置,不能直接乘含非均匀缩放的模型矩阵。[glTF 坐标与变换规范](https://registry.khronos.org/glTF/specs/2.0/glTF-2.0.html#coordinate-system-and-units)
|
||||
|
||||
### 3.2 “形变”不是天然的毫米
|
||||
|
||||
当前 `lift_uv_deformation_to_3d()` 对 vnormal 求模,再乘法线;这不能保留一个任意有符号法向位移的含义。`mesh_uv_to_world_scale()` 是由网格跨度估计每 UV 像素对应的世界尺度,不是经过验证的位移标定。
|
||||
|
||||
因此帧契约必须分开:
|
||||
|
||||
- `scalar`:着色量,带 `quantity`、`unit`、`signed`、`quality`;如 `flow_magnitude / px / false / approximate`。
|
||||
- `displacement_sensor_m`:若提供,必须说明是 calibrated、approximate 还是仅 display_only;approximate 即使以米存储,也不能以毫米测量值向用户呈现。
|
||||
- `display_gain`:只作用于显示顶点 `p_display = p_rest + gain * displacement`,不得写回原始记录、改动标定或用放大后的值计算色标。
|
||||
- 没有可靠位移时,默认只在静态曲面着色。用户启用示意形变时显示“相对形变示意 ×N”,不得把 FN/FX/FY 总力直接当作每个顶点高度。
|
||||
|
||||
跨帧比较默认固定色标;有符号量使用围绕零的发散色图,非负量使用顺序色图。无效点显示中性颜色或遮罩,不当零值,超范围值夹取颜色并保留超范围状态。
|
||||
|
||||
## 4. 感知表面与非感知结构怎样分开
|
||||
|
||||
建议一个场景、两个独立 draw part,不把整机合成一个动态网格:
|
||||
|
||||
| part_id / role | 几何来源 | 每帧更新 | 显示规则 |
|
||||
|---|---|---|---|
|
||||
| `sensing_surface` / sensing | 真实感知曲面或绑定其采样映射的显示 LOD | 顶点、必要时法线、scalar、valid | 科学色图;参与传感色标和数据查询 |
|
||||
| `housing` / housing | Blender 简化外壳 | 无;VBO/EBO 只上传一次 | 灰/石墨材质;不参加测量色标和峰值统计 |
|
||||
| `reference_axes` / reference | 显示辅助几何 | 相机变化时更新视图 | 用户可隐藏;不伪装成测量 |
|
||||
|
||||
两部分都转换到 sensor 坐标,分别持有索引和材质。感知面不与外壳共用可变顶点缓冲。Blender 推荐两个明确命名的 Collection/Object,导出器按角色读取,不以颜色猜角色。
|
||||
|
||||
第一版外壳使用不透明材质和简单漫反射;感知面采用保留色值的着色,光照不能让同一数值在不同朝向看起来像不同数值。先保留“外壳显示/隐藏”,透明外壳后做:它涉及排序和深度写入,不能只把 alpha 调小。
|
||||
|
||||
衔接原则:
|
||||
|
||||
- 模型接缝由美术在统一坐标中设计;外壳不复制一层完全重叠的感知面,避免 z-fighting。
|
||||
- 若显示放大产生明显穿插,可提供“示意形变”倍率上限或仅着色模式;不能为美观强行锁定测量边界并改写数据。
|
||||
- 外壳 LOD 可独立简化。感知面简化必须同时生成新的采样绑定,不能保留旧 vertex_id/UV 表后换拓扑。
|
||||
- 拾取后续如加入,返回 part_id;点到 housing 时显示“非感知结构”,不能返回一个虚构力值。
|
||||
|
||||
## 5. 文件格式与复制保护
|
||||
|
||||
### 5.1 三层资产
|
||||
|
||||
| 层 | 推荐格式 | 发布策略 |
|
||||
|---|---|---|
|
||||
| 内部源文件 | `.blend`、高精度机械模型、原始相机标定 | 留在受控资产仓库;不随客户程序分发 |
|
||||
| 制作交换 | 外观采用 `.glb`;算法几何/映射保留现有 NPZ/NPY | 用于美术与导出工具;GLB 是标准交换格式,不是保密格式 |
|
||||
| Studio 运行包 | 自定义扩展名 `.oryscene`,内部为签名 manifest + 数值数组块;可选加密 | 渲染器仅接收已解包、已验证的 SceneAssets,不感知文件格式 |
|
||||
|
||||
第一版无需在 ARM 运行时实现完整 glTF/PBR 引擎:离线 exporter 将 GLB 的必要节点、变换、法线和基础材质编译成紧凑数组;运行时只支持本规范的固定 mesh/material 子集。GLB 动画、骨骼、任意扩展、Draco 等默认不进入第一版。
|
||||
|
||||
既有 SDK NPZ/NPY 路径继续兼容。若要加密算法资产,应另行给 SDK 增加只读 AssetProvider/内存流入口;仅加密 Studio 包而继续分发相同明文算法网格,不应宣称那些几何已得到保护。
|
||||
|
||||
### 5.2 明确保护目标
|
||||
|
||||
用户确认目标为**提高逆向和复制成本**,不是保证客户端无法提取模型。
|
||||
|
||||
建议依次落实:
|
||||
|
||||
1. **最小交付**:仅发布简化外壳与需要的感知几何;去掉内部机械结构、隐藏对象、原始命名、无用相机、设计历史和高精度源资产。
|
||||
2. **完整性和来源**:构建机签名发布包,客户端只带验签公钥;manifest 的版本、资源角色、大小、哈希和兼容信息都纳入签名。SHA-256 只能检查内容,不等同于防篡改签名。
|
||||
3. **可选加密**:使用成熟库的认证加密,例如 AES-GCM;同一密钥下每次包加密的 nonce 必须唯一,元数据作为关联数据绑定。不要自制 XOR、把密码藏在扩展名或把 ZIP 当加密。[认证加密接口及 nonce 约束](https://cryptography.io/en/latest/hazmat/primitives/aead/#cryptography.hazmat.primitives.ciphers.aead.AESGCM)
|
||||
4. **密钥隔离**:签名私钥不能进入客户软件;内容密钥经 KeyProvider 获取,可按产品/客户授权。若需要离线,明确离线授权/缓存失效流程。Python 内硬编码全局密钥只能提供很弱的阻力;原生加载器、设备密钥保护可提高成本但不能防运行时捕获。
|
||||
5. **解密范围**:尽量内存解密、不输出明文临时文件、日志不打印密钥;Python/驱动内存副本不能承诺被完全擦除。
|
||||
|
||||
即使加密,渲染最终仍需在 CPU/GPU 获得几何。若某结构绝不能泄露,就不要交付该结构,改成视觉代理模型。包格式和加密不能代替这一决策。
|
||||
|
||||
### 5.3 `.oryscene` v1 逻辑内容
|
||||
|
||||
建议封装采用有大小上限的 manifest 与数组条目,不执行脚本、不使用 pickle。压缩是容量优化,不属于安全机制。读取器拒绝路径穿越、未知必须扩展、异常尺寸、越界索引、非有限坐标及解压超限。
|
||||
|
||||
```text
|
||||
envelope.json # magic、envelope_version、key_id、nonce、签名信息
|
||||
payload # 可选认证加密;解开后是下列逻辑内容
|
||||
manifest.json
|
||||
sensing/positions.npy # float32[N,3],sensor 坐标、米
|
||||
sensing/normals.npy # float32[N,3],单位向量
|
||||
sensing/triangles.npy # uint32[M,3]
|
||||
sensing/sample_indices.npy # int32[N,4],UV 网格展平下标
|
||||
sensing/sample_weights.npy # float32[N,4],双线性权重
|
||||
housing/positions.npy
|
||||
housing/normals.npy
|
||||
housing/triangles.npy
|
||||
```
|
||||
|
||||
manifest 必须包含:schema_version、asset_id、asset_version、geometry_id、sensor_model、适用硬件修订范围、geometry_sha256、mapping_sha256、coordinate_frame、length_unit、源坐标到 sensor 的烘焙记录、算法网格/相机配置哈希、UV 宽高及原点方向、source_scale/crop/flip_v、各 part_id/role/LOD/材质/数组类型尺寸、所有内容哈希、默认观察相机、编译工具版本。
|
||||
|
||||
hash 的定义必须稳定:签名不包含自身;按规定的规范化 manifest 字节及有序条目摘要计算。明文内容哈希用于 geometry_id/兼容性,密文完整性由认证加密与签名处理,二者不能混称。
|
||||
|
||||
`sample_indices` 的每一行最多四个有效下标,未使用项为 -1 且权重 0;有效权重和为 1。加载后验证下标属于 manifest 声明的 UV 域。运行期若任一正权重点无效,默认将该顶点标无效,不跨遮罩补值。
|
||||
|
||||
编译器与加载器要共用 JSON Schema。下面仅为字段关系示例,尖括号值由编译器生成,不能作为有效发布包:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"asset_id": "finger-display",
|
||||
"asset_version": "1.0.0",
|
||||
"geometry_id": "<geometry-digest>",
|
||||
"coordinate_frame": "sensor",
|
||||
"length_unit": "m",
|
||||
"compatible": {"sensor_model": "finger", "hardware_revisions": ["<revision>"]},
|
||||
"field_domain": {
|
||||
"id": "<domain-digest>", "shape_hw": [300, 280],
|
||||
"index_order": "row-major", "origin": "top-left",
|
||||
"source_scale": 0.4, "flip_v": "<actual-value>",
|
||||
"mapping_sha256": "<mapping-digest>", "camera_sha256": "<camera-digest>"
|
||||
},
|
||||
"parts": [
|
||||
{"id": "sensing_surface", "role": "sensing", "mesh": "sensing", "binding": "uv-lod0"},
|
||||
{"id": "housing", "role": "housing", "mesh": "housing"}
|
||||
],
|
||||
"entries": {
|
||||
"sensing/positions.npy": {"dtype": "<f4", "shape": ["<N>", 3], "sha256": "<digest>"}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. 模块边界与文件骨架
|
||||
|
||||
```text
|
||||
orisys-app/shared/src/orisys_app_shared/scene3d/
|
||||
contracts.py # DTO、枚举、形状/单位/身份校验,无 Qt/OpenGL
|
||||
assets.py # AssetLoader、LegacyFingerImporter、包验证
|
||||
sampling.py # SurfaceSampler:UV 场 → 顶点字段,纯 NumPy
|
||||
composer.py # SceneComposer:字段 → RenderFrame,含法线/显示倍率
|
||||
|
||||
orisys-app/apps/studio/src/orisys_studio/scene3d/
|
||||
coordinator.py # 订阅、latest-only 调度、generation/状态、错误
|
||||
adapters.py # SdkPacket/FingerRuntime → SurfaceFieldFrame
|
||||
gl_backend.py # GLDispatch、GLRenderer、shader、GPU 资源
|
||||
camera.py # OrbitCamera;纯数学,不读取算法相机配置
|
||||
widget.py # Sensor3DWidget(QOpenGLWidget);Qt 事件与上下文
|
||||
panel.py # 视角/倍率/色标/外壳显隐,委托 coordinator
|
||||
|
||||
orisys-app/tools/scene_assets/
|
||||
compile_scene.py # 离线旧 NPZ + GLB → 运行包
|
||||
validate_scene.py # 数值、角色、坐标、兼容性和包完整性检查
|
||||
probe_gl.py # 在实际 Qt 窗口中探测上下文并输出诊断 JSON
|
||||
```
|
||||
|
||||
这些是建议职责,不要求第一天全部拆成独立大类。体量小时 `sampling/composer` 可同文件,shader 可与 backend 同目录;禁止把 SDK、解密、重建网格、OpenGL 和 UI 事件塞到一个 Widget。
|
||||
|
||||
依赖方向:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
SDK[SDK / 采集线程] --> A[FrameAdapter]
|
||||
A --> Q[LatestFrameMailbox]
|
||||
Q --> C[SceneComposer / CPU 工作线程]
|
||||
P[AssetLoader / 已验证资产] --> C
|
||||
C --> R[Latest RenderFrame]
|
||||
R --> W[Sensor3DWidget / GUI线程]
|
||||
W --> G[GLRenderer / 当前Qt上下文]
|
||||
U[Panel / OrbitCamera] --> W
|
||||
SDK --> D[原有全量记录通道]
|
||||
```
|
||||
|
||||
三维帧丢弃只影响显示,不增加录像/CSV 丢帧数;不能给全量记录通道施加渲染背压。SDK 数学层也不能反向 import Studio 或 OpenGL。
|
||||
|
||||
## 7. 核心数据接口
|
||||
|
||||
完整签名见同目录 `studio-3d-contracts.pyi`。下面的 shape 是硬性契约;frozen dataclass 不会冻结 NumPy 数组,发布方必须复制或转移所有权并置只读。
|
||||
|
||||
| 类型 | 关键字段 | 不变量 |
|
||||
|---|---|---|
|
||||
| `FrameIdentity` | source_id、session_id、frame_id、t_rel_s、generation、request_id | source_id 区分实时/录像;generation 切换源时增加;request_id 区分回放请求 |
|
||||
| `FieldDescriptor` | quantity、unit、quality、signed | 不允许没有单位和质量说明的深度/位移 |
|
||||
| `SurfaceFieldFrame` | identity、geometry_id、field_domain_id、scalar[H,W]、valid[H,W]、可选 displacement[H,W,3] | 同一个采集帧、同一 UV 域、数组只读;缺失字段不能沿用前一帧 |
|
||||
| `MeshPart` | part_id、role、positions[N,3]、normals[N,3]、triangles[M,3]、材质 | 米、sensor 坐标;索引小于 N;role 决定是否动态 |
|
||||
| `SurfaceBinding` | sensing part_id、geometry_id、domain_id、shape_hw、indices[N,4]、weights[N,4] | 与 sensing 顶点数一致;LOD 改变必须更换绑定 |
|
||||
| `SceneAssets` | asset_id/hash、geometry_id、parts、binding、camera_presets | 第一版一个 sensing part,0..多个 housing;不能把相机预设作为映射 |
|
||||
| `DisplaySettings` | gain、deform_enabled、ColorScale、housing_visible、max_fps | 只影响显示;同一 scalar_descriptor 才能沿用色标 |
|
||||
| `RenderFrame` | identity、asset_hash、geometry_id、动态 positions/normals/scalar/valid | 无 GPU handle;携带完整身份,用于拒绝旧结果 |
|
||||
| `RenderStats` | displayed_frames、superseded_frames、compose_ms、upload_ms、paint_ms、estimated_gpu_bytes | 统计不和录制混用;CPU 提交时间不冒充真实 GPU 执行时间 |
|
||||
|
||||
## 8. 类与函数实施合同
|
||||
|
||||
### 8.1 资产加载与编译
|
||||
|
||||
| 类/函数 | 输入 | 输出 | 线程、错误与副作用 |
|
||||
|---|---|---|---|
|
||||
| `AssetLoader.load(path, expected, keys)` | 运行包路径、ExpectedGeometry、可选 KeyProvider | SceneAssets | CPU 工作线程;有文件 IO;签名/哈希/域/单位不匹配抛 ThreeDError,不能退回同名不同资产 |
|
||||
| `AssetLoader.validate(assets, expected)` | 资产、期望设备/域 | tuple[Diagnostic,...] | 纯校验;error 级诊断禁止发布资产,warning 可显示 |
|
||||
| `KeyProvider.get_content_key(key_id)` | 密钥标识 | bytes | 平台授权层;不得写日志;加载器不内置通用主密钥 |
|
||||
| `LegacyFingerImporter.convert(export_dir, map_path, transform, units)` | 旧资产目录、运行 UV 映射、已确认的变换/尺度 | SceneAssets | 仅开发/离线;缺少坐标依据必须拒绝,不从文件名猜 |
|
||||
| `SceneCompiler.compile(surface, housing_glb, output, options)` | 源资产、可选 GLB、输出、签名/打包选项 | CompileReport | 离线;校验后写临时包,再原子替换目标;私钥通过外部配置提供 |
|
||||
|
||||
旧 NPZ 导入器要先验证 `loop_to_vertex` 与面角顺序;LOD 导出后重新生成 binding;保持算法和显示资产各自版本,避免改外壳导致算法标定哈希变化。
|
||||
|
||||
`LegacyFingerImporter.convert()` 的 `meters_per_source_unit` 只负责先把源顶点长度换成米;`transform_sensor_from_source` 随后只包含刚体旋转和平移,平移单位为米,不重复含缩放。对 source 法线只应用旋转。GLB 本身使用米,其 transform 同样不重复进行单位换算。
|
||||
|
||||
### 8.2 字段适配、采样与合成
|
||||
|
||||
| 类/函数 | 输入 | 输出 | 线程与行为 |
|
||||
|---|---|---|---|
|
||||
| `FingerFrameAdapter.snapshot(packet, runtime, identity)` | 采集包、当前拥有的 FingerRuntime、身份 | SurfaceFieldFrame 或 None | 只能在 runtime 所在线程读其数组;没有三维订阅时返回 None;复制本帧所需数据,禁止 GUI 再追读 runtime |
|
||||
| `BoxFrameAdapter.snapshot(packet, identity, domain)` | 平面数据及已确认域 | SurfaceFieldFrame 或 None | 第一版可只支持 scalar;未知空间尺度不能造毫米高度场 |
|
||||
| `SurfaceSampler.sample(frame, binding)` | 只读 UV 字段、绑定 | VertexFields | CPU;按 indices/weights 向量化采样,检查域;不在每帧搜索最近三角形 |
|
||||
| `SceneComposer.compose(assets, frame, settings)` | 场景、字段、显示设置 | RenderFrame | CPU 工作线程;应用位移/倍率并更新动态法线;不操作 Qt、不创建 GL handle |
|
||||
| `SceneComposer.rest_frame(assets, identity)` | 场景与身份 | RenderFrame | 清除旧颜色/位移;用于刚加载、缺数据或断连后的静态参考 |
|
||||
|
||||
surface 原始坐标、动态位置和 scalar 分开持有。颜色映射建议在 shader 中用 scalar + LUT + 固定范围完成,避免每帧在 Python 分配大量 RGB 数组;适配器不得每帧遍历全部 Blender 面来建映射。
|
||||
|
||||
### 8.3 帧调度与状态
|
||||
|
||||
| 类/函数 | 输入 | 输出 | 线程与行为 |
|
||||
|---|---|---|---|
|
||||
| `LatestFrameMailbox.offer(frame)` | SurfaceFieldFrame | OfferResult | 采集线程可调用;容量 1,替换旧帧并计数,锁内只换引用 |
|
||||
| `LatestFrameMailbox.take()` | 无 | frame 或 None | CPU 合成线程;取出并清空;没有帧不忙轮询 |
|
||||
| `ThreeDCoordinator.activate(assets, source_id)` | 场景与数据源 | generation:int | GUI;取消旧请求、清空两个 mailbox、建立源身份 |
|
||||
| `ThreeDCoordinator.submit(frame)` | 帧 | OfferResult | 任意生产线程;快速校验身份,错误/过期拒绝;不直接触发 paint |
|
||||
| `ThreeDCoordinator.tick()` | 无 | None | GUI 定时器;收最新 RenderFrame,调用 widget.present(),按上限请求 update |
|
||||
| `ThreeDCoordinator.set_visible(bool)` | 可见性 | None | GUI;隐藏时取消三维合成/上传/渲染任务和订阅;不停止采集/记录 |
|
||||
| `ThreeDCoordinator.set_settings(settings)` | 显示设置 | None | GUI;色标/相机可独立刷新,位移倍率变化需重合成当前字段 |
|
||||
| `ThreeDCoordinator.close()` | 无 | None | GUI;取消任务,停止定时器,清 mailbox,断开信号;幂等 |
|
||||
|
||||
状态机:Disabled → Loading → Ready → Live;缺字段为 NoData,资产/GL 失败为 Unavailable。隐藏为 Suspended,恢复时用最新帧;断连时清旧动态帧并显示参考模型/“无实时数据”。过期帧、旧 generation 和旧 request_id 不得重新覆盖新场景。
|
||||
|
||||
两个 mailbox 各容量 1,加一个正在计算的帧;Qt 信号只通知状态,不排队传递每帧大数组。CPU 任务不得无限提交给线程池。完整数据记录继续走原有通道。
|
||||
|
||||
标准失败语义:
|
||||
|
||||
| code | 触发 | 调用方处理 |
|
||||
|---|---|---|
|
||||
| `asset_auth_failed` | 签名/认证标签错误 | 拒绝资产,进入 Unavailable;不尝试明文回退 |
|
||||
| `geometry_mismatch` | 场景、绑定、设备几何不一致 | 拒绝帧/资产,显示预期与实际 ID |
|
||||
| `field_domain_mismatch` | H/W、scale、flip 或域不同 | 拒绝帧;不能自动 resize 后继续冒充原映射 |
|
||||
| `field_unavailable` | 当前帧缺 scalar/有效数据 | NoData,清除旧测量颜色;允许参考模型 |
|
||||
| `stale_frame` | generation/request_id 过期 | 安静丢弃并计数,不弹窗打断操作 |
|
||||
| `gl_unsupported` | 实际上下文达不到所选 profile | Unavailable,二维照常;附 probe 信息 |
|
||||
| `shader_compile_failed` | shader 编译/链接失败 | Unavailable,日志保留 shader 版本和错误信息 |
|
||||
| `gpu_budget_exceeded` | 分配超过配置预算 | 显式选择更低 LOD 或拒绝三维,不无限重试 |
|
||||
|
||||
参考帧 `rest_frame()` 的 scalar 全部不可用,descriptor 使用 `quantity="unavailable"`、`unit="1"`、`quality="display_only"`,UI 隐藏测量色条。渲染器不得因为它与先前色标不匹配而留下旧颜色。
|
||||
|
||||
### 8.4 OpenGL 与 Qt
|
||||
|
||||
| 类/函数 | 输入 | 输出 | 限制 |
|
||||
|---|---|---|---|
|
||||
| `configure_surface_format(profile)` | desktop33 / gles30 | None | QApplication 创建之前;只选一个已验证平台配置 |
|
||||
| `GLDispatch.bind_current_context()` | 已经 current 的 Qt context | GLCapabilities | 选择和实际 GL/ES 匹配的函数绑定;不自行创建第二套 EGL 窗口 |
|
||||
| `GLRenderer.initialize(capabilities)` | 实际能力 | None | GUI/current context;编译对应 shader,失败抛 shader diagnostic |
|
||||
| `GLRenderer.upload_scene(assets)` | 验证后的资产 | None | 静态 VBO/EBO/LUT 一次分配;建立预算 |
|
||||
| `GLRenderer.update_surface(render_frame)` | 同资产动态帧 | None | 更新既有动态缓冲;不重建 housing/索引 |
|
||||
| `GLRenderer.draw(camera, settings, framebuffer, viewport)` | 相机矩阵、设置、Qt FBO、物理像素尺寸 | RenderStats | 一次场景绘制,不读磁盘、不调用 SDK、不处理 Qt 事件循环 |
|
||||
| `GLRenderer.release()` | 无 | None | 必须正确 context current;幂等;GPU 资源清零 |
|
||||
| `Sensor3DWidget.initializeGL()` | Qt 调用 | None | 创建 backend,连接 context.aboutToBeDestroyed;失败进入 Unavailable |
|
||||
| `Sensor3DWidget.paintGL()` | Qt 调用 | None | 验证最新帧身份,上传并绘制;无新帧只在相机/设置变化时重画 |
|
||||
| `Sensor3DWidget.resizeGL(w,h)` | Qt 尺寸 | None | 校准 DPR 与实际 framebuffer 尺寸,更新投影;不缓存旧 FBO |
|
||||
| `Sensor3DWidget.present(frame)` | RenderFrame | None | GUI,仅保留 CPU 帧并调用 update;不要求调用点已 current |
|
||||
| `Sensor3DWidget.release_resources()` | 无 | None | makeCurrent → release → doneCurrent;销毁/重建/关闭均能安全重复 |
|
||||
|
||||
Qt 的 widget 使用自身 FBO,不能把默认目标绑回 0;Qt 生命周期入口已经提供 current context,其他入口需显式管理。重新挂接窗口可能导致 context 重建,要从 CPU 资产恢复 GPU 资源。清理绑定 aboutToBeDestroyed,同时在关闭时显式执行。[Qt 5.15 QOpenGLWidget](https://doc.qt.io/qt-5/qopenglwidget.html)
|
||||
|
||||
shader 维护两个头部变体:桌面 `#version 330 core`;ES `#version 300 es`,显式 float/int precision。共享计算逻辑,禁止 glBegin/glEnd、固定管线矩阵、GL_QUADS、GLU/GLUT。第一版不依赖 geometry shader、compute shader、桌面专用 polygon mode。
|
||||
|
||||
buffer 使用连续 float32 和合法的 uint32 索引;draw call 按 part 合批。若最终必须支持 GLES2,作为独立降级项目处理着色器、VAO 和 16 位索引限制,不能以把版本号改成 2.0 作为完成。
|
||||
|
||||
### 8.5 相机和 UI 接口
|
||||
|
||||
| OrbitCamera 方法 | 输入 | 输出/行为 |
|
||||
|---|---|---|
|
||||
| `fit(bounds, aspect)` | 场景包围盒、宽高比 | None;设置 center/distance/near/far,使完整模型可见 |
|
||||
| `orbit(delta_px, viewport)` | 鼠标二维增量、视口 | None;围绕 target 旋转,不旋转模型数据 |
|
||||
| `pan(delta_px, viewport)` | 鼠标增量、视口 | None;按距离/投影换算世界平移 |
|
||||
| `zoom(wheel_steps)` | 滚轮步长 | None;指数缩放并限制近远范围,避免穿模 |
|
||||
| `set_preset(preset)` | CameraPreset | None;前/侧/顶/等轴测/默认 |
|
||||
| `matrices(viewport)` | 物理视口尺寸 | CameraMatrices(view, projection) |
|
||||
|
||||
建议交互:左键旋转,Shift+左键或中键平移,滚轮缩放,复位按钮回资产默认视角。手势保存在 panel/widget,具体轨迹计算在 OrbitCamera。相机、外壳显隐、倍率与色标可存 UI preferences,不改变 SDK 参数。
|
||||
|
||||
Panel 控件:三维开关、预设视角、复位、外壳显隐、显示形变开关与倍率、色标范围/自动范围、量名称/单位/quality、帧号/数据不可用提示。默认不把算法相机内参暴露成用户的观察相机控件。
|
||||
|
||||
## 9. 现有文件怎样接入
|
||||
|
||||
| 文件 | 类/函数入口 | 最小修改 |
|
||||
|---|---|---|
|
||||
| `apps/studio/src/orisys_studio/main.py` | `main()` | 在 QApplication 前选择 GL profile;避免 app_window 的导入链提前加载错误 PyOpenGL platform |
|
||||
| `shared/.../acquisition/models.py` | `SdkPacket` | 可选 `surface_frame` 或显示订阅 DTO;向后兼容默认 None,模型不 import Qt/OpenGL |
|
||||
| `shared/.../acquisition/finger_worker.py` | 实际采集循环的组包位置 | 仅订阅时,在 SDK 已计算完且 runtime 线程拥有数据时调用 adapter;发布带 UV 域身份的只读快照 |
|
||||
| `shared/.../acquisition/sdk_worker.py` | 平面组包位置 | 输出已经确认语义的 scalar/有效遮罩;无位移不伪造 |
|
||||
| `apps/studio/src/orisys_studio/app_window.py` | `StudioMainWindow.__init__()`、`_on_run_tick()`、`_render_run_image()`、`closeEvent()` | 只装配 panel/coordinator、切换视图与关闭;不写 GL 调用、资产解密或顶点映射 |
|
||||
| `apps/studio/src/orisys_studio/replay.py` | 打开录像、`set_position()`、关闭 | 对三维能力显式返回 unavailable;只有独立重算/保存的场数据源才发布 SurfaceFieldFrame |
|
||||
| `apps/studio/pyproject.toml` 或项目依赖入口 | optional dependencies | 新增 three-d 安装项及每平台锁定文件;Open3D 不进入默认 Studio 依赖 |
|
||||
| `tools/` 与 CI | 新 probe/export/validate 测试 | 纯 CPU 合同测试与真窗口 GL 冒烟分开 |
|
||||
|
||||
当前工作区正在改动采集滤波,工程师接入前应按函数职责定位,不依据本提案的旧行号覆盖文件。不得从 GUI 线程直接读取 worker.sensor 或修改它的图形状态。
|
||||
|
||||
## 10. 实时、回放与记录
|
||||
|
||||
实时:感知图/三维/力曲线共享 FrameIdentity;显示可降帧,记录通道保持既有语义。新 scalar 或 displacement 的计算不得为三维单独再滤一次,导致与二维不一致。
|
||||
|
||||
普通现有记录仅有视频和力时:显示静态参考模型并提示“三维场未记录”。完整重算必须通过独立数据源,携带 SDK/算法配置/几何/标定版本与恢复质量;不能自动重启已经删除的旧深度重算类。
|
||||
|
||||
未来如增加可追溯三维记录,建议另存分块 SurfaceFieldFrame 数据与 frame_id 索引,记录 quantity/unit/quality/domain_id/geometry_id,以及场是否 calibrated;不要仅保存着色后的网格或 UI 屏幕。session.json 增加可选 `visualization3d` 能力/资产版本块,旧文件缺此块视为不支持。此记录扩展不是第一版实时渲染的前置条件。
|
||||
|
||||
回放拖动时递增 request_id,CPU 合成结束后再次核对 request_id;无效/缺帧必须清理上次动态结果,不能让光标已前进但三维还冒充当前帧。
|
||||
|
||||
## 11. ARM 与安装/打包规范
|
||||
|
||||
### 11.1 依赖
|
||||
|
||||
- 继续使用项目验证过的 Python、PyQt5、NumPy;新增 PyOpenGL。先建立兼容版本范围,PoC 通过后分别锁定 Windows/Jetson/QCS6490 的版本与 wheel 哈希,不在文档虚构已验证版本。
|
||||
- PyOpenGL_accelerate 是可选优化,不作为首轮 ARM 安装前提;官方也将它列为可选项。[PyOpenGL 安装](https://pyopengl.sourceforge.net/documentation/installation.html)
|
||||
- 加密发布模式另引入成熟密码库;Blender、GLB 转换工具只在资产构建环境,客户运行时不依赖 Blender。
|
||||
- PyOpenGL 不自带 GPU 驱动。不要为了满足依赖让 pip/系统包覆盖板卡 BSP 指定的 EGL/GLES 库。
|
||||
|
||||
拟定安装入口(工程师实现 extras 后才可执行):
|
||||
|
||||
```text
|
||||
python -m pip install -e ./apps/studio[three-d]
|
||||
python tools/scene_assets/probe_gl.py --profile desktop33 --output gl-report.json
|
||||
python tools/scene_assets/probe_gl.py --profile gles30 --output gl-report.json
|
||||
```
|
||||
|
||||
两种 profile 是不同启动配置,先按实际平台选其一;不要求同一进程依次更换 Qt/PyOpenGL 平台。PyOpenGL 的 platform 选择必须在第一次 import OpenGL 之前完成,并匹配 Qt 实际使用的 GLX/EGL;不能对所有 Linux 机器一律强设 EGL。探测程序必须建立实际 Qt widget,输出取得的 context,而非只查询系统库是否存在。
|
||||
|
||||
### 11.2 待补验收矩阵
|
||||
|
||||
| 平台 | 首轮验证路径 | 必须记录 |
|
||||
|---|---|---|
|
||||
| 开发机 Windows | desktop33 | GPU/驱动、Qt/Python/PyOpenGL、实际 GL/GLSL、DPR |
|
||||
| Jetson Orin Nano / Ubuntu | BSP 支持的 desktop GL;需要时另验证 ES | 板卡内存、JetPack/BSP、桌面会话、功耗模式、驱动/renderer、Qt 实际 backend |
|
||||
| QCS6490 / Ubuntu | BSP 支持的 EGL/GLES3 路径优先验证 | 模组厂商/BSP、Ubuntu、Wayland/X11、EGL/GLES、Qt 构建、驱动能力 |
|
||||
|
||||
不根据 SoC 名称猜桌面图形支持。QCS6490 与 Jetson 最终 profile 由探测报告决定;若驱动/绑定不满足,保持二维功能并显示具体原因。不得把 llvmpipe 等软件渲染误报为目标 GPU 验收通过。
|
||||
|
||||
## 12. 性能预算与验收用例
|
||||
|
||||
建议起始目标,不是目前测量结果:动态感知面约 20k 顶点/40k 三角形,外壳约 10k–20k 三角形;实际 LOD 由曲面误差和上板性能共同决定。将 full UV 网格保留为参考质量模式。
|
||||
|
||||
建议 30 FPS 显示上限;低配可明确降为 15 FPS,采集和记录速率不能因此下降。以 20k 顶点、position/normal/scalar/valid 约 32 字节计算,单份动态数组约 0.64 MB;此估算不含多份 CPU/GPU 缓冲、输入 UV 场、纹理和驱动开销。
|
||||
|
||||
| 测试 | 通过标准 |
|
||||
|---|---|
|
||||
| 原点/轴/尺度 | 三个非共线已知点,表面与外壳对齐;法线方向、绕序、米/毫米换算正确 |
|
||||
| 资产身份 | 1005/1032 拓扑混用、UV 尺寸不同、flip_v 不同、错误哈希均被拒绝 |
|
||||
| 采样 | 已知常量/斜坡/单点脉冲/NaN/遮罩场与 CPU 参考一致;接缝无跨域插值 |
|
||||
| 几何分层 | 改 scalar/gain 只更新 sensing;housing 顶点和 GPU 静态资源不变 |
|
||||
| 测量含义 | approximate 不显示成已标定位移;增大 gain 不改变原始 scalar 和力值 |
|
||||
| 生命周期 | 隐藏/显示、反复 resize、切换布局、断连、源切换、关闭以及上下文重建无崩溃/泄漏 |
|
||||
| 过期帧 | 旧 source/generation/request_id 永远不能覆盖新场景;无数据不显示旧测量 |
|
||||
| 渲染限制 | shader 故障/GL 不足时降到二维并给诊断;无软件 renderer 冒充 GPU |
|
||||
| 记录隔离 | 同样输入下,开/关三维的 CSV 行序和有效样本相同;三维超时不阻塞记录 |
|
||||
| 性能 | 上板统计 p50/p95 合成、上传提交、绘制、总响应延迟,报告分辨率/网格/功耗配置;无无限任务增长 |
|
||||
| 长时 | 建议 2 小时采集+记录+三维后,CPU/GPU 内存不持续增长;报告最大 RSS 和队列上界 |
|
||||
| 包保护 | 改 manifest/密文/条目哈希被拒绝;无明文临时资产;客户包无签名私钥/内部源模型 |
|
||||
|
||||
CPU 合同测试在无 GUI 环境运行;GL 测试必须使用可见/实际窗口系统的上下文,offscreen UI 测试不能代替 ARM GPU 验证。
|
||||
|
||||
## 13. 实施拆分与交付物
|
||||
|
||||
1. **平台 PoC**:probe_gl、三角形、静态感知网格+外壳、resize/关闭测试;两块 ARM 至少拿到能力报告,再冻结 GLDispatch/profile。
|
||||
2. **资产合同**:旧 NPZ 导入、统一坐标、roles、manifest、绑定验证和一套简化外壳;不改 SDK 算法网格。
|
||||
3. **实时数据**:FrameAdapter、SurfaceSampler、SceneComposer、最新帧调度;先 scalar 着色,再加明确标记的示意位移。
|
||||
4. **Qt 产品接入**:Sensor3DWidget、相机/色标/倍率/显隐、异常与生命周期、现有记录隔离测试。
|
||||
5. **发布保护与性能**:精简/签名包、可选加密与 KeyProvider、平台锁定、性能/长时/故障测试。
|
||||
|
||||
每项交付包括:实现、公共接口单测、输入样例、失败样例、兼容说明、平台诊断报告。先评审本文的坐标/场语义/资产合同,再开始组件并行开发;不要等画面完成后才决定数组每一维和单位的含义。
|
||||
+8
-2
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
||||
[project]
|
||||
name = "orisys-app"
|
||||
version = "0.1.0"
|
||||
description = "Orisys desktop applications (customer + future internal)"
|
||||
description = "Orisys desktop applications (studio + future internal)"
|
||||
requires-python = ">=3.10"
|
||||
readme = "README.md"
|
||||
authors = [{ name = "Orisys-Tech" }]
|
||||
@@ -13,8 +13,14 @@ authors = [{ name = "Orisys-Tech" }]
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"orisys-app-shared @ file:./shared",
|
||||
"orisys-customer @ file:./apps/customer",
|
||||
"orisys-studio @ file:./apps/studio",
|
||||
"pytest>=7.4",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
packages = []
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["shared/tests", "apps/studio/tests"]
|
||||
pythonpath = ["shared/src", "apps/studio/src"]
|
||||
addopts = "-q"
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
"""Acquisition helpers."""
|
||||
|
||||
from orisys_app_shared.acquisition.constants import (
|
||||
FORCE_SAMPLE_RATE_HZ,
|
||||
LOWPASS_CUTOFF_HZ,
|
||||
)
|
||||
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
|
||||
|
||||
__all__ = [
|
||||
"FORCE_SAMPLE_RATE_HZ",
|
||||
"LOWPASS_CUTOFF_HZ",
|
||||
"FingerAcquisitionWorker",
|
||||
"SdkAcquisitionWorker",
|
||||
"SdkPacket",
|
||||
"depth_map_to_bgr",
|
||||
"draw_arrow_display",
|
||||
"flow_colormap",
|
||||
"hex_to_bgr",
|
||||
"load_fixed_mode",
|
||||
"orisys_importable",
|
||||
@@ -28,6 +25,7 @@ _LAZY_ATTRS = {
|
||||
"orisys_importable": ("orisys_app_shared.acquisition.sdk_worker", "orisys_importable"),
|
||||
"depth_map_to_bgr": ("orisys_app_shared.acquisition.arrows", "depth_map_to_bgr"),
|
||||
"draw_arrow_display": ("orisys_app_shared.acquisition.arrows", "draw_arrow_display"),
|
||||
"flow_colormap": ("orisys_app_shared.acquisition.arrows", "flow_colormap"),
|
||||
"hex_to_bgr": ("orisys_app_shared.acquisition.arrows", "hex_to_bgr"),
|
||||
}
|
||||
|
||||
|
||||
@@ -2,24 +2,29 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Optional, Sequence
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from orisys_app_shared.theme import color
|
||||
|
||||
ARROW_BG_BGR = (0x16, 0x13, 0x12)
|
||||
_ARROW_DRAW_KW = dict(
|
||||
threshold=2,
|
||||
grid_spacing=20,
|
||||
arrow_scale=0.5,
|
||||
below_threshold_color=(160, 160, 160),
|
||||
)
|
||||
_FINGER_ARROW_DRAW_KW = dict(
|
||||
threshold=0.4,
|
||||
grid_spacing=10,
|
||||
arrow_scale=1.0,
|
||||
below_threshold_color=(160, 160, 160),
|
||||
)
|
||||
"""Fallback canvas colour (dark theme ``arrow_canvas``)."""
|
||||
|
||||
FLOW_RAMP_FALLBACK = "#12315D,#6E4AD0,#FA02F3,#FF8A6B,#D8FF00"
|
||||
"""Design-spec flow ramp: brand navy, violet, magenta, coral, lime."""
|
||||
|
||||
FLOW_IDLE_HEX = "#8A93A6"
|
||||
"""Brand-neutral tint for the grid dots below the flow threshold."""
|
||||
|
||||
#: BGR grey used for depth samples that carry no value.
|
||||
NO_DEPTH_BGR = (0x8A, 0x8A, 0x8A)
|
||||
|
||||
_LUT_STEPS = 256
|
||||
_RAMP_MIN_STOPS = 2
|
||||
_RAMP_MAX_STOPS = 8
|
||||
|
||||
|
||||
def hex_to_bgr(hex_color: str, fallback: tuple[int, int, int] = ARROW_BG_BGR) -> tuple[int, int, int]:
|
||||
@@ -35,6 +40,81 @@ def hex_to_bgr(hex_color: str, fallback: tuple[int, int, int] = ARROW_BG_BGR) ->
|
||||
return (b, g, r)
|
||||
|
||||
|
||||
_ARROW_DRAW_KW = dict(
|
||||
threshold=2,
|
||||
grid_spacing=20,
|
||||
arrow_scale=0.5,
|
||||
below_threshold_color=hex_to_bgr(FLOW_IDLE_HEX),
|
||||
)
|
||||
_FINGER_ARROW_DRAW_KW = dict(
|
||||
threshold=0.4,
|
||||
grid_spacing=10,
|
||||
arrow_scale=1.0,
|
||||
below_threshold_color=hex_to_bgr(FLOW_IDLE_HEX),
|
||||
)
|
||||
|
||||
|
||||
def _parse_ramp(ramp: str) -> list[tuple[int, int, int]]:
|
||||
stops: list[tuple[int, int, int]] = []
|
||||
for token in str(ramp or "").split(","):
|
||||
text = token.strip().lstrip("#")
|
||||
if len(text) != 6:
|
||||
continue
|
||||
try:
|
||||
stops.append((int(text[0:2], 16), int(text[2:4], 16), int(text[4:6], 16)))
|
||||
except ValueError:
|
||||
continue
|
||||
return stops
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _lut_from_ramp(ramp: str) -> np.ndarray:
|
||||
"""Build a 256-entry user colour table from ``#RRGGBB`` ramp stops.
|
||||
|
||||
The table is BGR, matching how :func:`cv2.applyColorMap` consumes user
|
||||
colormaps. Invalid input never raises: it falls back to
|
||||
:data:`FLOW_RAMP_FALLBACK`.
|
||||
"""
|
||||
stops = _parse_ramp(ramp)
|
||||
if not _RAMP_MIN_STOPS <= len(stops) <= _RAMP_MAX_STOPS:
|
||||
stops = _parse_ramp(FLOW_RAMP_FALLBACK)
|
||||
positions = np.linspace(0.0, 1.0, _LUT_STEPS)
|
||||
span = np.linspace(0.0, 1.0, len(stops))
|
||||
lut = np.empty((_LUT_STEPS, 1, 3), dtype=np.uint8)
|
||||
for channel in range(3): # source order: R, G, B
|
||||
values = np.interp(positions, span, [stop[channel] for stop in stops])
|
||||
lut[:, 0, channel] = np.clip(np.round(values), 0, 255).astype(np.uint8)
|
||||
return lut[:, :, ::-1].copy()
|
||||
|
||||
|
||||
def flow_colormap(theme: str = "dark") -> np.ndarray:
|
||||
"""Return the branded flow-magnitude colour table for ``theme``.
|
||||
|
||||
Cached on the resolved ramp string rather than the theme name, so a theme
|
||||
switch is picked up as soon as :func:`build_qss` reloads the tokens.
|
||||
"""
|
||||
return _lut_from_ramp(color(theme, "flow_ramp", FLOW_RAMP_FALLBACK))
|
||||
|
||||
|
||||
def arrow_display_scale(draw_style: str = "box") -> float:
|
||||
"""How much the drawn arrows amplify the measured displacement.
|
||||
|
||||
``orisys.util.draw_arrows`` divides the displacement by ``arrow_scale``, so
|
||||
the effective display magnification is its reciprocal (box ``0.5`` -> ``2x``,
|
||||
finger ``1.0`` -> ``1x``). The drawn length is additionally capped at
|
||||
``grid_spacing * 0.8`` pixels, so the returned factor describes the mapping
|
||||
only while an arrow has not hit that cap — the UI says so in its tooltip.
|
||||
"""
|
||||
draw_kw = _FINGER_ARROW_DRAW_KW if str(draw_style).lower().startswith("f") else _ARROW_DRAW_KW
|
||||
try:
|
||||
scale = float(draw_kw["arrow_scale"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return 1.0
|
||||
if scale <= 0:
|
||||
return 1.0
|
||||
return 1.0 / scale
|
||||
|
||||
|
||||
def _ensure_bgr(img: np.ndarray) -> np.ndarray:
|
||||
if img.ndim == 2:
|
||||
return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
|
||||
@@ -53,19 +133,22 @@ def draw_arrow_display(
|
||||
canvas_bgr: Sequence[int] | None = None,
|
||||
draw_style: str = "box",
|
||||
valid_mask: Optional[np.ndarray] = None,
|
||||
colormap: Optional[np.ndarray] = None,
|
||||
) -> np.ndarray:
|
||||
"""Draw flow arrows on a solid canvas or over the processed frame."""
|
||||
base = _ensure_bgr(processed_frame)
|
||||
if flow is None:
|
||||
return base
|
||||
draw_kw = _FINGER_ARROW_DRAW_KW if str(draw_style).lower().startswith("f") else _ARROW_DRAW_KW
|
||||
draw_kw = dict(_FINGER_ARROW_DRAW_KW if str(draw_style).lower().startswith("f") else _ARROW_DRAW_KW)
|
||||
if colormap is not None:
|
||||
draw_kw["colormap"] = colormap
|
||||
try:
|
||||
import orisys
|
||||
|
||||
if background == "pure_color":
|
||||
canvas = np.empty_like(base)
|
||||
color = ARROW_BG_BGR if canvas_bgr is None else tuple(int(v) for v in canvas_bgr)
|
||||
canvas[:, :] = color
|
||||
color_bgr = ARROW_BG_BGR if canvas_bgr is None else tuple(int(v) for v in canvas_bgr)
|
||||
canvas[:, :] = color_bgr
|
||||
return orisys.util.draw_arrows(
|
||||
canvas, flow, valid_mask=valid_mask, **draw_kw
|
||||
)
|
||||
@@ -74,8 +157,23 @@ def draw_arrow_display(
|
||||
return base
|
||||
|
||||
|
||||
def depth_map_to_bgr(depth: Optional[np.ndarray]) -> Optional[np.ndarray]:
|
||||
"""Convert a float depth/divergence map to a JET colormap BGR image."""
|
||||
def depth_map_to_bgr(
|
||||
depth: Optional[np.ndarray],
|
||||
colormap: Optional[np.ndarray] = None,
|
||||
*,
|
||||
value_range: Optional[tuple[float, float]] = None,
|
||||
invalid_mask: Optional[np.ndarray] = None,
|
||||
) -> Optional[np.ndarray]:
|
||||
"""Convert a float depth/divergence map to a colormap BGR image.
|
||||
|
||||
``value_range`` pins the colour scale. Per-frame re-normalisation (the old
|
||||
behaviour) makes the colours drift from frame to frame, so two frames can
|
||||
look identical while representing different depths — pass the same range
|
||||
across a session when frames are meant to be comparable.
|
||||
|
||||
``invalid_mask`` marks samples that carry no depth. They are painted with a
|
||||
neutral grey so "no data" can never be mistaken for "zero depth".
|
||||
"""
|
||||
if depth is None:
|
||||
return None
|
||||
arr = np.asarray(depth, dtype=np.float64)
|
||||
@@ -84,10 +182,28 @@ def depth_map_to_bgr(depth: Optional[np.ndarray]) -> Optional[np.ndarray]:
|
||||
finite = np.isfinite(arr)
|
||||
if not np.any(finite):
|
||||
return None
|
||||
if value_range is not None and value_range[1] > value_range[0]:
|
||||
lo, hi = float(value_range[0]), float(value_range[1])
|
||||
else:
|
||||
lo = float(np.nanmin(arr[finite]))
|
||||
hi = float(np.nanmax(arr[finite]))
|
||||
if hi > lo:
|
||||
normalized = ((arr - lo) / (hi - lo) * 255.0).astype(np.uint8)
|
||||
scaled = (arr - lo) / (hi - lo)
|
||||
else:
|
||||
normalized = np.zeros(arr.shape, dtype=np.uint8)
|
||||
return cv2.applyColorMap(normalized, cv2.COLORMAP_JET)
|
||||
scaled = np.zeros(arr.shape, dtype=np.float64)
|
||||
scaled = np.clip(scaled, 0.0, 1.0)
|
||||
normalized = (scaled * 255.0).astype(np.uint8)
|
||||
image = cv2.applyColorMap(
|
||||
normalized, cv2.COLORMAP_JET if colormap is None else colormap
|
||||
)
|
||||
missing = ~finite
|
||||
if invalid_mask is not None:
|
||||
mask = np.asarray(invalid_mask, dtype=bool)
|
||||
if mask.shape[:2] == image.shape[:2]:
|
||||
missing = missing | ~mask
|
||||
if np.any(missing):
|
||||
#: Neutral grey — deliberately outside the JET ramp so it reads as
|
||||
#: "undefined" rather than as a depth value.
|
||||
image[missing] = NO_DEPTH_BGR
|
||||
return image
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""Shared acquisition constants (no heavy imports)."""
|
||||
|
||||
LOWPASS_CUTOFF_HZ = 10.0
|
||||
#: The rate the SDK designs its force low-pass coefficients for until the device
|
||||
#: measures its own. Also the assumption the plots and a recording's time base
|
||||
#: are built on, so it stays a constant rather than a per-run guess.
|
||||
FORCE_SAMPLE_RATE_HZ = 30.0
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -9,21 +10,34 @@ from typing import Callable, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from orisys_app_shared.acquisition.constants import (
|
||||
FORCE_SAMPLE_RATE_HZ,
|
||||
LOWPASS_CUTOFF_HZ,
|
||||
from orisys_app_shared.acquisition.sinks import RecordSinkMixin
|
||||
# The packet and the geometry come from ``models``, so the finger worker does not
|
||||
# depend on the box worker; only genuinely shared helpers are imported from it.
|
||||
from orisys_app_shared.acquisition.models import (
|
||||
FixedCameraMode,
|
||||
SdkPacket,
|
||||
ensure_bgr as _ensure_bgr,
|
||||
is_file_source as _is_file_source,
|
||||
load_fixed_mode,
|
||||
orisys_importable,
|
||||
)
|
||||
from orisys_app_shared.acquisition.sdk_worker import (
|
||||
CAMERA_DISCONNECT_TIMEOUT_S,
|
||||
FPS_WINDOW_S,
|
||||
HANDOVER_TIMEOUT_S,
|
||||
STOP_JOIN_TIMEOUT_S,
|
||||
VIDEO_EOF_TIMEOUT_S,
|
||||
FixedCameraMode,
|
||||
SdkPacket,
|
||||
_ensure_bgr,
|
||||
_is_file_source,
|
||||
load_fixed_mode,
|
||||
orisys_importable,
|
||||
_capture_sequence,
|
||||
calibration_digest,
|
||||
capture_status_of,
|
||||
loaded_calibration_version,
|
||||
loaded_path,
|
||||
process_chain,
|
||||
read_forces_from_sensor,
|
||||
)
|
||||
from orisys_app_shared.force.filters import ALL_KEYS, FilterSettings, effective_filters
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_EXPORT_REQUIRED = ("mesh.npz", "camera_intrinsics.npz", "world_to_cam.npy")
|
||||
|
||||
@@ -51,7 +65,7 @@ def resolve_finger_export_dir(config_ref: str | Path) -> Path | None:
|
||||
return candidate.resolve()
|
||||
|
||||
|
||||
class FingerAcquisitionWorker:
|
||||
class FingerAcquisitionWorker(RecordSinkMixin):
|
||||
"""Background loop: FingerRuntime grab/unroll + deformation + force readout."""
|
||||
|
||||
def __init__(
|
||||
@@ -59,20 +73,76 @@ class FingerAcquisitionWorker:
|
||||
*,
|
||||
on_error: Optional[Callable[[str], None]] = None,
|
||||
on_status: Optional[Callable[[str], None]] = None,
|
||||
on_calibration_applied: Optional[Callable[[str, str, int, float, int], None]] = None,
|
||||
on_reset_applied: Optional[Callable[[int, float, int], None]] = None,
|
||||
):
|
||||
self._init_sinks()
|
||||
self._on_error = on_error
|
||||
self._on_status = on_status
|
||||
#: Called from the acquisition thread once new coefficients are loaded,
|
||||
#: with ``(path, version, frame_id, mono_t, session_id)``.
|
||||
self._on_calibration_applied = on_calibration_applied
|
||||
#: Called from the acquisition thread once a queued zeroing took effect.
|
||||
self._on_reset_applied = on_reset_applied
|
||||
self._thread: Optional[threading.Thread] = None
|
||||
self._stop = threading.Event()
|
||||
self._lock = threading.Lock()
|
||||
self._latest: Optional[SdkPacket] = None
|
||||
self._session_id = 0
|
||||
self._runtime = None
|
||||
# Keyed by session so a session that is still winding down can only
|
||||
# ever close its own runtime, never the runtime of a newer session.
|
||||
self._runtimes: dict[int, object] = {}
|
||||
self._mode: Optional[FixedCameraMode] = None
|
||||
self._video_source: Optional[str | int] = None
|
||||
self._export_dir: Optional[Path] = None
|
||||
self._end_reason: Optional[str] = None
|
||||
self._phase: str = "idle"
|
||||
self._pending_calibration: Optional[str] = None
|
||||
#: A zeroing requested from another thread, applied by this one.
|
||||
self._pending_reset: bool = False
|
||||
self._mono_t0: float = 0.0
|
||||
self._calibration_version: str = ""
|
||||
#: What the filters should do; set before ``start()`` (see the box worker
|
||||
#: for the reasoning: one session, one way of filtering).
|
||||
self._filter_settings: FilterSettings = FilterSettings()
|
||||
#: ``filters_state()`` of the running session: the effective values.
|
||||
self._filter_state: dict = {}
|
||||
#: Keys the installed SDK could not apply.
|
||||
self._filters_unsupported: tuple[str, ...] = ()
|
||||
#: The capture rate this session measured for itself (the driver's
|
||||
#: reported rate is an echo of the request and cannot be trusted).
|
||||
self._measured_capture_fps: float = 0.0
|
||||
|
||||
def set_filter_settings(self, settings: Optional[FilterSettings]) -> None:
|
||||
"""Choose the filters for the **next** session."""
|
||||
with self._lock:
|
||||
self._filter_settings = (settings or FilterSettings()).normalised()
|
||||
|
||||
def filter_settings(self) -> FilterSettings:
|
||||
with self._lock:
|
||||
return self._filter_settings
|
||||
|
||||
def filter_state(self) -> dict:
|
||||
"""What the running device reports as in effect (``{}`` if it cannot say)."""
|
||||
with self._lock:
|
||||
return dict(self._filter_state)
|
||||
|
||||
def filters_unsupported(self) -> tuple[str, ...]:
|
||||
"""Settings the installed SDK could not apply, reported not swallowed."""
|
||||
with self._lock:
|
||||
return tuple(self._filters_unsupported)
|
||||
|
||||
def capture_status(self) -> dict:
|
||||
"""What the camera was asked for, what it gave, and what we measured."""
|
||||
with self._lock:
|
||||
runtime = next(iter(self._runtimes.values()), None)
|
||||
measured = float(self._measured_capture_fps or 0.0)
|
||||
tactile = getattr(runtime, "tactile", None) if runtime is not None else None
|
||||
raw_sensor = getattr(tactile, "raw_sensor", None) if tactile is not None else None
|
||||
status = capture_status_of(raw_sensor)
|
||||
if status and measured > 0.0:
|
||||
status["measured_fps"] = measured
|
||||
return status
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
@@ -99,7 +169,10 @@ class FingerAcquisitionWorker:
|
||||
*,
|
||||
config_ref: str | Path,
|
||||
) -> FixedCameraMode:
|
||||
# Non-blocking: a previous session that is stuck opening must never hold
|
||||
# the caller (the GUI thread) hostage.
|
||||
self.stop()
|
||||
previous = self._thread
|
||||
ok, detail = orisys_importable()
|
||||
if not ok:
|
||||
raise RuntimeError(f"orisys SDK is not importable: {detail}")
|
||||
@@ -111,36 +184,116 @@ class FingerAcquisitionWorker:
|
||||
self._latest = None
|
||||
self._end_reason = None
|
||||
self._phase = "connecting"
|
||||
# This session starts with no calibration of its own: an uncalibrated
|
||||
# runtime must not inherit the previous session's version, and a stale
|
||||
# zeroing request must not fire into the new session's frames.
|
||||
self._calibration_version = ""
|
||||
self._pending_reset = False
|
||||
# Nor any filter state: until this session's sensor has been
|
||||
# configured and asked, there is nothing to report about it.
|
||||
self._filter_state = {}
|
||||
self._filters_unsupported = ()
|
||||
self._session_id += 1
|
||||
session = self._session_id
|
||||
self._stop.clear()
|
||||
# Fresh monotonic origin for this session.
|
||||
self._mono_t0 = time.perf_counter()
|
||||
self._thread = threading.Thread(
|
||||
target=self._run,
|
||||
name=f"FingerAcquisition-{session}",
|
||||
daemon=True,
|
||||
args=(session,),
|
||||
args=(session, previous),
|
||||
)
|
||||
self._thread.start()
|
||||
return mode
|
||||
|
||||
def stop(self) -> None:
|
||||
def stop(self, *, wait: bool = False) -> None:
|
||||
"""Ask the acquisition thread to stop.
|
||||
|
||||
The default is deliberately **non-blocking**: an open that never returns
|
||||
must not freeze the caller, and the worker thread owns its own teardown.
|
||||
Use ``wait=True`` only on shutdown paths that must not leave the runtime
|
||||
behind.
|
||||
"""
|
||||
self._set_end_reason("stopped")
|
||||
self._stop.set()
|
||||
thread = self._thread
|
||||
if thread is not None and thread.is_alive():
|
||||
thread.join(timeout=8.0)
|
||||
if wait and thread is not None and thread.is_alive():
|
||||
thread.join(timeout=STOP_JOIN_TIMEOUT_S)
|
||||
if thread is None or not thread.is_alive():
|
||||
self._settle(owner=thread)
|
||||
# Otherwise the thread settles itself on the way out.
|
||||
|
||||
def _settle(self, *, owner: Optional[threading.Thread]) -> None:
|
||||
"""Release worker state once no thread owns the runtime any more."""
|
||||
if owner is not None and self._thread is not owner:
|
||||
return # a newer session owns the worker now
|
||||
self._thread = None
|
||||
self._close_runtime()
|
||||
self._close_all_runtimes()
|
||||
with self._lock:
|
||||
self._latest = None
|
||||
self._phase = "idle"
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Request a tracker reset, applied by the acquisition thread.
|
||||
|
||||
The runtime's tactile reset from the GUI thread would race the frame the
|
||||
loop is processing, so the zeroing happens at a frame boundary on the
|
||||
acquisition thread, which reports the frame and time it took effect.
|
||||
"""
|
||||
with self._lock:
|
||||
runtime = self._runtime
|
||||
if runtime is None:
|
||||
if self._session_id <= 0:
|
||||
raise RuntimeError("Finger runtime is not running")
|
||||
runtime.tactile.reset()
|
||||
self._pending_reset = True
|
||||
|
||||
def _consume_pending_reset(self, runtime, frame_id: int, mono_t: float) -> None:
|
||||
"""Apply a queued reset and report the frame it actually took effect at."""
|
||||
with self._lock:
|
||||
pending = self._pending_reset
|
||||
self._pending_reset = False
|
||||
session = self._session_id
|
||||
if not pending or runtime is None:
|
||||
return
|
||||
tactile = getattr(runtime, "tactile", None)
|
||||
target = tactile if callable(getattr(tactile, "reset", None)) else None
|
||||
if target is None:
|
||||
sensor = getattr(tactile, "raw_sensor", None)
|
||||
target = sensor if callable(getattr(sensor, "reset", None)) else None
|
||||
if target is None:
|
||||
raise RuntimeError("runtime does not support reset")
|
||||
target.reset()
|
||||
if self._on_reset_applied is not None:
|
||||
self._on_reset_applied(int(frame_id), float(mono_t), int(session))
|
||||
|
||||
def set_pending_calibration(self, path: str | Path | None) -> None:
|
||||
"""Queue a force_calibration.json path for the acquisition thread."""
|
||||
with self._lock:
|
||||
self._pending_calibration = str(path) if path is not None else None
|
||||
|
||||
def _consume_pending_calibration(
|
||||
self, sensor, frame_id: int = 0, mono_t: float = 0.0
|
||||
) -> None:
|
||||
"""Apply queued coefficients and report confirmation, not intent.
|
||||
|
||||
A rejected document leaves the version alone on purpose: the sensor still
|
||||
has the coefficients it had. What never happens is inventing a version for
|
||||
a device that has no calibration — that is the empty string.
|
||||
"""
|
||||
with self._lock:
|
||||
path = self._pending_calibration
|
||||
self._pending_calibration = None
|
||||
session = self._session_id
|
||||
if path is None or sensor is None:
|
||||
return
|
||||
if not hasattr(sensor, "set_calibration"):
|
||||
raise RuntimeError("sensor does not support set_calibration")
|
||||
sensor.set_calibration(path)
|
||||
version = calibration_digest(path)
|
||||
self._calibration_version = version
|
||||
if self._on_calibration_applied is not None:
|
||||
self._on_calibration_applied(
|
||||
str(path), version, int(frame_id), float(mono_t), int(session)
|
||||
)
|
||||
|
||||
def _set_end_reason(self, reason: str) -> None:
|
||||
with self._lock:
|
||||
@@ -151,9 +304,10 @@ class FingerAcquisitionWorker:
|
||||
with self._lock:
|
||||
self._phase = phase
|
||||
|
||||
def _close_runtime(self) -> None:
|
||||
runtime = self._runtime
|
||||
self._runtime = None
|
||||
def _close_runtime(self, session: int) -> None:
|
||||
"""Close the runtime opened by ``session`` (no-op if it owns none)."""
|
||||
with self._lock:
|
||||
runtime = self._runtimes.pop(session, None)
|
||||
if runtime is None:
|
||||
return
|
||||
try:
|
||||
@@ -161,13 +315,38 @@ class FingerAcquisitionWorker:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _close_all_runtimes(self) -> None:
|
||||
with self._lock:
|
||||
sessions = list(self._runtimes)
|
||||
for session in sessions:
|
||||
self._close_runtime(session)
|
||||
|
||||
def _status(self, message: str) -> None:
|
||||
if self._on_status is not None:
|
||||
self._on_status(message)
|
||||
|
||||
def _run(self, session: int) -> None:
|
||||
def _run(self, session: int, previous: Optional[threading.Thread] = None) -> None:
|
||||
try:
|
||||
self._run_session(session, previous)
|
||||
finally:
|
||||
# The finishing thread owns the teardown — this is what lets
|
||||
# ``stop()`` return immediately without leaking the runtime.
|
||||
self._close_runtime(session)
|
||||
if self._thread is threading.current_thread():
|
||||
self._thread = None
|
||||
with self._lock:
|
||||
self._latest = None
|
||||
self._phase = "idle"
|
||||
|
||||
def _run_session(self, session: int, previous: Optional[threading.Thread]) -> None:
|
||||
from orisys.finger import FingerRuntime
|
||||
|
||||
if previous is not None and previous.is_alive():
|
||||
# Wait here rather than on the GUI thread, so the runtime is never
|
||||
# opened twice at the same time.
|
||||
self._status("Waiting for the previous session to finish…")
|
||||
previous.join(timeout=HANDOVER_TIMEOUT_S)
|
||||
|
||||
mode = self._mode
|
||||
source = self._video_source
|
||||
if mode is None or source is None:
|
||||
@@ -182,24 +361,42 @@ class FingerAcquisitionWorker:
|
||||
}
|
||||
if self._export_dir is not None:
|
||||
open_kw["export_dir"] = str(self._export_dir)
|
||||
self._runtime = FingerRuntime.open(**open_kw)
|
||||
runtime = FingerRuntime.open(**open_kw)
|
||||
with self._lock:
|
||||
self._runtimes[session] = runtime
|
||||
if self._stop.is_set() or session != self._session_id:
|
||||
self._close_runtime()
|
||||
self._close_runtime(session)
|
||||
return
|
||||
self._runtime.warmup(color=False)
|
||||
runtime.warmup(color=False)
|
||||
if self._stop.is_set() or session != self._session_id:
|
||||
self._close_runtime()
|
||||
self._close_runtime(session)
|
||||
return
|
||||
raw_sensor = getattr(self._runtime.tactile, "raw_sensor", None)
|
||||
raw_sensor = getattr(runtime.tactile, "raw_sensor", None)
|
||||
if raw_sensor is not None and hasattr(raw_sensor, "configure"):
|
||||
# The filter chain lives in the SDK; the app says what the
|
||||
# operator asked for and reads back what the device is doing.
|
||||
with self._lock:
|
||||
settings = self._filter_settings
|
||||
try:
|
||||
raw_sensor.configure(
|
||||
lowpass_cutoff_hz=LOWPASS_CUTOFF_HZ,
|
||||
lowpass_sample_rate_hz=FORCE_SAMPLE_RATE_HZ,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
unsupported = settings.apply_to(raw_sensor)
|
||||
except Exception: # noqa: BLE001 - a finger runtime may refuse
|
||||
logger.exception("Could not apply filter settings to the finger sensor")
|
||||
unsupported = ALL_KEYS
|
||||
with self._lock:
|
||||
self._filter_state = effective_filters(raw_sensor)
|
||||
self._filters_unsupported = tuple(unsupported)
|
||||
self._status(f"Streaming finger {mode.label()}")
|
||||
# The tactile sensor loads its own bundle calibration while opening;
|
||||
# name those coefficients from the first frame, not the request path.
|
||||
tactile = getattr(runtime, "tactile", None)
|
||||
loaded = loaded_calibration_version(tactile) if tactile else ""
|
||||
# Assigned even when empty: an uncalibrated device reports *no*
|
||||
# calibration, never the previous session's version.
|
||||
self._calibration_version = loaded
|
||||
if loaded and self._on_calibration_applied is not None:
|
||||
self._on_calibration_applied(
|
||||
str(loaded_path(tactile) or ""), loaded, 0, 0.0, int(session)
|
||||
)
|
||||
except Exception as exc:
|
||||
self._set_end_reason("error")
|
||||
if self._on_error is not None and session == self._session_id:
|
||||
@@ -208,10 +405,18 @@ class FingerAcquisitionWorker:
|
||||
return
|
||||
|
||||
frame_times: list[float] = []
|
||||
#: (collection time, published sequence), so the camera's own rate
|
||||
#: can be told apart from the rate this loop consumes (see the box
|
||||
#: worker: the two are the same measurement and must mean the same).
|
||||
source_times: list[tuple[float, int]] = []
|
||||
last_source_seq = None
|
||||
capture_fps = 0.0
|
||||
saw_frame = False
|
||||
last_frame_mono = 0.0
|
||||
none_streak_start: Optional[float] = None
|
||||
runtime = self._runtime
|
||||
# Per-session monotonic frame index: the join key shared by the video,
|
||||
# the CSV and the calibration records.
|
||||
frame_id = 0
|
||||
|
||||
while not self._stop.is_set() and session == self._session_id and runtime is not None:
|
||||
try:
|
||||
@@ -245,24 +450,66 @@ class FingerAcquisitionWorker:
|
||||
|
||||
none_streak_start = None
|
||||
tactile = runtime.tactile
|
||||
compute_error = ""
|
||||
# The frame's acquisition time, so an event that happens on this
|
||||
# frame is dated when it happened rather than when it was queued.
|
||||
frame_mono = max(0.0, time.perf_counter() - self._mono_t0)
|
||||
try:
|
||||
tactile.compute_deformation(decompose=True, check_motion=False)
|
||||
tactile.compute_contact()
|
||||
except Exception:
|
||||
pass
|
||||
self._consume_pending_calibration(tactile, frame_id, frame_mono)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
message = f"calibration rejected: {type(exc).__name__}: {exc}"
|
||||
if self._on_error is not None and session == self._session_id:
|
||||
self._on_error(message)
|
||||
compute_error = message
|
||||
try:
|
||||
# Applied between two frames on this thread, then reported.
|
||||
self._consume_pending_reset(runtime, frame_id, frame_mono)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
message = f"reset failed: {type(exc).__name__}: {exc}"
|
||||
if self._on_error is not None and session == self._session_id:
|
||||
self._on_error(message)
|
||||
# The same chain the offline depth replay runs on the tactile.
|
||||
chain_depth, chain_error = process_chain(tactile)
|
||||
compute_error = compute_error or chain_error
|
||||
t1 = time.perf_counter()
|
||||
frame_times.append(t1)
|
||||
cutoff = t1 - 1.0
|
||||
frame_times.append(t0)
|
||||
cutoff = t1 - FPS_WINDOW_S
|
||||
frame_times = [t for t in frame_times if t >= cutoff]
|
||||
fps = float(len(frame_times)) if frame_times else 0.0
|
||||
# A *rate*, like the box view: counting frames in a one-second
|
||||
# window reads one frame high, and the two views have to agree
|
||||
# about what "fps" means.
|
||||
span = frame_times[-1] - frame_times[0] if len(frame_times) > 1 else 0.0
|
||||
fps = (len(frame_times) - 1) / span if span > 0.0 else 0.0
|
||||
# The camera's own rate, so this view can attribute a low rate too.
|
||||
delivered = _capture_sequence(tactile)
|
||||
if delivered is not None and delivered != last_source_seq:
|
||||
last_source_seq = delivered
|
||||
source_times.append((t0, delivered))
|
||||
source_cutoff = t0 - FPS_WINDOW_S
|
||||
source_times = [
|
||||
item for item in source_times if item[0] >= source_cutoff
|
||||
]
|
||||
if len(source_times) > 1:
|
||||
source_span = source_times[-1][0] - source_times[0][0]
|
||||
published = source_times[-1][1] - source_times[0][1]
|
||||
if source_span > 0.0:
|
||||
capture_fps = max(0.0, published / source_span)
|
||||
with self._lock:
|
||||
self._measured_capture_fps = capture_fps
|
||||
forces = read_forces_from_sensor(
|
||||
tactile,
|
||||
config_ref=mode.config_ref,
|
||||
)
|
||||
invalid_reason = forces.get("invalid_reason") or compute_error
|
||||
raw_frame = _ensure_bgr(source_frame)
|
||||
# The frame the tactile chain consumed, kept for depth replay.
|
||||
self.note_preroll(raw_frame)
|
||||
packet = SdkPacket(
|
||||
raw_frame=_ensure_bgr(source_frame),
|
||||
raw_frame=raw_frame,
|
||||
processed_frame=_ensure_bgr(unrolled),
|
||||
timestamp=time.time(),
|
||||
wall_t=time.time(),
|
||||
mono_t=max(0.0, t1 - self._mono_t0),
|
||||
fps=fps,
|
||||
process_ms=(t1 - t0) * 1000.0,
|
||||
normal_force=forces["normal_force"],
|
||||
@@ -273,14 +520,30 @@ class FingerAcquisitionWorker:
|
||||
shear_y_raw=forces["shear_y_raw"],
|
||||
force_unit=forces["force_unit"],
|
||||
force_calibrated=forces["force_calibrated"],
|
||||
calibrated_axes=tuple(forces.get("calibrated_axes", ())),
|
||||
calibration_version=self._calibration_version,
|
||||
session_id=session,
|
||||
frame_id=frame_id,
|
||||
valid=bool(forces.get("valid", True)) and not compute_error,
|
||||
invalid_reason=invalid_reason,
|
||||
# A failed chain leaves the previous frame's force fields in
|
||||
# place; the app must not record them as this frame's data.
|
||||
stale_forces=bool(compute_error),
|
||||
raw_available=bool(forces.get("raw_available", True)),
|
||||
flow=forces["flow"],
|
||||
depth=forces.get("depth"),
|
||||
depth=(
|
||||
forces.get("depth")
|
||||
if forces.get("depth") is not None
|
||||
else chain_depth
|
||||
),
|
||||
valid_mask=runtime.valid_mask,
|
||||
)
|
||||
with self._lock:
|
||||
self._latest = packet
|
||||
self._phase = "running"
|
||||
# Record channel: every packet, regardless of UI refresh rate.
|
||||
self._emit(packet)
|
||||
frame_id += 1
|
||||
saw_frame = True
|
||||
last_frame_mono = t1
|
||||
except Exception as exc:
|
||||
@@ -289,7 +552,7 @@ class FingerAcquisitionWorker:
|
||||
self._on_error(str(exc))
|
||||
break
|
||||
|
||||
self._close_runtime()
|
||||
self._close_runtime(session)
|
||||
self._set_phase("idle")
|
||||
if self._stop.is_set():
|
||||
self._set_end_reason("stopped")
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
"""The data shapes shared by the acquisition workers, the record channel and the UI.
|
||||
|
||||
They live here rather than in one of the workers so that neither worker has to
|
||||
import the other, and so the record channel can describe a packet without
|
||||
importing a device loop. The workers re-export these names for compatibility.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FixedCameraMode:
|
||||
"""The camera geometry a session runs with."""
|
||||
|
||||
width: int
|
||||
height: int
|
||||
fps: float
|
||||
config_ref: str
|
||||
|
||||
def label(self) -> str:
|
||||
return f"{self.width}x{self.height} @{self.fps:g}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SdkPacket:
|
||||
"""One acquired frame: the images, the force readout and its provenance."""
|
||||
|
||||
raw_frame: np.ndarray
|
||||
processed_frame: np.ndarray
|
||||
timestamp: float
|
||||
fps: float
|
||||
process_ms: float
|
||||
#: The rate the *capture source* published frames at, over the same window as
|
||||
#: ``fps``. ``fps`` is what this loop consumed; when the two disagree, the
|
||||
#: difference is frames the camera had and the loop did not take.
|
||||
capture_fps: float = 0.0
|
||||
#: How many published frames were replaced before anyone consumed them.
|
||||
capture_dropped: int = 0
|
||||
normal_force: float = 0.0
|
||||
shear_x: float = 0.0
|
||||
shear_y: float = 0.0
|
||||
normal_force_raw: float = 0.0
|
||||
shear_x_raw: float = 0.0
|
||||
shear_y_raw: float = 0.0
|
||||
force_unit: str = "raw"
|
||||
force_calibrated: bool = False
|
||||
#: Which axes actually have a calibration curve loaded. The sensor-level
|
||||
#: ``force_calibrated`` flag cannot express a partially calibrated device.
|
||||
calibrated_axes: tuple[str, ...] = ()
|
||||
session_id: int = 0
|
||||
#: Monotonic per-session frame index; the join key for video, CSV and
|
||||
#: calibration records.
|
||||
frame_id: int = 0
|
||||
#: Monotonic, session-relative acquisition time. Used for ordering so a wall
|
||||
#: clock that steps backwards cannot reorder history. ``None`` means the
|
||||
#: producer did not measure one, and the wall clock is used instead — a
|
||||
#: default of ``0.0`` would have made every frame look simultaneous.
|
||||
mono_t: Optional[float] = None
|
||||
#: Wall clock at hand-off, for metadata only. ``None`` means unmeasured.
|
||||
wall_t: Optional[float] = None
|
||||
#: False when the force readout failed. Invalid packets carry NaN forces and
|
||||
#: must never be averaged into calibration data or written as zeros.
|
||||
valid: bool = True
|
||||
invalid_reason: str = ""
|
||||
#: True when the compute chain failed for this frame. The SDK's force fields
|
||||
#: then still hold the value computed for an earlier frame, so they must not
|
||||
#: be recorded as this frame's measurement.
|
||||
stale_forces: bool = False
|
||||
#: Content hash of the calibration actually in effect when this frame was
|
||||
#: acquired; changes mid-session when new coefficients are applied.
|
||||
calibration_version: str = ""
|
||||
#: False when the sensor exposes no RAW channels; the raw fields are then
|
||||
#: NaN rather than a copy of the calibrated values.
|
||||
raw_available: bool = True
|
||||
flow: Optional[np.ndarray] = None
|
||||
depth: Optional[np.ndarray] = None
|
||||
valid_mask: Optional[np.ndarray] = None
|
||||
|
||||
|
||||
def load_fixed_mode(config_ref: str | Path) -> FixedCameraMode:
|
||||
"""Resolve a config reference (a file path or a product name) to its geometry."""
|
||||
ref = str(config_ref).strip()
|
||||
path = Path(ref)
|
||||
if path.is_file():
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
resolved = str(path.resolve())
|
||||
else:
|
||||
import orisys
|
||||
|
||||
data = orisys.configs.load_config(ref)
|
||||
resolved = ref
|
||||
camera = data.get("camera") or {}
|
||||
raw = camera.get("resolution_raw") or [1920, 1080]
|
||||
fps = float(camera.get("fps") or 30.0)
|
||||
return FixedCameraMode(
|
||||
width=int(raw[0]),
|
||||
height=int(raw[1]),
|
||||
fps=fps,
|
||||
config_ref=resolved,
|
||||
)
|
||||
|
||||
|
||||
def ensure_bgr(img: np.ndarray) -> np.ndarray:
|
||||
"""Three-channel BGR view of whatever the sensor hands over."""
|
||||
if img.ndim == 2:
|
||||
return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
|
||||
if img.ndim == 3 and img.shape[2] == 1:
|
||||
return cv2.cvtColor(img[:, :, 0], cv2.COLOR_GRAY2BGR)
|
||||
if img.ndim == 3 and img.shape[2] == 4:
|
||||
return cv2.cvtColor(img, cv2.COLOR_BGRA2BGR)
|
||||
return img
|
||||
|
||||
|
||||
def is_file_source(video_source: str | int) -> bool:
|
||||
"""True when the source is a file (a replay) rather than a live camera."""
|
||||
return isinstance(video_source, str)
|
||||
|
||||
|
||||
def orisys_importable() -> tuple[bool, str]:
|
||||
"""Whether the SDK can be imported, with the reason when it cannot."""
|
||||
try:
|
||||
import orisys # noqa: F401
|
||||
|
||||
return True, getattr(__import__("orisys"), "__version__", "unknown")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return False, str(exc)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,121 @@
|
||||
"""Record-channel fan-out shared by the acquisition workers.
|
||||
|
||||
The workers used to expose exactly one consumer of their data — a single
|
||||
``latest()`` slot polled by a Qt timer. That made recording depend on the UI
|
||||
refresh rate, and it meant that hiding a panel could stop a recording.
|
||||
|
||||
Every worker now also publishes each :class:`~orisys_app_shared.acquisition.sdk_worker.SdkPacket`
|
||||
to a list of sinks, from the acquisition thread. Sinks are simple callables and
|
||||
must be cheap and Qt-free; a sink that raises is logged and dropped for that
|
||||
packet rather than being allowed to kill acquisition.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from collections import deque
|
||||
from typing import Any, Callable, Iterable
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: How many pre-processing frames are kept before a recording starts.
|
||||
PREROLL_FRAMES = 30
|
||||
#: Byte ceiling for the same buffer (30 frames of 1080p BGR is already ~190 MB).
|
||||
PREROLL_BYTES = 192 * 1024 * 1024
|
||||
|
||||
|
||||
class RecordSinkMixin:
|
||||
"""Mixin adding ``add_sink`` / ``remove_sink`` / ``_emit`` to a worker."""
|
||||
|
||||
def _init_sinks(self) -> None:
|
||||
self._sinks: list[Callable[[Any], None]] = []
|
||||
self._sinks_lock = threading.Lock()
|
||||
self._emit_failures = 0
|
||||
self._init_preroll()
|
||||
|
||||
# ------------------------------------------------------------- pre-roll
|
||||
def _init_preroll(self) -> None:
|
||||
"""Buffer of the frames captured before recording starts.
|
||||
|
||||
The SDK's depth chain is stateful (optical-flow tracker plus temporal
|
||||
filters), so the first frames of a recording cannot be recomputed offline
|
||||
without the frames that preceded them. This is that history, bounded by
|
||||
both a frame count and a byte budget.
|
||||
"""
|
||||
self._preroll: deque = deque()
|
||||
self._preroll_lock = threading.Lock()
|
||||
self._preroll_bytes = 0
|
||||
|
||||
def note_preroll(self, frame) -> None:
|
||||
"""Remember one pre-processing frame. Called from the acquisition thread."""
|
||||
if frame is None:
|
||||
return
|
||||
block = np.asarray(frame)
|
||||
if block.size == 0:
|
||||
return
|
||||
copy = block.copy() # the sensor reuses its buffer between reads
|
||||
with self._preroll_lock:
|
||||
self._preroll.append(copy)
|
||||
self._preroll_bytes += int(copy.nbytes)
|
||||
while self._preroll and (
|
||||
len(self._preroll) > PREROLL_FRAMES
|
||||
or self._preroll_bytes > PREROLL_BYTES
|
||||
):
|
||||
dropped = self._preroll.popleft()
|
||||
self._preroll_bytes -= int(np.asarray(dropped).nbytes)
|
||||
|
||||
def take_preroll(self) -> list:
|
||||
"""Hand the buffered frames to a recorder and start a fresh buffer."""
|
||||
with self._preroll_lock:
|
||||
frames = list(self._preroll)
|
||||
self._preroll.clear()
|
||||
self._preroll_bytes = 0
|
||||
return frames
|
||||
|
||||
def preroll_size(self) -> int:
|
||||
with self._preroll_lock:
|
||||
return len(self._preroll)
|
||||
|
||||
def add_sink(self, sink: Callable[[Any], None]) -> None:
|
||||
"""Register a record-channel consumer of every packet."""
|
||||
with self._sinks_lock:
|
||||
if sink not in self._sinks:
|
||||
self._sinks.append(sink)
|
||||
|
||||
def remove_sink(self, sink: Callable[[Any], None]) -> None:
|
||||
with self._sinks_lock:
|
||||
if sink in self._sinks:
|
||||
self._sinks.remove(sink)
|
||||
|
||||
def sinks(self) -> tuple[Callable[[Any], None], ...]:
|
||||
with self._sinks_lock:
|
||||
return tuple(self._sinks)
|
||||
|
||||
@property
|
||||
def emit_failures(self) -> int:
|
||||
"""Number of sink calls that raised — surfaced so it is never silent."""
|
||||
return self._emit_failures
|
||||
|
||||
def _emit(self, packet: Any) -> None:
|
||||
"""Hand ``packet`` to every sink, from the acquisition thread."""
|
||||
for sink in self.sinks():
|
||||
try:
|
||||
sink(packet)
|
||||
except Exception: # noqa: BLE001 - one bad consumer must not stop capture
|
||||
self._emit_failures += 1
|
||||
logger.exception("Record sink %r failed", sink)
|
||||
|
||||
|
||||
def emit_all(sinks: Iterable[Callable[[Any], None]], packet: Any) -> int:
|
||||
"""Utility for tests: emit to a plain list of sinks, returning failures."""
|
||||
failures = 0
|
||||
for sink in sinks:
|
||||
try:
|
||||
sink(packet)
|
||||
except Exception: # noqa: BLE001
|
||||
failures += 1
|
||||
logger.exception("Record sink %r failed", sink)
|
||||
return failures
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Force history and unit helpers."""
|
||||
"""Force history, session storage, calibration sampling and unit helpers."""
|
||||
|
||||
from orisys_app_shared.force.calibration_io import (
|
||||
CHANNEL_KEYS,
|
||||
@@ -8,19 +8,53 @@ from orisys_app_shared.force.calibration_io import (
|
||||
export_force_calibration_v2,
|
||||
meta_from_sensor_json,
|
||||
)
|
||||
from orisys_app_shared.force.calibration_recorder import (
|
||||
AxisStats,
|
||||
CalibrationPoint,
|
||||
CalibrationRecorder,
|
||||
)
|
||||
from orisys_app_shared.force.filters import (
|
||||
FilterSettings,
|
||||
ForceFilterChain,
|
||||
describe_state,
|
||||
effective_filters,
|
||||
settings_from_mapping,
|
||||
)
|
||||
from orisys_app_shared.force.history import MetricHistory, PlotState, lowpass_filter
|
||||
from orisys_app_shared.force.session import (
|
||||
NA,
|
||||
SelectionStats,
|
||||
SessionSample,
|
||||
SessionSlice,
|
||||
SessionStore,
|
||||
summarise,
|
||||
)
|
||||
from orisys_app_shared.force.units import DisplayForces, select_display_forces
|
||||
|
||||
__all__ = [
|
||||
"CHANNEL_KEYS",
|
||||
"NA",
|
||||
"AxisStats",
|
||||
"CalSample",
|
||||
"CalSessionStore",
|
||||
"CalibrationPoint",
|
||||
"CalibrationRecorder",
|
||||
"DisplayForces",
|
||||
"FilterSettings",
|
||||
"ForceFilterChain",
|
||||
"MetricHistory",
|
||||
"PlotState",
|
||||
"SelectionStats",
|
||||
"SessionSample",
|
||||
"SessionSlice",
|
||||
"SessionStore",
|
||||
"build_force_calibration_v2",
|
||||
"describe_state",
|
||||
"effective_filters",
|
||||
"export_force_calibration_v2",
|
||||
"lowpass_filter",
|
||||
"meta_from_sensor_json",
|
||||
"select_display_forces",
|
||||
"settings_from_mapping",
|
||||
"summarise",
|
||||
]
|
||||
|
||||
@@ -1,29 +1,32 @@
|
||||
"""Build and write schema_version-2 force_calibration.json files."""
|
||||
"""Build and write schema_version-2 force_calibration.json files.
|
||||
|
||||
Delegates schema/parse/write to ``orisys.calibration`` so the app and SDK share
|
||||
one format. ``CalSessionStore`` remains the UI sampling table.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from copy import deepcopy
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from dataclasses import asdict, dataclass, field, replace
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Literal
|
||||
from typing import Any, Iterable, Literal, Mapping
|
||||
|
||||
from orisys.calibration import (
|
||||
CalSample as SdkCalSample,
|
||||
build as sdk_build,
|
||||
check as sdk_check,
|
||||
pipeline_from_sensor_json,
|
||||
write as sdk_write,
|
||||
)
|
||||
|
||||
ChannelKey = Literal["fn", "fx", "fy"]
|
||||
CHANNEL_KEYS: tuple[ChannelKey, ...] = ("fn", "fx", "fy")
|
||||
|
||||
_CHANNEL_TO_JSON = {
|
||||
"fn": "normal",
|
||||
"fx": "shear_x",
|
||||
"fy": "shear_y",
|
||||
}
|
||||
_JSON_RAW_KEY = {
|
||||
"normal": "fnormal",
|
||||
"shear_x": "fshearx",
|
||||
"shear_y": "fsheary",
|
||||
}
|
||||
|
||||
WEIGHT_EPS = 1e-6
|
||||
#: Channel slot of each axis inside ``Calibration.convert``'s triple.
|
||||
_CONVERT_SLOT: dict[str, int] = {"fn": 0, "fx": 1, "fy": 2}
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -33,7 +36,65 @@ class CalSample:
|
||||
n: int
|
||||
std: float = 0.0
|
||||
|
||||
def to_sdk(self) -> SdkCalSample:
|
||||
return SdkCalSample(
|
||||
weight_n=float(self.weight_n),
|
||||
raw_mean=float(self.raw_mean),
|
||||
n=int(self.n),
|
||||
std=float(self.std),
|
||||
)
|
||||
|
||||
|
||||
def merge_samples(first: CalSample, second: CalSample) -> CalSample:
|
||||
"""Combine two observations of one reference load.
|
||||
|
||||
The means are n-weighted and the standard deviations are pooled about the
|
||||
combined mean, so a repeated load ends up with the scatter of every sample
|
||||
that went into it rather than of whichever one happened to be last.
|
||||
"""
|
||||
n_total = int(first.n) + int(second.n)
|
||||
if n_total <= 0:
|
||||
return CalSample(
|
||||
weight_n=float(first.weight_n),
|
||||
raw_mean=(float(first.raw_mean) + float(second.raw_mean)) / 2.0,
|
||||
n=0,
|
||||
std=0.0,
|
||||
)
|
||||
mean = (
|
||||
float(first.raw_mean) * int(first.n) + float(second.raw_mean) * int(second.n)
|
||||
) / n_total
|
||||
ss = (int(first.n) - 1) * float(first.std) ** 2
|
||||
ss += (int(second.n) - 1) * float(second.std) ** 2
|
||||
ss += int(first.n) * (float(first.raw_mean) - mean) ** 2
|
||||
ss += int(second.n) * (float(second.raw_mean) - mean) ** 2
|
||||
var = ss / (n_total - 1) if n_total > 1 else 0.0
|
||||
return CalSample(
|
||||
weight_n=float(first.weight_n),
|
||||
raw_mean=float(mean),
|
||||
n=n_total,
|
||||
std=math.sqrt(max(0.0, float(var))),
|
||||
)
|
||||
|
||||
|
||||
def build_export_model(samples: Mapping[str, list[CalSample]]):
|
||||
"""Build exactly the model that export and application would use.
|
||||
|
||||
Diagnostics must be computed on *this* curve. A separate least-squares fit
|
||||
can look healthy while the piecewise curve the device actually applies is
|
||||
the one with the error.
|
||||
"""
|
||||
payload = {
|
||||
key: [sample.to_sdk() for sample in (samples.get(key) or [])]
|
||||
for key in CHANNEL_KEYS
|
||||
}
|
||||
return sdk_build(payload, origin="diagnostics")
|
||||
|
||||
|
||||
def predict_channel(cal, channel: str, raw: float) -> float:
|
||||
"""Apply ``cal`` to one axis exactly as the runtime conversion does."""
|
||||
triple = [0.0, 0.0, 0.0]
|
||||
triple[_CONVERT_SLOT[channel]] = float(raw)
|
||||
return float(cal.convert(triple[0], triple[1], triple[2])[_CONVERT_SLOT[channel]])
|
||||
@dataclass
|
||||
class CalSessionStore:
|
||||
"""In-memory calibration points for FN / FX / FY."""
|
||||
@@ -67,6 +128,23 @@ class CalSessionStore:
|
||||
rows.sort(key=lambda s: s.weight_n)
|
||||
self.set_channel(key, rows)
|
||||
|
||||
def aggregate(self, key: ChannelKey, sample: CalSample) -> None:
|
||||
"""Add one observation, merging repeated loads instead of overwriting.
|
||||
|
||||
The operator may sample the same reference load several times on purpose.
|
||||
Letting the last sample win threw that evidence away — and with it the
|
||||
scatter that tells whether a load was actually stable.
|
||||
"""
|
||||
rows = self.channel(key)
|
||||
for i, existing in enumerate(rows):
|
||||
if abs(existing.weight_n - sample.weight_n) <= WEIGHT_EPS:
|
||||
rows[i] = merge_samples(existing, sample)
|
||||
break
|
||||
else:
|
||||
rows.append(sample)
|
||||
rows.sort(key=lambda s: s.weight_n)
|
||||
self.set_channel(key, rows)
|
||||
|
||||
def remove_at(self, key: ChannelKey, index: int) -> None:
|
||||
rows = self.channel(key)
|
||||
if 0 <= index < len(rows):
|
||||
@@ -78,143 +156,326 @@ class CalSessionStore:
|
||||
out[key] = [asdict(s) for s in self.channel(key)]
|
||||
return out
|
||||
|
||||
def to_sdk_samples(self) -> dict[str, list[SdkCalSample]]:
|
||||
return {
|
||||
key: [s.to_sdk() for s in self.channel(key)]
|
||||
for key in CHANNEL_KEYS
|
||||
}
|
||||
|
||||
|
||||
def meta_from_sensor_json(path: str | Path | None) -> dict[str, Any]:
|
||||
"""Pull stitch / tracker fields from a sensor.json for export meta."""
|
||||
meta: dict[str, Any] = {
|
||||
"stitch_resolution": None,
|
||||
"dis_mode": None,
|
||||
"hhd_downsample": None,
|
||||
"config_ref": None,
|
||||
pipeline = pipeline_from_sensor_json(path)
|
||||
return {
|
||||
"stitch_resolution": pipeline.get("stitch_resolution"),
|
||||
"dis_mode": pipeline.get("dis_mode"),
|
||||
"hhd_downsample": pipeline.get("hhd_downsample"),
|
||||
"config_ref": pipeline.get("config_ref"),
|
||||
}
|
||||
if path is None:
|
||||
return meta
|
||||
p = Path(path)
|
||||
meta["config_ref"] = str(p)
|
||||
if not p.is_file():
|
||||
return meta
|
||||
try:
|
||||
data = json.loads(p.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return meta
|
||||
if not isinstance(data, dict):
|
||||
return meta
|
||||
camera = data.get("camera") if isinstance(data.get("camera"), dict) else {}
|
||||
tracker = data.get("tracker") if isinstance(data.get("tracker"), dict) else {}
|
||||
stitch = camera.get("resolution_stitch")
|
||||
if isinstance(stitch, (list, tuple)) and len(stitch) >= 2:
|
||||
meta["stitch_resolution"] = [int(stitch[0]), int(stitch[1])]
|
||||
if "dis_mode" in tracker:
|
||||
try:
|
||||
meta["dis_mode"] = int(tracker["dis_mode"])
|
||||
except (TypeError, ValueError):
|
||||
meta["dis_mode"] = tracker.get("dis_mode")
|
||||
if "hhd_downsample" in tracker:
|
||||
try:
|
||||
meta["hhd_downsample"] = int(tracker["hhd_downsample"])
|
||||
except (TypeError, ValueError):
|
||||
meta["hhd_downsample"] = tracker.get("hhd_downsample")
|
||||
return meta
|
||||
|
||||
|
||||
def _piecewise_channel(
|
||||
*,
|
||||
json_name: str,
|
||||
samples: Iterable[CalSample],
|
||||
signed: bool,
|
||||
) -> dict[str, Any] | None:
|
||||
rows = sorted(samples, key=lambda s: abs(float(s.raw_mean)))
|
||||
if len(rows) < 2:
|
||||
def _meta_blocks(meta: dict[str, Any] | None) -> tuple[dict, dict, dict, str]:
|
||||
"""Split a flat export-meta dict into the SDK's identity/pipeline/extra."""
|
||||
meta = dict(meta or {})
|
||||
identity = {
|
||||
"sensor_type": meta.get("sensor_type", ""),
|
||||
"sn": meta.get("sn", ""),
|
||||
}
|
||||
pipeline = {
|
||||
"stitch_resolution": meta.get("stitch_resolution"),
|
||||
"dis_mode": meta.get("dis_mode"),
|
||||
"hhd_downsample": meta.get("hhd_downsample"),
|
||||
"config_ref": meta.get("config_ref"),
|
||||
}
|
||||
extra = {
|
||||
key: value
|
||||
for key, value in meta.items()
|
||||
if key
|
||||
not in (
|
||||
"sensor_type",
|
||||
"sn",
|
||||
"stitch_resolution",
|
||||
"dis_mode",
|
||||
"hhd_downsample",
|
||||
"config_ref",
|
||||
"samples",
|
||||
)
|
||||
}
|
||||
return identity, pipeline, extra, str(meta.get("origin") or "user")
|
||||
|
||||
|
||||
def aggregate_samples(rows: Iterable[CalSample]) -> list[CalSample]:
|
||||
"""n-weighted merge of repeated reference loads, sorted by load.
|
||||
|
||||
Sampling the same load twice is normal and deliberate; the model has to see
|
||||
the combined mean and the scatter *between* the samples, not whichever
|
||||
sample happened to be last.
|
||||
"""
|
||||
merged: list[CalSample] = []
|
||||
for sample in rows:
|
||||
for index, existing in enumerate(merged):
|
||||
if abs(existing.weight_n - sample.weight_n) <= WEIGHT_EPS:
|
||||
merged[index] = merge_samples(existing, sample)
|
||||
break
|
||||
else:
|
||||
merged.append(sample)
|
||||
merged.sort(key=lambda item: item.weight_n)
|
||||
return merged
|
||||
|
||||
|
||||
def training_samples(points) -> tuple[dict[str, list[CalSample]], list, list]:
|
||||
"""Split points into the training build and the held-out validation set.
|
||||
|
||||
This is the **single** filter: a point that is excluded, invalid or has no
|
||||
reference load never reaches a model, and a validation point is only ever
|
||||
measured against one. Callers must not re-implement it.
|
||||
"""
|
||||
training: list = []
|
||||
validation: list = []
|
||||
for point in points or ():
|
||||
if bool(getattr(point, "excluded", False)):
|
||||
continue
|
||||
try:
|
||||
load = float(point.reference_load_n)
|
||||
raw_mean = float(point.loaded_stats().mean)
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
continue
|
||||
if not math.isfinite(load) or not math.isfinite(raw_mean):
|
||||
continue
|
||||
if bool(getattr(point, "is_validation", False)):
|
||||
validation.append(point)
|
||||
else:
|
||||
training.append(point)
|
||||
|
||||
by_channel: dict[str, list[CalSample]] = {key: [] for key in CHANNEL_KEYS}
|
||||
for point in training:
|
||||
stats = point.loaded_stats()
|
||||
std = float(stats.std)
|
||||
by_channel[str(point.channel)].append(
|
||||
CalSample(
|
||||
weight_n=float(point.reference_load_n),
|
||||
raw_mean=float(stats.mean),
|
||||
n=int(stats.n or 0),
|
||||
std=std if math.isfinite(std) else 0.0,
|
||||
)
|
||||
)
|
||||
return (
|
||||
{key: aggregate_samples(rows) for key, rows in by_channel.items()},
|
||||
training,
|
||||
validation,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedCalibration:
|
||||
"""One preparation of a calibration session: samples -> model -> diagnostics.
|
||||
|
||||
The diagnostics and the exported document come from *this* object, so the
|
||||
numbers on screen describe exactly the model that is written to disk. Two
|
||||
separate preparations (one for the panel, one for the fit) is how the
|
||||
displayed residual and the applied curve drifted apart in the first place.
|
||||
"""
|
||||
|
||||
model: Any = None
|
||||
samples: dict[str, list[CalSample]] = field(default_factory=dict)
|
||||
training: list = field(default_factory=list)
|
||||
validation: list = field(default_factory=list)
|
||||
#: ``id(point)`` -> prediction error at that point (predicted - reference).
|
||||
residuals: dict[int, float] = field(default_factory=dict)
|
||||
#: ``id(point)`` -> absolute error of a held-out point.
|
||||
validation_errors: dict[int, float] = field(default_factory=dict)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
meta: dict = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.model is not None
|
||||
|
||||
def channel_samples(self, key: str) -> list[CalSample]:
|
||||
return list(self.samples.get(key) or [])
|
||||
|
||||
def method(self, channel: str):
|
||||
curve = self.curve(channel)
|
||||
return str(getattr(curve, "method", "") or "") if curve is not None else None
|
||||
|
||||
def breakpoints(self, channel: str) -> int:
|
||||
curve = self.curve(channel)
|
||||
return len(getattr(curve, "points", ()) or ()) if curve is not None else 0
|
||||
|
||||
def curve(self, channel: str):
|
||||
if self.model is None:
|
||||
return None
|
||||
points = [[abs(float(s.raw_mean)), float(s.weight_n)] for s in rows]
|
||||
block: dict[str, Any] = {
|
||||
"method": "piecewise_linear",
|
||||
"raw_key": _JSON_RAW_KEY[json_name],
|
||||
"extrapolate": True,
|
||||
"clamp_output_min": 0.0,
|
||||
"points": points,
|
||||
return self.model.get(channel)
|
||||
|
||||
def predict(self, channel: str, raw: float) -> float:
|
||||
if self.model is None:
|
||||
return float("nan")
|
||||
return predict_channel(self.model, channel, raw)
|
||||
|
||||
def points_in_fit(self, channel: str) -> int:
|
||||
return len(self.samples.get(channel) or [])
|
||||
|
||||
def validation_points(self, channel: str) -> int:
|
||||
return sum(1 for point in self.validation if point.channel == channel)
|
||||
|
||||
def max_residual(self, channel: str) -> float:
|
||||
values = [
|
||||
abs(value)
|
||||
for point in self.training
|
||||
if point.channel == channel
|
||||
for value in [self.residuals.get(id(point))]
|
||||
if value is not None and math.isfinite(value)
|
||||
]
|
||||
return max(values) if values else float("nan")
|
||||
|
||||
def max_validation_error(self, channel: str) -> float:
|
||||
values = [
|
||||
value
|
||||
for point in self.validation
|
||||
if point.channel == channel
|
||||
for value in [self.validation_errors.get(id(point))]
|
||||
if value is not None and math.isfinite(value)
|
||||
]
|
||||
return max(values) if values else float("nan")
|
||||
|
||||
def to_document(self) -> dict[str, Any]:
|
||||
"""The schema-v2 document for this prepared model."""
|
||||
if self.model is None:
|
||||
raise ValueError(
|
||||
"No complete channels to build (each needs ≥2 points). "
|
||||
+ "; ".join(self.warnings)
|
||||
)
|
||||
return self.model.to_dict()
|
||||
|
||||
|
||||
def prepare_from_samples(
|
||||
samples: dict[str, list[CalSample]],
|
||||
*,
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> PreparedCalibration:
|
||||
"""Build the model and its diagnostics from prepared training samples.
|
||||
|
||||
Repeated loads are merged here too, so a caller that hands in raw (unmerged)
|
||||
samples still gets the same model as one that hands in points.
|
||||
"""
|
||||
merged = {
|
||||
key: aggregate_samples(samples.get(key) or []) for key in CHANNEL_KEYS
|
||||
}
|
||||
if signed:
|
||||
block["signed"] = True
|
||||
return block
|
||||
identity, pipeline, extra, origin = _meta_blocks(meta)
|
||||
warnings: list[str] = []
|
||||
model = None
|
||||
try:
|
||||
model = sdk_build(
|
||||
{key: [sample.to_sdk() for sample in rows] for key, rows in merged.items()},
|
||||
identity=identity,
|
||||
pipeline=pipeline,
|
||||
origin=origin,
|
||||
extra_meta=extra,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - surfaced as warnings + no model
|
||||
warnings.append(str(exc))
|
||||
if model is not None:
|
||||
try:
|
||||
warnings.extend(sdk_check(model).warnings)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
warnings.append(f"check failed: {exc}")
|
||||
for key in CHANNEL_KEYS:
|
||||
if not model.has(key):
|
||||
warnings.append(_missing_channel_message(key, merged.get(key) or []))
|
||||
return PreparedCalibration(
|
||||
model=model,
|
||||
samples=merged,
|
||||
warnings=warnings,
|
||||
meta=dict(meta or {}),
|
||||
)
|
||||
|
||||
|
||||
def _missing_channel_message(key: str, rows: list[CalSample]) -> str:
|
||||
if rows:
|
||||
return f"{key}: need at least 2 points (have {len(rows)})"
|
||||
return f"{key}: no samples"
|
||||
|
||||
|
||||
def prepare_calibration(
|
||||
points,
|
||||
*,
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> PreparedCalibration:
|
||||
"""The one entry point: points -> training samples -> model -> diagnostics."""
|
||||
samples, training, validation = training_samples(points)
|
||||
prepared = prepare_from_samples(samples, meta=meta)
|
||||
residuals: dict[int, float] = {}
|
||||
for point in training:
|
||||
predicted = prepared.predict(str(point.channel), float(point.loaded_stats().mean))
|
||||
if math.isfinite(predicted):
|
||||
residuals[id(point)] = predicted - float(point.reference_load_n)
|
||||
validation_errors: dict[int, float] = {}
|
||||
for point in validation:
|
||||
predicted = prepared.predict(str(point.channel), float(point.loaded_stats().mean))
|
||||
if math.isfinite(predicted):
|
||||
validation_errors[id(point)] = abs(predicted - float(point.reference_load_n))
|
||||
return replace(
|
||||
prepared,
|
||||
training=list(training),
|
||||
validation=list(validation),
|
||||
residuals=residuals,
|
||||
validation_errors=validation_errors,
|
||||
)
|
||||
|
||||
|
||||
def build_export_model(samples: dict[str, list[CalSample]]):
|
||||
"""The SDK model for ``samples`` — the same preparation the panel uses."""
|
||||
return prepare_from_samples(samples).model
|
||||
|
||||
|
||||
def build_force_calibration_v2(
|
||||
store: CalSessionStore,
|
||||
source,
|
||||
*,
|
||||
meta: dict[str, Any] | None = None,
|
||||
) -> tuple[dict[str, Any], list[str]]:
|
||||
"""Build schema v2 JSON. Returns (document, skipped_channel_warnings)."""
|
||||
warnings: list[str] = []
|
||||
channels: dict[str, Any] = {}
|
||||
for key, json_name in _CHANNEL_TO_JSON.items():
|
||||
signed = key in ("fx", "fy")
|
||||
block = _piecewise_channel(
|
||||
json_name=json_name,
|
||||
samples=store.channel(key),
|
||||
signed=signed,
|
||||
)
|
||||
if block is None:
|
||||
if store.channel(key):
|
||||
warnings.append(
|
||||
f"{key}: need at least 2 points (have {len(store.channel(key))})"
|
||||
)
|
||||
else:
|
||||
warnings.append(f"{key}: no samples")
|
||||
continue
|
||||
channels[json_name] = block
|
||||
"""Serialise the prepared model. Returns (document, warnings).
|
||||
|
||||
base_meta = {
|
||||
"date": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
|
||||
"sensor_type": "",
|
||||
"sn": "",
|
||||
"stitch_resolution": None,
|
||||
"dis_mode": None,
|
||||
"hhd_downsample": None,
|
||||
"record_duration_s": None,
|
||||
"config_ref": None,
|
||||
"samples": store.to_audit_dict(),
|
||||
}
|
||||
if meta:
|
||||
for k, v in meta.items():
|
||||
if k == "samples":
|
||||
continue
|
||||
base_meta[k] = v
|
||||
if "samples" in meta and meta["samples"] is not None:
|
||||
base_meta["samples"] = meta["samples"]
|
||||
|
||||
doc = {
|
||||
"schema_version": 2,
|
||||
"unit": "N",
|
||||
"channels": channels,
|
||||
"meta": base_meta,
|
||||
}
|
||||
return doc, warnings
|
||||
``source`` is a :class:`PreparedCalibration` (the normal path — the document
|
||||
is exactly the model that was diagnosed) or, for older callers, a
|
||||
:class:`CalSessionStore`, which is prepared here with ``meta``.
|
||||
"""
|
||||
prepared = (
|
||||
source
|
||||
if isinstance(source, PreparedCalibration)
|
||||
else prepare_from_samples(
|
||||
{key: source.channel(key) for key in CHANNEL_KEYS}, meta=meta
|
||||
)
|
||||
)
|
||||
if prepared.model is None:
|
||||
# Match the previous API: raise ValueError carrying the channel warnings.
|
||||
raise ValueError("; ".join(prepared.warnings) or "no complete channels")
|
||||
return prepared.to_document(), list(prepared.warnings)
|
||||
|
||||
|
||||
def write_force_calibration(
|
||||
doc: dict[str, Any],
|
||||
path: str | Path,
|
||||
) -> Path:
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
json.dumps(doc, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path.resolve()
|
||||
"""Write a calibration dict (already built) to disk."""
|
||||
from orisys.calibration import parse
|
||||
|
||||
cal = parse(doc)
|
||||
return sdk_write(cal, path, overwrite=True)
|
||||
|
||||
|
||||
def export_force_calibration_v2(
|
||||
store: CalSessionStore,
|
||||
source,
|
||||
*,
|
||||
export_dir: str | Path,
|
||||
sensor_type: str,
|
||||
meta: dict[str, Any] | None = None,
|
||||
local_bundle_dir: str | Path | None = None,
|
||||
) -> tuple[Path, Path | None, list[str]]:
|
||||
"""Write timestamped export and optional local bundle force_calibration.json."""
|
||||
doc, warnings = build_force_calibration_v2(store, meta=meta)
|
||||
"""Write timestamped export and optional local bundle force_calibration.json.
|
||||
|
||||
``source`` is the prepared calibration (preferred) or a session store.
|
||||
"""
|
||||
doc, warnings = build_force_calibration_v2(source, meta=meta)
|
||||
if not doc.get("channels"):
|
||||
raise ValueError(
|
||||
"No complete channels to export (each needs ≥2 points). "
|
||||
|
||||
@@ -0,0 +1,445 @@
|
||||
"""Calibration sampling state machine, independent of any Qt widget.
|
||||
|
||||
Two responsibilities are deliberately split across two threads:
|
||||
|
||||
``feed(sample)``
|
||||
Called from the acquisition thread (via the worker's sink list) for **every**
|
||||
acquired sample. It only appends to a thread-safe buffer, so recording keeps
|
||||
running when the calibration panel is hidden, when the plots are redrawing
|
||||
slowly, or when the UI is busy. It never touches Qt.
|
||||
|
||||
``tick()``
|
||||
Called from the UI thread. It evaluates the sampling deadline and turns the
|
||||
buffered samples into a finished :class:`CalibrationPoint`, invoking the
|
||||
callbacks on the UI thread where it is safe to touch widgets.
|
||||
|
||||
Each point stores all three axes rather than only the loaded one, so crosstalk
|
||||
between axes is observable from the recorded data. The non-loaded axes carry raw
|
||||
statistics but **no** reference load: their true load is unknown during a
|
||||
single-axis calibration, so it is left empty rather than filled with zero.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from orisys_app_shared.force.models import (
|
||||
CHANNEL_KEYS,
|
||||
MAX_TRACE_POINTS,
|
||||
NA,
|
||||
SAMPLE_FIELDS,
|
||||
STATUS_CANCELLED,
|
||||
STATUS_FEW_SAMPLES,
|
||||
STATUS_OK,
|
||||
AxisStats,
|
||||
CalibrationPoint,
|
||||
ChannelKey,
|
||||
trace_dt,
|
||||
trace_from,
|
||||
)
|
||||
from orisys_app_shared.force.session import SessionSample, summarise
|
||||
|
||||
# Re-exported: the models live in ``force.models`` so the session file format can
|
||||
# describe a point without importing the sampling state machine.
|
||||
__all__ = [
|
||||
"AxisStats",
|
||||
"CalibrationPoint",
|
||||
"CalibrationRecorder",
|
||||
"FitResult",
|
||||
"SAMPLE_FIELDS",
|
||||
"trace_dt",
|
||||
"trace_from",
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FitResult:
|
||||
"""Diagnostics for the model that would actually be exported."""
|
||||
|
||||
channel: ChannelKey
|
||||
n_points: int = 0
|
||||
max_residual_n: float = NA
|
||||
validation_error_n: float = NA
|
||||
validation_points: int = 0
|
||||
#: Method of the exported curve (``piecewise_linear`` / ``linear``).
|
||||
method: str = ""
|
||||
#: Breakpoints in the exported curve, for the UI to describe the model.
|
||||
breakpoints: int = 0
|
||||
#: Least-squares summary shown next to the model, never used for residuals.
|
||||
linear_gain: float = NA
|
||||
linear_offset: float = NA
|
||||
|
||||
@property
|
||||
def usable(self) -> bool:
|
||||
return self.n_points >= 2 and bool(self.method)
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"channel": self.channel,
|
||||
"method": self.method,
|
||||
"breakpoints": self.breakpoints,
|
||||
"linear_gain_n_per_raw": self.linear_gain,
|
||||
"linear_offset_n": self.linear_offset,
|
||||
"points_in_fit": self.n_points,
|
||||
"max_residual_n": self.max_residual_n,
|
||||
"validation_error_n": self.validation_error_n,
|
||||
"validation_points": self.validation_points,
|
||||
}
|
||||
|
||||
|
||||
class CalibrationRecorder:
|
||||
"""Owns the calibration sampling lifecycle."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
on_finished: Optional[Callable[[CalibrationPoint], None]] = None,
|
||||
on_cancelled: Optional[Callable[[], None]] = None,
|
||||
clock: Callable[[], float] = time.perf_counter,
|
||||
wall_clock: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
|
||||
) -> None:
|
||||
self._on_finished = on_finished
|
||||
self._on_cancelled = on_cancelled
|
||||
self._clock = clock
|
||||
self._wall_clock = wall_clock
|
||||
self._lock = threading.Lock()
|
||||
self._recording = False
|
||||
self._channel: ChannelKey = "fn"
|
||||
self._reference_load_n = 0.0
|
||||
self._direction = "load"
|
||||
self._note = ""
|
||||
self._started_perf = 0.0
|
||||
self._duration_s = 0.0
|
||||
self._started_at = ""
|
||||
self._samples: list[tuple[float, float, float, float, float, float, float, bool]] = []
|
||||
self._rounds: dict[ChannelKey, int] = {}
|
||||
self.points: list[CalibrationPoint] = []
|
||||
|
||||
# ------------------------------------------------------------------ state
|
||||
@property
|
||||
def is_recording(self) -> bool:
|
||||
with self._lock:
|
||||
return self._recording
|
||||
|
||||
@property
|
||||
def channel(self) -> ChannelKey:
|
||||
with self._lock:
|
||||
return self._channel
|
||||
|
||||
@property
|
||||
def requested_duration_s(self) -> float:
|
||||
with self._lock:
|
||||
return self._duration_s
|
||||
|
||||
def elapsed_s(self) -> float:
|
||||
with self._lock:
|
||||
if not self._recording:
|
||||
return 0.0
|
||||
return max(0.0, self._clock() - self._started_perf)
|
||||
|
||||
def progress(self) -> float:
|
||||
elapsed = self.elapsed_s()
|
||||
duration = self.requested_duration_s
|
||||
if duration <= 0:
|
||||
return 0.0
|
||||
return min(1.0, elapsed / duration)
|
||||
|
||||
def note(self) -> str:
|
||||
with self._lock:
|
||||
return self._note
|
||||
|
||||
# ------------------------------------------------------------- lifecycle
|
||||
def reset_session(self) -> None:
|
||||
"""Clear per-session state; called when a new acquisition starts."""
|
||||
with self._lock:
|
||||
self._recording = False
|
||||
self._samples = []
|
||||
self._rounds = {}
|
||||
self.points = []
|
||||
|
||||
def start(
|
||||
self,
|
||||
*,
|
||||
channel: ChannelKey,
|
||||
reference_load_n: float,
|
||||
duration_s: float,
|
||||
direction: str = "load",
|
||||
note: str = "",
|
||||
) -> None:
|
||||
with self._lock:
|
||||
self._channel = channel if channel in CHANNEL_KEYS else "fn"
|
||||
self._reference_load_n = float(reference_load_n)
|
||||
self._duration_s = max(0.05, float(duration_s))
|
||||
self._direction = str(direction or "load")
|
||||
self._note = str(note or "")
|
||||
self._started_perf = self._clock()
|
||||
self._started_at = self._wall_clock().isoformat(timespec="seconds")
|
||||
self._samples = []
|
||||
self._recording = True
|
||||
|
||||
def cancel(self) -> None:
|
||||
with self._lock:
|
||||
if not self._recording:
|
||||
return
|
||||
self._recording = False
|
||||
self._samples = []
|
||||
if self._on_cancelled is not None:
|
||||
self._on_cancelled()
|
||||
|
||||
def feed(self, sample: SessionSample) -> None:
|
||||
"""Buffer one sample. Safe to call from the acquisition thread."""
|
||||
with self._lock:
|
||||
if not self._recording:
|
||||
return
|
||||
self._samples.append(
|
||||
(
|
||||
int(sample.frame_id),
|
||||
float(sample.t_rel_s),
|
||||
float(sample.fn_raw),
|
||||
float(sample.fx_raw),
|
||||
float(sample.fy_raw),
|
||||
float(sample.fn_n),
|
||||
float(sample.fx_n),
|
||||
float(sample.fy_n),
|
||||
bool(sample.valid),
|
||||
)
|
||||
)
|
||||
|
||||
def tick(self) -> Optional[CalibrationPoint]:
|
||||
"""Finish on the UI thread once the deadline has passed."""
|
||||
with self._lock:
|
||||
if not self._recording:
|
||||
return None
|
||||
elapsed = self._clock() - self._started_perf
|
||||
if elapsed < self._duration_s:
|
||||
return None
|
||||
return self._finish()
|
||||
|
||||
def finish_now(self) -> Optional[CalibrationPoint]:
|
||||
"""Stop early and keep whatever has been captured so far."""
|
||||
with self._lock:
|
||||
if not self._recording:
|
||||
return None
|
||||
return self._finish()
|
||||
|
||||
# --------------------------------------------------------------- internals
|
||||
def _finish(self) -> Optional[CalibrationPoint]:
|
||||
with self._lock:
|
||||
samples = self._samples
|
||||
self._samples = []
|
||||
self._recording = False
|
||||
channel = self._channel
|
||||
reference = self._reference_load_n
|
||||
direction = self._direction
|
||||
note = self._note
|
||||
started_at = self._started_at
|
||||
duration = self._duration_s
|
||||
ended_at = self._wall_clock().isoformat(timespec="seconds")
|
||||
actual_duration = 0.0
|
||||
if samples:
|
||||
actual_duration = max(0.0, samples[-1][1] - samples[0][1])
|
||||
|
||||
block = np.asarray(samples, dtype=np.float64) if samples else np.zeros((0, 9))
|
||||
valid_mask = block[:, 8] > 0.5 if block.size else np.zeros(0, dtype=bool)
|
||||
valid_block = block[valid_mask] if block.size else block
|
||||
|
||||
def axis_stats(column: int) -> AxisStats:
|
||||
if valid_block.size == 0:
|
||||
return AxisStats()
|
||||
mean, std, count = summarise(valid_block[:, column])
|
||||
return AxisStats(mean=mean, std=std, n=count)
|
||||
|
||||
point = CalibrationPoint(
|
||||
channel=channel,
|
||||
reference_load_n=reference,
|
||||
direction=direction,
|
||||
round_index=self._next_round(channel),
|
||||
note=note,
|
||||
started_at=started_at,
|
||||
ended_at=ended_at,
|
||||
duration_s=actual_duration,
|
||||
requested_duration_s=duration,
|
||||
n_total=int(block.shape[0]),
|
||||
n_valid=int(valid_block.shape[0]),
|
||||
raw={"fn": axis_stats(2), "fx": axis_stats(3), "fy": axis_stats(4)},
|
||||
newtons={"fn": axis_stats(5), "fx": axis_stats(6), "fy": axis_stats(7)},
|
||||
status=STATUS_OK if valid_block.shape[0] >= 2 else STATUS_FEW_SAMPLES,
|
||||
trace=trace_from(valid_block),
|
||||
trace_dt_s=trace_dt(valid_block),
|
||||
# Every sample is kept, including the invalid ones: a point that was
|
||||
# mostly invalid is itself a finding. ``samples`` holds the original
|
||||
# tuples so ``frame_id`` stays an integer.
|
||||
samples=list(samples),
|
||||
)
|
||||
with self._lock:
|
||||
self.points.append(point)
|
||||
if self._on_finished is not None:
|
||||
self._on_finished(point)
|
||||
return point
|
||||
|
||||
# ------------------------------------------------------------ diagnostics
|
||||
def all_points(self) -> list[CalibrationPoint]:
|
||||
"""Snapshot of every recorded point, across channels."""
|
||||
with self._lock:
|
||||
return list(self.points)
|
||||
|
||||
def _resolve(self, point: CalibrationPoint) -> Optional[CalibrationPoint]:
|
||||
"""Find the recorded point that is ``point``, by identity.
|
||||
|
||||
Callers pass the object they are looking at. An integer row index would
|
||||
be ambiguous: the panel shows a per-channel filtered list while the
|
||||
recorder stores every channel in one list, so index 0 of the table is
|
||||
not index 0 of ``points``.
|
||||
"""
|
||||
if point is None:
|
||||
return None
|
||||
for candidate in self.points:
|
||||
if candidate is point:
|
||||
return candidate
|
||||
return None
|
||||
|
||||
def set_excluded(self, point: CalibrationPoint, excluded: bool) -> bool:
|
||||
"""Remove (or restore) a point from the fit and from the export."""
|
||||
with self._lock:
|
||||
target = self._resolve(point)
|
||||
if target is None:
|
||||
return False
|
||||
target.excluded = bool(excluded)
|
||||
if excluded:
|
||||
target.is_validation = False
|
||||
return True
|
||||
|
||||
def set_validation(self, point: CalibrationPoint, validation: bool) -> bool:
|
||||
"""Hold a point out of the fit and use it to test the fit instead."""
|
||||
with self._lock:
|
||||
target = self._resolve(point)
|
||||
if target is None:
|
||||
return False
|
||||
target.is_validation = bool(validation)
|
||||
if validation:
|
||||
target.excluded = False
|
||||
return True
|
||||
|
||||
def remove_point(self, point: CalibrationPoint) -> bool:
|
||||
"""Drop a point entirely (used by "delete row")."""
|
||||
with self._lock:
|
||||
target = self._resolve(point)
|
||||
if target is None:
|
||||
return False
|
||||
self.points.remove(target)
|
||||
return True
|
||||
|
||||
def clear_points(self, channel: Optional[ChannelKey] = None) -> int:
|
||||
"""Drop every point, or every point of one channel. Returns the count."""
|
||||
with self._lock:
|
||||
if channel is None:
|
||||
removed = len(self.points)
|
||||
self.points = []
|
||||
return removed
|
||||
kept = [point for point in self.points if point.channel != channel]
|
||||
removed = len(self.points) - len(kept)
|
||||
self.points = kept
|
||||
return removed
|
||||
|
||||
def load_points(self, points, *, replace: bool = True) -> int:
|
||||
"""Adopt points read back from a saved calibration session.
|
||||
|
||||
Reopening a session has to put the operator back exactly where they left
|
||||
off, so the diagnostics (excluded, validation) travel with the points.
|
||||
"""
|
||||
loaded = list(points)
|
||||
with self._lock:
|
||||
if replace:
|
||||
self.points = loaded
|
||||
else:
|
||||
self.points.extend(loaded)
|
||||
for point in loaded:
|
||||
index = self._rounds.get(point.channel, 0)
|
||||
self._rounds[point.channel] = max(index, int(point.round_index) + 1)
|
||||
return len(loaded)
|
||||
|
||||
def points_for(self, channel: ChannelKey) -> list[CalibrationPoint]:
|
||||
with self._lock:
|
||||
return [point for point in self.points if point.channel == channel]
|
||||
|
||||
def prepared(self, *, meta: Optional[dict] = None):
|
||||
"""The session prepared into one model plus its diagnostics.
|
||||
|
||||
Everything that shows or writes a calibration goes through this, so the
|
||||
panel, the fit summary and the exported file cannot disagree about which
|
||||
points were used.
|
||||
"""
|
||||
from orisys_app_shared.force.calibration_io import prepare_calibration
|
||||
|
||||
with self._lock:
|
||||
points = list(self.points)
|
||||
return prepare_calibration(points, meta=meta)
|
||||
|
||||
def fit(self, channel: ChannelKey, prepared=None) -> FitResult:
|
||||
"""Diagnose the model that export and application would actually use.
|
||||
|
||||
The residual and validation figures used to come from a private
|
||||
least-squares line while the exported file carried a piecewise curve, so
|
||||
the numbers on screen did not describe the calibration that would be
|
||||
applied. Both now come from the one prepared model (see
|
||||
:func:`~orisys_app_shared.force.calibration_io.prepare_calibration`):
|
||||
excluded points are dropped, validation points are held out of the build
|
||||
and only measured, and repeated loads are merged before the build.
|
||||
"""
|
||||
if prepared is None:
|
||||
prepared = self.prepared()
|
||||
|
||||
points = self.points_for(channel)
|
||||
for point in points:
|
||||
value = prepared.residuals.get(id(point))
|
||||
point.residual_n = NA if value is None else float(value)
|
||||
|
||||
curve = prepared.curve(channel)
|
||||
n_points = prepared.points_in_fit(channel)
|
||||
if curve is None or n_points < 2:
|
||||
return FitResult(channel=channel, n_points=n_points)
|
||||
|
||||
raws = np.asarray(
|
||||
[
|
||||
float(point.loaded_stats().mean)
|
||||
for point in prepared.training
|
||||
if point.channel == channel
|
||||
],
|
||||
dtype=float,
|
||||
)
|
||||
loads = np.asarray(
|
||||
[
|
||||
float(point.reference_load_n)
|
||||
for point in prepared.training
|
||||
if point.channel == channel
|
||||
],
|
||||
dtype=float,
|
||||
)
|
||||
linear_gain = linear_offset = NA
|
||||
if raws.size >= 2 and not np.allclose(raws, raws[0]):
|
||||
linear_gain, linear_offset = (float(v) for v in np.polyfit(raws, loads, 1))
|
||||
|
||||
return FitResult(
|
||||
channel=channel,
|
||||
n_points=n_points,
|
||||
max_residual_n=prepared.max_residual(channel),
|
||||
validation_error_n=prepared.max_validation_error(channel),
|
||||
validation_points=prepared.validation_points(channel),
|
||||
method=str(getattr(curve, "method", "") or ""),
|
||||
breakpoints=prepared.breakpoints(channel),
|
||||
linear_gain=linear_gain,
|
||||
linear_offset=linear_offset,
|
||||
)
|
||||
|
||||
def _next_round(self, channel: ChannelKey) -> int:
|
||||
with self._lock:
|
||||
index = self._rounds.get(channel, 0)
|
||||
self._rounds[channel] = index + 1
|
||||
return index
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Persist a calibration session: every point and its full three-axis samples.
|
||||
|
||||
The exported SDK coefficients are the *result* of a calibration; this file is the
|
||||
*evidence*. It keeps what the coefficients cannot:
|
||||
|
||||
* the complete sample series of every point (frame id, relative time, all three
|
||||
raw axes, all three newton axes and the validity flag), so a point can be
|
||||
re-examined even though the diagnostic trace on screen is downsampled;
|
||||
* the actual duration of each point next to the duration that was requested;
|
||||
* which points were excluded and which were held out as validation points;
|
||||
* the direction (load / unload) and note of each point.
|
||||
|
||||
The file is written next to the exported coefficients and can be reopened to
|
||||
inspect the session again.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
from orisys_app_shared.force.models import (
|
||||
SAMPLE_FIELDS,
|
||||
AxisStats,
|
||||
CalibrationPoint,
|
||||
trace_dt,
|
||||
trace_from,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Format version of the calibration session document.
|
||||
SESSION_FORMAT_VERSION = "1.0"
|
||||
SESSION_REQUIRED = ("format", "format_version", "created_at", "points")
|
||||
POINT_REQUIRED = ("channel", "reference_load_n")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalibrationSessionRecord:
|
||||
"""One reopened calibration session."""
|
||||
|
||||
path: Path
|
||||
format_version: str
|
||||
created_at: str
|
||||
meta: dict = field(default_factory=dict)
|
||||
points: list = field(default_factory=list)
|
||||
#: What could not be restored exactly (reported, never silently dropped).
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return bool(self.points) and not self.warnings
|
||||
|
||||
def identity(self) -> dict:
|
||||
"""The device this session belongs to.
|
||||
|
||||
A calibration record is evidence about *one* device: its sensor type, its
|
||||
serial number and the configuration it was taken with. Reopening it must
|
||||
not restamp it with whatever device happens to be connected now.
|
||||
"""
|
||||
return {
|
||||
"sensor_type": str(self.meta.get("sensor_type") or ""),
|
||||
"sn": str(self.meta.get("sn") or ""),
|
||||
"config_ref": str(self.meta.get("config_ref") or ""),
|
||||
"created_at": self.created_at,
|
||||
}
|
||||
|
||||
def describe(self) -> str:
|
||||
identity = self.identity()
|
||||
parts = [identity["sensor_type"] or "?"]
|
||||
if identity["sn"]:
|
||||
parts.append(f"SN {identity['sn']}")
|
||||
if identity["config_ref"]:
|
||||
parts.append(identity["config_ref"])
|
||||
return " / ".join(parts)
|
||||
|
||||
|
||||
def _jsonable(value: Any) -> Any:
|
||||
"""NaN/Inf are not valid JSON; they are written as ``null``."""
|
||||
if isinstance(value, float):
|
||||
if value != value or value in (float("inf"), float("-inf")):
|
||||
return None
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
return {key: _jsonable(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_jsonable(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def build_session_document(
|
||||
points: Iterable,
|
||||
*,
|
||||
meta: Optional[dict] = None,
|
||||
created_at: Optional[str] = None,
|
||||
) -> dict:
|
||||
"""Build the document for ``points`` without touching the disk."""
|
||||
documents = []
|
||||
for point in points:
|
||||
entry = _jsonable(point.as_dict())
|
||||
# The full series is the audit data; the trace stays as a quick preview.
|
||||
entry["sample_fields"] = list(SAMPLE_FIELDS)
|
||||
entry["samples"] = [
|
||||
[None if value != value else value for value in row]
|
||||
for row in getattr(point, "samples", ())
|
||||
]
|
||||
documents.append(entry)
|
||||
return {
|
||||
"format": "orisys.calibration_session",
|
||||
"format_version": SESSION_FORMAT_VERSION,
|
||||
"created_at": created_at
|
||||
or datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
"sample_fields": list(SAMPLE_FIELDS),
|
||||
"meta": _jsonable(dict(meta or {})),
|
||||
"points": documents,
|
||||
}
|
||||
|
||||
|
||||
def session_filename(sensor_type: str, *, stamp: Optional[str] = None) -> str:
|
||||
when = stamp or datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
return f"calibration_session_{sensor_type or 'unknown'}_{when}.json"
|
||||
|
||||
|
||||
def write_calibration_session(
|
||||
path: str | Path,
|
||||
points: Iterable,
|
||||
*,
|
||||
meta: Optional[dict] = None,
|
||||
) -> Path:
|
||||
"""Write the session document. Returns the written path."""
|
||||
target = Path(path)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
document = build_session_document(points, meta=meta)
|
||||
target.write_text(
|
||||
json.dumps(document, indent=2, ensure_ascii=False, allow_nan=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return target
|
||||
|
||||
|
||||
def read_calibration_session(path: str | Path) -> CalibrationSessionRecord:
|
||||
"""Load a session document and rebuild its points.
|
||||
|
||||
A file that cannot be trusted must say so: a truncated or foreign document
|
||||
raises, and a document from a newer format is loaded with a warning instead
|
||||
of being interpreted as if the layout were known.
|
||||
"""
|
||||
target = Path(path)
|
||||
data = json.loads(target.read_text(encoding="utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(f"not a calibration session document: {target}")
|
||||
missing = [key for key in SESSION_REQUIRED if key not in data]
|
||||
if missing:
|
||||
raise ValueError(
|
||||
f"calibration session {target.name} is missing {', '.join(missing)}"
|
||||
)
|
||||
if str(data.get("format")) != "orisys.calibration_session":
|
||||
raise ValueError(f"not a calibration session document: {target}")
|
||||
|
||||
warnings: list[str] = []
|
||||
version = str(data.get("format_version") or "")
|
||||
if version != SESSION_FORMAT_VERSION:
|
||||
warnings.append(f"format_version {version or '?'} is not {SESSION_FORMAT_VERSION}")
|
||||
|
||||
points = []
|
||||
for index, raw in enumerate(data.get("points") or []):
|
||||
if not isinstance(raw, dict):
|
||||
warnings.append(f"point {index}: not an object")
|
||||
continue
|
||||
absent = [key for key in POINT_REQUIRED if key not in raw]
|
||||
if absent:
|
||||
warnings.append(f"point {index}: missing {', '.join(absent)}")
|
||||
continue
|
||||
points.append(_point_from_dict(raw, CalibrationPoint, AxisStats))
|
||||
return CalibrationSessionRecord(
|
||||
path=target,
|
||||
format_version=version,
|
||||
created_at=str(data.get("created_at") or ""),
|
||||
meta=dict(data.get("meta") or {}),
|
||||
points=points,
|
||||
warnings=warnings,
|
||||
)
|
||||
|
||||
|
||||
def _stats_from_dict(raw: Any, AxisStats):
|
||||
if not isinstance(raw, dict):
|
||||
return AxisStats()
|
||||
def _number(key: str, fallback: float) -> float:
|
||||
value = raw.get(key)
|
||||
if value is None:
|
||||
return fallback
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return fallback
|
||||
|
||||
return AxisStats(
|
||||
mean=_number("mean", float("nan")),
|
||||
std=_number("std", float("nan")),
|
||||
n=int(raw.get("n") or 0),
|
||||
)
|
||||
|
||||
|
||||
def _point_from_dict(raw: dict, CalibrationPoint, AxisStats):
|
||||
point = CalibrationPoint(
|
||||
channel=str(raw.get("channel") or "fn"),
|
||||
reference_load_n=float(raw.get("reference_load_n") or 0.0),
|
||||
direction=str(raw.get("direction") or "load"),
|
||||
round_index=int(raw.get("round_index") or 0),
|
||||
note=str(raw.get("note") or ""),
|
||||
started_at=str(raw.get("started_at") or ""),
|
||||
ended_at=str(raw.get("ended_at") or ""),
|
||||
duration_s=float(raw.get("duration_s") or 0.0),
|
||||
requested_duration_s=float(raw.get("requested_duration_s") or 0.0),
|
||||
n_total=int(raw.get("n_total") or 0),
|
||||
n_valid=int(raw.get("n_valid") or 0),
|
||||
status=str(raw.get("status") or "ok"),
|
||||
excluded=bool(raw.get("excluded", False)),
|
||||
is_validation=bool(raw.get("is_validation", False)),
|
||||
)
|
||||
point.raw = {
|
||||
key: _stats_from_dict(value, AxisStats)
|
||||
for key, value in (raw.get("raw") or {}).items()
|
||||
}
|
||||
point.newtons = {
|
||||
key: _stats_from_dict(value, AxisStats)
|
||||
for key, value in (raw.get("newtons") or {}).items()
|
||||
}
|
||||
point.trace = {
|
||||
str(key): [float(value) for value in (series or [])]
|
||||
for key, series in (raw.get("trace") or {}).items()
|
||||
}
|
||||
point.residual_n = _optional_float(raw.get("residual_n"))
|
||||
rows = []
|
||||
for row in raw.get("samples") or []:
|
||||
if not isinstance(row, (list, tuple)):
|
||||
continue
|
||||
values = [float("nan") if value is None else value for value in row]
|
||||
rows.append(tuple(values))
|
||||
point.samples = rows
|
||||
# The full series is the audit data, so everything a diagnostic view needs is
|
||||
# rebuilt from it: the per-axis trace, its sample interval and, when the
|
||||
# stored duration is missing, the real sampling span.
|
||||
restore_diagnostics(point)
|
||||
return point
|
||||
|
||||
|
||||
def restore_diagnostics(point) -> None:
|
||||
"""Rebuild the three-axis trace and the sampling span from the samples."""
|
||||
if not point.samples:
|
||||
return
|
||||
try:
|
||||
import numpy as np
|
||||
except Exception: # noqa: BLE001 - a partial install must still reopen points
|
||||
return
|
||||
|
||||
width = max(len(row) for row in point.samples)
|
||||
padded = [tuple(row) + (float("nan"),) * (width - len(row)) for row in point.samples]
|
||||
block = np.asarray(padded, dtype=np.float64)
|
||||
if block.shape[1] < len(SAMPLE_FIELDS):
|
||||
return
|
||||
valid = block[:, 8] > 0.5
|
||||
valid_block = block[valid]
|
||||
if not point.trace and valid_block.size:
|
||||
point.trace = trace_from(valid_block)
|
||||
if not point.trace_dt_s and valid_block.size:
|
||||
point.trace_dt_s = trace_dt(valid_block)
|
||||
if not point.duration_s and block.shape[0] > 1:
|
||||
span = float(block[-1, 1] - block[0, 1])
|
||||
if span == span and span >= 0.0:
|
||||
point.duration_s = span
|
||||
|
||||
|
||||
def _optional_float(value: Any) -> float:
|
||||
if value is None:
|
||||
return float("nan")
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return float("nan")
|
||||
@@ -0,0 +1,533 @@
|
||||
"""The app's filter control surface: what was asked for, and what is running.
|
||||
|
||||
The filter *chain* lives in the SDK — ``compute_flow`` applies the flow EMA
|
||||
before the HHD, and ``_apply_force_filter`` applies the median and the low-pass
|
||||
*before* the device calibration. Filtering anywhere else would produce numbers no
|
||||
recording could reproduce, and would leave the unfiltered newtons unrecoverable
|
||||
(the calibration has already been applied by then). So this module does three
|
||||
things, and nothing else:
|
||||
|
||||
* turn the operator's choice into one ``Sensor.configure(...)`` call
|
||||
(:meth:`FilterSettings.apply_to`);
|
||||
* read the **effective** values back from ``Sensor.filters_state()``
|
||||
(:meth:`FilterSettings.from_filters_state`), so the dialog and the record show
|
||||
what the device is doing rather than what was typed into it;
|
||||
* write the settings into the recording metadata (:meth:`FilterSettings.metadata`),
|
||||
keeping the **measurement** group (``flow_ema_alpha``: it changes the flow, so
|
||||
two recordings with different values are not comparable) apart from the
|
||||
**readout** group (median, low-pass: they only smooth the force on its way out).
|
||||
|
||||
``ForceFilterChain`` at the bottom is a reference implementation kept for
|
||||
``tests/test_force_filters.py``, which pins that this app understands the SDK's
|
||||
chain exactly. The app itself never filters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Any, Iterable, Mapping, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Channels of the reference chain: three raw counts then the three newtons.
|
||||
CHANNELS = 6
|
||||
|
||||
#: The SDK settings this app drives, in chain order (measurement first). These
|
||||
#: are the sensor's own *state* keys: the same words appear in ``sensor.json``,
|
||||
#: ``filters_state()``, ``session.json`` and the preferences, so a reader never
|
||||
#: has to translate between two vocabularies.
|
||||
MEASUREMENT_KEYS: tuple[str, ...] = ("flow_ema_alpha",)
|
||||
READOUT_KEYS: tuple[str, ...] = (
|
||||
"force_median_window",
|
||||
"force_lowpass_cutoff_hz",
|
||||
"force_lowpass_order",
|
||||
"force_lowpass_sample_rate_hz",
|
||||
)
|
||||
|
||||
#: State key -> ``Sensor.configure`` parameter. Not the same on both sides: the
|
||||
#: low-pass pair is ``force_lowpass_*`` in the config/state and ``lowpass_*`` in
|
||||
#: the call. One table, so "can the SDK take this?" and "what do we pass?" can
|
||||
#: never disagree.
|
||||
CONFIGURE_PARAMETERS: dict[str, str] = {
|
||||
"flow_ema_alpha": "flow_ema_alpha",
|
||||
"force_median_window": "force_median_window",
|
||||
"force_lowpass_cutoff_hz": "lowpass_cutoff_hz",
|
||||
"force_lowpass_order": "lowpass_order",
|
||||
"force_lowpass_sample_rate_hz": "lowpass_sample_rate_hz",
|
||||
}
|
||||
|
||||
#: Every key above, in chain order — "nothing could be applied" is this tuple.
|
||||
ALL_KEYS: tuple[str, ...] = tuple(CONFIGURE_PARAMETERS)
|
||||
|
||||
#: A sensible cutoff to offer when the operator switches the low-pass on.
|
||||
SUGGESTED_LOWPASS_HZ = 10.0
|
||||
|
||||
#: The presets to offer when the SDK cannot be asked for its own. Kept in step
|
||||
#: with ``orisys.sensor.FILTER_PRESETS`` by tests/test_force_filters.py; the SDK's
|
||||
#: table wins whenever it can be imported, because that is the table the device
|
||||
#: resolves against.
|
||||
FALLBACK_PRESETS: dict[str, dict[str, Any]] = {
|
||||
"off": {
|
||||
"flow_ema_alpha": 1.0,
|
||||
"force_median_window": 1,
|
||||
"force_lowpass_cutoff_hz": 0.0,
|
||||
},
|
||||
"low_latency": {
|
||||
"flow_ema_alpha": 1.0,
|
||||
"force_median_window": 3,
|
||||
"force_lowpass_cutoff_hz": 0.0,
|
||||
},
|
||||
"balanced": {
|
||||
"flow_ema_alpha": 0.5,
|
||||
"force_median_window": 3,
|
||||
"force_lowpass_cutoff_hz": 0.0,
|
||||
},
|
||||
"low_noise": {
|
||||
"flow_ema_alpha": 0.3,
|
||||
"force_median_window": 3,
|
||||
"force_lowpass_cutoff_hz": 10.0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def available_presets() -> dict[str, dict[str, Any]]:
|
||||
"""Named presets for the dialog: the SDK's own, or the documented copies."""
|
||||
fallback = {name: dict(values) for name, values in FALLBACK_PRESETS.items()}
|
||||
try:
|
||||
from orisys.sensor import available_filter_presets
|
||||
except Exception: # noqa: BLE001 - the SDK may not be importable at all
|
||||
return fallback
|
||||
try:
|
||||
presets = available_filter_presets()
|
||||
except Exception: # noqa: BLE001
|
||||
return fallback
|
||||
resolved = {str(name): dict(values) for name, values in dict(presets).items()}
|
||||
return resolved or fallback
|
||||
|
||||
|
||||
def _as_float(value: Any, default: float) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _as_int(value: Any, default: int) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _sample_rate(value: Any) -> float | str:
|
||||
"""``"auto"``, or a positive rate in Hz. Anything else is ``"auto"``."""
|
||||
if isinstance(value, str):
|
||||
text = value.strip().lower()
|
||||
return "auto" if text in ("", "auto", "measured") else "auto"
|
||||
rate = _as_float(value, 0.0)
|
||||
return rate if rate > 0 else "auto"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FilterSettings:
|
||||
"""The filter settings the operator chose, in the SDK's own vocabulary.
|
||||
|
||||
Every stage is switched by its **own value** (``flow_ema_alpha >= 1``,
|
||||
``median_window <= 1``, ``lowpass_hz <= 0`` all mean "off"), so there is no
|
||||
second boolean to disagree with the number. Values that cannot be used are
|
||||
resolved to "off" by :meth:`normalised`, never to a default that filters.
|
||||
"""
|
||||
|
||||
#: Flow-EMA exponent. **Measurement**: it changes the flow, the contact
|
||||
#: statistics and ``_force_raw``, so two recordings with different values are
|
||||
#: not comparable.
|
||||
flow_ema_alpha: float = 1.0
|
||||
#: Sliding-median window in samples. **Readout**.
|
||||
median_window: int = 1
|
||||
#: Low-pass cutoff in Hz. **Readout**.
|
||||
lowpass_hz: float = 0.0
|
||||
lowpass_order: int = 2
|
||||
#: ``"auto"`` follows the measured frame rate (30 fps until measured); a
|
||||
#: number pins the rate the coefficients were designed for.
|
||||
sample_rate_hz: float | str = "auto"
|
||||
#: Display only — draw the unfiltered curves beside the filtered ones. Never
|
||||
#: sent to the sensor, and never part of what makes two records comparable.
|
||||
show_unfiltered: bool = False
|
||||
#: The preset this came from, when it came from one (kept so a record can say
|
||||
#: "balanced" instead of only the numbers it expanded to).
|
||||
preset: Optional[str] = None
|
||||
|
||||
# ------------------------------------------------------------- resolving
|
||||
def normalised(self) -> "FilterSettings":
|
||||
"""Clamp to what the SDK can run, with unusable values meaning "off"."""
|
||||
alpha = _as_float(self.flow_ema_alpha, 1.0)
|
||||
if not 0.0 < alpha < 1.0:
|
||||
alpha = 1.0
|
||||
window = max(1, _as_int(self.median_window, 1))
|
||||
cutoff = max(0.0, _as_float(self.lowpass_hz, 0.0))
|
||||
order = min(6, max(1, _as_int(self.lowpass_order, 2)))
|
||||
return replace(
|
||||
self,
|
||||
flow_ema_alpha=alpha,
|
||||
median_window=window,
|
||||
lowpass_hz=cutoff,
|
||||
lowpass_order=order,
|
||||
sample_rate_hz=_sample_rate(self.sample_rate_hz),
|
||||
show_unfiltered=bool(self.show_unfiltered),
|
||||
)
|
||||
|
||||
@property
|
||||
def flow_ema_on(self) -> bool:
|
||||
return 0.0 < _as_float(self.flow_ema_alpha, 1.0) < 1.0
|
||||
|
||||
@property
|
||||
def median_on(self) -> bool:
|
||||
return _as_int(self.median_window, 1) > 1
|
||||
|
||||
@property
|
||||
def lowpass_on(self) -> bool:
|
||||
return _as_float(self.lowpass_hz, 0.0) > 0.0
|
||||
|
||||
def active(self) -> bool:
|
||||
"""Whether any stage filters at all (an off chain is a pass-through)."""
|
||||
return bool(self.flow_ema_on or self.median_on or self.lowpass_on)
|
||||
|
||||
def as_state(self) -> dict[str, Any]:
|
||||
"""The settings in the shape ``Sensor.filters_state()`` reports."""
|
||||
settings = self.normalised()
|
||||
rate = settings.sample_rate_hz
|
||||
readout: dict[str, Any] = {
|
||||
"force_median_window": settings.median_window,
|
||||
"force_lowpass_cutoff_hz": settings.lowpass_hz,
|
||||
"force_lowpass_order": settings.lowpass_order,
|
||||
"force_lowpass_rate_auto": rate == "auto",
|
||||
}
|
||||
if rate != "auto":
|
||||
readout["force_lowpass_sample_rate_hz"] = float(rate)
|
||||
return {
|
||||
"measurement": {"flow_ema_alpha": settings.flow_ema_alpha},
|
||||
"readout": readout,
|
||||
}
|
||||
|
||||
def _values_by_state_key(self) -> dict[str, Any]:
|
||||
"""This request, keyed the way the SDK's state names things."""
|
||||
settings = self.normalised()
|
||||
return {
|
||||
"flow_ema_alpha": settings.flow_ema_alpha,
|
||||
"force_median_window": settings.median_window,
|
||||
"force_lowpass_cutoff_hz": settings.lowpass_hz,
|
||||
"force_lowpass_order": settings.lowpass_order,
|
||||
"force_lowpass_sample_rate_hz": settings.sample_rate_hz,
|
||||
}
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""A flat mapping for the preferences file (SDK keys, plus display)."""
|
||||
settings = self.normalised()
|
||||
return {
|
||||
"flow_ema_alpha": settings.flow_ema_alpha,
|
||||
"force_median_window": settings.median_window,
|
||||
"force_lowpass_cutoff_hz": settings.lowpass_hz,
|
||||
"force_lowpass_order": settings.lowpass_order,
|
||||
"force_lowpass_sample_rate_hz": settings.sample_rate_hz,
|
||||
"show_unfiltered": settings.show_unfiltered,
|
||||
"preset": settings.preset,
|
||||
}
|
||||
|
||||
def describe(self) -> str:
|
||||
"""A short line for the record, upstream stage first."""
|
||||
return describe_state(self.as_state())
|
||||
|
||||
def nyquist_issue(self, sample_rate_hz: Optional[float]) -> Optional[dict[str, float]]:
|
||||
"""The cutoff/Nyquist problem as numbers, for a UI to phrase itself.
|
||||
|
||||
The SDK drops a low-pass whose cutoff is at or above Nyquist instead of
|
||||
silently re-tuning it, so the dialog has to say so *before* a session
|
||||
starts — otherwise the file would claim a filter the device is not
|
||||
running.
|
||||
"""
|
||||
settings = self.normalised()
|
||||
if not settings.lowpass_on or not sample_rate_hz:
|
||||
return None
|
||||
rate = _as_float(sample_rate_hz, 0.0)
|
||||
nyquist = 0.5 * rate
|
||||
if nyquist <= 0 or settings.lowpass_hz < nyquist:
|
||||
return None
|
||||
return {
|
||||
"cutoff_hz": float(settings.lowpass_hz),
|
||||
"nyquist_hz": float(nyquist),
|
||||
"rate_hz": float(rate),
|
||||
}
|
||||
|
||||
def problems(self, sample_rate_hz: Optional[float] = None) -> tuple[str, ...]:
|
||||
"""Why the device would not do what these settings ask (English, for logs)."""
|
||||
issue = self.nyquist_issue(sample_rate_hz)
|
||||
if issue is None:
|
||||
return ()
|
||||
return (
|
||||
f"lowpass {issue['cutoff_hz']:g} Hz is at or above Nyquist "
|
||||
f"({issue['nyquist_hz']:g} Hz at {issue['rate_hz']:g} fps): the device "
|
||||
"would not filter",
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------- driving the SDK
|
||||
def unsupported_by(self, sensor: object) -> tuple[str, ...]:
|
||||
"""The SDK keys this sensor's ``configure()`` does not accept.
|
||||
|
||||
Reported rather than dropped: a stage the operator switched on but the
|
||||
installed SDK cannot run is exactly the disagreement that ends up in a
|
||||
recorded file. Older SDKs (before 0.5.2) have no ``flow_ema_alpha`` and
|
||||
no median, and their flow EMA is not switchable at all.
|
||||
"""
|
||||
try:
|
||||
parameters = inspect.signature(sensor.configure).parameters
|
||||
except (AttributeError, TypeError, ValueError): # pragma: no cover - exotic fake
|
||||
return ALL_KEYS
|
||||
return tuple(
|
||||
key
|
||||
for key, parameter in CONFIGURE_PARAMETERS.items()
|
||||
if parameter not in parameters
|
||||
)
|
||||
|
||||
def configure_kwargs(self, skip: Iterable[str] = ()) -> dict[str, Any]:
|
||||
"""The ``Sensor.configure(...)`` call these settings amount to."""
|
||||
skipped = set(skip)
|
||||
return {
|
||||
CONFIGURE_PARAMETERS[key]: value
|
||||
for key, value in self._values_by_state_key().items()
|
||||
if key not in skipped
|
||||
}
|
||||
|
||||
def apply_to(self, sensor: object) -> tuple[str, ...]:
|
||||
"""Push these settings into the device; return what it could not take.
|
||||
|
||||
Called from the acquisition thread right after the sensor opens, so a
|
||||
session is filtered the way the operator asked for from its first frame.
|
||||
"""
|
||||
unsupported = self.unsupported_by(sensor)
|
||||
if unsupported:
|
||||
logger.warning(
|
||||
"The installed orisys SDK cannot apply %s; the effective filter "
|
||||
"state is in Sensor.filters_state()",
|
||||
", ".join(unsupported),
|
||||
)
|
||||
sensor.configure(**self.configure_kwargs(unsupported))
|
||||
return unsupported
|
||||
|
||||
# ------------------------------------------------------------ reading back
|
||||
@classmethod
|
||||
def from_filters_state(
|
||||
cls, state: Mapping[str, Any], *, show_unfiltered: bool = False
|
||||
) -> "FilterSettings":
|
||||
"""The settings a device reports as in effect.
|
||||
|
||||
This is what the dialog displays and what a record stores: a config file
|
||||
or a request can say one thing while the running device does another.
|
||||
"""
|
||||
measurement = dict(state.get("measurement") or {})
|
||||
readout = dict(state.get("readout") or {})
|
||||
rate: Any = "auto"
|
||||
if not readout.get("force_lowpass_rate_auto", True):
|
||||
rate = readout.get("force_lowpass_sample_rate_hz", "auto")
|
||||
return cls(
|
||||
flow_ema_alpha=_as_float(measurement.get("flow_ema_alpha"), 1.0),
|
||||
median_window=_as_int(readout.get("force_median_window"), 1),
|
||||
lowpass_hz=_as_float(readout.get("force_lowpass_cutoff_hz"), 0.0),
|
||||
lowpass_order=_as_int(readout.get("force_lowpass_order"), 2),
|
||||
sample_rate_hz=rate,
|
||||
show_unfiltered=bool(show_unfiltered),
|
||||
preset=state.get("preset"),
|
||||
).normalised()
|
||||
|
||||
def metadata(self, sensor_state: Optional[Mapping[str, Any]] = None) -> dict[str, Any]:
|
||||
"""The ``filters`` block for ``session.json`` / a recording header.
|
||||
|
||||
With a sensor state this records what the device was doing
|
||||
(``source: sensor.filters_state``); without one it records what the app
|
||||
asked for and says so, so the two are never mistaken for each other.
|
||||
"""
|
||||
if sensor_state:
|
||||
measurement = dict(sensor_state.get("measurement") or {})
|
||||
readout = dict(sensor_state.get("readout") or {})
|
||||
preset = sensor_state.get("preset")
|
||||
source = str(sensor_state.get("source") or "sensor.filters_state")
|
||||
describe = describe_state(sensor_state)
|
||||
else:
|
||||
state = self.as_state()
|
||||
measurement = state["measurement"]
|
||||
readout = state["readout"]
|
||||
preset = self.normalised().preset
|
||||
source = "app.FilterSettings"
|
||||
describe = describe_state(state)
|
||||
block: dict[str, Any] = {
|
||||
"measurement": measurement,
|
||||
"readout": readout,
|
||||
"describe": describe,
|
||||
"source": source,
|
||||
"show_unfiltered": bool(self.show_unfiltered),
|
||||
}
|
||||
if preset:
|
||||
block["preset"] = str(preset)
|
||||
return block
|
||||
|
||||
|
||||
def describe_state(state: Mapping[str, Any]) -> str:
|
||||
"""``none`` / ``flowEMA(0.5) + median(3) + lowpass(10Hz,order2)``.
|
||||
|
||||
Written upstream-first, which is also the order the stages run and the order
|
||||
the two groups are listed in: the measurement stage first.
|
||||
"""
|
||||
measurement = dict(state.get("measurement") or {})
|
||||
readout = dict(state.get("readout") or {})
|
||||
parts: list[str] = []
|
||||
alpha = _as_float(measurement.get("flow_ema_alpha"), 1.0)
|
||||
if 0.0 < alpha < 1.0:
|
||||
parts.append(f"flowEMA({alpha:g})")
|
||||
window = _as_int(readout.get("force_median_window"), 1)
|
||||
if window > 1:
|
||||
parts.append(f"median({window})")
|
||||
cutoff = _as_float(readout.get("force_lowpass_cutoff_hz"), 0.0)
|
||||
if cutoff > 0:
|
||||
order = _as_int(readout.get("force_lowpass_order"), 2)
|
||||
parts.append(f"lowpass({cutoff:g}Hz,order{order})")
|
||||
return " + ".join(parts) if parts else "none"
|
||||
|
||||
|
||||
def settings_from_mapping(data: Optional[Mapping[str, Any]]) -> FilterSettings:
|
||||
"""Build settings from a preferences/metadata mapping, with defaults."""
|
||||
source = dict(data or {})
|
||||
base = FilterSettings()
|
||||
return FilterSettings(
|
||||
flow_ema_alpha=source.get("flow_ema_alpha", base.flow_ema_alpha),
|
||||
median_window=source.get("force_median_window", base.median_window),
|
||||
lowpass_hz=source.get("force_lowpass_cutoff_hz", base.lowpass_hz),
|
||||
lowpass_order=source.get("force_lowpass_order", base.lowpass_order),
|
||||
sample_rate_hz=source.get("force_lowpass_sample_rate_hz", base.sample_rate_hz),
|
||||
show_unfiltered=source.get("show_unfiltered", base.show_unfiltered),
|
||||
preset=source.get("preset", base.preset),
|
||||
).normalised()
|
||||
|
||||
|
||||
def effective_filters(sensor: object) -> dict[str, Any]:
|
||||
"""What a sensor reports as in effect, for a record or for the dialog.
|
||||
|
||||
``{}`` means the question could not be asked — an SDK older than 0.5.2 has no
|
||||
``filters_state()`` — which is different from "nothing is filtering" and has
|
||||
to stay different in the record.
|
||||
"""
|
||||
reader = getattr(sensor, "filters_state", None)
|
||||
if not callable(reader):
|
||||
return {}
|
||||
try:
|
||||
state = reader()
|
||||
except Exception: # noqa: BLE001 - metadata must never break a session
|
||||
logger.exception("Could not read the sensor's filter state")
|
||||
return {}
|
||||
return dict(state) if isinstance(state, Mapping) else {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reference implementation: kept so the tests can prove this app understands the
|
||||
# SDK's chain exactly. Nothing in the app calls it.
|
||||
# ---------------------------------------------------------------------------
|
||||
class ForceFilterChain:
|
||||
"""The median + low-pass chain the SDK runs, reimplemented for comparison.
|
||||
|
||||
Both stages are causal and per-sample, because acquisition cannot look
|
||||
ahead: the median removes single-sample spikes without smearing a step, and
|
||||
the low-pass is a Butterworth IIR with persistent state (``sosfilt`` with
|
||||
``zi``). Order is median then low-pass, so a spike cannot ring the IIR.
|
||||
"""
|
||||
|
||||
def __init__(self, settings: Optional[FilterSettings] = None) -> None:
|
||||
self._settings = (settings or FilterSettings()).normalised()
|
||||
self._median: list[list[float]] = [[] for _ in range(CHANNELS)]
|
||||
self._sos = None
|
||||
#: One IIR state per channel: the six readings are independent signals.
|
||||
self._zi: Optional[list[np.ndarray]] = None
|
||||
self._build()
|
||||
|
||||
# ------------------------------------------------------------------ setup
|
||||
def _build(self) -> None:
|
||||
self._sos = None
|
||||
self._zi = None
|
||||
settings = self._settings
|
||||
if not settings.lowpass_on:
|
||||
return
|
||||
from scipy.signal import butter
|
||||
|
||||
rate = _as_float(settings.sample_rate_hz, 30.0)
|
||||
nyquist = 0.5 * rate
|
||||
cutoff = min(float(settings.lowpass_hz), nyquist * 0.95)
|
||||
if cutoff <= 0:
|
||||
return
|
||||
self._sos = butter(
|
||||
settings.lowpass_order,
|
||||
cutoff / nyquist,
|
||||
btype="low",
|
||||
output="sos",
|
||||
)
|
||||
self._zi = None
|
||||
|
||||
def configure(self, settings: FilterSettings) -> None:
|
||||
"""Re-tune the chain; the caller resets the stream when it does."""
|
||||
self._settings = settings.normalised()
|
||||
self._median = [[] for _ in range(CHANNELS)]
|
||||
self._build()
|
||||
|
||||
def settings(self) -> FilterSettings:
|
||||
return self._settings
|
||||
|
||||
@property
|
||||
def active(self) -> bool:
|
||||
return self._settings.active()
|
||||
|
||||
def reset(self, first: Optional[Iterable[float]] = None) -> None:
|
||||
"""Drop the filter memory (a new session must not inherit the old one)."""
|
||||
self._median = [[] for _ in range(CHANNELS)]
|
||||
self._zi = None
|
||||
if first is not None and self._sos is not None:
|
||||
# Start each IIR at its first sample's steady state instead of at
|
||||
# zero: a step from 0 to a real reading otherwise rings for a few
|
||||
# samples, which would show up as a transient at session start.
|
||||
from scipy.signal import sosfilt_zi
|
||||
|
||||
values = np.asarray(list(first), dtype=np.float64).reshape(-1)
|
||||
if values.size == CHANNELS:
|
||||
steady = sosfilt_zi(self._sos)
|
||||
self._zi = [steady * float(value) for value in values]
|
||||
|
||||
# ----------------------------------------------------------------- filter
|
||||
def process(self, values) -> np.ndarray:
|
||||
"""Filter one 6-channel sample: three raw counts then three newtons."""
|
||||
sample = np.asarray(list(values), dtype=np.float64).reshape(-1)
|
||||
if sample.size != CHANNELS:
|
||||
raise ValueError(f"expected {CHANNELS} channels, got {sample.size}")
|
||||
if not self.active:
|
||||
return sample.copy()
|
||||
settings = self._settings
|
||||
out = sample.copy()
|
||||
if settings.median_on:
|
||||
window = int(settings.median_window)
|
||||
for index in range(CHANNELS):
|
||||
series = self._median[index]
|
||||
series.append(float(sample[index]))
|
||||
if len(series) > window:
|
||||
del series[0]
|
||||
out[index] = float(np.median(series))
|
||||
if self._sos is not None:
|
||||
from scipy.signal import sosfilt
|
||||
|
||||
if self._zi is None:
|
||||
self.reset(out)
|
||||
states = self._zi if self._zi is not None else [None] * CHANNELS
|
||||
for index in range(CHANNELS):
|
||||
filtered, states[index] = sosfilt(
|
||||
self._sos, np.array([out[index]]), zi=states[index]
|
||||
)
|
||||
out[index] = float(filtered[0])
|
||||
self._zi = states
|
||||
return out
|
||||
@@ -1,12 +1,20 @@
|
||||
"""Rolling force history with optional display low-pass."""
|
||||
"""Rolling force history with optional display low-pass.
|
||||
|
||||
Uses a time-windowed buffer (keep samples in ``[t_end - window_sec, t_end]``)
|
||||
with a headroom cap so high FPS still covers the visible X range after scroll.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
DEFAULT_MAX_FPS = 90.0
|
||||
HEADROOM = 1.5
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlotState:
|
||||
@@ -41,8 +49,8 @@ def lowpass_filter(
|
||||
return y.copy()
|
||||
|
||||
|
||||
def _npoints_for_window(window_sec: float, sample_rate_hz: float) -> int:
|
||||
return max(2, int(round(float(window_sec) * float(sample_rate_hz))))
|
||||
def _max_points_for_window(window_sec: float, max_fps: float) -> int:
|
||||
return max(2, int(math.ceil(float(window_sec) * float(max_fps) * HEADROOM)))
|
||||
|
||||
|
||||
class MetricHistory:
|
||||
@@ -55,35 +63,89 @@ class MetricHistory:
|
||||
sample_rate_hz: float | None = None,
|
||||
lowpass_cutoff_hz: float = 0.0,
|
||||
y_axis_min_span: float = 0.5,
|
||||
max_fps: float = DEFAULT_MAX_FPS,
|
||||
):
|
||||
self.channels = int(channels)
|
||||
self.window_sec = float(window_sec)
|
||||
self.max_fps = max(1.0, float(max_fps))
|
||||
self.sample_rate_hz = (
|
||||
float(sample_rate_hz)
|
||||
if sample_rate_hz is not None
|
||||
else (30.0 if self.window_sec <= 0 else 30.0)
|
||||
float(sample_rate_hz) if sample_rate_hz is not None else 30.0
|
||||
)
|
||||
# ``npoints`` overrides max capacity (tests / callers); else headroom cap.
|
||||
if npoints is None:
|
||||
self.npoints = _npoints_for_window(self.window_sec, self.sample_rate_hz)
|
||||
self.max_points = _max_points_for_window(self.window_sec, self.max_fps)
|
||||
else:
|
||||
self.npoints = int(npoints)
|
||||
self.max_points = max(2, int(npoints))
|
||||
# Back-compat alias used by older callers.
|
||||
self.npoints = self.max_points
|
||||
self.lowpass_cutoff_hz = float(lowpass_cutoff_hz)
|
||||
self.y_axis_min_span = float(y_axis_min_span)
|
||||
self._capacity = self.max_points
|
||||
self.reset()
|
||||
|
||||
def set_window_sec(self, window_sec: float) -> None:
|
||||
self.window_sec = max(0.1, float(window_sec))
|
||||
self.npoints = _npoints_for_window(self.window_sec, self.sample_rate_hz)
|
||||
self.reset()
|
||||
self.max_points = _max_points_for_window(self.window_sec, self.max_fps)
|
||||
self.npoints = self.max_points
|
||||
self._ensure_capacity(self.max_points)
|
||||
self._prune()
|
||||
self._y_range_smooth = [None for _ in range(self.channels)]
|
||||
|
||||
def reset(self) -> None:
|
||||
self._data = np.full((self.npoints, 1 + self.channels), np.nan, dtype=np.float64)
|
||||
self._capacity = max(self.max_points, 2)
|
||||
self._data = np.full(
|
||||
(self._capacity, 1 + self.channels), np.nan, dtype=np.float64
|
||||
)
|
||||
self.sample_count = 0
|
||||
self._t0: Optional[float] = None
|
||||
self._y_range_smooth: list[Optional[tuple[float, float]]] = [
|
||||
None for _ in range(self.channels)
|
||||
]
|
||||
|
||||
def clear(self, *, keep_origin: bool = True) -> None:
|
||||
"""Drop every sample.
|
||||
|
||||
Unlike :meth:`reset` this keeps the time origin by default, so a buffer
|
||||
rebuilt after a display-unit switch does not restart the X axis at zero.
|
||||
"""
|
||||
origin = self._t0 if keep_origin else None
|
||||
self.reset()
|
||||
self._t0 = origin
|
||||
|
||||
def _ensure_capacity(self, needed: int) -> None:
|
||||
needed = max(needed, self.max_points, 2)
|
||||
if needed <= self._capacity:
|
||||
return
|
||||
new_cap = max(needed, self._capacity * 2)
|
||||
new_data = np.full(
|
||||
(new_cap, 1 + self.channels), np.nan, dtype=np.float64
|
||||
)
|
||||
if self.sample_count > 0:
|
||||
new_data[: self.sample_count] = self._data[: self.sample_count]
|
||||
self._data = new_data
|
||||
self._capacity = new_cap
|
||||
|
||||
def _prune(self) -> None:
|
||||
"""Drop samples older than the time window, then enforce max_points."""
|
||||
n = self.sample_count
|
||||
if n == 0:
|
||||
return
|
||||
t_end = float(self._data[n - 1, 0])
|
||||
cutoff = t_end - self.window_sec
|
||||
times = self._data[:n, 0]
|
||||
# First index with t >= cutoff
|
||||
keep_from = int(np.searchsorted(times, cutoff, side="left"))
|
||||
if keep_from > 0:
|
||||
remaining = n - keep_from
|
||||
self._data[:remaining] = self._data[keep_from:n]
|
||||
self.sample_count = remaining
|
||||
n = remaining
|
||||
if n > self.max_points:
|
||||
drop = n - self.max_points
|
||||
remaining = n - drop
|
||||
self._data[:remaining] = self._data[drop:n]
|
||||
self.sample_count = remaining
|
||||
|
||||
def push(self, timestamp: float, *values: float) -> None:
|
||||
if len(values) != self.channels:
|
||||
raise ValueError(f"Expected {self.channels} values, got {len(values)}")
|
||||
@@ -91,12 +153,11 @@ class MetricHistory:
|
||||
self._t0 = float(timestamp)
|
||||
t = float(timestamp) - self._t0
|
||||
row = (t, *map(float, values))
|
||||
if self.sample_count < self.npoints:
|
||||
if self.sample_count >= self._capacity:
|
||||
self._ensure_capacity(self.sample_count + 1)
|
||||
self._data[self.sample_count, :] = row
|
||||
self.sample_count += 1
|
||||
return
|
||||
self._data[:-1, :] = self._data[1:, :]
|
||||
self._data[-1, :] = row
|
||||
self._prune()
|
||||
|
||||
def _smooth_y_range(self, channel: int, ymin: float, ymax: float) -> tuple[float, float]:
|
||||
if self._y_range_smooth[channel] is None:
|
||||
@@ -122,6 +183,24 @@ class MetricHistory:
|
||||
block = self._data[:n]
|
||||
times = block[:, 0].copy()
|
||||
values = block[:, 1:].copy()
|
||||
t_end = float(times[-1])
|
||||
if t_end >= self.window_sec:
|
||||
x_range = (t_end - self.window_sec, t_end)
|
||||
# Keep setData lean: only points inside the visible window.
|
||||
mask = times >= x_range[0]
|
||||
times = times[mask]
|
||||
values = values[mask]
|
||||
# Discrete samples usually start slightly after the left edge;
|
||||
# prepend a hold point so the curve spans the full X range.
|
||||
if times.size > 0 and float(times[0]) > x_range[0]:
|
||||
times = np.concatenate(([x_range[0]], times))
|
||||
values = np.vstack([values[0:1], values])
|
||||
else:
|
||||
x_range = (0.0, self.window_sec)
|
||||
|
||||
if times.size == 0:
|
||||
return None
|
||||
|
||||
if self.lowpass_cutoff_hz > 0:
|
||||
for i in range(self.channels):
|
||||
values[:, i] = lowpass_filter(
|
||||
@@ -129,11 +208,6 @@ class MetricHistory:
|
||||
self.lowpass_cutoff_hz,
|
||||
sample_rate_hz=self.sample_rate_hz,
|
||||
)
|
||||
t_end = float(times[-1])
|
||||
if t_end >= self.window_sec:
|
||||
x_range = (t_end - self.window_sec, t_end)
|
||||
else:
|
||||
x_range = (0.0, self.window_sec)
|
||||
y_ranges = []
|
||||
for i in range(self.channels):
|
||||
col = values[:, i]
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"""The calibration data shapes, independent of sampling and of file storage.
|
||||
|
||||
They live here so the sampling state machine and the session file format can both
|
||||
describe a calibration point without importing each other. ``calibration_recorder``
|
||||
and ``calibration_session`` re-export these names for compatibility.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
|
||||
#: Stands in for "this value is not available".
|
||||
NA = float("nan")
|
||||
|
||||
ChannelKey = Literal["fn", "fx", "fy"]
|
||||
CHANNEL_KEYS: tuple[ChannelKey, ...] = ("fn", "fx", "fy")
|
||||
|
||||
STATUS_OK = "ok"
|
||||
STATUS_FEW_SAMPLES = "few_samples"
|
||||
STATUS_CANCELLED = "cancelled"
|
||||
|
||||
#: Columns of one audit sample row, in order. A calibration record is only
|
||||
#: auditable if this layout is fixed: it is what a reopened point is rebuilt from.
|
||||
SAMPLE_FIELDS = (
|
||||
"frame_id",
|
||||
"t_rel_s",
|
||||
"fn_raw",
|
||||
"fx_raw",
|
||||
"fy_raw",
|
||||
"fn_n",
|
||||
"fx_n",
|
||||
"fy_n",
|
||||
"valid",
|
||||
)
|
||||
|
||||
#: Index of each sample column, so callers do not hard-code positions.
|
||||
SAMPLE_INDEX = {name: index for index, name in enumerate(SAMPLE_FIELDS)}
|
||||
|
||||
#: Cap on stored trace samples per axis; peaks are preserved when reducing.
|
||||
MAX_TRACE_POINTS = 400
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AxisStats:
|
||||
"""Mean / standard deviation / valid count for one axis."""
|
||||
|
||||
mean: float = NA
|
||||
std: float = NA
|
||||
n: int = 0
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {"mean": self.mean, "std": self.std, "n": self.n}
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalibrationPoint:
|
||||
"""One recorded calibration point, with all three axes captured."""
|
||||
|
||||
channel: ChannelKey
|
||||
reference_load_n: float
|
||||
direction: str = "load"
|
||||
round_index: int = 0
|
||||
note: str = ""
|
||||
started_at: str = ""
|
||||
ended_at: str = ""
|
||||
duration_s: float = 0.0
|
||||
requested_duration_s: float = 0.0
|
||||
n_total: int = 0
|
||||
n_valid: int = 0
|
||||
raw: dict[str, AxisStats] = field(default_factory=dict)
|
||||
newtons: dict[str, AxisStats] = field(default_factory=dict)
|
||||
status: str = STATUS_OK
|
||||
#: Downsampled per-axis raw trace, so a row can be inspected after the fact.
|
||||
trace: dict[str, list[float]] = field(default_factory=dict)
|
||||
trace_dt_s: float = 0.0
|
||||
#: **Audit** series: every sample of the point, in :data:`SAMPLE_FIELDS` order.
|
||||
#: The trace above is a preview and must never be the only surviving copy.
|
||||
samples: list[tuple] = field(default_factory=list)
|
||||
#: Diagnostics, all operator-controlled. Excluded points leave the fit;
|
||||
#: validation points are held out of it and used to test it.
|
||||
excluded: bool = False
|
||||
is_validation: bool = False
|
||||
#: Filled in by :meth:`CalibrationRecorder.fit`.
|
||||
residual_n: float = NA
|
||||
|
||||
def loaded_stats(self) -> AxisStats:
|
||||
return self.raw.get(self.channel, AxisStats())
|
||||
|
||||
def axis_mean(self, channel: str) -> float:
|
||||
stats = self.raw.get(channel)
|
||||
return stats.mean if stats is not None else NA
|
||||
|
||||
def state_key(self) -> str:
|
||||
if self.excluded:
|
||||
return "cal.state_excluded"
|
||||
if self.is_validation:
|
||||
return "cal.state_validation"
|
||||
return "cal.state_ok"
|
||||
|
||||
def as_dict(self) -> dict:
|
||||
return {
|
||||
"channel": self.channel,
|
||||
"reference_load_n": self.reference_load_n,
|
||||
# Only the loaded axis has a known reference load; the other two are
|
||||
# intentionally absent rather than zero.
|
||||
"direction": self.direction,
|
||||
"round_index": self.round_index,
|
||||
"note": self.note,
|
||||
"started_at": self.started_at,
|
||||
"ended_at": self.ended_at,
|
||||
"duration_s": self.duration_s,
|
||||
"requested_duration_s": self.requested_duration_s,
|
||||
"n_total": self.n_total,
|
||||
"n_valid": self.n_valid,
|
||||
"status": self.status,
|
||||
"excluded": self.excluded,
|
||||
"is_validation": self.is_validation,
|
||||
"residual_n": self.residual_n,
|
||||
"raw": {key: stats.as_dict() for key, stats in self.raw.items()},
|
||||
"newtons": {key: stats.as_dict() for key, stats in self.newtons.items()},
|
||||
# The audit series is part of the point's identity, not a UI extra:
|
||||
# without it the only surviving copy of the samples is the
|
||||
# downsampled trace.
|
||||
"sample_fields": list(SAMPLE_FIELDS),
|
||||
"n_samples": len(self.samples),
|
||||
"samples": [list(row) for row in self.samples],
|
||||
}
|
||||
|
||||
|
||||
def trace_from(valid_block: np.ndarray) -> dict[str, list[float]]:
|
||||
"""Downsampled raw trace per axis, bounded by ``MAX_TRACE_POINTS``.
|
||||
|
||||
One downsampling rule, whether the point was just recorded or read back from
|
||||
a saved session.
|
||||
"""
|
||||
if valid_block.size == 0:
|
||||
return {}
|
||||
traces: dict[str, list[float]] = {}
|
||||
for key, column in (("fn", 2), ("fx", 3), ("fy", 4)):
|
||||
values = valid_block[:, column]
|
||||
values = values[np.isfinite(values)]
|
||||
if values.size == 0:
|
||||
continue
|
||||
if values.size > MAX_TRACE_POINTS:
|
||||
# Keep the shape of the trace, including its extremes.
|
||||
edges = np.linspace(0, values.size, MAX_TRACE_POINTS + 1, dtype=int)
|
||||
reduced = [
|
||||
values[start:max(start + 1, stop)]
|
||||
for start, stop in zip(edges[:-1], edges[1:])
|
||||
]
|
||||
values = np.asarray(
|
||||
[
|
||||
bucket[int(np.argmax(np.abs(bucket)))]
|
||||
for bucket in reduced
|
||||
if bucket.size
|
||||
]
|
||||
)
|
||||
traces[key] = [float(value) for value in values]
|
||||
return traces
|
||||
|
||||
|
||||
def trace_dt(valid_block: np.ndarray) -> float:
|
||||
"""The sample interval of a trace, from the block it was taken from."""
|
||||
if valid_block.shape[0] < 2:
|
||||
return 0.0
|
||||
span = float(valid_block[-1, 1] - valid_block[0, 1])
|
||||
return span / max(1, valid_block.shape[0] - 1)
|
||||
@@ -0,0 +1,513 @@
|
||||
"""Full-session force sample store.
|
||||
|
||||
The visible plot buffer (:class:`~orisys_app_shared.force.history.MetricHistory`)
|
||||
deliberately drops every sample outside its time window, so it can never be the
|
||||
source of truth for an export, a calibration point or an audit trail.
|
||||
|
||||
This store keeps the whole session. It is fed from the acquisition thread via
|
||||
the worker's sink list, so what gets recorded does not depend on the UI refresh
|
||||
rate, on which panel happens to be visible, or on how fast the plots redraw.
|
||||
|
||||
Design notes
|
||||
------------
|
||||
* Samples are appended into fixed-size numpy blocks, so ``append`` is O(1) and
|
||||
memory stays bounded. When ``max_samples`` is reached the oldest block is
|
||||
released and ``dropped`` is incremented — callers must surface that counter
|
||||
rather than let data vanish silently.
|
||||
* Both the raw and the calibrated triplet are stored per sample, so switching
|
||||
the display unit never has to reinterpret old data.
|
||||
* Invalid samples (a failed readout) are stored with ``valid=False`` and NaN
|
||||
values. They are never silently rewritten to zero, and consumers that build
|
||||
calibration points or fits must filter on ``valid``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Iterable, Iterator, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from orisys_app_shared.record.sample import plottable_axis_mask
|
||||
|
||||
#: Stands in for "this value is not available / not valid".
|
||||
NA = float("nan")
|
||||
|
||||
DEFAULT_BLOCK_SIZE = 8192
|
||||
#: ~4.5 h at 30 Hz of three raw + three calibrated float64 channels.
|
||||
DEFAULT_MAX_SAMPLES = 500_000
|
||||
|
||||
# Column layout of the packed store.
|
||||
_COL_FRAME_ID = 0
|
||||
_COL_T = 1
|
||||
_COL_FN_RAW = 2
|
||||
_COL_FX_RAW = 3
|
||||
_COL_FY_RAW = 4
|
||||
_COL_FN_N = 5
|
||||
_COL_FX_N = 6
|
||||
_COL_FY_N = 7
|
||||
_COL_VALID = 8
|
||||
_COLUMNS = 9
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionSample:
|
||||
"""One acquisition step, as handed to the record channel."""
|
||||
|
||||
frame_id: int
|
||||
t_rel_s: float
|
||||
fn_raw: float = NA
|
||||
fx_raw: float = NA
|
||||
fy_raw: float = NA
|
||||
fn_n: float = NA
|
||||
fx_n: float = NA
|
||||
fy_n: float = NA
|
||||
valid: bool = True
|
||||
calibration_id: str = ""
|
||||
error: str = ""
|
||||
#: Axes that actually have a calibration curve. An axis outside this set has
|
||||
#: no newton value, which the UI shows as a dash rather than as a number.
|
||||
calibrated_axes: tuple[str, ...] = ()
|
||||
|
||||
@classmethod
|
||||
def from_record(cls, record) -> "SessionSample":
|
||||
"""Build from the canonical :class:`RecordSample`.
|
||||
|
||||
The record channel has one conversion from a packet (see
|
||||
``record.sample.to_record_sample``); this only renames the fields so the
|
||||
store keeps its own shape.
|
||||
"""
|
||||
return cls(
|
||||
frame_id=int(record.frame_id),
|
||||
t_rel_s=float(record.mono_t),
|
||||
fn_raw=float(record.fn_raw),
|
||||
fx_raw=float(record.fx_raw),
|
||||
fy_raw=float(record.fy_raw),
|
||||
fn_n=float(record.fn_n),
|
||||
fx_n=float(record.fx_n),
|
||||
fy_n=float(record.fy_n),
|
||||
valid=bool(record.valid),
|
||||
calibration_id=str(record.calibration_version or ""),
|
||||
error=str(record.invalid_reason or ""),
|
||||
calibrated_axes=tuple(record.calibrated_axes or ()),
|
||||
)
|
||||
|
||||
def raw_triplet(self) -> tuple[float, float, float]:
|
||||
return (self.fn_raw, self.fx_raw, self.fy_raw)
|
||||
|
||||
def newton_triplet(self) -> tuple[float, float, float]:
|
||||
return (self.fn_n, self.fx_n, self.fy_n)
|
||||
|
||||
def has_newton(self) -> bool:
|
||||
return all(np.isfinite(v) for v in self.newton_triplet())
|
||||
|
||||
def has_raw(self) -> bool:
|
||||
return all(np.isfinite(v) for v in self.raw_triplet())
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionSlice:
|
||||
"""A copy of a time range, ready for plotting or analysis."""
|
||||
|
||||
times: np.ndarray
|
||||
raw: np.ndarray
|
||||
newtons: np.ndarray
|
||||
valid: np.ndarray
|
||||
frame_ids: np.ndarray
|
||||
truncated: bool = False
|
||||
|
||||
def __len__(self) -> int:
|
||||
return int(self.times.size)
|
||||
|
||||
def value_for_unit(self, unit: str) -> np.ndarray:
|
||||
"""Return the ``(n, 3)`` block matching a display unit."""
|
||||
if str(unit).upper().startswith("N"):
|
||||
return self.newtons
|
||||
return self.raw
|
||||
|
||||
def plottable(self, unit: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""``(times, values, mask)`` for drawing, under one shared rule.
|
||||
|
||||
``mask`` is per axis ``(n, 3)``: a slice is drawn only where the sample is
|
||||
**valid** *and that axis* is finite. Filtering on finiteness alone let a
|
||||
stale number — left behind by a failed compute step — reappear in one view
|
||||
after another view had already excluded it, and an all-axes rule hid a
|
||||
valid FN whenever the device had no shear calibration.
|
||||
"""
|
||||
values = self.value_for_unit(unit)
|
||||
return self.times, values, plottable_axis_mask(values, self.valid)
|
||||
|
||||
def plottable_axis(self, unit: str, index: int) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""``(times, values)`` for one channel, with the other axes ignored."""
|
||||
values = self.value_for_unit(unit)
|
||||
mask = plottable_axis_mask(values, self.valid)[:, index]
|
||||
return self.times[mask], values[mask, index]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SelectionStats:
|
||||
"""What the browser shows for the current selection."""
|
||||
|
||||
duration_s: float = 0.0
|
||||
samples: int = 0
|
||||
peaks: tuple[float, float, float] = (NA, NA, NA)
|
||||
means: tuple[float, float, float] = (NA, NA, NA)
|
||||
stds: tuple[float, float, float] = (NA, NA, NA)
|
||||
|
||||
|
||||
def _decimate_preserving_peaks(rows: np.ndarray, max_points: int, unit: str = "raw") -> np.ndarray:
|
||||
"""Reduce ``rows`` to about ``max_points`` while keeping every spike.
|
||||
|
||||
Each bucket contributes the extreme sample of **every plotted axis** in the
|
||||
active unit, deduplicated and put back in time order. Selecting on one axis
|
||||
only (the old behaviour) erased a shear spike completely: with FN flat and a
|
||||
single FX point at 999, the decimated data reported an FX maximum of 0.
|
||||
"""
|
||||
total = rows.shape[0]
|
||||
if max_points < 4 or total <= max_points:
|
||||
return rows
|
||||
columns = (
|
||||
(_COL_FN_N, _COL_FX_N, _COL_FY_N)
|
||||
if str(unit).upper().startswith("N")
|
||||
else (_COL_FN_RAW, _COL_FX_RAW, _COL_FY_RAW)
|
||||
)
|
||||
bucket = max(2, total // max(2, max_points // (2 * len(columns))))
|
||||
usable = (total // bucket) * bucket
|
||||
head = rows[:usable].reshape(-1, bucket, rows.shape[1])
|
||||
tail = rows[usable:]
|
||||
buckets = head.shape[0]
|
||||
index = np.arange(buckets)
|
||||
|
||||
picks: list[np.ndarray] = []
|
||||
for column in columns:
|
||||
values = head[:, :, column]
|
||||
finite = np.isfinite(values)
|
||||
ordered = np.where(finite, values, -np.inf)
|
||||
low = np.argmin(ordered, axis=1)
|
||||
high = np.argmax(ordered, axis=1)
|
||||
picks.append(head[index, np.minimum(low, high)])
|
||||
picks.append(head[index, np.maximum(low, high)])
|
||||
picked = np.concatenate(picks, axis=0)
|
||||
|
||||
# De-duplicate (the same row is often the extreme for several axes) and
|
||||
# restore time order. Sorting the *rows* by frame id is what restores the
|
||||
# order; sorting the indices of a concatenation of per-axis picks does not,
|
||||
# because the picks restart from the first bucket for every axis.
|
||||
picked = picked[np.argsort(picked[:, _COL_FRAME_ID], kind="stable")]
|
||||
keep = np.ones(picked.shape[0], dtype=bool)
|
||||
if picked.shape[0] > 1:
|
||||
keep[1:] = np.diff(picked[:, _COL_FRAME_ID]) != 0
|
||||
picked = picked[keep]
|
||||
if tail.shape[0]:
|
||||
picked = np.vstack([picked, tail])
|
||||
return picked
|
||||
|
||||
|
||||
class SessionStore:
|
||||
"""Thread-safe, memory-bounded, whole-session sample store."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
max_samples: int = DEFAULT_MAX_SAMPLES,
|
||||
block_size: int = DEFAULT_BLOCK_SIZE,
|
||||
) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self.max_samples = max(2, int(max_samples))
|
||||
self.block_size = max(16, int(block_size))
|
||||
self._blocks: list[np.ndarray] = []
|
||||
self._count = 0
|
||||
self._dropped = 0
|
||||
self._session_id = 0
|
||||
self._calibration_id = ""
|
||||
self._t0: Optional[float] = None
|
||||
self._last_t: Optional[float] = None
|
||||
self._invalid = 0
|
||||
|
||||
# ------------------------------------------------------------- lifecycle
|
||||
def reset(self, *, session_id: int = 0, calibration_id: str = "") -> None:
|
||||
with self._lock:
|
||||
self._blocks = []
|
||||
self._count = 0
|
||||
self._dropped = 0
|
||||
self._invalid = 0
|
||||
self._session_id = int(session_id)
|
||||
self._calibration_id = str(calibration_id or "")
|
||||
self._t0 = None
|
||||
self._last_t = None
|
||||
|
||||
def set_calibration_id(self, calibration_id: str) -> None:
|
||||
with self._lock:
|
||||
self._calibration_id = str(calibration_id or "")
|
||||
|
||||
# ---------------------------------------------------------------- writing
|
||||
def append(self, sample: SessionSample) -> None:
|
||||
"""Record one sample. Never raises on bad values — it marks them invalid."""
|
||||
with self._lock:
|
||||
if self._t0 is None:
|
||||
self._t0 = float(sample.t_rel_s)
|
||||
t = float(sample.t_rel_s) - self._t0
|
||||
valid = 1.0 if sample.valid else 0.0
|
||||
if not sample.valid:
|
||||
self._invalid += 1
|
||||
row = (
|
||||
int(sample.frame_id),
|
||||
t,
|
||||
float(sample.fn_raw),
|
||||
float(sample.fx_raw),
|
||||
float(sample.fy_raw),
|
||||
float(sample.fn_n),
|
||||
float(sample.fx_n),
|
||||
float(sample.fy_n),
|
||||
valid,
|
||||
)
|
||||
self._write_row(row)
|
||||
self._last_t = float(sample.t_rel_s)
|
||||
|
||||
def _write_row(self, row: tuple) -> None:
|
||||
if self._count % self.block_size == 0:
|
||||
self._blocks.append(
|
||||
np.full((self.block_size, _COLUMNS), np.nan, dtype=np.float64)
|
||||
)
|
||||
block = self._blocks[-1]
|
||||
offset = self._count % self.block_size
|
||||
block[offset, :] = row
|
||||
self._count += 1
|
||||
if self._count > self.max_samples:
|
||||
self._release_oldest()
|
||||
|
||||
def _release_oldest(self) -> None:
|
||||
"""Drop whole blocks from the front so memory stays bounded.
|
||||
|
||||
The block currently being written is never released, so the most recent
|
||||
``block_size`` samples always survive.
|
||||
"""
|
||||
while self._count > self.max_samples and len(self._blocks) > 1:
|
||||
self._blocks.pop(0)
|
||||
self._count -= self.block_size
|
||||
self._dropped += self.block_size
|
||||
if self._count < 0:
|
||||
self._count = 0
|
||||
|
||||
# ---------------------------------------------------------------- reading
|
||||
@property
|
||||
def count(self) -> int:
|
||||
with self._lock:
|
||||
return self._count
|
||||
|
||||
@property
|
||||
def dropped(self) -> int:
|
||||
"""Samples evicted by the memory bound — must be surfaced, never hidden."""
|
||||
with self._lock:
|
||||
return self._dropped
|
||||
|
||||
@property
|
||||
def invalid(self) -> int:
|
||||
with self._lock:
|
||||
return self._invalid
|
||||
|
||||
@property
|
||||
def session_id(self) -> int:
|
||||
with self._lock:
|
||||
return self._session_id
|
||||
|
||||
@property
|
||||
def calibration_id(self) -> str:
|
||||
with self._lock:
|
||||
return self._calibration_id
|
||||
|
||||
@property
|
||||
def duration_s(self) -> float:
|
||||
with self._lock:
|
||||
if self._last_t is None or self._t0 is None:
|
||||
return 0.0
|
||||
return float(self._last_t - self._t0)
|
||||
|
||||
def origin_time(self) -> float:
|
||||
"""Absolute acquisition timestamp that ``t_rel_s == 0`` refers to."""
|
||||
with self._lock:
|
||||
return float(self._t0) if self._t0 is not None else 0.0
|
||||
|
||||
def _packed(self) -> np.ndarray:
|
||||
if self._count == 0:
|
||||
return np.empty((0, _COLUMNS), dtype=np.float64)
|
||||
blocks: list[np.ndarray] = []
|
||||
remaining = self._count
|
||||
for block in self._blocks:
|
||||
take = min(remaining, block.shape[0])
|
||||
blocks.append(block[:take])
|
||||
remaining -= take
|
||||
if remaining <= 0:
|
||||
break
|
||||
return np.vstack(blocks) if len(blocks) > 1 else blocks[0]
|
||||
|
||||
def data(self) -> SessionSlice:
|
||||
"""The whole stored session (used by export)."""
|
||||
with self._lock:
|
||||
return self._slice_from(self._packed())
|
||||
|
||||
def tail(self, seconds: float) -> SessionSlice:
|
||||
"""The last ``seconds`` of the session."""
|
||||
with self._lock:
|
||||
packed = self._packed()
|
||||
if packed.shape[0] == 0:
|
||||
return self._slice_from(packed)
|
||||
t_end = float(packed[-1, _COL_T])
|
||||
mask = packed[:, _COL_T] >= (t_end - max(0.0, float(seconds)))
|
||||
return self._slice_from(packed[mask])
|
||||
|
||||
def window(self, t0: float, t1: float) -> SessionSlice:
|
||||
with self._lock:
|
||||
packed = self._packed()
|
||||
if packed.shape[0] == 0:
|
||||
return self._slice_from(packed)
|
||||
mask = (packed[:, _COL_T] >= float(t0)) & (packed[:, _COL_T] <= float(t1))
|
||||
return self._slice_from(packed[mask])
|
||||
|
||||
# -------------------------------------------------------------- browsing
|
||||
def extent(self) -> tuple[float, float]:
|
||||
"""``(first_t, last_t)`` of the stored session; ``(0, 0)`` when empty."""
|
||||
with self._lock:
|
||||
if self._count == 0 or self._t0 is None or self._last_t is None:
|
||||
return (0.0, 0.0)
|
||||
first = self._blocks[0][0, _COL_T] if self._blocks else 0.0
|
||||
return (float(first), float(self._last_t - self._t0))
|
||||
|
||||
def _block_span(self, block: np.ndarray, rows: int) -> tuple[float, float]:
|
||||
return (float(block[0, _COL_T]), float(block[rows - 1, _COL_T]))
|
||||
|
||||
def visible(
|
||||
self, t0: float, t1: float, max_points: int = 2000, *, unit: str = "raw"
|
||||
) -> SessionSlice:
|
||||
"""Decimated slice for drawing, keeping peaks on every plotted axis.
|
||||
|
||||
Only the blocks that overlap ``[t0, t1]`` are touched, so browsing a
|
||||
long session does not copy the whole thing, and the decimation keeps the
|
||||
extremes of all three axes in ``unit`` so no channel's spike is smoothed
|
||||
away.
|
||||
"""
|
||||
t0 = float(t0)
|
||||
t1 = float(t1)
|
||||
if t1 < t0:
|
||||
t0, t1 = t1, t0
|
||||
with self._lock:
|
||||
rows = self._packed_window(t0, t1)
|
||||
truncated = self._dropped > 0
|
||||
if max_points and rows.shape[0] > max_points:
|
||||
rows = _decimate_preserving_peaks(rows, max_points, unit)
|
||||
return replace(self._slice_from(rows), truncated=truncated)
|
||||
|
||||
def _packed_window(self, t0: float, t1: float) -> np.ndarray:
|
||||
"""Concatenate only the rows inside the window. Caller holds the lock."""
|
||||
if self._count == 0:
|
||||
return np.empty((0, _COLUMNS), dtype=np.float64)
|
||||
selected: list[np.ndarray] = []
|
||||
remaining = self._count
|
||||
for block in self._blocks:
|
||||
rows = min(remaining, block.shape[0])
|
||||
remaining -= rows
|
||||
if rows <= 0:
|
||||
break
|
||||
span = self._block_span(block, rows)
|
||||
if span[1] < t0 or span[0] > t1:
|
||||
continue # blocks are time-ordered: skip whole blocks
|
||||
times = block[:rows, _COL_T]
|
||||
start = int(np.searchsorted(times, t0, side="left"))
|
||||
stop = int(np.searchsorted(times, t1, side="right"))
|
||||
if stop > start:
|
||||
selected.append(block[start:stop])
|
||||
if not selected:
|
||||
return np.empty((0, _COLUMNS), dtype=np.float64)
|
||||
return np.vstack(selected) if len(selected) > 1 else selected[0]
|
||||
|
||||
def statistics(self, t0: float, t1: float, unit: str = "raw") -> "SelectionStats":
|
||||
"""Duration, peak, mean and std over a selection, per channel."""
|
||||
t0 = float(t0)
|
||||
t1 = float(t1)
|
||||
if t1 < t0:
|
||||
t0, t1 = t1, t0
|
||||
with self._lock:
|
||||
rows = self._packed_window(t0, t1)
|
||||
if rows.shape[0] == 0:
|
||||
return SelectionStats(duration_s=0.0, samples=0)
|
||||
mask = rows[:, _COL_VALID] > 0.5
|
||||
valid_rows = rows[mask]
|
||||
times = rows[:, _COL_T]
|
||||
duration = float(times[-1] - times[0]) if times.size else 0.0
|
||||
if valid_rows.shape[0] == 0:
|
||||
return SelectionStats(duration_s=duration, samples=0)
|
||||
columns = (
|
||||
(_COL_FN_N, _COL_FX_N, _COL_FY_N)
|
||||
if str(unit).upper().startswith("N")
|
||||
else (_COL_FN_RAW, _COL_FX_RAW, _COL_FY_RAW)
|
||||
)
|
||||
peaks: list[float] = []
|
||||
means: list[float] = []
|
||||
stds: list[float] = []
|
||||
for column in columns:
|
||||
values = valid_rows[:, column]
|
||||
values = values[np.isfinite(values)]
|
||||
if values.size == 0:
|
||||
peaks.append(NA)
|
||||
means.append(NA)
|
||||
stds.append(NA)
|
||||
continue
|
||||
peaks.append(float(values[np.argmax(np.abs(values))]))
|
||||
means.append(float(values.mean()))
|
||||
stds.append(float(values.std()))
|
||||
return SelectionStats(
|
||||
duration_s=duration,
|
||||
samples=int(valid_rows.shape[0]),
|
||||
peaks=tuple(peaks),
|
||||
means=tuple(means),
|
||||
stds=tuple(stds),
|
||||
)
|
||||
|
||||
def _slice_from(self, packed: np.ndarray) -> SessionSlice:
|
||||
if packed.shape[0] == 0:
|
||||
empty = np.empty(0, dtype=np.float64)
|
||||
return SessionSlice(
|
||||
times=empty,
|
||||
raw=np.empty((0, 3)),
|
||||
newtons=np.empty((0, 3)),
|
||||
valid=np.empty(0, dtype=bool),
|
||||
frame_ids=np.empty(0, dtype=np.int64),
|
||||
)
|
||||
return SessionSlice(
|
||||
times=packed[:, _COL_T].copy(),
|
||||
raw=packed[:, [_COL_FN_RAW, _COL_FX_RAW, _COL_FY_RAW]].copy(),
|
||||
newtons=packed[:, [_COL_FN_N, _COL_FX_N, _COL_FY_N]].copy(),
|
||||
valid=packed[:, _COL_VALID] > 0.5,
|
||||
frame_ids=packed[:, _COL_FRAME_ID].astype(np.int64, copy=True),
|
||||
truncated=self._dropped > 0,
|
||||
)
|
||||
|
||||
def iter_valid(self, unit: str = "raw") -> Iterator[tuple[float, float, float, float]]:
|
||||
"""Yield ``(t_rel_s, v0, v1, v2)`` for valid samples only.
|
||||
|
||||
Calibration fitting uses this so a failed readout can never be averaged
|
||||
in as a zero.
|
||||
"""
|
||||
slice_ = self.data()
|
||||
values = slice_.value_for_unit(unit)
|
||||
for index in range(len(slice_)):
|
||||
if not bool(slice_.valid[index]):
|
||||
continue
|
||||
row = values[index]
|
||||
if not all(np.isfinite(v) for v in row):
|
||||
continue
|
||||
yield (
|
||||
float(slice_.times[index]),
|
||||
float(row[0]),
|
||||
float(row[1]),
|
||||
float(row[2]),
|
||||
)
|
||||
|
||||
|
||||
def summarise(values: Iterable[float]) -> tuple[float, float, int]:
|
||||
"""Return ``(mean, std, n)`` over the finite values, NaN when empty."""
|
||||
block = np.asarray([v for v in values if np.isfinite(v)], dtype=np.float64)
|
||||
if block.size == 0:
|
||||
return (NA, NA, 0)
|
||||
return (float(block.mean()), float(block.std()), int(block.size))
|
||||
@@ -0,0 +1,228 @@
|
||||
"""One interface for reading curve data, whatever it is stored in.
|
||||
|
||||
The live charts read a bounded in-memory session store; the replay reads a
|
||||
recording on disk. Both have to answer the same questions — what is the extent,
|
||||
what is in this interval, what does the whole session look like as an envelope —
|
||||
so the plotting code asks a *source* instead of knowing which one it has.
|
||||
|
||||
A source never hands over more than the caller's point budget, and it always
|
||||
reports what it had to do to fit: ``decimated`` means the values are a view, not
|
||||
every sample.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
import numpy as np
|
||||
|
||||
from orisys_app_shared.force.session import NA, SessionSlice
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceStats:
|
||||
"""What the selection readout shows, for any source."""
|
||||
|
||||
duration_s: float = 0.0
|
||||
samples: int = 0
|
||||
peaks: tuple[float, float, float] = (NA, NA, NA)
|
||||
means: tuple[float, float, float] = (NA, NA, NA)
|
||||
stds: tuple[float, float, float] = (NA, NA, NA)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SourceWindow:
|
||||
"""Rows for one interval, in the shape the charts already consume."""
|
||||
|
||||
times: np.ndarray
|
||||
values: np.ndarray
|
||||
valid: np.ndarray
|
||||
frame_ids: np.ndarray
|
||||
unit: str = "raw"
|
||||
decimated: bool = False
|
||||
|
||||
def __len__(self) -> int:
|
||||
return int(self.times.size)
|
||||
|
||||
def plottable_axis(self, index: int):
|
||||
from orisys_app_shared.record.sample import plottable_axis_mask
|
||||
|
||||
mask = plottable_axis_mask(self.values, self.valid)[:, index]
|
||||
return self.times[mask], self.values[mask, index]
|
||||
|
||||
def statistics(self) -> SourceStats:
|
||||
mask = np.isfinite(self.values) & self.valid[:, None]
|
||||
peaks: list[float] = []
|
||||
means: list[float] = []
|
||||
stds: list[float] = []
|
||||
for index in range(3):
|
||||
column = self.values[:, index][mask[:, index]]
|
||||
if column.size == 0:
|
||||
peaks.append(NA)
|
||||
means.append(NA)
|
||||
stds.append(NA)
|
||||
continue
|
||||
peaks.append(float(column[np.argmax(np.abs(column))]))
|
||||
means.append(float(np.nanmean(column)))
|
||||
stds.append(float(np.nanstd(column)))
|
||||
span = float(self.times[-1] - self.times[0]) if self.times.size > 1 else 0.0
|
||||
return SourceStats(
|
||||
duration_s=span,
|
||||
samples=int(mask.any(axis=1).sum()),
|
||||
peaks=tuple(peaks),
|
||||
means=tuple(means),
|
||||
stds=tuple(stds),
|
||||
)
|
||||
|
||||
|
||||
class CurveSource(Protocol):
|
||||
"""What the charts need from wherever the data lives."""
|
||||
|
||||
kind: str
|
||||
|
||||
def extent(self) -> tuple[float, float]: ...
|
||||
|
||||
def window(self, t0: float, t1: float, *, max_points: int | None = None) -> SourceWindow: ...
|
||||
|
||||
def envelope(self, *, buckets: int = 1200) -> SourceWindow: ...
|
||||
|
||||
def duration_s(self) -> float: ...
|
||||
|
||||
|
||||
class StoreCurveSource:
|
||||
"""The live session store: already in memory, bounded by its own blocks."""
|
||||
|
||||
kind = "memory"
|
||||
|
||||
def __init__(self, store=None) -> None:
|
||||
self.store = store
|
||||
|
||||
def set_store(self, store) -> None:
|
||||
self.store = store
|
||||
|
||||
def extent(self) -> tuple[float, float]:
|
||||
if self.store is None:
|
||||
return (0.0, 0.0)
|
||||
return self.store.extent()
|
||||
|
||||
def duration_s(self) -> float:
|
||||
if self.store is None:
|
||||
return 0.0
|
||||
first, last = self.store.extent()
|
||||
return max(0.0, last - first)
|
||||
|
||||
def _slice(self, t0: float, t1: float, max_points: int | None, unit: str) -> SessionSlice:
|
||||
if self.store is None:
|
||||
empty = np.empty(0)
|
||||
return SessionSlice(
|
||||
times=empty,
|
||||
raw=np.empty((0, 3)),
|
||||
newtons=np.empty((0, 3)),
|
||||
valid=np.empty(0, dtype=bool),
|
||||
frame_ids=np.empty(0, dtype=np.int64),
|
||||
)
|
||||
return self.store.visible(
|
||||
t0, t1, max_points=max_points or 0, unit=unit
|
||||
)
|
||||
|
||||
def window(
|
||||
self,
|
||||
t0: float,
|
||||
t1: float,
|
||||
*,
|
||||
max_points: int | None = None,
|
||||
unit: str = "raw",
|
||||
) -> SourceWindow:
|
||||
slice_ = self._slice(t0, t1, max_points, unit)
|
||||
return SourceWindow(
|
||||
times=slice_.times,
|
||||
values=slice_.value_for_unit(unit),
|
||||
valid=slice_.valid,
|
||||
frame_ids=slice_.frame_ids,
|
||||
unit=unit,
|
||||
decimated=bool(max_points and slice_.times.size >= int(max_points)),
|
||||
)
|
||||
|
||||
def envelope(self, *, buckets: int = 1200, unit: str = "raw") -> SourceWindow:
|
||||
"""The whole session, decimated — the store's own bounded view."""
|
||||
if self.store is None:
|
||||
return self.window(0.0, 0.0)
|
||||
first, last = self.store.extent()
|
||||
return self.window(first, last, max_points=int(buckets) * 2, unit=unit)
|
||||
|
||||
def statistics(self, t0: float, t1: float, *, unit: str = "raw") -> SourceStats:
|
||||
if self.store is None:
|
||||
return SourceStats()
|
||||
stats = self.store.statistics(t0, t1, unit=unit)
|
||||
return SourceStats(
|
||||
duration_s=stats.duration_s,
|
||||
samples=stats.samples,
|
||||
peaks=stats.peaks,
|
||||
means=stats.means,
|
||||
stds=stats.stds,
|
||||
)
|
||||
|
||||
|
||||
class RecordingCurveSource:
|
||||
"""A recording on disk: interval reads plus a cached envelope."""
|
||||
|
||||
kind = "disk"
|
||||
|
||||
def __init__(self, reader) -> None:
|
||||
self.reader = reader
|
||||
|
||||
def extent(self) -> tuple[float, float]:
|
||||
return self.reader.index().extent()
|
||||
|
||||
def duration_s(self) -> float:
|
||||
return self.reader.index().duration_s()
|
||||
|
||||
def window(
|
||||
self,
|
||||
t0: float,
|
||||
t1: float,
|
||||
*,
|
||||
max_points: int | None = None,
|
||||
unit: str = "raw",
|
||||
) -> SourceWindow:
|
||||
window = self.reader.window(t0, t1, unit=unit, max_points=max_points)
|
||||
return SourceWindow(
|
||||
times=window.times,
|
||||
values=window.value_for_unit(unit),
|
||||
valid=window.valid,
|
||||
frame_ids=window.frame_ids,
|
||||
unit=unit,
|
||||
decimated=window.decimated,
|
||||
)
|
||||
|
||||
def envelope(self, *, buckets: int = 1200, unit: str = "raw") -> SourceWindow:
|
||||
"""The whole-session envelope, from the index's cached overview."""
|
||||
overview = self.reader.overview(unit=unit, buckets=int(buckets))
|
||||
keep = ~np.isnan(overview.times)
|
||||
times = overview.times[keep]
|
||||
if times.size == 0:
|
||||
return self.window(0.0, 0.0, unit=unit)
|
||||
# The envelope is drawn as a band: the low and high of each bucket. It is
|
||||
# reported as a plain window so callers need only one shape.
|
||||
values = np.where(
|
||||
np.isnan(overview.highs[keep]), overview.lows[keep], overview.highs[keep]
|
||||
)
|
||||
return SourceWindow(
|
||||
times=times,
|
||||
values=values,
|
||||
valid=np.ones(times.size, dtype=bool),
|
||||
frame_ids=np.full(times.size, -1, dtype=np.int64),
|
||||
unit=unit,
|
||||
decimated=True,
|
||||
)
|
||||
|
||||
def statistics(self, t0: float, t1: float, *, unit: str = "raw") -> SourceStats:
|
||||
stats = self.reader.statistics(t0, t1, unit=unit)
|
||||
return SourceStats(
|
||||
duration_s=stats.duration_s,
|
||||
samples=stats.samples,
|
||||
peaks=stats.peaks,
|
||||
means=stats.means,
|
||||
stds=stats.stds,
|
||||
)
|
||||
@@ -1,46 +1,104 @@
|
||||
"""Force unit selection for display."""
|
||||
"""Force unit selection for display.
|
||||
|
||||
The decision is made **per axis** from the standardised measurement
|
||||
(:class:`~orisys_app_shared.record.sample.RecordSample`), not from a device-level
|
||||
``force_calibrated`` flag. A device can legitimately have only its normal-force
|
||||
axis calibrated: the old rule then either showed every axis in raw (losing the
|
||||
one axis that really was in newtons) or, worse, showed raw counts in the newton
|
||||
panels. An axis without a curve is now simply **unavailable** in newton mode and
|
||||
reported as such.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from orisys_app_shared.record.sample import AXES
|
||||
|
||||
#: Index of each axis inside the triplets, matching ``AXES``.
|
||||
AXIS_INDEX: dict[str, int] = {axis: index for index, axis in enumerate(AXES)}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DisplayForces:
|
||||
fn: float
|
||||
fx: float
|
||||
fy: float
|
||||
"""What the live view should show for one sample."""
|
||||
|
||||
#: One value per axis, in ``AXES`` order. NaN means "not available".
|
||||
values: tuple[float, float, float]
|
||||
#: The unit the values actually are (``N`` only when they are newtons).
|
||||
unit: str
|
||||
#: Per axis: is this value a real measurement in ``unit``?
|
||||
available: tuple[bool, bool, bool]
|
||||
#: The requested unit was newton but no axis had a curve, so raw is shown.
|
||||
using_raw_fallback: bool
|
||||
#: What the operator asked for (``N``/``raw``), for the fallback message.
|
||||
requested_unit: str
|
||||
|
||||
def value(self, axis: str) -> float:
|
||||
return self.values[AXIS_INDEX[axis]]
|
||||
|
||||
def is_available(self, axis: str) -> bool:
|
||||
return self.available[AXIS_INDEX[axis]]
|
||||
|
||||
def unavailable_axes(self) -> tuple[str, ...]:
|
||||
return tuple(
|
||||
axis for axis in AXES if not self.available[AXIS_INDEX[axis]]
|
||||
)
|
||||
|
||||
|
||||
def select_display_forces(
|
||||
*,
|
||||
units_pref: str,
|
||||
force_calibrated: bool,
|
||||
fn: float,
|
||||
fx: float,
|
||||
fy: float,
|
||||
fn_raw: float,
|
||||
fx_raw: float,
|
||||
fy_raw: float,
|
||||
) -> DisplayForces:
|
||||
"""Return values for plots/labels given newton/raw preference."""
|
||||
def _finite(value: float) -> bool:
|
||||
try:
|
||||
return value == value and value not in (float("inf"), float("-inf"))
|
||||
except TypeError: # pragma: no cover - defensive
|
||||
return False
|
||||
|
||||
|
||||
def select_display_forces(*, units_pref: str, sample) -> DisplayForces:
|
||||
"""Pick the values to display for one standardised measurement.
|
||||
|
||||
``sample`` is a
|
||||
:class:`~orisys_app_shared.record.sample.RecordSample`, so the same conversion
|
||||
that feeds the CSV and the session store also decides what the live charts
|
||||
show: there is no second interpretation of what a value means.
|
||||
"""
|
||||
want_newton = str(units_pref or "newton").lower().startswith("n")
|
||||
if want_newton and force_calibrated:
|
||||
return DisplayForces(fn=fn, fx=fx, fy=fy, unit="N", using_raw_fallback=False)
|
||||
if want_newton and not force_calibrated:
|
||||
raw = tuple(sample.raw_for(axis) for axis in AXES)
|
||||
newton = tuple(sample.newton_for(axis) for axis in AXES)
|
||||
requested = "N" if want_newton else "raw"
|
||||
|
||||
if not want_newton:
|
||||
return DisplayForces(
|
||||
fn=fn_raw,
|
||||
fx=fx_raw,
|
||||
fy=fy_raw,
|
||||
unit="raw",
|
||||
using_raw_fallback=True,
|
||||
)
|
||||
return DisplayForces(
|
||||
fn=fn_raw,
|
||||
fx=fx_raw,
|
||||
fy=fy_raw,
|
||||
values=raw,
|
||||
unit="raw",
|
||||
available=tuple(_finite(value) for value in raw),
|
||||
using_raw_fallback=False,
|
||||
requested_unit=requested,
|
||||
)
|
||||
|
||||
values: list[float] = []
|
||||
available: list[bool] = []
|
||||
for index, _axis in enumerate(AXES):
|
||||
if _finite(newton[index]):
|
||||
values.append(newton[index])
|
||||
available.append(True)
|
||||
else:
|
||||
# Not "zero" and not the raw count: there is no newton value at all.
|
||||
values.append(float("nan"))
|
||||
available.append(False)
|
||||
|
||||
if not any(available):
|
||||
# Nothing is calibrated: showing raw is honest as long as it is labelled.
|
||||
return DisplayForces(
|
||||
values=raw,
|
||||
unit="raw",
|
||||
available=tuple(_finite(value) for value in raw),
|
||||
using_raw_fallback=True,
|
||||
requested_unit=requested,
|
||||
)
|
||||
return DisplayForces(
|
||||
values=tuple(values),
|
||||
unit="N",
|
||||
available=tuple(available),
|
||||
using_raw_fallback=False,
|
||||
requested_unit=requested,
|
||||
)
|
||||
|
||||
@@ -10,25 +10,25 @@ def is_frozen() -> bool:
|
||||
return bool(getattr(sys, "frozen", False))
|
||||
|
||||
|
||||
def _customer_root_from_shared() -> Path:
|
||||
"""Repo layout: shared/src/orisys_app_shared -> apps/customer."""
|
||||
def _studio_root_from_shared() -> Path:
|
||||
"""Repo layout: shared/src/orisys_app_shared -> apps/studio."""
|
||||
here = Path(__file__).resolve()
|
||||
# .../orisys-app/shared/src/orisys_app_shared/paths.py
|
||||
repo_root = here.parents[3]
|
||||
return repo_root / "apps" / "customer"
|
||||
return repo_root / "apps" / "studio"
|
||||
|
||||
|
||||
def app_root() -> Path:
|
||||
"""Return the customer app root (editable) or EXE directory (frozen)."""
|
||||
"""Return the studio app root (editable) or EXE directory (frozen)."""
|
||||
if is_frozen():
|
||||
return Path(sys.executable).resolve().parent
|
||||
# Prefer cwd override when launched from apps/customer
|
||||
# Prefer cwd override when launched from apps/studio
|
||||
cwd = Path.cwd().resolve()
|
||||
if (cwd / "config").is_dir() and (cwd / "run.py").is_file():
|
||||
return cwd
|
||||
if (cwd / "apps" / "customer" / "config").is_dir():
|
||||
return cwd / "apps" / "customer"
|
||||
return _customer_root_from_shared()
|
||||
if (cwd / "apps" / "studio" / "config").is_dir():
|
||||
return cwd / "apps" / "studio"
|
||||
return _studio_root_from_shared()
|
||||
|
||||
|
||||
def config_dir() -> Path:
|
||||
@@ -68,6 +68,13 @@ def calibration_export_dir() -> Path:
|
||||
return path
|
||||
|
||||
|
||||
def recordings_dir() -> Path:
|
||||
"""Root for one directory per recorded experiment."""
|
||||
path = outputs_dir() / "recordings"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def preference_path() -> Path:
|
||||
return app_root() / "preference.json"
|
||||
|
||||
|
||||
@@ -24,8 +24,22 @@ _DEFAULTS: dict[str, Any] = {
|
||||
"plot_window_sec": 4.0,
|
||||
"plot_fn_max": 20.0,
|
||||
"plot_shear_max": 20.0,
|
||||
# Raw (uncalibrated) HHD units. Sized so the idle jitter is sub-pixel on
|
||||
# both raw-mode products while never clipping: measured peaks are ~447k
|
||||
# (mini, 365x365) and ~41k (finger, 280x300); idle sigma is ~80 (finger).
|
||||
"plot_fn_raw_max": 1000000.0,
|
||||
"plot_shear_raw_max": 500000.0,
|
||||
"image_view_mode": "arrows_pure",
|
||||
"arrow_rotation_deg": 90,
|
||||
"show_fps": False,
|
||||
#: The top rail's settings area (source / camera / config / units) starts
|
||||
#: collapsed: the rail is for state, not configuration.
|
||||
"settings_expanded": False,
|
||||
#: Top-level view: "custom" (a hand-arranged layout), "live", "replay" or
|
||||
#: "calibration". Each view is a layout of the same window.
|
||||
"view_preset": "custom",
|
||||
"splitter_sizes": [340, 480, 400],
|
||||
"window_default_size": [1440, 880],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Experiment recording (video + data + metadata) and replay."""
|
||||
|
||||
from orisys_app_shared.record.calibration_snapshot import (
|
||||
list_calibration_snapshots,
|
||||
store_calibration_snapshot,
|
||||
)
|
||||
from orisys_app_shared.record.experiment import (
|
||||
EVENTS_HEADER,
|
||||
FORCES_HEADER,
|
||||
FRAMES_HEADER,
|
||||
RECORD_FORMAT_VERSION,
|
||||
ExperimentRecorder,
|
||||
RecordConfig,
|
||||
RecordReport,
|
||||
RecordStats,
|
||||
format_value,
|
||||
)
|
||||
from orisys_app_shared.record.playback import (
|
||||
RecordingFrame,
|
||||
RecordingInfo,
|
||||
RecordingReader,
|
||||
list_recordings,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"EVENTS_HEADER",
|
||||
"FORCES_HEADER",
|
||||
"FRAMES_HEADER",
|
||||
"RECORD_FORMAT_VERSION",
|
||||
"ExperimentRecorder",
|
||||
"RecordConfig",
|
||||
"RecordReport",
|
||||
"RecordStats",
|
||||
"RecordingFrame",
|
||||
"RecordingInfo",
|
||||
"RecordingReader",
|
||||
"format_value",
|
||||
"list_calibration_snapshots",
|
||||
"list_recordings",
|
||||
"store_calibration_snapshot",
|
||||
]
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Store the coefficients a recording was made with, next to the recording.
|
||||
|
||||
The hash of a calibration tells you *which* coefficients were applied; it does not
|
||||
let you check what they were, and the original export file can be overwritten or
|
||||
deleted afterwards. So the recording keeps its own copy:
|
||||
|
||||
```
|
||||
outputs/recordings/<stamp>_session<NNNN>/
|
||||
calibration/
|
||||
force_calibration_<hash12>.json
|
||||
```
|
||||
|
||||
The copy is content-addressed by the digest, so applying the same calibration
|
||||
twice in one session stores one file, and two different calibrations can never
|
||||
collide.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Directory inside a recording that holds the coefficient snapshots.
|
||||
SNAPSHOT_DIRNAME = "calibration"
|
||||
|
||||
|
||||
def snapshot_name(version: str, source: Path) -> str:
|
||||
"""The snapshot's file name: content-addressed, with the original extension."""
|
||||
digest = str(version or "").strip()
|
||||
stem = f"force_calibration_{digest[:12]}" if digest else f"force_calibration_{source.stem}"
|
||||
return f"{stem}{source.suffix or '.json'}"
|
||||
|
||||
|
||||
def store_calibration_snapshot(directory: str | Path, source: str | Path, version: str) -> str:
|
||||
"""Copy ``source`` into ``directory/calibration``. Returns the relative name.
|
||||
|
||||
Raises on I/O failure; the caller reports it and keeps recording.
|
||||
"""
|
||||
base = Path(directory) / SNAPSHOT_DIRNAME
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
origin = Path(source)
|
||||
target = base / snapshot_name(version, origin)
|
||||
if not target.is_file() or target.stat().st_size != origin.stat().st_size:
|
||||
# Same content-addressed name means the same coefficients; a size check
|
||||
# is enough to notice a truncated previous copy.
|
||||
shutil.copy2(origin, target)
|
||||
return f"{SNAPSHOT_DIRNAME}/{target.name}"
|
||||
|
||||
|
||||
def list_calibration_snapshots(directory: str | Path) -> list[str]:
|
||||
"""Every snapshot already stored in a recording, sorted."""
|
||||
base = Path(directory) / SNAPSHOT_DIRNAME
|
||||
if not base.is_dir():
|
||||
return []
|
||||
return sorted(
|
||||
f"{SNAPSHOT_DIRNAME}/{path.name}" for path in base.iterdir() if path.is_file()
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,495 @@
|
||||
"""A time index over a recorded ``forces.csv``, so history is read on demand.
|
||||
|
||||
The replay used to load every row of a recording into Python lists and then into
|
||||
arrays. That is fine for a minute and hopeless for the multi-hour recordings the
|
||||
store is designed for, and it made the charts the only place where "the whole
|
||||
session" had to exist in memory at once.
|
||||
|
||||
This index keeps the *small* things in memory — one time per row, one validity
|
||||
flag, one frame id, plus periodic byte offsets — and reads the *values* only for
|
||||
the interval being drawn. It also builds a multi-level overview (per-bucket
|
||||
min/max/count) while streaming the file once, which is what a whole-session strip
|
||||
needs and what it cannot get by sampling a few thousand rows.
|
||||
|
||||
Nothing here touches Qt. Unordered recordings keep a stable time-to-byte-offset
|
||||
mapping, shared by all interval reads and statistics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import logging
|
||||
from array import array
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Iterator, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: How many rows to read between two recorded byte offsets. Larger means a
|
||||
#: cheaper interval read but a longer scan to reach the interval.
|
||||
CHECKPOINT_ROWS = 4096
|
||||
#: Default number of overview buckets for the whole-session strip.
|
||||
DEFAULT_BUCKETS = 1200
|
||||
|
||||
_FORCE_COLUMNS = ("FN_raw", "FX_raw", "FY_raw", "FN_N", "FX_N", "FY_N")
|
||||
|
||||
|
||||
def _parse(text: str) -> float:
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
# An empty cell means "not available"; it must never become 0.
|
||||
return float("nan")
|
||||
try:
|
||||
return float(text)
|
||||
except ValueError:
|
||||
return float("nan")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ForceWindow:
|
||||
"""Rows inside one interval — the same shape a session slice has."""
|
||||
|
||||
times: np.ndarray
|
||||
raw: np.ndarray
|
||||
newtons: np.ndarray
|
||||
valid: np.ndarray
|
||||
frame_ids: np.ndarray
|
||||
#: True when the rows were decimated to satisfy the caller's point budget.
|
||||
decimated: bool = False
|
||||
|
||||
def __len__(self) -> int:
|
||||
return int(self.times.size)
|
||||
|
||||
def value_for_unit(self, unit: str) -> np.ndarray:
|
||||
if str(unit).upper().startswith("N"):
|
||||
return self.newtons
|
||||
return self.raw
|
||||
|
||||
def plottable(self, unit: str):
|
||||
"""``(times, values, mask)`` with a per-axis mask — the shared rule."""
|
||||
from orisys_app_shared.record.sample import plottable_axis_mask
|
||||
|
||||
values = self.value_for_unit(unit)
|
||||
return self.times, values, plottable_axis_mask(values, self.valid)
|
||||
|
||||
def plottable_axis(self, unit: str, index: int):
|
||||
from orisys_app_shared.record.sample import plottable_axis_mask
|
||||
|
||||
values = self.value_for_unit(unit)
|
||||
mask = plottable_axis_mask(values, self.valid)[:, index]
|
||||
return self.times[mask], values[mask, index]
|
||||
|
||||
def statistics(self, unit: str) -> "WindowStats":
|
||||
values = self.value_for_unit(unit)
|
||||
mask = np.isfinite(values) & self.valid[:, None]
|
||||
peaks: list[float] = []
|
||||
means: list[float] = []
|
||||
stds: list[float] = []
|
||||
for index in range(3):
|
||||
column = values[:, index][mask[:, index]]
|
||||
if column.size == 0:
|
||||
peaks.append(float("nan"))
|
||||
means.append(float("nan"))
|
||||
stds.append(float("nan"))
|
||||
continue
|
||||
peaks.append(float(column[np.argmax(np.abs(column))]))
|
||||
means.append(float(np.nanmean(column)))
|
||||
stds.append(float(np.nanstd(column)))
|
||||
span = 0.0
|
||||
if self.times.size > 1:
|
||||
span = float(self.times[-1] - self.times[0])
|
||||
return WindowStats(
|
||||
duration_s=span,
|
||||
samples=int(mask.any(axis=1).sum()),
|
||||
peaks=tuple(peaks),
|
||||
means=tuple(means),
|
||||
stds=tuple(stds),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WindowStats:
|
||||
duration_s: float = 0.0
|
||||
samples: int = 0
|
||||
peaks: tuple[float, float, float] = (float("nan"),) * 3
|
||||
means: tuple[float, float, float] = (float("nan"),) * 3
|
||||
stds: tuple[float, float, float] = (float("nan"),) * 3
|
||||
|
||||
|
||||
@dataclass
|
||||
class Overview:
|
||||
"""A whole-session envelope, cheap enough to keep in memory."""
|
||||
|
||||
times: np.ndarray
|
||||
lows: np.ndarray # (buckets, 3)
|
||||
highs: np.ndarray # (buckets, 3)
|
||||
counts: np.ndarray # (buckets,)
|
||||
unit: str = "raw"
|
||||
|
||||
def __len__(self) -> int:
|
||||
return int(self.times.size)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Checkpoint:
|
||||
row: int
|
||||
time: float
|
||||
offset: int
|
||||
|
||||
|
||||
class RecordingIndex:
|
||||
"""Index one recording directory's ``forces.csv`` (and its validity flags)."""
|
||||
|
||||
def __init__(self, directory: str | Path, *, checkpoint_rows: int = CHECKPOINT_ROWS):
|
||||
self.directory = Path(directory)
|
||||
self.checkpoint_rows = max(1, int(checkpoint_rows))
|
||||
self.times = np.empty(0, dtype=np.float64)
|
||||
self.valid = np.empty(0, dtype=bool)
|
||||
self.frame_ids = np.empty(0, dtype=np.int64)
|
||||
self._checkpoints: list[_Checkpoint] = []
|
||||
self._rows = 0
|
||||
self._byte_size = 0
|
||||
self._built = False
|
||||
self._offsets = None # Only unordered files need one byte offset per row.
|
||||
self._columns = {}
|
||||
self.ordered = True
|
||||
self.dropped_rows = 0
|
||||
|
||||
# ------------------------------------------------------------------ building
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
return self.directory / "forces.csv"
|
||||
|
||||
@property
|
||||
def rows(self) -> int:
|
||||
return self._rows
|
||||
|
||||
@property
|
||||
def byte_size(self) -> int:
|
||||
"""Size of the data file the index describes."""
|
||||
return self._byte_size
|
||||
|
||||
def build(self) -> "RecordingIndex":
|
||||
"""One streaming pass: times, flags, frame ids and byte offsets."""
|
||||
path = self.path
|
||||
times: list[float] = []
|
||||
valid: list[bool] = []
|
||||
frame_ids: list[int] = []
|
||||
checkpoints: list[_Checkpoint] = []
|
||||
offsets = array("Q")
|
||||
self.dropped_rows = 0
|
||||
if path.is_file():
|
||||
with path.open("rb") as handle:
|
||||
header = handle.readline().decode("utf-8", "replace").strip()
|
||||
columns = {name: index for index, name in enumerate(header.split(","))}
|
||||
self._columns = columns
|
||||
offset = handle.tell()
|
||||
for row, line in enumerate(handle):
|
||||
next_offset = handle.tell()
|
||||
record = self._parse_row(line)
|
||||
if record is None:
|
||||
self.dropped_rows += 1
|
||||
offset = next_offset
|
||||
continue
|
||||
frame_id, timestamp, _raw, _newtons, is_valid = record
|
||||
logical_row = len(times)
|
||||
if logical_row % self.checkpoint_rows == 0:
|
||||
checkpoints.append(
|
||||
_Checkpoint(row=logical_row, time=timestamp, offset=offset)
|
||||
)
|
||||
times.append(timestamp)
|
||||
valid.append(is_valid)
|
||||
frame_ids.append(frame_id)
|
||||
offsets.append(offset)
|
||||
offset = next_offset
|
||||
self.times = np.asarray(times, dtype=np.float64)
|
||||
self.valid = np.asarray(valid, dtype=bool)
|
||||
self.frame_ids = np.asarray(frame_ids, dtype=np.int64)
|
||||
self.ordered = bool(np.all(self.times[1:] >= self.times[:-1]))
|
||||
self._offsets = None
|
||||
if not self.ordered:
|
||||
order = np.argsort(self.times, kind="stable")
|
||||
self.times = self.times[order]
|
||||
self.valid = self.valid[order]
|
||||
self.frame_ids = self.frame_ids[order]
|
||||
self._offsets = np.asarray(offsets, dtype=np.uint64)[order]
|
||||
self._checkpoints = checkpoints
|
||||
self._rows = int(self.times.size)
|
||||
try:
|
||||
self._byte_size = path.stat().st_size
|
||||
except OSError:
|
||||
self._byte_size = 0
|
||||
self._built = True
|
||||
return self
|
||||
|
||||
def _parse_row(self, line: bytes):
|
||||
"""Parse atomically; malformed rows never shift one column alone."""
|
||||
try:
|
||||
fields = next(csv.reader([line.decode("utf-8", "replace")]))
|
||||
def cell(name):
|
||||
return fields[self._columns[name]]
|
||||
frame_id = int(cell("frame_id"))
|
||||
timestamp = float(cell("t_rel_s"))
|
||||
if not np.isfinite(timestamp) or not -(2**63) <= frame_id < 2**63:
|
||||
return None
|
||||
values = tuple(_parse(cell(name)) for name in _FORCE_COLUMNS)
|
||||
return frame_id, timestamp, values[:3], values[3:], cell("valid").strip() == "1"
|
||||
except (KeyError, IndexError, ValueError, StopIteration, csv.Error):
|
||||
return None
|
||||
|
||||
def ensure_built(self) -> "RecordingIndex":
|
||||
if not self._built:
|
||||
self.build()
|
||||
return self
|
||||
|
||||
# ------------------------------------------------------------------- queries
|
||||
def extent(self) -> tuple[float, float]:
|
||||
self.ensure_built()
|
||||
if self._rows == 0:
|
||||
return (0.0, 0.0)
|
||||
return (float(self.times[0]), float(self.times[-1]))
|
||||
|
||||
def duration_s(self) -> float:
|
||||
first, last = self.extent()
|
||||
return max(0.0, last - first)
|
||||
|
||||
def row_range(self, t0: float, t1: float) -> tuple[int, int]:
|
||||
"""Half-open row range covering ``[t0, t1]``."""
|
||||
self.ensure_built()
|
||||
if self._rows == 0:
|
||||
return (0, 0)
|
||||
if float(t1) < float(t0):
|
||||
t0, t1 = t1, t0
|
||||
start = int(self.times.searchsorted(float(t0), side="left"))
|
||||
stop = int(self.times.searchsorted(float(t1), side="right"))
|
||||
return (start, max(start, stop))
|
||||
|
||||
def _checkpoint_before(self, row: int) -> _Checkpoint:
|
||||
chosen = _Checkpoint(row=0, time=0.0, offset=0)
|
||||
for checkpoint in self._checkpoints:
|
||||
if checkpoint.row <= row:
|
||||
chosen = checkpoint
|
||||
else:
|
||||
break
|
||||
return chosen
|
||||
|
||||
def read_rows(
|
||||
self,
|
||||
start: int,
|
||||
stop: int,
|
||||
*,
|
||||
max_points: int | None = None,
|
||||
unit: str = "raw",
|
||||
) -> ForceWindow:
|
||||
"""Read ``[start, stop)`` by seeking to the nearest checkpoint.
|
||||
|
||||
With ``max_points`` the rows are decimated **while streaming**: each
|
||||
bucket contributes the extreme row of every axis in ``unit``, so a long
|
||||
interval never has to exist in memory in full. Extrema are taken independently on all three axes in the requested unit,
|
||||
so a shear spike survives a flat normal channel. A bucket keeps at most
|
||||
nine rows; budgets smaller than nine may therefore return nine points.
|
||||
"""
|
||||
self.ensure_built()
|
||||
start = max(0, min(int(start), self._rows))
|
||||
stop = max(start, min(int(stop), self._rows))
|
||||
if stop <= start:
|
||||
return _empty_window()
|
||||
path = self.path
|
||||
if not path.is_file():
|
||||
return _empty_window()
|
||||
|
||||
newton_unit = str(unit).upper().startswith("N")
|
||||
budget = int(max_points) if max_points else 0
|
||||
total = stop - start
|
||||
# At most six extrema, two endpoints and a missing-data marker per
|
||||
# bucket. No bucket holds a list proportional to the recording length.
|
||||
bucket = max(1, (total + max(1, budget // 9) - 1) // max(1, budget // 9)) if budget and total > budget else 1
|
||||
picked: list[tuple] = []
|
||||
extrema = {}
|
||||
first = last = missing = None
|
||||
count = 0
|
||||
|
||||
def flush():
|
||||
nonlocal first, last, missing, count
|
||||
chosen = {item[0]: item for item in extrema.values()}
|
||||
for item in (first, last, missing):
|
||||
if item is not None:
|
||||
chosen[item[0]] = item
|
||||
picked.extend(item[1:] for item in sorted(chosen.values(), key=lambda item: item[0]))
|
||||
extrema.clear()
|
||||
first = last = missing = None
|
||||
count = 0
|
||||
|
||||
for item in self.iter_rows(start, stop):
|
||||
# item: logical row, frame id, time, raw, newtons, validity.
|
||||
if bucket == 1:
|
||||
picked.append(item[1:])
|
||||
continue
|
||||
if first is None:
|
||||
first = item
|
||||
last = item
|
||||
values = item[4] if newton_unit else item[3]
|
||||
for axis, value in enumerate(values):
|
||||
if not item[5] or not np.isfinite(value):
|
||||
if missing is None:
|
||||
missing = item
|
||||
continue
|
||||
for side in ('min', 'max'):
|
||||
key = (axis, side)
|
||||
previous = extrema.get(key)
|
||||
old = previous[4 if newton_unit else 3][axis] if previous is not None else None
|
||||
if old is None or (value < old if side == 'min' else value > old):
|
||||
extrema[key] = item
|
||||
count += 1
|
||||
if count >= bucket:
|
||||
flush()
|
||||
flush()
|
||||
|
||||
if not picked:
|
||||
return _empty_window()
|
||||
# A row can be the extreme of several axes; keep one copy, in time order.
|
||||
rows = picked # Already in stable time order; duplicate frame IDs are not deduplicated.
|
||||
return ForceWindow(
|
||||
times=np.asarray([row[1] for row in rows], dtype=np.float64),
|
||||
raw=np.asarray([row[2] for row in rows], dtype=np.float64).reshape(-1, 3),
|
||||
newtons=np.asarray([row[3] for row in rows], dtype=np.float64).reshape(-1, 3),
|
||||
valid=np.asarray([row[4] for row in rows], dtype=bool),
|
||||
frame_ids=np.asarray([row[0] for row in rows], dtype=np.int64),
|
||||
decimated=bucket > 1,
|
||||
)
|
||||
|
||||
def iter_rows(self, start: int = 0, stop: Optional[int] = None) -> Iterator[tuple]:
|
||||
"""Yield logical rows in stable time order, seeking only the selection."""
|
||||
self.ensure_built()
|
||||
start = max(0, int(start))
|
||||
stop = self._rows if stop is None else min(int(stop), self._rows)
|
||||
if start >= stop or not self.path.is_file():
|
||||
return
|
||||
with self.path.open("rb") as handle:
|
||||
if self._offsets is not None:
|
||||
for row in range(start, stop):
|
||||
handle.seek(int(self._offsets[row]))
|
||||
record = self._parse_row(handle.readline())
|
||||
if record is not None:
|
||||
yield (row, *record)
|
||||
return
|
||||
checkpoint = self._checkpoint_before(start)
|
||||
handle.seek(checkpoint.offset)
|
||||
row = checkpoint.row
|
||||
for line in handle:
|
||||
record = self._parse_row(line)
|
||||
if record is None:
|
||||
continue
|
||||
if row >= start:
|
||||
yield (row, *record)
|
||||
row += 1
|
||||
if row >= stop:
|
||||
return
|
||||
|
||||
def statistics(self, t0: float, t1: float, *, unit: str = "raw") -> WindowStats:
|
||||
"""Exact selected-row statistics with constant working memory."""
|
||||
start, stop = self.row_range(t0, t1)
|
||||
counts = np.zeros(3, dtype=np.int64)
|
||||
means = np.zeros(3)
|
||||
m2 = np.zeros(3)
|
||||
peaks = np.full(3, np.nan)
|
||||
samples = 0
|
||||
first = last = None
|
||||
newton = str(unit).upper().startswith("N")
|
||||
for _, _, timestamp, raw, newtons, valid in self.iter_rows(start, stop):
|
||||
if first is None:
|
||||
first = timestamp
|
||||
last = timestamp
|
||||
if not valid:
|
||||
continue
|
||||
values = np.asarray(newtons if newton else raw)
|
||||
mask = np.isfinite(values)
|
||||
samples += int(mask.any())
|
||||
for axis in np.flatnonzero(mask):
|
||||
value = values[axis]
|
||||
counts[axis] += 1
|
||||
delta = value - means[axis]
|
||||
means[axis] += delta / counts[axis]
|
||||
m2[axis] += delta * (value - means[axis])
|
||||
if not np.isfinite(peaks[axis]) or abs(value) > abs(peaks[axis]):
|
||||
peaks[axis] = value
|
||||
available = counts > 0
|
||||
stds = np.full(3, np.nan)
|
||||
stds[available] = np.sqrt(np.maximum(0, m2[available] / counts[available]))
|
||||
means[~available] = np.nan
|
||||
return WindowStats(
|
||||
duration_s=0.0 if first is None else last - first,
|
||||
samples=samples, peaks=tuple(peaks), means=tuple(means), stds=tuple(stds),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ overview
|
||||
def overview(self, *, unit: str = "raw", buckets: int = DEFAULT_BUCKETS) -> Overview:
|
||||
"""A per-bucket envelope of the whole recording, in the active unit.
|
||||
|
||||
Built by streaming the file once and folding each row into its bucket, so
|
||||
a peak in a bucket the strip would otherwise skip is still visible.
|
||||
"""
|
||||
self.ensure_built()
|
||||
buckets = max(1, int(buckets))
|
||||
newton = str(unit).upper().startswith("N")
|
||||
if self._rows == 0:
|
||||
return Overview(
|
||||
times=np.empty(0),
|
||||
lows=np.empty((0, 3)),
|
||||
highs=np.empty((0, 3)),
|
||||
counts=np.empty(0, dtype=np.int64),
|
||||
unit="N" if newton else "raw",
|
||||
)
|
||||
first, last = self.extent()
|
||||
span = max(1e-9, last - first)
|
||||
lows = np.full((buckets, 3), np.nan)
|
||||
highs = np.full((buckets, 3), np.nan)
|
||||
counts = np.zeros(buckets, dtype=np.int64)
|
||||
starts = np.full(buckets, np.nan)
|
||||
for _row, _frame_id, t_rel, raw, newtons, is_valid in self.iter_rows():
|
||||
if not is_valid:
|
||||
continue
|
||||
bucket = int((t_rel - first) / span * (buckets - 1) + 0.5)
|
||||
bucket = max(0, min(buckets - 1, bucket))
|
||||
values = newtons if newton else raw
|
||||
for axis in range(3):
|
||||
value = values[axis]
|
||||
if value != value: # NaN
|
||||
continue
|
||||
current_low = lows[bucket, axis]
|
||||
current_high = highs[bucket, axis]
|
||||
lows[bucket, axis] = value if current_low != current_low else min(current_low, value)
|
||||
highs[bucket, axis] = value if current_high != current_high else max(current_high, value)
|
||||
if starts[bucket] != starts[bucket]:
|
||||
starts[bucket] = t_rel
|
||||
counts[bucket] += 1
|
||||
return Overview(
|
||||
times=starts,
|
||||
lows=lows,
|
||||
highs=highs,
|
||||
counts=counts,
|
||||
unit="N" if newton else "raw",
|
||||
)
|
||||
|
||||
|
||||
def _time_of(text: str) -> float:
|
||||
fields = next(csv.reader([text])) if text else []
|
||||
if len(fields) > 2:
|
||||
try:
|
||||
return float(fields[2])
|
||||
except (TypeError, ValueError):
|
||||
return float("nan")
|
||||
return float("nan")
|
||||
|
||||
|
||||
def _empty_window() -> ForceWindow:
|
||||
return ForceWindow(
|
||||
times=np.empty(0, dtype=np.float64),
|
||||
raw=np.empty((0, 3)),
|
||||
newtons=np.empty((0, 3)),
|
||||
valid=np.empty(0, dtype=bool),
|
||||
frame_ids=np.empty(0, dtype=np.int64),
|
||||
)
|
||||
@@ -0,0 +1,614 @@
|
||||
"""Read a recorded experiment back: forces, the frame index and the video.
|
||||
|
||||
Replay policy for depth (decided with the team): **depth is not stored**. It is
|
||||
a function of the frame and the sensor configuration, so it is recomputed from
|
||||
``raw_*.mp4`` with the config recorded in ``session.json``. That keeps a
|
||||
recording small at the cost of requiring the SDK to reproduce the same result,
|
||||
which is why :meth:`RecordingReader.replay_warnings` checks that the config hash
|
||||
and SDK version still match what was recorded.
|
||||
|
||||
Nothing here touches Qt, so the reader is testable without a display.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Iterator, Optional
|
||||
|
||||
import numpy as np
|
||||
|
||||
from orisys_app_shared.record.index import (
|
||||
CHECKPOINT_ROWS,
|
||||
DEFAULT_BUCKETS,
|
||||
ForceWindow,
|
||||
Overview,
|
||||
RecordingIndex,
|
||||
)
|
||||
from orisys_app_shared.record.sample import plottable_axis_mask
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Column names as written by the recorder (index == position in forces.csv).
|
||||
FORCE_COLUMNS = (
|
||||
"session_id",
|
||||
"frame_id",
|
||||
"t_rel_s",
|
||||
"FN_raw",
|
||||
"FX_raw",
|
||||
"FY_raw",
|
||||
"FN_N",
|
||||
"FX_N",
|
||||
"FY_N",
|
||||
"valid",
|
||||
"calibration_id",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecordingFrame:
|
||||
"""One row of ``frames.csv`` — the mapping from a frame to the video."""
|
||||
|
||||
frame_id: int
|
||||
t_rel_s: float
|
||||
acquired_at: str
|
||||
segment: int
|
||||
segment_frame: int
|
||||
in_video: bool
|
||||
#: True when the frame's data row was lost and only this index row exists.
|
||||
dropped: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecordingInfo:
|
||||
directory: Path
|
||||
session: dict
|
||||
frames: list[RecordingFrame]
|
||||
times: np.ndarray
|
||||
raw: np.ndarray
|
||||
newtons: np.ndarray
|
||||
valid: np.ndarray
|
||||
frame_ids: np.ndarray
|
||||
dropped_rows: int = 0
|
||||
frames_dropped: int = 0
|
||||
#: The index rows were in time order as written (False = a legacy record).
|
||||
frames_out_of_order: bool = True
|
||||
#: The force rows were in time order as written.
|
||||
forces_out_of_order: bool = True
|
||||
#: True when the arrays above are a decimated view of a long recording.
|
||||
decimated: bool = False
|
||||
#: Total rows in ``forces.csv`` (the decimated view has fewer).
|
||||
total_rows: int = 0
|
||||
#: The recording's real duration, from the frame index, never from the view.
|
||||
total_duration_s: float = 0.0
|
||||
|
||||
@property
|
||||
def duration_s(self) -> float:
|
||||
if self.total_duration_s:
|
||||
return float(self.total_duration_s)
|
||||
if self.times.size == 0:
|
||||
return 0.0
|
||||
return float(self.times[-1])
|
||||
|
||||
def value_for_unit(self, unit: str) -> np.ndarray:
|
||||
if str(unit).upper().startswith("N"):
|
||||
return self.newtons
|
||||
return self.raw
|
||||
|
||||
def plottable(self, unit: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
"""``(times, values, mask)`` — the same rule the live charts use.
|
||||
|
||||
The replay used to filter on finiteness alone, so a stale value left by a
|
||||
failed compute step could reappear here after another view had already
|
||||
excluded it. ``mask`` is per axis: a single-axis calibration is normal and
|
||||
must not hide the axis that *is* calibrated.
|
||||
"""
|
||||
values = self.value_for_unit(unit)
|
||||
return self.times, values, plottable_axis_mask(values, self.valid)
|
||||
|
||||
def plottable_axis(self, unit: str, index: int) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""``(times, values)`` for one channel, with the other axes ignored."""
|
||||
values = self.value_for_unit(unit)
|
||||
mask = plottable_axis_mask(values, self.valid)[:, index]
|
||||
return self.times[mask], values[mask, index]
|
||||
|
||||
|
||||
class RecordingReader:
|
||||
"""Open one experiment directory and serve replay queries from it.
|
||||
|
||||
``max_rows`` bounds what :meth:`info` keeps in memory: a long recording is
|
||||
loaded as a decimated view (peaks preserved) and marked ``decimated``, while
|
||||
:meth:`window` and :meth:`overview` read the interval being drawn from disk
|
||||
through :class:`~orisys_app_shared.record.index.RecordingIndex`. That is what
|
||||
lets the replay show an hour-long recording without holding all of it.
|
||||
"""
|
||||
|
||||
#: Above this many rows ``info()`` decimates instead of loading everything.
|
||||
DEFAULT_MAX_ROWS = 200_000
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
directory: str | Path,
|
||||
*,
|
||||
max_rows: int | None = None,
|
||||
checkpoint_rows: int = CHECKPOINT_ROWS,
|
||||
) -> None:
|
||||
self.directory = Path(directory)
|
||||
if not self.directory.is_dir():
|
||||
raise FileNotFoundError(f"Not a recording directory: {self.directory}")
|
||||
self._info: Optional[RecordingInfo] = None
|
||||
self._events: Optional[list[dict]] = None
|
||||
self.max_rows = int(max_rows or self.DEFAULT_MAX_ROWS)
|
||||
self._index = RecordingIndex(self.directory, checkpoint_rows=checkpoint_rows)
|
||||
self._overviews: dict[tuple[str, int], Overview] = {}
|
||||
#: One open decoder per segment, kept between calls. Opening the file and
|
||||
#: seeking costs 10 ms for a small clip and hundreds for 1080p H.264, and
|
||||
#: playback asks for a frame 30 times a second: reopening per frame is
|
||||
#: what made the video stand still.
|
||||
self._capture = None
|
||||
self._capture_segment: int | None = None
|
||||
self._capture_index = -1
|
||||
self._capture_lock = threading.Lock()
|
||||
|
||||
# ------------------------------------------------------------------ loading
|
||||
def info(self) -> RecordingInfo:
|
||||
if self._info is None:
|
||||
self._info = self._load()
|
||||
return self._info
|
||||
|
||||
def index(self) -> RecordingIndex:
|
||||
"""The time index, built once and shared with the windows."""
|
||||
return self._index.ensure_built()
|
||||
|
||||
def session(self) -> dict:
|
||||
path = self.directory / "session.json"
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception: # noqa: BLE001 - a broken session.json must not crash replay
|
||||
logger.exception("Could not read %s", path)
|
||||
return {}
|
||||
|
||||
def _load(self) -> RecordingInfo:
|
||||
frames, frames_ordered = self._load_frames()
|
||||
index = self.index()
|
||||
total_rows = index.rows
|
||||
decimated = total_rows > self.max_rows > 0
|
||||
if decimated:
|
||||
window = self.window_all(
|
||||
max_points=self.max_rows
|
||||
)
|
||||
times = window.times
|
||||
raw = window.raw
|
||||
newtons = window.newtons
|
||||
valid = window.valid
|
||||
frame_ids = window.frame_ids
|
||||
forces_ordered = index.ordered
|
||||
dropped = index.dropped_rows
|
||||
else:
|
||||
(times, raw, newtons, valid, frame_ids, dropped, forces_ordered) = (
|
||||
self._load_forces()
|
||||
)
|
||||
total_duration = 0.0
|
||||
if frames:
|
||||
total_duration = float(frames[-1].t_rel_s)
|
||||
elif times.size:
|
||||
total_duration = float(times[-1])
|
||||
return RecordingInfo(
|
||||
directory=self.directory,
|
||||
session=self.session(),
|
||||
frames=frames,
|
||||
times=times,
|
||||
raw=raw,
|
||||
newtons=newtons,
|
||||
valid=valid,
|
||||
frame_ids=frame_ids,
|
||||
dropped_rows=dropped,
|
||||
frames_dropped=sum(1 for frame in frames if frame.dropped),
|
||||
frames_out_of_order=frames_ordered,
|
||||
forces_out_of_order=forces_ordered,
|
||||
decimated=decimated,
|
||||
total_rows=total_rows,
|
||||
total_duration_s=total_duration,
|
||||
)
|
||||
|
||||
def _load_frames(self) -> tuple[list[RecordingFrame], bool]:
|
||||
"""Load the frame index in time order.
|
||||
|
||||
Recordings made before the ordered-write fix (see
|
||||
``ExperimentRecorder``) can contain a frame index whose rows are not in
|
||||
time order, because the row backlog used to be drained before the queue.
|
||||
``frame_at`` does a binary search over these times, so an unsorted index
|
||||
would silently answer with the wrong frame. The rows are sorted here and
|
||||
the fact is reported as a replay warning.
|
||||
"""
|
||||
path = self.directory / "frames.csv"
|
||||
if not path.is_file():
|
||||
return [], False
|
||||
rows: list[RecordingFrame] = []
|
||||
with path.open(encoding="utf-8", newline="") as handle:
|
||||
for row in csv.DictReader(handle):
|
||||
try:
|
||||
rows.append(
|
||||
RecordingFrame(
|
||||
frame_id=int(row["frame_id"]),
|
||||
t_rel_s=float(row["t_rel_s"]),
|
||||
acquired_at=row.get("acquired_at", ""),
|
||||
segment=int(row.get("segment") or 0),
|
||||
segment_frame=int(row.get("segment_frame") or 0),
|
||||
in_video=row.get("in_video") == "1",
|
||||
dropped=(row.get("dropped") or "0").strip() == "1",
|
||||
)
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
ordered = _is_time_ordered([frame.t_rel_s for frame in rows])
|
||||
if not ordered:
|
||||
rows.sort(key=lambda frame: frame.t_rel_s)
|
||||
return rows, ordered
|
||||
|
||||
def _load_forces(self):
|
||||
index = self.index()
|
||||
window = index.read_rows(0, index.rows)
|
||||
return (window.times, window.raw, window.newtons, window.valid,
|
||||
window.frame_ids, index.dropped_rows, index.ordered)
|
||||
|
||||
# ------------------------------------------------------------------ queries
|
||||
def frame_at(self, t_rel_s: float) -> Optional[RecordingFrame]:
|
||||
"""The recorded frame at or just before ``t_rel_s``.
|
||||
|
||||
The index is sorted at load time, so the binary search is valid; the
|
||||
fallback walk keeps a hand-edited or truncated file from answering with
|
||||
the wrong frame.
|
||||
"""
|
||||
frames = self.info().frames
|
||||
if not frames:
|
||||
return None
|
||||
times = np.asarray([frame.t_rel_s for frame in frames], dtype=np.float64)
|
||||
if not _is_time_ordered(times.tolist()):
|
||||
candidates = [frame for frame in frames if frame.t_rel_s <= float(t_rel_s)]
|
||||
return max(candidates, key=lambda frame: frame.t_rel_s) if candidates else frames[0]
|
||||
index = int(np.searchsorted(times, float(t_rel_s), side="right")) - 1
|
||||
index = max(0, min(index, len(frames) - 1))
|
||||
return frames[index]
|
||||
|
||||
def segment_path(self, segment: int) -> Optional[Path]:
|
||||
if segment <= 0:
|
||||
return None
|
||||
path = self.directory / f"raw_{segment:04d}.mp4"
|
||||
return path if path.is_file() else None
|
||||
|
||||
def segments(self) -> list[Path]:
|
||||
return sorted(self.directory.glob("raw_*.mp4"))
|
||||
|
||||
# ------------------------------------------------------------- replay plan
|
||||
def events(self) -> list[dict]:
|
||||
"""``events.csv`` rows, cached. Operator actions, not measurements."""
|
||||
if self._events is None:
|
||||
path = self.directory / "events.csv"
|
||||
rows: list[dict] = []
|
||||
if path.is_file():
|
||||
with path.open(encoding="utf-8", newline="") as handle:
|
||||
rows = [dict(row) for row in csv.DictReader(handle)]
|
||||
self._events = rows
|
||||
return self._events
|
||||
|
||||
def event_frames(self, kind: str) -> list[int]:
|
||||
"""Frame ids of every event of ``kind`` that names a position."""
|
||||
found: list[int] = []
|
||||
for row in self.events():
|
||||
if str(row.get("kind") or "") != kind:
|
||||
continue
|
||||
try:
|
||||
frame_id = int(row.get("frame_id") or -1)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if frame_id >= 0:
|
||||
found.append(frame_id)
|
||||
return found
|
||||
|
||||
def last_reset_frame(self, at_or_before: int) -> Optional[int]:
|
||||
"""The most recent reset at or before a frame — a tracker state origin.
|
||||
|
||||
``Sensor.reset`` clears the optical-flow tracker and the force filters, so
|
||||
the state at a later frame is fully determined by the frames since that
|
||||
reset and does not need any earlier history. Both spellings are accepted:
|
||||
the app has logged ``zeroing`` since before the replay existed.
|
||||
"""
|
||||
candidates = [
|
||||
frame_id
|
||||
for kind in ("zeroing", "reset")
|
||||
for frame_id in self.event_frames(kind)
|
||||
if frame_id <= int(at_or_before)
|
||||
]
|
||||
return max(candidates) if candidates else None
|
||||
|
||||
def frame_of(self, frame_id: int) -> Optional[RecordingFrame]:
|
||||
for frame in self.info().frames:
|
||||
if frame.frame_id == int(frame_id):
|
||||
return frame
|
||||
return None
|
||||
|
||||
def segment_start_frame(self, segment: int) -> Optional[RecordingFrame]:
|
||||
"""The first indexed frame that lives in ``segment``."""
|
||||
rows = [frame for frame in self.info().frames if frame.segment == int(segment)]
|
||||
if not rows:
|
||||
return None
|
||||
return min(rows, key=lambda frame: frame.segment_frame)
|
||||
|
||||
def preroll_frames(self) -> int:
|
||||
return int(self.info().session.get("preroll_frames") or 0)
|
||||
|
||||
def preroll_segment(self) -> Optional[Path]:
|
||||
"""The pre-roll video recorded just before this recording started."""
|
||||
name = str(self.info().session.get("preroll_segment") or "")
|
||||
if not name:
|
||||
return None
|
||||
path = self.directory / name
|
||||
return path if path.is_file() else None
|
||||
|
||||
def video_frame_at(self, t_rel_s: float):
|
||||
"""Decode the video frame for a replay position.
|
||||
|
||||
Returns ``(frame_bgr, RecordingFrame)`` or ``(None, frame)`` when the
|
||||
frame was not written to video (the queue dropped it).
|
||||
|
||||
The decoder stays open and is *stepped* when the requested frame follows
|
||||
the previous one, which is what playback does: a seek costs a keyframe
|
||||
decode, a step costs one frame. Seeking is kept for jumps (dragging the
|
||||
cursor).
|
||||
"""
|
||||
import cv2
|
||||
|
||||
frame = self.frame_at(t_rel_s)
|
||||
if frame is None or not frame.in_video:
|
||||
return None, frame
|
||||
target = max(0, int(frame.segment_frame) - 1)
|
||||
with self._capture_lock:
|
||||
capture = self._capture_for(frame.segment)
|
||||
if capture is None:
|
||||
return None, frame
|
||||
try:
|
||||
if self._capture_index >= 0 and target == self._capture_index + 1:
|
||||
ok, image = capture.read()
|
||||
else:
|
||||
capture.set(cv2.CAP_PROP_POS_FRAMES, target)
|
||||
ok, image = capture.read()
|
||||
except Exception: # noqa: BLE001 - a broken decode is not a crash
|
||||
logger.exception("Could not decode frame %s", target)
|
||||
self._release_capture()
|
||||
return None, frame
|
||||
# A failed read leaves the decoder's position unknown: start over next
|
||||
# time rather than stepping from a frame that was never delivered.
|
||||
self._capture_index = target if ok else -1
|
||||
return (image if ok else None), frame
|
||||
|
||||
def _capture_for(self, segment: int):
|
||||
"""The open decoder for ``segment``, opening (or switching) as needed."""
|
||||
import cv2
|
||||
|
||||
if self._capture is not None and self._capture_segment == int(segment):
|
||||
return self._capture
|
||||
self._release_capture()
|
||||
path = self.segment_path(int(segment))
|
||||
if path is None:
|
||||
return None
|
||||
capture = cv2.VideoCapture(str(path))
|
||||
if not capture.isOpened():
|
||||
capture.release()
|
||||
return None
|
||||
self._capture = capture
|
||||
self._capture_segment = int(segment)
|
||||
self._capture_index = -1
|
||||
return capture
|
||||
|
||||
def _release_capture(self) -> None:
|
||||
if self._capture is not None:
|
||||
try:
|
||||
self._capture.release()
|
||||
except Exception: # noqa: BLE001 - releasing is best effort
|
||||
logger.debug("Could not release the replay decoder", exc_info=True)
|
||||
self._capture = None
|
||||
self._capture_segment = None
|
||||
self._capture_index = -1
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release the decoder. The reader is reusable afterwards."""
|
||||
with self._capture_lock:
|
||||
self._release_capture()
|
||||
|
||||
def forces_window(self, t0: float, t1: float) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""``(times, values)`` inside ``[t0, t1]`` for the raw triplet."""
|
||||
info = self.info()
|
||||
if info.times.size == 0:
|
||||
return info.times, info.raw
|
||||
mask = (info.times >= float(t0)) & (info.times <= float(t1))
|
||||
return info.times[mask], info.raw[mask]
|
||||
|
||||
# ------------------------------------------------------ on-demand history
|
||||
def window(
|
||||
self,
|
||||
t0: float,
|
||||
t1: float,
|
||||
*,
|
||||
unit: str = "raw",
|
||||
max_points: int | None = None,
|
||||
) -> ForceWindow:
|
||||
"""The recorded rows inside ``[t0, t1]``, read from disk.
|
||||
|
||||
This is the interval read: it seeks to the nearest checkpoint in the
|
||||
index and scans forward, so asking for ten seconds of an hour-long
|
||||
recording costs ten seconds of parsing, not an hour. ``max_points``
|
||||
decimates *while* reading, so the interval never has to exist in memory
|
||||
in full.
|
||||
"""
|
||||
start, stop = self.index().row_range(t0, t1)
|
||||
return self.index().read_rows(
|
||||
start, stop, max_points=max_points, unit=unit
|
||||
)
|
||||
|
||||
def window_all(self, *, unit: str = "raw", max_points: int | None = None) -> ForceWindow:
|
||||
"""The whole recording as one window, decimated while streaming."""
|
||||
return self.window(
|
||||
*self.index().extent(), unit=unit, max_points=max_points
|
||||
)
|
||||
|
||||
def overview(self, *, unit: str = "raw", buckets: int = DEFAULT_BUCKETS) -> Overview:
|
||||
"""A whole-session envelope, cached per (unit, bucket count)."""
|
||||
key = (str(unit), int(buckets))
|
||||
if key not in self._overviews:
|
||||
self._overviews[key] = self.index().overview(
|
||||
unit=str(unit), buckets=int(buckets)
|
||||
)
|
||||
return self._overviews[key]
|
||||
|
||||
def statistics(self, t0: float, t1: float, *, unit: str = "raw"):
|
||||
"""Statistics of an interval, computed from the interval's own rows."""
|
||||
return self.index().statistics(t0, t1, unit=unit)
|
||||
|
||||
# ------------------------------------------------------------------ policy
|
||||
def depth_policy(self) -> dict:
|
||||
return dict(self.info().session.get("depth") or {})
|
||||
|
||||
def depth_is_stored(self) -> bool:
|
||||
return bool(self.depth_policy().get("stored", False))
|
||||
|
||||
def recorded_config_ref(self) -> str:
|
||||
device = self.info().session.get("device") or {}
|
||||
return str(device.get("config_ref") or "")
|
||||
|
||||
@staticmethod
|
||||
def config_sha256(config_ref: str) -> str:
|
||||
"""Hash of the config file currently on disk (empty when unreadable)."""
|
||||
if not config_ref:
|
||||
return ""
|
||||
try:
|
||||
import hashlib
|
||||
|
||||
path = Path(str(config_ref))
|
||||
if path.is_file():
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
except Exception: # noqa: BLE001 - a missing config is reported, not raised
|
||||
return ""
|
||||
return ""
|
||||
|
||||
def replay_warnings(self, *, current_config_sha: str = "", sdk_version: str = "") -> list[str]:
|
||||
"""What could stop this recording from replaying faithfully.
|
||||
|
||||
A changed config or SDK means the recorded frames are read by a different
|
||||
pipeline than the one that produced them; that must be stated, not
|
||||
discovered later.
|
||||
"""
|
||||
session = self.info().session
|
||||
warnings: list[str] = []
|
||||
recorded_sha = str((session.get("device") or {}).get("config_sha256") or "")
|
||||
if not current_config_sha:
|
||||
# Compare against the config file as it is now, which is exactly what
|
||||
# a recompute over the video would use.
|
||||
current_config_sha = self.config_sha256(self.recorded_config_ref())
|
||||
if recorded_sha and current_config_sha and recorded_sha != current_config_sha:
|
||||
warnings.append("config_changed")
|
||||
versions = session.get("versions") or {}
|
||||
recorded_sdk = str(versions.get("sdk") or "")
|
||||
if recorded_sdk and sdk_version and recorded_sdk != sdk_version:
|
||||
warnings.append("sdk_changed")
|
||||
if recorded_sha and not current_config_sha:
|
||||
warnings.append("config_missing")
|
||||
if self.info().dropped_rows:
|
||||
warnings.append("rows_skipped")
|
||||
if self.info().frames_dropped:
|
||||
warnings.append("frames_dropped")
|
||||
if not self.info().frames_out_of_order or not self.info().forces_out_of_order:
|
||||
warnings.append("out_of_order")
|
||||
return warnings
|
||||
|
||||
def iter_forces(self, batch: int = 4096) -> Iterator[tuple[np.ndarray, np.ndarray]]:
|
||||
"""Stream the force rows, so a long recording is not read twice."""
|
||||
path = self.directory / "forces.csv"
|
||||
if not path.is_file():
|
||||
return
|
||||
times: list[float] = []
|
||||
values: list[tuple[float, float, float, float, float, float]] = []
|
||||
with path.open(encoding="utf-8", newline="") as handle:
|
||||
for row in csv.DictReader(handle):
|
||||
try:
|
||||
times.append(float(row["t_rel_s"]))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
values.append(
|
||||
tuple(
|
||||
float(row[key]) if (row.get(key) or "").strip() else float("nan")
|
||||
for key in ("FN_raw", "FX_raw", "FY_raw", "FN_N", "FX_N", "FY_N")
|
||||
)
|
||||
)
|
||||
if len(times) >= batch:
|
||||
yield np.asarray(times), np.asarray(values).reshape(-1, 6)
|
||||
times, values = [], []
|
||||
if times:
|
||||
yield np.asarray(times), np.asarray(values).reshape(-1, 6)
|
||||
|
||||
|
||||
def _is_time_ordered(values) -> bool:
|
||||
"""True when ``values`` never decreases (an empty or single value is ordered)."""
|
||||
previous = None
|
||||
for value in values:
|
||||
if previous is not None and float(value) < float(previous):
|
||||
return False
|
||||
previous = value
|
||||
return True
|
||||
|
||||
|
||||
def decimate_window(window: ForceWindow, max_points: int, *, unit: str = "raw") -> ForceWindow:
|
||||
"""Reduce a window to about ``max_points``, keeping every axis' extremes.
|
||||
|
||||
The same rule the live store uses: each bucket contributes the extreme row of
|
||||
**every plotted axis** in the active unit, so a shear spike survives a flat
|
||||
normal channel. The result is marked ``decimated``.
|
||||
"""
|
||||
total = len(window)
|
||||
max_points = max(4, int(max_points))
|
||||
if total <= max_points:
|
||||
return window
|
||||
values = window.value_for_unit(unit)
|
||||
def index_of(row: int) -> int:
|
||||
return int(window.frame_ids[row]) if total else row
|
||||
bucket = max(1, total // max(1, max_points // 6))
|
||||
picked: set[int] = set()
|
||||
for start in range(0, total, bucket):
|
||||
stop = min(total, start + bucket)
|
||||
for axis in range(3):
|
||||
column = values[start:stop, axis]
|
||||
finite = np.isfinite(column)
|
||||
if not finite.any():
|
||||
continue
|
||||
ordered = np.where(finite, column, -np.inf)
|
||||
picked.add(start + int(np.argmin(ordered)))
|
||||
picked.add(start + int(np.argmax(ordered)))
|
||||
if not picked:
|
||||
return window
|
||||
rows = sorted(picked)
|
||||
return ForceWindow(
|
||||
times=window.times[rows],
|
||||
raw=window.raw[rows],
|
||||
newtons=window.newtons[rows],
|
||||
valid=window.valid[rows],
|
||||
frame_ids=window.frame_ids[rows],
|
||||
decimated=True,
|
||||
)
|
||||
|
||||
|
||||
def list_recordings(root: str | Path) -> list[Path]:
|
||||
base = Path(root)
|
||||
if not base.is_dir():
|
||||
return []
|
||||
candidates = [
|
||||
path
|
||||
for path in base.iterdir()
|
||||
if path.is_dir() and (path / "forces.csv").is_file()
|
||||
]
|
||||
return sorted(candidates, key=lambda path: path.name, reverse=True)
|
||||
@@ -0,0 +1,238 @@
|
||||
"""The one conversion from an acquired packet to a recorded sample.
|
||||
|
||||
Everything that records or displays force data goes through
|
||||
:func:`to_record_sample`. There used to be two independent conversions — one in
|
||||
``ExperimentRecorder.on_packet`` (for the CSV) and one in
|
||||
``StudioMainWindow._on_packet_recorded`` (for the in-memory session store) — and
|
||||
they disagreed with each other and with the truth:
|
||||
|
||||
* an uncalibrated sensor reports its raw counts in the *ordinary* force fields,
|
||||
so those numbers were written into the ``FN_N``/``FX_N``/``FY_N`` columns;
|
||||
* when the compute chain fails, the SDK's ``info.FNORMAL`` still returns the
|
||||
value it last computed, so a failed frame recorded a **stale finite number**
|
||||
that was indistinguishable from a fresh measurement.
|
||||
|
||||
The rules are therefore:
|
||||
|
||||
1. A sample whose compute chain failed is recorded with **all six force values
|
||||
unavailable**. The value may belong to an earlier frame; nothing can prove
|
||||
otherwise, so it is not recorded as a measurement.
|
||||
2. A newton value is recorded **only for an axis that actually has a calibration
|
||||
curve loaded**. An uncalibrated axis leaves its column empty.
|
||||
3. A raw value is recorded only when the sensor exposes that raw channel.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable
|
||||
|
||||
AXES: tuple[str, ...] = ("fn", "fx", "fy")
|
||||
|
||||
#: Reason recorded when a stale force value had to be discarded.
|
||||
STALE_REASON = "stale forces: the compute chain failed for this frame"
|
||||
|
||||
|
||||
def _finite(value: object) -> float:
|
||||
"""Coerce to a float, mapping anything unusable to NaN."""
|
||||
try:
|
||||
number = float(value) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return float("nan")
|
||||
return number if math.isfinite(number) else float("nan")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecordSample:
|
||||
"""One acquired step, as it is recorded."""
|
||||
|
||||
frame_id: int
|
||||
#: Monotonic, session-relative acquisition time. This is the ordering key
|
||||
#: for the session store, the CSV and the frame index; a wall clock that
|
||||
#: steps backwards must not be able to reorder history.
|
||||
mono_t: float
|
||||
#: Wall clock at hand-off, kept for metadata only.
|
||||
wall_t: float
|
||||
valid: bool
|
||||
invalid_reason: str
|
||||
fn_raw: float = float("nan")
|
||||
fx_raw: float = float("nan")
|
||||
fy_raw: float = float("nan")
|
||||
fn_n: float = float("nan")
|
||||
fx_n: float = float("nan")
|
||||
fy_n: float = float("nan")
|
||||
calibrated_axes: tuple[str, ...] = ()
|
||||
calibration_version: str = ""
|
||||
|
||||
# ------------------------------------------------------------- accessors
|
||||
def raw_triplet(self) -> tuple[float, float, float]:
|
||||
return (self.fn_raw, self.fx_raw, self.fy_raw)
|
||||
|
||||
def newton_triplet(self) -> tuple[float, float, float]:
|
||||
return (self.fn_n, self.fx_n, self.fy_n)
|
||||
|
||||
def raw_for(self, axis: str) -> float:
|
||||
return {"fn": self.fn_raw, "fx": self.fx_raw, "fy": self.fy_raw}.get(
|
||||
axis, float("nan")
|
||||
)
|
||||
|
||||
def newton_for(self, axis: str) -> float:
|
||||
return {"fn": self.fn_n, "fx": self.fx_n, "fy": self.fy_n}.get(
|
||||
axis, float("nan")
|
||||
)
|
||||
|
||||
def has_newton(self, axis: str) -> bool:
|
||||
return math.isfinite(self.newton_for(axis))
|
||||
|
||||
def has_raw(self, axis: str) -> bool:
|
||||
return math.isfinite(self.raw_for(axis))
|
||||
|
||||
def value_for_unit(self, axis: str, unit: str) -> float:
|
||||
if str(unit).upper().startswith("N"):
|
||||
return self.newton_for(axis)
|
||||
return self.raw_for(axis)
|
||||
|
||||
def as_row(self) -> tuple:
|
||||
"""Positional form used by the CSV writer and the session store."""
|
||||
return (
|
||||
self.frame_id,
|
||||
self.mono_t,
|
||||
self.fn_raw,
|
||||
self.fx_raw,
|
||||
self.fy_raw,
|
||||
self.fn_n,
|
||||
self.fx_n,
|
||||
self.fy_n,
|
||||
1 if self.valid else 0,
|
||||
)
|
||||
|
||||
|
||||
def calibrated_axes_of(packet) -> tuple[str, ...]:
|
||||
"""Which axes have a calibration curve, per axis rather than per sensor."""
|
||||
declared = tuple(str(axis) for axis in (getattr(packet, "calibrated_axes", ()) or ()))
|
||||
if declared:
|
||||
return tuple(axis for axis in AXES if axis in declared)
|
||||
# Backwards compatible fallback for packets that only carry the sensor-level
|
||||
# flag: calibrated means all three, uncalibrated means none.
|
||||
if bool(getattr(packet, "force_calibrated", False)):
|
||||
return AXES
|
||||
return ()
|
||||
|
||||
|
||||
def to_record_sample(packet, *, calibration_version: str = "") -> RecordSample:
|
||||
"""Convert one packet into the single canonical recorded form."""
|
||||
frame_id = int(getattr(packet, "frame_id", 0))
|
||||
# ``mono_t`` is the acquisition clock and is authoritative when present; a
|
||||
# packet that carries only a wall clock (a test double, or an older
|
||||
# producer) still has to produce a *moving* time, never a constant one.
|
||||
mono_t = _finite(getattr(packet, "mono_t", None))
|
||||
wall_t = _finite(getattr(packet, "wall_t", None))
|
||||
if not math.isfinite(mono_t):
|
||||
mono_t = wall_t
|
||||
if not math.isfinite(mono_t):
|
||||
mono_t = _finite(getattr(packet, "timestamp", 0.0))
|
||||
if not math.isfinite(wall_t):
|
||||
wall_t = _finite(getattr(packet, "timestamp", 0.0))
|
||||
if not math.isfinite(wall_t):
|
||||
wall_t = mono_t
|
||||
|
||||
valid = bool(getattr(packet, "valid", True))
|
||||
reason = str(getattr(packet, "invalid_reason", "") or "")
|
||||
stale = bool(getattr(packet, "stale_forces", False))
|
||||
# A packet that *carries* a version owns it, including the empty one: the
|
||||
# acquisition thread stamps every frame, and an empty stamp is the device
|
||||
# saying "no calibration loaded". Falling back to the session default there
|
||||
# would resurrect coefficients that are no longer in effect. A packet from an
|
||||
# older producer that has no version field at all still gets the default.
|
||||
packet_version = getattr(packet, "calibration_version", None)
|
||||
version = (
|
||||
str(calibration_version or "")
|
||||
if packet_version is None
|
||||
else str(packet_version)
|
||||
)
|
||||
axes = calibrated_axes_of(packet)
|
||||
|
||||
if not valid or stale:
|
||||
if stale and not reason:
|
||||
reason = STALE_REASON
|
||||
# Rule 1: a frame with no trustworthy readout records nothing numeric.
|
||||
return RecordSample(
|
||||
frame_id=frame_id,
|
||||
mono_t=mono_t,
|
||||
wall_t=wall_t,
|
||||
valid=False,
|
||||
invalid_reason=reason,
|
||||
calibrated_axes=axes,
|
||||
calibration_version=version,
|
||||
)
|
||||
|
||||
raw_available = bool(getattr(packet, "raw_available", True))
|
||||
raw = (
|
||||
_finite(getattr(packet, "normal_force_raw", None)),
|
||||
_finite(getattr(packet, "shear_x_raw", None)),
|
||||
_finite(getattr(packet, "shear_y_raw", None)),
|
||||
)
|
||||
if not raw_available:
|
||||
raw = (float("nan"), float("nan"), float("nan"))
|
||||
|
||||
newton = (
|
||||
_finite(getattr(packet, "normal_force", None)),
|
||||
_finite(getattr(packet, "shear_x", None)),
|
||||
_finite(getattr(packet, "shear_y", None)),
|
||||
)
|
||||
# Rule 2: an axis without a calibration curve has no newton value at all.
|
||||
newton = tuple(
|
||||
value if axis in axes else float("nan")
|
||||
for axis, value in zip(AXES, newton)
|
||||
)
|
||||
|
||||
return RecordSample(
|
||||
frame_id=frame_id,
|
||||
mono_t=mono_t,
|
||||
wall_t=wall_t,
|
||||
valid=True,
|
||||
invalid_reason="",
|
||||
fn_raw=raw[0],
|
||||
fx_raw=raw[1],
|
||||
fy_raw=raw[2],
|
||||
fn_n=newton[0],
|
||||
fx_n=newton[1],
|
||||
fy_n=newton[2],
|
||||
calibrated_axes=axes,
|
||||
calibration_version=version,
|
||||
)
|
||||
|
||||
|
||||
def axes_without_newton(sample: RecordSample) -> tuple[str, ...]:
|
||||
"""Axes that produced no newton value — used by the UI to explain blanks."""
|
||||
return tuple(axis for axis in AXES if not sample.has_newton(axis))
|
||||
|
||||
|
||||
def plottable_mask(values: Iterable[float], valid: Iterable[bool]) -> list[bool]:
|
||||
"""Shared visibility rule: a point is drawn only when valid *and* finite.
|
||||
|
||||
Every drawing path uses this so that a stale-but-finite value cannot reappear
|
||||
in one view after being filtered out of another.
|
||||
"""
|
||||
return [
|
||||
bool(is_valid) and math.isfinite(float(value))
|
||||
for value, is_valid in zip(values, valid)
|
||||
]
|
||||
|
||||
|
||||
def plottable_axis_mask(values, valid) -> "np.ndarray":
|
||||
"""``(n, axes)`` mask: one column per axis, decided for that axis alone.
|
||||
|
||||
The rule has to be per axis. A single-axis calibration is legitimate — the
|
||||
device may have a normal-force curve and no shear curves — and an all-axes
|
||||
rule hid a perfectly good FN because FX and FY had no newton value.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
block = np.asarray(values, dtype=np.float64).reshape(-1, 3)
|
||||
validity = np.asarray(valid, dtype=bool).reshape(-1)
|
||||
finite = np.isfinite(block)
|
||||
if validity.size != block.shape[0]:
|
||||
return finite
|
||||
return finite & validity[:, None]
|
||||
@@ -0,0 +1,97 @@
|
||||
"""The video side of a recording: one rolling MP4 segment at a time.
|
||||
|
||||
Kept apart from the CSV writer on purpose. The two have different failure modes —
|
||||
a full disk breaks both, but a codec that refuses to open only breaks this one —
|
||||
and the recorder's byte budget is about *these* frames, not about the rows.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import cv2
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VideoSegments:
|
||||
"""Writes raw frames into ``raw_NNNN.mp4`` files, rolling on a time budget."""
|
||||
|
||||
def __init__(self, directory: Path, *, codec: str, fps: float, segment_seconds: float):
|
||||
self.directory = Path(directory)
|
||||
self.codec = str(codec)
|
||||
self.fps = max(1.0, float(fps))
|
||||
self.segment_seconds = float(segment_seconds)
|
||||
self.segments = 0
|
||||
self.video_frames = 0
|
||||
self.frame_shape: Optional[tuple[int, int]] = None
|
||||
self._writer: Optional[cv2.VideoWriter] = None
|
||||
self._index = 0
|
||||
self._frames_in_segment = 0
|
||||
self._started = 0.0
|
||||
|
||||
# ------------------------------------------------------------------ writing
|
||||
def write(self, frame) -> tuple[int, int]:
|
||||
"""Append one frame. Returns ``(segment, frame_in_segment)``."""
|
||||
if self.frame_shape is None:
|
||||
self.frame_shape = (int(frame.shape[1]), int(frame.shape[0]))
|
||||
if self._should_roll():
|
||||
self._close_segment()
|
||||
if self._writer is None:
|
||||
self._open_segment()
|
||||
self._writer.write(frame)
|
||||
self._frames_in_segment += 1
|
||||
self.video_frames += 1
|
||||
return (self._index, self._frames_in_segment)
|
||||
|
||||
def write_sequence(self, frames) -> int:
|
||||
"""Write a run of frames as one standalone segment. Returns the count."""
|
||||
frames = [frame for frame in frames if frame is not None]
|
||||
if not frames:
|
||||
return 0
|
||||
self.frame_shape = (int(frames[0].shape[1]), int(frames[0].shape[0]))
|
||||
name = "preroll_0001.mp4"
|
||||
writer = self._open_writer(self.directory / name)
|
||||
written = 0
|
||||
try:
|
||||
for frame in frames:
|
||||
writer.write(frame)
|
||||
written += 1
|
||||
finally:
|
||||
writer.release()
|
||||
return written
|
||||
|
||||
# ----------------------------------------------------------------- segments
|
||||
def _should_roll(self) -> bool:
|
||||
if self._writer is None:
|
||||
return False
|
||||
if self.segment_seconds <= 0:
|
||||
return False
|
||||
return (time.perf_counter() - self._started) >= self.segment_seconds
|
||||
|
||||
def _open_writer(self, path: Path) -> cv2.VideoWriter:
|
||||
fourcc = cv2.VideoWriter_fourcc(*self.codec)
|
||||
writer = cv2.VideoWriter(str(path), fourcc, self.fps, self.frame_shape)
|
||||
if not writer.isOpened():
|
||||
raise OSError(f"could not open video writer for {path.name}")
|
||||
return writer
|
||||
|
||||
def _open_segment(self) -> None:
|
||||
self._index += 1
|
||||
path = self.directory / f"raw_{self._index:04d}.mp4"
|
||||
self._writer = self._open_writer(path)
|
||||
self._frames_in_segment = 0
|
||||
self._started = time.perf_counter()
|
||||
self.segments = self._index
|
||||
|
||||
def _close_segment(self) -> None:
|
||||
if self._writer is None:
|
||||
return
|
||||
self._writer.release()
|
||||
self._writer = None
|
||||
|
||||
def close(self) -> None:
|
||||
self._close_segment()
|
||||
@@ -1,23 +1,51 @@
|
||||
{
|
||||
"window_bg": "#121316",
|
||||
"panel_bg": "#1a1c20",
|
||||
"panel_border": "#2a2d33",
|
||||
"text_primary": "#e8eaed",
|
||||
"text_secondary": "#9aa0a6",
|
||||
"accent": "#536dce",
|
||||
"accent_hover": "#6b82d9",
|
||||
"button_bg": "#2a2d33",
|
||||
"button_hover": "#3a3f47",
|
||||
"input_bg": "#0f1012",
|
||||
"input_border": "#3a3f47",
|
||||
"menu_bg": "#1a1c20",
|
||||
"menu_hover": "#2a2d33",
|
||||
"plot_fn": "#8ab4f8",
|
||||
"plot_fx": "#34a853",
|
||||
"plot_fy": "#fbbc04",
|
||||
"plot_bg": "#121316",
|
||||
"plot_grid": "#2a2d33",
|
||||
"arrow_canvas": "#121316",
|
||||
"danger": "#ea4335",
|
||||
"success": "#34a853"
|
||||
"window_bg": "#0E1320",
|
||||
"panel_bg": "#161C2A",
|
||||
"panel_border": "#28303F",
|
||||
"toolbar_bg": "#161C2A",
|
||||
"card_bg": "#1B2231",
|
||||
"card_border": "#2E3748",
|
||||
"separator": "#28303F",
|
||||
"input_bg": "#0B0F19",
|
||||
"input_border": "#66739A",
|
||||
"button_bg": "#28303F",
|
||||
"button_hover": "#333D50",
|
||||
"menu_bg": "#161C2A",
|
||||
"menu_hover": "#28303F",
|
||||
"table_header_bg": "#1F2635",
|
||||
"table_row_alt": "#1A2130",
|
||||
"table_selected": "#2C3A63",
|
||||
"scrollbar_bg": "#161C2A",
|
||||
"scrollbar_handle": "#627092",
|
||||
"scrollbar_handle_hover": "#6E7C9E",
|
||||
"plot_bg": "#11161F",
|
||||
"plot_grid": "#28303F",
|
||||
"plot_fn": "#6FA8FF",
|
||||
"plot_fx": "#FF9A4D",
|
||||
"plot_fy": "#7CE0A0",
|
||||
"text_primary": "#E6EAF2",
|
||||
"text_secondary": "#9AA4B8",
|
||||
"text_muted": "#838FA6",
|
||||
"hint_text": "#838FA6",
|
||||
"accent": "#4D62BE",
|
||||
"accent_hover": "#5770CE",
|
||||
"accent_text": "#FFFFFF",
|
||||
"focus_ring": "#7C92E8",
|
||||
"danger": "#EE6B60",
|
||||
"danger_hover": "#F0796E",
|
||||
"danger_fill": "#CC4438",
|
||||
"danger_fill_hover": "#BE3A31",
|
||||
"danger_text": "#FFFFFF",
|
||||
"warning": "#F0B429",
|
||||
"info": "#8AA6F2",
|
||||
"success": "#4FC08D",
|
||||
"status_idle": "#9AA4B8",
|
||||
"status_connecting": "#F0B429",
|
||||
"status_running": "#4FC08D",
|
||||
"status_error": "#EE6B60",
|
||||
"brand_bar_bg": "#12315D",
|
||||
"brand_bar_fg": "#FFFFFF",
|
||||
"brand_bar_fg_muted": "#B9C8E4",
|
||||
"arrow_canvas": "#0B1830",
|
||||
"flow_ramp": "#0B1830,#6E4AD0,#FA02F3,#FF8A6B,#D8FF00"
|
||||
}
|
||||
|
||||
@@ -1,23 +1,51 @@
|
||||
{
|
||||
"window_bg": "#f5f6f8",
|
||||
"panel_bg": "#ffffff",
|
||||
"panel_border": "#d0d4dc",
|
||||
"text_primary": "#1a1c20",
|
||||
"text_secondary": "#5f6368",
|
||||
"accent": "#536dce",
|
||||
"accent_hover": "#3f56b5",
|
||||
"button_bg": "#e8eaed",
|
||||
"button_hover": "#d0d4dc",
|
||||
"input_bg": "#ffffff",
|
||||
"input_border": "#c0c4cc",
|
||||
"menu_bg": "#ffffff",
|
||||
"menu_hover": "#e8eaed",
|
||||
"plot_fn": "#1a73e8",
|
||||
"plot_fx": "#188038",
|
||||
"plot_fy": "#f9ab00",
|
||||
"plot_bg": "#ffffff",
|
||||
"plot_grid": "#e0e3e8",
|
||||
"arrow_canvas": "#eef0f3",
|
||||
"danger": "#d93025",
|
||||
"success": "#188038"
|
||||
"window_bg": "#E1E6EF",
|
||||
"panel_bg": "#FFFFFF",
|
||||
"panel_border": "#CFD7E4",
|
||||
"toolbar_bg": "#EDF1F8",
|
||||
"card_bg": "#F4F7FC",
|
||||
"card_border": "#D5DCE8",
|
||||
"separator": "#D8DFEA",
|
||||
"input_bg": "#FFFFFF",
|
||||
"input_border": "#7C8AA4",
|
||||
"button_bg": "#F6F8FD",
|
||||
"button_hover": "#E7EDF9",
|
||||
"menu_bg": "#FFFFFF",
|
||||
"menu_hover": "#EDF1F8",
|
||||
"table_header_bg": "#EEF2F9",
|
||||
"table_row_alt": "#F7F9FD",
|
||||
"table_selected": "#D7E2FA",
|
||||
"scrollbar_bg": "#E6EBF3",
|
||||
"scrollbar_handle": "#78839A",
|
||||
"scrollbar_handle_hover": "#6A7590",
|
||||
"plot_bg": "#F8FAFD",
|
||||
"plot_grid": "#DFE5EE",
|
||||
"plot_fn": "#003893",
|
||||
"plot_fx": "#B45309",
|
||||
"plot_fy": "#2E7D32",
|
||||
"text_primary": "#141A24",
|
||||
"text_secondary": "#4A5566",
|
||||
"text_muted": "#5C6678",
|
||||
"hint_text": "#5C6678",
|
||||
"accent": "#003893",
|
||||
"accent_hover": "#4D62BE",
|
||||
"accent_text": "#FFFFFF",
|
||||
"focus_ring": "#4D62BE",
|
||||
"danger": "#C7362B",
|
||||
"danger_hover": "#B93227",
|
||||
"danger_fill": "#C7362B",
|
||||
"danger_fill_hover": "#B93227",
|
||||
"danger_text": "#FFFFFF",
|
||||
"warning": "#8A5A00",
|
||||
"info": "#3A55B8",
|
||||
"success": "#1F7A4D",
|
||||
"status_idle": "#4A5566",
|
||||
"status_connecting": "#8A5A00",
|
||||
"status_running": "#1F7A4D",
|
||||
"status_error": "#C7362B",
|
||||
"brand_bar_bg": "#12315D",
|
||||
"brand_bar_fg": "#FFFFFF",
|
||||
"brand_bar_fg_muted": "#B9C8E4",
|
||||
"arrow_canvas": "#12315D",
|
||||
"flow_ramp": "#12315D,#6E4AD0,#FA02F3,#FF8A6B,#D8FF00"
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user