一、项目背景与最终标定结果
本项目使用一台输出 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。负号只代表坐标系方向定义,不表示镜头装反。Ty 和 Tz 接近零,说明两个镜头基本同高,也没有明显前后错位。
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.yaml 和 stereo_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×11 或 15×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"的流程,才能得到可靠的目标距离和三维坐标。