张氏标定 Python 实现

import cv2
import numpy as np
import os
import glob
from scipy.optimize import least_squares
import sys

# 本代码为博客【张正友标定法解析与C++代码实现】的Python对应实现
# 程序中公式的原理,可查阅此博客
# https://blog.csdn.net/memmolo/article/details/131741018

def readImages(pattern, images):
    """读取指定路径下的PNG图片"""
    # 查找路径下所有png图片,非递归
    list_files = glob.glob(pattern)
    
    if not list_files:
        print("未在路径下找到PNG格式图片!")
        return False
    else:
        print(f"找到图片数量:{len(list_files)}")
        for file_path in list_files:
            print(file_path)
            # 读取图片至列表
            img = cv2.imread(file_path, cv2.IMREAD_UNCHANGED)
            images.append(img)
        return True

def findCorners(images, w, h, scale, board_type, imgPntsVec, objPntsVec):
    """查找棋盘格角点"""
    imgPoints = []
    objPoints = []
    # 生成物体坐标系中的角点坐标列表
    for i in range(h):
        for j in range(w):
            objPoints.append((j * scale, i * scale, 0))
    
    count = 0
    for img in images:
        if board_type == "chessboard":
            # 调用OpenCV查找棋盘格角点,findChessboardCornersSB是亚像素级角点检测
            ret, corners = cv2.findChessboardCornersSB(img, (w, h), flags=0)
        elif board_type == "circleboard":
            ret, corners = cv2.findCirclesGrid(img, (w, h))
        else:
            print("unknow calib board")
            exit()
        if ret:
            imgPntsVec.append(corners)
            objPntsVec.append(objPoints)
            count += 1
            # 显示棋盘格检测结果
            cv2.namedWindow("Corners")
            if len(img.shape) == 2:  # 灰度图(只有高、宽,无通道)
                img_copy = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)  # 灰度转BGR彩色
            else:  # 已是彩色图(有通道维度)
                img_copy = img.copy()  # 直接复制彩色图
            cv2.drawChessboardCorners(img_copy, (w, h), corners, ret)
            cv2.imshow("Corners", img_copy)
            cv2.waitKey(0)
            cv2.destroyWindow("Corners")
    
    if count < 3:
        print("检测出的有效棋盘格标定板数量小于3!")
        return False
    else:
        print(f"检测出有效棋盘格标定板数量为:{count}")
        return True

def findHomography(imgPoints, objPoints):
    """求解单应矩阵(无归一化)"""
    number = len(imgPoints)
    A = np.zeros((number * 2, 9), dtype=np.float64)
    
    # 构造A矩阵,参考博客公式(1)
    for i in range(number):
        u = imgPoints[i][0][0]
        v = imgPoints[i][0][1]
        x = objPoints[i][0]
        y = objPoints[i][1]
        
        A[2*i, 0] = x
        A[2*i, 1] = y
        A[2*i, 2] = 1
        A[2*i, 3] = 0
        A[2*i, 4] = 0
        A[2*i, 5] = 0
        A[2*i, 6] = -u * x
        A[2*i, 7] = -u * y
        A[2*i, 8] = -u
        
        A[2*i+1, 0] = 0
        A[2*i+1, 1] = 0
        A[2*i+1, 2] = 0
        A[2*i+1, 3] = x
        A[2*i+1, 4] = y
        A[2*i+1, 5] = 1
        A[2*i+1, 6] = -v * x
        A[2*i+1, 7] = -v * y
        A[2*i+1, 8] = -v
    
    # 求解Ax=0 (SVD分解)
    u, s, vt = np.linalg.svd(A)
    homo = vt[-1].reshape(-1, 1)
    
    # 单应矩阵处理,保持最后一个元素为1
    homo = homo / homo[8, 0]
    homo = homo.reshape(3, 3)
    
    return homo

