四、相机标定实战:使用OpenCV进行棋盘格标定

相机标定,说白了就是给相机做一次「视力矫正」。

你想想看,每台相机出厂时都有点小毛病——镜头畸变、焦距偏差、光心偏移。如果不做标定,你从三维扫描仪里拿到的点云数据,就像戴着散光眼镜看世界,全是变形的。

我个人习惯把标定分成三步走:拍棋盘 → 算参数 → 验结果。今天我们就用OpenCV把这套流程走一遍。

核心知识点

  • 棋盘格标定的原理与流程
  • OpenCV的cv2.findChessboardCorners()和cv2.calibrateCamera()
  • 重投影误差的计算与评估
  • 标定结果的保存与加载
① 拍摄棋盘格图像 至少10-20张不同角度 ② 提取角点坐标 cv2.findChessboardCorners() ③ 计算相机参数 cv2.calibrateCamera() ④ 评估标定结果 计算重投影误差 ⑤ 保存标定文件 XML / YAML / JSON ⑥ 加载标定文件 用于后续三维重建 迭代优化:如果误差太大,回到步骤①重新拍摄

4.1 准备工作:棋盘格与拍摄

棋盘格标定板,你可以打印出来贴在平板上。我建议用10×7的内角点棋盘(也就是9×6的格子),尺寸大一点,角点更容易被检测到。

我的经验:棋盘格一定要贴平!我曾经用亚克力板夹着打印纸,结果纸面有轻微褶皱,标定出来的畸变参数全是错的。后来换了3mm厚的铝基板,问题才解决。

拍摄时注意几点:

  • 至少拍15-20张,覆盖不同角度和距离
  • 棋盘格要占画面30%-70%
  • 避免过度倾斜(超过45度角点检测容易失败)
  • 光照均匀,不要有强反光

4.2 核心代码:角点提取与标定

直接上代码。这段代码我用了很多项目,基本是标定的「标准模板」。

import cv2
import numpy as np
import glob

# 棋盘格参数
CHECKERBOARD = (9, 6)  # 内角点数
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)

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

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)
        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)

cv2.destroyAllWindows()

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

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

注意:cv2.findChessboardCorners()对图像分辨率敏感。如果棋盘格太小,可以先用cv2.resize()放大图像再检测。我遇到过1080p的图检测不到,缩放到2倍就成功了。

4.3 标定结果评估:重投影误差

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

原理很简单:用标定出的参数,把3D角点投影回图像平面,算算和实际检测到的2D角点差了多少像素。

def compute_reprojection_error(objpoints, imgpoints, rvecs, tvecs, mtx, dist):
    total_error = 0
    total_points = 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
        total_points += len(imgpoints2)
    
    mean_error = total_error / len(objpoints)
    return mean_error

reprojection_error = compute_reprojection_error(
    objpoints, imgpoints, rvecs, tvecs, mtx, dist
)
print(f"平均重投影误差: {reprojection_error:.4f} 像素")

误差多少算好?我个人经验:

误差范围 评价 建议
< 0.3 像素 优秀 可以直接用于高精度三维扫描
0.3 - 0.8 像素 良好 大多数场景可用,检查是否有系统偏差
0.8 - 1.5 像素 一般 建议重新拍摄,检查棋盘格是否平整
> 1.5 像素 标定失败,检查代码或硬件

避坑指南:我曾经遇到一次标定,误差只有0.15像素,但实际三维重建效果很差。后来发现是因为所有棋盘格图像都集中在画面中央,边缘的畸变根本没被约束到。所以拍摄时一定要让棋盘格出现在画面的各个角落。

4.4 标定文件保存与加载

标定完的参数要存下来,不然每次启动都重新标定,太傻了。

我习惯用XML格式保存,因为可读性好,也方便其他语言调用。

import cv2

# 保存标定结果
fs = cv2.FileStorage('camera_calib.xml', cv2.FILE_STORAGE_WRITE)
fs.write('camera_matrix', mtx)
fs.write('dist_coeffs', dist)
fs.write('reprojection_error', reprojection_error)
fs.release()

print("标定文件已保存到 camera_calib.xml")

加载时也很简单:

# 加载标定结果
fs = cv2.FileStorage('camera_calib.xml', cv2.FILE_STORAGE_READ)
mtx_loaded = fs.getNode('camera_matrix').mat()
dist_loaded = fs.getNode('dist_coeffs').mat()
error_loaded = fs.getNode('reprojection_error').real()
fs.release()

print("加载完成,重投影误差:", error_loaded)

小技巧:除了XML,你也可以用np.savez()保存为.npz格式,加载更快。但XML的好处是可以用文本编辑器打开查看,调试时很方便。

4.5 实战中的常见问题

做标定这么多年,我总结了几条高频踩坑点:

  • 角点检测失败:试试调整cv2.findChessboardCorners()的flags参数,或者用自适应阈值预处理图像
  • 标定结果不稳定:每次运行结果不一样?说明图像数量不够或角度变化太小,加到30张试试
  • 畸变校正后图像边缘扭曲:这是正常的,因为镜头边缘畸变最大。可以适当裁剪边缘区域
  • 重投影误差小但实际效果差:检查棋盘格是否变形,或者相机是否在标定过程中被碰过

嗯,到这里标定的核心流程就走通了。你拿着这套代码,配合一块打印的棋盘格,半小时内就能完成一次完整的相机标定。记住,标定不是一次性的活——换镜头、调焦距、甚至温度变化都可能需要重新标定。


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