跳转至

第11章 标定系统

本章目标

理解标定在自瞄系统中的核心作用,掌握相机内参标定和手眼标定的原理与实操流程,能够独立阅读和运行 sp_vision_25 中的标定代码,用重投影误差验证结果质量。

预计用时: 2-3 天

前置知识: 第5章 OpenCV基础、第9章 PnP 位姿解算


11.1 为什么需要标定

这是什么

标定(Calibration)是确定相机成像模型参数的过程。相机镜头不是理想的针孔模型,存在光学畸变;相机安装在云台上时,两者之间存在制造和装配误差。标定就是用数学方法把这些误差"测量"出来,然后在算法中补偿掉。

为什么需要

自瞄系统的完整链路是:像素坐标 → 目标3D位姿 → 云台转动 → 开火。如果相机参数不准确,第一步的 PnP 解算就会有误差,后续所有环节的精度都会劣化。

标定要解决两个核心问题:

问题 来源 后果
镜头畸变 镜片加工和组装带来的光学像差 图像边缘的目标形状扭曲,角点提取不准,PnP 解算偏移
手眼关系未知 相机安装在云台上,两者的相对位姿由装配决定 即使 PnP 解算正确,也无法正确转换到云台坐标系

直觉理解

想象你用肉眼通过一个玻璃杯看桌上的硬币——玻璃杯会让硬币看起来偏移了位置。畸变标定就是"测量"这个偏移量,手眼标定就是"测量"你的眼睛和手之间的相对位置关系。两者缺一不可。

标定系统在自瞄链路中的位置