def normalizePoints(points_2d=None, points_3d=None):
    """
    对点进行归一化,使求解更稳定
    支持2D点或3D点归一化,返回归一化后的点和归一化矩阵N
    """
    N = np.eye(3, dtype=np.float64)
    
    if points_2d is not None:
        points = np.array([(p[0][0], p[0][1]) for p in points_2d])
        is_2d = True
    elif points_3d is not None:
        points = np.array(points_3d)[:, :2]  # 只取x,y
        is_2d = False
    else:
        return None, N
    
    num = len(points)
    # 计算均值
    xmean = np.mean(points[:, 0])
    ymean = np.mean(points[:, 1])
    
    # 计算标准差
    xstd = np.sqrt(np.sum((points[:, 0] - xmean)**2) / num)
    ystd = np.sqrt(np.sum((points[:, 1] - ymean)**2) / num)
    
    # 点归一化
    normalized = points.copy()
    normalized[:, 0] = (normalized[:, 0] - xmean) / xstd
    normalized[:, 1] = (normalized[:, 1] - ymean) / ystd
    
    # 构建归一化矩阵
    N[0, 0] = 1 / xstd
    N[0, 2] = -xmean / xstd
    N[1, 1] = 1 / ystd
    N[1, 2] = -ymean / ystd
    
    # 转换回原格式
    if is_2d:
        normalized_points = [[(x, y)] for x, y in normalized]
    else:
        normalized_points = [(x, y, 0) for x, y in normalized]
    
    return normalized_points, N

def findHomographyWithNormalization(imgPoints, objPoints):
    """求解单应矩阵(带归一化)"""
    number = len(imgPoints)
    A = np.zeros((number * 2, 9), dtype=np.float64)
    
    # 物点归一化
    objNPnts, No = normalizePoints(points_3d=objPoints)
    # 图像点归一化
    imgNPnts, Ni = normalizePoints(points_2d=imgPoints)
    
    # 构造A矩阵
    for i in range(number):
        u = imgNPnts[i][0][0]
        v = imgNPnts[i][0][1]
        x = objNPnts[i][0]
        y = objNPnts[i][1]
        
        A[2*i, 0] = x
        A[2*i, 1] = y
        A[2*i, 2] = 1
        A[2*i, 3] = 0
        A[2*i, 4] = 0
        A[2*i, 5] = 0
        A[2*i, 6] = -u * x
        A[2*i, 7] = -u * y
        A[2*i, 8] = -u
        
        A[2*i+1, 0] = 0
        A[2*i+1, 1] = 0
        A[2*i+1, 2] = 0
        A[2*i+1, 3] = x
        A[2*i+1, 4] = y
        A[2*i+1, 5] = 1
        A[2*i+1, 6] = -v * x
        A[2*i+1, 7] = -v * y
        A[2*i+1, 8] = -v
    
    # 求解Ax=0
    u, s, vt = np.linalg.svd(A)
    homo = vt[-1].reshape(3, 3)
    
    # 参考博客公式(3)
    homo = np.linalg.inv(Ni) @ homo @ No
    
    # 单应矩阵处理,保持最后一个元素为1
    homo = homo / homo[2, 2]
    
    return homo

def findAllHomography(imgPntsVec, objPntsVec, homoVec):
    """求解所有图像的单应矩阵"""
    for imgPnts, objPnts in zip(imgPntsVec, objPntsVec):
        # 使用带归一化的单应矩阵求解
        homo = findHomographyWithNormalization(imgPnts, objPnts)
        homoVec.append(homo)

