第三章 相机内参标定实战:使用OpenCV进行棋盘格标定

相机内参标定,说白了就是搞清楚你的相机到底是怎么「看」世界的。每台相机都有自己独特的脾气——镜头畸变、焦距偏差、主点偏移,这些都需要通过标定来摸清楚。我在项目里见过太多人拿着没标定过的相机直接做三维重建,结果出来的点云歪七扭八,那叫一个惨。

这一章,我们就用OpenCV把棋盘格标定这件事彻底讲透。从角点检测到标定流程,再到结果验证,每一步我都会把实战中踩过的坑告诉你。

3.1 棋盘格标定的基本原理

棋盘格标定,本质上是在求解一个「从三维世界到二维图像」的映射关系。这个映射由两部分组成:

  • 内参矩阵:焦距(fx, fy)、主点(cx, cy),描述相机本身的几何特性
  • 畸变系数:径向畸变(k1, k2, k3)和切向畸变(p1, p2),描述镜头的光学缺陷

你想想看,棋盘格上的每个角点,在三维空间中的位置是已知的(比如角点间距30mm)。相机拍下来后,这些角点在图像上的像素坐标也是已知的。有了这些对应关系,我们就能反推出相机的内参和畸变。

核心公式(简化版)

s * [u, v, 1]^T = K * [R|t] * [X, Y, Z, 1]^T

其中K就是内参矩阵,[R|t]是外参,[X,Y,Z]是世界坐标,[u,v]是像素坐标。

3.2 棋盘格的选择与准备

棋盘格的选择,我建议你注意这几点:

  • 格子数量:内角点数量建议在9×6到12×9之间。太少解不准确,太多容易过拟合
  • 格子尺寸:打印出来后用游标卡尺量一下实际尺寸,别信打印机的设置。我吃过这个亏,打印出来的格子实际是29.8mm,不是30mm
  • 材质:用哑光纸打印,别用光面纸。光面纸反光严重,角点检测容易失败
  • 贴在硬板上:软纸会弯曲,影响标定精度。我习惯贴在亚克力板或者铝板上

我的小技巧:在棋盘格四周留一圈白边,方便手持时不会遮挡角点。另外,棋盘格不要太小,A4纸大小就挺好。

3.3 图像采集的实战要点

图像采集这一步,很多人觉得随便拍几张就行。嗯,这里要注意,采集质量直接决定标定结果的好坏。

我一般会拍20-30张,覆盖以下场景:

  1. 不同角度:左右倾斜、上下倾斜、旋转,让棋盘格在图像中呈现各种姿态
  2. 不同距离:从近到远,覆盖相机的工作距离范围
  3. 不同位置:棋盘格出现在图像的四个角和中心区域
  4. 光照均匀:避免强光直射或阴影遮挡棋盘格

避坑指南:我曾经为了省事只拍了8张图,结果标定出来的焦距偏差了3%。后来老老实实拍了25张,结果就稳定了。记住,数量不够,精度来凑——但精度凑不了。

3.4 角点检测实战代码

OpenCV提供了现成的角点检测函数,但用起来有几个坑。先看代码:

import cv2
import numpy as np
import glob

# 棋盘格内角点数量(列数-1,行数-1)
CHECKERBOARD = (9, 6)
# 棋盘格单个格子尺寸(毫米)
SQUARE_SIZE = 30.0

# 准备世界坐标系中的点
objp = np.zeros((CHECKERBOARD[0] * CHECKERBOARD[1], 3), np.float32)
objp[:, :2] = np.mgrid[0:CHECKERBOARD[0], 0:CHECKERBOARD[1]].T.reshape(-1, 2)
objp *= SQUARE_SIZE

# 存储所有图像的点
objpoints = []  # 3D点
imgpoints = []  # 2D点

images = glob.glob('calib_images/*.jpg')

for fname in images:
    img = cv2.imread(fname)
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    
    # 查找棋盘格角点
    ret, corners = cv2.findChessboardCorners(gray, CHECKERBOARD, None)
    
    if ret:
        objpoints.append(objp)
        
        # 亚像素级角点优化
        criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)
        corners2 = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria)
        imgpoints.append(corners2)
        
        # 可视化角点
        cv2.drawChessboardCorners(img, CHECKERBOARD, corners2, ret)
        cv2.imshow('Corners', img)
        cv2.waitKey(100)
    else:
        print(f"角点检测失败: {fname}")

cv2.destroyAllWindows()

这段代码里,我特别想强调两个地方:

  • 亚像素优化cornerSubPix这一步不能省。原始角点检测的精度是像素级的,优化后能达到0.1像素级别。标定精度差0.1像素,三维重建可能就差几毫米
  • 角点顺序:OpenCV返回的角点顺序是从左上角开始,按行排列。如果你自己写可视化代码,要注意这个顺序

