← 返回报告索引
2026-09-03 · 实机运行架构

实机跑的时候,深度相机和 SmolVLA 是怎么配合的

一句话答案:今天它们完全没有配合 —— 深度只是被录到磁盘上,推理循环一眼都不看它。 这一页先把现在真实的推理循环画清楚(每个数字都有出处),再讲要让深度真正参与, 有哪三种接法、各自要动什么、代价多大。

先纠正一个容易有的印象:录制时深度相机确实在转、文件也确实在写,但那是旁路(sidecar)—— 它写到数据集旁边的 depth/ 目录,既不进 LeRobot 数据集,也不进模型的观测。 训练时模型没见过深度,推理时也不会读深度。两条线从头到尾没有交汇点。

一、现在真实的推理循环

每个控制周期做这几件事(run_policy_trials.py):

┌─ 观测 build_obs() ──────────────────────────────────────┐
│  observation.state          17 维 float32               │
│  observation.images.head            RGB 424×240         │
│  observation.images.left_arm_wrist  RGB 640×480         │
│  observation.images.right_arm_wrist RGB 640×480         │
│  ★ 没有深度。一个字节都没有。 ★                          │
└──────────────────────┬──────────────────────────────────┘
                       ↓  + 语言指令(整段试验固定不变)
              ┌────────────────────┐
              │  SmolVLA           │  一次推理吐 50 步动作
              │  predict_action()  │  (n_action_steps=50)
              └────────┬───────────┘
                       ↓  动作块进队列,之后 49 帧从队列取
              ┌────────────────────────────────────────┐
              │  PolicySafetyFilter  每一帧都过 7 步:  │
              │  1 NaN/离谱值 → 拒帧                    │
              │  2 底盘速度清零   3 头部锁定            │
              │  4 关节限位钳位   5 每帧步长上限        │
              │  6 FK 笛卡尔包络 → 拒帧  7 看门狗       │
              └────────┬───────────────────────────────┘
                       ↓  只有活下来的帧才发出去
                  robot.send_action()

那 17 维状态里有什么

左臂 6 + 右臂 6 + 头 2 + 底盘速度 3 = 17(policy_safety.DIMS)。 全是本体自身的关节和速度,没有一维是关于外部世界的 —— 没有杯子在哪,没有距离。 模型对「杯子在哪」的全部认识,只能来自那三路 RGB 图。

几个实测数字

项实测出处
推理耗时GPU 146 ms/帧 ≈ 6.9 Hz;CPU 2397 ms ≈ 0.4 Hz(慢 16.5 倍)_pick_device()
训练节拍30 Hz数据集 meta/info.json
动作块一次推理规划 50 步 = 训练节拍下的 1.67 秒n_action_steps=50
这个落差的后果块是按 30 Hz 规划的,实际只跑到 ~8 Hz,直接播会被拉长约 4 倍;--chunk-rate 就是为此加的(一个周期消费多步、只发最后一步,让计划按真实时间播)_chunk_rate_note()
语言指令是模型输入的一部分。 SmolVLA 是语言条件模型:换一句话就是换一个条件向量,等于换了个任务。 所以试跑时发的那句必须和训练数据集里存的逐字相同(现在脚本会自动从 checkpoint 的 train_config.json → 数据集 meta/tasks.parquet 取,这是唯一不会错的来源)。

二、深度现在在哪

录制时:DepthRecorder 起一个独立线程,按 30 fps 抓 16-bit 毫米图,写成 depth/<集>/frame_%06d.png + timestamps.jsonl(墙钟时间戳,供事后对齐)。 录完就躺在磁盘上,没有任何训练或推理代码读它。

详细的调用链、以及它自带的三个缺陷(孤儿线程写满磁盘、fd 泄漏、USB 假死导致 0 帧), 见 深度相机那一页。那里也记着一件事: lerobot 从 2026-06-27 起就原生支持深度(存 16-bit TIFF、带 is_depth_map 标志), 这套自研旁路本可以不写。

