1. 准备工作
准备棋盘格标定板,最好可以采购专业的标定板
如图:
2. 采集图像数据
标定板在多个角度,多个距离下采集图片;
整个过程中保持相机配置不变(分辨率、焦距、变倍等)
3. 根据采集图像计算相机内参
代码输入参数介绍:
CameraCalibrator对象需要输入images_list,即标定图像路径的数组;如何获取到images_list根据自己标定的数据存放位置和方式自行决定;
pattern_size是指棋盘格的内焦点数量,分别是行数和列数,不要理解为格子的行数和列数。如图的标定板应该是;
标定的可视化图片默认存储在corner_visual目录下,可以判断角度的检测是否正常,效果如图:
内参标定结果文件存储在当前目录下的vis_camera_calibration_{timestamp}.json和vis_camera_calibration_{timestamp}.npz文件中。
两者的文件内容相同,仅格式不同。
相机内参标定的可执行代码:
import cv2
import numpy as np
import glob
import os
import json
from datetime import datetime
class CameraCalibrator:
def __init__(self, images_list, pattern_size, square_size_mm, camera_type="ir"):
"""
相机标定类 - 使用毫米(mm)为单位
Args:
images_list: 标定图像路径列表
pattern_size: 棋盘格内角点数量 (cols, rows)
square_size_mm: 棋盘格方格实际尺寸(毫米)
camera_type: 相机类型 ("ir" 或 "vis")
单位说明:
- 棋盘格尺寸: 毫米(mm)
- 世界坐标: 毫米(mm)
- 焦距: 像素/毫米(px/mm)
- 重投影误差: 像素(px)
"""
self.images_list = images_list
self.pattern_size = pattern_size
self.square_size_mm = square_size_mm # 明确使用毫米单位
self.camera_type = camera_type
# 世界坐标系点 (Z=0) - 使用毫米单位
self.objp = np.zeros((pattern_size[0] * pattern_size[1], 3), np.float32)
self.objp[:, :2] = np.mgrid[0:pattern_size[0], 0:pattern_size[1]].T.reshape(-1, 2)
self.objp *= square_size_mm # 乘以毫米尺寸
# 标定结果
self.camera_matrix = None
self.dist_coeffs = None
self.rvecs = None
self.tvecs = None
self.reprojection_error = None
def find_corners(self, save_visualization=True, visual_dir="corner_visual"):
"""检测棋盘格角点"""
images = self.images_list
print(f"找到 {len(images)} 张标定图像")
objpoints_all = []
imgpoints_all = []
used_imgs_all = []
gray_shape = None
if save_visualization:
os.makedirs(visual_dir, exist_ok=True)
success_count = 0
for idx, fname in enumerate(images):
img = cv2.imread(fname)
if img is None:
print(f"⚠️ 无法读取图像: {fname}")
continue
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# 优先使用更稳定的方法
ret, corners = cv2.findChessboardCornersSB(
gray, self.pattern_size,
flags=cv2.CALIB_CB_NORMALIZE_IMAGE | cv2.CALIB_CB_EXHAUSTIVE
)
# 如果失败,尝试传统方法
if not ret:
ret, corners = cv2.findChessboardCorners(
gray, self.pattern_size,
flags=cv2.CALIB_CB_ADAPTIVE_THRESH + cv2.CALIB_CB_NORMALIZE_IMAGE
)
if ret:
# 亚像素精确化
corners_refined = cv2.cornerSubPix(
gray, corners, (11, 11), (-1, -1),
criteria=(cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)
)
objpoints_all.append(self.objp)
imgpoints_all.append(corners_refined)
used_imgs_all.append(fname)
success_count += 1
if gray_shape is None:
gray_shape = gray.shape[::-1]
if save_visualization:
vis_img = img.copy()
cv2.drawChessboardCorners(vis_img, self.pattern_size, corners_refined, ret)
filename = os.path.basename(fname)
save_path = os.path.join(visual_dir, f"corners_{filename}")
cv2.imwrite(save_path, vis_img)
print(f"✅ {os.path.basename(fname)}: 角点检测成功")
else:
print(f"❌ {os.path.basename(fname)}: 角点检测失败")
print(f"\n角点检测完成: {success_count}/{len(images)} 张图像成功")
return objpoints_all, imgpoints_all, used_imgs_all, gray_shape
def calibrate(self, reproj_error_thresh=0.5, save_results=True):
"""执行相机标定"""
# 检测角点
objpoints_all, imgpoints_all, used_imgs_all, gray_shape = self.find_corners()
if len(objpoints_all) < 5:
raise RuntimeError(f"有效标定图像不足 5 张(当前 {len(objpoints_all)} 张),请检查棋盘格和图像质量")
# 第一次标定(计算重投影误差)
print("正在进行第一次标定...")
ret_all, K_all, D_all, rvecs_all, tvecs_all = cv2.calibrateCamera(
objpoints_all, imgpoints_all, gray_shape, None, None,
flags=cv2.CALIB_FIX_K3 + cv2.CALIB_ZERO_TANGENT_DIST
)
# 计算重投影误差
errors = []
for i in range(len(objpoints_all)):
imgpoints_proj, _ = cv2.projectPoints(objpoints_all[i], rvecs_all[i], tvecs_all[i], K_all, D_all)
error = cv2.norm(imgpoints_all[i], imgpoints_proj, cv2.NORM_L2) / len(imgpoints_proj)
errors.append(error)
# 过滤高误差图像
good_idx = [i for i, e in enumerate(errors) if e < reproj_error_thresh]
objpoints = [objpoints_all[i] for i in good_idx]
imgpoints = [imgpoints_all[i] for i in good_idx]
used_imgs = [used_imgs_all[i] for i in good_idx]
print(f"过滤前: {len(objpoints_all)} 张, 平均误差: {np.mean(errors):.3f} px")
print(f"过滤后: {len(objpoints)} 张, 平均误差: {np.mean([errors[i] for i in good_idx]):.3f} px")
if len(objpoints) < 5:
raise RuntimeError("过滤后有效图像不足 5 张,请调整阈值或补充图像")
# 第二次标定(使用过滤后的数据)
print("正在进行最终标定...")
self.reprojection_error, self.camera_matrix, self.dist_coeffs, self.rvecs, self.tvecs = cv2.calibrateCamera(
objpoints, imgpoints, gray_shape, None, None,
flags=cv2.CALIB_FIX_K3 + cv2.CALIB_ZERO_TANGENT_DIST
)
# 保存结果
if save_results:
self.save_results(used_imgs, errors, good_idx)
return self.reprojection_error, self.camera_matrix, self.dist_coeffs
def calculate_focal_length(self):
"""计算焦距(像素/毫米)"""
if self.camera_matrix is None:
raise ValueError("请先执行标定")
fx = self.camera_matrix[0, 0] # 像素/毫米
fy = self.camera_matrix[1, 1] # 像素/毫米
print(f"焦距 fx: {fx:.2f} px/mm")
print(f"焦距 fy: {fy:.2f} px/mm")
print(f"平均焦距: {(fx + fy) / 2:.2f} px/mm")
return (fx + fy) / 2
def save_results(self, used_imgs, errors, good_idx):
"""保存标定结果"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
# 保存为npz文件
npz_filename = f"{self.camera_type}_camera_intrinsics_{timestamp}.npz"
np.savez(npz_filename,
camera_matrix=self.camera_matrix,
dist_coeffs=self.dist_coeffs,
reprojection_error=self.reprojection_error)
# 保存为JSON文件(可读性更好)
json_filename = f"{self.camera_type}_camera_calibration_{timestamp}.json"
# 计算焦距(像素/毫米)
focal_length_px_per_mm = self.calculate_focal_length()
calibration_data = {
"camera_type": self.camera_type,
"calibration_date": timestamp,
"image_count": len(good_idx),
"reprojection_error": float(self.reprojection_error),
"camera_matrix": self.camera_matrix.tolist(),
"dist_coeffs": self.dist_coeffs.flatten().tolist(),
"focal_length_px_per_mm": float(focal_length_px_per_mm),
"square_size_mm": float(self.square_size_mm),
"used_images": [os.path.basename(f) for i, f in enumerate(used_imgs) if i in good_idx],
"units": {
"focal_length": "px/mm",
"square_size": "mm",
"world_coordinates": "mm"
}
}
with open(json_filename, 'w') as f:
json.dump(calibration_data, f, indent=2)
print(f"\n✅ 标定结果已保存:")
print(f" - {npz_filename} (NumPy格式)")
print(f" - {json_filename} (JSON格式)")
def print_detailed_results(self):
"""打印详细标定结果"""
if self.camera_matrix is None:
raise ValueError("请先执行标定")
print("\n" + "="*50)
print(f"📷 {self.camera_type.upper()} 相机标定结果")
print("="*50)
print(f"棋盘格尺寸: {self.square_size_mm} mm")
print(f"重投影误差: {self.reprojection_error:.3f} px")
print(f"图像尺寸: {self.camera_matrix[0,2]*2:.0f} x {self.camera_matrix[1,2]*2:.0f}")
print("\n相机内参矩阵 (单位: 像素/毫米):")
print(f"fx = {self.camera_matrix[0,0]:.2f} px/mm")
print(f"fy = {self.camera_matrix[1,1]:.2f} px/mm")
print(f"cx = {self.camera_matrix[0,2]:.2f} px")
print(f"cy = {self.camera_matrix[1,2]:.2f} px")
print("\n畸变系数:")
print(f"k1 = {self.dist_coeffs[0,0]:.6f}")
print(f"k2 = {self.dist_coeffs[0,1]:.6f}")
print(f"p1 = {self.dist_coeffs[0,2]:.6f}")
print(f"p2 = {self.dist_coeffs[0,3]:.6f}")
print(f"k3 = {self.dist_coeffs[0,4]:.6f}")
# ====================== 辅助函数 ======================
def collect_images(root_dir, sub_dir="vis"):
"""
遍历 root_dir 下所有子目录,收集 {sub_dir} 文件夹里的图像路径
"""
img_exts = ("*.png", "*.jpg", "*.bmp", "*.jpeg")
images = []
vis_dirs = glob.glob(os.path.join(root_dir, f"*/{sub_dir}"))
for vdir in vis_dirs:
for ext in img_exts:
images.extend(glob.glob(os.path.join(vdir, ext)))
return sorted(images)
# ====================== 使用示例 ======================
if __name__ == "__main__":
# 收集图像路径列表(需要根据自己情况来设置)
images_list = collect_images("250922", sub_dir="vis")
# 创建标定器
calibrator = CameraCalibrator(
images_list=images_list,
pattern_size=(11, 8), # 内角点数量 (列, 行)
square_size_mm=20.0, # 棋盘格尺寸 (毫米)
camera_type="vis"
)
try:
# 执行标定
reproj_error, camera_matrix, dist_coeffs = calibrator.calibrate(reproj_error_thresh=0.5)
# 打印详细结果
calibrator.print_detailed_results()
# 计算焦距
focal_length_px_per_mm = calibrator.calculate_focal_length()
focal_length_px_per_m = focal_length_px_per_mm * 1000 # 转换为像素/米
print(f"\n📏 焦距转换:")
print(f" {focal_length_px_per_mm:.2f} px/mm")
print(f" {focal_length_px_per_m:.2f} px/m")
except Exception as e:
print(f"❌ 标定失败: {e}")
4. 加载相机内参文件
vis_camera_calibration_{timestamp}.json的内参结构如下,按需加载并使用
:
{
"camera_type": "vis",
"calibration_date": "20250923_160641",
"image_count": 50,
"reprojection_error": 0.20444170548920967,
"camera_matrix": [
1287.9153968530886, 0.0, 666.6348530947699 \], \[ 0.0, 1294.5628736235296, 353.20852733360584 \], \[ 0.0, 0.0, 1.0
],
"dist_coeffs": [
-0.3249388217365418,
0.22219388748081903,
0.0,
0.0,
0.0
],
"focal_length_px_per_mm": 1291.2391352383092,
"square_size_mm": 20.0,
"used_images": [
"100_1.png",
"100_2.png",
"100_3.png",
"100_4.png",
"100_5.png",
"110_1.png",
"110_2.png",
"110_3.png",
"110_4.png",
"110_5.png",
"120_1.png",
"120_2.png",
"120_3.png",
"120_4.png",
"120_5.png",
"130_1.png",
"130_2.png",
"130_3.png",
"130_4.png",
"130_5.png",
"140_1.png",
"140_2.png",
"140_3.png",
"140_4.png",
"140_5.png",
"150_1.png",
"150_2.png",
"150_3.png",
"150_4.png",
"150_5.png",
"160_1.png",
"160_2.png",
"160_3.png",
"160_4.png",
"160_5.png",
"170_1.png",
"170_2.png",
"170_3.png",
"170_4.png",
"170_5.png",
"70_1.png",
"70_2.png",
"70_3.png",
"80_1.png",
"80_2.png",
"80_3.png",
"90_1.png",
"90_2.png",
"90_3.png",
"90_4.png"
],
"units": {
"focal_length": "px/mm",
"square_size": "mm",
"world_coordinates": "mm"
}
}
示例:加载vis_camera_calibration_{timestamp}.json
def load_camera_intrinsics_from_json(self, json_path):
"""加载相机内参"""
with open(json_path, 'r') as f:
calib_data = json.load(f)
camera_matrix = np.array(calib_data["camera_matrix"])
if "focal_length_px_per_mm" in calib_data:
focal_length_px_per_mm = calib_data["focal_length_px_per_mm"]
else:
fx, fy = camera_matrix[0,0], camera_matrix[1,1]
focal_length_px_per_mm = (fx + fy) / 2
print(f"📊 标定焦距: {focal_length_px_per_mm:.2f} px/mm")
return camera_matrix, focal_length_px_per_mm