计算机视觉基础:真正理解小孔成像与 fx、fy、cx、cy

相机内参最容易出现的学习状态是:知道矩阵里有四个数,代码也能跑,但说不清某个数变大后画面会怎么变,更不知道裁剪图片后为什么定位突然偏了。

这篇笔记从一条主线展开:三维点先变成一条方向,再把这个方向换算成像素。 理解这两步,内参就不再是一组需要死记的参数。

配套阅读:dToF 到深度图:range、Z、FOV 与裁剪。那篇将这里的几何用到测距、点云和图像预处理上。

1. 先约定:我们到底在哪个坐标系里说话

本文采用常见的相机 optical 坐标系:原点是投影中心 OO,XX 向右,YY 向下,ZZ 向前;像素坐标 uu 向右、vv 向下。

名字 例子 单位 代表什么
相机三维坐标 (X,Y,Z)=(0.2,0.1,2)(X,Y,Z)=(0.2,0.1,2) 米 点相对相机的位置
归一化像平面坐标 (xn,yn)=(0.1,0.05)(x_n,y_n)=(0.1,0.05) 无量纲 光线相对光轴的斜率
像素坐标 (u,v)=(380,270)(u,v)=(380,270) 像素 图像上落在哪里
图像尺寸 W=640,H=480W=640,H=480 像素 图像有多少列、多少行

本文用整数表示像素中心:左上像素中心为 (0,0)(0,0),右下为 (W−1,H−1)(W-1,H-1)。因此整幅图像的外边缘是 −0.5-0.5 和 W−0.5W-0.5,而不是两个像素中心。后文算 FOV、做最近像素投影时会保持这个约定。坐标约定可参照 RealSense 的投影说明。

注意:机器人本体经常使用“前、左、上”轴,不能把本体坐标直接代入相机投影式;ROS 中名字带 camera_link 也不自动意味着它就是 optical 坐标系。参见 ROS REP 103。

2. 小孔成像为什么能用相似三角形

把相机暂时想成一个很小的孔:空间点发出的光穿过同一个孔,落到后面的成像平面上。

真实成像平面在孔后形成倒像,虚拟前像平面用相似三角形推导正号投影
图中 +Z 向右、+Y 向下;虚拟像平面是计算约定,不是实际传感器。手机端可横向滑动查看。

如果真实成像平面位于孔后面,即 Z=−fZ=-f,相似三角形给出:

xreal=−fXZ,yreal=−fYZx_{\mathrm{real}}=-f\frac{X}{Z},\qquad y_{\mathrm{real}}=-f\frac{Y}{Z}

负号就是“倒像”。计算机视觉常把计算用的像平面放到孔前面 Z=fZ=f,使用一个虚拟前像平面,于是:

x=fXZ,y=fYZx=f\frac{X}{Z},\qquad y=f\frac{Y}{Z}

不是物理传感器真的搬到了前面,而是采用了更方便的投影表示。实际镜头比小孔复杂;这里的投影中心与焦距是几何模型的等效量,不要把整个标定模型理解成拿尺子量镜头外壳。

先令虚拟平面的距离为 1,得到:

xn=XZ,yn=YZx_n=\frac{X}{Z},\qquad y_n=\frac{Y}{Z}

这两个数只表示方向。例如 (0.2,0.1,2)(0.2,0.1,2) 和 (0.4,0.2,4)(0.4,0.2,4) 的归一化坐标相同,所以它们在同一条光线上,投影也相同。

单张图像损失的,正是沿这条光线的距离。

3. 从“方向”到“像素”:四个内参各干什么

上一节的 x=fX/Zx=fX/Z 还没有回答一个工程问题:这个 xx 究竟是毫米,还是图像中的第几列?如果 ff 使用毫米,那么 xx 也是像平面上的毫米距离;但程序处理的是像素数组,不能把“0.36 mm”直接当成“第 0.36 个像素”。

因此完整链条其实有四层:

层次 水平方向的量 单位 含义
空间方向 xn=X/Zx_n=X/Z 无量纲 光线相对光轴的斜率
物理像平面 xmm=fmmxnx_{\mathrm{mm}}=f_{\mathrm{mm}}x_n mm 成像点距离光轴多少毫米
像素网格 Δu=xmm/px\Delta u=x_{\mathrm{mm}}/p_x pixel 成像点距离主点多少个横向像素间距
图像坐标 u=Δu+cxu=\Delta u+c_x pixel 加上像素坐标原点后,落在图像的哪一列

