3. 相机标定基础:张正友标定法原理、棋盘格角点检测、单应性矩阵求解

说到三维重建,第一步永远绕不开相机标定。我刚开始做仿生眼项目时,总觉得标定是个「体力活」,随便拍几张棋盘格就跑流程。结果呢?重建出来的深度图全是扭曲的,像哈哈镜一样。后来才明白——标定不准,后面全是白干。

今天咱们就聊聊张正友标定法。这个方法在计算机视觉圈里,可以说是「标定界的标准答案」。它巧妙的地方在于:你不需要昂贵的3D标定物,一张打印的棋盘格就够了。

3.1 张正友标定法核心思想

说白了,张正友标定法解决的是这么一个问题:如何从多张平面棋盘格照片中,反推出相机的内参和外参

你想想看,我们拍到的每一张棋盘格照片,其实都包含了两个信息:

  • 内参:焦距、主点坐标、畸变系数——这些是相机自身的属性
  • 外参:相机相对于棋盘格的旋转和平移——每次拍照都不同

张正友的聪明之处在于:他利用棋盘格上角点的共面性,把问题拆成了两步。先忽略畸变,用线性方法求出初始值;再考虑畸变,用非线性优化微调。嗯,这里要注意,这种「先粗后精」的思路,在工程中非常实用。

核心假设:棋盘格位于世界坐标系的 Z=0 平面上。这个假设大大简化了计算。

3.2 棋盘格角点检测

角点检测是标定的第一步,也是最容易出坑的一步。我记得第一次做标定时,拍的照片反光严重,角点检测死活对不准。后来学乖了——拍照时用漫反射光源,别用直射灯。

OpenCV 提供了现成的函数,但理解背后的原理很重要:

import cv2
import numpy as np

# 棋盘格尺寸(内角点数量)
pattern_size = (9, 6)  # 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 = []  # 3D点
imgpoints = []  # 2D点

# 读取图像并检测角点
img = cv2.imread('chessboard.jpg')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

# 查找棋盘格角点
ret, corners = cv2.findChessboardCorners(gray, pattern_size, None)

if ret:
    # 亚像素级精确化
    criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)
    corners2 = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria)
    
    objpoints.append(objp)
    imgpoints.append(corners2)
    
    # 绘制并显示
    cv2.drawChessboardCorners(img, pattern_size, corners2, ret)
    cv2.imshow('Corners', img)
    cv2.waitKey(0)

避坑指南:我曾经拍了一组照片,角点检测总是失败。排查了半天,发现是棋盘格打印时边缘裁切了,导致实际角点数量与代码中设置的不一致。建议拍照前先数一遍内角点数量。

3.3 单应性矩阵求解

单应性矩阵(Homography)是张正友标定法的数学核心。它描述的是:同一个平面在两个不同视角下的投影变换关系

为什么会用到单应性?因为棋盘格是平面(Z=0),所以世界坐标到图像坐标的映射,可以简化为一个 3×3 的矩阵 H:

s * [u, v, 1]^T = H * [X, Y, 1]^T

其中 H 包含了内参和外参的混合信息。求解 H 至少需要 4 对匹配点(棋盘格角点正好满足)。

实际求解时,我们通常用 DLT(直接线性变换)方法:

def compute_homography(src_points, dst_points):
    """
    使用DLT方法求解单应性矩阵
    src_points: 世界坐标系中的点 (N, 2)
    dst_points: 图像坐标系中的点 (N, 2)
    """
    assert len(src_points) >= 4, "至少需要4对点"
    
    A = []
    for i in range(len(src_points)):
        x, y = src_points[i]
        u, v = dst_points[i]
        A.append([-x, -y, -1, 0, 0, 0, u*x, u*y, u])
        A.append([0, 0, 0, -x, -y, -1, v*x, v*y, v])
    
    A = np.array(A)
    
    # SVD分解求解 Ah = 0
    U, S, Vt = np.linalg.svd(A)
    h = Vt[-1, :]  # 最小奇异值对应的右奇异向量
    
    H = h.reshape(3, 3)
    return H / H[2, 2]  # 归一化

注意:直接使用上述代码在数值上可能不稳定。实际工程中,建议先对坐标进行归一化(平移+缩放),再求解单应性矩阵。我踩过这个坑——不归一化时,矩阵条件数太大,结果误差能到好几个像素。

3.4 从单应性矩阵到内外参

求解出 H 后,怎么拆出内参和外参呢?张正友给出了一个优雅的解法。

把 H 写成列向量形式:H = [h1, h2, h3]。根据旋转矩阵的性质(列向量正交且模长为1),可以得到两个约束方程:

  1. h1^T * A^{-T} * A^{-1} * h2 = 0(正交约束)
  2. h1^T * A^{-T} * A^{-1} * h1 = h2^T * A^{-T} * A^{-1} * h2(等模长约束)

其中 A 是内参矩阵。每张棋盘格照片提供两个方程,理论上 3 张照片就能解出内参(5个未知数)。

实际中我建议拍 10-15 张不同角度的照片。为什么?因为照片越多,噪声平均效果越好,标定结果越稳定。

3.5 完整标定流程

把上面的步骤串起来,完整的标定流程是这样的:

# 收集所有图像数据后,执行标定
ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera(
    objpoints, imgpoints, gray.shape[::-1], None, None
)

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

# 评估标定精度
total_error = 0
for i in range(len(objpoints)):
    imgpoints2, _ = cv2.projectPoints(objpoints[i], rvecs[i], tvecs[i], mtx, dist)
    error = cv2.norm(imgpoints[i], imgpoints2, cv2.NORM_L2) / len(imgpoints2)
    total_error += error

print("平均重投影误差:", total_error / len(objpoints))

经验之谈:重投影误差小于 0.5 像素,说明标定质量不错。如果超过 1 个像素,建议检查一下:

  • 棋盘格是否平整(贴在硬纸板上,别用软纸)
  • 照片是否清晰(避免运动模糊)
  • 角点检测是否准确(手动检查几张)

3.6 知识体系总览

下面这张图总结了本章的核心逻辑,我把它画成了流程图,方便你理解整个标定过程的脉络:

张正友标定法知识体系 拍摄棋盘格图像 角点检测(亚像素) 求解单应性矩阵 H 从 H 分解内参 A 和外参 [R|t] 非线性优化(含畸变校正) 输入 特征提取 几何变换 参数分解 优化输出

3.7 实战中的注意事项

最后,分享几个我在项目中积累的经验:

  • 照片数量:10-15张足够,但角度要丰富(倾斜、旋转、远近都要有)
  • 棋盘格尺寸:方格边长要精确测量,误差会直接带入标定结果
  • 光照条件:均匀光照,避免反光和阴影
  • 标定板位置:覆盖整个视场,边缘和中心都要有

一个小技巧:标定完成后,可以用标定结果去校正一张照片,看看直线是否变直了。如果棋盘格边缘的直线在校正后还是弯的,说明标定有问题,需要重新来。

好了,相机标定的核心内容就这些。说白了,张正友标定法就是「用平面棋盘格,通过多张照片,反推相机参数」的一套数学框架。理解了这个,后面做三维重建和深度估计,就有了坚实的基础。


专注资料整理