棋盘格相机标定:从张正友算法到 OpenCV 实践

示例文章 · 数学推导与 Python 代码

/ 计算机视觉

拍摄同一个正方形,为什么靠近画面边缘时会弯曲?机器人看到一个角点,怎样把像素位置与真实空间联系起来?这些问题都离不开相机标定

棋盘格标定利用尺寸已知的平面图案,从多个视角估计相机内参、镜头畸变,以及每张照片中标定板相对于相机的姿态。本文面向普通透视相机;超广角鱼眼镜头通常应使用专门的鱼眼模型。

先弄清楚:我们在求什么?

标定参数分为三类:

参数含义多张照片是否共用
内参焦距、主点等成像参数同一成像设置下共用
畸变系数镜头造成的径向与切向偏移同一成像设置下共用
外参标定板坐标到相机坐标的旋转和平移每张照片不同

这里的“共用”有前提:拍摄过程中不应改变分辨率、变焦或对焦状态。后续若裁剪、缩放图像,也需要相应调整内参。

针孔模型:空间点如何变成像素?

设标定板上的三维点为 $\mathbf{P}_w=(X,Y,Z)^T$。旋转矩阵 $R$ 和平移向量 $\mathbf{t}$ 将其转换到相机坐标系:

$$ \mathbf{P}_c=R\mathbf{P}_w+\mathbf{t}. $$

忽略畸变时,透视投影可以写成:

$$ \lambda \begin{bmatrix}u\\v\\1\end{bmatrix} = K\begin{bmatrix}R&\mathbf{t}\end{bmatrix} \begin{bmatrix}X\\Y\\Z\\1\end{bmatrix}, \qquad K= \begin{bmatrix} f_x&\gamma&c_x\\ 0&f_y&c_y\\ 0&0&1 \end{bmatrix}. $$

其中,$f_x,f_y$ 是以像素为单位的焦距,$(c_x,c_y)$ 是主点,$\gamma$ 是像素坐标轴的斜切参数。常见数字相机通常假设 $\gamma=0$,下面的 OpenCV 示例也采用这一假设。比例因子 $\lambda$ 对应空间点的相机深度。

单目内参标定不会让一张任意照片自动具备深度信息。将像素恢复为三维位置,还需要深度、已知平面或其他几何约束。

为什么平面的棋盘格足够?

从平面到单应矩阵

把棋盘格所在平面定义为 $Z=0$,并把旋转矩阵的三列记为 $\mathbf{r}_1,\mathbf{r}_2,\mathbf{r}_3$,则投影关系化简为:

$$ \lambda\begin{bmatrix}u\\v\\1\end{bmatrix} =K\begin{bmatrix}\mathbf{r}_1&\mathbf{r}_2&\mathbf{t}\end{bmatrix} \begin{bmatrix}X\\Y\\1\end{bmatrix} =H\begin{bmatrix}X\\Y\\1\end{bmatrix}. $$

$H$ 就是平面坐标到图像坐标的单应矩阵,只确定到一个非零比例。在无畸变的理想模型下,至少四对处于一般位置的点对应就可以估计单应矩阵;棋盘格提供大量规则角点,有利于提高抗噪能力。

实际图像含有畸变,因此直接由原始角点拟合的单应性只是初始化近似,后续仍需联合优化。

用旋转的约束求内参

设 $H=[\mathbf{h}_1\ \mathbf{h}_2\ \mathbf{h}_3]$,定义:

$$ B=K^{-T}K^{-1}. $$

因为旋转矩阵前两列正交且长度相同,每个视角可以提供两个约束:

$$ \mathbf{h}_1^T B\mathbf{h}_2=0, \qquad \mathbf{h}_1^T B\mathbf{h}_1 -\mathbf{h}_2^T B\mathbf{h}_2=0. $$

$B$ 是对称矩阵,有六个独立元素,但只确定到尺度。把不同视角的约束堆叠起来,就能通过线性方法估计 $B$,再恢复内参 $K$。一般完整内参情形下,需要至少三个非退化视角提供足够的独立约束;实际拍摄远不应只满足这个数量下限。

得到内参后,可以从 $K^{-1}H$ 恢复每张图像的旋转和平移初值,再把有噪声的旋转估计投影到合法的旋转矩阵上。这就是张正友方法的核心思路:先得到几何初值,再做非线性精化

畸变模型:直线为什么会弯?

令相机坐标为 $(X_c,Y_c,Z_c)$,归一化坐标为 $x=X_c/Z_c$、$y=Y_c/Z_c$,并令 $r^2=x^2+y^2$。

常用的五参数模型包含三个径向系数 $k_1,k_2,k_3$ 和两个切向系数 $p_1,p_2$:

$$ \begin{aligned} L&=1+k_1r^2+k_2r^4+k_3r^6,\\ x_d&=xL+2p_1xy+p_2(r^2+2x^2),\\ y_d&=yL+p_1(r^2+2y^2)+2p_2xy. \end{aligned} $$

在零斜切假设下,畸变后的像素位置为:

$$ u=f_xx_d+c_x,\qquad v=f_yy_d+c_y. $$

径向畸变通常体现为桶形或枕形弯曲;切向畸变用于描述镜头与成像平面未理想对齐等情况带来的偏移。

OpenCV 默认五参数输出顺序是 k1, k2, p1, p2, k3,不是先放完三个径向系数。只有确实需要时,才考虑有理模型等更复杂的参数化;参数更多不一定意味着泛化更好。

最终优化目标:重投影误差

标定算法将每个已知棋盘格点重新投影到图像中,并让预测位置尽可能靠近检测到的角点:

$$ \min_{K,\mathbf{d},\{R_i,\mathbf{t}_i\}} \sum_{i=1}^{M}\sum_{j=1}^{N_i} \left\| \mathbf{q}_{ij}-\pi(K,\mathbf{d},R_i,\mathbf{t}_i,\mathbf{P}_j) \right\|_2^2. $$

这里,$\mathbf{d}$ 是畸变系数,$\pi$ 包含透视投影和畸变,$\mathbf{q}_{ij}$ 是第 $i$ 张图像中第 $j$ 个实测角点。

本文使用按二维点距离定义的 RMS:

$$ \mathrm{RMS}=\sqrt{ \frac{\sum_i\sum_j\|\mathbf{q}_{ij}-\widehat{\mathbf{q}}_{ij}\|_2^2} {\sum_i N_i} }. $$

单位是像素。注意,这不是“把所有距离直接求平均”,也不是“整体 L2 范数除以点数”。下面代码逐张计算同一定义的 RMS,方便定位模糊或误检的照片。

数据采集比调参更重要

  • 使用平整、刚性的棋盘格,确认打印没有非均匀拉伸,并测量实际格边长。
  • 检测器需要的是内角点数量:10 列、7 行方格对应 9 列、6 行内角点。
  • 实践中可先采集 15–25 张清晰照片,覆盖画面中心、四角以及不同倾角和距离。
  • 避免所有照片都正对标定板,或只在画面中心平移;重复视角不会自动增加有效信息。
  • 避免严重反光、运动模糊和过于倾斜的视角;棋盘格应完整可见且角点足够大。
  • 固定焦距、对焦、分辨率,尽量关闭可能动态改变几何映射的防抖与处理功能。

Python 实践:检测、标定、评估与去畸变

安装依赖,把照片放进 calibration_images/,将下方代码保存为 calibrate.py

python -m pip install numpy opencv-python
python calibrate.py

示例假设内角点为 9 列、6 行,格边长为 25 毫米。必须按自己的标定板修改这两个值。长度单位决定平移向量的单位,内参焦距仍以像素表示。

from pathlib import Path

import cv2
import numpy as np


PATTERN_SIZE = (9, 6)  # (内角点列数, 内角点行数)
SQUARE_SIZE = 25.0     # 毫米;不是整张标定板的尺寸
MIN_VIEWS = 10         # 示例的数据质量门槛,不是理论最少视角数


