# 记录通道与数据可靠性 本文记录 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//`;应用前弹窗显示目标设备类型与配置,且只下发给匹配的 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/_session/ 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 的采集缓冲,直接持有引用 会让录制内容随后续帧改变。 ### 已知环境限制 本机未安装 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 深度重算与游标同步(P1-3、P1-4) - **不再重开回放位置**:`DepthRecomputer` 记录当前已读到的段内帧号,请求第 2 帧再请求第 3 帧只多读 1 帧(旧实现每次都从头读,2 和 3 会落到 5);往回退才重开视频。 - 每帧都执行**实时同一条链路**:盒子 `compute_deformation + compute_contact`,手指再加上 `FingerRuntime` 的展开与有效区域(`_BoxReplay` / `_FingerReplay`)。 - `config_name` 用**完整解析后的配置路径**(`load_fixed_mode`),不再缩减成父目录名或 stem —— 那可能加载同名但不同的配置,而哈希校验还在比对原路径。 - **算法参考状态**:SDK 的跟踪器/滤波是有状态的,离线复现需要历史。两条可用依据: 1. 操作归零(`zeroing` 事件,带帧号)会清空跟踪器,因此从该帧重放是**精确**的; 2. 录制开始前采集线程保留 `PREROLL_FRAMES`(30 帧,且不超过 192 MiB)的预处理帧, 录制开始时写入 `preroll_0001.mp4`,`session.json` 记 `preroll_frames` / `preroll_segment`,回放先把它们喂给 SDK 再进入首帧。 都不满足时(跨分段、无归零、无预卷)仍然重算,但结果带 `state_approximate` / `state_across_segment` 提示,**不假装与当时一致**。 - 回放游标与深度结果按**请求序号 + 帧号**绑定:等待期间移动游标会作废旧请求,结果回来 只渲染属于当前帧的那一张;否则面板显示「当前游标位置尚未重算深度」,绝不复用旧图。 --- ## 7. 同步回放与深度重算 **决定:不保存深度。** 回放时按录像与配置**离线重算**。 理由是体积:30 Hz、320×240 的 float32 深度每帧约 300 KB,一小时就是 ~16 GB; 即使降频到 1 Hz 也要百兆级。所以 `session.json` 里明确写: ```json "depth": { "stored": false, "replay": "recompute", "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": "..." } ``` 代价是重算要 SDK 能复现同样结果,因此 `RecordingReader.replay_warnings()` 会主动 比对**当前配置文件哈希**与录制时的哈希、以及 SDK 版本,回放窗口顶部把这些提示出来: | 提示 | 含义 | |---|---| | `depth_recomputed` | 深度来自离线重算,不是当时保存的数据(始终提示) | | `config_changed` | 配置文件已变,重算结果可能与当时不同 | | `sdk_changed` | SDK 版本已变,重算结果可能不同 | | `config_missing` | 缺少配置哈希,无法校验一致性 | | `rows_skipped` | 有无法解析的 CSV 行被跳过 | ### 回放界面 `ui/playback_dialog.py`,入口在「文件 → 打开录制回放…/回放最近一次录制」。 - **一个游标驱动三处**:移动位置同时更新视频帧、深度面板与三张曲线的游标线; 并且通过 `cursor_moved` 信号同步到主界面的曲线面板。 - 单位可切换(raw/N),数据直接来自录制目录的 `forces.csv`。 - 帧未被写入视频时(记录队列曾溢出)明确提示,而不是显示上一帧冒充当前帧。 - **深度重算在守护线程上执行**,UI 线程立即返回。实测 `_on_recompute_depth` 返回耗时 0.001 s,而 SDK 调用仍在后台跑。 两个必须知晓的边界: 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、队列积压与丢失。