4. OpenCV实战:使用cv2.findChessboardCorners与cv2.calibrateCamera进行相机标定

相机标定,说白了就是给镜头做「体检」。

我刚开始做视觉项目时,总觉得标定是件很玄乎的事。后来踩过几次坑才明白——标定做不好,后面的三维重建、测距、拼接全是白搭。今天咱们就手把手走一遍OpenCV的标定流程,把棋盘格找角点、算内参、去畸变这些事彻底搞明白。

4.1 标定的核心逻辑

先理清一个概念:相机标定到底在干什么?

简单说,就是建立「真实世界坐标」到「图像像素坐标」的映射关系。这个映射由两部分组成:

  • 内参矩阵:焦距、主点位置、像素倾斜因子
  • 畸变系数:径向畸变(k1,k2,k3)、切向畸变(p1,p2)

你想想看,一个理想的小孔成像模型,图像应该是完美的矩形。但实际镜头有曲率,边缘会弯曲——这就是畸变。标定就是算出这些畸变参数,然后反向矫正。

关键公式(理解即可,不用背):

投影方程:s * [u, v, 1]^T = K * [R|t] * [X, Y, Z, 1]^T

其中K是内参矩阵,R和t是外参(旋转和平移)。

4.2 棋盘格:标定的「标准答案」

为什么用棋盘格?因为它有天然的角点——黑白交界处,亚像素精度高,而且图案简单稳定。我个人习惯用7×10的棋盘格(内角点6×9),格子大小20mm或30mm都行。

我的经验:棋盘格一定要打印平整,贴在硬纸板或亚克力板上。我曾经用普通A4纸贴在墙上,结果标定出来的焦距偏差了3%——因为纸面有褶皱。

采集图像时,注意几点:

  • 至少拍15-20张不同角度
  • 棋盘格要出现在画面各个区域(四角、中心、边缘)
  • 角度变化要大:俯仰、偏航、旋转都要有
  • 避免纯正面拍摄(那样解算不稳定)

4.3 cv2.findChessboardCorners:找到角点

这个函数是标定的第一步。它会在图像里搜索棋盘格的内角点,返回它们的像素坐标。

import cv2
import numpy as np

# 棋盘格内角点数量(列数-1, 行数-1)
pattern_size = (6, 9)  # 7×10的棋盘格

# 准备标定图像
img = cv2.imread('calib_01.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_refined = cv2.cornerSubPix(gray, corners, (11,11), (-1,-1), criteria)
    
    # 画出来看看
    cv2.drawChessboardCorners(img, pattern_size, corners_refined, ret)
    cv2.imshow('Corners', img)
    cv2.waitKey(0)

注意:findChessboardCorners对光照敏感。如果棋盘格反光或者阴影太重,它会找不到角点。我建议在均匀光照下拍摄,或者用偏光片消除反光。

为什么会找不到角点?常见原因:

  • 棋盘格太小或太大(占画面比例不合适)
  • 图像模糊(运动模糊或对焦不准)
  • 角度太极端(比如几乎平行于光轴)

4.4 cv2.calibrateCamera:算内参和畸变

找到角点后,就可以交给calibrateCamera了。它会根据多张图像的角点坐标,用最小二乘法解算出内参和畸变系数。

# 准备世界坐标系中的点(假设棋盘格在Z=0平面)
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)
# 如果格子是20mm,就乘以20
objp *= 20.0  # 单位:mm

# 存储所有图像的角点
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)

输出解读:

mtx是3×3矩阵,形如:

[[fx,  0, cx],
 [ 0, fy, cy],
 [ 0,  0,  1]]

fx、fy是焦距(像素单位),cx、cy是主点坐标。理想情况下cx≈图像宽度/2,cy≈图像高度/2。

dist是5个系数:[k1, k2, p1, p2, k3]。

4.5 去畸变:矫正图像

标定完,就该用结果去矫正图像了。OpenCV提供了两个函数:

# 方法一:直接矫正
dst = cv2.undistort(img, mtx, dist, None, mtx)

# 方法二:先算映射表,再重映射(适合批量处理)
mapx, mapy = cv2.initUndistortRectifyMap(mtx, dist, None, mtx, 
                                          (w, h), cv2.CV_32FC1)
dst = cv2.remap(img, mapx, mapy, cv2.INTER_LINEAR)

方法二效率更高,因为映射表可以复用。我一般在批量处理视频帧时用remap,单张图片用undistort就够了。

避坑指南:我曾经在矫正后图像边缘出现黑色区域——这是因为畸变矫正后,边缘像素被拉伸了。解决办法是用cv2.getOptimalNewCameraMatrix调整内参,保留更多有效区域。

# 调整内参,保留全部有效像素
h, w = img.shape[:2]
new_mtx, roi = cv2.getOptimalNewCameraMatrix(mtx, dist, (w,h), 1, (w,h))
dst = cv2.undistort(img, mtx, dist, None, new_mtx)

# 裁剪掉黑色边框
x, y, w, h = roi
dst = dst[y:y+h, x:x+w]

4.6 评估标定质量

标定完怎么知道好不好?看重投影误差。

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像素算不错,小于0.3像素算优秀。如果误差大于1像素,说明标定图像质量有问题——要么角点检测不准,要么图像数量不够。

我的教训:有一次标定误差0.8像素,我以为是算法问题。折腾了两天,最后发现是棋盘格打印时尺寸不对——格子实际是19.5mm,我代码里写的是20mm。所以,物理尺寸一定要量准

4.7 完整流程总结

嗯,到这里标定的核心流程就走完了。我画了个流程图,帮你理清思路:

相机标定核心流程 ① 采集棋盘格图像 ② 检测棋盘格角点 ③ 执行相机标定 ④ 评估重投影误差 输出:内参 + 畸变系数 至少15-20张 不同角度 亚像素精确化 最小二乘法 <0.5像素合格

标定这件事,说白了就是「用已知去推算未知」。棋盘格提供了已知的世界坐标,角点检测提供了像素坐标,calibrateCamera负责解方程。只要数据质量好,结果就靠谱。

我个人建议:标定完一定要做一次实际验证——拍一张已知尺寸的物体,用矫正后的图像去测量,看看误差在不在容忍范围内。理论再漂亮,不如实测一锤定音。

专注资料整理