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

ui/

定位

ui/ 是 DAQ 桌面主程序的 PyQt5 界面层:主窗口把设备枚举面板、多画面网格、录制控制、录制历史、任务选择、上传与回放全部串成图形界面,后台数据链路(管线、数据库、网络服务)均由本层发起的对象持有并注入。界面层本身不写数据文件,持久化一律经 config.settings 与 core.* 完成。

被以下模块引用(证据):

  • main.py from ui.main_window import MainWindow —— 主入口(Qt-Material 暗色主题),构造并 show()。
  • tools/tests/ 多个回归/冒烟测试直接构造界面类:exposure_control_test.py、device_panel_gui_smoke_test.py、grid_drag_fps_test.py、test_playback_multifps.py(还 import _get_effective_fps)、d435_playback_test.py、glove_widget_test.py 等。
  • 包内互引:ui/main_window.py(CameraGrid/DevicePanel/ExposureDialog/LoginDialog/GuideDialog/PlaybackDialog/UploadDialog/TaskSelectionPage)、ui/playback_dialog.py(ZoomableVideoWidget、CameraGrid 及两个分割条常量)、ui/camera_grid.py 与 ui/glove_widget.py(CameraWidget)、ui/login_dialog.py(复用 ui/guide_dialog.py 的 VisibleCheckBox)。

注:磁盘上存在 ui/hand_model/ 目录,但其中只剩未跟踪的 __pycache__/(已删除 __init__.py、state.py 的编译残留),无任何源码 .py,故不列入文件清单(其源码已在开源准备提交 8ea2f44 的死代码清理中移除)。

文件清单

文件 一句话作用
ui/main_window.py 主窗口:任务/采集双页面栈、设备面板路由(UVC/D435/S80M/蓝牙统一开关)、录制控制、录制历史表、上传与回放入口
ui/camera_grid.py 嵌套 QSplitter 多画面网格容器:画面拖拽调位、分割条调大小、空状态提示
ui/camera_widget.py 单路画面控件:ZoomableVideoWidget(滚轮缩放/拖拽平移/双击还原)+ CameraWidget(信息条、FPS、状态灯、☀ 曝光按钮)
ui/device_panel.py 左侧设备检测面板:已连接设备统一分组列表(UVC/D435/S80M/手套/其他蓝牙),勾选开关、双击命名
ui/exposure_dialog.py 每设备曝光设置对话框(自动曝光开关 + 滑块,量程/单位语义由调用方注入)
ui/glove_widget.py BLE 手套仿生手掌画面控件,直接嵌入主网格,录制数据经 pipeline.write_sensor 落盘
ui/login_dialog.py 启动/切换账号登录对话框:账号登录(对话框内同步校验)或游客登录,关闭窗口等价游客;结果经 choice()/server_url()/username()/cookies() 等访问器读取
ui/guide_dialog.py 使用说明窗口(首次启动自动弹出、帮助菜单重开;中英 HTML 模板;「不再显示」持久化到 QSettings)+ VisibleCheckBox 复选组件
ui/playback_dialog.py 录制回放对话框:统一网格同步播放全部视频 + 传感器渲染,深度槽识别、手部关键点叠加、批量删除
ui/pose_view_qt.py 夹爪位姿视图控件:QPainter 手绘的实时轨迹 + 世界坐标轴 + 跟随夹爪的体轴三箭头 + 夹爪模型(两张夹板按开合度张合)
ui/task_page.py 启动时的任务选择页:任务表(5 列,含「来源」)、新建/改名/删除本机任务、服务器地址/凭据输入、连接状态、进入采集
ui/task_create_dialog.py 本机任务对话框:新建(名称/目标段数/描述 + 实时校验)与改名(已有录制目录时禁止),错误码本地化
ui/upload_dialog.py 录制会话一键打包上传对话框
ui/__init__.py 空包标记(0 行)

各文件详解

ui/main_window.py

作用:应用程序主窗口,界面层中枢。组合页面栈(任务选择页 + 数据采集页)、CameraGrid 主网格、左侧 DevicePanel 设备面板(_device_dock:可拖宽/拖窄,带 Closable 故标题栏有 × 可收起;宽度与收起状态经 server_config.json 的 device_dock 段跨启动保持,恢复入口为「视图 ▸ 设备检测栏」/ Ctrl+D;只有用户明确收起(点 × / 走菜单)才记偏好,正常关窗与最小化不算收起)、底部日志 dock、右侧录制历史 dock、工具栏与菜单;启动时经 _show_login_flow 弹 LoginDialog 确定账号/游客身份,首次启动经 _show_guide 弹 GuideDialog 使用说明;持有 CameraPipeline、DeviceScanner、TaskService、UploadManager 与 core.device_manager.DeviceManager 注册表(_workers 为其 entries 别名)等核心对象并负责信号接线。所有设备的开启/关闭都经左侧面板开关按 core.device_manager.dispatch_toggle 分派到 _open_*/_close_* 系列方法:S80M 双目子进程 + 管道解析在 core.s80m_manager.S80MDeviceManager,D435 worker 生命周期与帧口径在 core.d435_manager.D435DeviceManager,手套以 BLE 引擎 + 渲染定时器运行。

模块级辅助与常量(v1.0.3 后多为 core 模块的兼容 re-export,离线测试直接 patch 这些名字):

名称 签名要点 作用 返回/副作用
_realsense_short / _slot_base / _normalize_original — core.device_naming 的 realsense_short/slot_base/normalize_original 旧名兼容别名 同 core 定义
_LEGACY_CAM_NAMES 常量 旧录制记录的退化相机名("多路录制"/"Multi-Camera"/""),历史表自愈判据 —
_STEREO_AVAILABLE 常量 core.s80m_manager.STEREO_AVAILABLE 别名(自包含双目模块 tools/stereo_s80m/read_stereo_rgb.py,内含 libfays_vikit.so 3.9.0 与 yaml,与外部 SDK 目录独立) —
_D435_AVAILABLE / D435Worker / list_d400_devices 常量 导入 core.d435_camera 并缓存 d435_available();导入失败全置 None/False(模块全局保留供离线测试 patch) —
_HAND_PROC_AVAILABLE 常量 core.hand_processor.SessionHandProcessor + core.auto_labeler.AutoLabeler 可选导入结果 —
_GLOVE_AVAILABLE 常量 ui.glove_widget.GloveWidget 可选导入结果(依赖 bleak 子系统) —

类/信号(class MainWindow(QMainWindow)):

名称 签名要点 作用 返回/副作用
stereo_frame_ready pyqtSignal(str, np.ndarray, object, list) 双目帧信号(FIFO 读取线程 → 主线程),参数 slot_id, frame, hardware_ns, imu_samples 连接到 _on_stereo_frame。★ 封送坑:hardware_ns 必须用 object——PyQt5 队列信号把 Python int 按 C++ qint32 封送,超过 2^31(约 2.1s 纳秒)会静默截断为负数,录制数据受害
log_message pyqtSignal(str) 跨线程日志信号(后台线程 → 主线程 QTextEdit.append) _log() 在非主线程时经此投递
_upload_session_deleted pyqtSignal(str, str) 上传后自动删除线程 → 主线程,参数 (session_path, error_msg) 连接到 _on_upload_session_deleted

相机管理(设备开启/关闭全在此区):

