入门 · 1-2周

项目八:相机标定系统

实现完整张正友标定流水线——棋盘格角点亚像素检测、内参矩阵求解、畸变校正与重投影误差评估。

入门难度 预计1-2周 6个章节

1. 项目背景与学习目标

机械工程师视角

你用三坐标测量机(CMM)校准零件尺寸——已知标准件(棋盘格),测量多个姿态下的偏差,最小二乘拟合系统误差模型。相机标定是完全一样的逻辑:已知格点间距=标准件尺寸,检测角点=CMM探头,标定结果=误差补偿表。

什么是相机标定

相机标定就是求出针孔相机模型的5个内参:焦距$f_x, f_y$、主点$c_x, c_y$、畸变参数$k_1, k_2, p_1, p_2, k_3$。这些参数告诉你:像素坐标(u,v)对应的3D空间射线方向是什么。

$$s\begin{bmatrix}u\\v\\1\end{bmatrix} = \underbrace{\begin{bmatrix}f_x & 0 & c_x\\0 & f_y & c_y\\0 & 0 & 1\end{bmatrix}}_{K} \begin{bmatrix}X_c\\Y_c\\Z_c\end{bmatrix}$$

学习目标

2. 项目结构与环境搭建

目录结构

camera_calibration/
├── data/
│   ├── images/              # 20+张不同姿态棋盘格照片
│   │   ├── calib_001.jpg
│   │   ├── calib_002.jpg
│   │   └── ...
│   └── validation/          # 验证集(未参与标定的图片)
│       ├── val_001.jpg
│       └── val_002.jpg
├── src/
│   ├── calibrate.py         # 主标定脚本
│   ├── undistort.py         # 畸变校正脚本
│   └── verify.py            # 验证脚本
├── output/
│   ├── calibration.yaml     # 标定结果(K, dist, rvecs, tvecs)
│   ├── corners_visualized/  # 角点检测可视化
│   └── reprojection_report.png
└── README.md

环境准备

# 安装依赖
pip install opencv-python numpy matplotlib pyyaml

# 打印棋盘格(10列×7行,方格30mm)
# 贴到平整刚性板上——铝板最好,纸板会翘曲

# 拍照要点:
# 1. 覆盖相机视野的四角+中心
# 2. 每张图棋盘格姿态不同(旋转>15°,倾斜>20°)
# 3. 至少20张——更多图片→更稳定解
# 4. 对焦清晰,无运动模糊
# 5. 棋盘格占据图像面积>30%

3. 核心实现:完整标定流水线

import cv2
import numpy as np
import glob, yaml, os
from pathlib import Path