竖直方向完全相同,只需把 X,xn,xmm,px,u,cxX,x_n,x_{\mathrm{mm}},p_x,u,c_x 换成 Y,yn,ymm,py,v,cyY,y_n,y_{\mathrm{mm}},p_y,v,c_y。把中间两步合并,才得到代码里常见的无畸变、零斜切针孔公式:

u=fxXZ+cx,v=fyYZ+cy\boxed{u=f_x\frac{X}{Z}+c_x},\qquad \boxed{v=f_y\frac{Y}{Z}+c_y}

可以把它读成一句话:先算方向斜率,用 fx,fyf_x,f_y 把方向换成像素偏移,最后移动到主点位置。 这与 OpenCV 相机模型 的定义一致。

3.1 fx、fy:把物理焦距从毫米换成像素

先只看水平截面。若等效物理焦距为 fmmf_{\mathrm{mm}},三维点在物理像平面上的横向位置是:

xmm=fmmXZx_{\mathrm{mm}}=f_{\mathrm{mm}}\frac{X}{Z}

这里的 X/ZX/Z 没有单位,因此结果跟 fmmf_{\mathrm{mm}} 一样是毫米。它回答的是“投影点距离光轴多少毫米”,还不是图像坐标。

设传感器横向相邻像素中心的物理间距(pixel pitch)为 pxp_x,单位是 mm/pixel\mathrm{mm/pixel}。用毫米距离除以每个像素间距,才得到相对主点的像素偏移:

Δu=xmmpx=fmmpxXZ\Delta u =\frac{x_{\mathrm{mm}}}{p_x} =\frac{f_{\mathrm{mm}}}{p_x}\frac{X}{Z}

因此把括号中的换算系数定义为:

fx=fmmpx,fy=fmmpyf_x=\frac{f_{\mathrm{mm}}}{p_x},\qquad f_y=\frac{f_{\mathrm{mm}}}{p_y}

单位也会自然约掉:

mmmm/pixel=pixel\frac{\mathrm{mm}}{\mathrm{mm/pixel}}=\mathrm{pixel}

所以 fxf_x 可以直观理解成:一个物理焦距长度里,横向能排下多少个像素间距。 它不是说镜头“长 600 个像素”,而是把同一个焦距改用像素网格来计量。

例如:

fmm=3.6 mm,px=0.006 mm/pixelf_{\mathrm{mm}}=3.6\,\mathrm{mm},\qquad p_x=0.006\,\mathrm{mm/pixel}

那么 fx=3.6/0.006=600 pixelf_x=3.6/0.006=600\,\mathrm{pixel}。再取 X=0.2 m,Z=2 mX=0.2\,\mathrm{m},Z=2\,\mathrm{m},完整计算是:

XZ=0.1⟹xmm=3.6×0.1=0.36 mm\frac{X}{Z}=0.1 \quad\Longrightarrow\quad x_{\mathrm{mm}}=3.6\times0.1=0.36\,\mathrm{mm}

Δu=0.360.006=60 pixel⟹u=60+cx\Delta u=\frac{0.36}{0.006}=60\,\mathrm{pixel} \quad\Longrightarrow\quad u=60+c_x

若 cx=320c_x=320,最终就是 u=380u=380。这一步清楚地区分了两个问题:xmm=0.36 mmx_{\mathrm{mm}}=0.36\,\mathrm{mm} 是传感器上的物理位置,u=380 pixelu=380\,\mathrm{pixel} 才是程序访问图像时使用的列坐标。若跳过像素尺寸,直接把毫米与 cxc_x 相加,单位本身就不成立。

从角度看同一件事,令水平光线夹角为 θx\theta_x:

tan⁡θx=XZ,Δu=fxtan⁡θx\tan\theta_x=\frac{X}{Z},\qquad \Delta u=f_x\tan\theta_x