名称 签名要点 作用 返回/副作用
_auto_detect_cameras () 工具栏「🔍 扫描」:立即请求一次设备扫描(接入统一走面板开关,不自动打开画面) 记日志;DeviceScanner.request_scan()
_teardown_all_workers () 委托 core.device_manager.DeviceManager.teardown_all(close_fns) 按 kind 分类关闭全部已开启设备 清空注册表
_open_uvc / _close_uvc (dev) -> bool / (dev_key) UVC 面板开关:增量建槽(settings._camera_slot_name(dev.video_index))/ 按注册表拆槽;条目经 DeviceManager.uvc_entry 创建 bool 成功与否
_open_s80m (dev) -> bool 面板开关打开 S80M 双目:先注入静态标定(config/s80m_stereo_calibration.json → StereoCalibration.load),创建 stereo_left/stereo_right 两槽并注册外部帧源(fps=settings.STEREO_RECORD_FPS=30);s80m_depth_available() 时追加第三格 stereo_depth(set_depth_camera,PNG16,v1.0.11),随后子进程/Popen/管道/watchdog/读线程全部交 core.s80m_manager.S80MDeviceManager.spawn bool;与 D435 互斥(共用 video0/video2,后开者弹窗拒绝)
_on_stereo_frame (slot_id, frame, hardware_ns=0, imu_samples=None) 主线程槽(S80MDeviceManager.frame_ready 转投):显示帧直推画面;录制时按 core.s80m_manager.frame_record_decision(1/30s 桶)抽帧;IMU 仅随 stereo_left 落盘 pipeline.write_external_frame(...)
_on_s80m_depth (slot_id, depth, hardware_ns=0) S80MDeviceManager.depth_ready 槽:depth_to_heatmap(固定色标 + 中值滤波)→ 深度格显示;录制中 pipeline.write_depth —
_close_s80m (dev_key) 委托 S80MDeviceManager.close(停 watchdog → 关 stdin → terminate/kill → join 读线程 → 清理临时文件),再注销两路外部帧源、clear_depth_camera(stereo_depth)、移除 UI 控件、清除本设备标定 —
_open_d435 (dev) -> bool 面板开关打开 RealSense:按 serial 活体复查 list_d400_devices()(不信任导入期缓存)、按 serial 查重、与 S80M 反向冲突检查;槽名 = core.device_naming.allocate_slot_names 派生 {base}_rgb + {base}_depth,RGB 注册外部帧源、深度注册深度伪相机(set_depth_camera,热力图 MP4 + PNG16 双通道,raw_depth=True);worker 创建/信号接线/启动交 core.d435_manager.D435DeviceManager.spawn bool;S80M 已开时弹窗拒绝
_on_d435_frames (slot_id, frame, hardware_ns=0, imu_samples=None, dev_key=None) 委托 core.d435_manager.D435DeviceManager.process_frame(深度槽 → 热力图显示 + write_depth;RGB 槽 → 显示 + write_external_frame;首帧后标定送进管线 calibration/head_stereo.json) 已关闭设备的迟到信号直接丢弃
_close_d435 (dev_key) 委托 D435DeviceManager.close(断信号 → stop → deleteLater)→ 注销外部帧源 + 清除深度伪相机 + 清除标定 → 移除双槽 —
_add_camera_slot (slot_id, camera_index, backend="", label="") UVC 建槽:管线 add_camera + 网格 add_camera + _connect_slot_signals 失败仅记日志
_connect_slot_signals (slot_id) 单个 CameraSlot 信号 → UI 回调:frame_ready/state_changed/error_occurred/camera_opened/fps_updated,以及 widget 的 remove_requested/exposure_clicked —
_remove_camera / _remove_camera_slot_no_confirm (slot_id) 移除单槽(录制中先弹确认)/ 不确认版;若槽属于某面板开启设备则整机关闭(d435 移除双槽之一即整机关)并取消勾选 同步面板勾选与高亮
_remove_all_cameras () 工具栏「✕ 全部移除」:确认后先 _teardown_all_workers() 再逐槽移除 清面板勾选/高亮集合

设备面板路由与曝光:

名称 签名要点 作用 返回/副作用
_device_label (dev) staticmethod DeviceInfo → 日志显示标签(用户命名优先,有 serial 时附 — serial) str
_on_devices_scanned (devices) 扫描结果(每 2s 轮询,settings.DEVICE_POLL_INTERVAL_MS=2000):填用户命名 → DevicePanel.set_devices + 恢复勾选/高亮 → 插拔 diff 日志(只记变化)→ 录制中拔线暂存 _lost_device_keys 重插恢复勾选 → 已开启但已消失的设备逐个 _handle_active_device_lost —
_handle_active_device_lost (key) 已开启设备被拔出:录制中不自动移除(重插恢复),否则按 kind 关设备 —
_on_device_toggled (dev, on) 面板 device_toggled 路由:委托 core.device_manager.dispatch_toggle(录制锁双保险 + kind 路由到 _open_*/_close_* 回调,多路并发,互不拆台);打开失败回退勾选 维护 _active_device_keys
_on_device_renamed (dev, name) 面板双击命名后:刷新插拔标签缓存与 worker 注册表标签 持久化已在面板完成
_show_exposure_button (slot_id) 在设备的「主槽位」(S80M 左目、D435 的 RGB 槽)信息条显示 ☀ 按钮 —
_open_exposure_dialog (slot_id) ☀ 入口:对话框参数口径在 core.exposure_controller.exposure_dialog_params(D435:worker 读回 µs 量程;S80M:1.0~885.0,decimals=1;UVC 无曝光入口固定自动曝光);按类型弹 ExposureDialog 模态 exec_()
_apply_exposure (dev_key, entry, auto, value) 委托 core.exposure_controller.apply_exposure(d435 → worker.set_exposure;s80m → _s80m_set_exposure 回调)并持久化(下次开启自动应用) —
_s80m_set_exposure (dev_key, auto, value) 委托 S80MDeviceManager.send_exposure(stdin 行协议 "SET_EXPOSURE <float>",-1.0=自动曝光),SDK 运行时生效无需重启 BrokenPipe 时记日志
_set_exposure_buttons_enabled (enabled) 录制锁:所有已显示的 ☀ 按钮随录制状态禁用/恢复 用 isHidden() 判断(窗口未 show 时 isVisible 恒 False)
_open_glove / _close_glove (dev) -> bool / (dev_key) 手套开关:广播名 L/R → settings.assign_glove_sensor_role 优先绑左右手;创建 GloveWidget(slot=sensor:{key})注入管线并 start(address),pipeline.register_sensor(role);关闭时停 BLE、撤画面、注销传感器列 bool
_open_ble_placeholder / _close_ble_placeholder (dev) -> bool / (dev_key) 无数据蓝牙(耳机类):主网格显示「该设备无可视化数据」占位画面 bool

录制:

名称 签名要点 作用 返回/副作用
_reset_s80m_record_state () 委托 DeviceManager.reset_s80m_record_state:录制起止时重置 S80M 50→30 抽帧状态(last_bucket 桶号 + pending_imu 缓冲) —
_build_device_meta () -> list 委托 DeviceManager.build_device_meta:手套无画面槽附 sensor_column;uvc/d435/s80m 附 slots(及 serial);无数据蓝牙不进 devices 段 list[dict]
_record_all () 工具栏「⏺ 全部录制」:任务名取自所选任务;所选任务进度已满(completed_count >= total_required,本地 load_tasks() 计数)时弹窗阻止;否则 pipeline.start_recording(首个槽, task_name=..., batch_index=已完成数+1, device_meta=...) —
_stop_all () 「⏹ 完成录制」:pipeline.finish_recording("") + 重置抽帧状态 —
_abort_recording () 「⛔ 异常终止」:pipeline.abort_recording("")(永久删除本次文件) 记日志、刷新状态栏

信号处理器(管线 → 控件):

名称 签名要点 作用 返回/副作用
_on_slot_added / _on_slot_removed (_slot_id) 空实现(控件已在 _add_camera_slot 中创建/移除) —
_on_frame (slot_id, frame) UVC 帧 → 画面(settings.CAMERA_MIRROR_HORIZONTAL 时先水平镜像) w.update_frame
_on_camera_state (slot_id, state) 状态 → 指示灯 + 日志;CameraState.DISCONNECTED 时 pipeline.record_event(slot_id, "disconnected"),否则记 "connected" —
_on_camera_fps (slot_id, fps) UVC 由 CameraWorker 自带的 fps 信号 → 标签 —
_note_frame_arrival (slot_id) 记录一帧到达时刻(D435/S80M 无 CameraWorker,主窗口统一计 FPS) 写入 _fps_ring
_update_fps_labels () 每秒刷新非 UVC 槽的 FPS(过去 1 秒到达帧数) —
_on_recording_started (slot_id) 录制开始:锁死设备面板开关 + 禁用曝光按钮 + 日志(含编码与任务名) —
_on_recording_finished (slot_id, session_path) 录制完成:解锁面板/曝光;core.helpers.session_summary(session_path, pipeline.last_recording_frames) 算摄像机列/时长/大小 → RecordingRecord 存库 → 刷新历史;increment_task_completed(实际任务名) 更新任务进度;自动上传入队(开关开启且该会话尚未 completed 时 _upload_manager.add_task);HAND_TRACK_ENABLED 时延迟 500ms 自动处理手部关键点(silent) —
_on_recording_aborted (slot_id) 异常停止:解锁面板/曝光,取消手部后处理与标注 —
_on_duration (_slot_id, seconds) 录制时长 → 状态栏 —

录制历史:

名称 签名要点 作用 返回/副作用
_refresh_history () RecordingRepo.list_all(limit=100) → 表格 5 列:摄像机/文件/时长/大小/状态;旧记录自愈:退化值(无大小、_LEGACY_CAM_NAMES 相机名、无时长)实时从磁盘 session_summary 补算并回写 DB;状态列按 completed/uploaded/uploaded_deleted 绿色(uploaded 含自愈兜底:completed 且目录仍在、上传任务表记为 completed 的旧行也显示「已上传」)、其余(已丢弃)异常色 启动时即加载

上传 / 回放入口:

