stouchinglhh
collector: 更新采集工程至 v1.3.17(时间轴修正 + 采集端自建任务)
ddb63ef
|
Raw History Blame Contribute Delete
130 kB

core/ — SDK 核心

定位

core/ 是 collector 的 SDK 核心:相机采集、设备管理器、录制落盘、SQLite 持久化、上传/后端通信、回放会话、手部关键点、BLE 手套传感与通用工具全部 平铺于此(v1.0.3 架构重组后由原 core/ + storage/ + network/ + utils/

  • tools/sensors/ 合并而成)。

分层规则(SDK 化后确立):

  • core 只依赖 PyQt5.QtCore(QObject/pyqtSignal/QTimer)与 config/, 禁止 import ui;唯一例外 core/sensor_config_dialogs.py(传感器 配置对话框继承 QDialog,需 PyQt5.QtWidgets);
  • 算法口径全部在 core(纯函数优先),ui 槽只做 widget 操作;
  • 跨线程一律语义信号(帧/日志/关闭完成),主线程创建 QObject;
  • 可能超过 32 位的信号参数(纳秒时间戳)用 object 封装 (PyQt5 int 参数按 qint32 封送,>2^31 静默翻负)。

文件清单

文件 一句话作用
core/camera.py UVC 相机采集:V4L2 sysfs 枚举 + CameraWorker 后台线程采集(重连/曝光/FOURCC)
core/d435_camera.py RealSense D400 系列采集:D435Worker 进程内 rs2.pipeline 双路输出 + 运行时标定提取
core/device_detector.py 统一设备枚举(UVC + D435 + S80M + BLE),DeviceScanner 后台扫描
core/pipeline.py CameraPipeline:多路相机 + 共享 EgoData 录制会话,独立写入线程
core/s80m_manager.py S80M 双目子进程生命周期:Popen/stdin 曝光通道/watchdog/帧管道读取 + 50→30 抽帧口径
core/d435_manager.py D435 worker 生命周期 + 帧处理口径(calib 首帧注入/热力图 EMA/录制写入)
core/device_manager.py 统一设备 worker 注册表 + 面板开关分派口径 + 录制设备元数据
core/device_naming.py 槽名清洗/分配(allocate_slot_names)+ 曝光基线归一化(纯函数,零 Qt)
core/exposure_controller.py 每设备曝光:对话框参数解析 + 下发/持久化口径(零 Qt)
core/egodata_writer.py EgoData / LeRobot v3 格式录制器:MP4/深度/Parquet/元数据多模态落盘
core/calibration.py 标定数据模型:双目内外参 + 深度缩放的 dataclass 与 JSON 序列化
core/database.py 线程安全 SQLite 单例(recording 与 upload_task 两表 + 建表 SQL)
core/recording_record.py RecordingRecord dataclass:录制历史一行记录的字段定义(原 storage/models)
core/recording_repository.py RecordingRepo:录制记录 CRUD 仓库(原 storage/repository)
core/task_record.py 任务进度持久化(data/tasks.json 唯一权威源,缺文件自动播种)
core/api_client.py 后端 HTTP REST 客户端(健康检查、上传、查询、删除)
core/task_service.py 后台任务轮询服务(QTimer + 后台线程,Cookie JWT 登录)
core/uploader.py 上传队列管理器(视频预压缩 → 打包 zip → 上传 → SQLite 状态持久化)
core/session_timeline.py 会话传感器时间线:向量化合并 per-sensor parquet + 帧号二分查询
core/session_catalog.py 会话扫描/元数据/帧率解析/录制列表(纯函数,零 Qt)
core/session_loader.py 回放后台加载器(parquet 合并 + 关键点,gen 防过期协议)
core/stereo_depth.py 手写 StereoSGBM + WLS 视差计算,深度热力图与 EMA 平滑
core/hand_tracking.py 手部关键点后处理纯函数 API(glove/bare 双模式、自动标注、parquet 读写)
core/hand_processor.py SessionHandProcessor:hand_tracking.process_session 的 Qt 薄封装
core/auto_labeler.py AutoLabeler:hand_tracking.label_session 的 Qt 薄封装
core/ble_engine.py BLE 传感器引擎:扫描/连接/通知订阅/帧解析/降噪,QObject + Qt 信号
core/render_engine.py 五种触觉渲染模式 + 仿生手掌配置路径常量(config/sensors/)
core/sensor_config_dialogs.py PyQt5 传感器配置对话框(矩阵行列 / 仿生手掌逐部位)
core/sensor_hand_config.py 仿生手掌配置加载/传感器列有效性过滤(纯函数,零 Qt)
core/helpers.py 通用工具函数(约 47 个:ID/时间/格式化/路径/会话扫描)
core/frame_gap.py 帧间隔空洞看门狗 GapWatch(纯算术,零 Qt/零 IO;录制侧与 tools/audit_frame_gaps.py 共用同一份空洞定义)
core/gripper/ UMI 夹爪 rig 自包含子包(Fays SLAM + Sightac 触觉 + 出厂标定生成),与上面平铺模块分开,见下文「夹爪」节

(core/__init__.py 为空包标记;core/gripper/__init__.py 为其子包标记。)

采集与设备

core/camera.py

作用:UVC 摄像机采集模块。模块级函数负责只读 sysfs 枚举与打开 (list_v4l_devices 不 open 设备、轮询安全;_try_open_camera 支持 /dev/v4l/by-id 永久路径与重连兜底;跳过 FTDI SDK 设备与 RealSense UVC 节点)。CameraWorker 在后台线程采集帧、经 Qt 信号发主线程,含指数退避 重连、UVC 固定自动曝光(首帧读出成功后强制应用)、MJPG FOURCC 设置与 读回校验、录制帧队列直推。

类/函数:

名称 签名要点 作用 返回/副作用
CameraState 类常量 状态枚举 DISCONNECTED/IDLE/RECORDING/ERROR
list_v4l_devices (max_index=16) sysfs 只读枚举 V4L2 设备,按物理 USB 设备分组(键取拓扑路径如 1-5,非 USB 节点退回 realpath)、每物理设备只留主视频流 每项 {video_index, name, serial, by_id_path, by_id_ambiguous, usb_path, vid, pid, is_sdk, is_realsense};无对应链接时 by_id_path 为 None。by_id_ambiguous=True = 该前缀被同型号多台共用、链接归属会在重枚举时翻转,不能当 key(见 _ambiguous_by_id_prefixes);by_id_path 本身不受影响,仍是「真路径或 None」,照旧可用于打开设备
detect_cameras (max_index=8) 打开测试(test_read)枚举可用相机 [(idx, backend), ...]
_try_open_camera(内部) (index, test_read=False, fallback_all_by_id=False) 按后端列表(Linux: V4L2/FFMPEG/ANY;Windows: DShow/MSMF/ANY)尝试打开 (VideoCapture, backend名) 或 (None, "")
_is_sdk_device / _is_realsense_node(内部) (index) sysfs name/VID:PID 判 FTDI SDK 设备与 RealSense 节点 bool
_usb_vid_pid(内部) (video_index) 沿 sysfs 设备树向上(≤6 层)找 idVendor/idProduct (vid, pid) 小写十六进制或 None
_physical_usb_path / _usb_ident_strings(内部) (index) 节点所属物理 USB 设备的拓扑路径 / 沿 sysfs 向上(≤6 层)找 USB manufacturer+product 字符串 str(如 "1-5")或 None / (厂商, 型号),取不到给空串
_v4l_nodes_by_physical_device(内部) (max_index) sysfs 里所有 v4l 节点按物理设备分组——同型号同序列号两台并存时 udev 的 by-id 链接名会相撞,只有拓扑分得开 {物理设备键: [video_index, ...]}
CameraWorker (camera_index=0, resolution=None, record_queue=None) UVC 采集 worker;默认分辨率 settings.DEFAULT_RESOLUTION(1280×960@30,MJPG) 信号:frame_ready(ndarray)、state_changed(str)、error_occurred(str)、fps_updated(float)、camera_opened(int,int,str)
CameraWorker.start / stop / pause / resume 无参 生命周期控制(stop join ≤5s) 无
CameraWorker.state / is_connected / resolution / latest_capture_ts_us property 状态、连接、实际分辨率、最新帧采集时间戳(Unix 微秒,线程安全) 对应值
CameraWorker._apply_exposure_to(内部,static) (cap, auto, value, backend="") V4L2 按候选菜单顺序(自动 0→3→2;手动 1→0)写自动曝光,以 set 返回 True 且 get 读回一致为准 (auto成功, 值成功)
_FPSCounter (window=30) 滑动窗口帧率计数 tick() 更新 .fps

关键数据:重连参数 _RECONNECT_BASE_DELAY=2.0s、_RECONNECT_MAX_DELAY=30.0s、_READ_FAIL_THRESHOLD=120、_INITIAL_FAIL_THRESHOLD=30、_INITIAL_GRACE_PERIOD=90(迭代数);曝光为"开启/重连后首帧读出成功后强制回自动"策略(注释:UVC 手动模式实测帧率上限 ~26fps);录制帧队列 queue.Queue(maxsize=3),满则丢旧帧保持最新。

调用关系:被 core/pipeline.py(CameraWorker/CameraState)、core/device_detector.py(list_v4l_devices/_is_sdk_device)、ui/main_window.py(_add_camera_slot)、tools/tests/exposure_control_test.py、tools/tests/test_device_detector.py、tools/tests/mono_regression.py 引用。

core/d435_camera.py

作用:Intel RealSense D400 系列(D435/D435I/D405 等)深度双目采集。D400 是稳定 UVC 用户态设备(pyrealsense2 自带 librealsense2 + libusb),无需像 S80M 那样隔离到子进程;D435Worker 后台线程跑 rs2.pipeline,对外输出 RGB(BGR 三通道)+ 深度(uint16,归一化毫米)两路信号。左红外流仅内部 启用(时间戳元数据),不对外输出;右红外不开流(基线改从 depth sensor 的 stereo_baseline 选项直读,双 RealSense 同 hub 时多一条 IR2 会把 D405 挤到 ~19fps)。带停滞/低帧率双看门狗,重连每次新建管道。frames_ready 信号的 hardware_ns 用 object 参数(PyQt5 队列信号把 int 按 qint32 封送,>2^31 纳秒会静默翻负)。

类/函数:

名称 签名要点 作用 返回/副作用
d435_available (force=False) pyrealsense2 可用且存在 D400;结果模块级缓存,force=True 绕过(热插拔后活体复查) bool
list_d400_devices 无参 枚举 D400 设备(product_line 含 "D400") [(name, serial), ...],无则 []
_apply_color_exposure(模块级) (sensor, auto, value) 对 color 传感器应用曝光(µs) bool,异常返回 False
D435Worker (width, height, fps, parent, ts_log, rgb_width, rgb_height, serial, model_name, rgb_slot, depth_slot, exposure) 采集 worker;分辨率默认 settings.D435_RESOLUTION/D435_RGB_RESOLUTION,serial 锁定设备(None→第一台 D400),槽名默认 settings.D435_SLOT_RGB/D435_SLOT_DEPTH 信号:frames_ready(str, np.ndarray, object, list)(slot_id, frame, hw_ns, imu_samples;D435/D405 无 IMU,恒 [])、error_occurred(str)、status_changed(str)
D435Worker.start / stop 无参 启动/停止后台线程(stop 阻塞 ≤3s) 无
D435Worker.get_calibration 无参 首帧后提取的标定 StereoCalibration 或 None
D435Worker.set_exposure (auto, value) 主线程只写 pending;采集线程在流启动后应用,重连重放最近设置 无
D435Worker.exposure_info 无参 返回量程/auto/value ((min,max)µs 或 None, auto, value)
D435Worker.original_exposure 无参 开流时读回的"最一开始"曝光基线 (auto, value) 或 None
_build_calibration(模块级) (profile, fps, resolution) 从 rs2 profile 提取左右红外内参/深度内参/基线 StereoCalibration(right IR2 未开流时内参镜像左目)

关键数据:输出深度统一归一化到毫米(_depth_unit_factor = 设备深度单位 / settings.DEPTH_SCALE,D405 原生 0.1mm/单位);帧时间戳逐路取值(v1.3.11 起):RGB 行用彩色帧自己的 frame_timestamp 元数据、深度用深度帧自己的,各自软件解绕 + 首帧归零 → 会话内单调 hardware_ns(与 S80M 语义一致)。元数据契约单位为 µs(开流时做量级自检,若固件给 ms 则 ×1000 换算并告警)。回退显式化:元数据不可用且帧组戳域 get_frame_timestamp_domain() 非 hardware_clock 时,hardware_ns 写 0(0=未知)并把宿主时间另存 host_ns,绝不用宿主钟冒充设备钟。逐帧 frame_counter 判重/判跳号 → parquet 列 depth_ns/rgb_dup/rgb_gap/depth_dup/depth_gap/ts_src/host_ns。设备时间戳归一(RECORD_UNIFORM_HW_TS,默认开,v1.3.11;v1.3.17 起只对设备钟链路生效):syncer 补帧会让设备钟的 hardware_ns 出现 Δ=0 / Δ=2T,录制期即改写成「锚点 + 序号 × T」,一帧不丢但时间戳均匀严格递增。判据是 pipeline._stamp_is_device_clock(extra):只有 D435 带 extra.ts_src ∈ {1,2}。宿主钟链路(S80M 双目 / 夹爪 RGB / 裸手 UVC)不重排、原样落盘 —— 它们的戳就是采集时刻,一视同仁地重排会让 hardware_ns 与真实采集时刻差到 ±100ms(实测 UMI 370382 会话:锁定 T=33.1917ms vs 真实均值 33.33ms,一段累计 −103ms;叠加 3038ms 双峰到达后残差是随机游走),而 timestamp 仍是对的。实现在 core/timestamp_norm.UniformTsClock(T 取干净间隔均值,按槽位 fps 建标称周期),挂在管线唯一出口 CameraPipeline._write_one_frame,每槽位一个归一器、每段 begin_segment() 重锚(跨 episode 空档不抹平)。原始值不丢:同写 raw_hw_ns 列(宿主钟链路上两列相同);补帧/跳帧事实仍由 rgb_dup/rgb_gap/depth_dup/depth_gap 记录;落盘空洞看门狗吃归一前的原始戳(归一发生在它之后)。离线等价工具 tools/fix_hardware_ns_uniform.py 口径一致。定因记录见 docs/ 与 tools/d435_ts_probe.py;流组合 color(rgb8) + depth(z16) + infrared#1(y8,仅内部);标定 cam_imu_timeshift=0.0(D435 无 IMU)。__main__ 为无头自检(运行 5s、抽帧存 /tmp/d435_selftest、打印标定)。

调用关系:被 ui/main_window.py(_open_d435,worker 类经 D435DeviceManager.spawn 传入)、tools/tests/d405_worker_test.py、tools/tests/d435_e2e_test.py、tools/tests/d435_gui_smoke_test.py、tools/tests/exposure_control_test.py 引用;函数内延迟导入 core/calibration.py。

core/device_detector.py

作用:统一枚举已连接设备(UVC + RealSense D400 + S80M + 蓝牙)。列表轮询 走 sysfs 只读扫描(不 open、不 test_read),轮询 <5ms、录制中安全;open 测试 只发生在开关打开后(各 worker 自带重连)。蓝牙两个来源:bluetoothctl 已配对列表(快、只读)+ bleak 主动发现(慢 ~5s,节流缓存 20s;手套连接中 可 set_ble_scan_suppressed(True) 抑制)。DeviceScanner 在后台线程扫描, 经排队信号回主线程。

类/函数:

名称 签名要点 作用 返回/副作用
DeviceInfo dataclass 单条设备描述 字段:key/kind/display_name/serial/video_index/by_id_path/backend/address/rssi/user_name;property stable_key、group(`camera
detect_devices (max_index=settings.DEVICE_SCAN_MAX_INDEX) 四段枚举(UVC+D435+S80M+BLE),各自容错整体不崩 List[DeviceInfo]
DeviceScanner (parent=None, max_index=None) 后台线程扫描,_busy 守卫防轮询堆积 信号 scan_finished(list);request_scan()/stop()
set_ble_scan_suppressed (on: bool) 抑制 bleak 主动发现(手套连接中防扫描挤占吞吐),不影响 bluetoothctl 列表 无
_list_uvc_devices(内部) (max_index, *, gripper_hubs=None) UVC 分段枚举;gripper_hubs=夹爪控制板占用的 USB 根端口集合,用来把单插的 DECXIN 与 rig 那颗同 VID/PID 的相机分开(None=未知,一律按组件排除) List[DeviceInfo]
_list_d435_devices / _list_s80m_devices / _list_ble_devices(内部) 各自 max_index 分段枚举 List[DeviceInfo]
gripper_root_hubs (gripper_devices) 从夹爪控制板的 tty 路径取它占用的 USB 根端口集合;空集=本次没接 rig set[str]
_is_gripper_component_camera(内部) (d, *, gripper_hubs=None) 按 VID/PID 判夹爪组件相机;DECXIN 若在非控制板根端口下则放行(单插那颗,有独立用途),Sightac/FT602 始终排除;相机自身根端口取不到时保守排除 bool
_is_glove_name(内部) (name) 广播名判手套:含 "matrix" 或单字母 l/r/left/right/l_glove/r_glove/left_glove/right_glove bool

关键数据:DeviceInfo.key 格式约定:"uvc:{by-id前缀 | usb-<拓扑路径> | 索引}"(无链接、或前缀被同型号多台共用(by_id_ambiguous,链接归属会在重枚举时翻转)→ 退到拓扑路径,两者都跨重启稳定,索引兜底但会漂移)、"d435:{serial}"、"s80m:ftdi"、"ble:{MAC}";kind 取值 uvc | d435 | s80m | data_ble | ble(data_ble=手套)。BLE 常量 BLE_DISCOVERY_INTERVAL_S=20.0、BLE_DISCOVERY_TIMEOUT_S=5.0。S80M:FTDI 命中一次即返回单条(SDK 配置写死 video0/video2)。RealSense UVC 节点判定:vendor 8086 且 PID 0b07(D435)或驱动 name 含 "RealSense"(D435i 为 0b3a、其余型号 PID 各异,name 兜底)。

调用关系:被 ui/main_window.py(DeviceScanner)、ui/device_panel.py、tools/tests/device_panel_gui_smoke_test.py(DeviceInfo/detect_devices)、tools/tests/glove_widget_test.py、tools/tests/multi_device_registry_test.py 引用;调用了 core/camera.py。

core/pipeline.py

作用:摄像机管线——管理多路相机槽位与一个共享 EgoData 录制会话。 CameraSlot 封装单路 CameraWorker + 帧缓冲队列(maxsize=3,采集线程直写, 绕过 Qt 信号/主线程)。CameraPipeline 录制时创建 EgoDataWriter,独立写入 线程以 settings.RECORDING_FPS(30fps)精确节拍把各槽位帧、传感器数据、 外部帧源(双目子进程等)、深度帧统一写入同一会话(MP4 + Parquet)。深度 "伪相机"经 set_depth_camera 显式注册(D435 原生深度与 S80C 子进程深度 引擎多路并存,S80C 用槽 settings.S80M_DEPTH_SLOT;无注册槽时随 stereo_left 兜底落盘)。

类/函数:

名称 签名要点 作用 返回/副作用
CameraSlot (slot_id, camera_index) 单相机槽位 信号 frame_ready(str, object)、state_changed(str, str)、error_occurred(str, str);start_camera()/stop_camera()/camera_state
CameraPipeline (output_dir=None)(默认 settings.RECORDING_DIR) 多路管线 信号:slot_added、slot_removed、session_changed(object)、recording_started(str)、recording_finished(str, str)、recording_aborted(str)、duration_changed(str, float)、error_occurred(str, str)、state_changed(str, str)
CameraPipeline.add_camera / remove_camera / get_slot / slot_ids / slot_count / remove_all (slot_id, camera_index) 槽位管理(录制中移除会先 abort_recording) 对应值/无
CameraPipeline.register_external_source / unregister_external_source (slot_id, resolution, fps=25.0) 外部帧源注册(队列 maxsize=2,不建 CameraWorker) 无
CameraPipeline.write_external_frame (slot_id, frame, hardware_ns=0, imu_samples=None) 外部帧入录制队列(非阻塞,满则丢) 无
CameraPipeline.write_depth (depth_frame, depth_slot="stereo_left") 深度帧入录制队列(uint16 毫米:S80C SDK 深度引擎 / D435 原生,同口径);单深度相机时自动回落 无
CameraPipeline.set_depth_camera / clear_depth_camera (name, resolution, fps, master_slot, heatmap_near_mm, heatmap_far_mm, heatmap_smooth_k, heatmap_temporal_alpha, raw_depth) 声明/清除深度伪相机(多路并存,只进 metadata 不建 MP4;raw_depth=True 时原始 uint16 bin 随热力图 MP4 落盘) 无
CameraPipeline.set_external_calibration / set_device_calibration / clear_device_calibration (calib) / (device_key, calib) / (device_key) 注册外部帧源标定(单值路径存 "_default" 键 → calibration/head_stereo.json) 无
CameraPipeline.register_sensors / register_sensor / unregister_sensor (sensor_names) / (sensor_name) / (sensor_name) 传感器名称注册(决定 parquet observation.<name> 列),须在 start_recording 前调用 无
CameraPipeline.start_recording (slot_id, task_name="", batch_index=0, device_meta=None) 全部相机开始录制同一会话(后台线程初始化) 信号 recording_started
CameraPipeline.finish_recording / abort_recording (slot_id) 正常停止(end_episode 最终化)/ 异常停止(abort_episode 删目录) 信号 recording_finished/recording_aborted
CameraPipeline.write_sensor (data, capture_ts_us=0, sensor_name="") 传感器数据入队列(16×16 float32 压力矩阵 ravel) 无
CameraPipeline.record_event (device, event_type, message="") 记录设备连接/断开事件 → 写入线程每帧写 status.<device> 列 无
CameraPipeline.is_recording / elapsed / last_recording_frames property 录制状态/已录时长/上一轮每相机帧数快照 对应值

关键数据:写入线程节拍 frame_interval = 1.0 / settings.RECORDING_FPS(30fps),sleep 到目标前 ~1ms 后 busy-wait 补齐(落后超过一拍就重置节拍,并记 drop_stats.write_pacing_resync_count / write_pacing_lag_max_ms —— v1.3.17 前这次掉拍完全不可见,只能事后从 wall_time 与采集时刻的错位里猜);每帧先排空传感器队列取最新数据,CameraSlot 队列"排空取最新帧"、外部帧源队列"取一帧不排空"(输出均匀无抖动);单目路径写 MP4 时上下翻转、外部双目帧路径已在 ui/main_window._on_stereo_frame 翻转故 flip_vertical=False;IMU 样本只随 stereo_left 写入(左右目共享一份,避免 data/imu/ 重复行);可选 settings.CAMERA_MIRROR_HORIZONTAL 水平镜像。深度 MP4 补拍(v1.0.11):_write_one_frame 每个 master 槽节拍至多消费一帧深度——队列有新帧则写热力图 MP4 +(raw_depth 时)PNG16 并缓存于 _last_depth_frames;队列空(S80C 深度引擎 ~20fps 低于录制 30fps)则重写缓存帧到热力图 MP4(不落 PNG、不推进序号),保证深度 MP4 时长与 RGB 对齐。

帧空洞与丢帧口径(v1.3.10):写入线程对每个外部槽跑一个 core.frame_gap.GapWatch (_ext_gap_watches,每槽一个,基座由该槽首帧锁定),取帧成功后、_write_one_frame 之前记账—— 不 sleep、不 IO、不加锁,空洞 ≥ settings.GRIPPER_RGB_GAP_ALERT_MS 时经既有 recording_log 信号当场告警([录制] RGB 帧空洞 …ms)。DropStats 另有 note_max(key, value)(峰值语义)与两个共用 helper:

is_frame_drop_key(key)   # 帧数键才为真:ext:<slot> / sensor_queue / imu_overflow
frame_drop_total(stats)  # 两处 UI 汇总共用,替代各自硬编的排除逻辑

键约定:*_ms / *_ns / *_count 后缀一律是时长/次数,不是帧数(imu_overflow 是遗留的次数键)。CameraPipeline.note_drop_stats(mapping) 由 UI 在停止录制前把 桥接的采集侧仪表(bridge.rgb_quality_snapshot())推进 drop_stats。parquet 只是 多几个 JSON 键,列与录制时序一个没动——「丢帧统计」与「换编码器」提示的文案也 一个字没改。

调用关系:被 ui/main_window.py(主程序)、tools/tests/d435_e2e_test.py、tools/tests/device_panel_gui_smoke_test.py、tools/tests/exposure_control_test.py 引用;调用了 core/camera.py 与 core/egodata_writer.py。

设备管理器与口径(v1.0.3 从 ui 抽出)

core/s80m_manager.py

作用:S80M 双目子进程生命周期管理(每台一个条目)。自包含双目模块 tools/stereo_s80m/ 内含 read_stereo_rgb.py + lib/fays_atrak/x86_64/Release/libfays_vikit.so(SDK 动态库,注释标 3.9.0)+ config/fays_vikit.yaml,与外部 SDK 目录完全独立——外部目录 git 更新、跑官方 demo 均不影响 DAQ 程序。Popen 子进程 stdout 管道传帧(--pipe -,避免 FIFO 权限问题),stdin 管道是曝光控制通道(行协议 "SET_EXPOSURE <float>", -1.0=自动),watchdog 定时检查进程存活并回读 stderr 末尾,reader 线程按 [4B len][8B ts_ns][jpg] 帧格式解析左右目 JPEG 与 IMU 样本块(56B/样本)。

管道协议扩展(v1.0.11):s80m_depth_available()(settings.S80M_DEPTH_ENABLED 且 tools/hand_3d_s80c/third_party 深度库/模型/OpenCV4.2 齐全)时 spawn 给子进程追加 --depth-sdk-dir,每帧 IMU 块之后、flush 之前必写深度块(引擎失败期恒写 0 长度,保证解析确定性):

[>I depth_len]                              ← 0 = 本帧无新深度
if depth_len>0: [>Q depth_ts][>I w][>I h][w*h*2 字节 uint16 毫米 LE]

reader 校验 depth_len == w*h*2 且尺寸合法后 depth_ready.emit(slot, uint16, ts);子进程内移植自 tools/hand_3d_s80c/s80c_depth_worker.py 的 SDK 深度引擎(20fps @ 相机 50fps,220MB 缓冲)。

类/函数:

名称 签名要点 作用 返回/副作用
SDK_ROOT / STEREO_DEMO / STEREO_AVAILABLE / STEREO_CFG_50 模块常量 锚定 tools/stereo_s80m/ 路径(不随 cwd 漂移) —
load_default_exposure 无参(模块级) 「恢复默认」基线:yaml stereo_init_exposure(<0=自动) (auto, value),兜底 (False, 400.0)
frame_record_decision (entry, slot_id, hardware_ns, imu_samples)(模块级) 50→30 抽帧纯口径:每 1/30s 桶保留首帧;被抽帧的 IMU 块累积到下一帧;hw_ns==0 兜底全录 (record, imu_batch)
S80MDeviceManager (pipeline, parent=None) 子进程生命周期管理 信号:frame_ready(str, np.ndarray, object, list)、depth_ready(str, np.ndarray, object)、device_closed(str)、log(str)
S80MDeviceManager.new_entry (label) staticmethod 注册表条目骨架(kind/slots/label + 进程字段占位;深度可用时 slots 含 settings.S80M_DEPTH_SLOT) dict
S80MDeviceManager.spawn (dev_key, entry) 临时 50fps yaml 副本 + Popen + watchdog + reader 线程;深度可用时追加 --depth-sdk-dir 并注入 LD_LIBRARY_PATH bool
S80MDeviceManager.read_pipe (dev_key) reader 线程:解析双目帧 → frame_ready ×2 + 深度块 → depth_ready 无
S80MDeviceManager.check_alive (dev_key) watchdog 槽:进程退出时停表 + 回读 stderr 末尾 500 字符 无
S80MDeviceManager.close (dev_key) 停 watchdog → 关 stdin → terminate/kill → 关 stdout → join reader → 清理临时文件 → device_closed 无
S80MDeviceManager.send_exposure (entry, auto, value) stdin 行协议下发曝光(-1.0=自动) 无
S80MDeviceManager.shutting_down 属性 窗口关闭标记(read_pipe 主循环退出条件) —

关键数据:settings.STEREO_RECORD_MIN_INTERVAL_S(1/30s)为抽帧桶宽; 帧时间戳为 SDK 硬件纳秒时钟(帧/IMU 同源,SLAM 对齐用);镜头方向已由 SDK config(left_cam_rotate_180/right_cam_rotate_180/stereo_swap_lr)处理, reader 不再翻转;深度由子进程 SDK 深度引擎计算(进程常驻 ~1 核 + ~220MB, S80M_DEPTH_ENABLED=False 一键回退纯 RGB);depth_ready 的 hw_ns 用 object 类型信号参数(PyQt5 qint32 封送会截断 >2^31 纳秒)。

调用关系:被 ui/main_window.py(_open_s80m/_on_stereo_frame/ _on_s80m_depth/_close_s80m/_s80m_set_exposure)实例化;frame_record_decision 经 MainWindow 集成路径被 tools/tests/s80m_50fps_decimation_test.py 覆盖 (测试构造假 50fps 帧流走 _on_stereo_frame 接线,不直接 import 该函数)。

core/d435_manager.py

作用:D435 深度双目设备管理器(每台一个条目):worker 生命周期 (创建/信号接线/停止)与帧处理口径(calib 首帧注入、深度热力图/EMA、 录制写入)。spawn 接收 worker 类参数(离线测试在 ui.main_window 模块 patch D435Worker 替身仍生效);信号连接在 worker.start() 之前完成。

类/函数:

名称 签名要点 作用 返回/副作用
D435DeviceManager (pipeline, parent=None) worker 生命周期 + 帧口径 信号:frames_ready(str, np.ndarray, object, object, str)(slot, frame, hw_ns, imu, dev_key)、log(str)
D435DeviceManager.new_entry (label, serial, rgb_slot, depth_slot, near_mm, far_mm, smooth_k, temporal_alpha) staticmethod 注册表条目骨架(worker 占位 None) dict
D435DeviceManager.spawn (dev_key, entry, model_name, profile, exposure, worker_cls) 创建 worker(分辨率/fps 取 profile)、连接 frames_ready→_relay_frames、start 无
D435DeviceManager.process_frame (entry, slot_id, frame, hardware_ns=0, dev_key=None) 帧处理口径 → (display_frame, is_depth);深度槽:热力图 + EMA + pipeline.write_depth;RGB 槽:原帧 + write_external_frame tuple
D435DeviceManager.close (entry) 断开信号 → stop → deleteLater 无

关键数据:calib 首帧注入只发生一次(entry["calib_sent"]),标定写 calibration/head_stereo.json;热力图 EMA 仅可视化(录制原始 PNG16 不受 影响);hw_ns 用 object 封装(同 S80M 口径)。

调用关系:被 ui/main_window.py(_open_d435/_on_d435_frames/ _close_d435)实例化;调用了 core/stereo_depth.py。

core/device_manager.py

作用:统一设备 worker 注册表 + 面板开关分派 + 录制元数据口径(纯 Python,零 Qt)。具体开启/关闭动作(建槽、弹窗、widget 接线)留在 UI 侧 回调,core 只做注册表与分派口径——录制锁双保险、kind 路由、失败语义统一 于此。主窗口 _workers 直接引用 DeviceManager.entries(同一 dict,离线 测试注入假条目沿用同一形状)。

类/函数:

名称 签名要点 作用 返回/副作用
DeviceManager 无参 注册表容器 属性 entries(dev_key → entry dict)
DeviceManager.uvc_entry / ble_entry / glove_entry (slots, label) / (slot, label) / (slot, role, glove_widget, label) staticmethod uvc/占位蓝牙/手套条目骨架 dict
DeviceManager.get / has_kind / has_serial (dev_key) / (kind) / (kind, serial) 注册表查询(serial 查重用于 d435 同机重复开关检测) 对应值
DeviceManager.build_device_meta / reset_s80m_record_state / teardown_all () / () / (close_fns) 录制设备元数据 / 抽帧状态重置 / 全部关闭 list / 无 / 无
build_device_meta (entries)(模块级) 注册表 → meta devices 段(手套附 sensor_column,无数据蓝牙不进) list
reset_s80m_record_state (entries)(模块级) 录制起止重置 50→30 抽帧状态(桶号 + IMU 缓冲) 无
teardown_all (entries, close_fns)(模块级) 遍历注册表分类清理(未知 kind 兜底弹出) 无
dispatch_toggle (dev, on, is_recording, open_fns, close_fns)(模块级) 面板开关分派:录制锁双保险 + kind 路由 → open/close 回调 opened: bool

调用关系:被 ui/main_window.py(_on_device_toggled/ _teardown_all_workers/_build_device_meta/_reset_s80m_record_state/ _open_uvc/_open_glove/_open_ble_placeholder + 冲突检测)使用。

core/device_naming.py

作用:槽名清洗/分配与曝光基线归一化(纯函数,零 Qt 依赖)。从 ui/main_window.py 抽出的命名口径,供主窗口与离线测试共用。

类/函数:

名称 签名要点 作用 返回/副作用
realsense_short (model_name) 型号名 → 短名(如 "Intel RealSense D405" → "D405") str
slot_base (name, fallback) 槽名清洗:非 [0-9A-Za-z_一-鿿] 字符 → _ str
normalize_original (orig) 曝光基线归一化 → (auto, value) 或 None `tuple
allocate_slot_names (user_name, model_name, occupied_rgb_slots) 槽名分配:用户命名优先 → 型号名回落(d435_rgb/d435_depth 经典名);同前缀多台编号追加(_2…) (rgb_slot, depth_slot)

调用关系:被 ui/main_window.py(_open_d435)与 core/exposure_controller.py 使用。

core/exposure_controller.py

作用:每设备曝光控制——对话框参数解析与下发/持久化口径(零 Qt 依赖)。 UVC 无曝光入口(固定自动曝光,返回 None)。「恢复默认」基线(original): 优先持久化的首见基线(settings.device_original),无则用 worker 本次开流 读回的硬件状态,并在对话框弹出时落盘锁定。

类/函数:

名称 签名要点 作用 返回/副作用
exposure_dialog_params (kind, entry, dev_key) 按设备类型解析对话框上下文(量程/自动/当前值/基线 + 首见基线落盘);D435 → µs(流启动后读真实量程,兜底 166000);S80M → 1.0885.0 `dict
apply_exposure (dev_key, entry, auto, value, send_s80m=None) 按类型下发(d435 → worker.set_exposure;s80m → send_s80m 回调)+ settings.save_device_exposure 持久化 无

调用关系:被 ui/main_window.py(_open_exposure_dialog/_apply_exposure)使用;tools/tests/exposure_control_test.py 经 MainWindow _apply_exposure 接线与 CameraWorker._apply_exposure_to 间接覆盖(测试不直接 import 本模块)。

录制落盘与持久化(原 storage/)

core/egodata_writer.py

作用:EgoData 格式录制器,多模态机器人遥操作数据采集的落盘核心 (QObject,带 Qt 信号)。一个 episode 对应一个会话目录:RGB 视频经 ffmpeg 管道写成 MP4,深度写热力图 MP4 和/或原始 uint16 PNG(png16),触觉传感器/IMU/手部 姿态写 LeRobot v3 兼容 Parquet,同时生成 metadata.json、timestamps.json、 meta/info.json、meta/tasks.jsonl、meta/stats.json、meta/episodes/ 等 元数据。meta/info.json 是服务器上传契约(字段名、类型、结构不可动); _write_metadata() 写的 metadata.json 含 devices 段(设备分组权威信息)。

类/函数:

名称 签名要点 作用 返回/副作用
_get_ffmpeg 无参(模块级) v1.0.9 起薄封装 core/encoder_probe.list_working_ffmpegs()(imageio 静态二进制 → PATH,须 -version 自检) 可执行文件路径或 None
_build_data_schema (sensor_names, sensor_dim=256, device_ids=None)(模块级) 构建 data parquet 的 schema pa.Schema
EgoDataWriter.start_episode (output_dir, cameras, fps=30, sensors, task_name, device_ids, calibration, camera_fps, depth_enabled, raw_depth, heatmap_near_mm, heatmap_far_mm, heatmap_smooth_k, heatmap_temporal_alpha, depth_heatmaps, depth_slots, raw_depth_slots, devices, calibrations, batch_index=0) 建 episode 目录结构、写标定与 metadata.json、启动各路 ffmpeg bool;发 episode_started(str)
EgoDataWriter.write_video_frame (camera_name, frame, flip_vertical=True) 一帧 BGR 写入对应 ffmpeg 管道 无;管道断裂发 error_occurred
EgoDataWriter.write_depth_frame (frame_index, depth_frame, depth_slot="") uint16 深度 → 热力图 → 深度 MP4(首帧惰性建目录/启动 ffmpeg) 无;settings.DEPTH_ENABLED 关闭且无显式深度槽时跳过
EgoDataWriter.write_raw_depth_frame (frame_index, depth_frame, depth_slot="") 原始 uint16 深度写 depth/<slot>/<1-based 序号>.png(16-bit grayscale,毫米,压缩级 D435_PNG_COMPRESSION;D435/D405/S80C 统一 png16) 写 PNG16
EgoDataWriter.write_frame_row (frame_index, timestamp_s, sensors, connection_status, hardware_ns=0, imu_samples) 追加一行到 data parquet 缓冲 + 记录 timestamps 条目 内存缓冲;hardware_ns 仅双目帧非零,imu_samples 仅随 stereo_left 携带
EgoDataWriter.end_episode 无参 关 ffmpeg,写 timestamps/data/episode/info/tasks/stats/compat 全部文件 发 episode_finished(episode_dir)
EgoDataWriter.abort_episode 无参 关 ffmpeg 并整目录 shutil.rmtree 丢弃 发 episode_finished("")
_write_metadata (cameras, fps, sensors, task_name)(内部) 写根级 metadata.json(含 devices 段) 写文件
_write_info_json(内部) 无参 写 LeRobot v3 精确兼容 meta/info.json(服务器上传契约,结构不可动) 写文件
_write_tasks_jsonl(内部) 无参 写 meta/tasks.jsonl(服务器上传依赖其识别任务) 写文件
_write_episode_parquet / _write_compat_parquet(内部) 无参 写 meta/episodes/chunk-000/file-000.parquet 并复制到兼容旧路径 chunk_000000.parquet 写文件
_write_timestamps(内部) 无参 写 timestamps.json;含 hardware_ns 的行按 hw 稳定排序保证时间线单调 写文件
_write_data_parquet(内部) 无参 按传感器分别写 data/<sensor>/chunk-0000/chunk_000000.parquet + data/imu/...(zstd) 写文件
_write_stats_json(内部) 无参 写 meta/stats.json(均值/标准差/极值,imu 为 6 轴样本级统计) 写文件
_plan_calibration_files / _device_slot_prefix(内部) (legacy_calib) 规划标定文件布局:首台双目型写 calibration/head_stereo.json(服务器/回放依赖),其余写 calibration/{slot前缀}_calibration.json {相对路径: StereoCalibration}

关键数据:

  • 目录布局(docstring 即约定):<output_dir>/<task_tag>/<tag>_000001/ 下 videos/<cam>/chunk-0000/<cam>.mp4、depth/<slot>/<slot>.mp4(热力图)或 depth/<slot>/000001.png(png16 uint16 毫米)、calibration/head_stereo.json、metadata.json、timestamps.json、data/<sensor>/chunk-0000/chunk_000000.parquet、meta/episodes/chunk-000/file-000.parquet。
  • 编码器选择(v1.0.9):start_episode 内(启动 ffmpeg 之前)按本会话 RGB 流数/最大分辨率调 core/encoder_probe.select_encoder(auto:nvenc 探针 → x265 速度门槛 → x264 兜底;显式指定失败按 auto 链回退),结果进程内缓存并写 metadata.video_codec;RGB 与深度热力图 ffmpeg 命令共用该选择(choice.encoder + choice.args)。
  • Parquet schema(代码中的 pa.schema 真实定义):_EPISODE_SCHEMA = episode_index/task_index/start_frame_index/end_frame_index/length 均 int64;_IMU_SCHEMA = episode_index, frame_index, timestamp(float32), task_index, hardware_ns(int64), imu_ts_ns(list<int64>), observation.imu(list<list<float32,6>>),IMU 样本 6 轴 = [gx, gy, gz, ax, ay, az];_build_data_schema 在传感器列 observation.<name>(list<float32,256>)之外固定加 observation.<HAND_POSE_LEFT>/observation.<HAND_POSE_RIGHT>(list<float32,63>,值全 0 占位)、action(list<float32,1>,恒 [0.0])、每设备 status.<device_id>(string,默认 "connected")。
  • metadata.json 顶层键:format="egodata"、format_version="1.0"、episode_index、fps、task_name、cameras(每相机 {height, width, type: rgb|depth, unit:"mm", format:"png16", fps?, device_key?, device?})、devices(每设备 {key, kind, name, slots, serial?, sensor_column?, resolution, fps, calibration})、sensors、sensor_dim、created_at、codebase_version(settings.APP_VERSION)、video_codec(v1.0.9 编码器选择 {encoder, codec, crf, ffmpeg, selected_by, probe})、drop_stats(v1.0.9 丢帧统计,end_episode 回写)。
  • meta/info.json 顶层键:codebase_version="v3.0"、fps、video、task_name、features(observation.<sn>: {dtype:"float32", shape:[16,16]}、observation.imu: {dtype:"float32", shape:[6]}、action: {shape:[1]})、cameras(dict,非 list——注释强调)、devices(紧凑段)、device_names(槽位→用户命名)、sensors、sensor_dim、created_at。
  • meta/tasks.jsonl 行:{"task_index": 0, "task": task_name or "default recording"}。
  • meta/stats.json:observation.<sn> 与 observation.imu 各含 mean/std/min/max(list),另有 action 恒值段。
  • timestamps.json:{"timestamps": [{frame_index, timestamp, wall_time, hardware_ns?}], "total_frames"}。
  • 深度槽位:显式 depth_slots 注册驱动落盘(不用槽名是否含 "depth" 猜测,D435/D405 槽名可被消歧编号如 d435_depth_2;S80C 用 settings.S80M_DEPTH_SLOT="stereo_depth");未注册时回退 S80M 传统单槽 settings.CAMERA_DEPTH("head_depth")。

调用关系:被 core/pipeline.py(录制主链路)、tools/tests/test_meta_devices.py、tools/tests/multi_device_registry_test.py 引用;调用了 core/stereo_depth.py(depth_to_heatmap/DepthHeatmapSmoother)、core/calibration.py、core/helpers.py 的路径函数。

core/calibration.py

作用:定义 EgoData 标准的标定数据模型。CameraIntrinsics 表达单目内参 (OpenCV 3×3 矩阵 + 畸变系数),StereoCalibration 表达双目 + 深度相机完整 标定(含基线、depth_scale、相机-IMU 时间偏移),并提供 OpenCV 结果互转与 JSON 读写。docstring 写明其 JSON 布局即为 calibration/head_stereo.json 的 落盘格式。

类/函数:

名称 签名要点 作用 返回/副作用
CameraIntrinsics intrinsic: [fx, fy, cx, cy];distortion 默认 [] 单目内参 dataclass 无
CameraIntrinsics.from_matrix (K: 3×3, dist=None) 从 OpenCV 标定矩阵构建 CameraIntrinsics
CameraIntrinsics.to_matrix 无参 还原 3×3 相机矩阵 np.ndarray (3,3)
CameraIntrinsics.to_dist_array 无参 还原畸变系数 np.ndarray(空时返回 zeros(4))
StereoCalibration 字段见下表 双目 + 深度标定 dataclass 无
StereoCalibration.to_dict 无参 序列化为 dict(depth_camera 非空才输出该键) dict
StereoCalibration.from_dict (d: dict) 从 dict 反序列化(缺键用默认值) StereoCalibration
StereoCalibration.save (path) 写 JSON 文件 磁盘文件
StereoCalibration.load (path) 读 JSON 文件 StereoCalibration
StereoCalibration.from_opencv (K_left, dist_left, K_right, dist_right, baseline=0.095, resolution=(1280,800), fps=25.0, depth_scale=0.001) 从 cv2.stereoCalibrate 结果构建 StereoCalibration

关键数据:StereoCalibration 字段与 JSON 键一一对应:type(默认 "stereo_rgbd_camera")、name(默认 "head_stereo")、resolution(默认 [1280, 800])、fps(默认 25.0)、baseline(默认 0.095,米)、left_camera/right_camera(各含 intrinsic 与 distortion)、depth_scale(默认 0.001,像素值 × depth_scale = 米)、cam_imu_timeshift(默认 -0.0019 秒,来源注释注明 SDK calib.yaml 的 timeshift_cam_imu)、可选 depth_camera(D435 独有;注释注明 rs2 畸变系数为 Inverse Brown Conrady 模型,原样存储)。

调用关系:被 core/egodata_writer.py(写入 calibration/head_stereo.json)、core/d435_camera.py(D435 运行时从 rs2 profile 提取标定)、ui/main_window.py(_open_s80m 静态标定注入)、tools/tests/test_meta_devices.py 引用。

core/database.py

作用:SQLite 数据库管理器。Database 类用 threading.local 为每线程维护 独立连接(线程安全),建库时开启 WAL 模式与外键约束;SCHEMA_SQL 定义 recording(录制历史)与 upload_task(上传任务)两张表及索引。模块底部 创建模块级单例 db = Database(),全项目共享。路径取自 config.settings.DB_PATH(config/settings.py:data/pipeline.db)。

类/函数:

名称 签名要点 作用 返回/副作用
Database.__init__ (db_path=DB_PATH) 保存路径 无
Database.conn(property) 无参 取当前线程连接,自动创建目录/连接、设 row_factory=Row、WAL、外键 sqlite3.Connection
Database.close 无参 关闭当前线程连接 无
Database.init_schema 无参 执行 SCHEMA_SQL(IF NOT EXISTS) 建表并 commit
db 模块级单例 全局共享实例 Database 实例

关键数据(SQLite 表,config.settings.DB_PATH = data/pipeline.db):

  • recording:id TEXT PK、camera_index INTEGER NOT NULL、camera_name TEXT NOT NULL、file_path TEXT、file_size_mb REAL、duration_sec REAL、resolution_w INTEGER、resolution_h INTEGER、status TEXT(completed | uploaded | aborted | deleted | uploaded_deleted)、started_at TEXT NOT NULL、finished_at TEXT NOT NULL;索引 idx_recording_camera(camera_index)、idx_recording_date(started_at)。
  • upload_task:id TEXT PK、session_path TEXT、session_name TEXT、status TEXT(pending | uploading | completed | failed | skipped)、progress REAL、retry_count INTEGER、server_url TEXT、server_session_id TEXT、error_message TEXT、created_at TEXT、updated_at TEXT;索引 idx_upload_status(status)、idx_upload_session(session_path)。

调用关系:被 core/recording_repository.py、core/uploader.py、ui/main_window.py 引用。

core/recording_record.py

作用:定义录制历史记录的 dataclass RecordingRecord,字段与 recording 表列一一对应,id/started_at/finished_at 默认用 core.helpers 的 new_id/utcnow 生成。(原名 storage/models.py,v1.0.3 改名避免与 tools/models/ 混淆。)

类/函数:

名称 签名要点 作用 返回/副作用
RecordingRecord 字段见下 一条录制记录 无
RecordingRecord.from_row (row) classmethod 从 sqlite3.Row 构造 RecordingRecord

关键数据:字段 id: str、camera_index: int = 0、camera_name: str = ""、file_path: str = ""、file_size_mb: float = 0.0、duration_sec: float = 0.0、resolution_w: int = 0、resolution_h: int = 0、status: str = "completed"(completed | uploaded | aborted | deleted | uploaded_deleted)、started_at: str、finished_at: str。

调用关系:被 core/recording_repository.py、ui/main_window.py 引用。

core/recording_repository.py

作用:录制记录的数据仓库层,RecordingRepo 全部为静态方法,直接操作 core.database 的单例 db。其中 mark_uploaded 支持"上传成功、本地保留" 的语义(行保留、状态改为 uploaded),mark_uploaded_deleted 支持"上传 成功、本地已删"(状态 uploaded_deleted),两者都保证历史可查。 本地文件被真删除时用 delete_local_episode / delete_local_dir 把录制行 一并真删(2026-10-12:删除 = 真删除,文件与记录一起消失,不再留痕迹行)。

类/函数:

名称 签名要点 作用 返回/副作用
RecordingRepo.save (r: RecordingRecord) INSERT OR REPLACE 一条记录 写入并 commit
RecordingRepo.list_all (limit=100) 按 started_at 倒序取最近 N 条 List[RecordingRecord]
RecordingRepo.list_by_camera (camera_index, limit=50) 按摄像机索引筛选 List[RecordingRecord]
RecordingRepo.delete (record_id) 按 id 删除 删除并 commit
RecordingRepo.delete_local_episode (file_path, episode_index=None) 本地文件已删净后真删该 episode 的录制行(DELETE,不标状态) 删除行数 int
RecordingRepo.delete_local_dir (dir_path) 真删某录制目录下全部录制行(路径 abspath+normpath 归一化比较) 删除行数 int
RecordingRepo.mark_uploaded (file_path) 按路径把状态改为 uploaded(已上传、本地保留) 受影响行数 int
RecordingRepo.mark_uploaded_deleted (file_path) 按路径把状态改为 uploaded_deleted 受影响行数 int

调用关系:被 ui/upload_dialog.py、ui/main_window.py 引用;调用了 core/database.py 与 core/recording_record.py。

core/task_record.py

作用:任务记录持久化。data/tasks.json 是唯一权威源(_TASKS_FILE = settings.DATA_DIR/tasks.json)。核心设计:进度按分账模型持久化—— local_count(本机累计完成段数,increment_task_completed() +1)、 synced_count(后端已确认的本机段数,水位只前进不后退)、backend_count (最近一次已知后端全局数),派生显示值 completed_count = backend_count + (local_count - synced_count) 由 _recount() 统一重算(字段名保留,UI 零 改动);上传成功后本地文件被删也不影响进度。旧数据缺新字段时一次性迁移 回填(local/synced/backend 均取旧 completed_count,目录扫描兜底, 历史不补报、显示连续)。_read_raw() 在文件缺失或 JSON 解析失败时自动 播种内置 _SEED_TASKS(3 个默认任务)。merge_backend_tasks() 把后端 推送的任务按 id 合并进本地记录(本地独有任务保留、(name, assigned_user) 同名去重、被删任务以 hidden 墓碑防止复活、账号身份轮询下的撤销清除; 后端全局数权威,本机水位沿用)。多用户语义:任务可带 assigned_user (null/缺失 = 公共任务),load_tasks(identity) 与 filter_by_identity() 按身份过滤可见范围(None=全部、"guest"=仅公共、用户名=公共+指派给该 用户)。

采集端自建任务(v1.3.16):create_local_task() 写入 source="local"、 id 前缀 local-、created_by 为创建身份的条目;remove_local_task() 整条 移除(无 hidden 墓碑),rename_local_task() 仅改名且本机已有录制目录时 拒绝(has_data);validate_task_name() 做字符/长度/保留名/查重校验,错误 码由 UI 本地化(TaskValidationError.code)。可见性:本机任务按 created_by 过滤(仅创建者;缺失 created_by 视为无主,所有人可见),assigned_user 恒 None;merge_backend_tasks() 的账号撤销清除显式豁免本机任务;进度只记 local_count,pending_sync_tasks() 跳过本机任务(不上报服务器)。

类/函数:

名称 签名要点 作用 返回/副作用
load_tasks (identity: str | None = None) 加载任务,过滤 hidden,自动计算 status,按 assigned_at 新→旧排序;identity 过滤可见范围(None=全部/"guest"=仅公共/用户名=公共+指派),本机任务按 created_by 过滤 list[dict];旧数据缺同步字段时迁移回填并写回文件
filter_by_identity (tasks, identity: str | None = None) 纯函数:按身份过滤任务列表(不修改原列表,供 UI 对已有列表即时过滤);本机任务走 created_by,服务端任务走 assigned_user 新列表
save_tasks (tasks) 持久化任务元数据(含分账字段) 写文件(哑写,不迁移不重算)
merge_backend_tasks (backend_tasks, view_scope: str | None = None) 后端任务合并进本地记录(id 合并/本地保留/(name, assigned_user) 同名去重墓碑/账号身份撤销清除——本机任务豁免/后端全局数权威+本机水位沿用) 合并后列表(内部调用 save_tasks + load_tasks)
mark_hidden (task_id) 标记服务端任务隐藏(UI 不再显示) 写文件
refresh_progress 无参 用持久化计数重载任务列表 list[dict]
increment_task_completed (task_name, task_id: str | None = None, assigned_user: str | None = None) 一次录制完成:匹配优先级 id → (name, assigned_user) → 唯一同名回退;local_count +1 并重算显示值 更新后的任务 dict,无匹配返回 None;写文件
pending_sync_tasks 无参 所有 local_count > synced_count 的非本机任务快照(含 hidden,不按身份过滤);纯读路径不触发迁移 list[dict](id/name/local_count/synced_count)
mark_synced (task_id, synced_count, backend_count) flush 成功后回写:水位只前进不后退,backend_count 用响应值无条件替换 更新后的任务 dict 或 None;写文件
task_by_id (task_id) 按 id 查单个任务 dict 或 None
is_local_task (task) -> bool 是否采集端自建任务(source == "local" 或 id 前缀 local-) bool
validate_task_name (name, exclude_id=None) -> tuple[bool, str] 本机任务名校验:非空、≤64 字符、只允许字母/数字/汉字/_/-、非 Windows 保留名、不重名(大小写不敏感,含 hidden 与种子任务) (ok, 错误码):name_empty / name_too_long / name_illegal / name_reserved / name_duplicate
create_local_task (name, total_required, description="", created_by="guest") 新建本机任务:校验后追加条目(id local-…、source="local"、assigned_at=UTC now、进度三字段 0) 任务 dict;失败抛 TaskValidationError(另含 total_invalid / description_too_long)
rename_local_task (task_id, new_name, allow_with_data=False) 改本机任务名;默认本机已有录制目录时拒绝(has_data),新名字目录已存在拒绝(name_dir_exists) 更新后的任务 dict;失败抛 TaskValidationError(task_not_found / not_local / 名字错误码)
remove_local_task (task_id) -> bool 硬移除本机任务(整条删除,无墓碑);非本机任务返回 False bool;写文件
new_local_task_id 无参 生成 local-YYYYmmdd-HHMMSS-<4hex> str
_read_raw 无参(内部) 读 tasks.json;缺文件/解析失败时播种 list[dict]
_write (data)(内部) 原子写(.tmp + os.replace) 写文件

关键数据(data/tasks.json 结构):顶层 {"tasks": [...], "updated_at": iso字符串}。任务条目字段:id、name、description、status(load_tasks 自动计算:pending(0%) / in_progress(>0%) / completed(100%))、total_required、assigned_at(ISO 时间)、assigned_user(指派用户;null/空/缺失归一化为 None = 公共任务)、source("local" = 采集端自建;缺失 = 服务端/历史任务)、created_by(本机任务创建身份,仅创建者可见)、params(dict)、completed_count(派生显示值 = backend_count + (local_count - synced_count))、local_count/synced_count/backend_count(进度分账三字段)、hidden(bool 墓碑,仅服务端任务)。内置播种任务:task_001(cylinder_grasping)、task_002(cube_placement)、task_003(valve_rotation)。_count_sessions() 按 settings.RECORDING_DIR/<task_name>/<会话>/meta/info.json 存在与否统计会话数,仅用于旧数据迁移回填。

调用关系:被 ui/main_window.py(load_tasks、increment_task_completed)、ui/task_page.py(load_tasks、filter_by_identity、merge_backend_tasks、refresh_progress、mark_hidden、is_local_task、create_local_task、rename_local_task、remove_local_task)、ui/task_create_dialog.py(validate_task_name、MAX_TASK_NAME_LEN 等)引用。

上传与后端通信(原 network/)

core/api_client.py

作用:对接 Data Acquisition 服务器的 HTTP REST 客户端。基于 requests, 支持复用外部已认证的 requests.Session(如 TaskService 的登录会话); 上传接口用包装文件对象在读取时回调进度,避免把数 GB 的 zip 一次性读入 内存。所有网络异常均被吞掉并转为布尔/空值/错误字典返回,不会向上抛出。

类/函数:

名称 签名要点 作用 返回/副作用
APIClient (base_url: str, session: requests.Session = None) REST 客户端;session 缺省时自建并设置 User-Agent: DAQ-SDK/1.0 —
APIClient.close () 仅关闭自建 session 复用外部 session 时不关闭
APIClient.health_check () -> bool GET /health 健康检查(UI「测试连接」用) 200 为 True,异常/非 200 为 False
APIClient.upload_session_zip (zip_path, session_name, progress_cb: Optional[Callable[[int, int], None]] = None, name: str = "", project_id: str = "") -> dict POST /api/v1/session/upload 上传 zip(progress_cb 收 (uploaded_bytes, total_bytes));name/project_id 为服务器目标项目表单字段(同名项目前缀歧义时服务器返回 409,须用 project_id 消歧) {"ok": True, "session_id": …, "response": …} 或 {"ok": False, "error": …, "ambiguous_project": True}(409 项目歧义时置 ambiguous_project;错误文本截断 300 字符)
APIClient.get_projects (limit: int = 100) -> list[dict] GET /api/v1/projects 项目列表(UI 下拉框用) 异常返回 []
APIClient.get_sessions (limit: int = 50) -> list[dict] GET /api/v1/sessions 查询列表 data["sessions"];异常返回 []
APIClient.try_get_sessions (limit: int = 50) -> Optional[list[dict]] 同上的"可区分失败"版本 **失败返回 None**([] 才是"服务器上没有会话");上传前后的"是否已入库"判定必须用它
APIClient.get_session (session_id: str) -> Optional[dict] GET /api/v1/session/{id} 详情 异常返回 None(注:服务器当前对该端点返回 405,实际不可用)
APIClient.delete_session (session_id: str) -> bool DELETE /api/v1/session/{id} 200 为 True
_ProgressReader 内部类(文件包装) 上传读取时回调进度 用完 close()
_PatientSendConnection urllib3 连接子类 发送阶段用读超时、建连阶段用连接超时(connect() 临时换回 CONNECT_TIMEOUT)、socket 加 TCP 保活 保活 60s+15s×4 ≈ 120s 内发现死连接;socket_options 经 _PatientSendAdapter.init_poolmanager 注入连接池(Windows 无 TCP_KEEPIDLE 时退化为仅 SO_KEEPALIVE)
_UPLOAD_RETRY urllib3.util.Retry total=2, connect=2, read=0, status=0, allowed_methods={"POST"} 只重试建连阶段错误(请求体未写出,不会重复发送);发送/读阶段一律不自动重发,交由 UploadManager 的"查服务器"判定

关键数据:

名称 类型 默认值 说明
CONNECT_TIMEOUT int 10 连接超时(秒);建连失败最多重试 2 次(共 3 次尝试)
READ_TIMEOUT int 1800 读超时(秒);大会话上传+服务器解包入库可能耗时数分钟,设 30 分钟
_KEEPALIVE_IDLE_S / _KEEPALIVE_INTVL_S / _KEEPALIVE_CNT int 60 / 15 / 4 TCP 保活参数(对端静默消失时约 120s 内报错,而非 Linux 默认 2 小时)
User-Agent str "DAQ-SDK/1.0" 自建 session 时写入请求头
API 端点 — POST /api/v1/session/upload、GET /api/v1/sessions、GET /api/v1/session/{id}、DELETE /api/v1/session/{id}、GET /api/v1/video/{id}/{cam}/stream、GET /health 模块 docstring 中的真实 API 清单

调用关系:被 core/uploader.py、ui/upload_dialog.py(懒加载用于测试连接与项目列表)import。依赖 requests。

core/task_service.py

作用:后台任务轮询服务。QTimer 定时触发,每次轮询在 threading.Thread 后台线程执行 HTTP 请求,不阻塞 UI;通过 Qt 信号把任务列表与连接状态通知 UI。支持 Cookie-based JWT 认证:先 POST /api/v1/auth/login (remember_me=True),复用带 cookie 的 requests.Session 轮询 GET /api/v1/device/tasks?device_name=…。设计模式参照已移除的 SyncService。

类/函数:

名称 签名要点 作用 返回/副作用
_normalize_tasks (raw: list[dict]) -> list[dict](模块级函数) 后端字段 → 内部字段名映射 current_count→completed_count,状态 "active"→"in_progress";输出含 id/name/description/status/total_required/completed_count/assigned_at/assigned_user(null/空→None)/params
TaskService (server_url: str = "", parent=None) 任务轮询服务;URL 缺省取 settings.SERVER_URL,轮询间隔取 settings.TASK_POLL_INTERVAL_MS(缺省 30000) 自建带 cookie 的 requests.Session,缓存最近一次任务列表;身份三态 _identity:None=未决 / "guest" / 用户名,_epoch 代次守卫丢弃过期身份响应
TaskService.verify_credentials (url, username, password)(staticmethod) 独立 Session 同步校验登录凭据(供登录对话框用,不触碰实例状态) (ok: bool, msg: str, cookies),成功时 cookies 由 adopt_login 接管
TaskService.adopt_login (url, username, cookies) 采纳已验证登录:cookie 拷入实例 Session,切换账号身份并立即轮询 identity_changed 信号
TaskService.set_guest (url: str | None = None) 切换游客身份:清 cookie 并立即轮询(只应拉到公共任务) identity_changed 信号
TaskService.current_identity () -> str | None 当前身份:None / "guest" / 用户名 —
TaskService.start / stop () 启动 / 停止定时轮询 start 只启动 QTimer 不触发拉取——首次拉取由身份确定路径(adopt_login/set_guest)触发,UI 在身份确定后才 start()
TaskService.poll_now () 手动触发一次拉取 不改变定时器状态
TaskService.set_server_url (url: str) 修改服务器地址并立即重新轮询 清 cookie、置未登录
TaskService.set_credentials (username, password) 更新登录凭据 —
TaskService.set_server_and_credentials (url, username, password) 同时更新地址与凭据 清 cookie、置未登录并重新轮询
TaskService.set_interval (ms: int) 动态修改轮询间隔 —
TaskService.cached_tasks () -> list[dict] 返回缓存任务列表 不发网络请求
TaskService.flush_now () 录制完成后的即时进度上报(后台线程) 本机显示不依赖它,仅为其他机器秒级传播;轮询 tick 兜底
信号 tasks_updated(list) / connection_status(bool) / error_occurred(str) / login_result(bool, str) / identity_changed(str) / identity_expired() / progress_synced() 任务刷新 / 连接状态 / 出错 / 登录结果 / 身份确定或切换("guest" 或用户名)/ 登录态轮询遇 401/403(会话过期,UI 重登或降级游客)/ 进度增量上报成功后本地计数已更新 登录态 401/403 → 身份置空 + identity_expired;游客态 401 只报错不断身份;网络错误与 5xx 只记错误、不改连接状态
内部 _trigger_poll / _trigger_login_and_poll / _login_and_poll / _do_login / _poll / _post_progress / _note_fail / _flush_progress 后台线程调度、登录、拉取、进度水位合并上报 daemon 线程执行;_flush_progress 仅在 GET 200 后调用,_flush_lock 防并发重复 POST

关键数据:

名称 类型 默认值 说明
CONNECT_TIMEOUT int 10 HTTP 请求超时(秒)
PROGRESS_RETRY_INTERVAL_S float 600 进度端点 404 后进入降级,冷却期满再探一次(见 _progress_retry_at)
上报白名单 — _cached_tasks + pending_sync_tasks() 只上报最近一次成功轮询里存在的任务:采集端自建任务(source="local")在 task_record.pending_sync_tasks() 里就被跳过,没有平台侧对应项目,上报必被拒 400,跳过不发也不报错(白名单是第二道保险)
首报基线 — _progress_source 后端该项目口径为 "sessions"(还没收到过任何上报)时,首次上报送本机全量而非增量 —— 只送增量会把后端计数从 session 数砸成增量本身(3/3/3 的项目再录一条变 1);上报成功即本地转 reported,之后只送增量
轮询间隔 int settings.TASK_POLL_INTERVAL_MS(默认 30000) 读不到时 getattr 兜底 5000 毫秒(代码兜底值,非配置默认)
设备认领名 str settings.DEVICE_NAME 读不到时回退 "EGO_001";作为 device_name 查询参数
API 端点 — POST /api/v1/auth/login、GET /api/v1/device/tasks、POST /api/v1/device/tasks/progress 登录 body 含 username/password/remember_me;进度上报 body 含 task_id/session_id/increment/device_name,session_id = "{device}:{task_id}:{水位}" 幂等(后端按 (device_name, session_id) 去重,重复请求返回当前全局数不重复加;游客上报公共任务应放行);404 → _progress_supported=False 降级本地口径,冷却期过后自动重探恢复(不必重启);set_server_url 立即解除降级
User-Agent str "DAQ-SDK/1.0" —

调用关系:被 ui/main_window.py 实例化。依赖 requests 与 PyQt5.QtCore。

core/uploader.py

作用:上传队列管理器。工作流:①(可选)用 ffmpeg 把 session 内 videos 重编码到低码率(保持分辨率/帧率不变,帧级对齐不受影响);②用 zipfile 打包 session 目录为 zip(ZIP_STORED 免二次压缩,EgoData 路径转 LeRobot v3 兼容路径);③经 APIClient 上传;④状态持久化到 SQLite 表 upload_task, 全程通过 Qt 信号通知 UI。失败自动重试(最多 settings.UPLOAD_RETRY_MAX 次),临时 zip 与预压缩产物用完即删。

"上传到底有没有入库"判定(服务器无 GET /session/{id},只能看列表): POST 前拉一次会话列表快照,POST 失败/抛异常后复查——出现"新出现的同名会话" 即判成功、不重传;程序被杀导致的遗留任务在启动续传时按"POST 开始时刻 (upload_task.updated_at,仅 status='uploading' 的行有)↔ 下一次 POST 开始时刻"的时间窗一对一认领(pick_session_in_window),避免同任务多条 续传互相串位或重复上传。

类/函数:

名称 签名要点 作用 返回/副作用
session_name_base (name: str) -> str(模块级) 去掉会话名尾部 episode 标记与 .zip → 任务基名 "X_000012"/"X_ep000012"/"X_episode-12"/"X.zip" → "X"
session_matches (session_name, server_session: dict) -> bool(模块级) 服务器会话记录是否就是本次上传(同名或基名相等) 服务器会给会话名加 _%06d 序号后缀,故用基名比较
pick_new_session (before_ids: set, sessions: list, session_name: str) -> Optional[dict](模块级) 快照差集:新出现的同名会话取最新一条 无则 None
pick_session_in_window (sessions, session_name, since_iso, until_iso="", tol_s=60.0) -> Optional[dict](模块级) 时间窗判定:created_at ∈ [since-tol, until] 的最新同名会话 until_iso 空 = 不限上界;时间戳不可解析 → None
UploadTask (session_path, server_url, episode_index=0, task_id="", resumed=False, created_at="") 单个上传任务数据对象(__slots__ 13 字段,含 resumed) 状态初值 pending,进度 0.0;task_id/created_at 供续传复用 DB 行
UploadTask.to_dict / UploadTask.from_row () -> dict / (row: dict)(staticmethod) 序列化 / 从数据库行还原 from_row 把 resumed 强转 bool;resumed 只在内存,不落库
UploadManager (server_url: str = "", session=None) 上传队列管理器;URL 缺省 settings.SERVER_URL,重试/并发取 settings.UPLOAD_RETRY_MAX / settings.UPLOAD_MAX_CONCURRENT;session 可复用已认证 requests.Session 队列与活跃任务表由 QMutex 保护
UploadManager.server_url property(可写) 当前服务器地址 —
UploadManager.add_task (session_path: str, episode_index: int = 0, task_id: str = "", resumed: bool = False, created_at: str = "") -> str 入队一个任务(先写库再入队) 返回 task_id;task_id/created_at 非空时复用续传的 DB 行而非新建
UploadManager.add_tasks (items: list) -> list[str] 批量入队 元素为 (session_path, episode_index) 或裸路径;只收存在的目录
UploadManager.start / stop () 启动 / 停止 worker 线程 stop 等线程最多 10 秒
UploadManager.pending_count / active_count / all_done () -> int / () -> int / () -> bool 队列计数查询 —
UploadManager.inflight () -> dict 队列中/执行中的任务快照,键 (session_path, episode_index) 值 {"task_id", "state"(active/queued), "progress", "elapsed"(秒,仅 active)};供上传对话框显示 ⏳
UploadManager.resume_pending (max_age_hours: float = 24.0) -> tuple 启动续传:捞 status IN ('pending','uploading') 且 created_at 在窗口内的 DB 行重新入队 返回 (resumed, verified, skipped);resumed = (task_id, path, episode_index),verified = (task_id, path, episode_index, server_session_id)(判定服务器已入库、直接收尾),skipped = {"path", "episode_index", "reason"};跳过原因 server_changed/already_done/missing_files/already_queued;同一任务多行只取最新一条,服务器快照只拉一次
UploadManager.sweep_orphan_temp_files (base_dir: str, max_age_hours: float = 24.0) -> int(staticmethod) 清理上次异常退出遗留的临时 zip / 临时 parquet / 预压缩产物 返回删除数;只删 _*_upload_*.zip、_episodes_*.parquet、_precomp_* 且超龄的文件
UploadManager._local_episode_exists (session_path: str, episode_index: int) -> bool(staticmethod) 续传前确认本地 episode 文件还在 经 core.helpers.list_task_episodes 判断
UploadManager._resume_sessions_snapshot () -> Optional[list] 续传用的服务器会话列表快照(settings.UPLOAD_SESSION_SNAPSHOT_LIMIT 条) 查询失败返回 None → 不做"已入库"判定,全部照常重传
UploadManager._mark_upload_verified (task, server_session: dict, reason: str, emit: bool = True) 复查确认服务器已入库后按成功收尾 落库 completed/进度 1.0/server_session_id;emit=True 时发 task_progress+task_status+task_completed,resume_pending 传 emit=False(那时调用方还没登记 task_id,收尾由它自己补跑)
UploadManager._verify_after_failure (client, task, before_ids: set, baseline_ok: bool) -> Optional[dict] POST 失败/中断后复查服务器会话列表 无基线或查询失败返回 None(宁可重传);命中 pick_new_session
信号 task_added(str) / task_started(str) / task_status(str, str) / task_progress(str, float) / task_completed(str) / task_failed(str, str) / all_completed() 入队 / 开始 / 状态文字 / 进度 0~1 / 完成 / 失败 / 全部完成 —
UploadManager.get_upload_status (session_path: str, episode_index: int = 0) -> str(staticmethod) 查某会话某 episode 最近一条上传状态 无记录返回 "pending"
UploadManager.list_tasks (session_path: str = "") -> list[dict](staticmethod) 查上传记录(可按会话过滤) 按 created_at 倒序
内部 _worker_loop / _upload_one / _zip_session / _find_working_ffmpeg / _precompress_videos / _save_to_db 并发调度、单任务全流程、打包、ffmpeg 探测、视频预压缩、写库 打包与预压缩的临时文件名均带 task_id,避免同进程并发任务互相覆盖

关键数据:

名称 类型 默认值 说明
并发 / 重试 / CRF 常量 取自 settings.UPLOAD_MAX_CONCURRENT(默认 1)、settings.UPLOAD_RETRY_MAX(默认 3)、settings.UPLOAD_VIDEO_CRF(默认 30) 串行=1 的原因:并发时预压缩临时文件互相覆盖致视频损坏
上传状态值 str "pending" / "uploading" / "completed" / "failed" 重试期间回 pending 重新入队;uploading 在 POST 发起前落库、其 updated_at 即"POST 开始时刻",续传时间窗判定依赖它(程序被杀时这一行会留在 uploading)
upload_task 表字段 — id, session_path, session_name, status, progress, retry_count, server_url, server_session_id, error_message, created_at, updated_at INSERT OR REPLACE 写 core/database.py 的 db
进度区间约定 — 预压缩 00.08,打包 0.080.12,上传 0.12~1.0 task_progress 的 0~1 映射
路径转换 — EgoData chunk-0000 → LeRobot v3 chunk_0000;videos/stereo_left/chunk-0000/stereo_left_aux.mp4 → videos/stereo_left/chunk_0000/stereo_left_aux.mp4;旧扁平结构 videos/<file>.mp4 → videos/<cam>/chunk_0000/file-0000.mp4 打包时改写 arcname
ffmpeg 候选 str 列表 ~/miniconda3/envs/lerobot/bin/ffmpeg、~/anaconda3/envs/lerobot/bin/ffmpeg、PATH 上的 "ffmpeg" conda base 的 ffmpeg 因 openvino/tbb 库冲突不可用,故优先 lerobot 环境的独立 ffmpeg;找不到则跳过压缩
预压缩参数 — libx265、-crf UPLOAD_VIDEO_CRF(默认 30)、-preset veryfast、-tag:v hvc1、-pix_fmt yuv420p、单文件超时 3600 秒 仅处理 videos/ 下 .mp4/.avi/.mov;v1.0.9 起先 ffmpeg -i 探测源编码,已是 HEVC(录制直出)的跳过预压,探测失败按未知照旧压缩

调用关系:被 ui/main_window.py、ui/upload_dialog.py、ui/playback_dialog.py(懒加载)import。自身调用:config.settings、core.database.db、core.api_client.APIClient。依赖 PyQt5.QtCore(QObject、pyqtSignal、QMutex、QMutexLocker)。

回放与会话

core/session_timeline.py

作用:录制会话传感器时间线——向量化合并 per-sensor parquet + 帧号二分 查询。替代 ui/playback_dialog.py 中逐行 dict 合并(docstring 实测:60 分钟 会话 179464 行,32.5s → 约 1s),提供 O(log n) 的帧号→传感器行查询。纯 numpy/pyarrow 实现,无 Qt 依赖,可在后台线程运行。

类/函数:

名称 签名要点 作用 返回/副作用
SensorTimeline (frame_indices, timestamps, obs, signal_mask) 统一时间线(按 (episode_index, frame_index) 排序) 属性:frame_indices (N,) int64、timestamps (N,) float64、obs {col: (N,D) float32}(缺值 NaN)、signal_mask (N,) bool、signal_count int;支持 bool()/len()
SensorTimeline.nearest_for_column (col, frame_idx) 该列中 frame_index 最接近的行 `(行号,
SensorTimeline.nearest_for_column_time (col, t_s) 该列中 timestamp 最接近 t_s(秒)的行;含暂停负跳变时退化为向量化 argmin `(行号,
load_timeline (session_dir, sensor_names) 加载会话传感器时间线(per-sensor 多文件或旧格式单表双路径) SensorTimeline
_merge_per_sensor_parquets(内部) (paths, sensor_names) 向量化合并:按 (episode_index, frame_index, round6(timestamp)) 分组,组内取最后一个非空观测值 SensorTimeline
_find_sensor_parquet / _find_all_sensor_parquets(内部) (session_dir) 定位 data/<sensor>/chunk-0000/chunk_000000.parquet 路径或 []
_column_to_matrix(内部) (col, n_rows) pyarrow 列 → (n_rows, D) float32 矩阵(fixed_size_list 展平,失败返回 None) np.ndarray 或 None

关键数据:合并键列 _KEY_COLS = ("episode_index", "frame_index", "timestamp")(旧格式单表缺 episode_index 时填 0);信号掩码语义 = 任一观测列该行 |值|.sum > 0;时间戳不保证严格单调(录制暂停点存在负跳变,查询用帧号)。

调用关系:被 ui/playback_dialog.py(load_timeline/SensorTimeline)、tools/tests/test_playback_multifps.py 引用。

core/session_catalog.py

作用:录制会话目录——扫描、元数据读取与主时钟帧率解析(零 Qt 依赖, 纯函数)。供回放对话框、录制历史刷新与上传对话框共用。

类/函数:

名称 签名要点 作用 返回/副作用
get_effective_fps (info) 回放主时钟帧率:每路相机 fps 最大值;旧数据无 per-camera fps 时按双目命名回退 settings.STEREO_FPS float
list_sessions (directory) 扫描全部会话(含元数据/时长/fps) [{name, path, tag, info, duration, fps}]
list_recordings (base_dir) 轻量扫描全部完整 session(上传对话框口径) [{name, path, tag}]
load_session_meta (session_dir) 同步读单会话元数据(格式探测 / info / fps / 传感器列名) {"fmt", "info", "fps", "sensor_names"}

调用关系:被 ui/playback_dialog.py、ui/upload_dialog.py(list_recordings)使用;调用了 core/helpers.py。

core/session_loader.py

作用:回放后台加载器——把时间线与手部关键点加载放进后台线程,经 finished(int, object) / failed(int, str) 信号回主线程(payload 为 {"timeline": SensorTimeline, "hand_kpts": dict},按引用传递);gen 防过期 协议由调用方(ui/playback_dialog.py)维护——收到 gen 不一致的迟到结果即 丢弃。加载期间新请求排队(最新覆盖旧的,过期结果本就会被 gen 丢弃)。 VideoCapture 不在 worker 中创建/读取(OpenCV FFmpeg 后端不承诺跨线程安全), 视频打开与播放留在调用方主线程。

类/函数:

名称 签名要点 作用 返回/副作用
SessionLoader (parent=None) 后台加载器 信号 finished(int, object)(gen, payload)、failed(int, str)(gen, error)
SessionLoader.start (gen, session_dir, sensor_names, load_kpts=False, load_timeline=True) 后台线程加载;已有加载在跑时把请求排队(新覆盖旧) 无
SessionLoader.is_running () 是否有加载在跑 bool

调用关系:被 ui/playback_dialog.py(.finished 接线 + .start(...) 调用)使用;调用了 core/session_timeline.py(load_timeline)与 core/hand_tracking.py(load_hand_kpts,惰性导入,缺失时降级为不加载关键点)。

手部关键点

core/hand_tracking.py

作用:手部关键点后处理独立模块,零 Qt 依赖纯函数式 API。两种追踪模式: "glove"(YOLO 检测黑色手套 + RTMPose 关键点,仅 2D)与 "bare"(MediaPipe 裸手追踪,2D + 3D world_landmarks,供下游 3D 可视化与数据集导出使用)。 支持对已录制会话逐帧提取关键点写 parquet、手势自动标注 (auto_labels.parquet + auto_actions.jsonl)、以及关键点/标注的读取 (三层路径回退:keypoints_output/ → session/keypoints/ → session/annotations/)。重型依赖(ultralytics/torch/rtmlib 与 hand_pipeline、hand_pipeline_mediapipe、hand_common)全部延迟导入。

类/函数:

名称 签名要点 作用 返回/副作用
process_session (session_path, mode="", detector="", det_device="cuda", pose_device="cuda", progress_cb, status_cb, cancel_check) 逐帧提取手部关键点写 parquet;bare 模式额外写 3D parquet;glove 模式无 CUDA 时设备自动降级 cpu {"success", "frames", "elapsed", "fps", "mode", "session_path"} 或 {"success": False, "error"}
label_session (session_path, progress_cb, cancel_check) 基于手部关键点 + 传感器数据逐帧手势标注,写 auto_labels.parquet 与 auto_actions.jsonl {"success", "frames", "elapsed"} 或错误
load_hand_kpts (session_path) 读 2D 关键点 {frame_index: {hand_data, num_hands, track_ids}} 或 None
load_hand_3d (session_path) 读 3D landmarks {frame_index: row} 或 None
load_auto_labels (session_path) 读自动标注 {frame_index: row} 或 None
draw_kpts_overlay (frame, data, track_ids=None) BGR 帧上叠加关键点 + 手 #id + 手势文本 叠加后帧
classify_gesture (extended, thumb_tip_pt, index_tip_pt) 按伸直手指数 + 拇指-食指距离分类 {"gesture", "pinch", "fist", "extended_count"}
_pack_hand_data / _unpack_hand_data / _pack_hand_3d_data(内部) 见下 固定长度打包/解包 np.ndarray / tuple
_compute_contact(内部) (motion_0, motion_1, sensors) 由手部运动 + 传感器峰值推接触状态 "grasping/holding/reaching/resting/none"
_segment_actions(内部) (rows) 帧级手势 → 动作片段(<10 帧合并入前段) [{start_frame, end_frame, label, dominant_hand, confidence}]

关键数据:常量 PER_HAND_DIM = 21*2 + 4 = 46(21 点×2 坐标 + 4 框坐标)、MAX_HANDS = 2、TOTAL_DIM = 92、PER_HAND_3D_DIM = 21*3 = 63。Parquet schema(真实定义):

  • _HAND_KPTS_SCHEMA:frame_index(int32)、num_hands(int32)、hand_data(list<float32,92>)、track_ids(list<int32,2>)。
  • _HAND_3D_SCHEMA:frame_index(int32)、hand_0_present(bool)、hand_0_landmarks_3d(list<float32,63>)、hand_0_label(string)、hand_1_* 同构。
  • _AUTO_LABELS_SCHEMA:frame_index(int32)、每手 hand_N_gesture(string)/hand_N_extended(list<string>)/hand_N_extended_count(int32)/hand_N_fist(bool)/hand_N_pinch(bool)/hand_N_center_x(float32)/hand_N_center_y(float32)/hand_N_motion(float32)、two_hand_distance(float32)、contact(string)。
  • 手势分类阈值:PINCH_THRESH = 30.0(像素)、TWO_HAND_TOUCH_THRESH = 80.0;MIN_SEGMENT_FRAMES = 10。
  • MediaPipe 模型缓存 ~/.cache/hand_landmarker.task(从 settings.HAND_MEDIAPIPE_MODEL 复制,大小不一致时更新)。

调用关系:被 ui/playback_dialog.py、scripts/process_hands.py、tools/demos/demo_glove_kpts/demo_glove_video.py、core/hand_processor.py、core/auto_labeler.py 引用。

core/hand_processor.py

作用:SessionHandProcessor——对已录制会话逐帧跑手部关键点推理的 Qt 薄 封装,核心逻辑全在 core.hand_tracking.process_session。后台线程执行,经 Qt 信号回主线程。

类/函数:

名称 签名要点 作用 返回/副作用
SessionHandProcessor (parent=None) 后台处理器 信号 progress(int, int)、finished(str, str)(session_path, error_msg,空=成功)、status_changed(str)
SessionHandProcessor.process_session (session_path, detector="", det_device="cuda", pose_device="cuda", mode="") 启动后台处理(运行中再调被忽略) 无
SessionHandProcessor.cancel 无参 取消当前任务 无

调用关系:被 ui/main_window.py、ui/playback_dialog.py 引用;调用了 core/hand_tracking.py。

core/auto_labeler.py

作用:AutoLabeler——基于手部关键点数据的自动标注器, core.hand_tracking.label_session 的 Qt 薄封装。后台线程执行,信号回主线程。

类/函数:

名称 签名要点 作用 返回/副作用
AutoLabeler (parent=None) 后台标注器 信号 progress(int, int)(已处理帧, 总帧数)、finished(str, str)(session_path, error_msg)
AutoLabeler.label_session (session_path) 启动后台标注(运行中再调被忽略) 无
AutoLabeler.cancel 无参 取消(_cancelled 检查 _running) 无

调用关系:被 ui/main_window.py 引用;调用了 core/hand_tracking.py。

手套传感(原 tools/sensors/)

core/ble_engine.py

作用:BLE 传感器数据采集引擎。作为 QObject 运行,在独立线程中处理 BLE 异步通信(基于 bleak),通过 Qt 信号把连接状态、FPS、校准进度与错误 发送到 UI 线程。数据链路:BleakClient 订阅 TX 特征通知 → 原始字节缓冲 → _parse_loop 按帧头/帧尾/校验和解析成 16×16 压力矩阵 → process_frame() 做校准、时域平滑、去基线、漂移补偿、动态噪声门与空间滤波,返回处理后的 矩阵供 UI 渲染(UI 侧是轮询式取帧,而非信号推帧)。

类/函数:

名称 签名要点 作用 返回/副作用
SensorBLEEngine __init__(self) 初始化 16×16 数据数组、降噪参数(base_noise_gate=500 等)、校准状态与线程锁 无
start_scan start_scan(self) 启动 daemon 线程执行 5 秒 BLE 扫描(BleakScanner.discover),按名称含 "Matrix"、非空名称、RSSI 排序 完成后发 scan_complete;失败发 error_occurred
connect_device connect_device(self, address: str) 记录目标地址,启动 _ble_loop(连接 + 订阅通知 + 断线重连)与 _parse_loop(帧解析)两个 daemon 线程 连接成功发 connected;失败发 error_occurred 与 disconnected
disconnect disconnect(self) 置 _running=False,结束通信与解析循环 循环退出时发 disconnected
start_calibration start_calibration(self) 清空校准缓冲并进入校准态 校准期间 process_frame 返回 (None, 0) 并逐帧发 calibration_progress
set_noise_gate set_noise_gate(self, value: int) 设置基础噪声门 无返回
set_dynamic_ratio set_dynamic_ratio(self, value: float) 设置动态噪声门比例 无返回
set_spatial_filter set_spatial_filter(self, enabled: bool) 开关空间滤波 无返回
set_temporal_smooth set_temporal_smooth(self, value: float) 设置时域平滑系数 无返回
process_frame process_frame(self) -> tuple 一帧完整处理:校准 → 时域平滑 → 去基线 → 漂移补偿 → 动态噪声门 → 空间滤波 返回 (processed_data, max_signal);校准期间返回 (None, 0)
信号 device_found(str, str, int) / scan_complete(list) / connected(str) / disconnected() / data_ready(ndarray) / fps_updated(float) / error_occurred(str) / calibration_progress(int) Qt 信号定义,UI 侧连接使用 device_found 与 data_ready 在本文件内定义但未 emit;UI 实际通过轮询 process_frame() 取帧

关键数据:

  • 传感器矩阵:MATRIX_ROWS = 16、MATRIX_COLS = 16,数据以 np.uint16 解析后转 float32。
  • 帧协议:帧头 b"\xAA\x55" + 512 字节数据 + 1 字节 XOR 校验和 + 帧尾 b"\xFB\x03";校验和不通过则整帧丢弃。512 字节 = 256 个 uint16 → reshape 为 16×16。
  • BLE 特征:TX_CHAR_UUID = "6e400003-b5a3-f393-e0a9-e50e24dcca9e"(Nordic UART 服务 TX 特征),通知回调只做字节缓冲。
  • 校准:CALIBRATION_FRAMES = 100,baseline_map = 校准帧逐格最大值 + 10;TARGET_FPS = 100 为常量定义。
  • 降噪默认参数:base_noise_gate=500、dynamic_noise_ratio=0.0、temporal_smooth=0.15、spatial_filter_enabled=True。
  • 漂移补偿:processed 的 40 分位数 > 100 时,减去其 0.8 倍(drift_baseline_val 同时供 UI 显示)。
  • 动态噪声门:min(current_max * dynamic_noise_ratio, 250),与基础门取较大者;低于门限的格点清零。
  • 空间滤波:孤立点判据——邻域(3×3 无中心核)全空且值低于 max(baseline_map * 2.5, 180) 的格点清零。
  • 时间戳:latest_data_ts_us(微秒)由解析线程写入、UI 线程读取,用于数据年龄判断。

调用关系:被 ui/glove_widget.py 导入并实例化(连接信号后调用 connect_device / disconnect,每帧轮询 process_frame() 与公开属性如 is_calibrating、latest_data_ts_us、hardware_fps);被 tools/tests/glove_widget_test.py mock 替换。依赖 bleak、PyQt5、numpy(空间滤波分支依赖 cv2)。

core/render_engine.py

作用:传感器数据渲染引擎,实现五种可视化模式:热力图(render_heatmap)、 轨迹(render_trace)、网格数值(render_grid)、仿生手掌(render_hand)、 拓扑形变(render_deform_mesh)。所有渲染函数接收处理后的 16×16 数据与 配置,返回 BGR 格式的 numpy 帧,由 UI 层转为 QPixmap 显示。本文件同时 集中定义仿生手掌映射配置路径常量(左右手各一套 JSON,位于 config/sensors/), 供 ui/glove_widget.py 与 ui/playback_dialog.py 复用——代码注释说明这些 常量原先定义在 sensors.sensor_panel(已删除),迁到 core 是为了让 UI 取 路径时不必引入 bleak。

类/函数:

名称 签名要点 作用 返回/副作用
CONFIG_DIR / CONFIG_FILE / CONFIG_FILE_LEFT 模块常量 指向 config/sensors/hand_ble_config.json 与 hand_ble_config_left.json(基于 settings.BASE_DIR) —
_get_viridis_lut _get_viridis_lut() -> np.ndarray 延迟初始化 256 级 Viridis 颜色查找表(VIRIDIS_LUT,首次调用构建) 返回 (256, 1, 3) uint8 LUT
_draw_hud _draw_hud(frame, lines, color=(255,255,255)) 帧左上角绘制半透明 HUD 信息栏 原地修改 frame
_load_json _load_json(path, default) 读取 JSON;default 为 dict 时浅合并(默认值兜底),异常静默回退 返回配置 dict
render_heatmap (processed, max_signal, config, window_size, current_vmax, fps, noise_gate, dyn_ratio, spatial_on) 模式 1:子矩阵提取 → subpixel 2× 超分 → 缩放/高斯模糊 → Viridis 伪彩 返回 (frame, new_vmax)
render_trace (processed, max_signal, config, window_size, current_vmax, fps, noise_gate, dyn_ratio, spatial_on) 模式 2:历史数据叠加显示(_trace_canvas 取逐格最大值),支持 clear_trace_canvas 清空 返回 (frame, new_vmax, changed)
clear_trace_canvas clear_trace_canvas() 清空轨迹画布 全局 _trace_canvas 置 None
render_grid (processed, max_signal, config, window_size, fps) 模式 3:每格显示数值,蓝色强度映射幅值 返回 frame
render_hand (processed, max_signal, config, window_size, current_vmax, fps, noise_gate, dyn_ratio, spatial_on, drift) 模式 4:仿生手掌映射——骨骼框架 + 各手指区域传感器格点上色 返回 (frame, new_vmax)
DeformMeshState __init__(self) 模式 5 交互状态:holes / flip_x / flip_y / deform_strength / 网格缓存 / 拖拽绘制状态 无
render_deform_mesh (processed, max_signal, config, window_size, current_vmax, fps, state: DeformMeshState) 模式 5:网格 + 孔洞形变 + 数据点圆 返回 (frame, new_vmax)

关键数据:

  • HAND_ANCHORS:16 个手部锚点坐标(thumb/index/middle/ring/pinky 各自的 tip/joint/base + palm),基于 1280×720 画布设计,运行时按实际 window_size 等比缩放;WRIST_ANCHOR = (640, 600)。
  • DEFAULT_HAND:默认 16×16 传感器到手部区域的映射,如 thumb_joint: rows [0,1,2] cols [14,12,13,15]、palm: rows 0-14 cols [10,9,8,6,4],各手指关节区域共享 axis_order: "col_row"。
  • 配置 JSON 字段(config/sensors/hand_ble_config.json 与 hand_ble_config_left.json):16 个部位键(thumb_tip … palm),每项含 name(中文部位名,如 "大拇指-指肚")、rows、cols、axis_order("row_col" 或 "col_row")。左右两文件键同值异(左手套关节锚点不同)。
  • 热力图/轨迹增强参数:subpixel(默认 True,梯度 2× 超分)、gamma(默认 1.0)、blur(默认 5,奇数化)。
  • vmax 自适应(热力图/轨迹模式):候选值 max(5000, p99.7),上浮时按 current_vmax*0.95 + vmax_cand*0.05 平滑;仿生手掌/形变模式改用 max(max_signal, 5000)。
  • 形变网格:CELL = 26(形变模式)/ CELL = int(14 * min(sx, sy))(仿生手掌模式)。

调用关系:被 ui/glove_widget.py(render_hand、DEFAULT_HAND、_load_json、CONFIG_FILE、CONFIG_FILE_LEFT)与 ui/playback_dialog.py(五种模式、DeformMeshState、clear_trace_canvas)调用。依赖 config.settings(BASE_DIR)、numpy、cv2。

core/sensor_config_dialogs.py

作用:PyQt5 配置对话框。MatrixConfigDialog 是通用 16×16 矩阵行列选择 界面(按钮点击选行/列,可撤销/清空,可选映射方向);HandConfigDialog 是 仿生手掌模式配置界面,为每个手部部位(指尖/关节/指根/手掌)列出当前 rows/cols 映射,点击"选择"弹出子级 MatrixConfigDialog 逐部位编辑。两者 操作的数据结构与 render_engine.py 的配置 JSON 字段一致(rows / cols / axis_order)。

类/函数:

名称 签名要点 作用 返回/副作用
MatrixConfigDialog __init__(self, parent, title: str, config: dict) 构建行列选择对话框(16 个行按钮 + 16 个列按钮 + 映射方向单选) 修改内部 _rows/_cols/_order
_add_row / _undo_row / _clear_row _add_row(self, val) 等 行选择序列的追加/撤销/清空 更新标签显示
_add_col / _undo_col / _clear_col _add_col(self, val) 等 列选择序列的追加/撤销/清空 更新标签显示
get_config get_config(self) -> dict 输出当前选择 返回 {"rows", "cols", "axis_order"}
HandConfigDialog __init__(self, parent, config: dict) 深拷贝配置并构建逐部位配置列表 无
_format_cfg _format_cfg(self, item) 把部位配置格式化为单行摘要文本 返回字符串
_open_sub_config _open_sub_config(self, key) 弹出 MatrixConfigDialog 编辑某部位 确认后写回 item["rows"/"cols"/"axis_order"] 并刷新标签
get_config get_config(self) -> dict 输出全部部位配置 返回完整配置 dict

关键数据:配置结构为 {部位键: {"rows": [...], "cols": [...], "axis_order": "row_col"|"col_row", 可选 "name"}};MatrixConfigDialog 固定显示 16 行(00-15)与 16 列(00-15)按钮。

调用关系:HandConfigDialog 内部调用 MatrixConfigDialog(子对话框),与 render_engine.py 的配置口径保持一致。

core/sensor_hand_config.py

作用:传感器(BLE 手套)仿生手掌配置与有效性过滤(零 Qt 依赖,纯函数)。 供 ui/glove_widget.py 与 ui/playback_dialog.py 共用同一套口径。

类/函数:

名称 签名要点 作用 返回/副作用
load_sensor_hand_config (sensor_name) 按传感器角色加载仿生手掌映射(左/右手套不同配置文件,DEFAULT_HAND 之外部位并入) dict
valid_sensor_names (timeline, names) 只保留时间线中确实存在 16×16 压力矩阵(256 宽)的传感器列(防幽灵传感器格) list

调用关系:被 ui/glove_widget.py(hand_config 加载)、ui/playback_dialog.py 使用;调用了 core/render_engine.py。

通用工具

core/helpers.py

作用:通用工具函数集合(原 utils/helpers.py),按主题分五块:①基础工具 (ID/时间/回收站/格式化);②会话摘要(供录制历史面板"摄像机/时长/大小" 三列使用);③命名与 LeRobot v3 分块路径;④关键点数据路径 (keypoints_output/ 镜像结构 + 两级回退路径);⑤EgoData 格式路径、会话 枚举与格式检测。无类,全部为模块级函数。

类/函数:

名称 签名要点 作用 返回/副作用
new_id () -> str 生成短 ID 12 位十六进制(uuid4().hex[:12])
utcnow () -> str UTC 时间戳 ISO-8601 字符串 YYYY-MM-DDTHH:MM:SSZ
send_to_recycle_bin (path: str) -> bool 将文件/文件夹移入回收站 Windows 走 SHFileOperationW(FOF_ALLOWUNDO);Linux 优先 gio trash,无 gio 时手动移入 ~/.local/share/Trash(写 .trashinfo 记录原路径);成功 True
format_duration (seconds: float) -> str 秒数格式化 HH:MM:SS 字符串
session_size_mb (session_dir: str) -> float 累加会话目录全部文件大小 MB;目录不存在/出错返回 0.0
read_metadata (session_dir: str) -> dict 读会话 metadata.json 任何失败返回 {}
session_summary (session_dir: str, frames_by_cam: dict) -> tuple 会话展示摘要(录制历史面板三列数据源) 返回 (camera_list, duration_sec, size_mb);时长=各相机 帧数/fps 的最大值,帧数全缺时降级 timestamps.json 最后一条相对时间,再降级 total_frames / 最小 fps
format_size_mb (mb: float) -> str 大小列文本 "12.3 MB";0/缺失显示 "-"
timestamp_filename (prefix: str = "rec", ext: str = ".avi") -> str 带时间戳文件名 如 rec_20260804_143052.avi
task_tag (task_name: str = "") -> str 任务分类标签(子文件夹名) 清洗后标签,如 "grasp_cup";空输入返回 "session"
session_dirname (task_name: str = "", batch_index: int = 0) -> str 会话目录名 如 grasp_cup_20260805_195046;batch_index>0 时插入 4 位批次号
chunk_dir / chunk_file (chunk_index: int = 0) -> str / (chunk_index: int = 0, name: str = "file") -> str LeRobot v3 分块目录/文件名 "chunk-000" / "file-000"
data_parquet_path (session_dir, chunk_index=0) -> str 数据 parquet 路径 data/chunk-000/file-000.parquet
episode_parquet_path (session_dir, chunk_index=0) -> str episode 元数据路径 meta/episodes/chunk-000/file-000.parquet
video_mp4_path (session_dir, camera_key, chunk_index=0) -> str 视频路径 videos/<camera_key>/chunk-000.mp4
tasks_jsonl_path (session_dir) -> str 任务记录路径 meta/tasks.jsonl
keypoints_video_dir (session_dir) -> str 关键点视频目录 keypoints_output/<项目>/<会话>/videos/
hand_kpts_parquet_path (session_dir) -> str 手部 2D 关键点路径 keypoints_output/…/hand_pose/chunk-000.parquet
auto_labels_parquet_path (session_dir) -> str 自动标注路径 keypoints_output/…/auto_labels/auto_labels.parquet
hand_3d_parquet_path (session_dir) -> str 手部 3D 关键点路径 keypoints_output/…/hand_pose_3d/chunk-000.parquet
list_all_sessions (base_dir: str) -> list 扫描全部会话目录(兼容 EgoData 与 LeRobot v3) [(ses_path, ses_name, tag_name), …],按名称倒序
episode_dirname (episode_index: int = 1, task_name: str = "", digits: int = 6) -> str EgoData episode 目录名 有任务名 Chew_gum_000001,否则 episode_000001
next_episode_index (base_dir, task_name="", batch_index=0) -> int 下一个 episode 索引 max(batch_index, 目录扫描最大值+1);batch_index<=0 时纯目录扫描
egodata_video_path / egodata_video_dir (episode_dir, camera_name) -> str 视频文件 / 目录路径 videos/<base_cam>/chunk-0000/<camera_name>.mp4;_aux 后缀摄像头归入主摄像头目录
egodata_depth_path (episode_dir, depth_name, frame_index) -> str 深度帧路径 depth/head_depth/000001.png(6 位序号,png16)
egodata_image_path (episode_dir, camera_name, frame_index) -> str 图像帧路径 images/head_left_rgb/000001.jpg
egodata_calibration_path (episode_dir, calib_name: str = "head_stereo") -> str 标定文件路径 calibration/head_stereo.json
egodata_metadata_path (episode_dir) -> str 元数据路径 episode_dir/metadata.json
egodata_timestamps_path (episode_dir) -> str 时间戳路径 episode_dir/timestamps.json
egodata_sensor_data_dir / egodata_sensor_parquet_path (episode_dir, sensor_name) -> str 传感器数据目录 / parquet data/<sensor_name>/chunk-0000/ 与 …/chunk_000000.parquet
detect_session_format (session_dir: str) -> str 会话格式检测 "egodata"(有 metadata.json)/ "lerobot_v3"(有 meta/info.json)/ "unknown"
list_all_egodata_sessions (base_dir: str) -> list 扫描全部 EgoData episode [(ep_path, ep_name, tag_name), …],按名称倒序
内部函数 _slot_base / _sub_name / _device_display_names / _timestamps_duration / _sanitize_tag / _keypoints_session_dir 流名→设备基名、槽名→子画面名、录制历史"摄像机"列文本、降级补算时长、标签清洗、session→镜像目录映射 供公开函数内部使用(见下方"会话摘要"说明)
内部函数 _session_kpts_hand_kpts_path / _session_kpts_hand_3d_path / _session_kpts_auto_labels_path / _legacy_hand_kpts_path / _legacy_hand_3d_path / _legacy_auto_labels_path 回退路径 1:会话内 keypoints/;回退路径 2:最旧版 annotations/ 历史数据兼容定位

关键数据(路径/命名约定,均为代码中真实存在的硬编码结构):

  • 会话识别标志:EgoData 会话 = 目录内含 metadata.json;LeRobot v3 会话 = 目录内含 meta/info.json。
  • 关键点输出镜像:data/recordings/<项目>/<会话> → keypoints_output/<项目>/<会话>(_keypoints_session_dir 基于 settings.RECORDING_DIR 与 settings.KEYPOINTS_OUTPUT_DIR 映射)。
  • 关键点回退查找顺序:keypoints_output/ 镜像 → 会话内 keypoints/ → 会话内 annotations/(最旧版,自动标注为 annotations/mmpose/auto_labels.parquet)。
  • 会话摘要约定(session_summary):每相机 fps 优先取 cameras[cam].fps,缺省 metadata["fps"],再缺省 settings.RECORDING_FPS;camera_list 由 _device_display_names 按设备聚合——D435 一台设备出 rgb+depth 两路子画面显示为 "D435_depth (rgb, depth)",双目 S80M 显示为 "FaysSense S80M (left, right)",单流设备只显示设备名;devices 段缺失(legacy 会话)时按 _slot_base 对 cameras 键分组。
  • 命名清洗规则(_sanitize_tag):保留字母、数字、下划线与中文(Unicode 一-鿿),其余字符替换为 _。
  • episode 索引兼容:新命名 <tag>_NNNNNN 与旧命名 episode_NNNNNN(位数取 settings.EPISODE_DIGITS)。

调用关系:被 core/(recording_record.py、egodata_writer.py、hand_tracking.py、session_timeline.py、session_catalog.py)、ui/(main_window.py、camera_widget.py、playback_dialog.py、upload_dialog.py)、scripts/process_hands.py、tools/tests/ 各 import 点引用;其中 session_summary 供录制历史面板生成"摄像机"列摘要,send_to_recycle_bin 供回放对话框删除会话。自身 from config import settings。

core/frame_gap.py

作用:帧间隔空洞看门狗,纯算术类(零 Qt、零 IO、零第三方依赖,故离线 审计脚本能直接 import)。录制侧(core/pipeline.py 的写入线程)与 tools/audit_frame_gaps.py 共用这一个类——空洞的定义只此一份,否则两边口径 迟早漂移。

空洞 = 相邻两帧时间差 − 标称帧间隔(30fps → 33_333_333 ns)

只报「断了多久」,不判「丢了几帧」(帧率本就不恒定,反推帧数会给出看似精确实则错 的数字)。日志里 [录制] RGB 帧空洞 4648ms 与 parquet 的 *_gap_ms 报的就是这个数。

名称 签名要点 作用
GapWatch (nominal_ns, min_gap_ns) min_gap_ns 是间隔下限(不是超出量),默认取 settings.GRIPPER_RGB_GAP_MIN_MS;间隔 ≥ 它才记为空洞
GapWatch.note (ts_ns, source) -> int 记一帧,返回本次空洞 ns(0 = 正常/跳过)。source:"hw"(帧自带的采集时刻)/ "mono"(出队瞬间的宿主单调钟,用于无时间戳槽位);本段首帧决定基座,之后 source 不符的帧只累加 frames、不参与空洞计算
GapWatch.snapshot () -> dict frames / span_ns / gap_ns / gap_max_ns / gap_count / gap_max_at_ns / resyncs …(全 int)
GapWatch.reset () 每段录制开始时归零

两个坑(都写进了回归用例):① ts_ns == 0 是「无时间戳」哨兵,但负值是合法 时刻——早年段的设备钟是有符号 32 位计数器,按 <= 0 跳过会把整段负值区当「无戳」 并凭空造出假空洞;② 混源(设备钟与宿主钟混喂)会把两个时钟的基准差当成一次天文 数字级的空洞,所以基座一旦锁定就不再接受另一种 source。

调用关系:被 core/pipeline.py(_ext_gap_watch)与 tools/audit_frame_gaps.py import;回归 tools/tests/test_frame_gap.py。背景见 postmortem 第四节。

core/stereo_depth.py

作用:双目深度计算模块。StereoDepthComputer 基于手写 OpenCV StereoSGBM(未用 SDK 深度引擎)+ DisparityWLSFilter(opencv-contrib), 从左右目 BGR 帧算视差图,输出 uint16(像素值 = 视差 × 16,OpenCV 标准亚 像素格式),单位可经标定参数(baseline × fx / disparity)换算为毫米。 depth_to_heatmap 与 DepthHeatmapSmoother 是深度可视化的共享工具(实时 显示与 core/egodata_writer.py 落盘热力图共用)。

类/函数:

名称 签名要点 作用 返回/副作用
depth_to_heatmap (depth_uint16, near_mm=0.0, far_mm=0.0, colormap=cv2.COLORMAP_JET, smooth_k=0) uint16 毫米深度 → BGR 热力图;near/far 同给为真固定色标(整幅 clip 0..255,demo 口径:无效值/近端→JET(0) 深蓝、超远→红饱和,不置黑),否则帧内自适应(min/max→1..255,无效→JET(0) 深蓝);smooth_k>0 先 k×k 中值滤波 np.ndarray (H, W, 3) uint8
DepthHeatmapSmoother (alpha=0.5) 热力图时域 EMA(仅可视化通道,原始 PNG16 不受影响) update(bgr) 返回平滑帧;reset()
StereoDepthComputer (resolution=(800, 640), num_disparities=128, block_size=11) S80M 双目调优的 SGBM 计算器;num_disparities 强制 16 倍数、block_size 强制奇数 无
StereoDepthComputer.compute (left, right)(BGR (H,W,3)) 灰度化 + 直方图均衡 → 左/右视差 → WLS 滤波 → ×16 转 uint16 np.ndarray (H, W) uint16
StereoDepthComputer.num_disparities / block_size property 参数查询 int

关键数据:SGBM 参数(代码硬编码):minDisparity=0、P1=8*3*block²、P2=32*3*block²、disp12MaxDiff=1、preFilterCap=63、uniquenessRatio=10、speckleWindowSize=100、speckleRange=32、mode=STEREO_SGBM_MODE_SGBM_3WAY;WLS lambda=8000.0、sigmaColor=1.5。输出视差缩放:value=256 → 视差 16 像素。

调用关系:被 ui/main_window.py(_on_s80m_depth 用 depth_to_heatmap 渲染 S80C 深度格)、core/d435_manager.py:24 与 core/egodata_writer.py:41(depth_to_heatmap/DepthHeatmapSmoother 热力图生成/落盘)、tools/tests/test_depth_heatmap.py:20 引用。StereoDepthComputer(旧 S80M 视差路径)自 v1.0.11 起无调用方,仅为 demo 保留。

夹爪(core/gripper/,v1.3.0 起)

core/gripper/ 是 UMI 夹爪 rig 的自包含子包(从 online/gripper_version1 分叉,运行期不依赖 online/),与上面平铺的 core/*.py 由 core/gripper/__init__.py 分隔。子包内自带 native/(约 785 MB 厂商二进制与 ORB-SLAM 资源,gitignore, 换机器需重新部署)与 sightac_sdk/(pyarmor 加密随包入库)。

文件 一句话作用
core/gripper/paths.py 原生资源路径与运行时常量补丁;per_device_fays_yamls(serial) 是 per-serial 标定的唯一解析口径
core/gripper/calibration.py 现场读取一只 Fays 的出厂标定,生成它的 per-serial 运行文件
core/gripper/fays_single.py 单夹爪场景的 Fays 租约(SingleFaysLease:ESP 匹配 → 拓扑发现 → 序列号探测 → 标定 → flock → IPC 目录)
core/gripper/fays_runtime.py FaysSense S80M 的单一运行时配置来源 + 探针/运行双 env(build_fays_probe_env / build_fays_runtime_env)
core/gripper/fays_serial_probe.py 经官方 VI Kit SDK 读 S80M 产品序列号
core/gripper/bridge.py 夹爪 rig 与主程序的 Qt 桥(后台线程采集 → 队列信号回主线程)
core/gripper/decxin_exposure.py 连夹爪时把 DECXIN 的曝光/白平衡写回自动档(状态存在相机机身里、逐台各一份)
core/gripper/rgb_quality.py RGB 采集质量仪表:RgbReadWatch(read() 失败连击的进入/限频/恢复三态告警)+ MaxLagWatch(emit 与派发滞后峰值);纯记账,日志行由调用方 _log
core/gripper/affinity.py 夹爪 CPU 预留(主进程掩码收窄 + raw 流接收线程专用核)
core/gripper/tactile_process_worker.py Sightac 触觉计算子进程
core/gripper/slam/ 桥接进程控制器与 stdout 协议解析(process_controller.py / protocol.py)
core/gripper/runtime/ 绑核、线程亲和与跨进程设备互斥(device_access.py、cpu_policy.py、thread_affinity.py、interruptible_queue.py)
core/gripper/recording/ raw 双目/IMU 流客户端(fays_raw_client.py)
core/gripper/devices/ ESP32 串口/控制板、Sightac 与 DECXIN 相机服务、USB 拓扑关联
core/gripper/control/ 夹爪板状态、百分比与双路 Fz 锁存

core/gripper/fays_single.py(配对链与离线入口)

作用:单夹爪场景的 Fays 租约。配对链是「UVC 组 → 唯一 ESP32(bus + outer_path)→ 读板子 NVS 里记的 Fays 序列号(串口 QF)→ 按官方 SDK 读到的 产品序列号唯一匹配在线 Fays」,每一步都失败关闭(不按端口号或枚举顺序 猜);绑定序列号写错的症状就是「连不上」,所以宁可当场报错。

关键接口(离线入口,v1.3.12):都不取租约、不建 IPC 目录,供 GUI 直接调。

方法 签名 作用
esp_binding_status (esp_serial) 连接与绑定现状:{"esp_serial","device","bound","response","reason"}。reason=="" 且 bound=="" =读通了但没绑定(新板子的正常状态,界面可直接进写入步骤);reason 非空则带上串口层原文(如 串口连接失败: serial open/handshake failed: Write timeout),由界面如实显示、不猜
online_fays_serials () 枚举在线 Fays 产品序列号 → (by_serial, skipped);探测口径与 acquire() 完全一致(超速预检 + --serial-only-fast),所以列出来的就是配对会认的;skipped 是被别的会话独占的那些。无完整设备组抛 GripperFaysError
write_bound_serial (esp_serial, fays_serial) WF:<serial> 写 NVS → 立刻 QF 回读并返回值。回读为空串就是空串,不谎报成功;缺节点/连不上/板上 ERR 都带原因抛出
esp_console_report (esp_serial, seconds=3.0) 只读诊断:{"esp_serial","device","physical_usb_path","connected","last_error","state","bound","lines","read_only_lines","read_only_error","seconds"}。除握手 ? 外不发命令、不改板子状态;lines 是随后板上主动吐出的整行。握手失败不是「没有证据」:此时会再 open_readonly() 一次(一个字节都不写)收 seconds 秒进 read_only_lines —— 写阻塞时读这条路仍然通,于是能分清「板子哑了」(0 行)与「板子在说话、只是不收主机写入」(有行),两者归因与处置都不同

公开行为(v1.3.12):三件事都在主程序夹爪右键菜单里(见 docs/ui.md)—— 串口诊断打印板上原样输出,配对序列号走「探板 → 在在线 Fays 里挑一台 → 确认 → 写入 → 回读」。只给选不给手输:写错序列号会让这只夹爪静默指向 别的 Fays,比现状更难排查。三条链路共用主窗口的 _gripper_offline_guard (序列号 + 录制检查)、_gripper_offline_take_over(先关掉已开的夹爪让出 串口)、_reopen_after_offline(善后):只读诊断一律开回,探板/写入失败 不开回(板子状态未知,交回用户决定),用户取消/没找到在线 Fays 则开回。

ESP32 串口(core/gripper/devices/gripper_serial.py):GripperSerial 是 唯一资源所有者。connect() 以 exclusive=True 独占打开,并在 CONNECT_BOOT_TIMEOUT 内反复发 ? 要一行以 STATE 开头的响应;send() 只 接受该命令自己的响应前缀,免得固件周期性的 STATE 顶替真响应(QF 是 两字符命令,前缀表按完整命令查,只查首字符会让它永远命中不到);WF: 的三 种终态都算本条命令的响应。read_lines(duration, *, limit=200)(v1.3.12)是 只读诊断用的收集器:不发任何命令,超时那次 readline 返回空串就继续等到时 间上限。v1.3.12 起 connect() = open_readonly() + handshake() 两半,两半 可以单独调:只读打开再收一段这条路在「写不进去」的板子上依然走得通,是 诊断拿得到 TX 侧证据的唯一途径。

关键数据:connect() 报的 serial open/handshake failed: Write timeout 与 serial handshake timeout after N attempts 是两件事 —— 前者是写阻塞(3 字节 的 ? 都送不进设备),后者是写得进去、板子不回话。两种都表明板子没在 服务自己的 USB 串口,但归因不能说死成「固件没烧」:ESP32-S3 的 USB-Serial-JTAG(303a:1001)节点由芯片自带的 USB 外设枚举,与固件是否在跑 无关,所以「节点在」不能当「板子在服务串口」;而官方 issue(espressif/esp-idf #18996)记录了另一种情形——固件完好,USB 外设被 DTR/RTS 触发的复位打死, 设备仍留在总线上、两个方向皆死寂,只有物理断电/EN 复位能恢复。写超时的 宿主侧机制也不是「FIFO 被灌满」:设备端只是不再「受理」新的 OUT 包 (TRM 33.3.3:缓冲要有空位才收,空位只在固件读走数据后恢复),一个挂起的写 URB 就让 cdc-acm 的 chars_in_buffer(1 个 wb 即 writesize = maxp*20 ≥ 256) 压过 WAKEUP_CHARS,n_tty_poll 于是不再报 POLLOUT —— pyserial 的写后 select 超时,抛 SerialTimeoutException('Write timeout')。权限/占用不在这条 路上:被占是 [Errno 16] Device or resource busy(或 Errno 13),不是写超时。 写阻塞时第一笔 ? 就可能卡住,日志里一条 [Serial] connect attempt n/8 都不会 出现,事后看会误判成没试过握手 —— 所以 v1.3.12 起异常路径额外打一行 [Serial] 握手中断于第 N 次写(最后一次读回 ...): <异常>,把「卡在第几笔」 和「卡之前读到过什么」一起留痕。

调用关系:acquire() 走完整配对(主程序连接夹爪时);GUI 三入口走上面四 个离线方法;离线回归见 tools/tests/test_gripper_fays_pairing.py(配对链 失败关闭)与 tools/tests/test_gripper_esp_binding.py(离线入口 + 界面流程)。

core/gripper/calibration.py

作用:新夹爪接入即自动就位(v1.3.4)。Fays 的运行标定是逐设备出厂 标定,不可跨设备复用(实测同型号不同机的 Camera1.fx/fy/cx/cy、Stereo.b、 IMU.T_b_c1 全不同),所以只能从这只夹爪自身读。本模块在设备锁内调厂商 导出程序,产出三份文件:

产物 路径 内容
原始 dump native/config/calibration/FS-VI80-<model>_<serial>_dump_calib.yaml dump_calib_opencv48 的原样输出(取证/回溯用)
SDK YAML native/gripper_version1/fays_config/fays_vikit_<serial>.yaml 由模板改写两个端口字段(stereo_dev_port / imu_dev_port)
ORB YAML native/dist/fays_opencv48/s80m_<serial>_stereo_inertial.yaml 由 ORB 模板改写出厂内参/IMU 外参

关键接口:

函数 签名 作用
ensure_fays_calibration (product_serial, ports, *, logger=None, force=False, timeout=60.0) 缺则生成、force=True 强制重生成;返回 (sdk_yaml, orb_yaml)
generate_fays_calibration (product_serial, ports, *, logger=None, timeout=60.0) 完整生成链(dump → SDK YAML → IMU 探针 → ORB YAML)
dump_fays_calibration (product_serial, ports, *, output_dir=None, dump_binary=None, template_path=None, timeout=60.0) 只跑厂商 dump,返回原始 dump 路径
run_imu_probe (ports, *, probe_binary=None, template_path=None, timeout=60.0) 只跑 IMU 探针,返回原始 stdout
write_fays_sdk_yaml (product_serial, ports, *, template_path=None, config_dir=None) 只由模板生成 SDK YAML
generate_orb_yaml (product_serial, dump_path, imu_output, *, template_path=None, output_dir=None) 只由 dump + 探针输出生成 ORB YAML

公开行为(v1.3.4):SingleFaysLease.acquire() 在探测到序列号后调 _ensure_calibration() —— 已有标定走 paths.per_device_fays_yamls(serial) 的 isfile 快路径(启动零额外开销、不碰设备);缺文件就地生成,复核落盘 路径后才连上,失败抛带序列号与兜底办法的 GripperFaysError。生成"成功" 却没落在 paths 认得的路径上必须当场报出来,否则会漏出 paths.py 那句误导 人的「请先在夹爪上位机中运行 device_setup」。手工入口与一键重生成: tools/import_gripper_calibration.py --generate(旧文件先备份 .bak_<时间戳>)、设备面板夹爪条目右键「重新读取出厂标定」(录制中禁用, 标定前自动关该夹爪、完成后开回,见 docs/ui.md)。

关键数据:ORB 内参由 cv2.fisheye.stereoRectify(..., CALIB_ZERO_DISPARITY) 的 P1/R1 得到,T_b_c1 = inv(T_cam_imu) @ T_rectified_to_unrectified; IMU 五项(NoiseGyro/NoiseAcc/GyroWalk/AccWalk/Frequency)由 fays_vikit_calibration_probe 读。厂商二进制定位可用环境变量覆盖: KSQ_FAYS_CALIBRATION_DUMP、KSQ_FAYS_IMU_CALIBRATION_PROBE、KSQ_FAYS_ORB_TEMPLATE。 所有接触 SDK 的子进程都套 device_access_guard(FAYS_SDK_INITIALIZATION_LOCK)

  • fays_device_guard(stereo_dev_port) 两把跨进程锁,并在 build_fays_probe_env()(OpenCV 4.2 + fays_atrak + ft602)下运行。

调用关系:由 core/gripper/fays_single.py(acquire() 内联生成、 refresh_calibration() 强制重生成)、tools/import_gripper_calibration.py 与 ui/main_window.py(后台线程跑 refresh_calibration)调用;离线回归见 tools/tests/test_gripper_calibration_autogen.py(重定向全部产物目录 + 假厂商 二进制,含真机产物逐字节回放)。

core/gripper/decxin_exposure.py

作用(v1.3.9):连夹爪时把 DECXIN(1bcf:2d4f)的 auto_exposure / white_balance_automatic 写回自动档。画面暗的根因不在采集链,而在相机 机身:auto_exposure=1(手动)+ AWB=0 会让它停在出厂 156/10000(1.56% 积分)的积分上,而这个状态存在相机里、跨重插保持且逐台各存一份 ⇒ 修好 001 那台对 002 一点用都没有。主程序侧此前没有任何写入口(libuvc 服务只有 uvc_set_altsetting_override;core/camera.py 的 _apply_exposure_to 走的是 OpenCV 通用相机路径,夹爪 RGB 不经过),所以过去每接一台新夹爪都要人工 v4l2-ctl -c auto_exposure=3,white_balance_automatic=1 一次;本模块就是那次 人工动作的自动化。

关键接口:

名称 签名要点 作用
normalize_decxin_exposure (*, logger=None, dry_run=False, max_index=16) 枚举所有 1bcf:2d4f 的物理设备并逐台归一化,返回 List[DecxinExposureResult]
DecxinExposureResult dataclass 单台结论:node/usb_path/state/before/after/detail;state ∈ ok/changed/partial/dry-run/skipped/unsupported,property healthy(skipped/unsupported 之外都算自动曝光在生效——partial 只差白平衡色偏,亮度契约仍成立)
python -m core.gripper.decxin_exposure [--dry-run] [--max-index N] 手工入口,必须先停主程序(见下)

关键数据:纯 fcntl.ioctl,不依赖 v4l2-ctl,不申请缓冲、不取流(O_NONBLOCK 打开,设备正在收流时也不会卡在 open)。ioctl 号由 <asm-generic/ioctl.h> 的 _IOWR 宏现算,期望值 VIDIOC_G_CTRL=0xC008561B / VIDIOC_S_CTRL=0xC008561C / VIDIOC_QUERYCTRL=0xC0445624 在 tools/tests/test_decxin_exposure.py 里钉死。 写之前先 VIDIOC_QUERYCTRL 读菜单:DECXIN 是 1=手动 / 3=光圈优先、没有 0 (写 0 得 EINVAL),故候选序 3/0/2(与 core/camera.py 的候选表同源,那边 0 在前是因为通用相机大多吃 0)。已是自动档则一个字节都不写。相机按 USB 拓扑路径分组而非节点号/by-id(节点号随重插漂移,同型号两台的 by-id 链接 会被顶掉并在重枚举时翻转,见 core/device_detector.py 的 by_id_ambiguous), 一台 DECXIN 的主/次两个节点都留着,哪个带控制项靠 probe 试出来。**只认 1bcf:2d4f**:Sightac 触觉相机(0c45:636f)绝不触碰——它的 AE=1/AWB=0/ WBTmp=6500/ET=312 是原厂存储态。异常一律不外泄:枚举失败=空结果 + 日志。

调用关系:由 core/gripper/bridge.py 的 _open_run 在拿到 Fays 租约之后、 UvcCameraServiceManager.select() 之前调用 —— 这个顺序是硬要求:服务一 启动设备就被 libusb 拿走、内核 uvcvideo 被 libusb_detach_kernel_driver() 摘 掉、/dev/videoN 随之注销,之后所有 V4L2 ioctl 都会失败(这就是「要停掉主程序 才能 v4l2-ctl」的由来)。纯附加动作,异常只记日志(画面暗是小事,连不上是 大事)。离线回归 tools/tests/test_decxin_exposure.py,含「调用点必须早于 select()」的静态断言。

core/gripper/rgb_quality.py

作用(v1.3.10):RGB 采集质量的纯记账仪表。2026-09-18 那 4.68s 空洞当时 在所有计数器里都是 0——因为「相机侧没帧可读」「emit 帧槽被顶掉」「GUI 主线程卡顿」 这三条路在 parquet 里留下完全相同的签名,日志里一行都没写。本模块把三条路分开 记账,只返回日志行列表,由调用方(bridge._log)落地。

类 接口 语义
RgbReadWatch note_fail(mono_ns, *, reconnects, error) / note_ok(mono_ns, *, reconnects, error) / snapshot() read() 失败连击的进入一次 / 按 GRIPPER_RGB_STALL_REPEAT_S 限频 / 恢复一次三态告警(去抖契约照抄 slam/process_controller.py 的 FaysRateAlarm);停摆期间的错误信息本地留一份带进恢复行(read() 一成功相机会清掉 last_transport_error,否则最有用的那句正好在恢复时丢掉)
MaxLagWatch note(lag_ns) / snapshot() 滞后峰值 + 样本数,emit 滞后与 GUI 派发滞后各一个实例
RgbReadWatch.note_overwrite() () latest-wins 帧槽被顶掉的计数(「帧采到了、但没人及时取走」的唯一证据)

snapshot() 的键全为 int,直接喂 CameraPipeline.note_drop_stats 落进 parquet 的 drop_stats(键带 _ms/_count 后缀,不加进帧数,见 core/pipeline.is_frame_drop_key)。

线程契约:类内自带锁(O(1)、无分配,除告警那一次);bridge 侧只在 RGB 线程 写、主线程读;reconnect_count 是普通 int、只算段内增量(reset 时记基座)。 RGB 线程绝不做文件 IO、绝不 emit(_emit_loop 是桥接唯一的信号出口)。

调用关系:core/gripper/bridge.py(_rgb_run 的失败/成功分支、_post_frame 的早退分支、_emit_loop 的滞留测量)、ui/main_window.py 与 ui/lite_window.py (派发滞后 + 录制结束汇总);离线回归 tools/tests/test_rgb_quality.py,含 「门槛差 1ms 不许开火」的反向用例。判据表见 postmortem §4.6。

数据流

采集链路:ui/main_window.py 经 core/device_detector.py(复用 core/camera.py 的 sysfs 枚举)获得统一设备列表;面板开关经 core/device_manager.dispatch_toggle 分派到 _open_*。UVC 走 core/camera.py 的 CameraWorker;S80M 走 core/s80m_manager.py 的子进程 管道(帧 → frame_ready 信号 → _on_stereo_frame 抽帧口径);D435 走 core/d435_manager.py + core/d435_camera.py。所有帧汇入 core/pipeline.py 的 CameraPipeline → 独立写入线程按 30fps 节拍调 core/egodata_writer.py 落盘(MP4 + Parquet + 深度热力图/PNG16 + 元数据)。 手套数据经 core/ble_engine.py 采集 → pipeline.write_sensor 入队。

持久化链路:会话结束后 ui/main_window.py 经 core/recording_repository.py(→ core/database.py 单例 db)把录制摘要写 进 data/pipeline.db 的 recording 表,上传任务另记 upload_task 表 (core/uploader.py 全程状态持久化);任务进度独立走 core/task_record.py 的 data/tasks.json(录制完成 → increment_task_completed 本机 local_count +1 → 显示值重算,与本地会话 文件是否被删无关),随后 TaskService._flush_progress 把本机未上报增量 以水位合并方式 POST /api/v1/device/tasks/progress(幂等键 {device}:{task_id}:{水位},断网/失败由下个轮询 tick 兜底重试;端点不存在 时降级 10 分钟后再探,服务端升级后自动恢复上报,无需重启采集端)。

进度口径:进度只由「录制完成」推动 —— 上传不改变任务进度。后端 GET /api/v1/device/tasks 的 current_count 取各设备上报录制数之和 (从未收到上报的历史项目才回退到项目目录下的 session 数),progress_source 字段标明该项目当前用的是 "reported" 还是 "sessions"。

上传链路:ui/upload_dialog.py 经 core/uploader.py (UploadManager:ffmpeg 预压缩 → zip 打包 → core/api_client.py 上传 → SQLite 状态)→ POST /api/v1/session/upload;meta/info.json 的结构即上传 服务器所依赖的契约。

回放链路:ui/playback_dialog.py 经 core/session_catalog.py 扫描/读 元数据,经 core/session_loader.py 后台加载(core/session_timeline.py 向量化合并 + core/hand_tracking.py 关键点),主线程只做 widget 操作。

后处理链路:已录制会话的手部关键点由 core/hand_tracking.py (scripts/process_hands.py 或 UI 经 core/hand_processor.py/ core/auto_labeler.py)提取为 2D/3D parquet,供下游 3D 可视化与数据集导出 使用。