因此 fxf_x 表示“给定视线角度会展开成多少像素”。在光轴附近,移动 1 pixel 对应的角度近似为 1/fx1/f_x 弧度;当 fx=600f_x=600 时,光轴旁一个像素约为 arctan⁡(1/600)≈0.0955∘\arctan(1/600)\approx0.0955^\circ。但整幅图上的每像素角度并非严格恒定,因为准确关系是 θx=arctan⁡((u−cx)/fx)\theta_x=\arctan((u-c_x)/f_x)。

这里不是“镜头有两个独立的物理焦距”。同一个光学投影在横、纵像素尺度下分别表示,标定、像素形状、非等比例缩放等都可能使 fx≠fyf_x\ne f_y。

为什么一定要引入 fx,fyf_x,f_y? 因为 fmmX/Zf_{\mathrm{mm}}X/Z 的输出是物理像平面上的毫米位置,而视觉算法最终必须得到图像数组中的像素位置;fx,fyf_x,f_y 正是连接这两个世界的换算尺度。只保存毫米焦距还不够:同一个 fmmf_{\mathrm{mm}} 搭配不同 pixel pitch,会得到不同的像素投影;图像经过缩放后,镜头的毫米焦距没有变化,fx,fyf_x,f_y 却必须跟着像素坐标尺度变化。实际标定给出的 fx,fyf_x,f_y 是当前分辨率与成像管线下的有效参数,不应总是假定它们能由镜头铭牌和传感器规格精确相除得到。

保持图像尺寸与其余内参不变,并且主点位于图像内时:

  • fxf_x 越大,同样的水平角度占越多像素,水平视场越窄。
  • fyf_y 越大,同样的竖直角度占越多像素,垂直视场越窄。
  • 单独把 fxf_x 改大,同一角度会占据更多横向像素,投影横向拉宽;这不是把相机向右移动。

例如 X/Z=0.1X/Z=0.1,fx=600f_x=600 时距离主点 60 像素,fx=900f_x=900 时是 90 像素。它控制的是相对主点的放大倍率。

3.2 cx、cy:光轴落在图像哪里

让三维点落在光轴上,即 X=Y=0X=Y=0,立刻得到:

u=cx,v=cyu=c_x,\qquad v=c_y

因此 (cx,cy)(c_x,c_y) 是主点:理想模型中,光轴与像平面的交点在像素坐标里的位置。

主点不是三维相机中心。相机中心 OO 在空间里;主点在图像平面上。主点也不保证恰好位于图像几何中心:装配、标定以及 ROI 裁剪都会影响它。

对于 640×480 图像,像素中心集合的几何中心是 (319.5,239.5)(319.5,239.5)。工程资料也常用 (320,240)(320,240) 作为近似。本文例题明确使用 cx=320,cy=240c_x=320,c_y=240,把它视为一组示例内参,不混用这两个约定。

只把 cxc_x 加 20,所有投影像素的 uu 都加 20,但点之间的像素间距不变;改变相机真实位置则通常会产生与物体深度有关的位移,两者不是同一件事。

3.3 K:把四个数放进一张矩阵

K=[fx0cx0fycy001],Z[uv1]=K[XYZ]K= \begin{bmatrix} f_x&0&c_x\\ 0&f_y&c_y\\ 0&0&1 \end{bmatrix},\qquad Z\begin{bmatrix}u\\v\\1\end{bmatrix} =K\begin{bmatrix}X\\Y\\Z\end{bmatrix}

左边的 ZZ 不能丢掉:右侧最后一个分量是 ZZ,需要除以它,才得到像素齐次坐标 [u,v,1]T[u,v,1]^T。

更一般的内参矩阵可以在第一行第二列含斜切参数 ss,此时 u=fxxn+syn+cxu=f_xx_n+sy_n+c_x。本文实验和代码假定 s=0s=0,不包含鱼眼、畸变或滚动快门模型。

4. 做一遍完整数值计算

设:

K=[60003200600240001],PC=[0.20.12]mK=\begin{bmatrix}600&0&320\\0&600&240\\0&0&1\end{bmatrix},\qquad P_C=\begin{bmatrix}0.2\\0.1\\2\end{bmatrix}\mathrm{m}

先得到方向 (0.1,0.05)(0.1,0.05),再换算像素:

u=600×0.1+320=380,v=600×0.05+240=270u=600\times0.1+320=380,\qquad v=600\times0.05+240=270