名称 签名要点 作用 返回/副作用
_open_playback () 「📂 回放」:PlaybackDialog(self, settings.RECORDING_DIR) 模态打开 —
_open_upload () 「☁ 上传」:UploadDialog(self, settings.RECORDING_DIR, session=TaskService._session) 模态打开 —
_on_upload_task_done (task_id) 自动上传完成:记日志;UPLOAD_DELETE_AFTER 开启时后台线程删本地目录,否则 RecordingRepo.mark_uploaded 标「已上传(本地保留)」并刷新历史 —
_delete_session_after_upload (session_path, episode_index=0) 后台线程 delete_local_session(真删除 + 删除后校验,防大目录阻塞 UI),结果经 _upload_session_deleted 回主线程 —
_on_upload_session_deleted (session_path, err) 删除收尾:失败(含校验残留)记日志;成功 RecordingRepo.delete_local_episode 真删录制行并刷新 —
_on_upload_task_failed (task_id, error) 自动上传失败(重试 3 次后仍失败才触发)→ 日志 —
_on_upload_auto_toggled / _on_upload_delete_toggled (on) 两个工具栏开关:settings.save_upload_auto_sync / save_upload_delete_after 持久化 + 运行时立即生效 + 按钮文字/样式刷新 —
_style_toolbar_toggle (action) 开关类 QAction 按钮高亮样式(开启绿底白字加粗) —

任务服务与页面切换:

名称 签名要点 作用 返回/副作用
_connect_task_service () TaskService 信号 ↔ TaskSelectionPage:tasks_updated/connection_status/login_result/identity_changed/identity_expired;任务页 refresh_requested→poll_now、switch_account_requested→_on_switch_account;错误首次出现只记一次(_last_task_error 判重) 轮询在身份确定后(_show_login_flow 末尾)才 start()
maybe_show_login / _show_login_flow () 启动入口(main.py 调用)→ 登录流程:LoginDialog exec 后 save_server_url;账号登录成功经 TaskService.adopt_login 接管 cookie(save_remembered_username),否则 set_guest;同步 _upload_manager.server_url 与任务页 set_server_display 后 task_service.start() —
_on_switch_account () 任务页「切换账号」按钮 → 重开登录对话框 —
_on_identity_expired () 登录态轮询遇 401/403 → 提示重登;关闭对话框(Esc)自动降级游客 —
_on_task_confirmed (task) 选中任务进入采集页(index 1),更新窗口标题;不自动扫描,提示先选模式再点扫描 —
_on_page_changed (index) 页面切换控制工具栏/dock 可见性;任务页停设备轮询,采集页立即扫描 + 启动 _device_timer(2s 轮询);末尾统一调 _apply_device_dock_visibility(设备栏:任务页隐藏、采集页显示,但用户收起过就保持收起) —
_go_back_to_task_selection () 返回任务选择:录制中先确认(同意则 abort);取消手部后处理/标注;_teardown_all_workers + 清网格 + 清面板状态;切回 index 0 并 poll_now() 刷新任务列表 —

其余(语言/视图/手部后处理/日志/退出):

名称 签名要点 作用 返回/副作用
_on_language_changed (lang) 中英文切换:菜单/工具栏/dock 标题/历史表头/手部模式下拉全部 tr() 重刷,并调 _task_page._on_language_changed —
_reset_camera_sizes () 视图菜单「重置画面大小」:grid._rebuild_layout() 等分 —
_apply_device_dock_visibility () 设备栏可见性的唯一口径:当前页==1 且 用户未收起 → 先 _capture_device_dock_width(收起前记住栏宽)再 setVisible,显示且非浮动态才 resizeDocks 复原宽度;期间置 _device_dock_syncing 挡「程序化改动被当成用户收起」 末尾同步菜单勾选
_toggle_device_dock (checked) 视图菜单「设备检测栏」/ Ctrl+D:置 _device_dock_user_hidden → 应用可见性 → 落盘 任务页只记偏好不显示
_on_device_dock_visibility (visible) 设备栏可见性变化 → 同步菜单勾选 + 落盘(幂等)。不从可见性推断用户意图:关窗/最小化/切页都会让 dock 不可见,把它们当成「用户收起」会让每次正常退出都写坏偏好;_device_dock_syncing 置位时直接返回 用户收起只由 × 的 Close 认定(见 eventFilter)
_sync_device_dock_action () 菜单勾选 = 用户是否想看设备栏(与当前页是否显示无关;blockSignals 防递归,C++ action 已删时吞 RuntimeError) —
_save_device_dock_prefs () _capture_device_dock_width() 后 settings.save_device_dock_prefs(未收起, 栏宽);写盘失败只丢一次偏好不抛异常(PyQt5 虚方法抛异常 = SIGABRT 134,会连带跳过停线程/拆 worker) closeEvent 也调一次
_capture_device_dock_width () 抓「此刻真实栏宽」:浮动态(width() 是浮动窗口宽)与不可见时直接返回。不跟随普通 Resize —— 窗口缩小/最大化时 Qt 会把 dock 压到最小宽,那不是用户拖出来的宽度 收起前 / 退出前各抓一次
eventFilter (obj, event) 仅拦 _device_dock 的 QEvent.Close → 置 _device_dock_user_hidden=True + 抓栏宽,这是唯一认定「用户收起」的地方(点标题栏 × / 右键菜单关闭 / 浮动窗口关闭都走 close() 发 Close 事件;而主窗口关闭/最小化只发 Hide,不给 dock 发 Close —— 2026-10-13 实测) 其余事件走 super()
_update_status () 状态栏:摄像机数(排除 sensor:/ble: 占位槽)+ 录制中标记与颜色 —
_process_hand_keypoints (session_path="", silent=False) 后台提取手部关键点(SessionHandProcessor.process_session,mode 由手套/裸手下拉决定);已有结果时弹确认(silent 模式跳过);无路径用最近一条录制 —
_on_hand_proc_progress / _on_hand_proc_finished (current, total) / (session_path, error) 进度 → 状态栏+按钮;完成后 300ms 自动触发 _auto_label(silent) —
_auto_label / _on_auto_label_finished (session_path="", silent=False) / (session_path, error) 基于关键点 parquet 自动生成手势标签(AutoLabeler.label_session) —
_log (msg) 时间戳日志:主线程直接 append,后台线程经 log_message 信号安全投递 —
_show_about () 关于对话框(应用名/版本/功能简介) —
closeEvent (event) 安全退出:置 _shutting_down → 停轮询/扫描器 → 录制中 abort → 拆全部 worker → 移除全部槽 → 取消手部后处理 → 停 TaskService/UploadManager accept

关键数据:

  • _workers 设备注册表:DeviceManager.entries 的别名,key → {"kind": "uvc"|"d435"|"s80m"|"data_ble"|"ble", "slots": [网格槽位], "label": 日志标签},另含 kind 专属字段(d435:worker/rgb_slot/depth_slot/near_mm/far_mm/smooth_k/temporal_alpha/heat_smoother/calib_sent/serial;s80m:proc/stdin/watchdog/reader_thread/stderr_file/config_file/original_exp/entry_depth/last_bucket/pending_imu;data_ble:sensor_column/glove)。
  • 设备面板状态:_active_device_keys(勾选集合=UI 状态源)、_lost_device_keys(录制中拔线暂存)、_last_device_keys(key→标签,插拔 diff 日志)。
  • FPS:_fps_ring(slot_id → 到达时刻 deque),1s 定时刷新。
  • 历史表列名:摄像机 / 文件 / 时长 / 大小 / 状态。
  • S80M 管道帧格式与曝光行协议见 core/s80m_manager.py,50→30 抽帧桶长 settings.STEREO_RECORD_MIN_INTERVAL_S。
  • D435 槽名约定:RGB 槽 = 前缀 + _rgb,深度槽 = 前缀本身若以 _depth 结尾否则补 _depth;depth 后缀仅用于回放过滤,是否落盘由显式注册决定。
  • meta 写入点:_build_device_meta() 产出 devices 段(key/kind/name/slots/serial/sensor_column)。

调用关系:被 main.py 构造。创建并持有:core.pipeline.CameraPipeline、core.device_detector.DeviceScanner、core.device_manager.DeviceManager、core.s80m_manager.S80MDeviceManager、core.d435_manager.D435DeviceManager、core.task_service.TaskService、core.uploader.UploadManager、core.recording_repository.RecordingRepo(静态调用)、ui 内全部其余组件。调用 core.d435_camera.D435Worker、core.stereo_depth.StereoDepthComputer/depth_to_heatmap/DepthHeatmapSmoother、core.calibration.StereoCalibration、core.task_record.load_tasks/increment_task_completed、core.helpers.session_summary/format_duration/format_size_mb/hand_kpts_parquet_path、core.device_naming/core.exposure_controller 口径;可选手部子系统 core.hand_processor.SessionHandProcessor、core.auto_labeler.AutoLabeler。

ui/camera_grid.py

作用:多画面网格容器 CameraGrid(继承 QScrollArea),把每路画面排进嵌套 QSplitter 布局;所有画面(相机 CameraWidget、回放传感器格、手套仿生手掌、占位画面)同格共存。提供两种交互:按住画面顶部信息条拖到另一画面上松手即互换位置(事件过滤器 + 鼠标抓取);画面间的分割条加宽(8px)并带悬停/按住高亮,可拖动调大小。

类/函数:

