☰
相机标定与3D坐标转换实战:从棋盘格到PnP与8点算法
2026/10/16 3:20:50 网站建设 项目流程

简介:这是一套面向计算机视觉初学者与工程开发者的相机标定与3D坐标转换实战资源,基于Python实现,覆盖从相机参数求解到二维图像还原三维空间信息的完整链路,可应用于机器人、自动驾驶、监控与增强现实等场景。包内共210个文件,以87张jpg与71张png标定图像、28个py脚本为主体,辅以npz标定数据、xml配置、json参数及少量cpp源码,压缩包约71.12MB,目录结构清晰,便于按标定、去畸变、坐标转换等模块检索学习。内容涵盖RGB相机标定、红外相机标定、图像去畸变,以及平面直线算法、PnP算法、8点算法等多种2D到3D转换方法,并附有说明文档与实用资源文件,方便读者理解算法原理、复现实验流程并迁移到自有项目中。目前已有140人学习下载,适合需要系统掌握相机标定与三维重建基础的中高级读者参考。

1. 从一张棋盘格照片说起:这套相机标定与 3D 坐标转换系统到底能干什么

想象一个再普通不过的场景:你手里有一台 RGB 相机和一台红外相机,想用它们做深度估计或者三维重建。你拍了一堆棋盘格照片,跑完标定脚本,拿到内参矩阵和畸变系数,然后兴冲冲地拿去做三角测量——结果发现同一个点在两幅图里反投影出来的三维坐标差了十几厘米。问题往往不在算法本身,而在标定流程的细节:棋盘格角点检测的亚像素精度、畸变模型选了几项、红外图和 RGB 图的坐标系有没有对齐、PnP 求解时用的对应点是不是同一批。这套基于计算机视觉的相机标定与 3D 坐标转换系统,就是把这些环节串成一条可复现的流水线:从 RGB 相机标定、红外相机标定,到图像去畸变、平面直线算法、PnP 算法、8 点算法,再到最终的 2D 到 3D 坐标转换。它适合正在做视觉测量、机器人手眼标定、或者多相机系统标定的开发者,尤其是那些已经跑过 OpenCV 单目标定、但一到多相机和 3D 转换就翻车的同学。Python 写的,依赖清晰,改起来不费劲。

2. 技术栈与目录结构:先看清这套系统由哪些模块拼起来

2.1 核心依赖与运行环境

这套系统的主语言是 Python,视觉算法层几乎全部压在 OpenCV 上。常见做法是 Python 3.8 以上配 opencv-python 和 opencv-contrib-python,后者是为了拿到一些扩展模块里的标定辅助函数。数值计算用 NumPy,矩阵分解、SVD、最小二乘都靠它。如果你要跑红外相机标定,可能还需要一个能读红外原始帧的接口,比如通过某跨平台采集库拿到灰度图,再转成 OpenCV 能处理的格式。绘图和结果可视化用 Matplotlib,主要是把去畸变前后的对比图、重投影误差分布画出来。

安装命令我一般写成这样,方便一次性把依赖拉齐:

pip install opencv-python opencv-contrib-python numpy matplotlib

这里有个细节:opencv-python 和 opencv-contrib-python 不要同时装两个版本,否则容易出现cv2.calibrateCamera找不到或者findChessboardCornersSB行为不一致的问题。我习惯先卸载再装 contrib 版本,因为它包含基础模块的全部功能。

2.2 目录结构拆解

从项目标题和功能描述推断,这套系统的目录大致会按“标定—去畸变—坐标转换”三层来组织。下面是我根据这类项目的常见做法还原出来的结构,实际拿到源码后可以对照着看:

camera_calibration_3d/ ├── calibration/ │ ├── rgb_calibrate.py # RGB 相机标定主流程 │ ├── ir_calibrate.py # 红外相机标定主流程 │ └── corner_detect.py # 棋盘格角点检测与亚像素优化 ├── undistort/ │ ├── undistort_image.py # 图像去畸变 │ └── undistort_points.py # 点集去畸变 ├── geometry/ │ ├── plane_line.py # 平面直线算法 │ ├── pnp_solver.py # PnP 算法求解位姿 │ └── eight_point.py # 8 点算法求基础矩阵 ├── transform/ │ ├── pixel_to_3d.py # 2D 到 3D 坐标转换 │ └── coord_utils.py # 坐标系变换工具 ├── data/ │ ├── rgb/ # RGB 标定图 │ └── ir/ # 红外标定图 ├── config/ │ └── camera_params.yaml # 标定结果与相机参数 └── main.py # 入口脚本