def estimateIntrinsics(homoVec, K):
    """从单应矩阵估计内参"""
    num_homo = len(homoVec)
    A = np.zeros((num_homo * 2, 5), dtype=np.float64)
    
    # 构造Ax=0的A矩阵,参考博客公式(4)
    for i in range(num_homo):
        h11 = homoVec[i][0, 0]
        h21 = homoVec[i][1, 0]
        h31 = homoVec[i][2, 0]
        h12 = homoVec[i][0, 1]
        h22 = homoVec[i][1, 1]
        h32 = homoVec[i][2, 1]
        
        A[2*i, 0] = h11 * h12
        A[2*i, 1] = h21 * h22
        A[2*i, 2] = h31 * h12 + h11 * h32
        A[2*i, 3] = h31 * h22 + h21 * h32
        A[2*i, 4] = h31 * h32
        
        A[2*i+1, 0] = h11*h11 - h12*h12
        A[2*i+1, 1] = h21*h21 - h22*h22
        A[2*i+1, 2] = 2 * (h11*h31 - h12*h32)
        A[2*i+1, 3] = 2 * (h21*h31 - h22*h32)
        A[2*i+1, 4] = h31*h31 - h32*h32
    
    # 求解B矩阵 (Ax=0)
    u, s, vt = np.linalg.svd(A)
    B = vt[-1]
    print(f"B: {B}")
    
    # 从B矩阵恢复内参,假定skewness为零,参考博客公式(5)
    B11 = B[0]
    B22 = B[1]
    B13 = B[2]
    B23 = B[3]
    B33 = B[4]
    
    cx = -B13 / B11
    cy = -B23 / B22
    lmd = B33 - (B13*B13/B11 + B23*B23/B22)
    fx = np.sqrt(lmd / B11)
    fy = np.sqrt(lmd / B22)
    
    # print(f"内参:fx: {fx} fy: {fy} cx: {cx} cy: {cy} lmd: {lmd}")
    
    # 给内参矩阵赋值
    K[0, 0] = fx
    K[1, 1] = fy
    K[0, 2] = cx
    K[1, 2] = cy

def estimateExtrinsics(homoVec, K, rvec, tvec):
    """从单应矩阵和内参估计外参"""
    invK = np.linalg.inv(K)
    
    for homo in homoVec:
        # 求解外参,rrt=[r1, r2, t],参考博客公式(6)
        rrt = invK @ homo
        lmd = (np.linalg.norm(rrt[:, 0]) + np.linalg.norm(rrt[:, 1])) / 2
        rrt = rrt / lmd
        
        r1 = rrt[:, 0].reshape(-1, 1)
        r2 = rrt[:, 1].reshape(-1, 1)
        t = rrt[:, 2].reshape(-1, 1)
        r3 = np.cross(r1.flatten(), r2.flatten()).reshape(-1, 1)
        
        R = np.hstack([r1, r2, r3])
        
        # SVD分解,找到符合约束的旋转矩阵,参考博客公式(7)
        u, s, vt = np.linalg.svd(R)
        R = u @ vt
        
        # 旋转矩阵转旋转向量
        axisangle, _ = cv2.Rodrigues(R)
        rvec.append(axisangle)
        tvec.append(t)

def reprojection_error(params, imgPntsVec, objPntsVec):
    """重投影误差计算(用于数值优化)"""
    # 解析参数
    fx, fy, cx, cy = params[:4]
    k1, k2, k3, p1, p2 = params[4:9]
    poses = params[9:].reshape(-1, 6)  # 每个pose: [rx, ry, rz, tx, ty, tz]
    
    residuals = []
    for i, (imgPnts, objPnts) in enumerate(zip(imgPntsVec, objPntsVec)):
        # 获取当前位姿
        rx, ry, rz, tx, ty, tz = poses[i]
        rvec = np.array([rx, ry, rz], dtype=np.float64)
        tvec = np.array([tx, ty, tz], dtype=np.float64)
        
        # 旋转矩阵
        R, _ = cv2.Rodrigues(rvec)
        
        for j, (img_pnt, obj_pnt) in enumerate(zip(imgPnts, objPnts)):
            # 3D点转换到相机坐标系
            p3d = np.array(obj_pnt, dtype=np.float64).reshape(3, 1)
            p_cam = R @ p3d + tvec.reshape(3, 1)
            
            # 归一化平面
            x = p_cam[0, 0] / p_cam[2, 0]
            y = p_cam[1, 0] / p_cam[2, 0]
            
            # 畸变矫正
            r2 = x*x + y*y
            r4 = r2*r2
            r6 = r4*r2
            
            xd = x*(1 + k1*r2 + k2*r4 + k3*r6) + 2*p1*x*y + p2*(r2 + 2*x*x)
            yd = y*(1 + k1*r2 + k2*r4 + k3*r6) + 2*p2*x*y + p1*(r2 + 2*y*y)
            
            # 像素坐标
            u = fx * xd + cx
            v = fy * yd + cy
            
            # 计算残差
            residuals.append(u - img_pnt[0][0])
            residuals.append(v - img_pnt[0][1])
    
    return np.array(residuals, dtype=np.float64)