名称 签名要点 作用 返回/副作用
add_camera (slot_id: str, camera_name: str = "") 添加一个 CameraWidget 到网格 返回 CameraWidget
add_widget (slot_id: str, widget: QWidget) 添加任意控件(手套仿生手掌/传感器格/占位等);重复 slot_id 直接返回已有控件 返回 QWidget;触发重建布局
remove_camera (slot_id: str) 移除指定控件:先 _end_drag() 复位拖拽状态(防抓取/光标残留),再 deleteLater() 触发重建布局
camera_widget (slot_id: str) 按 slot_id 取控件 Optional[CameraWidget]
clear () 移除所有画面 —
widget_count () -> int 当前画面数量 int
slot_ids () -> List[str] 全部 slot_id 列表(字典顺序即布局顺序) list
eventFilter (obj, ev) 拖拽手柄事件:顶部 DRAG_STRIP_H(32px)内按下左键→grabMouse 抓取 + 闭合手掌光标;移动→悬停目标高亮(DRAG_TARGET_BORDER);松开→_swap_widgets 互换位置;双击信息条不触发缩放还原;带 _no_drag 属性的控件(曝光按钮、模式下拉等)直接放行 消费事件时返回 True
_rebuild_layout () 按画面数重建布局:0 路=空提示文案(默认 i18n「尚未检测到摄像机…」,可 empty_text 覆盖);1 路=填满;2 路=[A|B];3 路=[A|B]/[C];4 路=2×2;5+ 路按 ceil(sqrt(n)) 行列嵌套分割;初始等分 —
_install_drag_filter / _remove_drag_filter (w) 给画面控件及其全部子控件装/卸事件过滤器(子控件才真正接收鼠标事件) —
_root_widget / _in_drag_strip / _widget_at_global 辅助 事件来源控件沿父链找所属画面 / 判断全局坐标是否落在信息条内 / 命中画面 QWidget 或 None
_set_drag_hover / _swap_widgets / _end_drag 辅助 高亮悬停目标(恢复旧样式)/ 互换字典顺序并重建 / 释放抓取、还原光标与高亮 —

关键数据:

名称 值 说明
DRAG_STRIP_H 32 每画面顶部拖拽手柄信息条高度(像素)
SPLITTER_HANDLE_WIDTH 8 分割条宽度(被 ui/playback_dialog.py:29 复用)
SPLITTER_HANDLE_QSS 灰条 + 悬停/按住高亮 暗色主题下分割条可见性(被 ui/playback_dialog.py:29 复用)
DRAG_TARGET_BORDER border:2px solid #4FC3F7 拖拽悬停目标边框高亮

调用关系:被 ui/main_window.py(主网格)、ui/playback_dialog.py(回放统一网格)使用;tools/tests/exposure_control_test.py、tools/tests/grid_drag_fps_test.py 直接构造。依赖 ui/camera_widget.py 的 CameraWidget 与 config.i18n.tr(空提示文案)。

ui/camera_widget.py

作用:单路画面的两个控件。ZoomableVideoWidget 是纯视频显示画布:接收 BGR np.ndarray 帧,内部 BGR→RGB 转 QImage.Format_RGB888(Qt x86 上 Format_BGR888 会红蓝互换),支持滚轮缩放(以鼠标位置为基准)、左键拖拽平移、双击还原、缩放百分比提示。CameraWidget 把视频画布 + 顶部覆盖条(名称、☀ 曝光按钮、FPS 标签、状态灯)组合成一路「画面」,供主网格与回放共用。

类/函数:

名称 签名要点 作用 返回/副作用
ZoomableVideoWidget.reset_view () 还原缩放(1.0)与平移(0,0) 重绘
ZoomableVideoWidget.set_frame (bgr_frame: np.ndarray, flip_vertical: bool = False) 传入 BGR 帧:可选上下翻转(摄像机视频需要,传感器渲染不需要),BGR→RGB 后存为 QPixmap 触发重绘
ZoomableVideoWidget.set_status_text (text: str) 设置无画面时居中显示的文字(如「无信号」「已断开」) 清空画面
ZoomableVideoWidget.wheelEvent (event) 滚轮缩放:1.08×/级,范围 0.25×~8.0×,以鼠标位置为基准调偏移,右下角短暂显示百分比 重绘
ZoomableVideoWidget.mousePressEvent / mouseMoveEvent / mouseReleaseEvent / mouseDoubleClickEvent (event) 左键拖拽平移(闭合手掌光标);双击 reset_view —
CameraWidget(信号) remove_requested(str) / exposure_clicked(str) 请求移除此摄像机 / 点击 ☀(主窗口按槽位所属设备弹曝光对话框) 由 MainWindow._connect_slot_signals 连接
CameraWidget.update_frame (bgr_frame) 相机采集帧更新显示(flip_vertical=True 适配安装方向) —
CameraWidget.update_fps (fps: float) 更新 FPS: n 标签 —
CameraWidget.set_frame_number (n: int) 回放用:信息条右侧显示当前帧号 #n(复用 FPS 标签位) —
CameraWidget.set_camera_state (state: str) recording/error/disconnected/idle → 状态灯颜色与「已断开/等待中…」文字 —
CameraWidget.set_exposure_button_visible / set_exposure_enabled (visible: bool) / (enabled: bool) 显示/隐藏 ☀(只对设备主槽位);启用/禁用(录制中禁用,tooltip 随之切换) —
CameraWidget._refresh_texts (_lang="") 语言切换刷新文案 连接 lang_manager.language_changed
CameraWidget.resizeEvent (event) 调整覆盖条宽度与位置 —

关键数据:

  • ZOOM_MIN = 0.25、ZOOM_MAX = 8.0、ZOOM_STEP = 1.08。
  • 覆盖条:固定高 28px、初始宽 260px(随 resize 收缩到 ≤300px),位于画面左上角;刻意不设 WA_TransparentForMouseEvents(会连 ☀ 按钮一起屏蔽),画面信息条拖拽由 CameraGrid 事件过滤器统一拦截。
  • ☀ 按钮带 _no_drag 属性标记,CameraGrid.eventFilter 对按下直接放行(不触发拖拽/双击还原)。
  • 注意:reset_view 在文件中定义了两次(第 74-78 行与第 109-113 行),后者覆盖前者,行为一致。

调用关系:被 ui/camera_grid.py、ui/glove_widget.py(GloveWidget 继承 CameraWidget)、ui/playback_dialog.py(ZoomableVideoWidget)使用。依赖 config.settings(FEED_MIN_WIDTH 等尺寸与颜色常量)、config.i18n.tr/lang_manager、core.helpers.format_duration(导入存在,当前代码未直接调用)。

ui/device_panel.py

作用:左侧停靠的设备检测面板。DeviceScanner(core/device_detector)每 2s 扫描的结果由 MainWindow._on_devices_scanned 调 set_devices 重建列表;三组展示(📷 相机 / 🧤 手套 / 🎧 其他蓝牙),「其他蓝牙」默认折叠、空组隐藏;每台设备带独立 QCheckBox,勾选发射 device_toggled(DeviceInfo, bool) 由主窗口路由到主网格显示;双击条目弹命名框并持久化到 device_names.json(此后每次连接显示用户命名)。

类/函数(class DevicePanel(QWidget)):

名称 签名要点 作用 返回/副作用
device_toggled / device_renamed(信号) pyqtSignal(object, bool) / pyqtSignal(object, str) 勾选变化(DeviceInfo, checked)/ 双击命名完成(DeviceInfo, new_name) 由 MainWindow 连接
set_devices (devices: list) blockSignals 下清空重建分组树;勾选状态由随后的 set_checked_keys 恢复;组展开状态跨重建保留 空列表显示「未检测到设备」占位
set_checked_keys (keys: set) 按 key 集合静默勾选(不发信号) —
set_checked (key: str, on: bool) 程序化勾选单个(不发信号,供失败回退) —
checked_keys () -> set 当前勾选的设备 key 集合 set(开关状态=UI 状态源)
set_active_keys (keys: set) 高亮正在显示中的设备(绿色前景),其余还原;空集合全还原 —
set_active_key (key) 兼容旧调用:单 key 高亮(None 全还原) —
set_locked (on: bool) 录制中锁死全部开关(去 Qt.ItemIsEnabled,命名同步禁用) —
key_for_kind (kind: str) 列表中第一个 kind 匹配的设备 key str 或 None
device_for_key (key: str) key → DeviceInfo(存在 Qt.UserRole) DeviceInfo 或 None
key_for_serial (serial: str) 按序列号找 key(多台 RealSense 时精确高亮) str 或 None
key_for_video_index (video_index: int) 第一个 video_index 匹配的设备 key str 或 None
refresh_texts () 语言切换刷新提示文字、组标题与占位项 —
_row_text (dev) -> str 行文本 = 图标 + label(+ — serial) —
_on_item_changed (item, column) 勾选状态真正变化(用 _last_check 挡文字类变化与组标题)才发射 device_toggled —
_on_item_double_clicked (item, _column) 双击 → QInputDialog 命名 → settings.save_device_name(dev.stable_key, name) 持久化 → 更新行文本 + device_renamed;对话框打开期间列表可能已被 2s 轮询重建,必须用当前列表里的条目更新 —
gripper_recalibration_requested(信号) pyqtSignal(object) 夹爪条目右键选「重新读取出厂标定」(v1.3.4) 由 MainWindow 连接
gripper_binding_requested(信号) pyqtSignal(object) 夹爪条目右键选「读取/写入 Fays 绑定序列号」 由 MainWindow 连接
gripper_diagnostics_requested(信号) pyqtSignal(object) 夹爪条目右键选「ESP32 串口诊断」 由 MainWindow 连接
_on_context_menu (pos) Qt.CustomContextMenu 回调:仅 dev.group == "gripper" 且非组标题/占位项才弹菜单(表驱动三项,顺序=重读标定 / 配对序列号 / 串口诊断,顺序是契约的一部分,见 test_gripper_calibration_autogen.py §14);_locked(录制中)时菜单项 setEnabled(False) 且选中后直接返回;按 chosen is action 判等,只弹不选不发信号 —
_refresh_hint_text () 底部提示:有夹爪时追加「;右键夹爪可重读标定 / 配对序列号 / 串口诊断」 由 refresh_texts 调用