这个结构的好处是每一层职责单一:calibration 只负责从图像里解出相机参数,undistort 只负责把畸变去掉,geometry 和 transform 负责几何计算和坐标映射。你改红外标定的时候不会碰到 PnP 的代码,调 8 点算法的时候也不会影响去畸变流程。

2.3 配置文件与参数管理

相机参数我强烈建议不要硬编码在脚本里。常见做法是用 YAML 存内参矩阵、畸变系数、图像尺寸和标定日期。读取的时候用cv2.FileStorage或者直接yaml.safe_load。下面是一个典型的参数文件长什么样:

rgb_camera: image_size: [1920, 1080] camera_matrix: [[1200.5, 0.0, 960.2], [0.0, 1200.8, 540.3], [0.0, 0.0, 1.0]] dist_coeffs: [0.12, -0.23, 0.001, 0.002, 0.05] rms_error: 0.18 ir_camera: image_size: [640, 480] camera_matrix: [[580.3, 0.0, 320.1], [0.0, 580.7, 240.5], [0.0, 0.0, 1.0]] dist_coeffs: [0.08, -0.15, 0.0, 0.0, 0.0] rms_error: 0.22

camera_matrix是 3x3 内参矩阵,dist_coeffs是 5 项畸变系数(k1, k2, p1, p2, k3)。rms_error是重投影误差,单位是像素,一般小于 0.3 就算标得不错。红外相机的畸变通常比 RGB 小,因为红外镜头结构简单,但也不是绝对的,得看具体镜头。

3. 标定流程拆解:从棋盘格角点到相机内参

3.1 棋盘格角点检测与亚像素优化

标定的第一步永远是找角点。OpenCV 提供了findChessboardCorners和更稳的findChessboardCornersSB。后者对光照和模糊更鲁棒,但要求棋盘格有白边。我一般先用 SB 版本,失败再退回普通版本。找到角点后必须做亚像素优化,否则重投影误差会大得离谱。

import cv2 import numpy as np def detect_corners(image_path, pattern_size=(9, 6)): img = cv2.imread(image_path) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 优先用 SB 版本,对红外图也相对友好 ret, corners = cv2.findChessboardCornersSB(gray, pattern_size, None) if not ret: ret, corners = cv2.findChessboardCorners(gray, pattern_size, None) if ret: # 亚像素优化,窗口大小 11x11,迭代 30 次或精度 0.001 criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) corners = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria) return ret, corners, gray.shape[::-1]

pattern_size是棋盘格内角点数,比如 9x6 表示每行 9 个、每列 6 个内角点。cornerSubPix的窗口大小 11x11 是经验值,图像分辨率高可以适当加大。红外图对比度低的时候,SB 版本也可能失败,这时候要么调曝光,要么用cv2.equalizeHist先做直方图均衡。

3.2 RGB 相机标定:calibrateCamera 的参数与输出

拿到所有图的角点后,就可以调cv2.calibrateCamera。这个函数的输入是世界坐标点、图像点、图像尺寸,输出内参矩阵、畸变系数、旋转向量和平移向量。

def calibrate_camera(obj_points, img_points, image_size): # obj_points 是 3D 点列表,img_points 是 2D 点列表 ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera( obj_points, img_points, image_size, None, None, flags=cv2.CALIB_FIX_K3 # 如果畸变不大,固定 k3 可以减少过拟合 ) return ret, mtx, dist, rvecs, tvecs

ret是重投影误差的 RMS,越小越好。mtx是内参矩阵,dist是畸变系数。flags里常用的还有cv2.CALIB_ZERO_TANGENT_DIST,如果你确定镜头没有切向畸变,可以固定 p1、p2 为 0。我一般先跑一遍不固定任何参数的,看 RMS 和畸变系数大小,再决定要不要加约束。如果 RMS 大于 0.5 像素,先别急着调算法,回去检查角点检测是不是有误检。

3.3 红外相机标定的特殊处理

红外相机标定和 RGB 最大的区别在图像本身:红外图是灰度图,对比度低,棋盘格可能发热不均匀导致边缘模糊。常见做法是先用cv2.normalize把灰度拉到 0-255,再做自适应直方图均衡。另外红外相机的内参和 RGB 不同,必须单独标定,不能共用。