def ceresCalibrate(imgPntsVec, objPntsVec, K, D, rvec, tvec):
    """模拟Ceres优化标定(使用scipy.optimize.least_squares)"""
    # print("Ceres优化(Python替代实现)")
    
    # 初始化参数
    # cam: [fx, fy, cx, cy, k1, k2, k3, p1, p2]
    cam = np.array([
        K[0, 0], K[1, 1], K[0, 2], K[1, 2],
        D[0, 0], D[0, 1], D[0, 4], D[0, 2], D[0, 3]
    ], dtype=np.float64)
    
    # pose: [rx, ry, rz, tx, ty, tz] * num_images
    pose = []
    for r, t in zip(rvec, tvec):
        pose.extend([r[0,0], r[1,0], r[2,0], t[0,0], t[1,0], t[2,0]])
    pose = np.array(pose, dtype=np.float64)
    
    # 合并参数
    x0 = np.concatenate([cam, pose])
    
    # 优化
    result = least_squares(
        reprojection_error,
        x0,
        args=(imgPntsVec, objPntsVec),
        method='lm',
        ftol=1e-10,
        gtol=1e-10,
        xtol=1e-8,
        max_nfev=50,
        verbose=2
    )
    
    print("\n优化摘要:")
    print(f"成功: {result.success}")
    print(f"迭代次数: {result.nfev}")
    print(f"最终成本: {result.cost}")
    
    # 提取优化结果
    opt_params = result.x
    # 更新内参
    K[0, 0] = opt_params[0]  # fx
    K[1, 1] = opt_params[1]  # fy
    K[0, 2] = opt_params[2]  # cx
    K[1, 2] = opt_params[3]  # cy
    
    # 更新畸变参数
    D[0, 0] = opt_params[4]  # k1
    D[0, 1] = opt_params[5]  # k2
    D[0, 2] = opt_params[7]  # p1
    D[0, 3] = opt_params[8]  # p2
    D[0, 4] = opt_params[6]  # k3
    
    # 更新外参
    poses = opt_params[9:].reshape(-1, 6)
    for i in range(len(rvec)):
        rvec[i][0,0] = poses[i][0]
        rvec[i][1,0] = poses[i][1]
        rvec[i][2,0] = poses[i][2]
        tvec[i][0,0] = poses[i][3]
        tvec[i][1,0] = poses[i][4]
        tvec[i][2,0] = poses[i][5]

def displayResults(K, D, rvec, tvec):
    """显示标定结果"""
    print("内参矩阵: ")
    print(K)
    print("畸变参数: ", D.flatten())
    for i, (r, t) in enumerate(zip(rvec, tvec)):
        print(f"图像索引: {i},旋转向量: {r.T},平移向量: {t.T}")

def project3DPoint(p3, r, t, K, D):
    """将3D点投影到2D像素坐标"""
    p = np.array([p3[0], p3[1], p3[2]], dtype=np.float64).reshape(3, 1)
    R, _ = cv2.Rodrigues(r)
    p = R @ p + t
    
    x = p[0, 0] / p[2, 0]
    y = p[1, 0] / p[2, 0]
    r2 = x*x + y*y
    r4 = r2*r2
    r6 = r4*r2
    
    k1 = D[0, 0]
    k2 = D[0, 1]
    p1 = D[0, 2]
    p2 = D[0, 3]
    k3 = D[0, 4]
    
    xd = x*(1 + k1*r2 + k2*r4 + k3*r6) + 2*p1*x*y + p2*(r2 + 2*x*x)
    yd = y*(1 + k1*r2 + k2*r4 + k3*r6) + 2*p2*x*y + p1*(r2 + 2*y*y)
    
    fx = K[0, 0]
    fy = K[1, 1]
    cx = K[0, 2]
    cy = K[1, 2]
    
    u = fx * xd + cx
    v = fy * yd + cy
    
    return (u, v)

