subject: store the whole session and drive calibration from the acquisition stream

This commit is contained in:
ykcheungab
2026-09-13 12:34:47 +08:00
parent f16d2b3d84
commit db228b6f57
6 changed files with 958 additions and 32 deletions
+131
View File
@@ -0,0 +1,131 @@
# 记录通道与数据可靠性
本文记录 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,不会杀掉采集。
### 关键文件
| 文件 | 职责 |
|---|---|
| `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_worker_cancellation.py`:4 项,覆盖非阻塞 stop、线程所有权、
会话交接。
- `tools/ui_check.py`:新增 `record channel` 段落,断言记录通道在**完全没有 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. 尚未完成(第二阶段及以后)
- MP4 分段录制、`frames.csv` 帧索引、`session.json` 元数据(评审第一阶段剩余部分)。
- CSV 导出(`session_id/frame_id/t_rel_s/FN_raw/…/valid/calibration_id`)——数据模型
`SessionSample` 已按这些字段设计,`SessionStore.iter_valid()` 已可直接供导出使用。
- 全程曲线浏览(跟随/历史、框选缩放、降采样保峰值)。
- 标定诊断扩展:稳定后采样、异常点排除/恢复、拟合残差、独立验证点。当前
`CalibrationPoint` 已记录三轴均值/标准差/有效样本数、方向、轮次与实际采样时长。
- PyOpenGL 三维显示(`mesh3d_panel.py` 目前仍是占位符,不是已有实现)。