4. 相机标定实战:使用calibrateCamera计算内参和畸变系数

好,到了真正动手的环节了。

前面我们聊了那么多理论,什么针孔模型、畸变模型、棋盘格检测……说实话,这些知识如果不落地跑一遍,你很快就会忘掉。我自己带新人时最深的体会就是:标定这件事,看十遍不如亲手跑一次

这一节,我们就用OpenCV的calibrateCamera函数,把内参矩阵和畸变系数算出来。我会带着你一步步走完整个流程,顺便聊聊我在项目中踩过的坑。

4.1 标定的核心流程

先给你一张流程图,把整个标定逻辑串起来。你心里有个谱,后面看代码就不会晕。

相机标定核心流程 ① 准备标定板 打印棋盘格 / 圆点图 ② 采集多张图像 不同角度、不同距离 ③ 角点检测 findChessboardCorners ④ 亚像素精细化 cornerSubPix(可选) ⑤ 构建世界坐标 棋盘格物理点坐标 ⑥ calibrateCamera 输出:内参矩阵 + 畸变系数 虚线:角点坐标同时用于世界坐标构建

说白了,标定就是做一件事:找到真实世界中的点,和图像上的点之间的映射关系。calibrateCamera就是帮我们解这个映射的数学工具。

4.2 准备数据:世界坐标与图像坐标

在调用calibrateCamera之前,我们需要准备两组数据:

  • objpoints:棋盘格上每个角点的世界坐标(单位:毫米)
  • imgpoints:这些角点在图像上的像素坐标

这里有个关键点——世界坐标怎么定?

我习惯把棋盘格左上角第一个内角点定为原点(0,0,0),然后沿着棋盘格的行和列方向,依次增加。比如一个9×6的棋盘格(内角点),每个格子边长25mm,那么世界坐标就是:

import numpy as np

# 棋盘格参数
pattern_size = (9, 6)   # 内角点数量 (列, 行)
square_size = 25.0      # 每个格子边长,单位mm

# 构建世界坐标
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)
objp *= square_size

print(objp[:5])
# 输出:
# [[ 0.  0.  0.]
#  [25.  0.  0.]
#  [50.  0.  0.]
#  [75.  0.  0.]
#  [100. 0.  0.]]
💡 我的习惯:世界坐标的Z轴统一设为0。因为棋盘格是平面,所有角点都在同一个平面上。这样简化了问题,标定结果也足够准确。

4.3 角点检测与存储

接下来,我们要从每张图片中提取角点。OpenCV提供了findChessboardCorners,用起来很简单:

import cv2
import glob

# 存储所有图片的角点
objpoints = []  # 世界坐标
imgpoints = []  # 图像坐标

# 读取所有标定图片
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, pattern_size, None)
    
    if ret:
        objpoints.append(objp)
        imgpoints.append(corners)
        
        # 可视化角点(可选)
        cv2.drawChessboardCorners(img, pattern_size, corners, ret)
        cv2.imshow('Corners', img)
        cv2.waitKey(100)

cv2.destroyAllWindows()

嗯,这里要注意几个细节:

  • 图片数量:我建议至少拍15-20张。太少的话,标定结果不稳定。
  • 角度变化:让棋盘格出现在画面的各个位置——左上、右下、中间、倾斜……覆盖整个视场。
  • 距离变化:远近都要拍。我见过有人只拍一个距离,结果标定出来的畸变系数完全不对。
⚠️ 我曾经踩过的坑:有一次我偷懒只拍了8张图片,结果标定出来的焦距值偏差了5%以上。后来补拍到20张,结果才稳定下来。所以,数量和质量同样重要

4.4 核心函数:calibrateCamera

数据准备好了,终于轮到主角登场了。

# 图像尺寸(用于初始化内参矩阵)
img_size = gray.shape[::-1]  # (width, height)