def calculateReprojectionError(imgPntsVec, objPntsVec, K, D, rvec, tvec):
    """计算重投影误差"""
    error = 0.0
    count = 0
    
    # 增加边界检查
    if len(imgPntsVec) != len(rvec) or len(imgPntsVec) != len(tvec):
        print(f"警告:数据长度不匹配!imgPntsVec: {len(imgPntsVec)}, rvec: {len(rvec)}, tvec: {len(tvec)}")
        # 取最小长度避免越界
        valid_len = min(len(imgPntsVec), len(rvec), len(tvec))
        imgPntsVec = imgPntsVec[:valid_len]
        objPntsVec = objPntsVec[:valid_len]
        rvec = rvec[:valid_len]
        tvec = tvec[:valid_len]
    
    for i in range(len(imgPntsVec)):
        for j in range(len(imgPntsVec[i])):
            prjpnt = project3DPoint(objPntsVec[i][j], rvec[i], tvec[i], K, D)
            imgpnt = imgPntsVec[i][j][0]
            
            # 计算欧氏距离
            dist = np.sqrt((imgpnt[0] - prjpnt[0])**2 + (imgpnt[1] - prjpnt[1])**2)
            error += dist
            count += 1
    
    print(f"有效标定点数量: {count}")
    if count > 0:
        avg_error = error / count
        print(f"平均重投影误差: {avg_error:.6f} 像素")
    else:
        print("无有效标定点,无法计算重投影误差")
        avg_error = 0.0
    return avg_error

def main():
    # 图片文件夹路径
    path = r"F:\DEV\calib_test\halcon"
    # 查找的图片格式
    pattern = "/*.png"
    # 存储图片的列表
    imageData = []
    # 查找并读取图片
    if not readImages(path + pattern, imageData):
        return
    
    # 棋盘格长、宽、尺度(内角点数量,比如11x8表示每行11个角点,每列8个)
    columns = 7
    rows = 7
    scale = 12.5 # millimeter
    board_type = "circleboard"
    objectPointsVector = []
    imagePointsVector = []
    # 查找角点
    if not findCorners(imageData, columns, rows, scale, board_type, imagePointsVector, objectPointsVector):
        return
    
    # 计算单应
    homographyVector = []
    findAllHomography(imagePointsVector, objectPointsVector, homographyVector)
    
    # 估计内参
    K = np.eye(3, dtype=np.float64)
    estimateIntrinsics(homographyVector, K)
    
    # 估计外参
    rotationVector = []
    translationVector = []
    estimateExtrinsics(homographyVector, K, rotationVector, translationVector)
    
    # 畸变初值设为0
    D = np.zeros((1, 5), dtype=np.float64)
    
    # Ceres优化标定(Python替代实现)
    ceresCalibrate(imagePointsVector, objectPointsVector, K, D, rotationVector, translationVector)
    print("\n===== 自定义标定优化结果 =====")
    displayResults(K, D, rotationVector, translationVector)
    calculateReprojectionError(imagePointsVector, objectPointsVector, K, D, rotationVector, translationVector)
    
    # OpenCV标定(修复核心错误:使用所有有效图片的角点)
    print("\n===== OpenCV标定结果 =====")
    # 转换数据格式以适配OpenCV
    obj_points = [np.array(pnts, dtype=np.float32) for pnts in objectPointsVector]
    img_points = [np.array(pnts, dtype=np.float32) for pnts in imagePointsVector]
    
    # 检查图像尺寸(使用第一张有效图片的尺寸)
    img_size = imageData[0].shape[:2][::-1] if imageData else (640, 480)
    
    # OpenCV标定
    ret, K_opencv, D_opencv, rvecs_opencv, tvecs_opencv = cv2.calibrateCamera(
        obj_points, img_points, img_size, None, None  # 不传入初始内参,让OpenCV自动求解
    )
    
    # 更新结果
    K[:] = K_opencv
    D[:] = D_opencv
    rotationVector[:] = rvecs_opencv
    translationVector[:] = tvecs_opencv
    
    displayResults(K, D, rotationVector, translationVector)
    calculateReprojectionError(imagePointsVector, objectPointsVector, K, D, rotationVector, translationVector)

if __name__ == "__main__":
    main()

posted @ 2026-01-20 18:34  GShang  阅读(2)  评论(0)    收藏  举报