def preprocess_ir(image_path): img = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) # 归一化到 0-255 img = cv2.normalize(img, None, 0, 255, cv2.NORM_MINMAX) # CLAHE 增强局部对比度 clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) img = clahe.apply(img) return img

clipLimit控制对比度增强的幅度,太大容易放大噪声。tileGridSize是分块大小,8x8 适合 640x480 的红外图。处理完之后再走和 RGB 一样的角点检测和标定流程。红外标定的 RMS 通常会比 RGB 大一点,0.3 到 0.5 之间都算正常。

4. 去畸变与几何算法:从畸变图像到干净坐标

4.1 图像去畸变:undistort 与 remap 的选择

去畸变有两个常用函数:cv2.undistort和cv2.initUndistortRectifyMap+cv2.remap。前者一行搞定,但每次都要重新计算映射表;后者适合视频流,映射表算一次可以反复用。

def undistort_image(img, mtx, dist): h, w = img.shape[:2] # 计算新的相机矩阵,保留所有像素 new_mtx, roi = cv2.getOptimalNewCameraMatrix(mtx, dist, (w, h), 1, (w, h)) # 生成映射表 mapx, mapy = cv2.initUndistortRectifyMap(mtx, dist, None, new_mtx, (w, h), 5) # 重映射 dst = cv2.remap(img, mapx, mapy, cv2.INTER_LINEAR) return dst, new_mtx, roi

getOptimalNewCameraMatrix的最后一个参数是 alpha,0 表示裁掉所有无效像素,1 表示保留全部像素但会有黑边。我一般用 1,因为后续做 3D 转换时边缘像素也有用。initUndistortRectifyMap的最后一个参数 5 表示映射类型是 CV_32FC1,固定写 5 就行。roi是有效区域,裁不裁看你的下游任务。

4.2 平面直线算法:拟合与交点计算

平面直线算法在这套系统里主要用于从图像中提取直线特征,比如标定板的边缘或者结构光条纹。常见做法是用cv2.HoughLinesP做概率霍夫变换,再用最小二乘拟合。

def fit_plane_line(points): # points 是 Nx2 的数组 # 用最小二乘拟合直线 ax + by + c = 0 A = np.c_[points[:, 0], points[:, 1], np.ones(points.shape[0])] _, _, V = np.linalg.svd(A) line = V[-1] # 最后一行是直线参数 return line # [a, b, c]

SVD 求直线参数比直接解方程稳,因为 A 矩阵可能秩亏。line是齐次坐标下的直线方程,归一化之后a^2 + b^2 = 1。两条直线求交点就是叉乘:

def line_intersection(line1, line2): return np.cross(line1, line2)

交点坐标是齐次的,除以第三维就是像素坐标。这个算法在标定板角点被遮挡时特别有用,可以用边缘直线交点来补全缺失的角点。

4.3 PnP 算法:从 2D 点解 3D 位姿

PnP(Perspective-n-Point)解决的是已知 3D 点和对应 2D 点,求相机位姿的问题。OpenCV 提供了solvePnP和solvePnPRansac。后者对误匹配更鲁棒,我一般优先用 RANSAC 版本。

def solve_pnp(object_points, image_points, mtx, dist): # object_points 是 Nx3,image_points 是 Nx2 ret, rvec, tvec, inliers = cv2.solvePnPRansac( object_points, image_points, mtx, dist, iterationsCount=100, reprojectionError=2.0, confidence=0.99 ) if ret: # 把旋转向量转成旋转矩阵 R, _ = cv2.Rodrigues(rvec) return R, tvec, inliers return None, None, None

reprojectionError是 RANSAC 的内点阈值,单位像素,2.0 是个比较宽松的值,标定好的相机可以设 1.0。inliers是内点索引,可以用来剔除误匹配。如果ret是 False,说明内点太少,检查 3D 点和 2D 点的对应关系是不是搞反了。

4.4 8 点算法:基础矩阵与极线约束

8 点算法用于从两幅图的对应点估计基础矩阵,是双目视觉里的经典方法。OpenCV 的findFundamentalMat默认就是用 8 点算法加 RANSAC。

def estimate_fundamental(pts1, pts2): # pts1, pts2 是 Nx2 的对应点 F, mask = cv2.findFundamentalMat( pts1, pts2, cv2.FM_8POINT + cv2.FM_RANSAC, ransacReprojThreshold=1.0, confidence=0.99 ) return F, mask