3.5 标定流程与参数求解

角点检测完成后,标定就简单了:

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

print("内参矩阵:")
print(mtx)
print("\n畸变系数:")
print(dist)
print(f"\n重投影误差: {ret:.4f} 像素")

calibrateCamera函数内部用的是张正友标定法,它会自动求解内参、畸变和外参。返回的ret就是重投影误差,这个值后面会详细分析。

输出示例

参数
fx823.4567
fy824.1234
cx640.2345
cy360.6789
k1-0.2345
k20.1234
p10.0012
p2-0.0008

3.6 结果验证:重投影误差分析

重投影误差,说白了就是「用标定结果反算回去,看看角点位置偏差了多少」。这个值越小,标定越准。

我一般会做两件事来验证标定结果:

  1. 计算平均重投影误差:小于0.5像素算合格,小于0.2像素算优秀
  2. 可视化每张图的误差分布:看看是不是某几张图误差特别大,如果是,说明那些图有问题
# 计算每张图的误差
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(f"图像 {i+1} 的重投影误差: {error:.4f} 像素")

print(f"\n平均重投影误差: {total_error/len(objpoints):.4f} 像素")

我的经验:如果某张图的误差超过1像素,我建议直接删掉那张图重新标定。通常是因为那张图拍糊了或者棋盘格没放平。删掉后重新标定,整体误差能降30%以上。

3.7 畸变校正与可视化

标定完,当然要看看效果。畸变校正后的图像,直线应该是直的:

# 畸变校正
img = cv2.imread('test_image.jpg')
h, w = img.shape[:2]
newcameramtx, roi = cv2.getOptimalNewCameraMatrix(mtx, dist, (w, h), 1, (w, h))

# 方法1:使用undistort
dst = cv2.undistort(img, mtx, dist, None, newcameramtx)

# 方法2:使用remap(更高效,适合视频流)
mapx, mapy = cv2.initUndistortRectifyMap(mtx, dist, None, newcameramtx, (w, h), 5)
dst2 = cv2.remap(img, mapx, mapy, cv2.INTER_LINEAR)

# 裁剪掉黑边
x, y, w2, h2 = roi
dst = dst[y:y+h2, x:x+w2]

cv2.imshow('Original', img)
cv2.imshow('Undistorted', dst)
cv2.waitKey(0)

这里有个细节:getOptimalNewCameraMatrix的alpha参数控制着图像缩放。alpha=1时保留所有像素(会有黑边),alpha=0时裁剪掉黑边。我一般用alpha=0.5,既保留大部分视野,又去掉黑边。

3.8 常见问题与解决方案

做标定这么多年,我总结了一些高频问题:

问题原因解决方案
角点检测失败光照不均、棋盘格反光调整光照,使用哑光棋盘格
重投影误差过大图像数量不足、棋盘格未贴平增加图像数量,确保棋盘格平整
畸变校正后图像扭曲畸变系数异常检查标定图像是否覆盖了图像边缘
fx和fy差异过大相机传感器像素不是正方形检查相机型号,部分工业相机确实如此

曾经踩过的坑:有一次标定结果畸变系数特别大,k1到了-0.8。我排查了半天,发现是棋盘格没贴平,中间鼓起来一块。重新贴平后,k1变成了-0.23,正常了。所以,棋盘格一定要贴在绝对平整的表面上。

3.9 标定结果的保存与加载

标定结果当然要保存下来,方便后续使用:

# 保存标定结果
np.savez('calibration_result.npz', mtx=mtx, dist=dist, 
         newcameramtx=newcameramtx, roi=roi)

# 加载标定结果
with np.load('calibration_result.npz') as data:
    mtx = data['mtx']
    dist = data['dist']
    newcameramtx = data['newcameramtx']
    roi = data['roi']

我个人习惯把标定结果和对应的图像一起保存,方便以后复现。毕竟,标定参数变了,整个系统的精度都会受影响。

3.10 本章小结

相机内参标定,说白了就是一次「相机和世界的对话」。通过棋盘格这个翻译官,我们让相机说出了自己的秘密——内参和畸变。

这一章我们走完了完整流程:从棋盘格准备、图像采集、角点检测,到标定求解、结果验证、畸变校正。每一步都有坑,但只要你按照流程来,结果不会差。

下一章,我们会进入多相机标定,看看如何让两台相机「对齐」它们的视野。到时候,你会发现内参标定只是热身,真正的挑战才刚刚开始。

核心要点回顾

  • 棋盘格标定是相机内参标定的标准方法,精度可达0.1像素级
  • 图像采集要覆盖不同角度、距离和位置,20-30张为宜
  • 亚像素角点优化是提升精度的关键步骤
  • 重投影误差小于0.5像素算合格,小于0.2像素算优秀
  • 畸变校正后要检查直线是否变直,这是最直观的验证方法

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