双目标定流程

一、项目背景与最终标定结果

本项目使用一台输出 Side-by-Side 图像的 USB 双目相机。相机每次输出一张 2560×720 图像,左半部分对应左目,右半部分对应右目,因此每个镜头的实际图像分辨率为 1280×720

标定板使用普通黑白棋盘格,横向 14 格、纵向 12 格。OpenCV 需要填写的是内部交点数量,不是棋盘格数量,因此本项目的角点配置为横向 13 个内角点、纵向 11 个内角点。单个黑白方格经过实际测量后,边长为 19.7 mm

最终完成标定后,得到的有效结果如下:

复制代码
最终参与标定图片对:32 对
左目 RMS:0.3912 px
右目 RMS:0.3904 px
双目 RMS:0.4056 px
平移向量 T:[-59.50, -0.05, 0.14] mm
基线长度:59.50 mm

这说明左右镜头中心间距约为 6 cm,两个镜头几乎同高,前后偏移极小,外参稳定可靠。该结果能够用于后续立体校正、视差计算、深度恢复,以及结合 YOLO 输出目标框中心的 XYZ 三维坐标。


第一部分:双目标定完整流程

1. 双目标定到底标定什么

双目标定不是只测量两个镜头之间的距离,而是要估计右相机相对于左相机的完整刚体关系,包括旋转矩阵 R 和平移向量 T

复制代码
P_right = R * P_left + T

其中,P_left 是空间点在左相机坐标系下的三维坐标,P_right 是同一个点在右相机坐标系下的三维坐标。

平移向量可以写为:

复制代码
T = [Tx, Ty, Tz]^T

基线就是平移向量的长度:

复制代码
B = sqrt(Tx^2 + Ty^2 + Tz^2)

对于左右水平排列的双目相机,通常有以下特征:

复制代码
Tx 接近真实镜头中心距
Ty 接近 0
Tz 接近 0
R 接近单位矩阵

本项目最终结果为:

复制代码
T = [-59.50, -0.05, 0.14] mm

其中 Tx=-59.50 mm 表示右相机相对于左相机主要沿 X 轴负方向移动约 59.50 mm。负号只代表坐标系方向定义,不表示镜头装反。TyTz 接近零,说明两个镜头基本同高,也没有明显前后错位。


2. SBS 图像拆分

本相机输出的是一张 2560×720 的 SBS 图像。处理时必须将其拆分成左右两张 1280×720 图像。

复制代码
left = frame[:, :1280].copy()
right = frame[:, 1280:2560].copy()

这里必须注意:左右图必须来自同一张 SBS 原始帧。不能先采集左图,再采集右图,因为两次采集之间棋盘姿态、曝光时刻、对焦状态都可能发生变化。

双目标定依赖左右相机在同一时刻观察同一个棋盘。如果左右图不是同一时刻的对应画面,后续即使棋盘角点检测成功,双目外参也会明显错误。


3. 棋盘格参数与世界坐标构造

本项目实际棋盘尺寸为横向 14 格、纵向 12 格,因此 OpenCV 内角点配置必须为:

复制代码
CHESSBOARD_COLS = 13
CHESSBOARD_ROWS = 11
PATTERN_SIZE = (13, 11)
SQUARE_SIZE_MM = 19.7

这里最常见的问题是把棋盘格数量直接填入 OpenCV,例如误写成 (14, 12)。实际上,OpenCV 的 findChessboardCorners() 需要的是内部交点数量。

棋盘角点三维坐标通常以第一个内部角点作为原点,X 轴沿棋盘横向,Y 轴沿棋盘纵向,Z 轴垂直于棋盘平面。

复制代码
P0 = [0.0, 0.0, 0.0]

P1 = [19.7, 0.0, 0.0]

P2 = [39.4, 0.0, 0.0]

所有角点都位于棋盘平面,因此初始状态下 Z 坐标均为零。

生成棋盘世界坐标的代码如下:

复制代码
obj_points = np.zeros((CHESSBOARD_COLS * CHESSBOARD_ROWS, 3), np.float32)

obj_points[:, :2] = np.mgrid[
    0:CHESSBOARD_COLS,
    0:CHESSBOARD_ROWS
].T.reshape(-1, 2)

obj_points *= SQUARE_SIZE_MM

4. 左右棋盘角点检测与亚像素优化

双目标定需要准确找到左右图中的棋盘内角点。本项目每张图理论上都应检测到:

复制代码
13 * 11 = 143 个角点

角点检测通常分两步:第一步用 findChessboardCorners() 找到整数级别角点;第二步用 cornerSubPix() 将角点优化到亚像素精度。

复制代码
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)

found, corners = cv2.findChessboardCorners(
    gray,
    PATTERN_SIZE,
    cv2.CALIB_CB_ADAPTIVE_THRESH
    | cv2.CALIB_CB_NORMALIZE_IMAGE
    | cv2.CALIB_CB_FILTER_QUADS
)

corners = cv2.cornerSubPix(
    gray,
    corners,
    (11, 11),
    (-1, -1),
    (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 50, 1e-4)
)

亚像素优化的意义在于:棋盘交点通常不会刚好落在整数像素坐标上,而双目深度恢复对像素级误差非常敏感。角点定位越准确,后续内参、外参、校正参数和深度精度就越稳定。


5. 左右单目标定

左目和右目需要分别完成单目标定,求出各自的相机内参与畸变参数。

左相机输出:

复制代码
K1:左相机内参矩阵
D1:左相机畸变参数

右相机输出:

复制代码
K2:右相机内参矩阵
D2:右相机畸变参数

相机成像关系可以写为:

复制代码
s * [u, v, 1]^T = K * [R | t] * [X, Y, Z, 1]^T

其中 [X, Y, Z] 是棋盘角点的世界坐标,[u, v] 是角点在图像中的像素坐标,K 是相机内参,R、t 是当前棋盘相对于该相机的姿态。

单目标定代码如下:

复制代码
rms_left, K1, D1, rvecs_left, tvecs_left = cv2.calibrateCamera(
    object_points,
    image_points_left,
    image_size,
    None,
    None,
    flags=0
)

rms_right, K2, D2, rvecs_right, tvecs_right = cv2.calibrateCamera(
    object_points,
    image_points_right,
    image_size,
    None,
    None,
    flags=0
)

单目 RMS 反映的是棋盘角点投影回图像后的平均误差。一般而言:

复制代码
RMS < 0.5 px:较好
RMS 0.5 ~ 1.0 px:通常可用
RMS > 1.5 px:需要检查图片质量和棋盘角点

本项目最终左目 RMS 为 0.3912 px,右目 RMS 为 0.3904 px,说明两个镜头各自的内参拟合质量都很好。


6. 双目标定求外参

完成左右单目标定后,使用 stereoCalibrate() 估计左右相机之间固定不变的外参 R、T

