SLAM 相机与图像(4)

前言

前两讲一直在讲"位姿":机器人在哪、朝哪。可是 SLAM 的输入不是位姿,是传感器数据。对视觉 SLAM 来说,传感器数据就是一张张图像------每张图像是几十万到几百万个像素,每个像素是一个 0~255 的亮度值。从这堆数字到"机器人在哪",中间隔着一个必须回答的问题:三维世界里的一个点,是怎么变成图像上某个位置的一个像素的?

这个问题的答案叫相机模型。它是一个确定的几何过程:光线从物体出发,穿过镜头,打在感光芯片上,芯片把光强量化成数字。如果我们能用数学写出这个过程,就能做两件事------正向:已知三维点和相机位姿,预测它会出现在图像的哪里;反向:已知它出现在图像的哪里,反推它在三维空间的位置(或至少是方向)。前者是后面所有"重投影误差"的基础,后者是三角测量和建图的基础。

这一讲还要回答一个相机模型本身回答不了的问题:**深度从哪里来。**一张照片把三维压成了二维,丢掉了一个维度------沿着视线方向的距离。单目相机天生不知道物体离它多远。要把这个维度找回来,要么用两只眼睛(双目),要么主动打光去测(RGB-D),要么靠相机自己运动起来从不同角度看(单目 SLAM 的办法,第 7 讲)。这一讲讲前两种。

最后,这一讲会用 OpenCV 做几个实践:读图、访问像素、去畸变、从双目图像算点云、把多帧 RGB-D 拼成一张地图。这些实践不难,但它们建立的直觉------"图像在内存里是什么样"、"深度图的一个像素值代表多少米"、"点云是怎么拼起来的"------是后面写任何视觉代码的前提。

第一章 针孔相机模型

1.1 小孔成像

最简单的相机模型就是初中物理的小孔成像:在一个暗箱前壁开一个小孔,物体发出的光线穿过小孔,在后壁成一个倒立的像。

把这个装置抽象成几何。建立相机坐标系 O-x-y-z:原点 O 是小孔(光心),z 轴指向相机正前方(光轴方向),x 轴向右,y 轴向下------注意是向下,这和图像坐标的约定一致。后壁(成像平面)与光轴垂直,距离光心的距离是 f,叫焦距。

空间中一点 P = X, Y, Z,它发出的光线穿过 O,打在成像平面上的 P′ = X′, Y′, Z′。由于成像平面在 z = −f 处,Z′ = −f。看 X 方向上的相似三角形:

复制代码
Z / f = −X / X′ = −Y / Y′

负号来自成像是倒立的。这个负号在数学上很烦人,所以通常做一个处理:把成像平面对称地搬到相机前方 z = +f 处。这样像就是正立的,负号消失,几何关系不变:

复制代码
Z / f = X / X′ = Y / Y′

整理得

复制代码
X′ = f X / Z
Y′ = f Y / Z

这就是针孔模型的核心:**三维点的坐标除以它的深度 Z,再乘以焦距,就是它在成像平面上的位置。**除以 Z 这一步是整个相机模型的灵魂------它是非线性的,也是"深度丢失"的根源:X 和 Z 同时放大两倍,X′ 不变。

1.2 从成像平面到像素

成像平面上的 P′ 还是用米(或毫米)为单位的物理坐标。真正的图像是一个像素阵列,所以还要做一步转换。

像素坐标系 o-u-v 定义在成像平面上:原点 o 在图像左上角,u 轴向右,v 轴向下。它和成像平面坐标的区别有两点:一是原点不同(成像平面坐标原点在光轴与平面的交点,像素坐标原点在左上角),二是单位不同(一个是米,一个是像素)。所以从 P′ 到像素 u, v 要经过一次缩放和一次平移:

复制代码
u = α X′ + cx
v = β Y′ + cy

α、β 的单位是"像素/米",表示每米物理长度对应多少个像素(由感光芯片的像元尺寸决定,通常 α ≈ β 但不完全相等);cx、cy 的单位是像素,是光轴与成像平面交点在像素坐标系下的位置,理想情况下是图像中心,实际会有偏差。

把 1.1 节的式子代进来,并令 fx = α f、fy = β f:

复制代码
u = fx · X / Z + cx
v = fy · Y / Z + cy

现在 fx、fy 的单位也是像素。这四个数 fx、fy、cx、cy 叫相机的内参(intrinsics),它们由相机出厂时决定,使用过程中不变(除非你调焦或换镜头)。一台相机买回来,第一件事是用标定板把这四个数标出来;SLAM 数据集都会给出它们。

1.3 内参矩阵

上面的式子里有一个除以 Z,不是线性的。用上一讲的齐次坐标技巧可以把它写成矩阵形式。把像素坐标写成齐次的 u, v, 1,并在两边乘上 Z:

复制代码
      ┌ u ┐   ┌ fx   0   cx ┐ ┌ X ┐
  Z · │ v │ = │  0  fy   cy │ │ Y │   ≜   K P
      └ 1 ┘   └  0   0    1 ┘ └ Z ┘

中间这个 3×3 矩阵 K 叫内参矩阵。你可以验证:右边乘出来第三行就是 Z,前两行是 fx X + cx Z 和 fy Y + cy Z,除以 Z 正好是 u 和 v。

习惯上把左边的 Z 挪到右边,写成

复制代码
p_uv = (1/Z) K P

读法是:三维点 P 先乘以 K,再除以深度 Z,得到齐次像素坐标。有时也省略那个 1/Z,直接写 p_uv = K P,这时等号的含义是"在相差一个尺度因子的意义下相等",齐次坐标允许这样做。

1.4 加上外参:从世界系到像素

上面的 P 是相机坐标系 下的坐标。路标点在地图里是以世界坐标系存的,叫 P_w。两者之间差一个相机位姿------就是前两讲一直在说的 T_cw:

复制代码
P = T_cw P_w = R P_w + t

把它代进投影公式:

复制代码
Z p_uv = K (R P_w + t) = K T_cw P_w

最后一个等号把 P_w 看作齐次坐标、T_cw 看作 4×4 矩阵,并隐含了"乘完之后取前三维"的操作------书里把这个过程叫"齐次坐标到非齐次坐标的转换",公式里不写出来,读者要自己记得。

这个式子就是视觉 SLAM 的观测方程 :给定相机位姿 T_cw(待估计的 x)和路标 P_w(待估计的 y),预测出像素位置 p_uv(观测 z)。相机位姿 T_cw 在这个语境下叫相机的外参 (extrinsics)。内参标定一次就固定;外参随着相机运动不断变化,SLAM 估计的就是外参。

1.5 归一化平面与深度丢失

把投影分成两步看,会更清楚。

第一步:把 P 除以 Z,得到

复制代码
P_c = [X/Z, Y/Z, 1]

这个点在 z = 1 的平面上,这个平面叫归一化平面,P_c 叫归一化坐标。它是"把三维点沿着视线方向推到距离光心 1 米处"的结果。

第二步:对归一化坐标乘 K,得到像素坐标。

这样拆开有两个好处。其一,归一化坐标与内参无关------不同的相机看同一个点,归一化坐标是一样的,只是最后乘的 K 不同;所以很多算法(对极几何、三角化)都在归一化平面上做,先把像素用 K⁻¹ 变回归一化坐标,算完再变回去。其二,它把"深度丢失"看得很明白:P = X, Y, Z 和 λP = λX, λY, λZ 的归一化坐标完全相同。一个像素对应的不是一个点,而是一条从光心出发的射线,射线上所有点都投影到同一个像素。单目相机只知道射线,不知道点在射线上的哪里。

1.6 畸变:真实镜头不是小孔

小孔不透光量太小,真实相机都用透镜。透镜带来两个后果:一是光线不再严格走直线,二是透镜与感光芯片装配时不可能完全平行。它们分别导致径向畸变 和切向畸变。

径向畸变 是透镜形状造成的:光线离光轴越远,弯折越厉害。表现为直线在图像边缘变弯------向外弯叫桶形畸变 (广角镜头常见),向内弯叫枕形畸变。由于它关于光轴对称,畸变量只和到光心的距离 r 有关,用 r 的多项式描述。