graph TD A["相机采集图像"] --> B["畸变校正
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 矩阵描述:

\[ \begin{bmatrix} u \\ v \\ 1 \end{bmatrix} = \frac{1}{Z} \underbrace{\begin{bmatrix} f_x & 0 & c_x \\ 0 & f_y & c_y \\ 0 & 0 & 1 \end{bmatrix}}_{K} \begin{bmatrix} X \\ Y \\ Z \end{bmatrix} \]

其中:

  • \(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\) 像素。不过实际值由标定程序计算,不需要手动换算。

完整的投影公式

从世界坐标系到像素坐标系的完整变换链是:

\[ \begin{bmatrix} u \\ v \\ 1 \end{bmatrix} = \frac{1}{Z_c} K \cdot [R \mid t] \cdot \begin{bmatrix} X_w \\ Y_w \\ Z_w \\ 1 \end{bmatrix} \]

其中 \([R \mid t]\) 是外参矩阵(描述世界坐标系到相机坐标系的旋转和平移)。内参标定就是求 \(K\) 和畸变系数。

畸变模型

真实镜头存在两类畸变:

径向畸变(Radial Distortion)— 光线在镜片边缘弯曲程度更大:

\[ \begin{cases} x_{\text{distorted}} = x(1 + k_1 r^2 + k_2 r^4 + k_3 r^6) \\ y_{\text{distorted}} = y(1 + k_1 r^2 + k_2 r^4 + k_3 r^6) \end{cases} \]

其中 \(r = \sqrt{x^2 + y^2}\) 是像素到光心的归一化距离,\(k_1, k_2, k_3\) 是径向畸变系数:

  • \(k_1 > 0\):枕形畸变(图像向外膨胀)
  • \(k_1 < 0\):桶形畸变(图像向内收缩)

切向畸变(Tangential Distortion)— 镜片与传感器不完全平行:

\[ \begin{cases} x_{\text{distorted}} = x + [2p_1 xy + p_2(r^2 + 2x^2)] \\ y_{\text{distorted}} = y + [p_1(r^2 + 2y^2) + 2p_2 xy] \end{cases} \]

\(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() 对图像做去畸变。其数学过程是:

  1. 对去畸变图像中的每个像素 \((u, v)\),用 \(K^{-1}\) 反投影到归一化坐标
  2. 用畸变模型计算该点在畸变图像中对应的位置
  3. 用双线性插值采样
graph LR A["畸变图像
边缘直线弯曲"] -->|"undistort()"
K + distCoeffs| B["去畸变图像
直线恢复平直"]

张正友标定法

张正友标定法(Zhang's Method)是目前最常用的相机标定方法,只需要一张打印的棋盘格图案和多张不同角度的照片就能完成标定。

核心思路:

graph LR A["打印棋盘格
已知格点尺寸"] --> B["多角度拍摄
≥15张"] B --> C["检测角点
cv::findChessboardCorners"] C --> D["求解内参+畸变
cv::calibrateCamera"] D --> E["获得 K, distCoeffs"]

数学原理简述:

  1. 棋盘格平面在世界坐标系中 \(Z=0\),所以单应性矩阵 \(H\) 可以将棋盘格的 2D 坐标映射到图像像素坐标
  2. 每张图片提供一个单应性矩阵 \(H = [h_1, h_2, h_3] = K[r_1, r_2, t]\)
  3. 利用旋转矩阵的正交性约束(\(r_1^T r_2 = 0\)\(\|r_1\| = \|r_2\|\)),每张图片可以提供 2 个方程
  4. 3 张以上不同角度的图片即可求解 \(K\) 中的 5 个未知数
  5. 实际使用 15-20 张是为了用最小二乘法提高精度和鲁棒性

棋盘格图案要求:

  • 通常用 9x6 或 11x8 的棋盘格(内部角点数量,即格子数减一)
  • 打印后贴在平整的板上(如亚克力板或硬纸板),不能弯曲
  • 每个格子的物理尺寸要精确测量(如 25mm),标定时作为参数传入
  • sp_vision_25 中使用的是圆点阵列(circles grid),而非棋盘格,原理类似

拍摄要求:

graph TB subgraph "好的标定数据" A1["正面"] A2["左侧倾斜"] A3["右侧倾斜"] A4["上方俯视"] A5["下方仰视"] end subgraph "坏的标定数据" B1["全部正面
角度太单一"] 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;

重投影误差计算步骤:

  1. 用标定得到的内参 \(K\)、畸变系数、外参 \((rvec_i, tvec_i)\) 将 3D 点重新投影到图像
  2. 计算投影点与实际检测到的 2D 角点之间的欧氏距离
  3. 对所有点求平均
graph LR A["3D 世界坐标
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 略大。两者都可用于判断标定质量,但比较不同标定结果时要用同一种指标。

输出结果

  print_yaml(camera_matrix, distort_coeffs, error);
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 方程(也叫手眼方程):

\[ A_i X = X B_i \]

在 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)
graph LR subgraph "已知" A["A: 云台姿态变化
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_matrixdistort_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;

坐标变换链:

\[ R_{\text{gimbal2world}} = R_{\text{gimbal2imubody}}^T \cdot R_{\text{imubody2imuabs}} \cdot R_{\text{gimbal2imubody}} \]

这是一次相似变换:先将云台坐标变换到 IMU 本体坐标系,再用 IMU 的绝对姿态变换到世界坐标系,最后变换回云台坐标系的表达方式。

graph LR A["云台坐标"] -->|"R_gimbal2imubody"| B["IMU 本体坐标"] B -->|"R_imubody2imuabs
(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

image_width: 1440
image_height: 1080

图像分辨率,必须和相机实际输出分辨率完全一致。如果标定时用 1280x720,但运行时用 1920x1080,内参就会完全错误。

camera_matrix

\[ K = \begin{bmatrix} 1792.2 & 0 & 760.0 \\ 0 & 1789.2 & 576.8 \\ 0 & 0 & 1 \end{bmatrix} \]
参数 含义 检查方法
\(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

\[ \text{dist} = [k_1, k_2, p_1, p_2, k_3] \]
参数 含义 正常范围
\(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

distortion_model: plumb_bob

plumb_bob 是 OpenCV 的标准畸变模型,使用 5 个参数 \((k_1, k_2, p_1, p_2, k_3)\)。另一种常见模型是 rational_polynomial(8 个参数),用于鱼眼镜头。

rectification_matrix

rectification_matrix:
  data: [1., 0., 0.,
         0., 1., 0.,
         0., 0., 1.]

单位矩阵,表示单目相机没有旋转校正。只有立体视觉(双目相机)才会使用非单位的校正矩阵。

projection_matrix

\[ P = \begin{bmatrix} 1770.4 & 0 & 763.1 & 0 \\ 0 & 1776.5 & 576.8 & 0 \\ 0 & 0 & 1 & 0 \end{bmatrix} \]

投影矩阵用于立体视觉。对于单目相机,它的前 3x3 部分和 camera_matrix 接近但不完全相同(因为 OpenCV 的 stereoCalibrate 可能会微调焦距)。在 rm_vision 中主要使用 camera_matrixdistortion_coefficients


11.7 标定操作流程

硬件准备

graph TB subgraph "必需物品" A["圆点标定板
10x7 圆点阵列"] B["工业相机 + 镜头"] C["CAN 通信线
连接下位机"] D["稳定的支架
标定板不能晃动"] end
  1. 打印标定板:用高精度打印机打印圆点阵列图案,贴在平整的亚克力板上
  2. 测量圆点间距:用游标卡尺精确测量相邻圆点中心距离(如 15mm),记录到 calibration.yaml
  3. 固定标定板:用支架或胶带固定标定板,确保在整个标定过程中不会移动
  4. 连接相机和下位机:确保相机能正常采集图像,下位机能正常发送 IMU 数据

步骤 1:内参标定

# 进入项目目录
cd sp_vision_25

# 创建数据目录
mkdir -p assets/img_intrinsics

# 编译标定工具
cmake --build build --target calibrate_camera calibrate_capture

采集标定图片:

# 启动采集程序(默认使用 10x7 圆点阵列)
./build/calibrate_capture -o assets/img_intrinsics

操作要点:

  • 画面中会实时显示圆点检测结果
  • s 保存图片(共需要 15-20 张)
  • 变换相机位置和角度:正面、左侧倾斜、右侧倾斜、俯视、仰视
  • 让标定板出现在画面的不同位置:左上、右下、正中、边缘
  • q 退出

运行内参标定:

# 使用标定板参数配置文件
./build/calibrate_camera assets/img_intrinsics -c configs/calibration.yaml

输出示例:

[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:手眼标定

# 创建数据目录
mkdir -p assets/img_handeye

# 编译手眼标定工具
cmake --build build --target calibrate_handeye

采集手眼标定数据:

# 启动采集程序
./build/calibrate_capture -o assets/img_handeye

操作要点:

  • 标定板在整个过程中不能移动
  • 每次转动云台(通过遥控器或手动推 yaw/pitch)一个角度
  • 等画面稳定后按 s 保存(同时记录云台角度和图像)
  • 需要 15-20 组数据,覆盖 yaw ±20° 和 pitch ±15° 的范围

运行手眼标定:

./build/calibrate_handeye assets/img_handeye -c configs/calibration.yaml

输出示例:

[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);
}

方法三:实际运行验证

标定完成后,最好的验证方式是实际运行自瞄:

  1. 畸变校正是否生效:对原图和去畸变图对比,场景中的直线在去畸变后应该确实是直的
  2. PnP 解算精度:对标定板做 PnP,然后用解算结果反投影,检查像素误差
  3. 手眼精度:手动将云台对准目标中心,检查视觉检测到的目标是否在画面中心附近
graph TB A["运行自瞄程序"] --> B["检测到装甲板"] B --> C["PnP 解算结果"] C --> D{"手动对准目标中心
检查 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 像素。

排查流程:

graph TB A["重投影误差过大"] --> B{"检查单张图片误差"} B -->|"只有几张误差大"| C["删除这几张
重新标定"] B -->|"所有图片误差都大"| D{"检查标定板参数"} D -->|"参数正确"| E{"检查图片质量"} D -->|"参数可能有误"| F["重新测量圆点间距
确认行列数"] E -->|"图片模糊/曝光异常"| G["重新拍摄"] E -->|"图片质量好"| H["增加拍摄角度覆盖
增加图片数量"]

问题 3:手眼标定偏角异常

症状: 输出的 yaw/pitch/roll 偏角 > 10°。

可能原因:

  1. 标定板在采集过程中移动了:这是最常见的原因。即使标定板只移动了几毫米,也会导致偏角异常
  2. IMU 零漂:在采集窗口观察欧拉角,静止时角度应稳定在 ±0.5° 以内
  3. R_gimbal2imubody 参数错误:这个矩阵描述云台和 IMU 的安装关系,需要根据机械图纸设置
  4. 四元数读取顺序错误:确保 read_q 函数读取的 \((w, x, y, z)\) 顺序与 write_q 输出的一致

问题 4:内参标定程序崩溃

症状: 运行 calibrate_camera 时出现段错误或异常退出。

可能原因:

原因 解决方案
标定图片太少(< 3 张成功) 至少拍摄 10 张以上能成功检测的图片
图片尺寸不一致 确保所有图片分辨率相同
YAML 配置文件路径错误 检查 -c 参数指定的文件是否存在
OpenCV 版本不兼容 确保使用 OpenCV 4.x

问题 5:手眼标定精度不够(射击偏)

症状: 标定误差合格(< 0.5 像素),但实际射击时有系统性偏差。

排查方法:

  1. 验证 PnP 精度:在已知位置放目标,看 PnP 解算的 3D 坐标是否准确
  2. 检查弹道模型:射击偏差可能来自弹道解算而非标定(见第10章)
  3. 检查云台编码器精度:云台实际转动角度可能与指令有偏差
  4. 增加手眼标定数据:扩大云台转动角度范围,增加覆盖区域
  5. 多次标定取平均:做 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]

迁移时需要:

  1. camera_matrix 的一维数组转为带 rows/cols 的格式
  2. distort_coeffs 改为 distortion_coefficients
  3. 添加 distortion_model: plumb_bob
  4. 添加 image_widthimage_height
  5. 添加 rectification_matrix(单位矩阵)和 projection_matrix

本章小结

graph TD A["标定系统"] --> B["内参标定"] A --> C["手眼标定"] B --> B1["张正友圆点阵列法"] B --> B2["输出: K, distCoeffs"] B --> B3["camera_info.yaml"] C --> C1["固定标定板 + 转动云台"] C --> C2["输出: R_camera2gimbal, t_camera2gimbal"] B2 --> D["重投影误差验证"] C2 --> D D --> E["内参误差 < 0.3 像素 ✓"] D --> F["手眼误差 < 0.5 像素 ✓"] D --> G["误差过大 → 重新标定"]

关键结论:

  1. 内参标定解决镜头畸变问题,手眼标定解决相机与云台的安装关系
  2. 两者缺一不可,精度直接影响 PnP 解算和最终射击精度
  3. cv::calibrateCamera()cv::calibrateHandEye() 是 OpenCV 提供的现成工具,理解原理即可,不必从头实现
  4. 标定质量用重投影误差衡量,内参 < 0.3 像素,手眼 < 0.5 像素为合格
  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 轨迹规划、全向感知等更高级的视觉算法。