复制代码
rms_stereo, K1, D1, K2, D2, R, T, E, F = cv2.stereoCalibrate(
    object_points,
    image_points_left,
    image_points_right,
    K1,
    D1,
    K2,
    D2,
    image_size,
    criteria=(cv2.TERM_CRITERIA_MAX_ITER + cv2.TERM_CRITERIA_EPS, 100, 1e-6),
    flags=cv2.CALIB_FIX_INTRINSIC
)

CALIB_FIX_INTRINSIC 的含义是:左右相机内参已经在单目标定阶段求出,双目标定阶段不再重新调整内参,重点优化左右相机之间的 R、T

双目标定最严格的前提是:左图第 j 个角点和右图第 j 个角点,必须是棋盘上的同一个物理交点。

正常情况下:

复制代码
left_corner[0] <-> right_corner[0]

left_corner[1] <-> right_corner[1]

left_corner[142] <-> right_corner[142]

只要其中部分图片的左右角点编号方向不一致,双目外参就会被错误约束拉偏。


7. 立体校正与深度恢复

得到左右相机外参后,需要调用 stereoRectify() 进行立体校正。

复制代码
R1, R2, P1, P2, Q, roi1, roi2 = cv2.stereoRectify(
    K1,
    D1,
    K2,
    D2,
    image_size,
    R,
    T,
    flags=cv2.CALIB_ZERO_DISPARITY,
    alpha=0
)

校正后左右图中的同一个物理点应满足:

复制代码
y_left ≈ y_right

此时,左右图横向坐标的差值称为视差:

复制代码
d = x_left - x_right

深度关系为:

复制代码
Z = f * B / d

本项目最终得到:

复制代码
校正后焦距 fx:1037.5928 px
校正后基线:59.5013 mm
深度因子 fB:61738.17 mm·px

因此:

复制代码
Z(mm) ≈ 61738.17 / disparity_px

例如:

复制代码
视差约 61.74 px,对应深度约 1000 mm

视差约 30.87 px,对应深度约 2000 mm

视差约 20.58 px,对应深度约 3000 mm

第二部分:本项目实际标定过程

1. 图像采集

本项目使用 2560×720 SBS 双目流采集了 35 对棋盘图。每对图片由同一张 SBS 图像切分得到,左图保存为:

复制代码
left_0001.png
left_0002.png
...

右图保存为:

复制代码
right_0001.png
right_0002.png
...

采集完成后,程序扫描 left/right/ 文件夹,按照编号匹配左右图片。

复制代码
left_0001.png <-> right_0001.png
left_0002.png <-> right_0002.png
...

35 对图片都成功检测到了 143 个角点,因此最开始认为图片整体质量正常。


2. 初次标定结果异常

第一次直接使用全部 35 对图进行双目标定时,单目标定误差较低:

复制代码
左目 RMS:约 0.39 px
右目 RMS:约 0.39 px

但双目 RMS 却高达约 34~35 px,同时估计基线只有约 13 mm

复制代码
Stereo RMS ≈ 35 px
Baseline ≈ 13 mm

这说明左右相机各自的内参没有问题,但左右图片之间的角点物理对应关系出现了错误。

这里需要特别注意:基线被估计成 13 mm,并不代表真实相机基线只有 13 mm。真实镜头中心距约为 60 mm。错误的 13 mm 只是优化器面对互相矛盾的角点约束时,计算出的一个数学折中结果。


3. 为什么单目没问题,双目却失败

单目标定只关注"棋盘角点是否可以被当前相机投影模型解释"。即使某张图中棋盘角点顺序从另一端开始编号,单目标定仍可能把它解释为棋盘姿态不同,因此单目 RMS 依然较低。

双目标定不同,它要求左图与右图的同编号角点必须是同一个真实角点。

普通黑白棋盘具有旋转对称性。对于少数图像,OpenCV 虽然找到了全部 143 个角点,但右图角点数组可能从棋盘的对角开始编号。

异常情况下可能变成:

复制代码
left_corner[0] <-> right_corner[142]

left_corner[1] <-> right_corner[141]

left_corner[142] <-> right_corner[0]

这不是棋盘"没检测到",而是角点检测顺序和其他图片对不一致。


4. 外参诊断脚本设计

为了定位问题,本项目编写了三层诊断。

第一层:全局 16 组合测试。分别测试右图是否需要整体水平翻转、垂直翻转、旋转 180 度,同时测试右图角点数组是否需要不同方向的重排。

第二层:逐对原始外参诊断。每一对图单独计算一次左右外参,观察每对图片反推出的基线是否稳定在真实 60 mm 左右。

第三层:逐对角点顺序自动搜索。对每一对图片分别尝试 4 种角点顺序,筛选出最符合真实双目物理结构的顺序。

该诊断脚本基于实际运行过的右图翻转、角点顺序和逐对外参检查逻辑整理。


5. 全局 16 组合测试结果

全局测试一共包含:

复制代码
4 种右图像素变换 × 4 种右目角点重排 = 16 种组合

右图像素变换包括:

复制代码
ORIGINAL
FLIP_HORIZONTAL
FLIP_VERTICAL
ROTATE_180

右目角点顺序也包括:

复制代码
ORIGINAL
FLIP_HORIZONTAL
FLIP_VERTICAL
ROTATE_180

测试结果显示,即使最优的全局组合,双目 RMS 仍有 27.5514 px

复制代码
最优全局组合:右图 ROTATE_180 + 角点 ROTATE_180
Stereo RMS:27.5514 px
Baseline:19.49 mm

这说明问题不是所有右图统一镜像,也不是所有右图统一旋转 180 度。

更合理的判断是:大部分图片的角点顺序正确,只有少数图片的右目角点顺序异常。


6. 逐对外参诊断结果

诊断脚本使用 solvePnP() 分别估计棋盘相对于左右相机的位姿。

复制代码
P_left = R_left * P_board + t_left

P_right = R_right * P_board + t_right

再反推出右相机相对于左相机的外参:

复制代码
R_lr = R_right * R_left^T

T_lr = t_right - R_lr * t_left

逐对诊断发现,大多数图片单独计算出的基线都位于 59~61 mm 附近,旋转角也接近零。例如:

复制代码
[0001] B=60.40 mm,T=[-60.38, -0.84, -1.46],R=0.13°

[0002] B=60.26 mm,T=[-60.25, -0.42, -0.80],R=0.13°

[0003] B=59.76 mm,T=[-59.76, 0.19, 0.18],R=0.14°

这说明真实双目外参本身非常稳定,相机硬件没有松动,左右图也不是完全错配。

真正异常的是少数图片对。


7. 自动搜索定位出的异常图片

逐对自动角点顺序搜索结果显示:

复制代码
33 对图片:右目角点顺序 ORIGINAL
2 对图片:右目角点顺序 ROTATE_180

最终定位出的异常图片编号为:

复制代码
0019

0031

也就是说,以下两对图片的右目角点编号方向与其他图片不同:

复制代码
left_0019.png + right_0019.png

left_0031.png + right_0031.png