class CameraCalibrator:
    """张正友相机标定——完整的工业级实现"""
    
    def __init__(self, pattern_size=(9, 6), square_size=0.030):
        """
        Args:
            pattern_size: 棋盘格内角点数 (cols, rows)
            square_size: 方格边长 (米)
        """
        self.pattern_size = pattern_size
        self.square_size = square_size
        self.K = None   # 内参矩阵
        self.dist = None # 畸变系数
        self.rvecs = []   # 每个视角的旋转向量
        self.tvecs = []   # 每个视角的平移向量
        
    def detect_corners(self, image_paths):
        """检测所有图像中的棋盘格角点(亚像素精度)"""
        # 3D世界点(棋盘格平面z=0)
        objp = np.zeros((
            self.pattern_size[0] * self.pattern_size[1], 3
        ), np.float32)
        objp[:, :2] = np.mgrid[
            0:self.pattern_size[0],
            0:self.pattern_size[1]
        ].T.reshape(-1, 2)
        objp *= self.square_size
        
        obj_points = []  # 3D点
        img_points = []  # 2D像素点
        valid_paths = []
        
        for path in image_paths:
            img = cv2.imread(path)
            gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
            
            # 1. 粗略角点检测
            ret, corners = cv2.findChessboardCorners(
                gray, self.pattern_size,
                flags=cv2.CALIB_CB_ADAPTIVE_THRESH + cv2.CALIB_CB_FAST_CHECK
            )
            
            if not ret:
                print(f"⚠️ 未检测到棋盘格: {path}")
                continue
            
            # 2. 亚像素精细化(关键步骤!精度提升10x)
            criteria = (cv2.TermCriteria_EPS + cv2.TermCriteria_MAX_ITER, 30, 0.001)
            corners_sub = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria)
            
            obj_points.append(objp)
            img_points.append(corners_sub)
            valid_paths.append(path)
        
        print(f"✅ 成功检测 {len(obj_points)}/{len(image_paths)} 张图像")
        return obj_points, img_points, valid_paths
    
    def calibrate(self, obj_points, img_points, image_size):
        """执行张正友标定"""
        print("
🔧 开始标定...")
        print(f"   图像数量: {len(obj_points)}")
        print(f"   图像尺寸: {image_size}")
        print(f"   棋盘格: {self.pattern_size}")
        print(f"   方格边长: {self.square_size*1000}mm")
        
        # OpenCV的张正友标定实现
        ret, K, dist, rvecs, tvecs = cv2.calibrateCamera(
            obj_points, img_points, image_size,
            cameraMatrix=None, distCoeffs=None,
            flags=cv2.CALIB_RATIONAL_MODEL  # k1,k2,p1,p2,k3,k4,k5,k6
        )
        
        self.K = K
        self.dist = dist
        self.rvecs = rvecs
        self.tvecs = tvecs
        
        # 计算重投影误差
        total_error = 0
        per_view_errors = []
        
        for i in range(len(obj_points)):
            img_points_proj, _ = cv2.projectPoints(
                obj_points[i], rvecs[i], tvecs[i], K, dist)
            error = cv2.norm(img_points[i], img_points_proj, cv2.NORM_L2) / len(img_points_proj)
            total_error += error
            per_view_errors.append(error)
        
        mean_error = total_error / len(obj_points)
        print(f"
📊 标定结果:")
        print(f"   内参矩阵 K =")
        print(f"   [[{K[0,0]:.1f},    {0.0:.1f}, {K[0,2]:.1f}],")
        print(f"    [   {0.0:.1f}, {K[1,1]:.1f}, {K[1,2]:.1f}],")
        print(f"    [   {0.0:.1f},    {0.0:.1f},    {1.0:.1f}]]")
        print(f"   平均重投影误差: {mean_error:.4f} px")
        
        if mean_error > 0.5:
            print(f"   ⚠️ 误差过大(>0.5px),建议:")
            print(f"      1. 检查是否有运动模糊的照片")
            print(f"      2. 增加标定图像数量(>25张)")
            print(f"      3. 确保棋盘格平整无翘曲")
            # 找出误差最大的图像并建议移除
            worst_idx = np.argmax(per_view_errors)
            print(f"      4. 考虑移除误差最大的图像(#{worst_idx}: {per_view_errors[worst_idx]:.4f}px)")
        
        return mean_error
    
    def save(self, filepath):
        """保存标定结果为YAML"""
        os.makedirs(os.path.dirname(filepath), exist_ok=True)
        data = {
            'image_width': self.image_size[0],
            'image_height': self.image_size[1],
            'camera_matrix': {
                'rows': 3, 'cols': 3,
                'data': self.K.flatten().tolist()
            },
            'distortion_coefficients': {
                'rows': 1, 'cols': len(self.dist[0]),
                'data': self.dist[0].tolist()
            }
        }
        with open(filepath, 'w') as f:
            yaml.dump(data, f, default_flow_style=False)
        print(f"💾 标定参数已保存到 {filepath}")

# ========== 主程序 ==========
if __name__ == '__main__':
    calibrator = CameraCalibrator(
        pattern_size=(9, 6),
        square_size=0.030  # 30mm方格
    )
    
    # 1. 检测角点
    images = sorted(glob.glob('data/images/*.jpg'))
    obj_pts, img_pts, valid = calibrator.detect_corners(images)
    
    # 2. 标定
    img = cv2.imread(images[0])
    calibrator.calibrate(obj_pts, img_pts, img.shape[1::-1])
    
    # 3. 保存
    calibrator.save('output/calibration.yaml')

4. 畸变校正——从理论到实践

畸变模型

径向畸变(桶形/枕形)+ 切向畸变(镜头组装不平行):

$$x_{distorted} = x(1 + k_1 r^2 + k_2 r^4 + k_3 r^6) + 2p_1 xy + p_2(r^2 + 2x^2)$$
$$y_{distorted} = y(1 + k_1 r^2 + k_2 r^4 + k_3 r^6) + p_1(r^2 + 2y^2) + 2p_2 xy$$
def undistort_image(img, K, dist, alpha=0.0):
    """畸变校正,alpha=0→裁剪到有效区,alpha=1→保留所有像素(带黑边)"""
    h, w = img.shape[:2]
    
    # 计算最优新内参(基于alpha平衡裁剪/黑边)
    new_K, roi = cv2.getOptimalNewCameraMatrix(K, dist, (w, h), alpha, (w, h))
    
    # 畸变校正
    undistorted = cv2.undistort(img, K, dist, None, new_K)
    
    # 裁剪有效区域
    if alpha == 0:
        x, y, w_roi, h_roi = roi
        undistorted = undistorted[y:y+h_roi, x:x+w_roi]
    
    return undistorted, new_K

# 验证:校正后直线的straightness
def verify_undistortion(original, undistorted, K, dist):
    """校正前后对比:在原始图上画棋盘格检测线,校正后应成直线"""
    fig, axes = plt.subplots(1, 2, figsize=(12, 5))
    axes[0].imshow(cv2.cvtColor(original, cv2.COLOR_BGR2RGB))
    axes[0].set_title('原始图像(注意边缘弯曲)')
    axes[1].imshow(cv2.cvtColor(undistorted, cv2.COLOR_BGR2RGB))
    axes[1].set_title('校正后(直线应笔直)')
    plt.savefig('output/undistort_comparison.png', dpi=150)
🔧 工程连接:镜头畸变和你的机床几何误差完全相同——直线度误差(径向畸变) + 垂直度误差(切向畸变)。21项机床误差模型对标定的启示:多测几个姿态→误差模型更完整。

5. 标定质量评估与调试

标定质量指标

指标优秀可接受不合格
平均重投影误差< 0.3 px0.3-0.5 px> 0.5 px
最大重投影误差< 0.8 px0.8-1.5 px> 1.5 px
fx/fy差异< 2%2-5%> 5%(镜头异常)
主点偏移< 图像尺寸的2%2-5%> 5%(安装偏差大)

常见问题诊断

def diagnose_calibration(calibrator, obj_pts, img_pts):
    """标定故障诊断"""
    
    # 问题1: 误差集中在某几张图
    per_view_errors = []
    for i in range(len(obj_pts)):
        proj, _ = cv2.projectPoints(obj_pts[i], calibrator.rvecs[i], 
                                       calibrator.tvecs[i], calibrator.K, calibrator.dist)
        error = cv2.norm(img_pts[i], proj, cv2.NORM_L2) / len(proj)
        per_view_errors.append(error)
    
    # 找出异常图像
    mean_err = np.mean(per_view_errors)
    std_err = np.std(per_view_errors)
    outliers = [i for i, e in enumerate(per_view_errors) if e > mean_err + 2*std_err]
    
    print(f"标定诊断报告:")
    print(f"  平均误差: {mean_err:.4f} px")
    print(f"  误差标准差: {std_err:.4f} px")
    if outliers:
        print(f"  ⚠️ 异常图像(#): {outliers} (误差 > 均值+2σ)")
        print(f"  建议: 重新拍摄这些视角或从标定集移除")
    
    # 问题2: fx/fy不合理
    fx, fy = calibrator.K[0,0], calibrator.K[1,1]
    if abs(fx/fy - 1.0) > 0.05:
        print(f"  ⚠️ fx/fy = {fx/fy:.3f}, 像素非正方形?检查传感器")
    
    # 问题3: 主点偏移过大
    cx, cy = calibrator.K[0,2], calibrator.K[1,2]
    if abs(cx - img_w/2) > img_w * 0.05:
        print(f"  ⚠️ 主点cx={cx:.1f}偏移图像中心{img_w/2:.1f} > 5%,镜头安装可能偏心")

验收标准