三、要让它们配合,三种接法

 A · 深度在策略外面B · 深度当策略的额外输入C · 换成点云策略
深度的角色算出杯子的 3D 坐标,用来校验和兜底,不进模型伪彩成 3 通道当第四路相机,或把 3D 位姿并进 state深度变点云,直接喂点云策略(DP3/iDP3)
要重训吗不要要要,而且换策略家族
要重采数据吗不要要(现有数据 102 集里只有 14 集有深度)要
现成基础cup_locate_demo.py 已走通一半colourise() 现成;lerobot 数据集侧现成无
主要障碍内参是估计值,要标定一次SigLIP/ResNet18 都是 RGB 预训练,伪彩深度是分布外输入;state 从 17 维改到 20 维 = 换了 embodiment整条链路重来

A 具体长什么样(推荐先做这个)

深度不进模型,而是在模型旁边并行跑,给三件事用:

深度相机 ──→ cup_locate_demo 那套(最近的一团 + 反投影) ──→ 杯子 3D 坐标 (x,y,z)
                                                            │
   ┌────────────────────────────────────────────────────────┤
   │                          │                             │
   ▼ ①开跑前的门              ▼ ②跑的时候记录               ▼ ③失败诊断
 杯子在不在工作区?          每次试验存下杯子实际位置       把「模型往哪抓」和
 在不在 120-175mm 中段?     (现在的试验记录里没有这个)     「杯子实际在哪」对齐,
 不对就别让手臂动                                          才知道是看错了还是抓歪了

这三件事一件都不需要改模型,但每一件都直接冲着现在最大的未知去: 「模型抓不到,到底是没看见杯子,还是看见了抓歪了」——今天的试验记录回答不了这个问题。

A 的额外价值:深度给的是真实世界坐标,不是「记住的位置」。 它换个桌面位置也成立,而这正是纯模仿学习最难泛化的部分。等 A 跑通、坐标可信之后, 再谈把 3D 位姿当输入喂进策略(那属于 B 的一半,且能大幅降低模仿学习需要的数据量)。

为什么不建议先做 B

两个策略的视觉主干都是 RGB 预训练的:SmolVLA 走 SigLIP(归一化到 [-1,1]), ACT 走 ResNet18 + ImageNet 权重。伪彩深度图对它们是分布外输入,等于白多一路要从头学的输入。 而且把 3D 位姿并进 state 会让 17 维变成 20 维 —— 那是换了 embodiment, 现有全部数据和 checkpoint 一次性作废。在「还没有一个 checkpoint 抓起过杯子」的前提下, 先加一个不确定的输入,只会让「是数据的问题还是输入的问题」更难分辨。

四、A 落地要先解决的两件事

  1. 相机标定。cup_locate_demo.py 现在用的是估计内参 (FX=FY=320, CX=320, CY=240,按 90° 水平视角假设)。 距离 Z 是深度相机直接测的,准;左右 X、上下 Y 是近似值。这是 A 唯一的硬门槛。
  2. 手眼关系。杯子的 3D 坐标是在相机坐标系里的,要用来判断「手臂该去哪」, 必须知道相机和机械臂基座的相对位姿。这一步没做之前,深度只能回答「杯子离相机多远」, 不能回答「手臂该往哪伸」。
还有一条物理边界:透明玻璃杯的深度会失效(返回 0 / 空洞)。 不透明杯和磨砂杯可行,透明的要贴标记兜底。

五、手眼标定怎么做

先说一个会毁掉整次标定的前提:这台机器的相机装在头上,而头是两个电机、会动的。
所以外参不是「相机↔机械臂」一个固定值,而是「头在某个姿态时」的相机↔机械臂。 头一动,标定结果全作废。
更麻烦的是试跑脚本的现状:head_lock 是启动时读机器人当时在哪就锁在哪 (run_policy_trials.py:531 — 日志里那句「头部锁定在它此刻的实际位置 (-3.78, 50.11)」), 不是一个固定值。也就是说每次试跑头可能都在不同位置。
所以标定之前必须先做一件事:用面板的头部预置位(/api/head/aim 存一个视角,比如叫 trial),标定前、每次试跑前都先把头移到这个预置位。没有这一步,后面所有精度都是假的。