这里的 ROTATE_180 不是说右图画面真的需要旋转 180 度,而是右图角点数组需要行列同时反向。换句话说,右图第 0 个角点实际对应棋盘另一端的角点,右图第 142 个角点才与左图第 0 个角点对应。

为了简化后续正式标定,本项目没有保留这两对图片,而是直接删除了它们。

此外,编号 0029 的图片单目误差偏高:

复制代码
左图误差约 1.19 px
右图误差约 1.23 px

因此正式标定脚本自动将 0029 从最终标定集剔除。

最终参与正式标定的图片数量为:

复制代码
35 - 2 - 1 = 32 对

8. 最终标定结果

删除 0019 和 0031 后,重新运行正式标定脚本。程序自动剔除 0029 后,最终得到:

复制代码
有效图片对:32
左目 RMS:0.3912 px
右目 RMS:0.3904 px
双目 RMS:0.4056 px
T(mm):[-59.50, -0.05, 0.14]
Baseline:59.50 mm

这个结果完全符合相机物理结构。

复制代码
Tx=-59.50 mm:左右镜头中心间距约 6 cm
Ty=-0.05 mm:左右镜头几乎同高
Tz=0.14 mm:前后安装偏差极小
Stereo RMS=0.4056 px:双目外参拟合质量较好

第三部分:正式标定代码

下面是可用于正式标定的完整 calibrate_stereo.py。脚本位于 calibration_code/ 目录,left/right/ 位于上一级项目目录。

复制代码
# -*- coding: utf-8 -*-
"""
calibrate_stereo.py
用途:读取 left/ 和 right/ 中的棋盘图,完成双目标定。
输出:stereo_params.yaml、stereo_maps.npz、rectify_preview.png、calibration_report.txt。
"""

from pathlib import Path
import re
import shutil
import sys

import cv2
import numpy as np


# ========================= 1. 参数配置 =========================

CHESSBOARD_COLS = 13
CHESSBOARD_ROWS = 11
PATTERN_SIZE = (CHESSBOARD_COLS, CHESSBOARD_ROWS)

SQUARE_SIZE_MM = 19.7

MIN_VALID_PAIRS = 12
AUTO_REMOVE_OUTLIERS = True
MAX_OUTLIER_RATIO = 0.25
SAVE_CORNER_PREVIEWS = True

# 若 0019、0031 没有物理删除,可保留在此处手动跳过。
MANUAL_EXCLUDE_IDS = {19, 31}

SCRIPT_DIR = Path(__file__).resolve().parent
ROOT_DIR = SCRIPT_DIR.parent

LEFT_DIR = ROOT_DIR / "left"
RIGHT_DIR = ROOT_DIR / "right"

CORNER_DIR = ROOT_DIR / "corner_preview"
YAML_PATH = ROOT_DIR / "stereo_params.yaml"
MAPS_PATH = ROOT_DIR / "stereo_maps.npz"
RECTIFY_PREVIEW_PATH = ROOT_DIR / "rectify_preview.png"
REPORT_PATH = ROOT_DIR / "calibration_report.txt"

IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp"}


# ========================= 2. 图片读写 =========================

def imread_unicode(path: Path):
    try:
        data = np.fromfile(str(path), dtype=np.uint8)
        return cv2.imdecode(data, cv2.IMREAD_COLOR)
    except Exception:
        return None


def imwrite_unicode(path: Path, image: np.ndarray):
    try:
        success, encoded = cv2.imencode(path.suffix, image)

        if not success:
            return False

        encoded.tofile(str(path))
        return True

    except Exception:
        return False


def extract_index(filename: str):
    values = re.findall(r"\d+", filename)
    return int(values[-1]) if values else None


def scan_images(folder: Path):
    result = {}

    for path in folder.iterdir():
        if not path.is_file():
            continue

        if path.suffix.lower() not in IMAGE_EXTENSIONS:
            continue

        index = extract_index(path.name)

        if index is not None:
            result[index] = path

    return result


def find_image_pairs():
    left_map = scan_images(LEFT_DIR)
    right_map = scan_images(RIGHT_DIR)

    pairs = []

    for index in sorted(set(left_map) & set(right_map)):
        if index in MANUAL_EXCLUDE_IDS:
            print(f"[手动跳过] 编号 {index:04d}")
            continue

        pairs.append((index, left_map[index], right_map[index]))

    return pairs


# ========================= 3. 棋盘角点 =========================

def create_object_points():
    points = np.zeros(
        (CHESSBOARD_COLS * CHESSBOARD_ROWS, 3),
        dtype=np.float32
    )

    points[:, :2] = np.mgrid[
        0:CHESSBOARD_COLS,
        0:CHESSBOARD_ROWS
    ].T.reshape(-1, 2)

    points *= SQUARE_SIZE_MM

    return points


def find_corners(image: np.ndarray):
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)

    flags = (
        cv2.CALIB_CB_ADAPTIVE_THRESH
        | cv2.CALIB_CB_NORMALIZE_IMAGE
        | cv2.CALIB_CB_FILTER_QUADS
    )

    found, corners = cv2.findChessboardCorners(
        gray,
        PATTERN_SIZE,
        flags
    )

    if not found and hasattr(cv2, "findChessboardCornersSB"):
        sb_flags = (
            cv2.CALIB_CB_NORMALIZE_IMAGE
            | cv2.CALIB_CB_EXHAUSTIVE
            | cv2.CALIB_CB_ACCURACY
        )

        found, corners = cv2.findChessboardCornersSB(
            gray,
            PATTERN_SIZE,
            sb_flags
        )

    if not found or corners is None:
        return None

    corners = corners.astype(np.float32)

    criteria = (
        cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER,
        50,
        1e-4
    )

    return cv2.cornerSubPix(
        gray,
        corners,
        (11, 11),
        (-1, -1),
        criteria
    )


def save_corner_preview(left, right, corners_left, corners_right, index):
    if not SAVE_CORNER_PREVIEWS:
        return

    left_show = left.copy()
    right_show = right.copy()

    cv2.drawChessboardCorners(
        left_show,
        PATTERN_SIZE,
        corners_left,
        True
    )

    cv2.drawChessboardCorners(
        right_show,
        PATTERN_SIZE,
        corners_right,
        True
    )

    merged = cv2.hconcat([left_show, right_show])

    imwrite_unicode(
        CORNER_DIR / f"pair_{index:04d}.jpg",
        merged
    )


# ========================= 4. 误差计算 =========================

def calculate_per_view_errors(object_points, image_points, rvecs, tvecs, K, D):
    errors = []

    for obj, image, rvec, tvec in zip(
        object_points,
        image_points,
        rvecs,
        tvecs
    ):
        projected, _ = cv2.projectPoints(
            obj,
            rvec,
            tvec,
            K,
            D
        )

        delta = image.reshape(-1, 2) - projected.reshape(-1, 2)

        rms = np.sqrt(
            np.mean(np.sum(delta * delta, axis=1))
        )

        errors.append(float(rms))

    return np.asarray(errors, dtype=np.float64)


