做了几年视觉测量项目之后,我渐渐发现一个反直觉的事实:相机成像最核心的问题,不是“怎么把世界坐标算成像素坐标”,而是“怎么从像素坐标里,找回那个不受镜头厚度、传感器尺寸、分辨率影响的标准化中间坐标”。
这个中间坐标,就是归一化平面坐标。回到成像侧的像素时,起决定作用的则是内参矩阵。很多人把内参矩阵当成“相机标定跑出来的一个矩阵”,背完公式、调完OpenCV就收工。可如果不搞明白它到底在做什么,归一化平面为什么是所有视觉算法的默认舞台,那后面做姿态估计、双目测距、三维重建时,你大概率会被一堆叫不出名字的精度问题和离奇报错卡住。
这篇文章我就用“从像素还原到归一化坐标”这条主线,把针孔模型、内参矩阵、畸变模型和实际代码串起来聊清楚。适合刚学完相机标定准备做SLAM的朋友,也适合已经在做3D视觉但坐标系关系还模棱两可的开发者——把这层纸捅破,后面很多接口文档看起来会舒服得多。
1. 先搞清楚一个问题:像素坐标到底能不能直接换算成三维坐标?
1.1 针孔模型:一张图讲清四个坐标系的关系
很多教材一上来就抛针孔模型公式,但初学者最容易忽略的是:针孔模型本质上是在回答“一束光在穿过镜头中心之后,打在传感器哪个位置”。这里的镜头中心就是光心,也叫相机坐标系原点。整个成像过程可以拆成四个坐标系之间的变换:世界坐标系、相机坐标系、图像物理坐标系、像素坐标系。
世界坐标系是你随便选的,比如标定板左上角的角点,或者机器人底盘中心。相机坐标系以光心为原点,Z轴指向相机正前方。图像物理坐标系在传感器平面内,单位是毫米,原点通常在光轴与传感器平面的交点。像素坐标系单位是像素,原点一般在图像的左上角。
这四个坐标系的关系是一层套一层的:世界坐标通过外参(旋转R和平移t)变换到相机坐标,相机坐标通过透视投影变换到图像物理坐标,图像物理坐标再通过内参矩阵里的像素缩放和平移,变成你最终看到的像素坐标。外参描述的是“相机在世界中的位置”,内参描述的是“传感器怎么把物理图像变成像素图像”。
这里有个很容易忽略的细节:透视投影这一步,数学上就是在做“除以Z”。一个三维点 (P_c(X_c, Y_c, Z_c)) 投影到图像物理平面上的坐标是 ((fX_c/Z_c, fY_c/Z_c))。这个“除以Z”的动作,就是把三维信息压成二维信息,也是深度信息丢失的地方。之所以要引入归一化平面,正是为了先把“除以Z”这件事单独拎出来,不被焦距、像素尺寸这些相机自身属性干扰。
1.2 为什么投影的第一步是“归一化”,而不是“直接出像素”
你可能会想:既然最终要的是像素坐标,那为什么不一步到位?理论上一台理想相机确实可以一步到位,但问题是“一步到位”会把相机本身的属性混进几何关系里。
举个例子,两台相机分辨率不同、传感器大小不同,同一个三维点投影到两台相机上的像素坐标肯定不一样。可如果你先把它投影到归一化平面,得到的是两个完全相同的坐标 ((X_c/Z_c, Y_c/Z_c))。这个坐标与相机无关,它只取决于三维点相对于相机的方向。方向相同、距离不同的点,归一化坐标完全相同。这一点是后续所有多视角几何算法的根基。
所以视觉算法界形成了一套默契:所有几何计算都尽量在归一化坐标下做,只有最后输出像素时,才用内参矩阵做一次线性变换。这样做的好处非常直观——算法不再需要关心你用的是工业相机还是手机镜头,也不关心传感器尺寸多大,大家在一个统一的“标准舞台”上讨论问题。
2. 内参矩阵K:一部“物理单位转像素单位”的全套配置
2.1 fx、fy、cx、cy、s这五个参数各管什么事
内参矩阵通常写作:
[ K = \begin{bmatrix} fx & s & cx \ 0 & fy & cy \ 0 & 0 & 1 \end{bmatrix} ]
这五个参数各有各的物理含义。fx、fy不是焦距f本身,而是“焦距的像素度量”。如果传感器像素是正方形的,且x、y两方向的像素密度一致,那fx和fy相等。可实际中像素往往不是完美正方形,或者感光芯片在贴装时有微小拉伸,导致两个方向的等效焦距有差异。
cx、cy是主点坐标,也就是光轴穿过传感器平面的那个点在像素坐标系中的位置。理想情况下它在图像正中心,但镜头安装偏心、传感器切割误差都会让主点偏移。很多算法在初始化时直接假设主点在图像中心,如果镜头畸变校正不彻底,这个假设会在后续计算中埋下隐患。
倾斜因子s则更微妙,它描述的是像素行与列之间是否严格垂直。大多数消费级相机的s非常接近0,很多标定程序直接忽略它。但如果你做的是高精度测量,s不校准会导致重投影误差出现系统性偏置。我见过一些项目用Matlab标定出的s值不为零,在OpenCV里却把它强制置零,结果三角化精度下降了一个量级。
2.2 一个数值算例:内参矩阵如何把归一化点变成像素点
假设某相机标定后得到:fx=800,fy=810,cx=320,cy=240,s=0。一个三维点在相机坐标系下的坐标是 (0.5, 0.3, 2.0)。它的归一化坐标就是 (0.25, 0.15, 1.0)。像素坐标计算如下:
[ u = 800 \times 0.25 + 0 + 320 = 520 ] [ v = 810 \times 0.15 + 0 + 240 = 361.5 ]
注意fy与fx不同,所以同样的归一化x、y,在像素域里被拉伸的程度不一样。如果这时候你用fx代替fy去算v坐标,误差是 (810-800)×0.15 = 1.5像素。看起来不大,但在多视角匹配时,1.5像素的偏差足以让极线约束出现明显误差,误匹配率直线上升。
这个算例还说明一件事:内参矩阵本质上是把点从归一化坐标空间映射到像素坐标空间的一个线性变换。它的输入是归一化点,输出是像素点。反过来,从像素点还原归一化点时,你做的就是乘以K的逆矩阵。
2.3 标定得到的K,怎么验证合理性
拿到标定结果后,不要急着扔进算法里。先做三件事:
第一,看fx和fy是否在合理范围。可以用“分辨率宽度/传感器物理宽度×焦距毫米值”粗算一下等效像素焦距。比如传感器宽6.4mm、分辨率宽1280,镜头焦距8mm,那fx理论值约为1600。如果标定结果是800或者3200,多半是标定板图像数量不够,或者板子姿态覆盖不全。
第二,看主点是否偏离中心太多。一般偏差在几十像素内算正常,如果cx、cy偏离中心超过图像宽度的20%,就要怀疑标定板角点提取有误,或者镜头畸变模型不匹配。
第三,看重投影误差。OpenCV标定输出的RMS误差通常在0.1到0.5像素之间。超过1像素就要警惕,说明标定图片要么模糊、要么板子太倾斜、要么畸变模型没选对。
3. 归一化平面:视觉算法真正运行的“标准舞台”
3.1 归一化平面到底是什么:z=1处的一张虚拟成像面
字面上看,归一化平面就是相机坐标系下 (Z=1) 的那个平面。一个三维点 (P_c) 投影到这个平面上,坐标变成 ((X_c/Z_c, Y_c/Z_c, 1))。这张平面是虚拟的,相机里并不存在物理对应的传感器,但它非常有用——你可以把它理解为“焦距为1像素的理想相机”的成像平面。
为什么要特意构造这样一个虚拟平面?因为投影几何里最难处理的就是那个“除以Z”。只要把“除以Z”这个非线性操作固定下来,剩下的从归一化平面到像素平面的变换就是纯粹的线性变换,可以用矩阵乘法完成。线性变换意味着你可以自由地求逆、复合、分解,而不会被非线性操作搅得焦头烂额。
从另一个角度看,归一化平面坐标其实是一个方向向量。点 (0.25, 0.15, 1.0) 和点 (0.5, 0.3, 2.0) 的归一化坐标相同,因为它们在同一条射线上。归一化坐标只关心方向,不关心距离,恰好符合透视成像的物理事实:一个点在图像上的位置只由方向决定,与深度无关。
3.2 本质矩阵、PnP、三角化为什么都默认使用归一化坐标
先看对极几何。本质矩阵E描述的是两帧图像之间对应点的约束关系,它的定义式是:
[ p_2^T E p_1 = 0 ]
这里的 (p_1)、(p_2) 是两帧图像中对应匹配点的归一化坐标。为什么不是像素坐标?因为本质矩阵分解出来是旋转和平移,这些量定义在相机坐标系下,而不是像素坐标系下。如果你直接用像素坐标算,算出来的矩阵叫基础矩阵F,它混入了内参信息,需要再乘上 (K^{-1}) 才能得到本质矩阵。
再看PnP。求解相机位姿时,经典做法是先把2D像素坐标反投影成归一化坐标,然后建立3D点与2D归一化点之间的对应关系,再求解旋转和平移。如果你直接用像素坐标做PnP,内参会被吸收进位姿解里,导致标定误差直接污染位姿结果。较好的做法是先归一化再求解,让优化问题关注纯几何关系。
三角化也一样。两条射线在归一化坐标下求交点,本质上是两个方向向量做叉积和最小二乘。如果射线方向向量被像素坐标污染,三角化出来的深度会出现系统性偏差,尤其在基线和深度比值很小的场景下,偏差会被放大到离谱的程度。
3.3 归一化平面与内参矩阵的关系式
把前面的关系串起来,可以写出一个极简的成像链条:
[ \lambda \begin{bmatrix} u \ v \ 1 \end{bmatrix} = K \begin{bmatrix} X_c / Z_c \ Y_c / Z_c \ 1 \end{bmatrix} ]
这里的 (\lambda) 是尺度因子。读法是这样的:先求归一化坐标,再用内参矩阵做线性变换,得到像素坐标。反过来,从像素坐标求归一化坐标就是:
[ \begin{bmatrix} X_c / Z_c \ Y_c / Z_c \ 1 \end{bmatrix} = K^{-1} \begin{bmatrix} u \ v \ 1 \end{bmatrix} ]
注意,这一步反求出来的归一化坐标是带尺度歧义的——它对应的是那条射线上任意一点,方向确定但深度未知。这也是为什么单目相机无法从一帧图像直接恢复绝对深度,只能恢复方向。
4. 畸变处理要摆对位置:去畸变其实发生在归一化前后
4.1 径向畸变与切向畸变对坐标的影响
真实镜头不是完美针孔,光线经过透镜时会发生弯曲,这就产生了畸变。OpenCV用了五参数畸变模型:三个径向畸变系数k1、k2、k3和两个切向畸变系数p1、p2。
径向畸变会让直线变弯。k1是主导项,表现为画面边缘的桶形或枕形畸变;k2负责更边缘的弯曲;k3通常只在鱼眼镜头或畸变极大的广角镜头上才显著。切向畸变则是因为镜头与传感器平面不平行,导致像点在切向方向发生偏移,在画面边缘更明显。
畸变模型的作用对象是归一化坐标,这一点很多人会搞反。正确的做法是:先把像素坐标还原成理想的归一化坐标,再根据畸变模型计算实际畸变后的归一化坐标,最后才投影到像素域。OpenCV的initUndistortRectifyMap和undistortPoints内部走的都是这条链路。
4.2 完整的畸变校正顺序:从像素到归一化的正确路径
一句话总结:畸变校正发生在归一化坐标系内,不在像素坐标系内。
从像素坐标求“无畸变归一化坐标”的标准流程是:
- 用 (K^{-1}) 把像素坐标转成归一化坐标。
- 对这个归一化坐标应用畸变模型,算出畸变后的归一化坐标。
- 如需继续求像素坐标,再用K把畸变后的归一化坐标转回像素坐标。
很多人写代码时直接用undistort函数对整张图像去畸变,然后从去畸变后的图上提取特征点。这样当然可以,但性能开销大。更高效的做法是只对提取到的角点或特征点做undistortPoints,一次性把像素坐标转成去畸变后的归一化坐标。
我自己在项目里通常不直接调用cv2.undistort,而是先提特征点,再用undistortPoints批量转换。实测下来速度提升明显,而且精度更高,因为不用经过图像插值。
5. OpenCV实操:用好内参矩阵和归一化平面的几个关键场景
5.1 从像素坐标反推归一化坐标的代码链路
代码最能说明问题。假设你有一组像素坐标pts_pixel,相机内参K和畸变系数dist已经标定好。要把这些点转成归一化坐标,直接:
import cv2 import numpy as np K = np.array([[800, 0, 320], [0, 810, 240], [0, 0, 1]], dtype=np.float64) dist = np.array([0.05, -0.02, 0.001, 0.002, 0.0]) pts_pixel = np.array([[520.0, 361.5], [100.0, 200.0]], dtype=np.float64) # 方法一:用 undistortPoints,直接得到归一化坐标 pts_norm = cv2.undistortPoints(pts_pixel, K, dist) # 输出形状为 (N,1,2),即去畸变后的归一化坐标 x/z, y/z print(pts_norm)有个细节要记住:OpenCV 4.x里的undistortPoints输出就是归一化坐标,不需要再除以焦距。早年间有些版本默认输出的是带焦距的物理坐标,需要手动归一化,这坑踩过的人不少。
如果你要的只是“像素转归一化坐标、不做畸变校正”,代码更简单:
pts_homo = cv2.convertPointsToHomogeneous(pts_pixel).reshape(-1, 3).T pts_norm = np.linalg.solve(K, pts_homo) pts_norm = pts_norm[:2] / pts_norm[2]5.2 solvePnP如何依赖内参与归一化平面
solvePnP是视觉测量和位姿估计中最常用的函数之一。它的入参是3D点、2D点、内参K和畸变系数dist。内部流程是:先用K和dist把2D像素点转成去畸变的归一化坐标,再根据3D点和归一化坐标求解R、t。
看一个实际例子,假设你在标定板上检测到9个角点,对应3D坐标已知,像素坐标已知。求解相机位姿:
retval, rvec, tvec = cv2.solvePnP(obj_points, img_points, K, dist)如果我故意不传K,把对象点坐标直接和像素点坐标对应起来,会发生什么?算法会试图用一个“单位像素焦距”的相机模型去解释物理投影,解出来的位姿会严重偏离真实值。这说明在PnP问题里,内参矩阵的作用就是把像素坐标先拉回归一化坐标系,让3D-2D对应关系建立在几何真实度量上。
5.3 标定结果的合理性检查,几行代码搞定
前面提到要验证重投影误差,这里给出一个免责最小案例:
# 假设已经得到标定结果 K, dist # obj_points: 标定板3D点 # img_points: 检测到的像素点 retval, rvec, tvec = cv2.solvePnP(obj_points, img_points, K, dist) proj_points, _ = cv2.projectPoints(obj_points, rvec, tvec, K, dist) error = np.mean(np.linalg.norm(proj_points.reshape(-1,2) - img_points, axis=1)) print("重投影误差:", error)如果误差在0.2像素以内,标定结果可以认为合格。超过0.5像素,我通常不会继续往下做,而是返回去检查标定采集流程。
6. 四个典型的坑,每个我都踩过
6.1 坑一:畸变与归一化顺序放反了
这是最常见的坑。有人从像素坐标出发,先除以焦距得到“归一化坐标”,再应用畸变模型。表面上看都是归一化坐标,但畸变模型中的x、y是理想归一化坐标,不是畸变后的坐标。顺序一颠倒,径向畸变的符号就反了,本应外凸的边缘会变成内凹。你用这样的坐标去算极线,会发现匹配点离极线越来越远。
正确的处理方式只有一个:先理想归一化,再畸变——OpenCV内部也是这么做的。
6.2 坑二:把fx和fy当同一个焦距
很多工程代码为了省事直接用fx代替fy,或者手动把像素坐标归一化时只除以一个标量。如果你用的图像分辨率接近正方形且标定结果fx与fy相差很小,可能毫无感觉。可一旦传感器像素不是正方形,或图像经过程序拉伸缩放,差别立刻显现。
我在一个项目里遇到过诡异现象:三角化出来的深度整体偏大。排查半天,发现是有人对图像做了resize,却用原内参去还原归一化坐标。resize之后的内参必须同步缩放,fx、fy和cx、cy都要按缩放比例更新。这个错误隐蔽性极强,因为图像看起来一切正常,只有深度值对不上。
6.3 坑三:坐标系方向习惯不一致
这里说的是图像坐标系y轴方向。图像物理坐标系通常以中心为原点、y轴向上,而像素坐标系以左上角为原点、y轴向下。转换时如果忘记把v方向取反,所有归一化坐标的y都会反号。在PnP位姿估计中,这个反号会让旋转矩阵多一个旋转π的项,位姿完全错误。
我习惯在代码里显式定义坐标变换,不用隐式约定。每次从像素到归一化,都明确写出“这里y方向已经转换”,防止过几天自己都看不明白。
6.4 坑四:把归一化坐标和相机坐标混为一谈
归一化坐标和相机坐标的区别其实很简单:归一化坐标的深度恒为1,相机坐标的深度是真实Z值。两者方向相同,但尺度不同。在做三角化时,有人直接把归一化坐标当作方向向量来处理,忘记乘以估计出的深度,结果重建出的3D点全部落在单位球面上,而不是真实位置。
正确的做法是:三角化先求归一化坐标方向,再根据两条射线的交点求深度,最后用深度乘以归一化方向,得到相机坐标系下的真实3D坐标。这一步差了,整个重建模型都会缩成一个极小的深度范围。
写在最后的一点小经验
如果你现在正被相机标定、PnP精度或者三角化结果折磨,我建议你回到本源:把每一个像素坐标都先还原成归一化坐标,再谈几何关系。绝大多数精度问题,追到根上都是归一化环节出了差错。
我自己的习惯是在代码里专门封装一个坐标变换模块,统一处理像素到归一化的转换和畸变校正,所有下游算法都只接收归一化坐标。这样一来,更换相机时只需要更新一个模块,整个算法链路不用动。时间久了你会发现,内参矩阵和归一化平面不是两个孤立的知识点,而是整个三维视觉体系的地基。地基稳了,上面盖什么楼都不慌。