关键数据:

  • _ICON = {"uvc": "📹", "d435": "🔭", "s80m": "👁", "data_ble": "🧤", "usb_glove": "🧤", "ble": "🎧", "gripper": "🤖"};_GROUP_ORDER = ["camera", "glove", "gripper", "other_ble"];_GROUP_TITLE 对应中文组标题。
  • _items:device key → QTreeWidgetItem;_last_check:key → 上次勾选状态(防 itemChanged 误触发);_group_expanded:组展开状态(初始 camera/glove 展开、other_ble 折叠);_has_gripper:列表里是否有夹爪(决定底部提示是否提右键入口,两个分支都要更新,空设备分支需重置)。
  • ⚠️ 面板内所有 QLabel 提示都必须 setWordWrap(True):不换行的 QLabel,其 minimumSizeHint().width() 就是整行文字宽,会成为面板与停靠栏的硬地板。底部提示那句中文(「双击设备可重命名;右键夹爪可重读标定 / 配对序列号 / 串口诊断」)曾在夹爪在场时把栏宽地板顶到 289px,导致设备栏只能拖宽、拖不窄(2026-10-13 修);面板另设 setMinimumWidth(settings.DEVICE_DOCK_MIN_WIDTH) 作为可拖到的最窄。回归哨兵见 tools/tests/device_dock_collapse_test.py。
  • 命名持久化:config.settings.save_device_name(device_names.json)。

调用关系:由 ui/main_window.py 构造并连接 device_toggled/device_renamed/gripper_recalibration_requested/gripper_binding_requested/gripper_diagnostics_requested(信号名按字符串 connect,写错不会报错、只是永远不触发 —— tools/tests/test_gripper_esp_binding.py §9 静态比对两处名字);tools/tests/device_panel_gui_smoke_test.py 直接构造。依赖 config.settings.save_device_name、config.i18n.tr。夹爪右键三条链路的完整链路(关夹爪 → 后台线程 → 完成后按原样开回)见 docs/core.md「夹爪」节与 ui/main_window.py 的 _on_gripper_recalibration / _on_gripper_binding / _on_gripper_diagnostics;三条共用 _gripper_offline_guard(序列号+录制检查)、_gripper_offline_take_over(让出串口)、_reopen_after_offline(善后)三件骨架。

ui/exposure_dialog.py

作用:每设备曝光设置模态对话框:自动曝光开关 + 曝光值滑块(内部统一 0..1000 刻度映射),拖动即时下发 apply_requested(auto, value),勾选自动曝光立即生效。范围/值语义由调用方(MainWindow)按设备类型注入:UVC 是 V4L2 原始曝光值、D435/D405 是 µs(流启动后读 rs.option.exposure 量程)、S80M 是 SDK 曝光值 1.0~885.0(与 yaml stereo_init_exposure 同单位)。对话框只发参数不改持久化(持久化由主窗口统一处理)。

类/函数(class ExposureDialog(QDialog)):

名称 签名要点 作用 返回/副作用
apply_requested(信号) pyqtSignal(bool, float) 参数变化:apply(auto, value)(auto=True 时 value 忽略) 由 MainWindow._open_exposure_dialog 连接到 _apply_exposure
__init__ (parent, title, vmin, vmax, value, auto, decimals=0, original=None) 构造:自动曝光勾选框 + 0..1000 滑块 + 数值标签 + 「恢复默认」按钮(仅传入 original 时显示)+ 关闭 模态
_to_ticks / _from_ticks (value) -> int / (ticks) -> float 曝光值 ↔ 0..1000 刻度线性映射 —
_fmt (value) -> str 按 decimals 格式化数值标签 —
_on_auto_toggled (checked) 自动曝光开关:禁用/启用滑块并立即 apply_requested.emit(checked, 当前值) —
_on_moved / _on_released (ticks) / () 拖动中/松手时更新值并下发(auto 时忽略) —
_on_reset () 「恢复默认」:控件静默(blockSignals)回到「最一开始」曝光基线 (auto, value) 并下发一次 —

关键数据:_original = 设备首次开启、应用任何设置之前读回的 (auto, value) 基线(来源为 settings.device_original 或 worker 读回值,经 core.device_naming.normalize_original 归一化)。

调用关系:由 ui/main_window.py(_open_exposure_dialog)按设备类型构造并 exec_();tools/tests/exposure_control_test.py 直接构造。依赖 config.i18n.tr。

ui/glove_widget.py

作用:BLE 手套仿生手掌画面控件(继承 CameraWidget,复用其覆盖条),直接嵌入主网格,替代旧的底部传感器 dock。面板开关打开手套后由主窗口创建;内部持有 core.ble_engine.SensorBLEEngine,30ms 渲染定时器处理一帧数据并用 core.render_engine.render_hand 画成 BGR 帧显示;录制中数据经 pipeline.write_sensor 写入 parquet 对应传感器列。

类/函数(class GloveWidget(CameraWidget)):

名称 签名要点 作用 返回/副作用
__init__ (slot_id, address, sensor_column, label="", parent=None) 固定 _RENDER_W=1280×_RENDER_H=720 画布;左/右手套配置由 core.sensor_hand_config.load_sensor_hand_config(sensor_column) 加载(左/右不同配置文件);启动 30ms 渲染定时器 —
start (address: str = "") 连接 BLE 设备(懒创建引擎并连接 connected/disconnected/fps_updated/calibration_progress/error_occurred 信号) 画面显示「连接中…」
stop () 断开连接、停止渲染 画面显示「已断开」
set_pipeline (pipeline) 主窗口注入/清除当前录制管线引用 —
_on_connected / _on_disconnected (addr) / () 连接状态 → 画面文字 + pipeline.record_event(sensor_column, "connected"/"disconnected") —
_on_error (msg) 引擎错误显示到画面状态栏(失败原因不再只有控制台可见) —
_on_fps (fps) 硬件 FPS 显示到信息条(HW: n) —
_on_calib_progress (progress) 校准进度显示(校准中… n% / 校准完成) —
_render_tick () 定时器触发:engine.process_frame() 拿一帧;录制中 pipeline.write_sensor(processed, capture_ts, sensor_name=sensor_column);render_hand(...) 渲染并传给画面(逻辑平移自旧 SensorPanel) —
_display_frame (frame) 渲染帧叠加传感器名 + 硬件 FPS + 数据帧龄(Age: n ms,按 <100/<300ms 绿/黄/红) —

关键数据:

  • 渲染配置常量 CONFIG_FILE/CONFIG_FILE_LEFT 定义在 core.render_engine(指向 config/sensors/hand_ble_config.json 与 hand_ble_config_left.json);加载与合并口径在 core.sensor_hand_config.load_sensor_hand_config。
  • _current_vmax 初始 5000.0,跨帧回传保持色标稳定。

调用关系:由 ui/main_window.py(try/except 可选导入)在 _open_glove 中创建。依赖 core.ble_engine.SensorBLEEngine、core.render_engine、core.sensor_hand_config、ui.camera_widget.CameraWidget;tools/tests/glove_widget_test.py import 本模块做自检。

ui/login_dialog.py

作用:登录对话框——启动时(或任务页点「切换账号」)弹出,确定会话身份。账号登录在对话框内同步校验(TaskService.verify_credentials,独立 requests.Session,≤8s),失败就地提示可重试,成功后携带 auth cookie 返回;游客登录直接进入(仅见公共任务);关闭窗口(Esc/X)等价游客——后端不可达时不把用户锁死在启动页。

类/函数(class LoginDialog(QDialog)):