要解两样东西,别混为一谈

 内参 (intrinsics)外参 / 手眼 (extrinsics)
回答什么像素 (u,v) + 深度 → 相机坐标系里的 3D 点相机坐标系的点 → 机械臂坐标系的点
现状是猜的:FX=FY=320, CX=320, CY=240(按 90° 水平视角假设,cup_locate_demo.py:36)完全没有
不做的后果Z(距离)准,X/Y 有系统偏差只能说「杯子离相机 0.4 m」,说不出「手臂该往哪伸」

仓库里没有任何现成代码:全仓 grep solvePnP / findChessboard / aruco / apriltag / calibrateCamera 命中 0。这是从零开始的一项工作。

方法一 · 用手臂自己当标定物(不用打印任何东西,今天就能开始)

思路:机械臂自己知道爪尖在哪(正运动学),相机也能看到爪尖在哪(深度反投影)。 同一个点的两套坐标凑够若干组,就能解出两个坐标系之间的刚体变换。

  1. 固定头部:移到预置位 trial,之后整个过程不许动头。
  2. 爪尖做个记号:夹爪上贴一小块不反光的浅色胶带或一个小球。深度相机靠形状找它, 反光和透明都会让深度失效。
  3. 采点:把手臂摆到 15–20 个不同姿态,覆盖真实工作区(前后、左右、高低都要有, 别都挤在一小块)。每个姿态记两样:
    • 机械臂侧:从关节角算爪尖 3D。注意 arm_kinematics.forward() 只给 矢状面内的 (y, z)(肩抬 + 肘),左右那一维要用 shoulder_pan 单独旋转出来, 再沿腕的朝向加上爪长 gripper_len_m = 0.1179。
    • 相机侧:在深度图里找到那个记号的像素 (u,v) 和深度 z,用内参反投影成相机系 3D。
  4. 解变换:两组点各 N 个,用 Kabsch / Umeyama 求最优刚体变换(旋转 R + 平移 t)。 cv2.estimateAffine3D 或 scipy.spatial.transform.Rotation.align_vectors 都能做。
  5. 验收(这一步不能省):留出 5 个点不参与求解,用解出的 R,t 预测它们的位置, 报残差中位数(毫米)。< 10 mm 可以开始用;> 20 mm 说明采点太集中或记号找歪了,重采。
这个方法的天花板:它把内参的误差一起吸收进外参里,所以在采点覆盖到的区域内准, 外推出去会越走越偏。要真正精确,还是得先标内参 —— 见方法二。

方法二 · 棋盘格标定板(精度更高,要先打印)

  1. 打印一张棋盘格(常用 9×6 内角点,格子边长 20–25 mm)。贴在硬质平板上, 不能有褶皱;打印后用尺子量一格实际边长填进代码 —— 打印机缩放会让标称值不准。
  2. 标内参:用红外/灰度图(不是深度图)从 15–20 个不同角度和距离拍棋盘格, cv2.findChessboardCorners + cv2.calibrateCamera → 得到真实的 fx, fy, cx, cy 和畸变系数,替换掉 cup_locate_demo.py 里那四个猜的数。
  3. 标外参:把棋盘格固定在工作台上不动,用 cv2.solvePnP 得到「相机 → 棋盘格」。 再让机械臂爪尖去触碰棋盘格上几个已知角点,由 FK 得到「机械臂 → 棋盘格」。 两者一组合就是「相机 → 机械臂」。
  4. 验收:同方法一,留出点报残差。
Orbbec 的一个便利:深度和 RGB 头是同一颗相机的两个接口 (2.3.1:1.0 深度 / 2.3.1:1.4 RGB),出厂就是对齐的。 所以可以用 RGB 图找棋盘格角点、用深度图取那个像素的距离,不需要额外做深度↔RGB 的配准。