def select_outliers(left_errors, right_errors):
    merged = np.maximum(left_errors, right_errors)

    median = float(np.median(merged))
    mad = float(np.median(np.abs(merged - median)))

    threshold = max(
        0.8,
        median + 3.0 * 1.4826 * mad
    )

    bad_indices = [
        i for i, value in enumerate(merged)
        if value > threshold
    ]

    good_indices = [
        i for i in range(len(merged))
        if i not in bad_indices
    ]

    return good_indices, bad_indices, threshold


# ========================= 5. YAML 与预览 =========================

def write_yaml(path: Path, values: dict):
    lines = ["%YAML:1.0", "---"]

    for key, value in values.items():
        if isinstance(value, tuple):
            text = ", ".join(str(int(x)) for x in value)
            lines.append(f"{key}: [ {text} ]")

        elif isinstance(value, np.ndarray):
            matrix = np.asarray(value, dtype=np.float64)

            if matrix.ndim == 1:
                matrix = matrix.reshape(1, -1)

            data = ", ".join(
                f"{float(x):.16e}"
                for x in matrix.ravel()
            )

            lines.extend([
                f"{key}: !!opencv-matrix",
                f"  rows: {matrix.shape[0]}",
                f"  cols: {matrix.shape[1]}",
                "  dt: d",
                f"  data: [ {data} ]",
            ])

        elif isinstance(value, (int, float, np.integer, np.floating)):
            lines.append(f"{key}: {float(value):.16e}")

        else:
            lines.append(f'{key}: "{value}"')

    path.write_text(
        "\n".join(lines) + "\n",
        encoding="utf-8"
    )


def save_rectify_preview(left, right, map_lx, map_ly, map_rx, map_ry):
    rect_left = cv2.remap(left, map_lx, map_ly, cv2.INTER_LINEAR)
    rect_right = cv2.remap(right, map_rx, map_ry, cv2.INTER_LINEAR)

    preview = cv2.hconcat([rect_left, rect_right])

    h, w = rect_left.shape[:2]

    for y in range(0, h, 40):
        cv2.line(preview, (0, y), (w * 2, y), (0, 255, 0), 1)

    cv2.line(preview, (w, 0), (w, h), (0, 255, 255), 2)

    imwrite_unicode(RECTIFY_PREVIEW_PATH, preview)


# ========================= 6. 主流程 =========================

def main():
    print("=" * 72)
    print("双目相机正式标定")
    print("=" * 72)

    if not LEFT_DIR.is_dir() or not RIGHT_DIR.is_dir():
        sys.exit("[错误] 未找到 left/ 或 right/ 文件夹。")

    if CORNER_DIR.exists():
        shutil.rmtree(CORNER_DIR)

    CORNER_DIR.mkdir(parents=True, exist_ok=True)

    pairs = find_image_pairs()

    print(f"[INFO] 找到图片对:{len(pairs)}")

    if len(pairs) < MIN_VALID_PAIRS:
        sys.exit("[错误] 图片对不足。")

    object_template = create_object_points()

    object_points = []
    left_points = []
    right_points = []
    valid_pairs = []

    image_size = None

    for index, left_path, right_path in pairs:
        left = imread_unicode(left_path)
        right = imread_unicode(right_path)

        if left is None or right is None:
            print(f"[跳过] {index:04d}:读取失败")
            continue

        lh, lw = left.shape[:2]
        rh, rw = right.shape[:2]

        if (lw, lh) != (rw, rh):
            print(f"[跳过] {index:04d}:左右尺寸不同")
            continue

        if image_size is None:
            image_size = (lw, lh)

        if image_size != (lw, lh):
            print(f"[跳过] {index:04d}:图像尺寸不一致")
            continue

        corners_left = find_corners(left)
        corners_right = find_corners(right)

        if corners_left is None or corners_right is None:
            print(f"[跳过] {index:04d}:角点检测失败")
            continue

        object_points.append(object_template.copy())
        left_points.append(corners_left)
        right_points.append(corners_right)
        valid_pairs.append((index, left_path, right_path))

        save_corner_preview(
            left,
            right,
            corners_left,
            corners_right,
            index
        )

        print(f"[成功] {index:04d}:143 个角点")

    if len(valid_pairs) < MIN_VALID_PAIRS:
        sys.exit("[错误] 有效图片对不足。")

    rms_l0, K1, D1, rvecs_l, tvecs_l = cv2.calibrateCamera(
        object_points,
        left_points,
        image_size,
        None,
        None,
        flags=0
    )

    rms_r0, K2, D2, rvecs_r, tvecs_r = cv2.calibrateCamera(
        object_points,
        right_points,
        image_size,
        None,
        None,
        flags=0
    )

    removed_ids = []

    if AUTO_REMOVE_OUTLIERS:
        left_errors = calculate_per_view_errors(
            object_points,
            left_points,
            rvecs_l,
            tvecs_l,
            K1,
            D1
        )

        right_errors = calculate_per_view_errors(
            object_points,
            right_points,
            rvecs_r,
            tvecs_r,
            K2,
            D2
        )

        good, bad, threshold = select_outliers(
            left_errors,
            right_errors
        )

        max_bad_count = max(
            1,
            int(len(valid_pairs) * MAX_OUTLIER_RATIO)
        )

        print(f"[INFO] 自动剔除阈值:{threshold:.4f} px")

        if bad and len(bad) <= max_bad_count and len(good) >= MIN_VALID_PAIRS:
            for i in bad:
                removed_ids.append(valid_pairs[i][0])

            object_points = [object_points[i] for i in good]
            left_points = [left_points[i] for i in good]
            right_points = [right_points[i] for i in good]
            valid_pairs = [valid_pairs[i] for i in good]

    rms_left, K1, D1, _, _ = cv2.calibrateCamera(
        object_points,
        left_points,
        image_size,
        None,
        None,
        flags=0
    )

    rms_right, K2, D2, _, _ = cv2.calibrateCamera(
        object_points,
        right_points,
        image_size,
        None,
        None,
        flags=0
    )

    criteria = (
        cv2.TERM_CRITERIA_MAX_ITER + cv2.TERM_CRITERIA_EPS,
        100,
        1e-6
    )

    rms_stereo, K1, D1, K2, D2, R, T, E, F = cv2.stereoCalibrate(
        object_points,
        left_points,
        right_points,
        K1,
        D1,
        K2,
        D2,
        image_size,
        criteria=criteria,
        flags=cv2.CALIB_FIX_INTRINSIC
    )

    baseline_mm = float(np.linalg.norm(T))

    R1, R2, P1, P2, Q, roi1, roi2 = cv2.stereoRectify(
        K1,
        D1,
        K2,
        D2,
        image_size,
        R,
        T,
        flags=cv2.CALIB_ZERO_DISPARITY,
        alpha=0
    )

    roi1 = tuple(map(int, roi1))
    roi2 = tuple(map(int, roi2))

    map_lx, map_ly = cv2.initUndistortRectifyMap(
        K1,
        D1,
        R1,
        P1,
        image_size,
        cv2.CV_32FC1
    )

    map_rx, map_ry = cv2.initUndistortRectifyMap(
        K2,
        D2,
        R2,
        P2,
        image_size,
        cv2.CV_32FC1
    )

    fx = float(P1[0, 0])
    rectified_baseline_mm = abs(float(P2[0, 3] / P2[0, 0]))
    depth_factor = fx * rectified_baseline_mm

    params = {
        "image_width": image_size[0],
        "image_height": image_size[1],
        "chessboard_cols": CHESSBOARD_COLS,
        "chessboard_rows": CHESSBOARD_ROWS,
        "square_size_mm": SQUARE_SIZE_MM,
        "K1": K1,
        "D1": D1,
        "K2": K2,
        "D2": D2,
        "R": R,
        "T": T,
        "E": E,
        "F": F,
        "R1": R1,
        "R2": R2,
        "P1": P1,
        "P2": P2,
        "Q": Q,
        "roi1": roi1,
        "roi2": roi2,
        "baseline_mm": baseline_mm,
        "rectified_baseline_mm": rectified_baseline_mm,
        "rms_mono_left": rms_left,
        "rms_mono_right": rms_right,
        "rms_stereo": rms_stereo,
        "num_valid_pairs": len(valid_pairs),
    }

    write_yaml(YAML_PATH, params)

    np.savez_compressed(
        MAPS_PATH,
        left_map_x=map_lx,
        left_map_y=map_ly,
        right_map_x=map_rx,
        right_map_y=map_ry
    )

    _, first_left, first_right = valid_pairs[0]

    save_rectify_preview(
        imread_unicode(first_left),
        imread_unicode(first_right),
        map_lx,
        map_ly,
        map_rx,
        map_ry
    )

    report = [
        "双目相机标定报告",
        f"有效图片对:{len(valid_pairs)}",
        f"自动剔除编号:{removed_ids}",
        f"左目 RMS:{rms_left:.6f} px",
        f"右目 RMS:{rms_right:.6f} px",
        f"双目 RMS:{rms_stereo:.6f} px",
        f"T(mm):{T.ravel()}",
        f"基线:{baseline_mm:.6f} mm",
        f"校正焦距 fx:{fx:.6f} px",
        f"深度因子 fB:{depth_factor:.6f} mm*px",
    ]

    REPORT_PATH.write_text(
        "\n".join(report) + "\n",
        encoding="utf-8"
    )

    print("\n" + "=" * 72)
    print("标定完成")
    print("=" * 72)
    print(f"有效图片对:{len(valid_pairs)}")
    print(f"左目 RMS:{rms_left:.4f} px")
    print(f"右目 RMS:{rms_right:.4f} px")
    print(f"双目 RMS:{rms_stereo:.4f} px")
    print(f"T(mm):{T.ravel()}")
    print(f"基线:{baseline_mm:.3f} mm")
    print(f"深度关系:Z(mm) ≈ {depth_factor:.2f} / disparity_px")


