git add apps/studio/config/locales/zh_CN.json `
apps/studio/config/locales/en_US.json `
apps/studio/tests/test_locales.py `
apps/studio/tools/ui_check.py `
apps/studio/tests/test_ui_smoke.py `
apps/studio/src/orisys_studio/app_window.py `
apps/studio/src/orisys_studio/ui/cal_panel.py `
apps/studio/src/orisys_studio/ui/playback_dialog.py `
apps/studio/src/orisys_studio/ui/force_plots.py `
apps/studio/docs/record-channel.md `
shared/src/orisys_app_shared/force/calibration_recorder.py `
shared/src/orisys_app_shared/record/__init__.py `
shared/src/orisys_app_shared/record/playback.py `
shared/src/orisys_app_shared/record/depth.py `
shared/tests/test_record_channel.py `
shared/tests/test_playback.py
12 KiB
记录通道与数据可靠性
本文记录 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 的三条保证
- 不是"把窗口调大":按定长 numpy 块追加,O(1) 写入;达到
max_samples时释放最旧 的整块并累加dropped。dropped由界面显式提示(status.samples_dropped), 不允许静默丢数据。 - 同时保存 raw 与标定后数值:每个样本存
(fn,fx,fy)_raw与(fn,fx,fy)_N两套。 切换显示单位时曲线从 store 重建,而不是把新单位的值追加到旧缓冲上。 - 无效样本标记而不是归零:读取失败时存 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. 验证
$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 的采集缓冲,直接持有引用 会让录制内容随后续帧改变。
已知环境限制
本机未安装 pytest,且沙箱拒绝写入工作区之外的路径,conftest.py 的 tmp_path 因此
写 prefs 失败。上述 pytest 用例是用等价驱动脚本执行的(参数化用例除外);ui_check.py
覆盖同样的不变量且可完整运行。录制相关的测试把临时目录放在
shared/tests/_scratch/(已加入 .gitignore)以避开该限制。
7. 同步回放与深度重算
决定:不保存深度。 回放时按录像与配置离线重算。
理由是体积:30 Hz、320×240 的 float32 深度每帧约 300 KB,一小时就是 ~16 GB;
即使降频到 1 Hz 也要百兆级。所以 session.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 调用仍在后台跑。
两个必须知晓的边界:
- SDK 打开视频逐帧步进可能长时间阻塞,且无法取消。因此用守护线程:窗口能关、 进程能退,但那个线程会一直挂到进程结束。用线程池(非守护)会让整个进程无法退出, 这是实测踩到的。
- 重算失败一律显示原因(SDK 不可用/未记录配置/配置路径不存在/无视频分段/ 帧不在视频中),绝不用其它图像顶替。
8. 尚未完成(第三阶段及以后)
- PyOpenGL 三维显示(
mesh3d_panel.py目前仍是占位符,不是已有实现)。 - CSV 的「导出选中时间段」(整段导出已随录制落盘;区间导出待做)。
- 标定的稳定后采样(当前按固定时长采样,稳定性以 σ 呈现)。