做完之后长什么样

标定产物(存成 configs/hand_eye.json):
  intrinsics: fx fy cx cy + 畸变系数
  extrinsics: 4x4 变换矩阵 T_cam_to_arm
  head_preset: "trial"        ← 标定时头在哪,用的时候必须也在这
  residual_mm: 6.2            ← 验收残差,写进去,以后才知道退化了没有

用的时候:
  深度图(u,v,z) --内参--> 相机系(X,Y,Z) --外参--> 机械臂系(x,y,z) --> 就是「杯子在哪」

六、路线 C 展开:lerobot 原生 depth 特征

先把话说清楚:路线 C 的数据集那一半是现成的、而且质量不错;策略那一半一个现成的都没有。

现成的部分(数据集侧,全部已核对)

能力出处说明
深度专用落盘格式datasets/utils.py:143DEPTH_FILE_PATTERN = "frame-{i:06d}.tiff" —— 16-bit TIFF,无损,不走视频编码
深度特征自动识别utils/feature_utils.py:98单通道 (H,W,1) 的相机自动带 info["is_depth_map"]=True
统计量不做 /255datasets/compute_stats.py:537带该标志的特征跳过 RGB 的 0–255 归一化 —— 毫米值被当一等公民
深度视频编码器datasets/video_utils.py:1224DepthEncoderConfig,要压成视频也有专门通路
元数据能查datasets/dataset_metadata.py:399有专门的「哪些 key 是深度」的查询
相机侧样板cameras/realsense/camera_realsense.py:140RealSense 的 use_depth + read_depth() 已实现,可照抄结构

缺的部分(策略侧)

全仓 grep is_depth_map:只命中 datasets/ 和 utils/, policies/ 下 零命中。
也就是说 lerobot 能录得进、存得好、查得到深度,但没有任何一个策略会去读它。 (注:policies/ 下确实能 grep 到 "depth" 字样,但那是 depth=18 这种网络层数,不是深度相机 —— 2026-09-03 核对时差点误读。)

所以路线 C 实际要做的三步

  1. 写一个 Orbbec 深度相机类,实现 lerobot Camera 接口 (connect / read / async_read / disconnect),输出 (H,W,1) uint16。 难的那部分已经有了 —— v4l2_depth.DepthCamera 已经把裸 V4L2 读 Z16 做完, 这一步主要是接口适配。工作量:小
  2. 先用小数据集验证端到端能跑通:几十集,确认 lerobot-record → 数据集带 is_depth_map 特征 → lerobot-train 不报错。 这一步没有任何人验证过,必须先试通再谈其它。工作量:小,但风险未知
  3. 决定策略怎么消费它 —— 这是真正的工作,三条子路:
    • 单独的深度编码器:给深度一路独立的小 CNN,特征和 RGB 特征拼起来。改动最干净,但要动模型结构。
    • 拼成 4 通道:RGB+D 一起进主干。代价是主干第一层的预训练权重作废(SigLIP/ImageNet 都是 3 通道)。
    • 换点云策略(DP3/iDP3):深度转点云,用为点云设计的策略。等于换策略家族, 但这是学界公认深度收益最大的用法 —— 之前的架构页也是这个结论: 「ACT/SmolVLA 加深度通道收益小,大收益在点云」。
    工作量:大
路线 C 的一个隐藏成本:观测多一路 = 换了 embodiment。 现有全部数据集(没有深度特征)和全部 checkpoint 都不能和新格式混用, 要么全部重录,要么接受两套并行。在「还没有一个 checkpoint 抓起过杯子」之前, 这个代价很难划算 —— 这也是为什么建议顺序仍然是 A → 再看 C。

五、诚实边界

2026-09-03 · 代码出处以本机 03-software/scripts/ 为准 · 配套页:深度相机的调用链与三个缺陷