3. 相机成像模型:从三维世界到像素平面

相机是怎么把真实的三维世界变成一张二维图片的?这个问题,说白了就是相机成像模型要回答的核心。我刚开始做视觉的时候,总觉得这不过是初中物理的小孔成像,后来踩了不少坑才明白——这里面的门道,远比想象中多。

咱们这一章,就把相机成像这件事彻底讲透。从最基础的针孔模型开始,再到镜头畸变、内参外参,一步步把相机“看世界”的数学原理拆开揉碎。

3.1 针孔相机模型:最朴素的成像原理

针孔相机模型是所有成像模型的基础。它假设光线通过一个小孔,在后面的成像平面上形成倒立的像。你想想看,这其实就是一个简单的相似三角形关系。

数学上,一个三维空间点 P(X, Y, Z) 投影到成像平面上的点 p(x, y),满足:

x = f * (X / Z)
y = f * (Y / Z)

其中 f 是焦距,也就是小孔到成像平面的距离。这个公式看起来简单,但它是整个相机几何的基石。

关键理解:针孔模型本质上是将三维空间点通过透视投影映射到二维平面。Z 值在分母上,意味着物体越远,成像越小——这就是“近大远小”的数学根源。

我在实际项目中遇到过一个问题:用针孔模型标定出来的参数,在图像边缘总有些偏差。后来才发现,真实镜头不是针孔,它有自己的脾气——这就是我们接下来要说的畸变。

3.2 透镜畸变:镜头不是完美的

真实镜头为了收集更多光线,用的是凸透镜而不是针孔。透镜的曲率、装配误差都会导致成像偏离理想针孔模型。这种偏离,就是畸变。

畸变主要分两类:

3.2.1 径向畸变

径向畸变是沿着镜头半径方向产生的变形。离光心越远,畸变越明显。我见过最典型的例子:用广角镜头拍一张棋盘格,边缘的格子都弯了。

  • 桶形畸变:图像向外凸出,像木桶的形状。常见于广角镜头。
  • 枕形畸变:图像向内凹陷,像枕头。常见于长焦镜头。

数学上,径向畸变用多项式来建模:

x_distorted = x * (1 + k1 * r² + k2 * r⁴ + k3 * r⁶)
y_distorted = y * (1 + k1 * r² + k2 * r⁴ + k3 * r⁶)

其中 r 是像素点到光心的距离,k1, k2, k3 是径向畸变系数。一般用 k1 和 k2 就够了,鱼眼镜头才需要 k3。

我曾经踩过的坑:有一次标定结果总是不收敛,查了半天发现是标定板图片数量不够。径向畸变参数至少需要 10-15 张不同角度的图片才能稳定估计。少于这个数,结果基本靠猜。

3.2.2 切向畸变

切向畸变是由于镜头和成像平面不平行造成的。说白了,就是镜头装歪了。这种畸变会让图像看起来像被“拧”了一下。

x_distorted = x + [2 * p1 * x * y + p2 * (r² + 2 * x²)]
y_distorted = y + [p1 * (r² + 2 * y²) + 2 * p2 * x * y]

其中 p1, p2 是切向畸变系数。在实际标定中,切向畸变通常比径向畸变小一个数量级,但也不能忽略。

畸变类型 产生原因 参数 影响范围
径向畸变 透镜曲率 k1, k2, k3 图像边缘明显
切向畸变 镜头装配误差 p1, p2 全图范围

3.3 相机内参矩阵:从物理世界到像素

内参矩阵描述的是相机内部的几何属性。它把相机坐标系下的三维点,映射到图像像素坐标系。我习惯把它理解为“相机自己的尺子”。

内参矩阵 K 的形式是:

K = [fx,  0,  cx]
    [ 0, fy,  cy]
    [ 0,  0,   1]
  • fx, fy:焦距在 x 和 y 方向上的像素度量。如果像素是正方形的,fx = fy。
  • cx, cy:光心在像素坐标系中的位置,通常是图像中心附近。

完整的投影过程是:

pixel = K * [R|t] * P_world