切向畸变是透镜与成像平面不平行造成的,它在切线方向上拉扯像点。

畸变在归一化平面上描述。设无畸变的归一化坐标是 x, y,r² = x² + y²,畸变后的归一化坐标是

复制代码
x_distorted = x (1 + k₁ r² + k₂ r⁴ + k₃ r⁶) + 2 p₁ x y + p₂ (r² + 2 x²)
y_distorted = y (1 + k₁ r² + k₂ r⁴ + k₃ r⁶) + p₁ (r² + 2 y²) + 2 p₂ x y

括号里是径向项,k₁、k₂、k₃ 叫径向畸变系数(普通镜头只用 k₁、k₂,鱼眼才需要 k₃);后面两项是切向项,p₁、p₂ 叫切向畸变系数。这五个数和 fx、fy、cx、cy 一起构成相机的完整内参,标定时一起标出。

然后把畸变后的归一化坐标投影到像素:

复制代码
u = fx x_distorted + cx
v = fy y_distorted + cy

有了这个模型,处理畸变有两种策略。一种是先把整张图去畸变 ,之后所有算法都在无畸变图上跑,当成理想针孔相机;另一种是不动图像,只在用到特征点时把点的坐标去畸变。前者简单,视觉 SLAM 实践中最常用;后者省计算,ORB-SLAM 用的是它。

鱼眼和全景相机的畸变太大,上面的多项式模型不够用,要换成 MEI 模型或 Kannala-Brandt 模型(ORB-SLAM3 支持后者)。书里只提了一句,这里不展开。

1.7 小结:一个点的完整旅程

把这一章串起来,一个世界系三维点 P_w 变成像素的全过程是:

  1. 用外参变到相机系:P = R P_w + t;

  2. 除以深度得归一化坐标:P_c = X/Z, Y/Z, 1;

  3. 用畸变模型得畸变后的归一化坐标 x_d, y_d;

  4. 用内参变到像素:u = fx x_d + cx,v = fy y_d + cy。

第 1 步用了前两讲的东西,第 2~4 步是这一讲的东西。SLAM 的后端要优化的是第 1 步里的 R、t 和 P_w,而第 2~4 步在这个过程中是固定的函数。


第二章 深度从哪里来:双目与 RGB-D

2.1 双目相机:用视差测距

人靠两只眼睛判断远近,双目相机也是。两台一模一样的相机水平并排放置,光心距离叫基线 b。同一个空间点 P 在左右两张图上的成像位置不同------它在左图的横坐标是 u_L,右图是 u_R。由于右相机在左相机右边,P 在右图上会偏左,所以 u_R < u_L。两者之差

复制代码
d = u_L − u_R

叫视差(disparity)。

现在看几何。左右相机光心 O_L、O_R 和点 P 构成一个三角形,成像平面在光心前方 f 处。由相似三角形(书中图 5-6),

复制代码
(z − f) / z = (b − u_L + u_R) / b = (b − d) / b

整理得

复制代码
z = f b / d

深度与视差成反比:视差越大,物体越近。f 和 b 已知(标定得到),只要在右图里找到左图某像素对应的点、算出视差 d,就能得到深度。这就是双目测距的全部原理。

公式简单,难点全在"找到对应点"上。这个过程叫立体匹配,要在右图同一行上搜索与左图某像素最相似的位置------因为两相机已经做了"校正"(rectification),对应点一定在同一行,搜索从二维降为一维。但即便如此,在纹理重复(白墙、地砖)或无纹理的地方,匹配仍然经常出错。OpenCV 的 SGBM(半全局块匹配)是常用算法,计算量大,通常要靠 GPU 或 FPGA 才能实时。

双目还有两个固有限制。第一,视差是整数像素 (或亚像素插值后精度有限),而 z = fb/d,d 每变化 1 个像素,近处 z 变化很小、远处 z 变化很大。所以双目对远处物体的深度精度急剧下降,有效测距范围由基线决定------基线越长测得越远,但基线长了相机就大了,近处的物体又可能只在一边的视野里。第二,量化:d 最小是 1 像素,所以能测的最远深度是 f b / 1 = f b;更远的物体视差为 0,相当于无穷远。