if __name__ == "__main__":
    main()

第四部分:外参异常诊断代码

下面是完整 diagnose_right_order.py。这份脚本用于排查"单目 RMS 很低,但双目 RMS 很大"的问题。

注意:该脚本应在异常图片尚未删除时运行。已经删除 0019 和 0031 后,再运行不会重新发现这两个异常图片。

复制代码
# -*- coding: utf-8 -*-
"""
diagnose_right_order.py
作用:诊断左右角点编号方向不一致导致的双目外参异常。

测试 A:全局 16 组合测试
测试 B:逐对原始外参诊断
测试 C:逐对右目角点顺序自动搜索
"""

from pathlib import Path
import csv
import math
import re
import shutil
import sys

import cv2
import numpy as np


# ========================= 1. 开关与参数 =========================

RUN_GLOBAL_16_COMBINATION_TEST = True
RUN_PER_PAIR_RAW_EXTRINSIC_TEST = True
RUN_PER_PAIR_AUTO_CORNER_ORDER_TEST = True

CHESSBOARD_COLS = 13
CHESSBOARD_ROWS = 11
PATTERN_SIZE = (CHESSBOARD_COLS, CHESSBOARD_ROWS)

SQUARE_SIZE_MM = 19.7

EXPECTED_BASELINE_MM = 60.0
BASELINE_TOLERANCE_MM = 15.0
MIN_VALID_PAIRS = 12

IMAGE_TRANSFORM_MODES = [
    "ORIGINAL",
    "FLIP_HORIZONTAL",
    "FLIP_VERTICAL",
    "ROTATE_180",
]

CORNER_ORDER_MODES = [
    "ORIGINAL",
    "FLIP_HORIZONTAL",
    "FLIP_VERTICAL",
    "ROTATE_180",
]

IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp"}

SCRIPT_DIR = Path(__file__).resolve().parent
ROOT_DIR = SCRIPT_DIR.parent

LEFT_DIR = ROOT_DIR / "left"
RIGHT_DIR = ROOT_DIR / "right"
OUTPUT_DIR = ROOT_DIR / "right_order_diagnosis"


# ========================= 2. 基础函数 =========================

def imread_unicode(path: Path):
    try:
        data = np.fromfile(str(path), dtype=np.uint8)
        return cv2.imdecode(data, cv2.IMREAD_COLOR)
    except Exception:
        return None


def extract_index(filename: str):
    values = re.findall(r"\d+", filename)
    return int(values[-1]) if values else None


def scan_images(folder: Path):
    result = {}

    for path in folder.iterdir():
        if not path.is_file():
            continue

        if path.suffix.lower() not in IMAGE_EXTENSIONS:
            continue

        index = extract_index(path.name)

        if index is not None:
            result[index] = path

    return result


def create_object_points():
    points = np.zeros(
        (CHESSBOARD_COLS * CHESSBOARD_ROWS, 3),
        dtype=np.float32
    )

    points[:, :2] = np.mgrid[
        0:CHESSBOARD_COLS,
        0:CHESSBOARD_ROWS
    ].T.reshape(-1, 2)

    points *= SQUARE_SIZE_MM

    return points


def find_corners(image: np.ndarray):
    gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)

    flags = (
        cv2.CALIB_CB_ADAPTIVE_THRESH
        | cv2.CALIB_CB_NORMALIZE_IMAGE
        | cv2.CALIB_CB_FILTER_QUADS
    )

    found, corners = cv2.findChessboardCorners(
        gray,
        PATTERN_SIZE,
        flags
    )

    if not found and hasattr(cv2, "findChessboardCornersSB"):
        sb_flags = (
            cv2.CALIB_CB_NORMALIZE_IMAGE
            | cv2.CALIB_CB_EXHAUSTIVE
            | cv2.CALIB_CB_ACCURACY
        )

        found, corners = cv2.findChessboardCornersSB(
            gray,
            PATTERN_SIZE,
            sb_flags
        )

    if not found or corners is None:
        return None

    corners = corners.astype(np.float32)

    criteria = (
        cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER,
        50,
        1e-4
    )

    return cv2.cornerSubPix(
        gray,
        corners,
        (11, 11),
        (-1, -1),
        criteria
    )


