第四章:张正友标定法——棋盘格标定、角点检测、单应性矩阵、标定流程详解

说到相机标定,张正友标定法绝对是绕不开的经典。我入行那会儿,第一次做视觉测量项目,用的就是这套方法。说白了,它解决了我们最头疼的问题:怎么用一张打印的棋盘格,把相机的内参和外参算出来

你可能会问:为什么非得用棋盘格?嗯,这里有个小秘密——棋盘格的角点检测特别稳定,而且它的几何结构简单,数学上容易处理。我个人习惯用7×9或8×10的棋盘格,太大太小都不好。

4.1 棋盘格标定:准备工作

先说说棋盘格。你打印一张棋盘格,贴在平板上,然后从不同角度拍十几张照片。注意,这里有个坑——棋盘格必须平整。我曾经用普通打印纸贴在纸板上,结果纸板有点弯,标定出来的参数偏差很大。

拍照时要注意几点:

  • 棋盘格要占画面1/3以上
  • 角度变化要丰富(俯仰、旋转、平移)
  • 光照均匀,不要有反光
  • 至少拍10-15张
我的经验:拍照时让棋盘格在画面中移动,但不要只在一个平面内移动。你想想看,如果所有照片的棋盘格都平行于像平面,那外参就解不出来了。

4.2 角点检测:找到棋盘格的“锚点”

角点检测是标定的第一步。我们要找到棋盘格上每个黑白格子的交点。OpenCV里有个现成的函数——cv2.findChessboardCorners()

它的原理其实不复杂:先对图像做二值化,然后找轮廓,再通过轮廓的拓扑关系定位角点。但实际用起来,你会发现有些照片就是检测不到角点。为什么?

我遇到过这种情况:光线太暗,或者棋盘格太远,角点就糊了。解决办法是:调整图像对比度,或者用cv2.cornerSubPix()做亚像素精度的角点定位。

import cv2
import numpy as np

# 棋盘格尺寸(内角点数量)
pattern_size = (7, 9)  # 7列9行内角点

# 读取图像
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)
    corners_sub = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria)
    
    # 绘制角点
    cv2.drawChessboardCorners(img, pattern_size, corners_sub, ret)
    cv2.imshow('Corners', img)
    cv2.waitKey(0)
注意:亚像素精化这一步很多人会跳过。但我建议你别省——它能把角点精度从像素级提升到亚像素级,对标定结果影响很大。

4.3 单应性矩阵:连接世界坐标与图像坐标的桥梁

单应性矩阵(Homography)是张正友标定法的核心。它描述的是一个平面到另一个平面的投影变换。在标定中,就是棋盘格平面到像平面的变换。

数学上,单应性矩阵是一个3×3的矩阵,有8个自由度。为什么是8个?因为它是齐次坐标,可以归一化。你想想看,一个点从世界坐标(u, v)映射到图像坐标(x, y),关系是这样的:

[x]   [h11 h12 h13] [u]
[y] = [h21 h22 h23] [v]
[1]   [h31 h32 h33] [1]

每对匹配点可以提供两个方程,所以至少需要4对点才能解出H矩阵。但实际中,我们通常用更多点(比如棋盘格上的所有角点),然后用最小二乘法求解。

我记得有一次做项目,发现标定结果总是不对。排查了半天,原来是棋盘格上的角点坐标顺序搞反了。嗯,这里要提醒你:世界坐标系的定义必须一致,否则单应性矩阵算出来就是错的。

4.4 标定流程详解:一步步来

张正友标定法的完整流程,我画了一张图帮你理解:

张正友标定法流程图 1. 准备棋盘格 打印并拍摄多张照片 2. 角点检测 findChessboardCorners 3. 计算单应性矩阵 每张照片一个H矩阵 4. 求解内参 闭合解 5. 求解外参 每张照片不同 6. 非线性优化 LM算法精化 7. 畸变校正 径向+切向畸变 8. 输出标定结果 内参矩阵+畸变系数

流程看起来简单,但每一步都有细节。我重点说说第4步到第6步——这是标定的核心。

4.5 内参求解:从单应性矩阵到相机内参

张正友标定法的巧妙之处在于:利用单应性矩阵的约束条件,直接解出内参。具体来说,每个单应性矩阵H可以分解为:

H = K [r1 r2 t]

其中K是内参矩阵,r1、r2是旋转矩阵的前两列,t是平移向量。因为r1和r2是正交的,所以可以得到两个约束方程。多张照片就能解出K。

这里有个数学细节:内参矩阵K有5个参数(fx, fy, cx, cy, s),但实际中我们通常假设s=0(像素无倾斜)。所以至少需要3张照片才能解出全部内参。

核心公式:

内参矩阵 K = [fx, s, cx; 0, fy, cy; 0, 0, 1]

其中fx, fy是焦距,cx, cy是主点坐标,s是倾斜因子(通常为0)

4.6 非线性优化:让结果更精确

闭合解虽然能算出内参,但精度不够。为什么?因为闭合解没有考虑畸变。所以张正友标定法最后一步是非线性优化,用Levenberg-Marquardt算法最小化重投影误差。

重投影误差是什么?说白了,就是把世界坐标系中的角点,用标定结果投影到图像上,然后跟实际检测到的角点位置比较。误差越小,标定越准。

OpenCV里用cv2.calibrateCamera()一步到位:

# 准备世界坐标
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.8像素。后来发现是照片数量不够,而且角度变化太小。增加到20张照片,误差降到0.3像素。所以,照片质量和数量直接决定标定精度

4.7 标定结果评估:怎么看标得好不好

标定完了,怎么知道结果好不好?我一般看三个指标:

指标 理想值 说明
重投影误差 < 0.5像素 所有角点的平均投影误差
主点坐标 接近图像中心 cx ≈ 图像宽度/2, cy ≈ 图像高度/2
焦距 与镜头参数一致 fx ≈ fy(对于方形像素)

如果重投影误差大于1像素,那标定结果基本不能用。我遇到过这种情况:检查后发现是棋盘格不平整,或者照片有运动模糊。重新拍一组就好了。

4.8 实际应用中的注意事项

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

  • 棋盘格尺寸要准确:每个格子的实际物理尺寸(比如30mm)必须精确测量,否则标定出来的尺度是错的。
  • 照片要覆盖整个视场:棋盘格出现在画面的各个位置,这样畸变校正才准确。
  • 不要用鱼眼镜头:张正友标定法假设畸变较小,鱼眼镜头的畸变太大,需要专门的标定方法。
  • 定期重新标定:相机如果受到震动或温度变化,内参可能会变。我一般每3个月重新标定一次。
小技巧:标定完成后,可以用标定结果对图像做畸变校正,看看棋盘格的直线是不是真的变直了。如果还有弯曲,说明标定精度不够。

嗯,张正友标定法就讲到这里。这套方法虽然经典,但用好了,能解决90%的视觉测量问题。下次你遇到标定不准的情况,不妨回头看看这几个步骤——多半是角点检测或者照片质量出了问题。


公众号:蓝海资料掘金营,微信deep3321