2.2 RGB-D 相机:主动测距

双目靠算,RGB-D 靠测。RGB-D 相机在普通彩色相机旁边加一个深度传感器,主动向外发射红外光并接收回波,直接给出每个像素的深度。按原理分两类:

结构光(Structured Light):投射一组已知图案的红外光斑到场景上,用红外相机拍下光斑在物体表面的变形,根据变形程度算出深度。本质上是把双目里"找对应点"这个难题用已知图案替代了。第一代 Kinect、早期 RealSense 用它。

飞行时间(Time-of-Flight,ToF):向场景发射光脉冲,测量光从发出到被反射回来的时间,乘以光速除以二就是距离。第二代 Kinect、现在大多数手机上的深度相机用它。

RGB-D 的输出是一张深度图,和彩色图一一对应:彩色图 (u, v) 处的像素,在深度图同样位置存着它的深度(通常以毫米为单位、16 位无符号整数)。有了深度 Z,加上内参,就能把每个像素还原成相机系下的三维点:

复制代码
X = (u − cx) · Z / fx
Y = (v − cy) · Z / fy

这是针孔模型的反向使用。于是一张 RGB-D 图像直接给出一片带颜色的三维点云,不需要任何计算。

RGB-D 的缺点也很明确:红外发射功率有限,测距范围短 (几米以内);阳光下的红外干扰 让它在室外基本不可用;透明、反光、吸光的材质(玻璃、镜子、黑色绒布)测不到或测错;深度图通常有不少空洞和噪声;相机本身比单目贵、大、耗电。所以 RGB-D SLAM 主要用在室内机器人和 AR 场景。

2.3 三种相机的对比

单目 双目 RGB-D
深度来源 无(靠运动三角化,第 7 讲) 左右视差计算 主动红外测量
尺度 未知(差一个全局缩放) 已知(由基线给出) 已知
计算量 小 大(立体匹配) 小
室外 可 可 基本不可
测距范围 --- 由基线决定,远处精度差 几米以内
成本/体积 最低 中 高

单目 SLAM 的尺度不确定性是它独有的麻烦------上一讲的 Sim(3) 就是为它准备的。但单目便宜、小、功耗低,手机和无人机上几乎只能用它,所以单目 SLAM 一直是研究热点。


第三章 图像在计算机里是什么

3.1 图像是一个二维数组

一张灰度图在数学上是一个函数 I(x, y):输入像素位置,输出亮度。在计算机里它就是一个二维数组,每个元素是一个 0~255 的整数(8 位无符号字符 unsigned char)。宽 w、高 h 的图像占 w × h 个字节。

两个容易搞混的约定:

坐标顺序。 数学上写 I(x, y),x 是横坐标(列)、y 是纵坐标(行)。但二维数组是先行后列 存储的,访问时写 image[y][x]------先给行号再给列号。OpenCV 的 image.at<uchar>(y, x) 同样是 (行, 列) 顺序,而 cv::Point(x, y) 又是 (列, 行) 顺序。写代码时这里出错的概率极高,看到异常的转置图像就先查这个。

**原点与方向。**像素坐标原点在图像左上角,x(u)向右,y(v)向下。这和数学中 y 向上的习惯相反,和 1.1 节相机坐标系 y 轴向下的约定一致------相机模型里把 y 轴定义成向下,正是为了与图像坐标对齐。

内存上,图像是行主序连续存放的:第 0 行的所有像素、接着第 1 行的所有像素......所以按行遍历比按列遍历快得多(缓存友好),这在实时 SLAM 里不是小事。

3.2 三种常见图像类型

灰度图 :每像素 1 字节,CV_8UC1。

深度图 :每像素 2 字节无符号整数(unsigned short,CV_16UC1),单位通常是毫米,最大能表示 65535 mm ≈ 65 m,足够了。TUM 数据集的深度图乘了一个因子 5000,即像素值除以 5000 才是以米为单位的深度------读数据集时一定要查它的缩放因子。

彩色图 :每像素 3 字节,分别是三个颜色通道。OpenCV 默认通道顺序是 B、G、R ,不是 RGB------这是历史遗留,和大多数其他库相反。CV_8UC3。用 image.channels() 可以查通道数。

