record the full experiment: video, CSV and metadata

Phase 1 of the review asked for one directory per experiment, a force CSV,
video of the pre-processing image, and metadata that ties them together. The
record channel from the previous change already kept the whole session; this
adds the on-disk format.

What
- Add record/experiment.py: ExperimentRecorder writes one directory per
  session containing session.json, forces.csv, frames.csv, events.csv,
  raw_NNNN.mp4 (segmented) and an optional lossless PNG sequence.
- forces.csv uses the reviewed column order
  (session_id, frame_id, t_rel_s, FN_raw, FX_raw, FY_raw, FN_N, FX_N, FY_N,
  valid, calibration_id) and leaves a cell empty when a value is unavailable:
  an uncalibrated newton value or a failed readout is never written as 0.
- frames.csv is the frame-index mapping: frame_id, t_rel_s, acquisition wall
  clock, segment, segment_frame, in_video. forces.csv and frames.csv share the
  packet frame_id and the same t_rel_s origin, so video and data can be
  aligned after dropped or segmented frames.
- session.json carries format version, session id, start/end time in UTC,
  device and config (with a sha256 of the config file), app/shared/SDK
  versions, calibration reference, axis convention, filter parameters, video
  codec and the lossy-MP4 caveat, plus the event log and end-of-run statistics.
  It is written once at start so a crash still leaves identifiable metadata.
- Add a record control to the toolbar: mode (video+data / data only) and a
  start/stop toggle that stays enabled while recording.
- Log zeroing, calibration application and session end as timestamped events.

Why
- The recorder is fed from the worker sink list on the acquisition thread and
  only appends to a bounded queue, so disk I/O can never stall acquisition.
- On_packet copies the raw frame instead of holding a reference: the packet
  frame may alias a buffer the SDK reuses between frames.
- A queue overflow is counted and alarmed (rate limited), and a write or encode
  failure is counted, reported and after 5 consecutive errors stops the
  recording with failed=true rather than silently losing data.

Verification
- shared/tests/test_experiment_recorder.py: 12 cases covering the directory
  layout, the CSV contract, frame-id/time-base agreement, segment rolling,
  data-only and lossless modes, queue overflow accounting, encoder failure,
  metadata fields, crash-before-stop, and that on_packet never blocks.
- tools/ui_check.py gains an "experiment record" section (169 checks, 0
  failures overall), driving a recording through the UI and asserting the
  layout, the empty-cell rule and the event sequence.
- apps/studio/tests/test_ui_smoke.py mirrors those invariants.
- docs/record-channel.md documents the format and the remaining phase-2 work.

Not in this change: CSV export of a selected time range, full-session curve
browsing, calibration diagnostics (outlier exclusion, fit residuals) and
PyOpenGL 3D.
This commit is contained in:
ykcheungab
2026-09-13 13:14:10 +08:00
parent f501fa93f3
commit 817f830565
12 changed files with 1531 additions and 16 deletions
+51 -7
View File
@@ -104,11 +104,14 @@ cd D:\orisys_ws\orisys-app\apps\studio
- `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` 段落,断言记录通道在**完全没有 UI 定时器
触发**的情况下仍收满样本、隐藏标定面板仍能采样、录制按钮是可用取消入口、
深度缺失不回退、色标锁帧。
- `tools/ui_check.py`:新增 `record channel` 与 `experiment record` 两段,断言记录通道
在**完全没有 UI 定时器触发**的情况下仍收满样本、隐藏标定面板仍能采样、录制按钮是可用
取消入口、深度缺失不回退、色标锁帧,以及通过界面录制的目录结构与事件序列。
- `apps/studio/tests/test_ui_smoke.py`:上述不变量各有对应 pytest 用例,复用
`ui_check._StuckWorker` 与 `ui_check._fake_packet`,保持单一来源。
@@ -120,11 +123,52 @@ cd D:\orisys_ws\orisys-app\apps\studio
---
## 6. 尚未完成(第二阶段及以后)
## 6. 实验录制(第一阶段剩余部分)
- MP4 分段录制、`frames.csv` 帧索引、`session.json` 元数据(评审第一阶段剩余部分)。
- CSV 导出(`session_id/frame_id/t_rel_s/FN_raw/…/valid/calibration_id`)——数据模型
`SessionSample` 已按这些字段设计,`SessionStore.iter_valid()` 已可直接供导出使用。
`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 的采集缓冲,直接持有引用
会让录制内容随后续帧改变。
### 已知环境限制
本机未安装 pytest,且沙箱拒绝写入工作区之外的路径,`conftest.py` 的 `tmp_path` 因此
写 prefs 失败。上述 pytest 用例是用等价驱动脚本执行的(参数化用例除外);`ui_check.py`
覆盖同样的不变量且可完整运行。录制相关的测试把临时目录放在
`shared/tests/_scratch/`(已加入 `.gitignore`)以避开该限制。
---
## 7. 尚未完成(第二阶段及以后)
- CSV 的「导出选中时间段」(整段导出已随录制落盘;区间导出待做)。
- 全程曲线浏览(跟随/历史、框选缩放、降采样保峰值)。
- 标定诊断扩展:稳定后采样、异常点排除/恢复、拟合残差、独立验证点。当前
`CalibrationPoint` 已记录三轴均值/标准差/有效样本数、方向、轮次与实际采样时长。