FM_8POINT指定用 8 点算法,FM_RANSAC加鲁棒估计。mask是内点掩码,可以用来过滤误匹配。基础矩阵 F 满足pts2^T * F * pts1 = 0,这是极线约束的核心。拿到 F 之后可以进一步分解出本质矩阵 E,再结合内参恢复相机之间的旋转和平移。

5. 避坑与排查:标定和 3D 转换里最容易翻车的几个点

5.1 重投影误差很小但 3D 坐标不准

现象:标定 RMS 只有 0.1 像素,但拿去做三角测量,同一个点的三维坐标在不同视角下差很多。原因通常是标定板的姿态太单一,所有图都是正对相机拍的,导致内参中的焦距和主点耦合,解不唯一。解决:标定图要覆盖不同角度和距离,至少 10 到 15 张,棋盘格在图像中的位置要分散到四个角和中心。我一般会强制要求每张图的棋盘格倾斜角度在 15 到 45 度之间。

5.2 红外标定角点检测失败

现象:RGB 标定很顺,红外图跑findChessboardCorners一直返回 False。原因是红外图对比度低,棋盘格黑白格温差不够。解决:先做 CLAHE 增强,再试findChessboardCornersSB。如果还不行,用加热的棋盘格或者主动散热片提高温差。另外红外镜头的焦距和 RGB 不同,棋盘格在红外图里可能太小,需要调整拍摄距离。

5.3 PnP 求解出的位姿跳变

现象:连续帧的 PnP 结果里,旋转矩阵偶尔会跳一下,平移向量也跟着抖。原因是 3D 点和 2D 点的对应关系里有误匹配,RANSAC 没完全剔除。解决:把reprojectionError从 2.0 降到 1.0,同时增加iterationsCount到 200。如果还跳,检查 3D 点是不是共面——共面的 3D 点做 PnP 会有二义性,需要至少 4 个不共面的点。

5.4 去畸变后图像边缘出现黑边

现象:undistort之后图像四周有黑色区域,影响后续特征提取。原因是getOptimalNewCameraMatrix的 alpha 设成了 0,裁掉了无效像素。解决:把 alpha 改成 1,保留全部像素,然后在后续处理里忽略黑边区域。或者用roi参数把有效区域裁出来,但这样会损失视野。

5.5 8 点算法估计的基础矩阵不稳定

现象:findFundamentalMat返回的 F 矩阵每次跑都不一样,极线约束误差很大。原因是 8 点算法对噪声敏感,尤其是点集分布不均匀的时候。解决:先用 RANSAC 过滤一遍,再把点集归一化(Hartley 归一化),最后用FM_LMEDS或者FM_RANSAC而不是纯FM_8POINT。如果点数少于 8 对,8 点算法直接失效,得换 5 点算法或者 7 点算法。

6. 二次开发与改造:把这套系统接到你自己的项目里

拿到这套源码之后,最直接的改造方向是替换数据源和扩展输出格式。比如你用的是某跨平台采集库拿到的红外帧,只需要在ir_calibrate.py里把cv2.imread换成你的采集接口,返回灰度图就行。标定结果我建议不要只存 YAML,同时存一份 JSON,方便其他语言的服务读取。

另一个常见的改造点是增加手眼标定模块。相机标定只解决了相机内参,如果相机装在机械臂上,还需要求相机坐标系到机械臂末端坐标系的变换。常见做法是用cv2.calibrateHandEye,输入是机械臂的位姿和标定板的位姿,输出是手眼矩阵。你可以新建一个handeye/目录,复用现有的角点检测和 PnP 求解代码。

验证改造是否正确的办法很简单:拿一个已知尺寸的标定板,放在相机视野里,用你的 2D 到 3D 转换算出标定板角点的三维坐标,再和实际物理尺寸对比。误差在 1% 以内就算合格。我一般会跑三组不同距离的数据,近中远各一组,看误差是不是稳定。

还有一个技巧:把标定和去畸变的映射表缓存成.npz文件,下次启动直接加载,省掉重复计算。np.savez('undistort_map.npz', mapx=mapx, mapy=mapy),加载的时候用np.load。这样视频流处理的时候帧率能稳不少。

从那以后我每次做多相机标定,都会强制走一遍“单目标定 → 去畸变 → 双目标定 → 手眼标定”的完整链路,中间任何一步 RMS 超过 0.5 像素就停下来查原因,绝不带着误差往下跑。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询