本系列文章旨在系统性地阐述如何利用 Python 与 OpenCV 库,从零开始构建一个完整的双目立体视觉系统。
本项目github地址:https://github.com/present-cjn/stereo-vision-python.git
在上一篇文章中,我们为项目设计了清晰的架构。现在,我们将深入第一个,也是整个双目视觉系统最关键的模块——相机标定 (Camera Calibration)。
如果说双目视觉系统是一座高楼,那么标定就是它的地基。一个不准确、不稳定的标定结果,会使后续所有的计算(立体匹配、三维重建)都失去意义,最终导致整个系统的崩塌。无论你的匹配算法多么先进,都无法弥补一个错误的地基。
本文将深入探讨相机标定的核心原理,详细对比“一步标定法”与“两步标定法”的优劣,并逐行解析我们项目中健壮的标定代码实现。
相机标定的根本目的,是精确地计算出相机的内参 (Intrinsics) 和 外参 (Extrinsics)。
内参描述了相机自身的光学特性,它将三维世界中的点投影到二维图像平面上。这与相机被如何放置无关,是相机出厂时就固有的属性。
K
:[[ fx, 0, cx ],
[ 0, fy, cy ],
[ 0, 0, 1 ]]
fx
, fy
): 以像素为单位,决定了相机的视野范围(FOV)。cx
, cy
): 相机光轴与成像平面的交点,通常非常接近图像的中心。D
: 由于镜头制造工艺的物理限制,图像会产生变形。畸变主要分为两种:对于双目系统,外参描述了右相机相对于左相机坐标系的空间位置关系。
R
(3x3): 描述了右相机相对于左相机的旋转姿态。T
(3x1): 描述了右相机光学中心相对于左相机光学中心的位移。T
的模长通常被称为基线距 (Baseline),是深度计算的关键参数。在OpenCV中,获取这些参数主要有两种策略。
cv2.stereoCalibrate
函数,让它一次性地、同时地去求解两个相机的内参、畸变以及它们之间的外参,总共20多个未知参数。cv2.calibrateCamera
函数。这是一个相对简单的优化问题,可以非常精确地计算出每个相机各自的内参 K
和畸变 D
。cv2.stereoCalibrate
,但这次将第一步得到的精确内参作为高质量的初始猜测值,并使用 cv2.CALIB_USE_INTRINSIC_GUESS
标志。现在,我们深入 calibration/calibrator.py
文件,看看这些理论是如何通过代码实现的。
__init__
:准备“标准答案卡”class StereoCalibrator:
def __init__(self, chessboard_size: tuple, square_size: float):
self.chessboard_size = chessboard_size
self.square_size = square_size
# 准备 objectPoints,这是棋盘格角点的三维物理坐标
width, height = self.chessboard_size
self.objp = np.zeros((width * height, 3), np.float32)
# 使用 np.mgrid 生成网格点,确保 (y, x) 顺序
grid_points = np.mgrid[0:height, 0:width].T.reshape(-1, 2)
self.objp[:, :2] = grid_points
self.objp *= self.square_size
self.objp
是我们提供给算法的“标准答案”。我们在这里创建了一个完美的、尺寸精确的虚拟棋盘格。np.mgrid[0:height, 0:width]
的顺序至关重要,它确保了我们生成的3D点顺序能与 findChessboardCorners
的输出顺序匹配。_find_corners_in_all_images
:寻找角点def _find_corners_in_all_images(self, image_source):
# ... (glob 和 natural_sort_key 加载图像对) ...
for left_path, right_path in image_pairs:
# ... (读取图像,并健壮地转换为灰度图) ...
# 为左图增加亮度归一化标志,提升在不同光照下的检测成功率
find_flags_l = cv2.CALIB_CB_NORMALIZE_IMAGE
ret_left, corners_left = cv2.findChessboardCorners(gray_left, self.chessboard_size, flags=find_flags_l)
ret_right, corners_right = cv2.findChessboardCorners(gray_right, self.chessboard_size, None)
# 只有当左右图像都成功找到了角点,才进行后续处理
if ret_left and ret_right:
# 将角点精确到亚像素级
corners_left_sub = cv2.cornerSubPix(gray_left, corners_left, (3, 3), (-1, -1), config.SUBPIX_CRITERIA)
corners_right_sub = cv2.cornerSubPix(gray_right, corners_right, (3, 3), (-1, -1), config.SUBPIX_CRITERIA)
object_points.append(self.objp)
image_points_left.append(corners_left_sub)
image_points_right.append(corners_right_sub)
else:
# 打印被跳过的图像对,便于调试
print(f" - Skipped pair: ... (Left found: {ret_left}, Right found: {ret_right})")
# ...
return object_points, image_points_left, image_points_right, image_size
if ret_left and ret_right:
这个“守门员”逻辑,自动剔除了质量不佳的图像对,是保证最终标定质量的关键。_calibrate_single_camera
:执行单目标定@staticmethod
def _calibrate_single_camera(obj_points, img_points, img_size, camera_name: str):
print(f"\nPerforming monocular calibration for {camera_name} camera...")
ret, K, D, rvecs, tvecs = cv2.calibrateCamera(
obj_points, img_points, img_size, None, None,
criteria=config.MONO_CALIB_CRITERIA
)
# 效果评估:要求重投影误差必须小于1.0
assert ret < 1.0, f"{camera_name} camera reprojection error is too high: {ret}"
print(f" - {camera_name} camera calibrated with reprojection error: {ret}")
return K, D, ret
K
和畸变 D
。我们用 assert ret < 1.0
来强制要求一个高质量的标定结果。_calibrate_stereo_relationship
:执行双目标定@staticmethod
def _calibrate_stereo_relationship(obj_points, img_points_l, img_points_r, K1, D1, K2, D2, img_size):
print("\nPerforming stereo calibration to find the relationship between cameras...")
# 核心标志:告诉函数使用我们提供的K,D作为高质量的初始猜测值
flags = config.STEREO_CALIB_FLAGS # cv2.CALIB_USE_INTRINSIC_GUESS
ret, K1, D1, K2, D2, R, T, E, F = cv2.stereoCalibrate(
obj_points, img_points_l, img_points_r,
K1, D1, # 传入单目标定的结果作为初始值
K2, D2,
img_size,
flags=flags,
criteria=config.STEREO_CALIB_CRITERIA
)
assert ret < 1.0, f"Stereo calibration reprojection error is too high: {ret}"
# ...
return stereo_params
flags=cv2.CALIB_USE_INTRINSIC_GUESS
,它告诉算法:“在优化 R
和 T
的同时,你可以微调一下我给你的 K
和 D
,以达到全局最优。”StereoCalibrator
类我们已经设计了一个功能强大且独立的 StereoCalibrator
类。那么,在一个简单的脚本中,我们该如何调用它来完成标定呢?下面是一个最简化的概念性示例:
# conceptual_usage_example.py
import os
from calibration.calibrator import StereoCalibrator
from utils import file_utils
# 1. 定义你的标定板参数
chessboard_size = (11, 8)
square_size_mm = 12.0
image_directory = "data/calibration_images/"
output_file = "output/stereo_params.yml"
# 2. 创建标定器实例
calibrator = StereoCalibrator(
chessboard_size=chessboard_size,
square_size=square_size_mm
)
# 3. 执行标定
# 在实际项目中,我们通过更灵活的方式传入图片路径
stereo_params = calibrator.run(image_directory)
# 4. 检查并保存结果
if stereo_params:
print("标定成功!正在保存参数...")
# 确保输出目录存在
os.makedirs("output", exist_ok=True)
file_utils.save_stereo_params(output_file, stereo_params)
else:
print("标定失败。")
在我们的完整项目中,上述所有步骤,包括参数的传递和文件的保存,都已经被优雅地封装在了 main.py
的命令行界面中。你不需要自己编写上面的脚本,只需要在克隆了我们的完整代码后,运行一个简单的命令即可开始标定:
# 用项目中的测试图片
python main.py calibrate
或者也可以用自己的采集的图片,按项目要求修改图片名放到[项目路径/data/calibration_images]
# 用自己给定的图片,例如一个11x8角点,格子边长12mm的标定板
python main.py calibrate --corners 11,8 --size 12
用项目中的测试图片运行成功后会打印如下信息
当你成功运行标定后,会得到一个 .yml
文件。解读这份标定结果文件的能力至关重要:
fx
和 fy
应该各自相近,并且两个相机的 K
矩阵整体也应该非常相似。R
应该非常接近一个单位矩阵。Tx
: 应该是负值,其绝对值就是你的基线距(单位mm),应该与你的物理测量值大致相符。Ty
, Tz
: 应该都非常接近于0,表明两个相机在垂直和前后方向上对齐得很好。