# ========================= 3. 图像和角点变换 =========================

def transform_right_image(image: np.ndarray, mode: str):
    if mode == "ORIGINAL":
        return image.copy()

    if mode == "FLIP_HORIZONTAL":
        return cv2.flip(image, 1)

    if mode == "FLIP_VERTICAL":
        return cv2.flip(image, 0)

    if mode == "ROTATE_180":
        return cv2.flip(image, -1)

    raise ValueError(f"未知图像变换:{mode}")


def reorder_right_corners(corners: np.ndarray, mode: str):
    grid = corners.reshape(
        CHESSBOARD_ROWS,
        CHESSBOARD_COLS,
        1,
        2
    )

    if mode == "ORIGINAL":
        output = grid

    elif mode == "FLIP_HORIZONTAL":
        output = grid[:, ::-1, :, :]

    elif mode == "FLIP_VERTICAL":
        output = grid[::-1, :, :, :]

    elif mode == "ROTATE_180":
        output = grid[::-1, ::-1, :, :]

    else:
        raise ValueError(f"未知角点顺序:{mode}")

    return np.ascontiguousarray(
        output.reshape(-1, 1, 2),
        dtype=np.float32
    )


# ========================= 4. 外参计算 =========================

def calibrate_mono(object_points, image_points, image_size):
    rms, K, D, _, _ = cv2.calibrateCamera(
        object_points,
        image_points,
        image_size,
        None,
        None,
        flags=0
    )

    return float(rms), K, D


def get_pair_extrinsic(obj, corners_left, corners_right, K1, D1, K2, D2):
    ok_left, rvec_left, tvec_left = cv2.solvePnP(
        obj,
        corners_left,
        K1,
        D1,
        flags=cv2.SOLVEPNP_ITERATIVE
    )

    ok_right, rvec_right, tvec_right = cv2.solvePnP(
        obj,
        corners_right,
        K2,
        D2,
        flags=cv2.SOLVEPNP_ITERATIVE
    )

    if not ok_left or not ok_right:
        return None

    R_left, _ = cv2.Rodrigues(rvec_left)
    R_right, _ = cv2.Rodrigues(rvec_right)

    R_lr = R_right @ R_left.T
    T_lr = tvec_right - R_lr @ tvec_left

    return R_lr, T_lr


def rotation_angle_deg(R):
    cosine = float((np.trace(R) - 1.0) / 2.0)
    cosine = min(1.0, max(-1.0, cosine))
    return math.degrees(math.acos(cosine))


def score_candidate(R, T):
    tx, ty, tz = [float(x) for x in T.ravel()]
    baseline = float(np.linalg.norm(T))
    angle = rotation_angle_deg(R)

    baseline_error = abs(
        baseline - EXPECTED_BASELINE_MM
    ) / EXPECTED_BASELINE_MM

    transverse_error = (
        abs(ty) + abs(tz)
    ) / EXPECTED_BASELINE_MM

    rotation_error = angle / 10.0

    return (
        8.0 * baseline_error
        + 5.0 * transverse_error
        + rotation_error
    )


def run_stereo_fit(object_points, left_points, right_points, image_size):
    rms_left, K1, D1 = calibrate_mono(
        object_points,
        left_points,
        image_size
    )

    rms_right, K2, D2 = calibrate_mono(
        object_points,
        right_points,
        image_size
    )

    criteria = (
        cv2.TERM_CRITERIA_MAX_ITER + cv2.TERM_CRITERIA_EPS,
        100,
        1e-6
    )

    rms_stereo, _, _, _, _, R, T, _, _ = cv2.stereoCalibrate(
        object_points,
        left_points,
        right_points,
        K1,
        D1,
        K2,
        D2,
        image_size,
        criteria=criteria,
        flags=cv2.CALIB_FIX_INTRINSIC
    )

    return {
        "rms_left": float(rms_left),
        "rms_right": float(rms_right),
        "rms_stereo": float(rms_stereo),
        "R": R,
        "T": T,
        "baseline_mm": float(np.linalg.norm(T)),
    }


def write_csv(filename: str, rows: list):
    if not rows:
        return

    path = OUTPUT_DIR / filename

    with path.open(
        "w",
        newline="",
        encoding="utf-8-sig"
    ) as file:
        writer = csv.DictWriter(
            file,
            fieldnames=list(rows[0].keys())
        )

        writer.writeheader()
        writer.writerows(rows)

    print(f"[保存] {path}")


# ========================= 5. 测试 A:全局 16 组合 =========================

def test_global_16(pair_data, image_size, right_cache, object_template):
    print("\n" + "=" * 72)
    print("[测试 A] 全局 16 组合测试")
    print("=" * 72)

    results = []

    for image_mode in IMAGE_TRANSFORM_MODES:
        for corner_mode in CORNER_ORDER_MODES:
            objects = []
            lefts = []
            rights = []

            for pair in pair_data:
                index = pair["index"]

                if index not in right_cache[image_mode]:
                    continue

                objects.append(object_template.copy())
                lefts.append(pair["left_corners"])

                rights.append(
                    reorder_right_corners(
                        right_cache[image_mode][index],
                        corner_mode
                    )
                )

            if len(objects) < MIN_VALID_PAIRS:
                continue

            result = run_stereo_fit(
                objects,
                lefts,
                rights,
                image_size
            )

            row = {
                "image_transform": image_mode,
                "corner_order": corner_mode,
                "valid_pairs": len(objects),
                "rms_stereo_px": result["rms_stereo"],
                "T_x_mm": float(result["T"][0, 0]),
                "T_y_mm": float(result["T"][1, 0]),
                "T_z_mm": float(result["T"][2, 0]),
                "baseline_mm": result["baseline_mm"],
            }

            results.append(row)

            print(
                f"图像={image_mode:18s} | "
                f"角点={corner_mode:18s} | "
                f"Stereo RMS={row['rms_stereo_px']:.4f}px | "
                f"B={row['baseline_mm']:.2f}mm"
            )

    results.sort(key=lambda item: item["rms_stereo_px"])

    write_csv("global_16_combination_results.csv", results)

    return results


# ========================= 6. 测试 B:逐对原始外参 =========================

