4、单目标定实战:使用OpenCV进行单目相机标定、获取内参和畸变系数、重投影误差评估

好,咱们今天来点真家伙。

前面讲了那么多理论,什么针孔模型、畸变模型、坐标系变换……说实话,光看公式容易犯困。我自己当年学的时候也是,看了三遍张正友标定法的论文,脑子里还是一团浆糊。直到我打开OpenCV,跑通了第一个标定程序,看到那些参数真的能矫正图像畸变——那一刻,才算是真正「通了」。

所以这一章,咱们直接上手。用OpenCV做一次完整的单目标定,拿到内参矩阵和畸变系数,最后再评估一下标定质量。

4.1 标定前的准备工作

先说说你需要什么。

  • 一块棋盘格标定板。你可以打印一张,贴在硬纸板上。我建议用A3纸,格子大小最好在20mm-30mm之间。太小了,远距离看不清;太大了,近距离拍不全。
  • 一台相机。手机摄像头、USB摄像头、工业相机都行。关键是——固定焦距。标定过程中千万别调焦,否则内参就变了,标定结果全废。
  • OpenCV库。我用的是OpenCV 4.x版本,Python接口。C++版本流程完全一样,只是语法不同。
我的小习惯: 标定前先把相机分辨率固定下来。比如1920x1080,后面所有标定和实际使用都用这个分辨率。分辨率一变,内参就得重新标。

4.2 采集标定图片

这一步看似简单,但坑最多。我见过太多人随便拍几张就开标,结果重投影误差大得离谱。

采集时要注意几点:

  • 数量: 至少15-20张。别偷懒,10张以下误差很难压下去。
  • 姿态: 棋盘格要在画面各个位置出现——左上、右下、中间、近处、远处、倾斜。说白了,要让相机从各个角度「看到」棋盘格。
  • 光照: 均匀一点。别让棋盘格上有大面积反光或阴影,角点检测会失败。

我曾经在一个项目中,为了省事只拍了12张,结果标出来的畸变系数在图像边缘处矫正过度,画面反而变形了。后来老老实实补到25张,一次过。

采集代码很简单:

import cv2
import os

cap = cv2.VideoCapture(0)
count = 0
os.makedirs('calib_images', exist_ok=True)

while True:
    ret, frame = cap.read()
    if not ret:
        break
    cv2.imshow('Press Space to capture', frame)
    key = cv2.waitKey(1)
    if key == ord(' '):
        cv2.imwrite(f'calib_images/img_{count}.jpg', frame)
        print(f'Captured {count}')
        count += 1
    elif key == ord('q'):
        break

cap.release()
cv2.destroyAllWindows()

4.3 角点检测与亚像素优化

有了图片,下一步就是找到棋盘格的内角点。OpenCV提供了cv2.findChessboardCorners(),但直接检测出来的角点是像素级别的整数坐标。嗯,这里要注意——标定精度要求高,我们需要亚像素级别的精度。

import cv2
import numpy as np

# 棋盘格内角点数量(列数-1,行数-1)
pattern_size = (9, 6)  # 9x6的内角点
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)

obj_points = []  # 世界坐标系中的点
img_points = []  # 图像坐标系中的点

images = [f'calib_images/img_{i}.jpg' for i in range(20)]

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:
        obj_points.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)
        img_points.append(corners2)
        
        # 可视化
        cv2.drawChessboardCorners(img, pattern_size, corners2, ret)
        cv2.imshow('Corners', img)
        cv2.waitKey(500)

cv2.destroyAllWindows()
关键点: cornerSubPix 的窗口大小 (11,11) 是经验值。如果棋盘格在图像中很小,可以减小到 (5,5);如果很大,可以增大到 (15,15)。我一般先用默认值,不行再调。

4.4 执行标定:获取内参和畸变系数

数据准备好了,标定就一行代码的事:

ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera(
    obj_points, img_points, gray.shape[::-1], None, None
)

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

输出结果大概长这样:

参数含义示例值
fx, fy焦距(像素单位)825.34, 824.67
cx, cy主点坐标640.12, 360.45
k1, k2, k3径向畸变系数-0.283, 0.112, -0.031
p1, p2切向畸变系数0.0012, -0.0008

拿到这些参数后,你就可以用 cv2.undistort() 来矫正任意一张图片了。说白了,这就是标定的最终目的——让图像变「直」。

4.5 重投影误差评估

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

最常用的指标就是重投影误差。简单说:把世界坐标系中的棋盘格角点,用标定出来的内参和外参投影回图像平面,算算投影点和实际检测到的角点差了多少像素。

total_error = 0
for i in range(len(obj_points)):
    img_points2, _ = cv2.projectPoints(
        obj_points[i], rvecs[i], tvecs[i], mtx, dist
    )
    error = cv2.norm(img_points[i], img_points2, cv2.NORM_L2) / len(img_points2)
    total_error += error

print(f"平均重投影误差: {total_error / len(obj_points):.4f} 像素")
经验阈值: 误差小于0.5像素,说明标定质量不错。0.3以下算优秀。如果超过1.0像素,建议检查图片质量或增加图片数量。我曾经有一次标出来误差1.8像素,查了半天发现是棋盘格打印时纸张没贴平,有褶皱。

4.6 保存与加载标定结果

标定结果不能每次都重新跑。我习惯保存成numpy文件或YAML格式:

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

# 加载
data = np.load('calibration_data.npz')
mtx = data['mtx']
dist = data['dist']

这样下次直接用,省时省力。

4.7 避坑指南

最后,分享几个我踩过的坑:

  • 棋盘格不要歪得太离谱。 倾斜角度超过45度,角点检测容易失败。
  • 图片不要全是一个姿态。 如果所有图片棋盘格都在画面中央,标出来的畸变系数在边缘处会很不准。
  • 标定板要平整。 贴在玻璃板上最好,普通纸张容易弯曲。
  • 别忘了固定焦距。 变焦镜头标定完再变焦,内参就变了,得重新标。

好了,这一章的内容就是这些。你跟着代码跑一遍,拿到内参和畸变系数,再算算重投影误差——如果误差在0.3以内,恭喜你,标定成功了。下一章咱们聊聊多相机联合标定,那才是真正有意思的部分。