机器人的眼睛:双目相机怎么产出深度图和点云
- 读懂一个双目相机 SDK 提供的数据链条:原始帧 → 校正帧 → 深度图 → 点云
- 掌握双目测距的几何本质,理解为什么它的误差随距离平方增长
- 学会判断双目、激光雷达、结构光/ToF 各自适合什么场景,以及标定这类静默错误怎么防
想让一台机器人绕开桌腿,你真正需要的不是一张好看的照片,而是「桌腿离我多远」。
这是相机和眼睛最本质的差别。一张普通图片,是三维世界往二维平面上的一次投影,深度这一维在快门按下的瞬间就被抹掉了。图上每个像素只告诉你「从镜头出发的这条射线上有东西」,不告诉你东西落在射线的哪一段。你可以用神经网络去猜,但那是猜,不是测。
想把这一维捞回来,最古老也最直接的办法是:再架一只眼睛。
这篇我们拿宇树公开的 UnitreecameraSDK 当样本,把双目这条路径从头到尾走一遍——它能吐出哪些数据、两张图怎么变成距离、哪一步最容易翻车,以及它和激光雷达、结构光各自的地盘在哪。
先把边界说清楚:这篇文章的全部事实来自公开仓库的 README 和文件树,我没有真机可以验证。所以我只描述代码结构、依赖关系和官方文档写的操作步骤,不谈运行效果。另外,这个仓库的描述里写明它面向的是宇树较早的一款四足机型,所以下文所有关于「这个 SDK 提供什么」的结论都只对它成立,不要外推到其他型号。
这个 SDK 到底给你什么
README 的 Overview 只有两句话,但信息密度很高,值得逐项拆开:它是一个面向宇树立体相机的跨平台库,允许深度与彩色数据的流式获取,提供内参标定信息,另外还能给出点云,以及对齐到彩色图的深度图。
最后那句「对齐到彩色图」容易被略过,其实很关键。深度是从两个相机的关系里算出来的,它天然处在某一个相机的坐标系里;而你要做的下游任务——比如「把这个杯子的像素区域抠出来,再问它有多远」——需要深度图和彩色图逐像素对得上。SDK 把这层对齐做掉了,省的是使用者最容易算错的一段坐标变换。
再看 README 列出的可执行示例,这份清单本身就是一条完整的处理链:
./bin/example_getRawFrame 原始帧
./bin/example_getCalibParamsFile 标定参数文件
./bin/example_getRectFrame 校正后的帧
./bin/example_getDepthFrame 深度帧
./bin/example_getPointCloud 点云
./bin/example_putImagetrans 把图像发到另一台设备
./bin/example_getimagetrans 从另一台设备取图像
原始帧 → 标定参数 → 校正帧 → 深度帧 → 点云。 这条链的每一站,SDK 都单独开了一个出口。这不是为了凑示例数量,而是因为这条链上每一步都可能出问题,而且出问题的表现完全不同。你调试的时候需要能停在任意一站看一眼——这个设计意图,比任何文档都直白。
从文件树能多读出什么
仓库根目录不大,把杂项去掉是这样:
include/StereoCameraCommon.hpp
include/UnitreeCameraSDK.hpp
include/SystemLog.hpp
lib/amd64/libunitree_camera.a
lib/amd64/libtstc_V4L2_xu_camera.a
lib/amd64/libsystemlog.a
lib/arm64/…(同样三个)
examples/example_getRawFrame.cc
examples/example_getCalibParamsFile.cc
examples/example_getRectFrame.cc
examples/example_getDepthFrame.cc
examples/example_getPointCloud.cc
examples/example_putImagetrans.cc
examples/example_getimagetrans.cc
examples/example_share.cc
examples/glViewer/glwindow.hpp
examples/glViewer/glwindow_x11.cpp
examples/glViewer/scenewindow.cpp
examples/glViewer/scenewindow.hpp
stereo_camera_config.yaml
trans_rect_config.yaml
几处值得停一秒的地方:
三个头文件配预编译静态库。 和我们拆过的 unitree_sdk2 一样,核心实现是编译好的 .a,不开源。amd64 和 arm64 各一份,这个组合描绘的是典型场景:你在 PC 上写代码,最终跑在机器人身上那块 ARM 板子。
libtstc_V4L2_xu_camera.a 这个名字。 里面有 V4L2(Linux 的视频设备接口)和 xu(扩展单元)。这至少说明底层是走 Linux 标准视频设备那条路子的,并且用到了厂商自定义的扩展。具体做了什么,源码没公开,我不猜。
glViewer/ 里那四个文件。 一个自带的 OpenGL 显示窗口,还带了 X11 版本的实现。这正对应 README 依赖里那句「点云 GUI 需要 OpenGL、GLUT、X11」——点云不像图片,imshow 一下就完了,它需要一个能转视角的三维窗口,作者干脆自己写了一个。
两个 yaml。 stereo_camera_config.yaml 和 trans_rect_config.yaml。名字里的 rect 就是 rectify(校正),trans 对应的是图像传输那对示例。配置外置成 yaml,意味着这些参数是会因设备而异、需要被改的。
example_share.cc。 它在 examples/ 目录里,但 README 的运行清单里没有它。这类情况在开源仓库里很常见——示例目录往往比 README 多,翻一翻经常有惊喜。它具体做什么我不去推断。
依赖和编译方式 README 写得很短:OpenCV 4 及以上(需要带 gstreamer 支持),CMake 2.8 以上,点云 GUI 另需 OpenGL、GLUT、X11。以官方仓库当前文档为准。构建就是最常规的三板斧:
cd UnitreecameraSDK
mkdir build && cd build
cmake .. ; make
OpenCV 那条「需要 gstreamer」的要求值得留意。它暗示图像获取这一环没有绕开系统的多媒体框架,而是站在 gstreamer 的管线上。如果你装的是 pip 装来的 OpenCV,大概率不带 gstreamer,这会是第一个卡住你的地方。
两只眼睛是怎么算出距离的
现在讲原理。这部分不依赖任何仓库,是几何本身。
把两个相机并排装好,让它们同时拍同一个场景。空间里的某个点 P,会在左图上成一个像,在右图上也成一个像。这两个像的位置不一样——这个横向的位置差,叫视差(disparity),记作 d。
你可以用手做一次实验:伸出食指举在眼前,交替闭左右眼,会看到手指在左右横跳。把手臂伸远一点,跳动幅度明显变小。把手指移到很远的墙上,几乎不跳了。
这个体感直接对应那个核心公式:
Z = f · B / d
Z:点到相机的距离
f:焦距(像素为单位)
B:基线,两个相机光心之间的距离
d:视差(像素)
距离和视差成反比。 近处的东西视差大,远处的东西视差小。这一条几乎决定了双目视觉的全部脾气。
再往前推一层。对上式关于 d 求导,可以得到距离误差和视差误差之间的关系:
ΔZ ≈ (Z² / (f · B)) · Δd
距离误差随距离的平方增长。 你的匹配算法在视差上的误差(Δd)大体是恒定的——比如稳定在亚像素级别——但同样的视差误差,放在近处只造成很小的距离偏差,放在远处会被平方级地放大。到了某个距离,真实视差本身已经小到跟匹配噪声一个量级,深度就彻底没有意义了。
这不是工程没做好,是几何的性质。理解了这一条,你就知道双目该被放在系统的哪个位置:近距离的抓取、避障、地形感知,它很称职;远距离的定位和建图,你得找别的手段配合。
再看公式里的 B。基线越长,同样距离下视差越大,远处的分辨能力越好;但基线一长,两个相机的公共视野就变小,近处会出现一大片「只有一只眼看得见」的盲区。这是个此消彼长的取舍,没有免费的午餐。
从视差图到点云:这条链是怎么接上的
有了公式,整条链就清楚了:
左图 + 右图
↓ 逐像素找对应点
视差图 D(u,v) 每个像素存一个视差值
↓ Z = f·B/d
深度图 Z(u,v) 每个像素存一个距离值
↓ 反投影:X=(u-cx)·Z/f, Y=(v-cy)·Z/f
点云 {(X,Y,Z)} 相机坐标系下的三维点集
注意最后一步用到了 cx, cy——主点坐标,也就是光轴穿过成像面的那个位置。它和焦距一起,属于相机内参。这个细节马上会变得很要命,我们下一节说。
还要注意一件事:这三样东西信息量是一样的。视差图、深度图、点云只是同一批数据的三种表达。视差图最接近算法的原始输出;深度图便于和彩色图逐像素对齐,做分割、抠图这类事;点云脱离了图像网格,变成纯粹的三维点集,适合做平面拟合、体素化、和地图做配准。SDK 把三者都开了出口,是因为下游任务需要的形态不同。
为什么必须先做极线校正
回到「逐像素找对应点」这一步。这是双目里计算量最大、也最容易出错的一环。
朴素的做法是:拿左图上的一个小块,去右图上到处找最像的位置。右图有多少个像素,你就要比对多少次——这是二维搜索,代价高得离谱,而且图像上重复纹理一多,很容易匹配到错误的地方。
几何给了一个强约束:对极约束。左图上一个点对应的空间射线,投影到右图上是一条直线(极线)。真正的对应点一定在这条线上,不可能在别处。搜索范围立刻从二维塌成一维。
但直接用极线还是麻烦——它是斜的,你得逐点算方程、沿着斜线插值采样。于是有了极线校正(rectification):对两幅图各做一次重映射,把它们变换到一个虚拟的、光轴严格平行且成像面共面的配置上。校正之后,所有极线都变成水平的,而且左图第 v 行上的点,对应点一定在右图的第 v 行上。
这一下匹配就变成了:在同一行里从左往右滑动比较。缓存友好,能向量化,还能整行整行地做动态规划优化。现代的立体匹配算法基本都建立在这个前提上。
这就是为什么 example_getRectFrame 是一个独立的示例,而且在 README 的顺序里排在 example_getDepthFrame 前面——顺序不是随手排的,它就是数据流的顺序。
校正这一步顺带还做了另一件事:把镜头畸变纠掉。广角镜头拍出来的直线是弯的,而上面所有公式都建立在理想小孔成像模型上。畸变不纠,公式从第一步就不成立了。
标定:这条链上最容易静默出错的一环
现在可以说那件要命的事了。
上面每一个公式里的量——焦距 f、主点 cx/cy、畸变系数、基线 B、两个相机之间的旋转——没有一个是能从图像里现算出来的。它们全部来自标定:
- 内参:每个相机各一套,焦距、主点、畸变系数
- 外参:两个相机之间的相对位姿,一个旋转加一个平移。基线 B 就藏在平移里
标定值不准,会发生什么?
程序不报错。
这是双目最阴险的地方。深度图照样出,点云照样出,颜色对齐照样做,看起来一切正常。只是所有数值都是错的。你拿着一个看似合理的点云去做避障、去做抓取,机器人一头撞上去,你还在怀疑是控制算法的问题。
这类静默错误比崩溃难查一个数量级。崩溃至少告诉你在哪一行;静默错误只会让你的系统「莫名其妙地不太行」。
从几何上能推出几种典型症状,记住它们能帮你快速定位:
- 基线标错:整张深度图按一个固定比例缩放。表现是「形状对,尺度不对」——桌子还是桌子的形状,但你测出来的距离整体偏大或偏小。这种最容易被忽略,因为图看起来完全正常。
- 相对旋转标错:校正之后极线不再严格水平,同一个点在左右图里差了几行。匹配算法只在同一行里找,自然找不到,结果是深度图上大面积空洞或者高噪声。
- 畸变系数不准:画面中心的深度是对的,越往边缘越飘。你会看到一面平墙在点云里中间平、四周卷边。
那这个 SDK 在标定上给了什么?按 README 的说法,它提供内参标定信息,并配了 example_getCalibParamsFile 这个示例专门把标定参数文件取出来;仓库根目录也确实躺着 stereo_camera_config.yaml 和 trans_rect_config.yaml 两个配置文件。至于这两个 yaml 里每个字段具体叫什么、SDK 是否附带一套从零开始的标定工具链,README 没有展开,我不替它补。
不过有一点是通用的:只要你的相机被磕碰过、被拆装过、经历过大的温度变化,就该怀疑标定。哪怕两个相机的相对角度只变了零点几度,校正后的极线对不齐,整套东西就废了。把「重新取一次标定参数、看一眼校正后的图上同名点是否落在同一行」当成例行检查,能省下大量排查时间。
双目、激光雷达、结构光/ToF:各有各的地盘
深度这件事有好几条技术路线,它们不是替代关系,而是各自成立于不同的条件。
双目(被动立体视觉)
- 不往外发任何东西,只是拍照,功耗和成本都低
- 深度和彩色图天生同源,语义信息和几何信息在同一套像素上,做分割、识别、抓取姿态估计都方便
- 它的输入是纹理。白墙、纯色地板、干净的桌面上没有可匹配的特征,匹配算法找不到唯一解,出来就是空洞
- 玻璃和镜面直接骗过它——两只眼睛看到的是反射或透射的东西,算出来的「距离」指向一个不存在的位置
- 依赖环境光。太暗没得看,逆光过曝也没得看
激光雷达
- 主动测距,自己发光自己收,不看环境光脸色,黑暗中照常工作
- 不依赖纹理,白墙对它来说和花墙一样
- 距离精度受距离影响远小于双目,这是原理层面的差别:它测的是光的飞行时间或相位,不是反比关系
- 代价是没有颜色和纹理信息,点是稀疏的,成本高,而且往往带机械或光学扫描结构
- 宇树自家这条线我们放在激光雷达那篇里单独讲
结构光 / ToF
- 也是主动方案。结构光往场景上投一套已知图案,靠图案的形变反推深度;ToF 直接测光往返的时间
- 结构光相当于人为给场景「贴纹理」,正好补上双目在白墙上的短板,室内近距离表现好
- 室外阳光是它们的天敌——太阳的红外能量会把主动光淹掉
- 多台设备同时工作时会互相干扰,这在多机器人场景里是实打实的麻烦
这里不做排名,因为排名本身就是错的问法。 正确的问法是「我这个任务的场景条件是什么」:室内、有纹理、要颜色 → 双目很合适;室外、大范围、要稳定几何 → 雷达更靠谱;桌面级、近距离、白色物体多 → 结构光有优势。
实际的机器人系统常常是几种并用,各补各的短板:雷达给可靠的几何骨架,相机给语义和细节,两者做时间和空间上的配准,再一起喂给上层。想了解这些数据最终是怎么汇进决策的,可以看具身智能那篇;各类传感器的横向对比,在传感器专区里还有更细的展开。
取图和传图是两件事
这里插一个很有价值的对照。宇树还有另一个和相机相关的仓库 teleimager,它做的事跟上面完全不同。
按它的 README,teleimager 是一个图像服务器:从多路相机(UVC、OpenCV、Intel RealSense)抓取视频流,用 ZeroMQ 的 PUB-SUB 或者 WebRTC 发布到网络上,另一端有个 image_client 负责接收和显示。它目前被用在遥操作项目里,给远端提供视频流。仓库结构简单到只有两个核心文件:
src/teleimager/image_server.py
src/teleimager/image_client.py
cam_config_server.yaml
setup_uvc.sh
setup_autostart.sh
它的 README 里有一节「设计原则」,讨论的问题特别实在,值得单独说两句:
相机怎么标识。 Linux 下一个相机可以用物理路径、序列号、或者 /dev/videoX 来指认。文档把三者的取舍摆得很清楚:物理路径不受重启和插拔顺序影响,适合机器人头部、腕部这种固定的多相机部署,缺点是换个 USB 口就得改配置;序列号跨端口稳定,但廉价相机可能几台共用同一个序列号;/dev/videoX 最省事,也最不可靠,重启一次编号可能就变了。任何做过多相机项目的人都被这件事坑过。
为什么支持多种传输。 README 给的理由是场景不同:局域网上跨机器传高质量帧走 ZeroMQ,实时预览和 VR 遥操作走 WebRTC(默认 H.264,也支持 VP8),同机器上追求极限性能则用共享内存。
三重环形缓冲。 文档说它保证写入方不必等待读取方,读取方每次拿到的都是最新的完整帧,不会读到写了一半的画面,旧帧允许被覆盖。这是实时系统里非常典型的取舍:宁可丢旧帧,也绝不卡住或撕裂。 对遥操作来说,一帧陈旧的画面比没有画面更危险。
把两个仓库并排放,分工一目了然:
UnitreecameraSDK → 从这对镜头里算出几何(感知)
teleimager → 把画面可靠地送到需要它的进程或机器(管道)
取图和传图是两个独立的问题,它们的失败模式、优化方向、依赖栈都不一样。混在一起写,你会得到一个既难调也难换的模块。分开之后,换相机不影响传输,换传输不影响算法。
顺带一提,在 unitree_sdk2 的头文件树里也能看到视频相关的模块:go2/video/ 下是 video_api.hpp、video_client.hpp、video_error.hpp 这套三件套,b2/ 下则分成了 front_video/ 和 back_video/。这只能说明这些型号的 SDK 暴露了取视频的接口,不能反过来推断别的型号做不到——仓库里没写的事,我们就不写。
读完这个仓库,记住三件事
第一,双目的能力边界是几何决定的,不是工程水平决定的。 距离误差随距离平方增长,视差在远处会被噪声淹没。你可以换更好的匹配算法、更长的基线,但改不了那个反比关系。设计系统时把双目放在它擅长的距离段,比事后调参有用得多。
第二,标定是这条链上唯一会「安静地错」的环节。 其他环节出问题会崩溃、会报错、会明显异常;标定出问题只会给你一份看起来正常、实际上全错的深度。把校正后同名点是否同行当成开机自检,成本极低。
第三,一条完整的视觉链路是可以拆开的。 原始帧、校正帧、深度帧、点云、网络传输——UnitreecameraSDK 把前四站各开了一个出口,teleimager 把最后一站单独做成了服务。这种拆法让你能在任意一站插入自己的东西:换匹配算法、换传输协议、在深度图上接自己的模型,都不需要动其他部分。
视觉在具身智能里的位置,说到底是把物理世界翻译成机器人能算的表示。深度图和点云就是这个翻译的第一层产物——再往上,才轮到「这是什么」「我该怎么动」。这一卷其他文章里,我们会接着看这些感知结果怎么进入运动控制和策略网络;如果你还没有搭过完整的机器人,机器人卷里的循迹小车和避障小车是更合适的起点,先把传感器到执行器这条最短的闭环跑通。