名称 签名要点 作用 返回/副作用
choice () -> str 结果:"login"(点登录且校验成功)或 "guest"(游客/关闭窗口) str
server_url () -> str 地址输入框内容;空则回退 settings.SERVER_URL str
username / password () -> str 账号/密码输入框内容 str
remember_checked () -> bool 「记住账号」勾选状态 bool
cookies () 登录成功后的 auth cookie(失败/游客为 None) cookie 或 None
_on_login_clicked () 空用户名就地提示;同步校验(按钮置「登录中…」防误以为卡死),成功记 cookie 并 accept,失败 _show_error —
_on_guest_clicked () choice="guest" 并 accept —

关键数据:表单回填——服务器地址(与出厂默认相同则不回填、以 placeholder 显示)、用户名(settings.load_remembered_username);错误提示仅登录失败时显示。

调用关系:由 ui/main_window.py(_show_login_flow)构造。依赖 config.settings、config.i18n.tr、core.task_service.TaskService.verify_credentials、ui.guide_dialog.VisibleCheckBox。

ui/guide_dialog.py

作用:使用说明窗口——首次启动自动弹出(guide/shown_once 未置位,或环境变量 DAQ_SHOW_GUIDE=1 强制),也可从菜单 帮助 → 使用说明 随时重开;「下次启动不再自动显示」勾选持久化到 QSettings(guide/dont_show,取消勾选即恢复自动弹出;每次关闭按当前勾选落盘并 sync() 立即写盘)。正文为整段 HTML 使用步骤(登录 → 设备 → 录制 → 回放 → 上传),按当前界面语言选择 GUIDE_HTML_ZH/GUIDE_HTML_EN 模板,与根目录 使用说明.md 内容一致。

类/函数:

名称 签名要点 作用 返回/副作用
guide_html () -> str 按 lang_manager.current 返回当前语言的 HTML 正文 str
VisibleCheckBox (QCheckBox) 暗色主题下勾选指示不明显:指示框加显式描边/填充,勾选时自绘白色 ✓(login_dialog.py 复用) —
GuideDialog (parent=None) 760×640 对话框:标题(APP_NAME + 版本)、QTextBrowser 渲染 HTML、底部 VisibleCheckBox + 「关闭」 —
dont_show_checked () -> bool 供调用方持久化「下次不再显示」 bool

调用关系:由 ui/main_window.py(_show_guide,惰性 import)构造;VisibleCheckBox 被 ui/login_dialog.py 复用。依赖 config.settings、config.i18n。

ui/playback_dialog.py

作用:本地录制回放对话框。左侧会话列表(多选、按上传状态筛选、批量移入回收站),右侧复用 CameraGrid 的统一网格:摄像机格与传感器格同格共存,可拖拽调位、分割条调大小;底部播放控制(播放/暂停、逐帧、进度条、倍速 0.25~4×、手部关键点叠加开关、追踪模式、提取关键点)。时间戳对齐经 Parquet 统一时间线(frame_idx 与 timestamp_us);会话加载(parquet 合并 + 手部关键点)全部放后台线程,视频打开与播放留在主线程(OpenCV FFmpeg 后端不承诺跨线程安全)。

模块级:

名称 签名要点 作用 返回/副作用
_get_effective_fps (info: dict) -> float core.session_catalog.get_effective_fps 的兼容别名(离线测试仍按旧名 import) float

类/函数:

名称 签名要点 作用 返回/副作用
SessionLoader (parent=None) QObject core.session_loader.SessionLoader re-export:后台加载器,parquet 读取全在 worker 线程,loaded(str, object, int) 携带 {"timeline": SensorTimeline, "hand_kpts": dict};load(session_dir, gen) 已有加载在跑时排队(新请求覆盖旧请求,过期结果被 gen 丢弃);cancel() 取消 信号 loaded/failed(int, str)
PlaybackSensorWidget (sensor_name, title="", parent=None) QFrame 单路传感器回放格:标题 + 模式下拉(🔥 热力图/📝 轨迹/📊 网格/🦾 仿生手掌/🕸 形变网格,默认仿生手掌)+ ZoomableVideoWidget + TS 标签;头部行 ≈32px 与网格拖拽手柄区对齐,mode_combo/ts_label 标 _no_drag 放行点击 —
PlaybackDialog._toggle_fullscreen () F11 切换全屏(QShortcut WindowShortcut 上下文,任意子控件聚焦时可用) —
PlaybackDialog._rebuild_grid () 按当前会话重建统一网格:先摄像机格(双目四路 stereo_left/right + _aux 保持 2×2 特殊排列),后传感器格(slot_id=sensor:{name},与主窗口手套格约定一致);摄像机格隐藏状态灯 —
_browse_dir / _refresh_list () 选择录制目录 / 重建会话列表(core.session_catalog.list_sessions;每项含上传状态图标 ✅/❌/⬜、路数、FPS、时长、任务 tag) —
_on_session_clicked (item) 点击会话 → _load_session(path) —
_filter_by_upload (uploaded: bool) 「⬆ 选中已上传」/「⬇ 选中未上传」:按 UploadManager.get_upload_status 勾选 —
_delete_selected () 勾选会话移入回收站:先停播并释放全部 cv2.VideoCapture 句柄(否则 Windows 阻止删除)+ 强制 GC;core.helpers.send_to_recycle_bin 逐个移入 可逆
_load_session (session_dir) 加载会话:主线程同步读元数据(core.session_catalog.load_session_meta:格式探测 → EgoData metadata.json 或 meta/info.json;传感器列表读 info["sensors"],无则从 info["features"] 的 observation.* 键推断,最旧格式回退 ["state"]);parquet 合并 + 关键点交 SessionLoader 后台加载 —
_on_session_loaded (gen, payload) 主线程槽:过期 gen 丢弃;过滤幽灵传感器列(时间线中确有 16×16=256 宽压力矩阵的才留,防 observation.imu 等非手套特征被误判);打开每路 MP4(深度槽取 depth/<slot>/<slot>.mp4);用户命名叠加(device_names + devices 段 slots/sensor_column → name);算主时钟(info fps → 视频实际 fps → 全局兜底)、每路总帧数;重建网格、_seek(0) —
_on_session_load_failed (gen, error) 后台加载失败 → 信息标签 —
_toggle_play / _stop () 播放/暂停切换;_stop 也用于切会话前收尾 —
_tick () 定时器推进:按真实流逝时间补帧追赶(主线程被 seek/渲染拖慢时保持原速);到末尾自动停 —
_prev_frame / _next_frame () 逐帧进退 —
_seek (idx: int) 主时钟帧号 → 每路帧号(t_s × fps_i 四舍五入,低帧率路按比例抽帧);小步前进(≤5 帧,定时器抖动/追赶所致)顺序 read() 到底(每帧 1-2ms),随机大跳(拖进度条)才 set+read seek(43-89ms/路)——小步也 seek 会因 seek 耗时把追赶 steps 推大形成死亡螺旋(HEVC B 帧 seek 更贵);随后 _update_sensor + 时间标签 —
_on_slider_pressed / _on_slider_moved / _on_slider_released (value) 拖动中不 seek(每秒数十个 move 事件 × 单次 90-180ms 会卡死),松手单次 seek;点击滑槽未拖动也跳到点击处;原在播则恢复播放 —
_set_video_frame (slot_id, frame, frame_num) 一帧显示到摄像机格 + 帧号 #n;可选手部关键点叠加(draw_kpts_overlay);MP4 帧录制时已 np.flip(axis=0) 过,回放不再翻转 —
_on_sensor_mode_changed (sensor_idx, mode_idx) 切换传感器可视化模式(heatmap/trace/grid/hand/deform),trace 时 clear_trace_canvas(),vmax 重置 5000.0 —
_update_sensor (frame_idx, t_s=0.0) 渲染全部传感器:多帧率混合会话按主时钟时间二分(nearest_for_column_time),帧率一致会话走帧号二分(nearest_for_column,时间戳含暂停负跳变时更稳);16×16 矩阵按模式调 render_heatmap/trace/grid/hand/deform_mesh;TS 标签显示传感器时间戳与 Δ(ms/帧) —
_cycle_speed () 倍速循环 0.25/0.5/1/2/4×,播放中即时改定时器间隔 —
_toggle_hand_overlay (checked) 手部关键点叠加开关 → 重刷当前帧 —
_process_current_kpts / _on_hand_proc_finished () / (session_path, error) 对当前会话后台提取手部关键点(主线程延迟创建 SessionHandProcessor 保证信号正常);完成后 kpts-only 重载(load_timeline=False) —
closeEvent (event) 置 _closing(迟到 loader 回调丢弃)、取消手部处理、停播、释放全部 VideoCapture —