现在做三个思想实验:

  1. X,YX,Y 不变,ZZ 从 2 米变 4 米:投影变成 (350,255)(350,255),向主点靠近。
  2. X,Y,ZX,Y,Z 都乘 2:投影仍是 (380,270)(380,270),因为方向没变。
  3. 点不动,fx,fyf_x,f_y 都乘 2:投影变成 (440,300)(440,300),离主点更远。

这也解释了为什么“相机后退”和“镜头变焦”不能在所有三维场景里互相代替:不同深度物体的相对大小、遮挡与透视关系可能不同。

5. 亲手拖动内参,看公式怎么变成画面

实验固定一个与像平面平行、边长 0.6 米的正方形,其中心是 P=(0.2,0.1,Z)P=(0.2,0.1,Z)。先猜结果,再拖动滑块:单独改 fxf_x、单独改 cxc_x、最后改 ZZ。

(0, 0)
  • 蓝框:0.6 m 正方形
  • 红点:P 的投影
  • 金色十字:主点

P 的投影:(380.00, 270.00) px;正方形投影尺寸:180.00 × 180.00 px。

水平视场角:56.14°;垂直视场角:43.60°。

仅模拟无畸变针孔投影,不是对真实相机重新标定;修改主点不会移动真实相机。所有计算在本页本地完成,无自动播放。

拖动后应观察到:改变主点会整体平移图形,改变焦距会改变图形尺寸,增加距离会使图形缩小。由于画面边界固定,主点偏移也会改变左右覆盖角度,不应始终套用“左右各一半 FOV”。

6. 反投影:一个像素究竟能恢复什么

把公式倒过来:

q=K−1[uv1]=[(u−cx)/fx(v−cy)/fy1]q=K^{-1}\begin{bmatrix}u\\v\\1\end{bmatrix} =\begin{bmatrix}(u-c_x)/f_x\\(v-c_y)/f_y\\1\end{bmatrix}

这是一个 zz 分量为 1 的方向代表向量,不是单位向量,也不是已经恢复好的三维点。

若额外知道光轴深度 ZZ,才有:

PC=Zq=[(u−cx)Z/fx(v−cy)Z/fyZ]P_C=Zq =\begin{bmatrix}(u-c_x)Z/f_x\\(v-c_y)Z/f_y\\Z\end{bmatrix}

对 (380,270)(380,270) 和 Z=2Z=2,恢复 (0.2,0.1,2)(0.2,0.1,2) 米。

如果设备给的是沿光线的距离 r=∥PC∥r=\|P_C\|,必须先归一化方向:

PC=rq∥q∥,Z=r∥q∥P_C=r\frac{q}{\|q\|},\qquad Z=\frac{r}{\|q\|}

例题里 Z=2Z=2 米,但 r=0.22+0.12+22≈2.01246r=\sqrt{0.2^2+0.1^2+2^2}\approx2.01246 米。中心附近差别小,离光轴越远差别越大。后续 dToF 笔记 会用 45° 光线说明为什么把两者混用会让平墙弯曲。

7. 内参、外参、畸变:三件不同的事

7.1 外参先把点送到相机坐标系

如果点在世界坐标系里,应先做:

PC=RCWPW+tCWP_C=R_{CW}P_W+t_{CW}

再使用相机投影。下标表示从 WW 变到 CC,不要只写一个不注明方向的 T。

这里 tCWt_{CW} 是世界原点在相机坐标中的位置,不是相机在世界坐标中的位置。相机中心的世界坐标是:

CW=−RCWTtCWC_W=-R_{CW}^{T}t_{CW}

只要令 PC=0P_C=0,就能推导出来。内参回答“方向如何变成像素”,外参回答“同一个空间点换个坐标系怎么表示”。求物体位姿时可参考 OpenCV solvePnP 坐标约定。

7.2 畸变不是改一改 fx 就能消除

针孔投影会让空间直线成像为直线,而真实镜头可能产生径向、切向畸变。以常见模型为例,归一化坐标半径平方为 ρ2=xn2+yn2\rho^2=x_n^2+y_n^2,径向部分会含有 1+k1ρ2+k2ρ4+⋯1+k_1\rho^2+k_2\rho^4+\cdots,变形量随位置改变。

所以一般流程是:

PW⟶PC⟶(xn,yn)⟶畸变坐标⟶(u,v)P_W\longrightarrow P_C\longrightarrow(x_n,y_n) \longrightarrow\text{畸变坐标}\longrightarrow(u,v)