# 执行标定
ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera(
    objpoints, 
    imgpoints, 
    img_size, 
    None, 
    None
)

print("内参矩阵 (Camera Matrix):")
print(mtx)
print("\n畸变系数 (Distortion Coefficients):")
print(dist)

输出结果长这样:

内参矩阵 (Camera Matrix):
[[1.532e+03 0.000e+00 9.600e+02]
 [0.000e+00 1.528e+03 5.400e+02]
 [0.000e+00 0.000e+00 1.000e+00]]

畸变系数 (Distortion Coefficients):
[[-0.321  0.108 -0.002  0.001  0.000]]

我来解释一下这些数字的含义:

参数 含义 示例值
fx, fy 焦距(像素单位) 1532, 1528
cx, cy 主点坐标(图像中心) 960, 540
k1, k2 径向畸变系数 -0.321, 0.108
p1, p2 切向畸变系数 -0.002, 0.001
k3 径向畸变第三项(通常为0) 0.000
🔑 关键解读:
  • fx和fy越接近,说明镜头在x和y方向的放大率越一致。
  • cx和cy越接近图像中心,说明镜头安装越正。
  • k1为负值表示桶形畸变(广角镜头常见),正值表示枕形畸变(长焦镜头常见)。

4.5 评估标定质量

标定完了,怎么知道结果好不好?

calibrateCamera返回的ret就是重投影误差(reprojection error)。这个值越小越好,通常小于0.5像素就算不错了。

print(f"重投影误差: {ret:.4f} 像素")
# 输出示例:重投影误差: 0.1234 像素

你也可以手动计算每张图片的重投影误差:

mean_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)
    mean_error += error

print(f"平均重投影误差: {mean_error/len(objpoints):.4f} 像素")
💡 实战经验:如果重投影误差大于1个像素,我通常会检查以下几点:
  1. 角点检测是否准确?有没有漏检或误检?
  2. 棋盘格是否平整?有没有弯曲?
  3. 图片数量是否足够?角度覆盖是否全面?

4.6 保存标定结果

标定结果来之不易,一定要保存好。我习惯用numpy的.npz格式,方便下次加载:

# 保存
np.savez('calibration_result.npz', mtx=mtx, dist=dist, rvecs=rvecs, tvecs=tvecs)

# 加载
with np.load('calibration_result.npz') as data:
    mtx = data['mtx']
    dist = data['dist']

你想想看,下次做畸变校正时,直接加载这个文件就行了,不用重新标定。省时省力。

4.7 完整代码示例

最后,给你一个完整的标定脚本。你可以直接拿去用,改一下图片路径和棋盘格参数就行。

import cv2
import numpy as np
import glob

# 参数设置
pattern_size = (9, 6)   # 内角点数量
square_size = 25.0      # 格子边长 (mm)
image_path = 'calib_images/*.jpg'

# 准备世界坐标
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)
objp *= square_size

# 存储数据
objpoints = []
imgpoints = []

# 遍历图片
images = glob.glob(image_path)
print(f"找到 {len(images)} 张标定图片")

for fname in images:
    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)
        print(f"✓ {fname} - 角点检测成功")
    else:
        print(f"✗ {fname} - 角点检测失败")

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

# 输出结果
print(f"\n重投影误差: {ret:.4f}")
print("内参矩阵:\n", mtx)
print("畸变系数:\n", dist)

# 保存结果
np.savez('calibration_result.npz', mtx=mtx, dist=dist, rvecs=rvecs, tvecs=tvecs)
print("\n标定结果已保存到 calibration_result.npz")

运行这个脚本,只要你的图片质量没问题,几分钟就能拿到标定结果。

好了,这一节的内容就到这里。你学会了如何用calibrateCamera计算内参和畸变系数,也知道了怎么评估标定质量。下一节,我们会用这些结果来做畸变校正,把变形的图像拉直。到时候你会发现——原来歪歪扭扭的图片,也能变得这么规整。


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