def main():
    paths = sorted(
        p for p in Path("calibration_images").glob("*")
        if p.suffix.lower() in {".jpg", ".jpeg", ".png", ".bmp"}
    )
    if not paths:
        raise RuntimeError("calibration_images 中没有图像")

    cols, rows = PATTERN_SIZE
    object_grid = np.zeros((cols * rows, 3), dtype=np.float32)
    object_grid[:, :2] = np.mgrid[0:cols, 0:rows].T.reshape(-1, 2)
    object_grid *= SQUARE_SIZE  # 所有点的 Z 坐标保持为 0

    object_points, image_points, used_paths = [], [], []
    image_size = None
    first_image = None
    criteria = (
        cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_MAX_ITER,
        30,
        0.001,
    )
    flags = cv2.CALIB_CB_ADAPTIVE_THRESH | cv2.CALIB_CB_NORMALIZE_IMAGE

    for path in paths:
        image = cv2.imread(str(path))
        if image is None:
            print(f"跳过无法读取的文件: {path}")
            continue

        gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
        size = (gray.shape[1], gray.shape[0])  # OpenCV 使用 (宽, 高)
        if image_size is not None and size != image_size:
            raise ValueError(f"图像尺寸不一致: {path}: {size} != {image_size}")

        found, corners = cv2.findChessboardCorners(gray, PATTERN_SIZE, flags)
        if not found:
            print(f"未找到完整棋盘格: {path}")
            continue

        corners = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria)
        object_points.append(object_grid.copy())
        image_points.append(corners)
        used_paths.append(str(path))
        image_size = size
        if first_image is None:
            first_image = image.copy()

    if len(object_points) < MIN_VIEWS:
        raise RuntimeError(f"{len(object_points)} 张有效图像,请补充多样视角")

    rms, K, dist, rvecs, tvecs = cv2.calibrateCamera(
        object_points, image_points, image_size, None, None
    )
    print(f"有效图像: {len(object_points)}, 全局 RMS: {rms:.4f} px")
    print("内参 K:\n", K)
    print("畸变 [k1, k2, p1, p2, k3]:", dist.ravel())

    view_rms = []
    total_squared_error = 0.0
    total_points = 0
    for path, obj, observed, rvec, tvec in zip(
        used_paths, object_points, image_points, rvecs, tvecs
    ):
        projected, _ = cv2.projectPoints(obj, rvec, tvec, K, dist)
        delta = observed.reshape(-1, 2) - projected.reshape(-1, 2)
        squared_distances = np.sum(delta.astype(np.float64) ** 2, axis=1)
        error = float(np.sqrt(squared_distances.mean()))
        view_rms.append(error)
        total_squared_error += float(squared_distances.sum())
        total_points += len(obj)
        print(f"{path}: RMS = {error:.4f} px")

    print(f"手动复核全局 RMS: {np.sqrt(total_squared_error / total_points):.4f} px")
    np.savez(
        "camera_calibration.npz",
        K=K, dist=dist, image_size=np.array(image_size),
        rvecs=np.asarray(rvecs), tvecs=np.asarray(tvecs),
        rms=rms, view_rms=np.array(view_rms),
        files=np.array(used_paths), square_size_mm=SQUARE_SIZE,
        pattern_size=np.array(PATTERN_SIZE),
    )

    # alpha=1 尽量保留原视野,输出可能带黑边。
    new_K, _ = cv2.getOptimalNewCameraMatrix(K, dist, image_size, 1, image_size)
    corrected = cv2.undistort(first_image, K, dist, None, new_K)
    if not cv2.imwrite("undistorted.png", corrected):
        raise RuntimeError("去畸变图像保存失败")
    # 去畸变后图像对应 new_K,而不是原来的 K。
    np.savez("undistorted_camera.npz", K=new_K, image_size=np.array(image_size))


if __name__ == "__main__":
    main()

rvecs 是 Rodrigues 旋转向量,可用 cv2.Rodrigues(rvec) 转为旋转矩阵;tvecs 表示从标定板坐标系到相机坐标系的平移,单位在本例中为毫米。

经典检测器之后调用 cornerSubPix,可以进一步精化角点。若改用 findChessboardCornersSB,它本身就提供亚像素定位,不必机械照搬这一精化步骤。

如何判断结果可信?

不只看一个 RMS 数字

较小的训练图像重投影误差是好信号,但不等于标定正确。没有统一适用于所有相机的“合格阈值”:分辨率、成像质量、角点尺寸和应用精度都影响判断。

可以按以下顺序检查:

  1. 将检测角点画回原图,确认角点没有误检,排列与物点网格对应。
  2. 查看逐张误差,优先检查异常图像的模糊、反光和棋盘格平整度,不要仅为了降低数字而删图。
  3. 查看去畸变结果中的直线,尤其是画面边缘;不要只看中心区域。
  4. 留出未参与标定的照片,固定内参与畸变,用 solvePnP 估计新外参,再评估重投影表现。这仍使用了新图中的角点求姿态,不是完全独立的三维精度测试。
  5. 若用于测量或机器人定位,再通过已知距离、位置等独立基准验证实际精度。

改分辨率后,内参也要改

在直接缩放、无裁剪且采用相应坐标缩放约定时,若横纵缩放比例分别为 $s_x,s_y$,则:

$$ K'=\begin{bmatrix}s_x&0&0\\0&s_y&0\\0&0&1\end{bmatrix}K. $$

精确的像素中心映射还取决于重采样约定。若进行了裁剪,还要从主点中减去裁剪原点的偏移。仅改变像素坐标尺度通常不改变归一化坐标下的畸变系数;但相机若同时改变了传感器读出区域或内部处理流程,应重新验证,必要时重新标定。

小结

棋盘格标定的完整链路是:已知平面几何 → 角点对应 → 单应性与内参初值 → 畸变和外参联合优化 → 重投影与实际任务验证

公式解释了参数为什么可求,代码让流程可以复现;而标定板质量、视角覆盖和独立验证,决定了最终结果是否值得信任。

延伸阅读

本文为 AI 辅助撰写的教学示例。代码需安装依赖并配合实拍棋盘格照片运行;文中不提供虚构的实测标定结果。