# 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 370~382 会话:锁定 T=33.1917ms vs 真实均值 33.33ms,一段累计 −103ms;叠加 30~38ms 双峰到达后残差是随机游走),而 `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|glove|other_ble`)、`label`(用户命名优先) | | `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.` 列),须在 `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.` 列 | 无 | | `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: ```python is_frame_drop_key(key) # 帧数键才为真:ext: / 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 "`, -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 | None` | | `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(流启动后读真实量程,兜底 1~66000);S80M → 1.0~885.0 | `dict | None` | | `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//<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//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 即约定):`//_000001/` 下 `videos//chunk-0000/.mp4`、`depth//.mp4`(热力图)或 `depth//000001.png`(png16 uint16 毫米)、`calibration/head_stereo.json`、`metadata.json`、`timestamps.json`、`data//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), observation.imu(list>)`,IMU 样本 6 轴 = `[gx, gy, gz, ax, ay, az]`;`_build_data_schema` 在传感器列 `observation.`(list)之外固定加 `observation.`/`observation.`(list,值全 0 占位)、`action`(list,恒 `[0.0]`)、每设备 `status.`(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.: {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.` 与 `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//<会话>/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` | | 进度区间约定 | — | 预压缩 0~0.08,打包 0.08~0.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/.mp4` → `videos//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` 最接近的行 | `(行号, |Δ帧|)` 或 `(None, None)`,二分 | | `SensorTimeline.nearest_for_column_time` | `(col, t_s)` | 该列中 `timestamp` 最接近 `t_s`(秒)的行;含暂停负跳变时退化为向量化 argmin | `(行号, |Δt|秒)` 或 `(None, None)` | | `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//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)`、`track_ids(list)`。 - `_HAND_3D_SCHEMA`:`frame_index(int32)`、`hand_0_present(bool)`、`hand_0_landmarks_3d(list)`、`hand_0_label(string)`、`hand_1_*` 同构。 - `_AUTO_LABELS_SCHEMA`:`frame_index(int32)`、每手 `hand_N_gesture(string)/hand_N_extended(list)/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//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//chunk-0000/.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//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 索引兼容:新命名 `_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 第四节](postmortem_trajectory_and_rgb.md)。 ### 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:` 写 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-__dump_calib.yaml` | `dump_calib_opencv48` 的原样输出(取证/回溯用) | | SDK YAML | `native/gripper_version1/fays_config/fays_vikit_.yaml` | 由模板改写两个端口字段(`stereo_dev_port` / `imu_dev_port`) | | ORB YAML | `native/dist/fays_opencv48/s80m__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 号由 `` 的 `_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](postmortem_trajectory_and_rgb.md)。 ## 数据流 **采集链路**:`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 可视化与数据集导出 使用。