其中 [R|t] 是外参矩阵,我们马上会讲。

个人经验:标定内参时,我建议用不同距离、不同角度的图片。只拍正对着的图片,标定出来的 fx 和 fy 可能不准。我一般拍 20 张左右,覆盖视野的各个区域。

3.4 相机外参矩阵:相机在世界中的位置

外参矩阵描述的是相机坐标系和世界坐标系之间的变换关系。说白了,就是相机放在哪里、朝哪个方向看。

外参矩阵由两部分组成:

  • 旋转矩阵 R:3×3 矩阵,描述相机相对于世界坐标系的朝向。
  • 平移向量 t:3×1 向量,描述相机在世界坐标系中的位置。

外参矩阵的齐次形式是:

[R | t] = [r11, r12, r13, t1]
          [r21, r22, r23, t2]
          [r31, r32, r33, t3]
          [  0,   0,   0,  1]

嗯,这里要注意:外参矩阵是随着相机移动而变化的。你挪一下相机,外参就得重新算。内参则相对固定,只要不换镜头、不调焦距,标定一次能用很久。

3.5 完整成像流程:从世界坐标到像素坐标

把上面所有内容串起来,一个三维点变成像素点的完整流程是:

  1. 世界坐标系 → 相机坐标系:通过外参矩阵 [R|t] 变换。
  2. 相机坐标系 → 归一化平面:除以 Z 值,得到归一化坐标 (x, y)。
  3. 加入畸变:用径向和切向畸变系数修正归一化坐标。
  4. 归一化平面 → 像素坐标系:通过内参矩阵 K 映射到像素坐标。

这个流程,我建议你把它刻在脑子里。手眼标定、目标检测、三维重建——几乎所有视觉任务都绕不开它。

一句话总结:内参告诉你相机“怎么看”,外参告诉你相机“在哪看”,畸变告诉你相机“看歪了多少”。三者合起来,就是完整的相机成像模型。

相机成像模型知识结构图 相机成像模型知识结构 相机成像模型 针孔相机模型 透镜畸变 内参矩阵 K 外参矩阵 [R|t] 透视投影:x = f·X/Z 相似三角形关系 径向畸变:k1, k2, k3 切向畸变:p1, p2 焦距 fx, fy 光心 cx, cy 旋转矩阵 R 平移向量 t 完整成像流程:世界坐标 → 外参变换 → 相机坐标 → 归一化 → 畸变修正 → 内参映射 → 像素坐标 内参:相机“怎么看” | 外参:相机“在哪看” | 畸变:相机“看歪了多少”

3.6 标定实战:用 OpenCV 获取相机参数

理论讲完了,咱们来点实际的。用 OpenCV 做相机标定,核心就几步:

import cv2
import numpy as np

# 准备棋盘格角点的世界坐标
pattern_size = (9, 6)  # 内角点数
objp = np.zeros((pattern_size[0] * pattern_size[1], 3), np.float32)
objp[:, :2] = np.mgrid[0:pattern_size[0], 0:pattern_size[1]].T.reshape(-1, 2)

# 收集所有图片的角点
objpoints = []  # 世界坐标
imgpoints = []  # 像素坐标

for fname in image_files:
    img = cv2.imread(fname)
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    ret, corners = cv2.findChessboardCorners(gray, pattern_size, None)
    if ret:
        objpoints.append(objp)
        imgpoints.append(corners)

# 标定
ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera(
    objpoints, imgpoints, gray.shape[::-1], None, None
)

print("内参矩阵:\n", mtx)
print("畸变系数:\n", dist)

我建议:标定完成后,一定要做重投影误差检查。用标定得到的参数把世界坐标重投影回像素坐标,计算与原始角点的误差。一般重投影误差小于 0.5 像素就算不错了。如果误差很大,检查一下图片质量或者标定板是否平整。

嗯,这一章的内容就到这里。相机成像模型是视觉系统的地基,地基稳了,后面的手眼标定和目标检测才能盖得起来。你把这些概念吃透了,后面遇到问题就知道该往哪个方向找原因。