关键数据:

  • 深度槽判定(_on_session_loaded):cameras[slot] 的 type == "depth" 或槽名以 _depth 结尾(槽名约定与主窗口 _open_d435 一致);深度槽仅当其热力图 MP4(depth/<slot>/<slot>.mp4)存在时加入回放列表。
  • 双目四路特殊排序:_STEREO_IDS = {"stereo_left", "stereo_right", "stereo_left_aux", "stereo_right_aux"} 四路齐备时按固定顺序排列。
  • 传感器状态列表:_sensor_modes/_sensor_vmax_list/_sensor_mesh_states/_sensor_widgets/_sensor_ts_labels/_sensor_hand_configs/_sensor_cells 按 info["sensors"] 动态创建。
  • meta 字段读取点:cameras[].fps、cameras[].type、sensors、features、device_names、devices[].slots/sensor_column/name/kind、task_name、fps(info["fps"] 兜底);EgoData 会话的 devices 全量段在 metadata.json、device_names 在 meta/info.json,两处合并读。
  • 会话列表项把上传状态存在 Qt.UserRole + 1。

调用关系:由 ui/main_window.py(_open_playback)构造;tools/tests/test_playback_multifps.py、tools/tests/d435_playback_test.py 直接构造。依赖 core.session_timeline.load_timeline/SensorTimeline、core.session_catalog(fps/扫描/元数据)、core.session_loader.SessionLoader、core.render_engine 五种渲染器与 CONFIG_FILE/CONFIG_FILE_LEFT、core.sensor_hand_config、core.helpers 路径与扫描函数、core.uploader.UploadManager.get_upload_status;可选手部叠加来自 core.hand_tracking。

ui/pose_view_qt.py

作用:夹爪位姿视图控件(QWidget 子类),挂在采集页网格的 pose 槽里,把夹爪的实时 SLAM 位姿画成一幅三维示意:轨迹折线(跟随夹爪累计的点)、世界坐标三箭头(固定在原点)、跟随夹爪的体轴三箭头(画的是发布姿态的三列),以及夹爪模型(两块夹板按 set_grip_state 的开合度张合)。视角可用鼠标拖拽旋转、滚轮缩放、双击复位,视野在轨迹走近边缘时自动扩张。

模块级常量:

常量 值 说明
DISPLAY_TRAJECTORY_MAX_POINTS 6000 显示上限(原 400 太小,见 docs/postmortem_trajectory_and_rgb.md);超出按 2 的幂翻倍 stride 抽稀
TRAJECTORY_UPDATE_MIN_INTERVAL 1/25 秒 轨迹重算节流(位姿本身不受影响)
SPAN_EXPAND_TRIGGER / SPAN_EXPAND_TARGET / SPAN_EXPAND_ANIM_SECONDS 0.85 / 1.35 / 0.3 视野自动扩张的触发比、目标比、动画时长
AZIMUTH_DEFAULT / ELEVATION_DEFAULT -150° / 30° 初始视角
BG / TITLE / TRAJECTORY / PLATE_GRIP / PLATE_OPEN / CONNECTOR / CAM_ORIGIN 暗色主题配色 背景、标题、轨迹、合拢/张开夹板、连接件、相机原点
WORLD_AXES / CAM_AXES 颜色 + 轴名 世界三箭头与跟随夹爪三箭头的配色(WORLD_AXES 的标签恰好是 ["X","Y","Z"],被测试断言)
_BODY_RELABEL 帧 id → 换基矩阵 见下:新帧 = M,旧帧 = 单位阵;把模型顶点从旧体轴搬到新体轴
TITLE_TOP 2.0 画布上两行字(标题、坐标帧)的顶;行高取字体实测高度不写死像素(写死会裁掉中文笔画,而帧那行正是「一眼确认改没改」的入口)

★ 坐标帧契约(v1.3.13):发布侧 core/gripper/slam/protocol.py 给位姿做了两次左乘 + 一次右乘(右乘 = 体轴改名),本控件是那个契约的另一半:

  • _draw_frame_axes 画的是 rotation 的三列(= 夹爪体轴),不是三行。同一张 M 也在发布侧给体轴改了名,所以重新锚定那一瞬发布姿态 = M·Mᵀ = 单位矩阵,跟随夹爪的三箭头与世界三箭头重合,标签同写 下/左/前。
  • _draw_gripper 用 _BODY_RELABEL(与发布侧同一张 M)把模型顶点从旧体轴坐标搬到新体轴坐标,于是 position + R·(M·v) == position + R_旧·v,夹爪的物理朝向与画面逐像素与改前相同(该恒等式对任意 R、v 代数上精确成立 —— M·R·Mᵀ·M·v = M·R·v,因为 MᵀM = I;渲染侧由 test_pose_view_qt.py::check_gripper_model_frame_invariant 逐像素钉住)。★式中的 R_旧 是改前那次发布的姿态(已含发布侧左乘的 M),不是夹爪自己的相对姿态 R_rel —— 拿 R_rel 代进去会差一个 M,算出"夹爪歪了 0.3–0.4 m"的假警报;对照:两处都改时残差只在浮点噪声量级,只改一半就是 0.3–0.4 m 的实偏差。
  • 后果:只改发布侧不改这里(或反过来)夹爪会被画歪 120°,而两种改法各自都"看起来自洽"——别只动一处。测试见 tools/tests/test_pose_view_qt.py。
  • 三箭头的轴名来自 config.settings.GRIPPER_SLAM_FRAME_AXES_CN,不自己抄(屏幕上的字与落进 info.json 的 frame_axes 必须同源)。

关键方法:

方法 签名 说明
update_pose (position, rotation, trajectory=()) 主入口:更新位姿与轨迹,节流重算
set_grip_state (percentage, gripped) 夹板开合度与抓取态(决定配色 PLATE_GRIP/PLATE_OPEN)
set_localization_backend / reset (backend) / () 标题栏后端标注 / 清空轨迹与视角(换夹爪、重新录制时调)
paintEvent / _draw_static — 全部绘制走 QPainter;静态层(世界轴、模型)进缓存,只有轨迹逐帧重画

调用关系:由 ui/main_window.py:1616 惰性 import 后构造(pose_view = PoseViewQt()),放进该夹爪的 entry["pose_view"];位姿经 _on_gripper_pose → update_pose,抓取态经 set_grip_state,reset() 在换设备/重录时调。tools/tests/test_pose_view_qt.py 直接构造并离屏渲染(QT_QPA_PLATFORM=offscreen)。依赖 PyQt5、config.settings(GRIPPER_SLAM_FRAME_AXES_CN 等)、numpy。lite 包里没有本控件(lite 不含位姿视图)。

ui/task_page.py

作用:启动时显示的任务选择页(页面栈 index 0)。顶栏只读显示服务器地址(set_server_display)与当前身份,附「切换账号」「+ 新建任务」「🔄 刷新」按钮;中部任务表(5 列,含「来源」),底部「→ 进入采集」「✏ 改名」「🗑 删除」。任务数据来自 data/tasks.json(core.task_record.load_tasks),后端任务经 merge_backend_tasks 合并,并 set_identity 按身份过滤(游客仅见公共任务);用户选中任务点「进入采集」发射 task_selected(dict),主窗口切换到数据采集页。

采集端自建任务:顶栏「+ 新建任务」打开 TaskCreateDialog,落库为 source="local"(id 前缀 local-、created_by 记当前身份)。本机任务仅创建者可见(过滤在 core.task_record.filter_by_identity),进度只在本机累计、不上报服务器;「来源」列显示「本机」,服务端任务显示「服务端」。本机任务可「✏ 改名」(已有录制目录时拒绝)与删除(真删数据 + remove_local_task 整条移除,无 hidden 墓碑);服务端任务删除仍走 mark_hidden 墓碑。

模块级:

名称 签名要点 作用 返回/副作用
_compute_status (task: dict) -> str 按 total_required/completed_count 算状态:pending/in_progress/completed str
_tid (task: dict) -> str 取任务 id(id 或 task_id) str
_format_date (iso_str: str) -> str 日期截取前 10 字符 str

类/函数(class TaskSelectionPage(QWidget)):