3.3 OpenCV 基本操作

复制代码
cv::Mat image = cv::imread(argv[1]);        // 读图,默认读成 BGR 三通道
if (image.data == nullptr) { /* 文件不存在 */ }
image.cols, image.rows, image.channels()    // 宽、高、通道数
cv::imshow("image", image);  cv::waitKey(0);

遍历像素的高效写法是用行指针:

复制代码
for (size_t y = 0; y < image.rows; y++) {
    unsigned char *row_ptr = image.ptr<unsigned char>(y);   // 第 y 行首地址
    for (size_t x = 0; x < image.cols; x++) {
        unsigned char *data_ptr = &row_ptr[x * image.channels()];
        for (int c = 0; c != image.channels(); c++) {
            unsigned char data = data_ptr[c];   // 第 (y,x) 像素第 c 通道
        }
    }
}

书里实测遍历一张 640×480 彩色图大约 10 ms 量级(取决于机器和优化等级),这是写实时视觉程序时要心里有数的基准。

cv::Mat 的拷贝陷阱 :cv::Mat image_another = image; 不会 复制图像数据,两个变量指向同一块内存,改一个另一个也变。这是 OpenCV 为了避免大数组频繁拷贝的刻意设计(引用计数)。要真的复制必须用 image.clone()。这个坑和 Eigen "矩阵赋值就是深拷贝"的行为正好相反,两个库混用时特别容易出错。

3.4 实践:手写去畸变

书的 5.3.2 节不用 OpenCV 的 cv::undistort,而是手写去畸变过程,目的是让读者亲手走一遍 1.6 节的公式。思路是反向映射:

  1. 新建一张和原图同样大小的空图,作为去畸变后的图;

  2. 对去畸变图的每一个像素 (u, v):

    • 用内参变回归一化坐标:x = (u − cx)/fx,y = (v − cy)/fy;

    • 用畸变模型算出它加上畸变后会在哪:x_d、y_d;

    • 用内参变回像素:u_d = fx x_d + cx,v_d = fy y_d + cy;

    • 去原图的 (u_d, v_d) 处取像素值(取整或双线性插值),填到去畸变图的 (u, v)。

为什么是"去畸变图的每个像素去原图找",而不是"原图的每个像素算它该去哪"?因为后者会在目标图上留下空洞(多个原像素映到同一处、有些位置没人映)。反向映射保证目标图每个像素都有值。这是图像几何变换的通用做法,后面光流和直接法的图像变形也是这样。


第四章 实践:从图像到三维

4.1 双目:从视差到点云

书的 5.4.1 节用一对双目图像(来自 KITTI 数据集,已校正)生成点云:

  1. 用 cv::StereoSGBM 计算视差图。几个关键参数:minDisparity 和 numDisparities 限定搜索范围(后者必须是 16 的倍数),blockSize 是匹配窗口大小,P1、P2 是平滑惩罚。参数需要按场景调,书给的一组是经验值。

  2. SGBM 输出的视差是放大了 16 倍的定点数,要除以 16 转成浮点。

  3. 对每个视差有效(d > 0 且在范围内)的像素,先算深度 z = f b / d,再用 2.2 节的反投影公式得到 X、Y,与灰度一起存为一个点。

  4. 用 Pangolin 显示点云。

视差图里会有大片无效区域(黑色)------无纹理或遮挡处匹配不到。这是双目的常态,不是 bug。

4.2 RGB-D:多帧点云拼接

书的 5.4.2 节是整讲最接近 SLAM 的一个实践:有 5 对彩色图和深度图,以及每帧对应的相机位姿 T_wc(TUM 格式,四元数实部在后,用 Eigen::Isometry3d 或 Sophus::SE3d 存),把 5 帧的点云拼成一张全局地图。

对第 i 帧的每个像素 (u, v):

  1. 读深度 d = depth(v, u),为 0 表示无效,跳过;

  2. 相机系三维点:Z = d / 5000(TUM 缩放因子),X = (u − cx) Z / fx,Y = (v − cy) Z / fy;

  3. 变到世界系:P_w = T_wc,i · X, Y, Z;

  4. 取彩色图同位置的 BGR 值作为颜色,存入全局点云。

