第11章 标定系统¶
本章目标
理解标定在自瞄系统中的核心作用,掌握相机内参标定和手眼标定的原理与实操流程,能够独立阅读和运行 sp_vision_25 中的标定代码,用重投影误差验证结果质量。
预计用时: 2-3 天
前置知识: 第5章 OpenCV基础、第9章 PnP 位姿解算
11.1 为什么需要标定¶
这是什么¶
标定(Calibration)是确定相机成像模型参数的过程。相机镜头不是理想的针孔模型,存在光学畸变;相机安装在云台上时,两者之间存在制造和装配误差。标定就是用数学方法把这些误差"测量"出来,然后在算法中补偿掉。
为什么需要¶
自瞄系统的完整链路是:像素坐标 → 目标3D位姿 → 云台转动 → 开火。如果相机参数不准确,第一步的 PnP 解算就会有误差,后续所有环节的精度都会劣化。
标定要解决两个核心问题:
| 问题 | 来源 | 后果 |
|---|---|---|
| 镜头畸变 | 镜片加工和组装带来的光学像差 | 图像边缘的目标形状扭曲,角点提取不准,PnP 解算偏移 |
| 手眼关系未知 | 相机安装在云台上,两者的相对位姿由装配决定 | 即使 PnP 解算正确,也无法正确转换到云台坐标系 |
直觉理解
想象你用肉眼通过一个玻璃杯看桌上的硬币——玻璃杯会让硬币看起来偏移了位置。畸变标定就是"测量"这个偏移量,手眼标定就是"测量"你的眼睛和手之间的相对位置关系。两者缺一不可。
标定系统在自瞄链路中的位置¶
camera_matrix, distort_coeffs"] B --> C["灯条检测 + 数字识别"] C --> D["PnP 解算
目标在相机坐标系的位姿"] D --> E["手眼变换
R_camera2gimbal, t_camera2gimbal"] E --> F["目标在云台坐标系的位姿"] F --> G["弹道解算 → 瞄准 → 开火"] style B fill:#fff3cd,stroke:#856404 style E fill:#fff3cd,stroke:#856404
标定结果是黄色标注的两个环节:畸变校正和手眼变换。任何一个不准确,后续解算都会产生系统性偏差。
11.2 相机内参标定原理¶
针孔相机模型¶
理想情况下,三维空间点 \(P(X, Y, Z)\) 投影到图像平面像素 \((u, v)\) 的过程可以用一个 3x3 矩阵描述:
其中:
- \(f_x, f_y\) — 焦距(以像素为单位),通常 \(f_x \approx f_y\)
- \(c_x, c_y\) — 光心(图像中心点坐标),通常接近图像分辨率的中心
- \(K\) — 内参矩阵(Intrinsic Matrix),描述相机自身的成像特性
焦距的单位
物理焦距 \(f\)(单位 mm)和像素焦距的关系是:\(f_x = f \cdot m_x\),\(f_y = f \cdot m_y\),其中 \(m_x, m_y\) 是传感器上每毫米的像素数(pixel/mm)。例如,一个 16mm 镜头配合 1/2 英寸传感器(像元尺寸 3.45μm),\(f_x \approx 16 / 0.00345 \approx 4638\) 像素。不过实际值由标定程序计算,不需要手动换算。
完整的投影公式¶
从世界坐标系到像素坐标系的完整变换链是:
其中 \([R \mid t]\) 是外参矩阵(描述世界坐标系到相机坐标系的旋转和平移)。内参标定就是求 \(K\) 和畸变系数。
畸变模型¶
真实镜头存在两类畸变:
径向畸变(Radial Distortion)— 光线在镜片边缘弯曲程度更大:
其中 \(r = \sqrt{x^2 + y^2}\) 是像素到光心的归一化距离,\(k_1, k_2, k_3\) 是径向畸变系数:
- \(k_1 > 0\):枕形畸变(图像向外膨胀)
- \(k_1 < 0\):桶形畸变(图像向内收缩)
切向畸变(Tangential Distortion)— 镜片与传感器不完全平行:
\(p_1, p_2\) 是切向畸变系数。对于工业镜头,切向畸变通常很小。
RoboMaster 实际经验
我们常用的工业镜头(如 Computar、Kowa)畸变通常很小,\(k_1\) 在 \(-0.1 \sim 0\) 左右。但鱼眼镜头的畸变会大很多,需要更多畸变系数来描述。sp_vision_25 中固定了 \(k_3=0\)(cv::CALIB_FIX_K3),因为小视场角镜头不需要高阶畸变项。
去畸变过程¶
已知 \(K\) 和畸变系数后,可以用 cv::undistort() 或 cv::initUndistortRectifyMap() 对图像做去畸变。其数学过程是:
- 对去畸变图像中的每个像素 \((u, v)\),用 \(K^{-1}\) 反投影到归一化坐标
- 用畸变模型计算该点在畸变图像中对应的位置
- 用双线性插值采样
边缘直线弯曲"] -->|"undistort()"
K + distCoeffs| B["去畸变图像
直线恢复平直"]
张正友标定法¶
张正友标定法(Zhang's Method)是目前最常用的相机标定方法,只需要一张打印的棋盘格图案和多张不同角度的照片就能完成标定。
核心思路:
已知格点尺寸"] --> B["多角度拍摄
≥15张"] B --> C["检测角点
cv::findChessboardCorners"] C --> D["求解内参+畸变
cv::calibrateCamera"] D --> E["获得 K, distCoeffs"]
数学原理简述:
- 棋盘格平面在世界坐标系中 \(Z=0\),所以单应性矩阵 \(H\) 可以将棋盘格的 2D 坐标映射到图像像素坐标
- 每张图片提供一个单应性矩阵 \(H = [h_1, h_2, h_3] = K[r_1, r_2, t]\)
- 利用旋转矩阵的正交性约束(\(r_1^T r_2 = 0\),\(\|r_1\| = \|r_2\|\)),每张图片可以提供 2 个方程
- 3 张以上不同角度的图片即可求解 \(K\) 中的 5 个未知数
- 实际使用 15-20 张是为了用最小二乘法提高精度和鲁棒性
棋盘格图案要求:
- 通常用 9x6 或 11x8 的棋盘格(内部角点数量,即格子数减一)
- 打印后贴在平整的板上(如亚克力板或硬纸板),不能弯曲
- 每个格子的物理尺寸要精确测量(如 25mm),标定时作为参数传入
sp_vision_25中使用的是圆点阵列(circles grid),而非棋盘格,原理类似
拍摄要求:
角度太单一"] B2["棋盘弯曲
平面假设失效"] B3["模糊/曝光异常
角点检测不准"] end
至少拍摄 15-20 张,覆盖图像的各个区域和不同倾斜角度。棋盘格应出现在画面的不同位置(左上、右下、正中、边缘),并且有不同角度的倾斜。
11.3 相机内参标定代码走读¶
以下是 sp_vision_25/calibration/calibrate_camera.cpp 的完整代码及逐段解释。
头文件和命令行参数¶
#include <fmt/core.h>
#include <yaml-cpp/yaml.h>
#include <fstream>
#include <opencv2/opencv.hpp>
#include "tools/img_tools.hpp"
fmt/core.h— 格式化输出库,比printf更安全yaml-cpp/yaml.h— YAML 文件读写,用于加载标定板参数和输出标定结果opencv2/opencv.hpp— OpenCV 主头文件,包含calib3d(标定模块)
const std::string keys =
"{help h usage ? | | 输出命令行参数说明}"
"{config-path c | configs/calibration.yaml | yaml配置文件路径 }"
"{@input-folder | assets/img_with_q | 输入文件夹路径 }";
OpenCV 的 CommandLineParser 定义了三个参数:
| 参数 | 默认值 | 含义 |
|---|---|---|
-h |
- | 显示帮助信息 |
-c / --config-path |
configs/calibration.yaml |
标定板配置文件(行列数、格点间距) |
input-folder(位置参数) |
assets/img_with_q |
存放标定图片的文件夹 |
生成 3D 世界坐标¶
std::vector<cv::Point3f> centers_3d(
const cv::Size & pattern_size, const float center_distance)
{
std::vector<cv::Point3f> centers_3d;
for (int i = 0; i < pattern_size.height; i++)
for (int j = 0; j < pattern_size.width; j++)
centers_3d.push_back({j * center_distance, i * center_distance, 0});
return centers_3d;
}
这个函数生成标定板上每个圆点的 3D 坐标。因为标定板是平面的(\(Z=0\)),所以:
- 第
i行第j列的圆点坐标为 \((j \times d, i \times d, 0)\) center_distance是相邻圆点的中心距离(单位 mm)- 坐标系原点在第一个圆点(左上角),X 轴向右,Y 轴向下
单位很重要
center_distance 的单位是 mm,这会直接影响标定输出中 \(t\)(平移向量)的单位。sp_vision_25 中平移向量的单位也是 mm,在手眼标定完成后会除以 1000 转换为米(见 calibrate_handeye.cpp 第 157 行)。
加载标定数据¶
void load(
const std::string & input_folder, const std::string & config_path,
cv::Size & img_size,
std::vector<std::vector<cv::Point3f>> & obj_points,
std::vector<std::vector<cv::Point2f>> & img_points)
{
auto yaml = YAML::LoadFile(config_path);
auto pattern_cols = yaml["pattern_cols"].as<int>();
auto pattern_rows = yaml["pattern_rows"].as<int>();
auto center_distance_mm = yaml["center_distance_mm"].as<double>();
cv::Size pattern_size(pattern_cols, pattern_rows);
从 YAML 配置文件中读取标定板参数:
pattern_cols/pattern_rows— 圆点阵列的列数和行数(如 10 列 7 行)center_distance_mm— 相邻圆点中心距离(mm)
for (int i = 1; true; i++) {
auto img_path = fmt::format("{}/{}.jpg", input_folder, i);
auto img = cv::imread(img_path);
if (img.empty()) break; // 图片读完,退出循环
img_size = img.size();
循环读取 1.jpg, 2.jpg, ... 直到读不到为止。注意图片必须按数字编号命名。
std::vector<cv::Point2f> centers_2d;
auto success = cv::findCirclesGrid(
img, pattern_size, centers_2d, cv::CALIB_CB_SYMMETRIC_GRID);
auto drawing = img.clone();
cv::drawChessboardCorners(drawing, pattern_size, centers_2d, success);
cv::resize(drawing, drawing, {}, 0.5, 0.5);
cv::imshow("Press any to continue", drawing);
cv::waitKey(0);
fmt::print("[{}] {}\n", success ? "success" : "failure", img_path);
if (!success) continue;
关键函数 cv::findCirclesGrid():
- 检测圆点阵列中的圆心位置
cv::CALIB_CB_SYMMETRIC_GRID表示对称排列的圆点(区别于非对称排列)- 返回值
success表示是否成功找到所有圆点 - 成功时
centers_2d包含检测到的所有圆心像素坐标
如果检测失败,打印 [failure] 并跳过该图片。程序会可视化检测结果让你确认。
img_points.emplace_back(centers_2d);
obj_points.emplace_back(centers_3d(pattern_size, center_distance_mm));
}
}
将成功的图片的 2D 像素坐标和对应 3D 世界坐标存入列表。
执行标定¶
int main(int argc, char * argv[])
{
cv::CommandLineParser cli(argc, argv, keys);
if (cli.has("help")) {
cli.printMessage();
return 0;
}
auto input_folder = cli.get<std::string>(0);
auto config_path = cli.get<std::string>("config-path");
cv::Size img_size;
std::vector<std::vector<cv::Point3f>> obj_points;
std::vector<std::vector<cv::Point2f>> img_points;
load(input_folder, config_path, img_size, obj_points, img_points);
main 函数的前半部分:解析命令行参数,调用 load() 加载数据。
cv::Mat camera_matrix, distort_coeffs;
std::vector<cv::Mat> rvecs, tvecs;
auto criteria = cv::TermCriteria(
cv::TermCriteria::COUNT + cv::TermCriteria::EPS, 100, DBL_EPSILON);
cv::calibrateCamera(
obj_points, img_points, img_size,
camera_matrix, distort_coeffs, rvecs, tvecs,
cv::CALIB_FIX_K3, criteria);
关键函数 cv::calibrateCamera():
| 参数 | 类型 | 含义 |
|---|---|---|
obj_points |
vector<vector<Point3f>> |
每张图片对应的 3D 世界坐标 |
img_points |
vector<vector<Point2f>> |
每张图片中检测到的 2D 像素坐标 |
img_size |
Size |
图像尺寸(宽 x 高) |
camera_matrix |
Mat (输出) |
3x3 内参矩阵 \(K\) |
distort_coeffs |
Mat (输出) |
畸变系数 \([k_1, k_2, p_1, p_2, k_3]\) |
rvecs |
vector<Mat> (输出) |
每张图片的旋转向量(Rodrigues) |
tvecs |
vector<Mat> (输出) |
每张图片的平移向量 |
cv::CALIB_FIX_K3 |
标志 | 固定 \(k_3 = 0\),小视场角不需要 |
criteria |
TermCriteria |
迭代终止条件 |
cv::CALIB_FIX_K3 的含义:
对于 RoboMaster 使用的工业镜头,视场角较小(一般 60-90 度),高阶畸变项 \(k_3\) 接近零。固定 $k_3=0` 可以避免过拟合,提高标定稳定性。
迭代终止条件 TermCriteria:
COUNT + EPS:当迭代次数达到 100 次 或 重投影误差变化小于DBL_EPSILON时停止- 默认的 30 次迭代有时会导致结果不收敛,这里增加到 100 次
计算重投影误差¶
double error_sum = 0;
size_t total_points = 0;
for (size_t i = 0; i < obj_points.size(); i++) {
std::vector<cv::Point2f> reprojected_points;
cv::projectPoints(
obj_points[i], rvecs[i], tvecs[i],
camera_matrix, distort_coeffs, reprojected_points);
total_points += reprojected_points.size();
for (size_t j = 0; j < reprojected_points.size(); j++)
error_sum += cv::norm(img_points[i][j] - reprojected_points[j]);
}
auto error = error_sum / total_points;
重投影误差计算步骤:
- 用标定得到的内参 \(K\)、畸变系数、外参 \((rvec_i, tvec_i)\) 将 3D 点重新投影到图像
- 计算投影点与实际检测到的 2D 角点之间的欧氏距离
- 对所有点求平均
obj_points[i]"] -->|"projectPoints()"| B["投影 2D 坐标
reprojected_points"] C["检测 2D 坐标
img_points[i]"] --> D["计算距离
cv::norm()"] B --> D D --> E["平均误差 error"]
这里的误差是平均误差,不是 RMS
sp_vision_25 中计算的是平均绝对误差(Mean Absolute Error),而非 OpenCV calibrateCamera 默认返回的 RMS 误差。MAE 通常比 RMS 略大。两者都可用于判断标定质量,但比较不同标定结果时要用同一种指标。
输出结果¶
void print_yaml(
const cv::Mat & camera_matrix, const cv::Mat & distort_coeffs, double error)
{
YAML::Emitter result;
std::vector<double> camera_matrix_data(
camera_matrix.begin<double>(), camera_matrix.end<double>());
std::vector<double> distort_coeffs_data(
distort_coeffs.begin<double>(), distort_coeffs.end<double>());
result << YAML::BeginMap;
result << YAML::Comment(fmt::format("重投影误差: {:.4f}px", error));
result << YAML::Key << "camera_matrix";
result << YAML::Value << YAML::Flow << camera_matrix_data;
result << YAML::Key << "distort_coeffs";
result << YAML::Value << YAML::Flow << distort_coeffs_data;
result << YAML::Newline;
result << YAML::EndMap;
fmt::print("\n{}\n", result.c_str());
}
输出格式示例:
# 重投影误差: 0.1234px
camera_matrix: [1792.2, 0, 759.9, 0, 1789.2, 576.7, 0, 0, 1]
distort_coeffs: [-0.068, 0.012, 0.0002, 0.003, 0]
camera_matrix 是 3x3 矩阵的行优先展开:[fx, 0, cx, 0, fy, cy, 0, 0, 1]。
11.4 标定图像采集代码走读¶
以下是 sp_vision_25/calibration/capture.cpp 的完整代码,用于采集标定所需的数据。
头文件和工具函数¶
#include <fmt/core.h>
#include <filesystem>
#include <fstream>
#include <opencv2/opencv.hpp>
#include "io/camera.hpp"
#include "io/cboard.hpp"
#include "tools/img_tools.hpp"
#include "tools/logger.hpp"
#include "tools/math_tools.hpp"
io/camera.hpp— 相机封装(工业相机 SDK)io/cboard.hpp— 下位机通信(读取 IMU 四元数)
void write_q(const std::string q_path, const Eigen::Quaterniond & q)
{
std::ofstream q_file(q_path);
Eigen::Vector4d xyzw = q.coeffs();
// 输出顺序为wxyz
q_file << fmt::format("{} {} {} {}", xyzw[3], xyzw[0], xyzw[1], xyzw[2]);
q_file.close();
}
注意 Eigen 四元数的存储顺序:
q.coeffs()返回 \((x, y, z, w)\) 顺序- 代码手动调整为 \((w, x, y, z)\) 顺序输出,便于阅读
- 这个四元数是 IMU 的姿态,用于手眼标定
采集主循环¶
void capture_loop(
const std::string & config_path, const std::string & can,
const std::string & output_folder)
{
io::CBoard cboard(config_path);
io::Camera camera(config_path);
cv::Mat img;
std::chrono::steady_clock::time_point timestamp;
初始化下位机和相机。cboard 通过 CAN 总线接收 IMU 数据。
int count = 0;
while (true) {
camera.read(img, timestamp);
Eigen::Quaterniond q = cboard.imu_at(timestamp);
关键:cboard.imu_at(timestamp)
这个函数获取与图像同一时刻的 IMU 四元数。它会在 IMU 的历史数据中插值,确保时间同步。这是手眼标定精度的关键——图像和姿态数据必须严格对应。
auto img_with_ypr = img.clone();
Eigen::Vector3d zyx = tools::eulers(q, 2, 1, 0) * 57.3; // degree
tools::draw_text(img_with_ypr, fmt::format("Z {:.2f}", zyx[0]), {40, 40}, {0, 0, 255});
tools::draw_text(img_with_ypr, fmt::format("Y {:.2f}", zyx[1]), {40, 80}, {0, 0, 255});
tools::draw_text(img_with_ypr, fmt::format("X {:.2f}", zyx[2]), {40, 120}, {0, 0, 255});
在图像上叠加显示当前的欧拉角(yaw/pitch/roll),方便你在采集时:
- 确认 IMU 坐标系的各轴正方向
- 检查是否存在零漂(静止时角度是否稳定)
std::vector<cv::Point2f> centers_2d;
auto success = cv::findCirclesGrid(img, cv::Size(10, 7), centers_2d);
cv::drawChessboardCorners(img_with_ypr, cv::Size(10, 7), centers_2d, success);
cv::resize(img_with_ypr, img_with_ypr, {}, 0.5, 0.5);
实时显示圆点检测结果。如果圆点被成功识别,会在图上画出连线,方便你确认是否可以保存。
cv::imshow("Press s to save, q to quit", img_with_ypr);
auto key = cv::waitKey(1);
if (key == 'q')
break;
else if (key != 's')
continue;
count++;
auto img_path = fmt::format("{}/{}.jpg", output_folder, count);
auto q_path = fmt::format("{}/{}.txt", output_folder, count);
cv::imwrite(img_path, img);
write_q(q_path, q);
tools::logger()->info("[{}] Saved in {}", count, output_folder);
}
操作方式:
- 按
s键:保存当前图像和对应的 IMU 四元数 - 按
q键:退出采集程序
每次保存两个文件:{count}.jpg(图像)和 {count}.txt(四元数),存放在输出目录下。
int main(int argc, char * argv[])
{
cv::CommandLineParser cli(argc, argv, keys);
if (cli.has("help")) {
cli.printMessage();
return 0;
}
auto config_path = cli.get<std::string>(0);
auto output_folder = cli.get<std::string>("output-folder");
std::filesystem::create_directory(output_folder);
tools::logger()->info("默认标定板尺寸为10列7行");
capture_loop(config_path, "can0", output_folder);
tools::logger()->warn("注意四元数输出顺序为wxyz");
return 0;
}
主函数解析参数,创建输出目录,启动采集循环。
11.5 手眼标定代码走读¶
以下是 sp_vision_25/calibration/calibrate_handeye.cpp 的完整代码。
手眼标定的数学问题¶
手眼标定求解的是 AX=XB 方程(也叫手眼方程):
在 RoboMaster 语境下:
- \(X = (R_{\text{camera2gimbal}}, t_{\text{camera2gimbal}})\) — 相机到云台的变换,是我们要求的
- \(A_i = (R_{\text{gimbal2world}}, t_{\text{gimbal2world}})\) — 云台的运动(来自 IMU/编码器)
- \(B_i = (R_{\text{target2cam}}, t_{\text{target2cam}})\) — 标定板在相机坐标系中的位姿(来自 PnP)
IMU 四元数"] B["B: 标定板在相机中的位姿
PnP 解算"] end subgraph "求解" X["X: 相机到云台变换
R_camera2gimbal
t_camera2gimbal"] end A --> X B --> X
头文件¶
#include <fmt/core.h>
#include <yaml-cpp/yaml.h>
#include <Eigen/Dense> // 必须在opencv2/core/eigen.hpp上面
#include <fstream>
#include <opencv2/core/eigen.hpp>
#include <opencv2/opencv.hpp>
#include "tools/img_tools.hpp"
#include "tools/math_tools.hpp"
Eigen 和 OpenCV 的头文件顺序
Eigen/Dense 必须在 opencv2/core/eigen.hpp 之前 include,否则会出现编译错误。这是因为 opencv2/core/eigen.hpp 中的模板特化需要先看到 Eigen 的类型定义。
读取四元数¶
Eigen::Quaterniond read_q(const std::string & q_path)
{
std::ifstream q_file(q_path);
double w, x, y, z;
q_file >> w >> x >> y >> z;
return {w, x, y, z};
}
读取 capture.cpp 保存的四元数文件。顺序是 \((w, x, y, z)\)。
加载手眼标定数据¶
void load(
const std::string & input_folder, const std::string & config_path,
std::vector<double> & R_gimbal2imubody_data,
std::vector<cv::Mat> & R_gimbal2world_list,
std::vector<cv::Mat> & t_gimbal2world_list,
std::vector<cv::Mat> & rvecs, std::vector<cv::Mat> & tvecs)
{
auto yaml = YAML::LoadFile(config_path);
auto pattern_cols = yaml["pattern_cols"].as<int>();
auto pattern_rows = yaml["pattern_rows"].as<int>();
auto center_distance_mm = yaml["center_distance_mm"].as<double>();
R_gimbal2imubody_data = yaml["R_gimbal2imubody"].as<std::vector<double>>();
auto camera_matrix_data = yaml["camera_matrix"].as<std::vector<double>>();
auto distort_coeffs_data = yaml["distort_coeffs"].as<std::vector<double>>();
除了标定板参数外,还需要:
R_gimbal2imubody— 云台坐标系到 IMU 本体坐标系的旋转(由机械设计决定)camera_matrix和distort_coeffs— 来自内参标定的结果
cv::Size pattern_size(pattern_cols, pattern_rows);
Eigen::Matrix<double, 3, 3, Eigen::RowMajor> R_gimbal2imubody(R_gimbal2imubody_data.data());
cv::Matx33d camera_matrix(camera_matrix_data.data());
cv::Mat distort_coeffs(distort_coeffs_data);
注意 Eigen::RowMajor:YAML 中的矩阵数据是行优先存储的,而 Eigen 默认是列优先,所以需要显式指定。
计算云台姿态¶
for (int i = 1; true; i++) {
auto img_path = fmt::format("{}/{}.jpg", input_folder, i);
auto q_path = fmt::format("{}/{}.txt", input_folder, i);
auto img = cv::imread(img_path);
Eigen::Quaterniond q = read_q(q_path);
if (img.empty()) break;
Eigen::Matrix3d R_imubody2imuabs = q.toRotationMatrix();
Eigen::Matrix3d R_gimbal2world =
R_gimbal2imubody.transpose() * R_imubody2imuabs * R_gimbal2imubody;
Eigen::Vector3d ypr = tools::eulers(R_gimbal2world, 2, 1, 0) * 57.3;
坐标变换链:
这是一次相似变换:先将云台坐标变换到 IMU 本体坐标系,再用 IMU 的绝对姿态变换到世界坐标系,最后变换回云台坐标系的表达方式。
(IMU 四元数)"| C["IMU 绝对坐标"] C -->|"R_gimbal2imubody^T"| D["云台世界坐标"]
检测标定板并做 PnP¶
std::vector<cv::Point2f> centers_2d;
auto success = cv::findCirclesGrid(img, pattern_size, centers_2d);
cv::drawChessboardCorners(drawing, pattern_size, centers_2d, success);
cv::resize(drawing, drawing, {}, 0.5, 0.5);
cv::imshow("Press any to continue", drawing);
cv::waitKey(0);
fmt::print("[{}] {}\n", success ? "success" : "failure", img_path);
if (!success) continue;
与内参标定类似,先检测圆点阵列。
cv::Mat t_gimbal2world = (cv::Mat_<double>(3, 1) << 0, 0, 0);
cv::Mat R_gimbal2world_cv;
cv::eigen2cv(R_gimbal2world, R_gimbal2world_cv);
cv::Mat rvec, tvec;
auto centers_3d_ = centers_3d(pattern_size, center_distance_mm);
cv::solvePnP(
centers_3d_, centers_2d, camera_matrix, distort_coeffs,
rvec, tvec, false, cv::SOLVEPNP_IPPE);
PnP 解算获得标定板在相机坐标系中的位姿:
centers_3d_— 标定板圆点的 3D 世界坐标(\(Z=0\) 的平面上)centers_2d— 检测到的 2D 像素坐标cv::SOLVEPNP_IPPE— 一种对平面目标效果很好的 PnP 方法(Iterative Perspective from Points using an Ellipse)- 输出
rvec,tvec就是标定板在相机坐标系中的位姿
R_gimbal2world_list.emplace_back(R_gimbal2world_cv);
t_gimbal2world_list.emplace_back(t_gimbal2world);
rvecs.emplace_back(rvec);
tvecs.emplace_back(tvec);
}
}
注意 t_gimbal2world 被设为零向量——因为手眼标定只关心旋转,平移不参与求解(云台的旋转中心位置不影响结果)。
执行手眼标定¶
int main(int argc, char * argv[])
{
// ... 参数解析和数据加载 ...
cv::Mat R_camera2gimbal, t_camera2gimbal;
cv::calibrateHandEye(
R_gimbal2world_list, t_gimbal2world_list,
rvecs, tvecs,
R_camera2gimbal, t_camera2gimbal);
t_camera2gimbal /= 1e3; // mm to m
关键函数 cv::calibrateHandEye():
| 参数 | 含义 |
|---|---|
R_gimbal2world_list |
每组数据中云台的旋转矩阵(来自 IMU) |
t_gimbal2world_list |
每组数据中云台的平移(这里全为零) |
rvecs |
每组数据中标定板在相机坐标系中的旋转向量 |
tvecs |
每组数据中标定板在相机坐标系中的平移 |
R_camera2gimbal (输出) |
相机到云台的旋转矩阵 |
t_camera2gimbal (输出) |
相机到云台的平移向量 |
注意 t_camera2gimbal /= 1e3:由于内参标定时距离单位是 mm,这里除以 1000 转换为米。
默认求解方法
cv::calibrateHandEye 默认使用 cv::CALIB_HAND_EYE_TSAI 方法。sp_vision_25 没有显式指定,使用的就是默认方法。Tsai 方法基于最小二乘,对噪声比较鲁棒。
计算偏角¶
Eigen::Matrix3d R_camera2gimbal_eigen;
cv::cv2eigen(R_camera2gimbal, R_camera2gimbal_eigen);
Eigen::Matrix3d R_gimbal2ideal{{0, -1, 0}, {0, 0, -1}, {1, 0, 0}};
Eigen::Matrix3d R_camera2ideal = R_gimbal2ideal * R_camera2gimbal_eigen;
Eigen::Vector3d ypr = tools::eulers(R_camera2ideal, 1, 0, 2) * 57.3;
计算相机相对理想朝向的偏角:
R_gimbal2ideal— 理想情况下云台坐标系到世界坐标系的旋转(相机正前方是 X 轴正方向)R_camera2ideal— 相机在理想世界坐标系中的朝向- 输出的 yaw/pitch/roll 就是相机安装的偏差角度
如果标定结果准确,这些角度应该接近 0(除非相机故意倾斜安装)。
输出结果¶
void print_yaml(
const std::vector<double> & R_gimbal2imubody_data,
const cv::Mat & R_camera2gimbal, const cv::Mat & t_camera2gimbal,
const Eigen::Vector3d & ypr)
{
// ... YAML 输出 ...
// 注释显示:相机同理想情况的偏角: yaw X.XX pitch X.XX roll X.XX degree
}
输出示例:
R_gimbal2imubody: [0, -1, 0, 1, 0, 0, 0, 0, 1]
# 相机同理想情况的偏角: yaw 1.23 pitch -0.45 roll 0.67 degree
R_camera2gimbal: [0.9998, -0.0123, 0.0045, ...]
t_camera2gimbal: [0.052, -0.018, 0.031]
11.6 camera_info.yaml 解读¶
以下是 RM_Vision_2027 项目中实际使用的 camera_info.yaml:
image_width: 1440
image_height: 1080
camera_name: narrow_stereo
camera_matrix:
rows: 3
cols: 3
data: [1792.20811, 0. , 759.9888 ,
0. , 1789.20887, 576.76258,
0. , 0. , 1. ]
distortion_model: plumb_bob
distortion_coefficients:
rows: 1
cols: 5
data: [-0.067962, 0.011610, 0.000201, 0.003028, 0.000000]
rectification_matrix:
rows: 3
cols: 3
data: [1., 0., 0.,
0., 1., 0.,
0., 0., 1.]
projection_matrix:
rows: 3
cols: 4
data: [1770.36072, 0. , 763.07062, 0. ,
0. , 1776.4541 , 576.78203, 0. ,
0. , 0. , 1. , 0. ]
字段逐一解释¶
image_width / image_height¶
图像分辨率,必须和相机实际输出分辨率完全一致。如果标定时用 1280x720,但运行时用 1920x1080,内参就会完全错误。
camera_matrix¶
| 参数 | 值 | 含义 | 检查方法 |
|---|---|---|---|
| \(f_x\) | 1792.2 | X 方向焦距(像素) | 应和 \(f_y\) 接近,差异 < 1% |
| \(f_y\) | 1789.2 | Y 方向焦距(像素) | \(f_x / f_y \approx 1.002\),合格 |
| \(c_x\) | 760.0 | 光心 X 坐标 | 应接近 \(1440/2 = 720\),偏移 40 像素是正常的 |
| \(c_y\) | 576.8 | 光心 Y 坐标 | 应接近 \(1080/2 = 540\),偏移 37 像素是正常的 |
焦距换算
这个相机的物理焦距 \(f\) 和传感器像元尺寸 \(p\) 的关系:\(f = f_x \times p = 1792 \times 0.00345 \approx 6.18 \text{mm}\)。如果镜头标注焦距是 6mm 或 8mm,这个标定结果就是合理的。
distortion_coefficients¶
| 参数 | 值 | 含义 | 正常范围 |
|---|---|---|---|
| \(k_1\) | -0.0680 | 一阶径向畸变 | 工业镜头一般 -0.15 ~ 0 |
| \(k_2\) | 0.0116 | 二阶径向畸变 | 通常很小,< 0.05 |
| \(p_1\) | 0.0002 | 切向畸变 | 通常 < 0.001 |
| \(p_2\) | 0.0030 | 切向畸变 | 通常 < 0.005 |
| \(k_3\) | 0.0000 | 三阶径向畸变 | CALIB_FIX_K3 固定为 0 |
\(k_1 = -0.068\) 表示轻微的桶形畸变,符合工业镜头的特性。\(k_3 = 0\) 是因为标定时使用了 cv::CALIB_FIX_K3 标志。
distortion_model¶
plumb_bob 是 OpenCV 的标准畸变模型,使用 5 个参数 \((k_1, k_2, p_1, p_2, k_3)\)。另一种常见模型是 rational_polynomial(8 个参数),用于鱼眼镜头。
rectification_matrix¶
单位矩阵,表示单目相机没有旋转校正。只有立体视觉(双目相机)才会使用非单位的校正矩阵。
projection_matrix¶
投影矩阵用于立体视觉。对于单目相机,它的前 3x3 部分和 camera_matrix 接近但不完全相同(因为 OpenCV 的 stereoCalibrate 可能会微调焦距)。在 rm_vision 中主要使用 camera_matrix 和 distortion_coefficients。
11.7 标定操作流程¶
硬件准备¶
10x7 圆点阵列"] B["工业相机 + 镜头"] C["CAN 通信线
连接下位机"] D["稳定的支架
标定板不能晃动"] end
- 打印标定板:用高精度打印机打印圆点阵列图案,贴在平整的亚克力板上
- 测量圆点间距:用游标卡尺精确测量相邻圆点中心距离(如 15mm),记录到
calibration.yaml中 - 固定标定板:用支架或胶带固定标定板,确保在整个标定过程中不会移动
- 连接相机和下位机:确保相机能正常采集图像,下位机能正常发送 IMU 数据
步骤 1:内参标定¶
# 进入项目目录
cd sp_vision_25
# 创建数据目录
mkdir -p assets/img_intrinsics
# 编译标定工具
cmake --build build --target calibrate_camera calibrate_capture
采集标定图片:
操作要点:
- 画面中会实时显示圆点检测结果
- 按
s保存图片(共需要 15-20 张) - 变换相机位置和角度:正面、左侧倾斜、右侧倾斜、俯视、仰视
- 让标定板出现在画面的不同位置:左上、右下、正中、边缘
- 按
q退出
运行内参标定:
输出示例:
[success] assets/img_intrinsics/1.jpg
[success] assets/img_intrinsics/2.jpg
...
[success] assets/img_intrinsics/18.jpg
# 重投影误差: 0.1234px
camera_matrix: [1792.2, 0, 760.0, 0, 1789.2, 576.8, 0, 0, 1]
distort_coeffs: [-0.068, 0.012, 0.0002, 0.003, 0]
检查误差:如果重投影误差 > 0.5 像素,需要重新采集标定图片。
步骤 2:手眼标定¶
采集手眼标定数据:
操作要点:
- 标定板在整个过程中不能移动
- 每次转动云台(通过遥控器或手动推 yaw/pitch)一个角度
- 等画面稳定后按
s保存(同时记录云台角度和图像) - 需要 15-20 组数据,覆盖 yaw ±20° 和 pitch ±15° 的范围
运行手眼标定:
输出示例:
[success] assets/img_handeye/1.jpg
...
[success] assets/img_handeye/18.jpg
R_gimbal2imubody: [0, -1, 0, 1, 0, 0, 0, 0, 1]
# 相机同理想情况的偏角: yaw 1.23 pitch -0.45 roll 0.67 degree
R_camera2gimbal: [0.9998, -0.0123, 0.0045, ...]
t_camera2gimbal: [0.052, -0.018, 0.031]
步骤 3:更新配置文件¶
将标定结果写入 camera_info.yaml 和手眼标定配置文件:
# 将内参标定输出复制到项目配置目录
cp output_camera_info.yaml rm_vision/rm_vision_bringup/config/camera_info.yaml
# 将手眼标定输出复制到项目配置目录
cp output_handeye.yaml rm_vision/rm_vision_bringup/config/handeye.yaml
11.8 标定结果验证¶
检查清单¶
拿到标定结果后,用以下清单逐一验证:
| 检查项 | 预期值 | 不合格时的处理 |
|---|---|---|
| 内参重投影误差(MAE) | < 0.3 像素 | 重新拍摄更多角度的标定图片 |
| 手眼重投影误差 | < 0.5 像素 | 增加云台转动角度范围,检查标定板是否固定 |
| \(f_x\) 与 \(f_y\) | 差异 < 1% | 差异超过 5% 可能标定有误 |
| \(c_x\) | 接近 image_width/2 | 偏差超过 10% 检查图片尺寸是否一致 |
| \(c_y\) | 接近 image_height/2 | 同上 |
| \(k_1\) | 绝对值 < 0.2(工业镜头) | 更大则可能标定数据有问题 |
| 相机偏角(yaw/pitch/roll) | < 5°(除非故意倾斜) | 超过 10° 检查标定板是否移动过 |
方法一:去畸变目视检查¶
// 快速验证:去畸变
cv::Mat undistorted;
cv::undistort(distortedImage, undistorted, cameraMatrix, distCoeffs);
// 观察场景中的直线是否变直
用标定结果对原图做去畸变,观察场景中的直线(如墙壁边缘、门框)在去畸变后是否真的变直。
# 使用 OpenCV 工具查看去畸变效果
# 在代码中:
# cv::imshow("Original", img);
# cv::imshow("Undistorted", undistorted);
# cv::waitKey(0);
方法二:重投影误差逐图检查¶
// 对每张图片单独计算重投影误差
for (size_t i = 0; i < obj_points.size(); i++) {
std::vector<cv::Point2f> reprojected;
cv::projectPoints(obj_points[i], rvecs[i], tvecs[i],
camera_matrix, distort_coeffs, reprojected);
double img_error = 0;
for (size_t j = 0; j < reprojected.size(); j++)
img_error += cv::norm(img_points[i][j] - reprojected[j]);
img_error /= reprojected.size();
// 如果某张图片的误差明显大于平均值(> 2倍),说明该图片质量有问题
if (img_error > 2 * average_error)
fmt::print("Warning: image {} has high error: {:.4f}\n", i+1, img_error);
}
方法三:实际运行验证¶
标定完成后,最好的验证方式是实际运行自瞄:
- 畸变校正是否生效:对原图和去畸变图对比,场景中的直线在去畸变后应该确实是直的
- PnP 解算精度:对标定板做 PnP,然后用解算结果反投影,检查像素误差
- 手眼精度:手动将云台对准目标中心,检查视觉检测到的目标是否在画面中心附近
检查 yaw/pitch 偏差"} D -->|"偏差 < 1°"| E["手眼标定合格 ✓"] D -->|"偏差 > 2°"| F["手眼标定有问题 ✗"] F --> G["检查标定板是否移动过
重新标定"]
常见坑
- 标定板不平整:打印后贴在硬板上,弯曲的标定板会让标定结果严重失真
- 图片数量太少:少于 10 张的标定数据通常不可靠,建议 15-20 张
- 角度覆盖不足:如果所有图片都是正面拍摄,\(c_x, c_y\) 的精度会很差
- 分辨率不一致:标定时的图片分辨率必须和实际运行时的分辨率完全一致
- 镜头对焦环移动:标定后如果转动了对焦环,焦距会改变,必须重新标定
11.9 常见标定问题与解决方案¶
问题 1:圆点检测失败率高¶
症状: findCirclesGrid 返回 false,大量图片无法识别。
可能原因:
| 原因 | 诊断方法 | 解决方案 |
|---|---|---|
| 曝光不足/过度 | 查看原图,圆点与背景对比度低 | 调整曝光时间和增益,确保圆点清晰可辨 |
| 标定板太远 | 圆点在图像中太小 | 将标定板移近相机(占图像面积 30%-60%) |
| 标定板不在焦平面上 | 圆点模糊 | 调整相机焦距,确保标定板在景深范围内 |
| 标定板打印质量差 | 圆点不圆或有墨迹 | 重新打印,使用高分辨率打印机 |
| 标定板弯曲 | 圆点间距不一致 | 使用硬质板材,不要用纸 |
问题 2:重投影误差太大¶
症状: 内参标定 RMS > 0.5 像素,或手眼标定误差 > 1.0 像素。
排查流程:
重新标定"] B -->|"所有图片误差都大"| D{"检查标定板参数"} D -->|"参数正确"| E{"检查图片质量"} D -->|"参数可能有误"| F["重新测量圆点间距
确认行列数"] E -->|"图片模糊/曝光异常"| G["重新拍摄"] E -->|"图片质量好"| H["增加拍摄角度覆盖
增加图片数量"]
问题 3:手眼标定偏角异常¶
症状: 输出的 yaw/pitch/roll 偏角 > 10°。
可能原因:
- 标定板在采集过程中移动了:这是最常见的原因。即使标定板只移动了几毫米,也会导致偏角异常
- IMU 零漂:在采集窗口观察欧拉角,静止时角度应稳定在 ±0.5° 以内
R_gimbal2imubody参数错误:这个矩阵描述云台和 IMU 的安装关系,需要根据机械图纸设置- 四元数读取顺序错误:确保
read_q函数读取的 \((w, x, y, z)\) 顺序与write_q输出的一致
问题 4:内参标定程序崩溃¶
症状: 运行 calibrate_camera 时出现段错误或异常退出。
可能原因:
| 原因 | 解决方案 |
|---|---|
| 标定图片太少(< 3 张成功) | 至少拍摄 10 张以上能成功检测的图片 |
| 图片尺寸不一致 | 确保所有图片分辨率相同 |
| YAML 配置文件路径错误 | 检查 -c 参数指定的文件是否存在 |
| OpenCV 版本不兼容 | 确保使用 OpenCV 4.x |
问题 5:手眼标定精度不够(射击偏)¶
症状: 标定误差合格(< 0.5 像素),但实际射击时有系统性偏差。
排查方法:
- 验证 PnP 精度:在已知位置放目标,看 PnP 解算的 3D 坐标是否准确
- 检查弹道模型:射击偏差可能来自弹道解算而非标定(见第10章)
- 检查云台编码器精度:云台实际转动角度可能与指令有偏差
- 增加手眼标定数据:扩大云台转动角度范围,增加覆盖区域
- 多次标定取平均:做 3-5 次手眼标定,取旋转矩阵的平均值(需要将旋转转换为四元数再平均)
11.10 从 sp_vision_25 到 RM_Vision_2027 的标定文件迁移¶
sp_vision_25 的标定输出格式和 RM_Vision_2027 使用的 camera_info.yaml 格式略有不同:
sp_vision_25 输出:
# 重投影误差: 0.1234px
camera_matrix: [1792.2, 0, 760.0, 0, 1789.2, 576.8, 0, 0, 1]
distort_coeffs: [-0.068, 0.012, 0.0002, 0.003, 0]
RM_Vision_2027 格式:
camera_matrix:
rows: 3
cols: 3
data: [1792.2, 0, 760.0, 0, 1789.2, 576.8, 0, 0, 1]
distortion_model: plumb_bob
distortion_coefficients:
rows: 1
cols: 5
data: [-0.068, 0.012, 0.0002, 0.003, 0]
迁移时需要:
- 将
camera_matrix的一维数组转为带rows/cols的格式 - 将
distort_coeffs改为distortion_coefficients - 添加
distortion_model: plumb_bob - 添加
image_width和image_height - 添加
rectification_matrix(单位矩阵)和projection_matrix
本章小结¶
关键结论:
- 内参标定解决镜头畸变问题,手眼标定解决相机与云台的安装关系
- 两者缺一不可,精度直接影响 PnP 解算和最终射击精度
cv::calibrateCamera()和cv::calibrateHandEye()是 OpenCV 提供的现成工具,理解原理即可,不必从头实现- 标定质量用重投影误差衡量,内参 < 0.3 像素,手眼 < 0.5 像素为合格
- 拆装相机后必须重新标定,标定结果文件要妥善保存
代码要点回顾:
| 文件 | 关键函数 | 作用 |
|---|---|---|
calibrate_camera.cpp |
cv::findCirclesGrid |
检测圆点阵列 |
calibrate_camera.cpp |
cv::calibrateCamera |
求解内参和畸变 |
calibrate_handeye.cpp |
cv::solvePnP |
每组数据的 PnP 解算 |
calibrate_handeye.cpp |
cv::calibrateHandEye |
求解手眼变换 |
capture.cpp |
camera.read + cboard.imu_at |
同步采集图像和 IMU |
下一章预告: 第12章将介绍进阶主题,包括 MPC 轨迹规划、全向感知等更高级的视觉算法。