不能把原始畸变图的像素直接代入本文无畸变反投影式。 应使用设备 SDK 的反投影函数,或依据正确的模型去畸变。OpenCV undistortPoints 不传 P 时通常输出归一化坐标;传入新相机矩阵时则输出相应像素坐标,不要再重复归一化。参见 OpenCV 标定与去畸变接口。

原图 K + D、矫正后图像的新内参、对齐到 RGB 后的目标内参,是不同的数据契约。ROS CameraInfo 也区分了原始图像的 K、畸变 D 与矫正后的投影矩阵 P;见 消息定义。

8. 如何得到可信的内参,而不是“猜一组能用的数”

从 FOV 和分辨率可以估算焦距,但这不是对真实镜头的完整标定。实践上应当:

  1. 先固定分辨率、裁剪模式、对焦/变焦状态,保存这些配置。
  2. 使用尺寸可信、足够平整的标定板,采集覆盖中心和边缘、含不同距离与倾斜角度的清晰图像,不要只在中心正对着拍。
  3. 按镜头类型选择模型并拟合内参与畸变,检查每张图的角点和重投影残差,而不只看一个平均数。
  4. 留出未参与拟合的图像检验;观察直线矫正效果,并用已知几何或距离检查最终定位结果。
  5. 图像预处理或相机工作模式变化后,确认内参是否需要相应更新或重新标定。

重投影误差是“已知三维标定点投回图像后,与检测到的角点差多少像素”。低误差是重要证据,却不自动保证所有位置的三维尺度都准确;标定板尺寸填错、数据覆盖不足、运动模糊等仍可能出问题。流程可参照 OpenCV 相机标定教程。

9. 裁剪和缩放后,为什么还要改 K

假设从原图左上 (x0,y0)(x_0,y_0) 开始裁剪,原像素在新图中的坐标是:

u′=u−x0,v′=v−y0u'=u-x_0,\qquad v'=v-y_0

因此焦距不变,主点变成 (cx−x0,cy−y0)(c_x-x_0,c_y-y_0)。这是图像坐标原点变了,不是镜头变了。

若再缩放,则焦距也要按横、纵比例变化;主点还要考虑具体重采样的像素中心约定。深度值如果以米表示,不能跟着图像尺寸一起乘缩放倍数。

完整的偏心 FOV、ROI、resize 半像素修正、letterbox 公式与算例,放在 dToF 到深度图的第 5—7 节,避免在两篇笔记里分别维护一套不同口径。

10. 可运行例题与自测

下载 camera_geometry_demo.py,在下载目录运行。只需要 Python 3.9+ 标准库,无需相机硬件,也不用安装 NumPy。

python3 camera_geometry_demo.py --self-test

也可以在同目录中导入:

from camera_geometry_demo import CameraModel

camera = CameraModel(640, 480, 600, 600, 320, 240)
print(camera.project((0.2, 0.1, 2.0))) # (380.0, 270.0)
print(camera.depth_to_point(380, 270, 2.0)) # (0.2, 0.1, 2.0)
print(camera.ray(380, 270)) # (0.1, 0.05, 1.0),不是单位向量

先不看答案,回答下面四个问题:

  1. 为什么像素 (cx,cy)(c_x,c_y) 无法告诉我们物体离相机几米?
  2. 保持三维点不动,把 cxc_x 加 10 和把 fxf_x 加 10,结果相同吗?
  3. 当 X/Z=1,Y=0X/Z=1,Y=0 时,轴向深度为 2 米的点,径向距离是多少?
  4. 裁掉左边 100 列后仍用原 cxc_x 反投影,会错在哪里?

答案:主点只对应光轴方向;改 cxc_x 是统一平移,改 fxf_x 的位移取决于 X/ZX/Z;径向距离为 222\sqrt{2} 米;应改成 cx−100c_x-100,否则归一化水平坐标少了 100/fx100/f_x,三维 XX 的误差为 −100Z/fx-100Z/f_x。

11. 接下来把基础接回机器人应用

最终要记住的不是一张矩阵,而是这条链:坐标变换 → 方向 → 镜头模型 → 像素;反过来则还需要一个有明确语义的深度。

知识共享许可协议