5 帧全部处理完,全局点云就是这五个视角拼起来的场景。这个实践的意义在于:它就是 SLAM 中"建图"那一环的最简版本------如果位姿是对的,点云就能严丝合缝地拼起来;如果位姿错了,墙会出现两层、桌子会错位。书给的位姿是真值,所以拼出来很干净;到了第 12 讲,位姿换成 SLAM 自己估计的,你就能直观地看到估计误差长什么样。

它也示范了"相机系 → 世界系"的方向:这里用的是 T_wc(相机到世界),因为我们手上是相机系的点、要去世界系。如果数据集给的是 T_cw,要先求逆。这是上一册 2.6 节的内容在实际代码里的样子。


第五章 预告:投影函数的雅可比

上一讲推出了"相机系三维点对位姿的导数":∂(T p)/∂δξ = I, −q\^(取前三行)。这一讲的投影函数提供了雅可比链条的另一半------像素坐标对相机系三维点的导数。

设相机系点 P = X, Y, Z(已去畸变,或假设无畸变),像素

复制代码
u = fx X / Z + cx
v = fy Y / Z + cy

对 X、Y、Z 分别求偏导(注意 Z 在分母上):

复制代码
                    ┌  fx/Z     0     −fx X/Z² ┐
  ∂[u, v] / ∂P  =   │                          │     (2×3)
                    └   0      fy/Z   −fy Y/Z² ┘

把它和上一讲的 3×6 矩阵 I, −P\^(P 对 δξ 的导数,取前三行)链式相乘,就得到像素对位姿的 2×6 雅可比:

复制代码
∂[u, v] / ∂δξ = ( ∂[u, v]/∂P ) · ( ∂P/∂δξ ) = (2×3) · (3×6) = 2×6

这个 2×6 矩阵是第 7 讲 PnP 的 BA 解法和第 9 讲 BA 的核心,书会在 7.7.3 节把它完整写出来。这里先把它的来源说清楚:它是"投影"和"位姿变换"两步各自雅可比的乘积,前者来自这一讲,后者来自上一讲。

同样地,像素对路标点世界坐标 P_w 的导数是 (∂u, v/∂P) · R,因为 P = R P_w + t,∂P/∂P_w = R。这个 2×3 矩阵是 BA 里优化路标点时用的。

至此,视觉 SLAM 几何部分的全部构件都齐了:位姿(第 3 讲)、位姿的导数与更新(第 4 讲)、观测模型及其导数(第 5 讲)。下一讲(第 6 讲)讲优化器本身------有了残差和雅可比之后,高斯牛顿和 LM 怎么一步步把误差压下去。

相关推荐
正经教主1 小时前
【FDE系列】阶段3:Day 71:高级检索 — 查询改写与 HyDE
人工智能·rag·fde
天空之城--1 小时前
Android一周动态:Android 18首次官宣、Compose Material3 1.4转正(5趋势+5资讯)
android·人工智能·flutter·架构·android jetpack
ZzT1 小时前
Claude 干活的时候,在终端里打砖块
人工智能·ai编程·claude
AI日报派送佬1 小时前
2026年10月3日AI行业日报|系统权限安全全面收紧,AI算力与模型工程化落地提速
人工智能·openai·英伟达·ai日报·智能体技术·ai安全管控·ai开源生态
Ai-_Man1 小时前
请问豆包的智能体聊天记录该怎么弄
开发语言·前端·javascript·人工智能·小程序·ecmascript·电脑
richard_yuu1 小时前
OpenCV 实战第 2 篇:cv::Mat 内存模型,引用计数、ROI 与连续性
人工智能·opencv·计算机视觉
艺杯羹1 小时前
Anthropic突袭发布Claude Code Mods!用TypeScript编写智能体中间件:告别AI误删与失控
人工智能·typescript·系统架构
Yolanda_20221 小时前
18.神经网络-卷积层
人工智能·神经网络·cnn
合米AI SOP系统1 小时前
螺丝细小难识别?合米科技 AI SOP 视觉依靠动作识别攻克微小工件核验难题.
人工智能·科技·计算机视觉