这一页回答两件事:①今天这套深度录制在代码里到底怎么串起来的、它坏在哪;②要让深度真正参与抓取, 有哪三条路可走、各自代价多大。所有结论都标了出处(文件:行号 或 实测数字), 推断和实测分开写。
feat(depth maps): adding support for depth in LeRobot,PR #3644)
合入于 2026-06-27,本机这份 checkout 停在 2026-08-07 —— 写旁路的时候,原生支持已经躺在本地代码里三周多了。| 问题 | 回答 |
|---|---|
| 深度现在能用吗 | 半能。硬件和采集是好的(实测有效像素占比 0.88),但录制生命周期有 bug,数据是断续的。 |
| 深度现在被谁消费 | 没有任何人。它只是写到磁盘上,训练不读它,推理不读它。 |
| 最快能产生价值的接法 | 路线 A:程序化感知 —— 深度→杯子 3D 坐标,不训练、不改数据集。cup_locate_demo.py 已经把这条路走通了一半。 |
| 最贵且最不确定的接法 | 路线 B:把深度当第四路相机喂给策略。SigLIP / ResNet18 都是 RGB 预训练的,伪彩深度图对它们是分布外输入。 |
| 该先做的一件事 | 已完成(2026-09-03):fd 泄漏修了,面板现在报每集深度帧数、0 帧标红。中期仍要迁 lerobot 原生 depth feature。这两项修复之前采的深度数据都不可靠。 |
三层,从下往上。
v4l2_depth.py · DepthCamera —— 裸 V4L2 直接读 Orbbec 深度节点,不经过任何 SDK。open() → read(timeout_s) → close();另有 colourise() 把毫米图转成能看的伪彩 BGR(v4l2_depth.py:189)。
depth_recorder.py · DepthRecorder —— 起一个后台线程按 30 fps 抓帧落盘。frame_%06d.png(16-bit 单通道 PNG,无损)、timestamps.jsonl(每帧一行 i / t_mono / t_wall / valid)、meta.json(收尾时写)。attach_to_process(out_dir, proc) 是给子进程路径用的封装:起一个看门线程,proc 一退就 stop()(depth_recorder.py:251)。
robot_server.py:5936,用 attach_to_process 绑在 lerobot.record 子进程上,写到 depth/<时间戳>/。leader_follower_server.py:902,在 _cmd_start 里手动 start()、_cmd_stop 里手动 stop(),写到 depth/ep<N>-<时间戳>/。
depth/ 子目录里,靠 timestamps.jsonl 的墙钟事后对齐。
代码注释写明了原因(robot_server.py:5931):深度是 16-bit 毫米值,
进 LeRobot 数据集会被视频编码压毁。
_cmd_start 原来直接 self._depth = DepthRecorder(...),覆盖前不停旧的。
按第二次 Start 时,上一个 DepthRecorder 就没人持有引用了:它的线程是 daemon,
只认自己的 _stop 事件,而 _cmd_stop 只停 self._depth 当前指向的那一个。
xlerobot-left-pick-cup-sep2-20260902-1819 的 ep20-20260902-191409):meta.json —— 那个文件只在 stop() 里写,缺失即证明 stop() 从未被调用。libpng error: Write Error(08:53:13),即磁盘写满、线程自行退出的时刻。
DepthCamera.open() 先 os.open() 拿到 fd,再做一串可能抛异常的 ioctl(S_FMT / REQBUFS / STREAMON)。
设备被占时 ioctl 抛错,DepthRecorder.start() 捕获后只把 _cam 置 None —— 那个已经打开的 fd 永远不关。
# v4l2_depth.py:119
def open(self):
self.fd = os.open(self.device, os.O_RDWR | os.O_NONBLOCK) # ← 先占住
...
fcntl.ioctl(self.fd, VIDIOC_S_FMT, fmt) # ← 这里抛异常,fd 就漏了
实测:leader_follower_server.py 进程上挂着 34 个 /dev/video0 句柄(ls /proc/<pid>/fd)。
depth_recorder.py:108 那条「2026-08-31 观测到:开-关-立刻重开会让流起不来,每次 read 都超时」的注释,
正是这个泄漏的表象 —— 当时把症状当成了 Orbbec 的怪癖,绕过去了,没找到根因。
open() 的 body 包一层 try/except,任何一步 ioctl 失败就调用 self.close()
(它按当前 fd/streaming/buffers 状态安全收尾,不要求 open() 已经走完)再把异常抛出去。open() 前后的 fd 集合完全一致,self.fd 正确回到 None,正常路径(真实设备上 open→read→close)不受影响。leader-follower 服务已重启加载,重启后 /dev/video0 句柄数:0(此前累积到 34)。
9-02 下午右臂四个数据集(1654 / 1717 / 1734 / 1746)的深度目录全部 0 帧,
meta.json 一律是 frames=0, dropped=0, error=None,起止正好 10.0 秒。
这个组合只有一种解释:open() 成功、ioctl 全过,然后 5 次 warm-up 读帧每次超时 2 秒 = 10 秒,
还没进主循环 stop() 就来了 —— 相机开着,但一帧都不给。1819 的 ep0–ep6 同样。
| 实测 | 结果 |
|---|---|
| 单独进程里冷开 / 关完立刻重开 / 隔 3 秒重开,各 5 轮 | 每轮第 1 次读就出帧,0.1–0.2 s —— 「快速重开会起不来流」(08-31 那条注释)不成立 |
| 9-02 每集之间的间隔 | 17–56 秒,与「太快」无关 |
| 假死从何时到何时 | 至少 16:46 起,到 18:31(ep6)都是 0 帧;19:00(ep7)起突然正常 |
| 中间发生了什么 | 内核日志 18:32:16:同一 USB hub(1-2.3)上的腕相机 USB2.0_CAM1 断开并重新枚举 —— 一次物理总线事件,之后深度就好了 |
USB 拓扑(/dev/v4l/by-path/)—— 深度和 RGB 头是同一个物理设备的两个接口,腕相机在同一个 hub 的另一个口:
usb-0:2.3.1:1.0 → video0 Orbbec Gemini 335 ← 深度 (Z16)
usb-0:2.3.1:1.4 → video6 Orbbec Gemini 335 ← RGB 头,同一颗相机
usb-0:2.3.2:1.0 → video9 (腕) ← 18:32:16 就是这个口断开重连的
usb-0:2.3.3:1.0 → video10 USB2.0_CAM1 (腕)
unbind/bind 看是否恢复)。DepthRecorder 把「0 帧」当成一次正常收尾写了
meta.json(error=None),面板上什么都没报。
缺陷 3 让 ep0–ep6 是 0 帧;缺陷 1 让 ep20 的录制器一直占着相机,缺陷 2 让后续每次开相机都失败且漏一个句柄。结果:
| 集号 | 深度 | 原因 |
|---|---|---|
| ep0 – ep6 | 0 帧 | 缺陷 3:相机开着但不出帧,18:32 总线事件之后才好(7 集) |
| ep7 – ep20 | 有,每集约 105 帧 | 正常(14 集) |
| ep21 – ep33 | 空 | 被 ep20 的孤儿占着相机,每次 open() 都失败(13 集) |
证据:ep21 起的每个深度目录都被创建了但一个文件都没有 —— 目录由 os.makedirs 在开相机之前建,
所以「空目录」精确地等于「尝试过、开相机失败」。ep21 的时间戳是 19:14:57,距 ep20 起跑仅 48 秒。
同一晚随后录的 ...-1928(17 集,19:28–19:43)整个数据集没有 depth 目录,同一个原因。
DepthRecorder.start() 的失败被设计成「降级为 no-op,绝不影响 RGB 录制」——
这个取舍本身是对的,但降级之后没有任何东西把「这一集没有深度」报到面板上。
当初做旁路的理由是「深度进数据集会被视频编码压毁」。这个理由从一开始就不成立。
本机 lerobot 0.6.2(~/lerobot/src/lerobot,checkout 于 2026-08-07)里
早已有完整的深度通路,全部来自 2026-06-27 的 PR #3644:
| 能力 | 出处 | 说明 |
|---|---|---|
| 深度单独的落盘格式 | datasets/utils.py:143 PR #3644 · 2026-06-27 | DEPTH_FILE_PATTERN = "frame-{frame_index:06d}.tiff" —— 16-bit TIFF,不是视频,不存在压毁问题 |
| 深度特征标志 | utils/feature_utils.py:98 | 单通道相机 (H,W,1) 自动打上 info["is_depth_map"]=True;三通道才当 RGB |
| 统计量不做 /255 | datasets/compute_stats.py:537 | 带 is_depth_map 的特征跳过 RGB 的 0–255 归一化 —— 说明毫米值是被当一等公民对待的 |
| 相机侧已有实现 | cameras/realsense/camera_realsense.py:140 | use_depth 开关 + read_depth(),RealSense 已经打通 |
| xlerobot 配置里留了口子 | robots/xlerobot/config_xlerobot.py:45 | 一行被注释掉的 use_depth=True |
dtype: "image" + is_depth_map,存成 TIFF),
跟 RGB 帧天然按 frame_index 对齐,不需要墙钟事后对齐,也不需要自研旁路。
is_depth_map 这个标志目前只在数据集侧被使用(落盘格式、统计量、可视化),
策略侧(ACT / SmolVLA)没有任何一处读它(全仓 grep is_depth_map 只命中 datasets/ 和 utils/)。
所以「录得进去」已验证,「训得起来」未验证 —— 见路线 C。
这是个绕不开的问题:既然旁路本可以不写,那换成原生就自动没事了吗?逐个看,答案不一样。
| 缺陷 | 原生之后 | 为什么 |
|---|---|---|
| 1 · 双击 Start 孤儿线程 | 消失 | 原生的模型是 connect() 开一次相机、和 RGB 在同一个采集循环里读、disconnect() 关。
没有「每集手动 start/stop 一个独立线程」这个环节,也就没有「起了不停」的可能。
这个缺陷是自研旁路特有的。 |
| 2 · open 半途失败漏 fd | 取决于怎么接 | 它在 v4l2_depth.py:119。给 lerobot 写 Orbbec Camera 类时如果复用这个文件,泄漏一起带过去;
换 pyorbbecsdk 就没有。不管走哪条路,这三行都该先修。 |
| 3 · USB 层假死 | 不会消失,但会变响 | 总线层的事,谁的代码都清不掉。区别在可见性: lerobot 的 record 循环读帧超时会直接抛错中断录制,当场就知道; 现在的旁路是静默写一个 0 帧目录再「正常」收尾,第二天才发现。 |
| 路线 A · 程序化感知 | 路线 B · 深度当第四路相机 | 路线 C · 原生 depth 特征 | |
|---|---|---|---|
| 做什么 | 深度 → 杯子 3D 坐标 → 喂给运动规划或作为策略的额外状态输入 | 把深度伪彩成 3 通道图,当成多一个相机喂进现有策略 | 用 lerobot 原生 depth feature 录进数据集,改策略去消费它 |
| 要不要训练 | 不要 | 要,重训 | 要,重训 + 改模型 |
| 要不要重采数据 | 不要 | 要(现有数据只有 14/34 集有深度) | 要 |
| 现成基础 | 已有一半 —— cup_locate_demo.py | colourise() 现成 | lerobot 数据集侧现成 |
| 主要风险 | X/Y 用的是估计内参,需要标定 | SigLIP/ResNet 是 RGB 预训练,伪彩深度是分布外输入 | 策略侧无人消费 is_depth_map,要自己改 |
| 建议 | 先做这个 | 不建议先做 | 中期做 |
03-software/scripts/cup_locate_demo.py 已经在做这件事:找画面里最近的一团,
用深度值 + 相机内参反投影出 3D 坐标。它不依赖任何 ML 模型,也不需要重训。
# cup_locate_demo.py 的自述,原文照录
# 积木② 深度相机每个像素给你一个距离(毫米);
# 积木③ 把"物体在画面的位置"+"那个位置多远"一拼 → 物体的真实 3D 坐标(反投影)。
它自己也诚实标了两条边界,这两条就是路线 A 的待办:
FX=FY=320, CX=320, CY=240,按 90° 水平视角假设),
是近似值 —— 要精确必须做一次相机标定。这是路线 A 唯一的硬门槛。「多一路相机」听起来最省事,但两个策略的视觉主干都是 RGB 预训练的:
prepare_images)。vision_backbone = "resnet18" + pretrained_backbone_weights = "ResNet18_Weights.IMAGENET1K_V1"(policies/act/configuration_act.py:98)。伪彩深度图对这两个主干都是分布外输入 —— 预训练特征在这种图上不成立,等于白白多一路要从头学的输入。 在「至今没有一个 checkpoint 抓起过杯子」的前提下,先加一路不确定的输入, 只会让「是数据的问题还是输入的问题」更难分辨。
录进去这一半是现成的;难点全在消费端。要做的事:
Camera 接口的 Orbbec 深度相机类(connect / read / async_read / disconnect,
见 cameras/camera.py),让它输出 (H,W,1) uint16 —— 这样 feature_utils 会自动打上 is_depth_map。
现有的 v4l2_depth.DepthCamera 已经把最难的裸 V4L2 部分做完了,这一步主要是接口适配。(H,W,1) 特征 —— 这一步没人验证过,必须先用几十集小数据集试通再说。v4l2_depth.py:119,try/except + self.close())。实测验证见缺陷 2。leader_follower_server.py _cmd_stop:message_kind="err" 时前端渲染红色)。
VR / 面板批量录制那条路径(robot_server.py,整段会话一个 depth 文件夹,不是逐集)还没做,是接下来的一步。cup_locate_demo.py 里的估计内参换成实测值。这是路线 A 唯一的门槛。cup_locate_demo.py 里 zmin=150mm 是经验值,不是标定结果);
深度与 RGB 两个节点之间的外参(做 3D→机械臂坐标必须要)。
这些报告平时发到 HF Space Suyang99/xlerobot-build-reports(public、RUNNING、290 个文件,
2026-09-03 实测从机器上取回 HTTP 200 / 195 KB)。但 国内访问不了 huggingface.co ——
于是自己写的报告自己看不到。文件本来就在仓库里,所以 2026-09-03 给 robot_server 加了直接供页的路由:
http://100.107.145.111:8770/reports # 所有报告的索引(71 页)
http://100.107.145.111:8770/reports/space-2026-08-21/... # 具体某一页
走 Tailscale,不经 HuggingFace,不需要 token(报告页里没有密钥;真实地址和密码在私有的
xlerobot-ops Space)。路径穿越已挡:解析后必须仍在 04-reports/ 内,
否则 403(实测 --path-as-is 三种穿越写法全部 403)。
03-software/scripts/ 与 ~/lerobot/src/lerobot(0.6.2)为准 ·
配套记忆:深度旁路孤儿线程、磁盘硬约束、数据集补传