def test_per_pair_raw(pair_data, image_size, object_template):
    print("\n" + "=" * 72)
    print("[测试 B] 逐对原始外参诊断")
    print("=" * 72)

    objects = [
        object_template.copy()
        for _ in pair_data
    ]

    lefts = [
        pair["left_corners"]
        for pair in pair_data
    ]

    rights = [
        pair["right_corners"]
        for pair in pair_data
    ]

    rms_left, K1, D1 = calibrate_mono(objects, lefts, image_size)
    rms_right, K2, D2 = calibrate_mono(objects, rights, image_size)

    print(f"左目内参 RMS:{rms_left:.4f}px")
    print(f"右目内参 RMS:{rms_right:.4f}px")

    rows = []

    for pair in pair_data:
        result = get_pair_extrinsic(
            object_template,
            pair["left_corners"],
            pair["right_corners"],
            K1,
            D1,
            K2,
            D2
        )

        if result is None:
            continue

        R, T = result
        baseline = float(np.linalg.norm(T))
        tx, ty, tz = [float(x) for x in T.ravel()]
        angle = rotation_angle_deg(R)

        abnormal = (
            abs(baseline - EXPECTED_BASELINE_MM) > BASELINE_TOLERANCE_MM
            or abs(ty) > 10.0
            or abs(tz) > 10.0
            or angle > 5.0
        )

        rows.append({
            "pair_number": pair["index"],
            "baseline_mm": baseline,
            "T_x_mm": tx,
            "T_y_mm": ty,
            "T_z_mm": tz,
            "relative_rotation_deg": angle,
            "status": "异常" if abnormal else "正常",
        })

    baselines = np.asarray(
        [row["baseline_mm"] for row in rows],
        dtype=np.float64
    )

    print(
        f"基线统计:median={np.median(baselines):.3f}mm,"
        f"mean={np.mean(baselines):.3f}mm,"
        f"std={np.std(baselines):.3f}mm,"
        f"min={np.min(baselines):.3f}mm,"
        f"max={np.max(baselines):.3f}mm"
    )

    write_csv("per_pair_raw_extrinsic.csv", rows)

    return rows


# ========================= 7. 测试 C:逐对角点顺序搜索 =========================

def test_per_pair_auto_order(pair_data, image_size, right_cache, object_template):
    print("\n" + "=" * 72)
    print("[测试 C] 逐对右目角点顺序自动搜索")
    print("=" * 72)

    all_results = []

    for image_mode in IMAGE_TRANSFORM_MODES:
        available = [
            pair for pair in pair_data
            if pair["index"] in right_cache[image_mode]
        ]

        if len(available) < MIN_VALID_PAIRS:
            continue

        objects = [
            object_template.copy()
            for _ in available
        ]

        lefts = [
            pair["left_corners"]
            for pair in available
        ]

        raw_rights = [
            right_cache[image_mode][pair["index"]]
            for pair in available
        ]

        _, K1, D1 = calibrate_mono(objects, lefts, image_size)
        _, K2, D2 = calibrate_mono(objects, raw_rights, image_size)

        selected_rights = []
        selected_rows = []

        for pair, raw_right in zip(available, raw_rights):
            candidates = []

            for corner_mode in CORNER_ORDER_MODES:
                ordered_right = reorder_right_corners(
                    raw_right,
                    corner_mode
                )

                extrinsic = get_pair_extrinsic(
                    object_template,
                    pair["left_corners"],
                    ordered_right,
                    K1,
                    D1,
                    K2,
                    D2
                )

                if extrinsic is None:
                    continue

                R, T = extrinsic

                candidates.append((
                    score_candidate(R, T),
                    corner_mode,
                    R,
                    T,
                    ordered_right
                ))

            candidates.sort(key=lambda item: item[0])

            score, best_order, R, T, best_right = candidates[0]

            selected_rights.append(best_right)

            selected_rows.append({
                "pair_number": pair["index"],
                "selected_corner_order": best_order,
                "score": float(score),
                "baseline_mm": float(np.linalg.norm(T)),
                "T_x_mm": float(T[0, 0]),
                "T_y_mm": float(T[1, 0]),
                "T_z_mm": float(T[2, 0]),
                "relative_rotation_deg": rotation_angle_deg(R),
            })

        result = run_stereo_fit(
            objects,
            lefts,
            selected_rights,
            image_size
        )

        counts = {
            mode: sum(
                row["selected_corner_order"] == mode
                for row in selected_rows
            )
            for mode in CORNER_ORDER_MODES
        }

        print("\n" + "-" * 72)
        print(f"右图像素变换:{image_mode}")
        print(
            f"顺序分布:原始={counts['ORIGINAL']},"
            f"水平翻={counts['FLIP_HORIZONTAL']},"
            f"垂直翻={counts['FLIP_VERTICAL']},"
            f"旋转180={counts['ROTATE_180']}"
        )
        print(f"混合顺序 Stereo RMS:{result['rms_stereo']:.4f}px")
        print(f"混合顺序 T(mm):{result['T'].ravel()}")
        print(f"混合顺序基线:{result['baseline_mm']:.3f}mm")

        write_csv(
            f"per_pair_selected_order_{image_mode}.csv",
            selected_rows
        )

        all_results.append({
            "image_transform": image_mode,
            "rms_stereo_px": result["rms_stereo"],
            "baseline_mm": result["baseline_mm"],
            "T_x_mm": float(result["T"][0, 0]),
            "T_y_mm": float(result["T"][1, 0]),
            "T_z_mm": float(result["T"][2, 0]),
            "original_count": counts["ORIGINAL"],
            "flip_horizontal_count": counts["FLIP_HORIZONTAL"],
            "flip_vertical_count": counts["FLIP_VERTICAL"],
            "rotate_180_count": counts["ROTATE_180"],
        })

    all_results.sort(key=lambda item: item["rms_stereo_px"])

    write_csv("per_pair_auto_order_summary.csv", all_results)

    return all_results


# ========================= 8. 主流程 =========================

def main():
    if not LEFT_DIR.is_dir() or not RIGHT_DIR.is_dir():
        sys.exit("[错误] 未找到 left/ 或 right/ 文件夹。")

    if OUTPUT_DIR.exists():
        shutil.rmtree(OUTPUT_DIR)

    OUTPUT_DIR.mkdir(parents=True, exist_ok=True)

    left_map = scan_images(LEFT_DIR)
    right_map = scan_images(RIGHT_DIR)

    common_ids = sorted(set(left_map) & set(right_map))

    pair_data = []
    image_size = None

    for index in common_ids:
        left = imread_unicode(left_map[index])
        right = imread_unicode(right_map[index])

        if left is None or right is None:
            continue

        lh, lw = left.shape[:2]
        rh, rw = right.shape[:2]

        if (lw, lh) != (rw, rh):
            continue

        if image_size is None:
            image_size = (lw, lh)

        corners_left = find_corners(left)
        corners_right = find_corners(right)

        if corners_left is None or corners_right is None:
            continue

        pair_data.append({
            "index": index,
            "left_image": left,
            "right_image": right,
            "left_corners": corners_left,
            "right_corners": corners_right,
        })

    if len(pair_data) < MIN_VALID_PAIRS:
        sys.exit("[错误] 有效图片对不足。")

    right_cache = {}

    for image_mode in IMAGE_TRANSFORM_MODES:
        right_cache[image_mode] = {}

        for pair in pair_data:
            transformed = transform_right_image(
                pair["right_image"],
                image_mode
            )

            corners = find_corners(transformed)

            if corners is not None:
                right_cache[image_mode][pair["index"]] = corners

    object_template = create_object_points()

    if RUN_GLOBAL_16_COMBINATION_TEST:
        global_results = test_global_16(
            pair_data,
            image_size,
            right_cache,
            object_template
        )

        if global_results:
            best = global_results[0]

            print(
                f"\n全局最优:图像={best['image_transform']},"
                f"角点={best['corner_order']},"
                f"RMS={best['rms_stereo_px']:.4f}px,"
                f"B={best['baseline_mm']:.2f}mm"
            )

    if RUN_PER_PAIR_RAW_EXTRINSIC_TEST:
        test_per_pair_raw(
            pair_data,
            image_size,
            object_template
        )

    if RUN_PER_PAIR_AUTO_CORNER_ORDER_TEST:
        auto_results = test_per_pair_auto_order(
            pair_data,
            image_size,
            right_cache,
            object_template
        )

        if auto_results:
            best = auto_results[0]

            print(
                f"\n逐对搜索最优:图像={best['image_transform']},"
                f"RMS={best['rms_stereo_px']:.4f}px,"
                f"B={best['baseline_mm']:.2f}mm"
            )


