Files
orisys-app/apps/studio/docs/record-channel.md
T
ykcheungab b8dc5e925b docs: repoint architecture docs at the workspace docs tree; ignore .scratch/
The workspace-root scratch cleanup (2026-09-27) deleted .repro/, the _review_*/
probe bundles and _probe_dataarea/. These tracked documents cited files inside
those directories, so their references are now explicitly marked as historical
and, where the evidence survived, repointed:

- record-channel.md section 7: the ten reproduction conditions are recorded in the
  archived review (docs/archive/reviews/phase12-review.md); reproduce.py is gone.
- studio-3d-stages.md: the _review_scene3d_20260914 review is archived under
  docs/archive/reviews/; the probe_stage6_* scripts and the .repro/ scan scripts
  are marked deleted. The perf report's stale .repro package path is explained in
  a note instead of editing the committed probe report, which feeds
  compile_scene.py --lock-from-probe.
- studio-architecture.md: the scan scripts that generated the diagrams are gone;
  the committed .svg/.png remain the surviving artefacts.

Also ignore .scratch/, the new per-task scratch directory (see
D:\orisys_ws\AGENTS.md). No source file is touched by this commit.
2026-09-27 21:14:20 +08:00

26 KiB
Raw Blame History

记录通道与数据可靠性

本文记录 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. 验证

$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 属于一次性工作区临时物, 已在 2026-09-27 的工作区整理中删除;十条条件与整改要求逐条记录在归档的评审正文 D:\orisys_ws\docs\archive\reviews\phase12-review.md("发现与整改条件"一节)。以下按发现逐条说明改法。

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 里明确写:

"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::ReplayEngine 只读录制并发信号,主窗口把它接到同一套面板上—— 状态轨的主按钮在回放视图里变成「▶ 回放 / ⏸ 暂停」(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、队列积压与丢失。