名称 签名要点 作用 返回/副作用
task_selected / refresh_requested / switch_account_requested(信号) pyqtSignal(dict) / pyqtSignal() / pyqtSignal() 点击「进入采集」/ 点击「🔄 刷新」/ 点击「切换账号」 由 MainWindow._connect_task_service 连接
update_tasks (tasks: list[dict]) 后端任务列表到达:merge_backend_tasks 合并(空则回退本地 load_tasks)→ 重算状态 → 重建表 —
update_task_progress (task_id: str, completed_count: int) 录制完成回写进度:refresh_progress() 后按 _tid 找行刷新 —
set_connection_status (connected: bool) 更新连接圆点与「后端已连接/未连接」文案 —
on_login_result (ok: bool, msg: str) 登录结果反馈(登陆成功 / 登陆失败: msg[:30]) —
set_identity (identity: str | None) 更新当前身份("guest" 或用户名)并即时按新身份重滤任务表(含本机任务可见性) —
set_server_display (url) 更新顶栏服务器地址显示与 tooltip(登录对话框改地址后由主窗口调用) —
current_task () -> dict | None 当前选中行任务 dict 或 None
_on_cell_changed (row, _col, _pr, _pc) 选行联动:completed 任务禁用「进入采集」;本机任务才启用「✏ 改名」,任一选中行启用「🗑 删除」 —
_on_double_clicked / _on_enter_clicked () 双击行=快捷进入;进入前 completed 任务弹警告 发射 task_selected
_on_new_task_clicked () 打开 TaskCreateDialog(传入当前身份)→ create_local_task → _refilter → 选中新行;校验失败弹「创建失败」 写 tasks.json
_on_rename_clicked () 仅本机任务:打开 TaskRenameDialog → rename_local_task → _refilter → 保持选中;失败弹「改名失败」 写 tasks.json
_select_task_by_id (task_id) -> int 按 id 选中行(新建/改名后保持选中) 行号或 -1
_task_error_message (code) -> str TaskValidationError.code → 本地化弹窗文案(has_data/name_too_long 补默认参数) str
_do_delete_task () 删除任务(按钮与右键菜单共用):二次确认后 delete_local_session 真删该任务录制目录 + RecordingRepo.delete_local_dir 删录制行;本机任务调 remove_local_task 整条移除,服务端任务调 mark_hidden 留墓碑 → 重载列表;目录没删净则弹错并不删除任务。删除目标先做兜底校验(必须是 RECORDING_DIR 的直接子目录)——_sanitize_tag 把纯空白任务名清洗成空串后 task_dir_of 会退化成 RECORDING_DIR 本身,不拦就会把所有任务的录制整根 rmtree —
_refresh_after_delete () 删除后重载列表并清空选中态(三个操作按钮禁用) —
_on_context_menu (pos) 右键菜单:服务端任务只有「🗑 删除任务」,本机任务多一项「✏ 改名任务…」;先 setCurrentCell 到右键行,避免操作落到之前选中的别的任务 —
_on_refresh_clicked () 发射 refresh_requested,按钮 1.5s 内显示「刷新中…」禁用 —
_on_language_changed (lang) 5 列表头/按钮文字(新建/改名/删除等)刷新并重建表 —

关键数据:

  • 表格 5 列:任务名称 / 来源 / 状态 / 进度 / 发布时间(_COL_NAME=0、_COL_SOURCE=1、_COL_STATUS=2、_COL_PROGRESS=3、_COL_DATE=4;列宽 90/150/110/130)。来源列「本机」绿色、「服务端」灰色;状态列用彩色方块+文字的 badge(_STATUS_CONFIG:pending 灰 #757575、in_progress 蓝 #42A5F5、completed 绿 #66BB6A);进度列 completed/total 右对齐;名称 tooltip 显示描述。
  • 身份与地址:服务器地址/记住账号由 LoginDialog 校验后经 MainWindow._show_login_flow 写入(settings.save_server_url / save_remembered_username);登录态 cookie 由 TaskService.adopt_login 接管;身份过滤 scope 经 _identity_scope() 传给 core.task_record。

调用关系:由 ui/main_window.py 构造;任务服务信号在 _connect_task_service 中双向接线。依赖 core.task_record(load_tasks/merge_backend_tasks/refresh_progress/mark_hidden/is_local_task/create_local_task/rename_local_task/remove_local_task/TaskValidationError)、ui.task_create_dialog、config.settings、config.i18n。

ui/task_create_dialog.py

作用:本机任务对话框(v1.3.16)。TaskCreateDialog 采集「任务名称 + 目标段数 + 描述」,TaskRenameDialog 只改名称;两者都做实时校验并在对话框内拦住非法输入。落盘由调用方(ui/task_page.py)经 core.task_record 完成,本模块不写文件。名称规则与 core.task_record.validate_task_name 一致:只允许字母/数字/汉字/下划线/连字符(空格、点、斜杠等一律拒绝),去首尾空白后 ≤64 字符,拒绝 Windows 保留名与重名;因此任务名 = 录制目录名 = 上传会话/项目名。新建时显示本机目录预览与「仅当前身份可见」提示;若同名目录已存在(无任务记录的孤儿目录)给出追加录制警告(允许创建)。改名时若本机已有录制目录则禁用确认按钮并说明原因(改名不迁移数据)。

模块级:

名称 签名要点 作用 返回/副作用
_ERROR_TEXT dict 错误码 → 中文源文案(缺失键回退「操作失败:code」) —
task_error_text (code, *args) -> str 错误码经 tr() 本地化(英文文案在 config/i18n.py) str
_identity_display (identity) -> str "guest" 显示「游客」,其余原样 str
_label / _cancel_button / _ok_button 控件工厂 与 LoginDialog 一致的深色样式 控件

类/函数:

名称 签名要点 作用 返回/副作用
TaskCreateDialog (parent=None, identity="guest") 新建任务对话框;exec_() 后 name()/total_required()/description() 取值 —
_validate () -> str 名称/段数/描述校验,返回错误码(空串=通过) str
_refresh_state () 实时刷新目录预览、错误/警告行、创建按钮可用性 —
accept () 再次校验后 QDialog.accept()(非法输入不关闭) —
TaskRenameDialog (parent=None, task=None) 改名对话框;new_name() 取值;本机已有录制目录时 _has_data=True 禁止确认 —
_refresh_state / accept () 校验新名称与目录占用,重复/非法/已有数据时保持打开 —

调用关系:仅被 ui/task_page.py 构造;依赖 core.task_record(校验/常量)、core.helpers(task_dir_of/list_task_episodes)、config.settings、config.i18n。

ui/upload_dialog.py

作用:录制数据一键上传对话框:顶部服务器地址 + 「测试连接」(core.api_client.APIClient.health_check),中部会话列表(多选、全选、按上传状态筛选),底部进度条与状态栏。「⬆ 一键上传」把勾选会话入队 UploadManager(复用已认证的 requests.Session);每项成功后遵循「上传后自动删除」开关:开启时后台线程删本地目录并把 recording 记录标记为 uploaded_deleted,否则标 uploaded(本地保留);成功/失败结果均经 _parent_log 写进主窗口日志面板(与自动上传同一口径)。

模块级:

名称 签名要点 作用 返回/副作用
(无) — 会话扫描用 core.session_catalog.list_recordings(轻量版,不读元数据) 按名称倒序

类/函数(class UploadDialog(QDialog)):

名称 签名要点 作用 返回/副作用
_session_deleted(信号) pyqtSignal(str, str) 后台删除线程 → 主线程:(session_path, error) 连接 _on_session_deleted
__init__ (parent=None, data_dir="", session=None) session 为复用已认证的 requests.Session;QTimer.singleShot(50, _init_manager) 延迟建 UploadManager(读 _url_edit 当前值) —
_refresh_list () 重建会话列表:每项图标按 UploadManager.get_upload_status(✅ completed / ❌ failed / ⬜ pending),默认勾选未完成项;「全选」用 blockSignals 复位防 _toggle_all 连锁 —
_on_item_clicked (item) 点击整行切换勾选(不必精确点复选框) —
_toggle_all (checked) 全选/全不选 —
_filter_uploaded (uploaded) 「⬆ 选中已上传」/「⬇ 选中未上传」 —
_test_connection () APIClient(url).health_check() 弹结果框 —
_start_upload () 勾选会话(过滤不存在的目录)_manager.add_tasks(valid) 入队并 start();_task_path_map 记 task_id → session_path;禁用上传按钮 —
_on_status (task_id, msg) 串行处理时状态栏显示「第 x/y 条 [会话名] 当前动作」 —
_on_progress (task_id, ratio) 总进度 =(已完成数 + 当前比例)/ 总数 进度条
_on_task_done (task_id) 单条完成:刷新列表;UPLOAD_DELETE_AFTER 开启时 _delete_after_upload(与主窗口自动上传同规则),否则 RecordingRepo.mark_uploaded + 主窗口日志「☁ 上传完成(本地保留)」 —
_delete_after_upload (session_path, episode_index) 后台线程 delete_local_session(真删除 + 删除后校验),结果经 _session_deleted 回主线程 —
_on_session_deleted (session_path, err) 删除收尾:失败仅提示(含校验残留);成功 RecordingRepo.delete_local_episode 真删录制行 + 刷新列表 + 主窗口日志「☁ 上传完成,本地文件已删除」 —
_on_task_failed / _on_all_done (task_id, error) / () 失败:状态栏「❌ 上传失败」+ 主窗口日志 + 刷新列表 / 全部完成:恢复按钮、进度条 100、状态「全部完成」 —
closeEvent (event) 停止 _manager —

关键数据:

  • 上传状态来源 UploadManager.get_upload_status(path):completed/failed/pending。
  • _task_path_map:task_id → session_path(删除与状态显示用)。
  • 「上传后自动删除」开关 settings.UPLOAD_DELETE_AFTER 对自动与手动上传统一生效。

调用关系:由 ui/main_window.py(_open_upload)构造。依赖 core.uploader.UploadManager、core.api_client.APIClient(测试连接)、core.recording_repository.RecordingRepo、core.session_catalog.list_recordings、config.settings、config.i18n.tr。

ui/init.py

作用:ui 包的空标记文件(0 行),仅使目录成为可导入包。无类、无函数、无数据。