if __name__ == "__main__":
    main()

第五部分:运行顺序与结果检查

诊断时先运行:

复制代码
& E:\python\Anaconda3\anaconda\envs\yolov5\python.exe "F:\shixi_qijian_xunlian\测距、深度图、标定代码\two_vision_calibration(标定)\calibration_code\diagnose_right_order.py"

诊断完成后,查看:

复制代码
right_order_diagnosis\per_pair_selected_order_ORIGINAL.csv

本项目中,这个 CSV 定位出 0019 和 0031 两对异常图片。

删除这两对图片的 PowerShell 命令为:

复制代码
Remove-Item ".\left\left_0019.png", ".\right\right_0019.png", ".\left\left_0031.png", ".\right\right_0031.png"

之后运行正式标定:

复制代码
& E:\python\Anaconda3\anaconda\envs\yolov5\python.exe "F:\shixi_qijian_xunlian\测距、深度图、标定代码\two_vision_calibration(标定)\calibration_code\calibrate_stereo.py"

标定完成后,需要重点检查以下内容:

复制代码
左目 RMS
右目 RMS
双目 RMS
基线长度
T 的三个分量
rectify_preview.png

rectify_preview.png 中,左右棋盘格对应角点应尽量落在同一条水平线上。若绿色水平线穿过左图某个棋盘角点,也应基本穿过右图对应角点。


第六部分:标定完成后如何做深度预测

标定得到的 stereo_params.yamlstereo_maps.npz 不能直接交给 YOLO 就得到深度。正确流程是先完成左右图校正,再计算视差,最后恢复三维坐标。

复制代码
2560×720 SBS 原图 → 切分左右 1280×720 图 → 立体校正 → 计算视差 → Q 矩阵恢复 XYZ → YOLO 检测框中心附近取中位数深度

实时程序中,先加载校正映射表和 Q 矩阵。

复制代码
maps = np.load("stereo_maps.npz")

left_map_x = maps["left_map_x"]
left_map_y = maps["left_map_y"]
right_map_x = maps["right_map_x"]
right_map_y = maps["right_map_y"]

fs = cv2.FileStorage("stereo_params.yaml", cv2.FILE_STORAGE_READ)
Q = fs.getNode("Q").mat()
fs.release()

将 SBS 图拆开并校正。

复制代码
left_raw = frame[:, :1280]
right_raw = frame[:, 1280:2560]

left_rect = cv2.remap(left_raw, left_map_x, left_map_y, cv2.INTER_LINEAR)
right_rect = cv2.remap(right_raw, right_map_x, right_map_y, cv2.INTER_LINEAR)

计算视差并恢复三维坐标。

复制代码
gray_left = cv2.cvtColor(left_rect, cv2.COLOR_BGR2GRAY)
gray_right = cv2.cvtColor(right_rect, cv2.COLOR_BGR2GRAY)

disparity_raw = stereo.compute(gray_left, gray_right)
disparity = disparity_raw.astype(np.float32) / 16.0

points_3d = cv2.reprojectImageTo3D(disparity, Q)

对于 YOLO 检测框,不建议直接读取框中心单个像素的深度。该像素可能位于边缘、遮挡区域、无效视差区域或反光位置。更稳妥的方法是在框中心附近取 11×1115×15 区域,过滤掉无效视差,再取中位数作为目标深度。


第七部分:总结

本次双目标定过程的核心经验是:双目 RMS 远大于单目 RMS 时,不能只看"棋盘角点是否检测成功",也不能只看左右单目 RMS 是否较低。左右单目 RMS 低,只能说明每个镜头各自能够较好地解释棋盘角点;它并不能证明左右图中同编号角点一定是同一个物理交点。双目标定对左右角点对应关系的要求更严格,所有图片对必须共享同一组固定外参。只要少数图像存在角点编号方向不一致、左右对应关系错误、画面镜像、不同步采集或图像编号错配,就会让 stereoCalibrate() 得到没有物理意义的假外参。

本项目最初采集的 35 对图全部成功检测到 143 个棋盘角点,左右单目 RMS 也只有约 0.39 px,但双目 RMS 却达到约 35 px,估计基线只有约 13 mm。这个现象非常典型:单个相机模型没有问题,但左右相机之间的角点匹配关系出现了矛盾。由于普通黑白棋盘具有旋转对称性,OpenCV 在极少数图像中可能从棋盘另一端开始排列角点序列。这样,右图第 0 个角点不再对应左图第 0 个角点,双目标定就会把错误的角点对应当作真实约束,导致整体外参被拉偏。

为解决这个问题,本项目没有直接重新拍摄所有图片,而是增加了三层诊断。全局 16 组合测试用于排除"所有右图统一镜像或统一旋转"的可能性;逐对原始外参诊断用于确认绝大多数图片的基线确实稳定在约 60 mm;逐对角点顺序搜索则准确定位出 0019 和 0031 两对图片的右目角点顺序与其余图片不一致。删除这两对图片后,正式标定脚本又自动剔除了单目误差偏高的 0029,最终使用 32 对高质量图片完成标定。

重新标定后,双目 RMS 从约 35 px 降到 0.4056 px,基线恢复为 59.50 mm,平移向量为 [-59.50, -0.05, 0.14] mm。这组结果与实际约 6 cm 的镜头中心距一致,同时 Y、Z 方向偏移很小,说明左右相机安装关系稳定。整个过程说明,双目标定不能只依赖一次 stereoCalibrate() 的输出,而应结合单目 RMS、双目 RMS、逐对外参分布、实际基线、校正预览图和角点顺序一致性进行综合判断。对于后续深度预测,也必须保证运行时使用与标定相同的单目分辨率 1280×720,并严格执行"左右校正---视差计算---Q 矩阵恢复 XYZ"的流程,才能得到可靠的目标距离和三维坐标。