跳转至

第七章 装甲板检测:从像素到位姿

本章概要

装甲板检测是 RoboMaster 视觉系统的核心任务。本章将逐函数拆解真实仓库代码,从预处理到位姿解算,让你真正理解每一行代码的作用。所有代码块均来自 RM_Vision_2027/rm_auto_aim/armor_detector/ 仓库,是可编译运行的真实代码。

前置知识

阅读本章前,建议先了解 ch05 OpenCV 基础(二值化、轮廓检测、透视变换)。 遇到 ROS2 概念(Topic、消息)时,参考 ch04 ROS2 核心


7.1 装甲板长什么样

在写任何代码之前,我们必须先理解目标——装甲板的物理结构。只有知道"真值"长什么样,才能设计出好的检测算法。

7.1.1 物理结构

一块完整的装甲板由两部分组成:

部件 说明
灯条(Light Bar) 发光 LED 灯条,位于装甲板两侧。比赛时灯条会以特定频率闪烁,呈现高亮度。这是最显著的视觉特征。
装甲面板(Armor Plate) 两根灯条中间的面板,上面贴有数字贴纸,用于区分不同机器人编号。
┌──────────────────────────────────────────────┐
│                                              │
│   ┃ LED ┃        数字区域        ┃ LED ┃     │
│   ┃灯条 ┃   (20mm x 100mm)      ┃灯条 ┃     │
│   ┃     ┃                       ┃     ┃     │
│                                              │
└──────────────────────────────────────────────┘
        ← ─ ─ ─ 装甲板宽度 ─ ─ ─ →

7.1.2 两种尺寸

RoboMaster 比赛中有两种装甲板尺寸:

  • 尺寸:135mm x 55mm
  • 适用:3/4/5 号步兵机器人、哨兵机器人
  • 灯条间距比center_dist / avg_length 在 [0.8, 3.2] 之间
  • 尺寸:225mm x 55mm
  • 适用:英雄机器人(1/2 号)、基地装甲板
  • 灯条间距比center_dist / avg_length 在 [3.2, 5.5] 之间

7.1.3 颜色

比赛分为红蓝双方,装甲板灯条颜色对应阵营:

  • 红色方:灯条发出红光
  • 蓝色方:灯条发出蓝光

实际拍摄注意

由于工业相机的自动白平衡和曝光,灯条颜色在图像中可能偏白或偏紫。这也是为什么很多检测算法最终用灰度而非颜色做主要判断的原因。


7.2 数据结构定义:armor.hpp

在开始看检测逻辑之前,我们先理解代码中最基础的数据结构。所有检测结果最终都存入 Armor 结构体。

7.2.1 Light 结构体

Light 继承自 cv::RotatedRect,是对一根灯条的完整描述。

armor.hpp - Light 结构体(真实代码)
struct Light : public cv::RotatedRect
{
  Light() = default;
  explicit Light(cv::RotatedRect box) : cv::RotatedRect(box)
  {
    cv::Point2f p[4];
    box.points(p);
    std::sort(p, p + 4, [](const cv::Point2f & a, const cv::Point2f & b) { return a.y < b.y; });
    top = (p[0] + p[1]) / 2;
    bottom = (p[2] + p[3]) / 2;

    length = cv::norm(top - bottom);
    width = cv::norm(p[0] - p[1]);

    tilt_angle = std::atan2(std::abs(top.x - bottom.x), std::abs(top.y - bottom.y));
    tilt_angle = tilt_angle / CV_PI * 180;
  }

  int color;
  cv::Point2f top, bottom;
  double length;
  double width;
  float tilt_angle;
};

逐行解读

  • struct Light : public cv::RotatedRect:Light 继承 OpenCV 的 RotatedRect,自动拥有 centersizeangle 等成员。
  • box.points(p):获取旋转矩形的 4 个顶点,存储到 p[4] 数组中。OpenCV 返回的顺序不固定。
  • std::sort(p, p + 4, ...):按 y 坐标升序排序。排序后 p[0]p[1] 是上方两个点(y 值小),p[2]p[3] 是下方两个点(y 值大)。
  • top = (p[0] + p[1]) / 2:取上方两个点的中点作为灯条的"顶部"坐标。同理 bottom 取下方两点中点。
  • length = cv::norm(top - bottom):灯条的长度 = 顶部到底部的欧氏距离。
  • width = cv::norm(p[0] - p[1]):灯条的宽度 = 上方两个点之间的距离。
  • tilt_angle:灯条的倾斜角度。使用 atan2 计算灯条与竖直方向的夹角,转换为度数。值为 0 表示完全竖直,值越大表示越倾斜。

7.2.2 Armor 结构体

Armor 是检测流水线的核心输出数据结构,承载了一块装甲板的所有信息。

armor.hpp - Armor 结构体(真实代码)
struct Armor
{
  Armor() = default;
  Armor(const Light & l1, const Light & l2)
  {
    if (l1.center.x < l2.center.x) {
      left_light = l1, right_light = l2;
    } else {
      left_light = l2, right_light = l1;
    }
    center = (left_light.center + right_light.center) / 2;
  }

  // Light pairs part
  Light left_light, right_light;
  cv::Point2f center;
  ArmorType type;

  // Number part
  cv::Mat number_img;
  std::string number;
  float confidence;
  std::string classfication_result;

  // Priority for target selection (1=highest, 5=lowest)
  int priority = 5;

  // YOLO corner points (4 points: top-left, top-right, bottom-right, bottom-left)
  std::vector<cv::Point2f> points;
};

逐行解读

  • Armor(const Light & l1, const Light & l2):构造函数,接受两根灯条。自动按 x 坐标确定左右关系——x 值小的作为左灯条,x 值大的作为右灯条。
  • center = (left_light.center + right_light.center) / 2:装甲板中心 = 两根灯条中心的中点。
  • Light left_light, right_light:左右两根灯条的完整数据(包含顶点、长度、宽度等)。
  • ArmorType type:装甲板类型,取值 SMALLLARGEINVALID
  • cv::Mat number_img:经过透视变换和二值化后的数字区域图像(20x28 像素),用于 MLP 分类器输入。
  • std::string number:数字分类器识别出的编号,如 "1""3""outpost" 等。
  • float confidence:数字分类器的置信度(0~1)。
  • int priority:目标选择优先级,1 为最高优先级。用于多感知系统中选择最优打击目标。
  • std::vector<cv::Point2f> points:YOLO 模型直接输出的 4 个角点坐标,传统检测方法不使用此字段。

7.2.3 颜色常量与类型枚举

armor.hpp - 常量定义(真实代码)
const int RED = 0;
const int BLUE = 1;

enum class ArmorType { SMALL, LARGE, INVALID };
const std::string ARMOR_TYPE_STR[3] = {"small", "large", "invalid"};

逐行解读

  • const int RED = 0; BLUE = 1;:颜色常量。注意这里用的是 int 而不是 enum,与 Light 结构体的 color 字段对应。
  • enum class ArmorType:使用强类型枚举(C++11),INVALID 用于标记不满足匹配条件的灯条对。
  • ARMOR_TYPE_STR[3]:类型到字符串的映射,用于调试输出。

7.3 传统检测方法全流程

传统检测流水线是 RoboMaster 视觉系统的经典方案,不需要 GPU 即可运行,延迟稳定在几毫秒级别。下面是完整的检测流程:

flowchart TD A["📷 原始图像 BGR"] --> B["🔍 预处理
灰度 + 二值化"] B --> C["🔲 findContours
轮廓提取"] C --> D["📏 灯条筛选
宽高比 + 角度"] D --> E["🔴🔵 颜色分类
R/B 通道统计"] E --> F["🔗 灯条配对
双重循环匹配"] F --> G{"配对成功?"} G -->|是| H["🔢 数字识别
透视变换 + MLP"] G -->|否| Z["❌ 无装甲板"] H --> I{"置信度 > 阈值?"} I -->|是| J["📐 PnP 位姿解算
rvec + tvec"] I -->|否| Z J --> K["✅ 输出装甲板
位置 + 姿态 + 类型"] style A fill:#4a9eff,color:#fff style K fill:#4caf50,color:#fff style Z fill:#f44336,color:#fff

顶层调用函数 detect() 串联了整个流水线。这是仓库中的真实代码:

detector.cpp - detect() 完整函数(真实代码)
std::vector<Armor> Detector::detect(const cv::Mat & input)
{
  binary_img = preprocessImage(input);
  lights_ = findLights(input, binary_img);
  armors_ = matchLights(lights_);

  if (!armors_.empty()) {
    classifier->extractNumbers(input, armors_);
    classifier->classify(armors_);
  }

  return armors_;
}

逐行解读

  • binary_img = preprocessImage(input):将原始 RGB 图像转灰度并二值化,返回二值图。结果保存为类成员变量(方便调试显示)。
  • lights_ = findLights(input, binary_img):在二值图中查找轮廓,用几何条件筛选出灯条,并统计颜色。同时传入原始图像用于颜色采样。
  • armors_ = matchLights(lights_):将同色灯条两两配对,通过距离比判断是大装甲还是小装甲,排除假阳性。
  • if (!armors_.empty()):只在有装甲板时才运行数字识别,节省计算资源。
  • classifier->extractNumbers(input, armors_):对每块装甲板做透视变换,提取数字区域图像并二值化。
  • classifier->classify(armors_):用 DNN 模型对数字图像做推理,识别编号和置信度,过滤掉低置信度和类型不匹配的结果。
  • 返回值:所有通过筛选的装甲板列表,包含位置、类型、编号等信息。

7.4 预处理:preprocessImage()

7.4.1 为什么用灰度二值化?

在开始写代码之前,先理解一个关键问题:为什么不用 HSV 颜色空间来提取灯条?

灰度二值化的原因

工业相机的动态范围有限。当灯条亮度极高时,感光元件的三个通道(R/G/B)会同时饱和,也就是说灯条中心区域的像素 R~G~B~255,颜色信息完全丢失。

用灰度二值化则不受影响——灯条再亮,灰度值依然是最高的,正好能被阈值分离出来。

7.4.2 真实代码

detector.cpp - preprocessImage() 完整函数(真实代码)
cv::Mat Detector::preprocessImage(const cv::Mat & rgb_img)
{
  cv::Mat gray_img;
  cv::cvtColor(rgb_img, gray_img, cv::COLOR_RGB2GRAY);

  cv::Mat binary_img;
  cv::threshold(gray_img, binary_img, binary_thres, 255, cv::THRESH_BINARY);

  return binary_img;
}

逐行解读

  • cv::cvtColor(rgb_img, gray_img, cv::COLOR_RGB2GRAY):RGB 三通道图像转灰度单通道。内部使用加权公式 Gray = 0.299*R + 0.587*G + 0.114*B。注意这里用的是 COLOR_RGB2GRAY 而非 COLOR_BGR2GRAY,说明上层传入的图像已经是 RGB 顺序(ROS2 的 cv_bridge 默认输出 BGR,但代码中做了转换)。
  • cv::threshold(gray_img, binary_img, binary_thres, 255, cv::THRESH_BINARY):固定阈值二值化。灰度值 > binary_thres 的像素变为 255(白色),其余变为 0(黑色)。binary_thres 是通过构造函数从参数文件读入的(默认值通常为 80)。
  • 返回 binary_img:单通道二值图,白色区域即为候选灯条区域。

📖 ch05 阈值处理详解

变量含义:

变量 含义
rgb_img 原始 RGB 图像,来自工业相机
gray_img 单通道灰度图(局部变量,函数结束后释放)
binary_img 二值化后的掩码,白色区域即为候选灯条区域
binary_thres 类成员变量,二值化阈值,从参数文件加载

binary_thres 阈值 80 的来源

这个值不是随便写的。RoboMaster 比赛场地灯光较暗(大约 200-500 lux),而灯条自身发光亮度很高。实际测试中,灰度值 80 是一个在"保留灯条"和"排除背景"之间较好的平衡点。不同光照环境下可能需要微调(见 7.10 节)。


7.5 灯条检测:findLights()

7.5.1 整体思路

在二值化图像中,灯条表现为白色的细长矩形区域。我们用轮廓检测找到这些区域,然后用旋转矩形拟合,最后用几何条件和颜色统计筛选出真正的灯条。

flowchart LR A["二值化图像"] --> B["findContours()"] B --> C["minAreaRect()"] C --> D{"isLight() 宽高比 + 角度?"} D -->|是| E["颜色判断
R/B 通道像素遍历"] E --> F["✅ 灯条"] D -->|否| G["❌ 丢弃"]

7.5.2 findLights() 真实代码

📖 ch05 轮廓检测详解

detector.cpp - findLights() 完整函数(真实代码)
// 注意:参数名 `rbg_img` 是仓库代码中的拼写错误,实际是 RGB 顺序。`[0]=R, [1]=G, [2]=B`
std::vector<Light> Detector::findLights(const cv::Mat std::vector<Light> Detector::findLights(const cv::Mat & rbg_img rbg_img, const cv::Mat & binary_img)
{
  using std::vector;
  vector<vector<cv::Point>> contours;
  vector<cv::Vec4i> hierarchy;
  cv::findContours(binary_img, contours, hierarchy, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE);

  vector<Light> lights;
  this->debug_lights.data.clear();

  for (const auto & contour : contours) {
    if (contour.size() < 5) continue;

    auto r_rect = cv::minAreaRect(contour);
    auto light = Light(r_rect);

    if (isLight(light)) {
      auto rect = light.boundingRect();
      if (  // Avoid assertion failed
        0 <= rect.x && 0 <= rect.width && rect.x + rect.width <= rbg_img.cols && 0 <= rect.y &&
        0 <= rect.height && rect.y + rect.height <= rbg_img.rows) {
        int sum_r = 0, sum_b = 0;
        auto roi = rbg_img(rect);
        // Iterate through the ROI
        for (int i = 0; i < roi.rows; i++) {
          for (int j = 0; j < roi.cols; j++) {
            if (cv::pointPolygonTest(contour, cv::Point2f(j + rect.x, i + rect.y), false) >= 0) {
              // if point is inside contour
              sum_r += roi.at<cv::Vec3b>(i, j)[0];
              sum_b += roi.at<cv::Vec3b>(i, j)[2];
            }
          }
        }
        // Sum of red pixels > sum of blue pixels ?
        light.color = sum_r > sum_b ? RED : BLUE;
        lights.emplace_back(light);
      }
    }
  }

  return lights;
}

逐行解读

  • vector<vector<cv::Point>> contours:OpenCV 轮廓容器。每个轮廓是一系列点的集合,描述一个连通区域的边界。
  • vector<cv::Vec4i> hierarchy:轮廓的层级信息。这里虽然没有用到,但 findContours 的 API 要求传入。
  • cv::findContours(binary_img, contours, hierarchy, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE):核心轮廓检测。RETR_EXTERNAL 表示只检测最外层轮廓(不检测嵌套的内轮廓),CHAIN_APPROX_SIMPLE 表示只保存轮廓的拐点(水平/垂直线段只存端点,压缩存储)。
  • this->debug_lights.data.clear():清空调试信息。debug_lights 是 ROS2 的消息类型,用于将灯条检测的中间数据发布到 topic,配合 rqt 可视化调参。
  • if (contour.size() < 5) continue:轮廓点数少于 5 个的直接跳过。minAreaRect() 要求至少 5 个点,不足的是噪声点。
  • auto r_rect = cv::minAreaRect(contour):用最小面积旋转矩形拟合轮廓。返回的 RotatedRect 包含 center(中心点)、size(宽高)、angle(角度,范围 [-90, 0))。
  • auto light = Light(r_rect):调用 Light 构造函数,从旋转矩形中自动计算 topbottomlengthwidthtilt_angle
  • if (isLight(light)):调用 isLight() 检查宽高比和倾斜角度是否满足灯条特征。
  • auto rect = light.boundingRect():获取灯条的外接轴对齐矩形(bounding box),用于提取 ROI。
  • 边界检查 0 <= rect.x && ...:防止 ROI 超出图像边界导致 OpenCV 断言失败(assertion failed)。这在图像边缘检测灯条时特别重要。
  • auto roi = rbg_img(rect):提取灯条所在的矩形区域(Region of Interest)。这是原图的一个子矩阵,共享数据内存,不拷贝。
  • 双重 for 循环遍历 ROI 内的每个像素:逐像素检查是否在轮廓内部,并累加 R/B 通道值。
  • cv::pointPolygonTest(contour, cv::Point2f(j + rect.x, i + rect.y), false) >= 0:判断像素点是否在轮廓内部(返回值 >= 0 表示在边界上或内部)。注意坐标要加上 rect.xrect.y 转换回全图坐标系。
  • sum_r += roi.at<cv::Vec3b>(i, j)[0]:累加 R 通道值。注意 OpenCV 的 Vec3b 是 BGR 顺序,[0] 是 B 通道,[2] 是 R 通道。但是——

仓库中的通道顺序

实际代码中 sum_r 累加的是 roi.at<cv::Vec3b>(i, j)[0],而 sum_b 累加的是 roi.at<cv::Vec3b>(i, j)[2]。函数名 rbg_img 提示传入的图像可能已经是 RGB 格式([0]=R, [2]=B),而非 OpenCV 默认的 BGR。最终 sum_r > sum_b 的判断逻辑是正确的。

  • light.color = sum_r > sum_b ? RED : BLUE:R 通道像素总和 > B 通道则判定为红色灯条,否则为蓝色。
  • lights.emplace_back(light):将通过所有筛选的灯条加入结果列表。

7.5.3 灯条筛选:isLight()

detector.cpp - isLight() 完整函数(真实代码)
bool Detector::isLight(const Light & light)
{
  // The ratio of light (short side / long side)
  float ratio = light.width / light.length;
  bool ratio_ok = l.min_ratio < ratio && ratio < l.max_ratio;

  bool angle_ok = light.tilt_angle < l.max_angle;

  bool is_light = ratio_ok && angle_ok;

  // Fill in debug information
  auto_aim_interfaces::msg::DebugLight light_data;
  light_data.center_x = light.center.x;
  light_data.ratio = ratio;
  light_data.angle = light.tilt_angle;
  light_data.is_light = is_light;
  this->debug_lights.data.emplace_back(light_data);

  return is_light;
}

逐行解读

  • float ratio = light.width / light.length:计算灯条的宽高比(短边 / 长边)。灯条是细长矩形,这个比值应该很小。
  • l.min_ratio < ratio && ratio < l.max_ratio:宽高比范围检查。lLightParams 结构体,从参数文件加载。典型值:min_ratio=0.1max_ratio=0.4。太窄可能是噪声线,太宽可能是反光区域。
  • light.tilt_angle < l.max_angle:倾斜角度检查。灯条应该接近竖直,倾斜角度过大的不是灯条。max_angle 典型值为 40 度。
  • 调试信息填充:将每个候选灯条的 center_xratioangleis_light 记录下来,通过 ROS2 topic 发布。可以用 rqt_plotrqt_multiplot 实时查看这些数据,方便调参。

7.5.4 Light 数据结构回顾

struct Light : public cv::RotatedRect
{
  int color;            // RED(0) 或 BLUE(1)
  cv::Point2f top;      // 灯条顶部中点
  cv::Point2f bottom;   // 灯条底部中点
  double length;        // 灯条长度 = norm(top - bottom)
  double width;         // 灯条宽度 = 上方两点间距
  float tilt_angle;     // 与竖直方向的夹角(度)
  // 继承自 RotatedRect: center, size, angle
};

为什么 top/bottom 要用排序法计算?

cv::RotatedRect::points() 返回的 4 个点顺序不固定(取决于角度),不能简单地取 p[0] 作为顶部。所以代码先按 y 坐标排序,取上方两点的中点为 top、下方两点的中点为 bottom,这样无论旋转矩形怎么旋转,都能正确识别顶和底。


7.6 装甲板匹配:matchLights()

找到灯条之后,下一步是将同色、等长、合适距离的两根灯条配对为一块装甲板。

7.6.1 整体思路

flowchart TD A["所有灯条列表"] --> B["双重循环配对"] B --> C{"颜色 == detect_color?"} C -->|否| D["❌ 跳过"] C -->|是| E{"containLight()?
中间有其他灯条?"} E -->|是| D E -->|否| F{"isArmor() 几何检查?"} F -->|INVALID| D F -->|SMALL / LARGE| G["✅ 构造 Armor"]

7.6.2 matchLights() 真实代码

detector.cpp - matchLights() 完整函数(真实代码)
std::vector<Armor> Detector::matchLights(const std::vector<Light> & lights)
{
  std::vector<Armor> armors;
  this->debug_armors.data.clear();

  // Loop all the pairing of lights
  for (auto light_1 = lights.begin(); light_1 != lights.end(); light_1++) {
    for (auto light_2 = light_1 + 1; light_2 != lights.end(); light_2++) {
      if (light_1->color != detect_color || light_2->color != detect_color) continue;

      if (containLight(*light_1, *light_2, lights)) {
        continue;
      }

      auto type = isArmor(*light_1, *light_2);
      if (type != ArmorType::INVALID) {
        auto armor = Armor(*light_1, *light_2);
        armor.type = type;
        armors.emplace_back(armor);
      }
    }
  }

  return armors;
}

逐行解读

  • for (auto light_1 = lights.begin(); light_1 != lights.end(); light_1++):外层循环遍历每根灯条。
  • for (auto light_2 = light_1 + 1; light_2 != lights.end(); light_2++):内层循环从 light_1 的下一个开始,避免重复配对和自配对。这样 (i, j)(j, i) 只会匹配一次。
  • if (light_1->color != detect_color || light_2->color != detect_color) continue:两根灯条的颜色都必须等于我们当前要检测的颜色(detect_color 是参数,RED 或 BLUE)。只要有一根颜色不匹配就跳过。
  • if (containLight(*light_1, *light_2, lights)) continue:调用 containLight() 检查两根灯条之间是否有其他灯条。如果有,说明可能是误配对(详见 7.6.3 节)。
  • auto type = isArmor(*light_1, *light_2):调用 isArmor() 检查灯条对的几何关系(长度比、距离比、角度),返回 SMALLLARGEINVALID
  • if (type != ArmorType::INVALID):只保留通过几何检查的灯条对。
  • auto armor = Armor(*light_1, *light_2):调用 Armor 构造函数,自动按 x 坐标确定左右灯条,计算装甲板中心。
  • armor.type = type:设置装甲板类型。
  • armors.emplace_back(armor):加入结果列表。

7.6.3 假阳性排除:containLight()

这是匹配过程中最容易被忽略的函数,但它对精度影响巨大。

detector.cpp - containLight() 完整函数(真实代码)
// Check if there is another light in the boundingRect formed by the 2 lights
bool Detector::containLight(
  const Light & light_1, const Light & light_2, const std::vector<Light> & lights)
{
  auto points = std::vector<cv::Point2f>{light_1.top, light_1.bottom, light_2.top, light_2.bottom};
  auto bounding_rect = cv::boundingRect(points);

  for (const auto & test_light : lights) {
    if (test_light.center == light_1.center || test_light.center == light_2.center) continue;

    if (
      bounding_rect.contains(test_light.top) || bounding_rect.contains(test_light.bottom) ||
      bounding_rect.contains(test_light.center)) {
      return true;
    }
  }

  return false;
}

逐行解读

  • auto points = std::vector<cv::Point2f>{light_1.top, light_1.bottom, light_2.top, light_2.bottom}:收集两根灯条的 4 个端点(两根灯条各有 top 和 bottom)。
  • auto bounding_rect = cv::boundingRect(points):计算这 4 个点的外接轴对齐矩形(bounding box)。这个矩形覆盖了两根灯条之间的区域。
  • 跳过自身test_light.center == light_1.center 用指针比较更高效,但这里用值比较也能工作。
  • bounding_rect.contains(test_light.top) || ...:检查其他灯条的 top、bottom 或 center 是否落在 bounding box 内。只要有一个点在里面,就说明有"中间灯条"。
  • return true:发现中间灯条,判定为假阳性,配对应被拒绝。

containLight 的作用示意

假设图像中有 4 根红色灯条从左到右排列:A B C D

如果不检查 containLight,A 和 D 可能被配对为"大装甲板"。但实际上 B 和 C 在中间,说明 A-B 和 C-D 才是正确的配对。

    A ─────────── B ─────────── C ─────────── D
    |← 错误配对 →|              |← 正确配对 →|
`containLight` 通过检查 A-D 之间的 bounding box 内是否有 B 和 C,从而拒绝 A-D 这个错误配对。

7.6.4 几何检查:isArmor()

detector.cpp - isArmor() 完整函数(真实代码)
ArmorType Detector::isArmor(const Light & light_1, const Light & light_2)
{
  // Ratio of the length of 2 lights (short side / long side)
  float light_length_ratio = light_1.length < light_2.length ? light_1.length / light_2.length
                                                             : light_2.length / light_1.length;
  bool light_ratio_ok = light_length_ratio > a.min_light_ratio;

  // Distance between the center of 2 lights (unit : light length)
  float avg_light_length = (light_1.length + light_2.length) / 2;
  float center_distance = cv::norm(light_1.center - light_2.center) / avg_light_length;
  bool center_distance_ok = (a.min_small_center_distance <= center_distance &&
                             center_distance < a.max_small_center_distance) ||
                            (a.min_large_center_distance <= center_distance &&
                             center_distance < a.max_large_center_distance);

  // Angle of light center connection
  cv::Point2f diff = light_1.center - light_2.center;
  float angle = std::abs(std::atan(diff.y / diff.x)) / CV_PI * 180;
  bool angle_ok = angle < a.max_angle;

  bool is_armor = light_ratio_ok && center_distance_ok && angle_ok;

  // Judge armor type
  ArmorType type;
  if (is_armor) {
    type = center_distance > a.min_large_center_distance ? ArmorType::LARGE : ArmorType::SMALL;
  } else {
    type = ArmorType::INVALID;
  }

  // Fill in debug information
  auto_aim_interfaces::msg::DebugArmor armor_data;
  armor_data.type = ARMOR_TYPE_STR[static_cast<int>(type)];
  armor_data.center_x = (light_1.center.x + light_2.center.x) / 2;
  armor_data.light_ratio = light_length_ratio;
  armor_data.center_distance = center_distance;
  armor_data.angle = angle;
  this->debug_armors.data.emplace_back(armor_data);

  return type;
}

逐行解读

  • light_length_ratio = min / max:两根灯条的长度比。较短的除以较长的,保证比值在 (0, 1] 之间。如果比值太小,说明两根灯条长度差异大,不太可能是同一块装甲板。
  • a.min_light_ratio:最小长度比阈值(典型值 0.8)。低于此值拒绝配对。
  • float avg_light_length = (light_1.length + light_2.length) / 2:两根灯条的平均长度。
  • float center_distance = cv::norm(light_1.center - light_2.center) / avg_light_length:灯条中心距除以平均长度,得到一个归一化距离。这是匹配的核心指标——它消除了远近造成的灯条大小差异。小装甲板这个值在 0.8~3.2 之间,大装甲板在 3.2~5.5 之间。
  • center_distance_ok:用两个区间检查——要么落在小装甲板范围 [min_small, max_small),要么落在大装甲板范围 [min_large, max_large)
  • cv::Point2f diff = light_1.center - light_2.center:两灯条中心的差值向量。
  • float angle = std::abs(std::atan(diff.y / diff.x)) / CV_PI * 180:计算两灯条中心连线与水平方向的夹角(度数)。如果角度太大,说明两根灯条上下错位太远,不太可能是水平排列的装甲板。
  • 类型判断:如果通过所有检查,center_distance > min_large_center_distance 则判定为大装甲板,否则为小装甲板。
  • 调试信息:将每次配对尝试的详细数据记录下来,用于可视化调参。

归一化距离的妙处

同一块装甲板,从 1 米和 3 米外看,灯条中心的像素距离差异很大。但除以灯条的平均长度后,归一化距离基本不变。这是传统检测方法能够适应不同距离的关键技巧。


7.7 数字识别:NumberClassifier

匹配到装甲板后,还需要识别面板上的数字编号,才能知道我们瞄准的是哪个机器人。

7.7.1 整体流程

flowchart TD A["装甲板
(两根灯条)"] --> B["提取灯条 4 个端点"] B --> C["透视变换
warpPerspective"] C --> D["裁剪 20x28 ROI"] D --> E["OTSU 二值化"] E --> F["DNN 前向推理
ONNX 模型"] F --> G["Softmax 概率"] G --> H{"置信度 > 阈值?"} H -->|是| I{"类型一致性检查
大装甲 != outpost?"} I -->|通过| J["✅ 输出编号"] I -->|不通过| K["❌ 丢弃"] H -->|否| K

7.7.2 构造函数:加载 ONNX 模型

number_classifier.cpp - NumberClassifier 构造函数(真实代码)
NumberClassifier::NumberClassifier(
  const std::string & model_path, const std::string & label_path, const double thre,
  const std::vector<std::string> & ignore_classes)
: threshold(thre), ignore_classes_(ignore_classes)
{
  net_ = cv::dnn::readNetFromONNX(model_path);

  std::ifstream label_file(label_path);
  std::string line;
  while (std::getline(label_file, line)) {
    class_names_.push_back(line);
  }
}

逐行解读

  • threshold(thre), ignore_classes_(ignore_classes):初始化列表,设置置信度阈值(典型值 0.8)和需要忽略的类别列表。
  • net_ = cv::dnn::readNetFromONNX(model_path):使用 OpenCV 的 DNN 模块加载 ONNX 格式的神经网络模型。net_cv::dnn::Net 类型。
  • std::ifstream label_file(label_path):打开标签文件(纯文本,每行一个类别名)。
  • while (std::getline(label_file, line)):逐行读取标签名,存入 class_names_ 向量。典型的类别名有:"0", "1", "2", "3", "4", "5", "outpost", "base", "guard"

7.7.3 透视变换与数字提取:extractNumbers()

📖 ch05 透视变换详解

这是数字识别最关键的预处理步骤。为什么需要透视变换?因为相机视角可能倾斜,拍摄到的装甲板不是正的矩形。透视变换把它"拉正",方便后续数字识别。

number_classifier.cpp - extractNumbers() 完整函数(真实代码)
void NumberClassifier::extractNumbers(const cv::Mat & src, std::vector<Armor> & armors)
{
  // Light length in image
  const int light_length = 12;
  // Image size after warp
  const int warp_height = 28;
  const int small_armor_width = 32;
  const int large_armor_width = 54;
  // Number ROI size
  const cv::Size roi_size(20, 28);

  for (auto & armor : armors) {
    // Warp perspective transform
    cv::Point2f lights_vertices[4] = {
      armor.left_light.bottom, armor.left_light.top, armor.right_light.top,
      armor.right_light.bottom};

    const int top_light_y = (warp_height - light_length) / 2 - 1;
    const int bottom_light_y = top_light_y + light_length;
    const int warp_width = armor.type == ArmorType::SMALL ? small_armor_width : large_armor_width;
    cv::Point2f target_vertices[4] = {
      cv::Point(0, bottom_light_y),
      cv::Point(0, top_light_y),
      cv::Point(warp_width - 1, top_light_y),
      cv::Point(warp_width - 1, bottom_light_y),
    };
    cv::Mat number_image;
    auto rotation_matrix = cv::getPerspectiveTransform(lights_vertices, target_vertices);
    cv::warpPerspective(src, number_image, rotation_matrix, cv::Size(warp_width, warp_height));

    // Get ROI
    number_image =
      number_image(cv::Rect(cv::Point((warp_width - roi_size.width) / 2, 0), roi_size));

    // Binarize
    cv::cvtColor(number_image, number_image, cv::COLOR_RGB2GRAY);
    cv::threshold(number_image, number_image, 0, 255, cv::THRESH_BINARY | cv::THRESH_OTSU);

    armor.number_img = number_image;
  }
}

逐行解读

  • const int light_length = 12:透视变换后灯条的像素长度。这是一个设计常量——无论原始图像中灯条多长,变换后统一为 12 像素。
  • const int warp_height = 28:变换后图像高度为 28 像素。
  • small_armor_width = 32, large_armor_width = 54:小装甲和大装甲变换后的宽度不同。大装甲板更宽,所以宽度更大。
  • const cv::Size roi_size(20, 28):最终提取的数字 ROI 大小,固定 20x28 像素,这是神经网络训练时的标准尺寸。
  • cv::Point2f lights_vertices[4]:源点——取灯条的 4 个端点。顺序是 左灯条底 -> 左灯条顶 -> 右灯条顶 -> 右灯条底,构成一个从左下角开始逆时针的四边形。
    左灯条顶(top) ──────── 右灯条顶(top)
         │                      │
    左灯条底(bottom) ──── 右灯条底(bottom)
- **`const int top_light_y = (warp_height - light_length) / 2 - 1`**:灯条在变换后图像中的 y 坐标。`(28 - 12) / 2 - 1 = 7`,灯条从 y=7 到 y=19(即 `7 + 12 = 19`),在 28 像素高度中居中。
- **`const int warp_width = armor.type == ArmorType::SMALL ? small_armor_width : large_armor_width`**:根据装甲板类型选择变换宽度。小装甲板 32 像素,大装甲板 54 像素。
- **`target_vertices[4]`**:目标点——变换后的 4 个角点位置。灯条被放在图像中间偏上位置。
- **`cv::getPerspectiveTransform(lights_vertices, target_vertices)`**:计算 3x3 透视变换矩阵 M。M 将源四边形映射到目标矩形。
- **`cv::warpPerspective(src, number_image, rotation_matrix, cv::Size(warp_width, warp_height))`**:执行透视变换,输出尺寸为 `warp_width x 28`。
- **ROI 裁剪 `number_image(cv::Rect(...))`**:从变换后的图像中裁剪中间 20x28 的区域,去掉两侧的灯条部分,只保留数字。
- **`cv::cvtColor(number_image, number_image, cv::COLOR_RGB2GRAY)`**:转灰度。
- **`cv::threshold(number_image, number_image, 0, 255, cv::THRESH_BINARY | cv::THRESH_OTSU)`**:OTSU 自适应二值化。阈值设为 0 表示由 OTSU 算法自动计算最优阈值。`OTSU` 比固定阈值更稳健,能适应不同亮度的数字贴纸。
- **`armor.number_img = number_image`**:将二值化后的数字图像存入 Armor 结构体,供后续分类器使用。

透视变换前后对比:

透视变换前(相机视角倾斜):        透视变换后(校正为正矩形):
    ╱‾‾‾‾╲                              ┌────┐
   ╱  2   ╲                             │  2 │
  ╱________╲                            │    │
                                        └────┘

7.7.4 DNN 推理与 Softmax:classify()

number_classifier.cpp - classify() 完整函数(真实代码)
void NumberClassifier::classify(std::vector<Armor> & armors)
{
  for (auto & armor : armors) {
    cv::Mat image = armor.number_img.clone();

    // Normalize
    image = image / 255.0;

    // Create blob from image
    cv::Mat blob;
    cv::dnn::blobFromImage(image, blob);

    // Set the input blob for the neural network
    net_.setInput(blob);
    // Forward pass the image blob through the model
    cv::Mat outputs = net_.forward();

    // Do softmax
    float max_prob = *std::max_element(outputs.begin<float>(), outputs.end<float>());
    cv::Mat softmax_prob;
    cv::exp(outputs - max_prob, softmax_prob);
    float sum = static_cast<float>(cv::sum(softmax_prob)[0]);
    softmax_prob /= sum;

    double confidence;
    cv::Point class_id_point;
    minMaxLoc(softmax_prob.reshape(1, 1), nullptr, &confidence, nullptr, &class_id_point);
    int label_id = class_id_point.x;

    armor.confidence = confidence;
    armor.number = class_names_[label_id];

    std::stringstream result_ss;
    result_ss << armor.number << ": " << std::fixed << std::setprecision(1)
              << armor.confidence * 100.0 << "%";
    armor.classfication_result = result_ss.str();
  }

  armors.erase(
    std::remove_if(
      armors.begin(), armors.end(),
      [this](const Armor & armor) {
        if (armor.confidence < threshold) {
          return true;
        }

        for (const auto & ignore_class : ignore_classes_) {
          if (armor.number == ignore_class) {
            return true;
          }
        }

        bool mismatch_armor_type = false;
        if (armor.type == ArmorType::LARGE) {
          mismatch_armor_type =
            armor.number == "outpost" || armor.number == "2" || armor.number == "guard";
        } else if (armor.type == ArmorType::SMALL) {
          mismatch_armor_type = armor.number == "1" || armor.number == "base";
        }
        return mismatch_armor_type;
      }),
    armors.end());
}

逐行解读——推理部分

  • cv::Mat image = armor.number_img.clone():克隆数字图像,避免修改原始数据。
  • image = image / 255.0:像素值归一化到 [0, 1] 范围。这是神经网络的标准输入格式。
  • cv::dnn::blobFromImage(image, blob):将图像转换为 DNN 的输入 blob(4D 张量)。默认输出形状为 [1, 1, 28, 20](batch=1, channels=1, height=28, width=20)。
  • net_.setInput(blob):设置网络输入。
  • cv::Mat outputs = net_.forward():前向推理,执行一次完整的网络前向传播。outputs 的形状为 [1, num_classes],每个元素是对应类别的原始得分(logits)。

逐行解读——Softmax 部分

  • float max_prob = *std::max_element(...):找到所有输出中的最大值。这是数值稳定的 softmax 的第一步——减去最大值防止 exp() 溢出。
  • cv::exp(outputs - max_prob, softmax_prob):计算 exp(x_i - max)。减去最大值保证最大的 expe^0 = 1,其余都小于 1,不会溢出。
  • float sum = static_cast<float>(cv::sum(softmax_prob)[0]):计算所有 exp 值的总和。
  • softmax_prob /= sum:除以总和,得到概率分布。每个值在 [0, 1] 之间,总和为 1。这就是 Softmax:P(i) = exp(x_i - max) / sum(exp(x_j - max))
  • minMaxLoc(softmax_prob.reshape(1, 1), nullptr, &confidence, nullptr, &class_id_point)reshape(1, 1) 将矩阵展平为一行。minMaxLoc 找到最大值及其位置,class_id_point.x 就是类别索引。
  • armor.number = class_names_[label_id]:通过索引查找类别名(如 "1", "3", "outpost" 等)。
  • result_ss << armor.number << ": " << ...:构造可读的结果字符串,如 "3: 95.2%",存储到 classfication_result 用于调试显示。

逐行解读——过滤部分

  • armors.erase(std::remove_if(...)):经典的 Erase-Remove 惯用法(C++),从 vector 中删除满足条件的元素。Lambda 表达式返回 true 表示该装甲板应被移除。
  • if (armor.confidence < threshold) return true:置信度过滤。低于阈值(默认 0.8)的识别结果不可信,直接丢弃。
  • for (const auto & ignore_class : ignore_classes_):忽略指定类别。某些比赛中可能不需要识别特定编号。
  • 类型一致性检查
装甲板类型 不可能的编号 原因
LARGE(大装甲) "outpost", "2", "guard" 前哨站、2号英雄、哨兵只有小装甲板
SMALL(小装甲) "1", "base" 1号英雄和基地只有大装甲板

这个检查利用了比赛规则的先验知识:如果 MLP 把大装甲识别为 "outpost",说明识别出错,丢弃更安全。

为什么手动实现 Softmax 而不用 cv::dnn?

OpenCV 的 DNN 模块输出的是原始 logits(未归一化的得分),而不是概率。手动实现 Softmax 有两个好处:(1) 数值稳定性更好(减去最大值防止溢出);(2) 可以控制实现细节,避免不必要的内存分配。


7.8 PnP 位姿解算:PnPSolver

📖 ch09 PnP 位姿解算详解

有了装甲板在图像中的 2D 坐标和物理尺寸,我们就可以用 PnP(Perspective-n-Point)算法求解相机到装甲板的 3D 位姿。

7.8.1 原理概述

PnP 算法需要两组输入:

  1. 3D 世界坐标点:装甲板在真实世界中的 4 个角点坐标(米)
  2. 2D 图像坐标点:这 4 个角点在图像中的像素位置

算法输出:

  • 旋转向量 rvec:3x1 向量,描述装甲板相对于相机的旋转(轴-角表示)
  • 平移向量 tvec:3x1 向量,描述装甲板相对于相机的平移(米)
flowchart LR A["3D 模型点
4 个角点(米)"] --> C["cv::solvePnP
SOLVEPNP_IPPE"] B["2D 图像点
4 个角点(像素)"] --> C C --> D["rvec: 旋转向量"] C --> E["tvec: 平移向量"] E --> F["距离 = norm(tvec)"]

7.8.2 构造函数:定义 3D 模型点

pnp_solver.cpp - PnPSolver 构造函数(真实代码)
PnPSolver::PnPSolver(
  const std::array<double, 9> & camera_matrix, const std::vector<double> & dist_coeffs)
: camera_matrix_(cv::Mat(3, 3, CV_64F, const_cast<double *>(camera_matrix.data())).clone()),
  dist_coeffs_(cv::Mat(1, 5, CV_64F, const_cast<double *>(dist_coeffs.data())).clone())
{
  // Unit: m
  constexpr double small_half_y = SMALL_ARMOR_WIDTH / 2.0 / 1000.0;
  constexpr double small_half_z = SMALL_ARMOR_HEIGHT / 2.0 / 1000.0;
  constexpr double large_half_y = LARGE_ARMOR_WIDTH / 2.0 / 1000.0;
  constexpr double large_half_z = LARGE_ARMOR_HEIGHT / 2.0 / 1000.0;

  // Start from bottom left in clockwise order
  // Model coordinate: x forward, y left, z up
  small_armor_points_.emplace_back(cv::Point3f(0, small_half_y, -small_half_z));
  small_armor_points_.emplace_back(cv::Point3f(0, small_half_y, small_half_z));
  small_armor_points_.emplace_back(cv::Point3f(0, -small_half_y, small_half_z));
  small_armor_points_.emplace_back(cv::Point3f(0, -small_half_y, -small_half_z));

  large_armor_points_.emplace_back(cv::Point3f(0, large_half_y, -large_half_z));
  large_armor_points_.emplace_back(cv::Point3f(0, large_half_y, large_half_z));
  large_armor_points_.emplace_back(cv::Point3f(0, -large_half_y, large_half_z));
  large_armor_points_.emplace_back(cv::Point3f(0, -large_half_y, -large_half_z));
}

逐行解读

  • camera_matrix_(cv::Mat(3, 3, CV_64F, ...).clone()):将相机内参数组转换为 3x3 的 cv::Matconst_cast 是因为 cv::Mat 构造函数接受非 const 指针,.clone() 创建深拷贝避免悬挂指针。
  • dist_coeffs_(cv::Mat(1, 5, CV_64F, ...).clone()):畸变系数矩阵,5 个参数(k1, k2, p1, p2, k3)。
  • constexpr double small_half_y = SMALL_ARMOR_WIDTH / 2.0 / 1000.0:小装甲板的半宽。SMALL_ARMOR_WIDTH 是 135mm,除以 2 得 67.5mm,再除以 1000 转换为米 = 0.0675m。constexpr 表示编译期计算。
  • 坐标系说明:模型坐标系为 x 前方, y 左方, z 上方。装甲板在 x=0 平面上(面向相机),4 个角点在 y-z 平面上分布。
  • 点的排列顺序:从左下角开始,逆时针排列。
    (0, -half_y, -half_z) ──── (0, -half_y, half_z)    右下 ──── 右上
         │                          │                      │          │
    (0, half_y, -half_z) ──── (0, half_y, half_z)      左下 ──── 左上
- **毫米转米**:`/ 1000.0` 将毫米转换为 OpenCV PnP 通常使用的单位——米。这样 `tvec` 的输出也是以米为单位。

7.8.3 solvePnP() 真实代码

pnp_solver.cpp - solvePnP() 完整函数(真实代码)
bool PnPSolver::solvePnP(const Armor & armor, cv::Mat & rvec, cv::Mat & tvec)
{
  std::vector<cv::Point2f> image_armor_points;

  // Use YOLO corner points if available, otherwise use lightbar points
  if (!armor.points.empty() && armor.points.size() == 4) {
    // YOLO keypoints: top-left, top-right, bottom-right, bottom-left
    // PnP expects: bottom-left, top-left, top-right, bottom-right (clockwise from bottom-left)
    image_armor_points.emplace_back(armor.points[3]);  // bottom-left
    image_armor_points.emplace_back(armor.points[0]);  // top-left
    image_armor_points.emplace_back(armor.points[1]);  // top-right
    image_armor_points.emplace_back(armor.points[2]);  // bottom-right
  } else {
    image_armor_points.emplace_back(armor.left_light.bottom);
    image_armor_points.emplace_back(armor.left_light.top);
    image_armor_points.emplace_back(armor.right_light.top);
    image_armor_points.emplace_back(armor.right_light.bottom);
  }

  // Solve pnp
  auto object_points = armor.type == ArmorType::SMALL ? small_armor_points_ : large_armor_points_;
  return cv::solvePnP(
    object_points, image_armor_points, camera_matrix_, dist_coeffs_, rvec, tvec, false,
    cv::SOLVEPNP_IPPE);
}

逐行解读

  • YOLO 兼容分支if (!armor.points.empty() && armor.points.size() == 4) 检查是否有 YOLO 模型直接输出的 4 个角点。如果有,使用 YOLO 角点(比灯条端点更精确)。YOLO 输出的顺序是 top-left, top-right, bottom-right, bottom-left,需要重排为 PnP 期望的顺序:bottom-left, top-left, top-right, bottom-right
  • 传统方法分支:使用灯条的 4 个端点(左灯条底, 左灯条顶, 右灯条顶, 右灯条底),与 3D 模型点的排列顺序一一对应。
  • auto object_points = ...:根据装甲板类型选择对应的 3D 模型点集合。
  • cv::solvePnP(object_points, image_armor_points, camera_matrix_, dist_coeffs_, rvec, tvec, false, cv::SOLVEPNP_IPPE):调用 OpenCV 的 PnP 求解器。
参数 含义
object_points 3D 世界坐标(4 个点,米为单位)
image_armor_points 2D 图像坐标(4 个点,像素为单位)
camera_matrix_ 3x3 相机内参矩阵 K
dist_coeffs_ 5 个畸变系数
rvec 输出:旋转向量 (3x1)
tvec 输出:平移向量 (3x1)
false 不使用初始猜测
cv::SOLVEPNP_IPPE 使用 IPPE 算法(Infinitesimal Plane-based Pose Estimation,基于无穷小平面的位姿估计)

为什么用 SOLVEPNP_IPPE?

对于平面目标(4 个共面点),IPPE 是专门为这种场景设计的算法。相比 SOLVEPNP_ITERATIVE(需要初始猜测),IPPE 不需要初始猜测,且对平面目标的解算更准确、更稳定。

7.8.4 辅助函数:calculateDistanceToCenter()

pnp_solver.cpp - calculateDistanceToCenter() 完整函数(真实代码)
float PnPSolver::calculateDistanceToCenter(const cv::Point2f & image_point)
{
  float cx = camera_matrix_.at<double>(0, 2);
  float cy = camera_matrix_.at<double>(1, 2);
  return cv::norm(image_point - cv::Point2f(cx, cy));
}

逐行解读

  • float cx = camera_matrix_.at<double>(0, 2):从相机内参矩阵中提取主点 x 坐标(图像中心 x)。内参矩阵 K(0, 2) 元素就是 cx
  • float cy = camera_matrix_.at<double>(1, 2):同理提取主点 y 坐标。
  • cv::norm(image_point - cv::Point2f(cx, cy)):计算目标点到图像中心的欧氏距离(像素)。距离越近,目标越靠近图像中心,PnP 精度越高(远离图像边缘的畸变更小)。
  • 用途:在多个装甲板中选择射击目标时,优先选择距离图像中心近的目标,因为边缘目标的 PnP 解算受镜头畸变影响更大。

7.8.5 位姿输出格式

PnP 求解后,tvec 直接给出了装甲板在相机坐标系中的 3D 位置:

// tvec 输出示例:
// tvec = [x, y, z]
// x: 水平偏移(右为正)
// y: 垂直偏移(下为正)
// z: 深度距离(前为正,单位:米)
// 距离 = cv::norm(tvec)

相机坐标系

  • X 轴:向右
  • Y 轴:向下
  • Z 轴:向前(指向目标方向)

因此 tvec 的 z 分量就是相机到装甲板的直线距离


7.9 YOLO 检测方法

传统检测方法虽然快速且不需要 GPU,但对光照变化和遮挡比较敏感。近年来,越来越多的队伍开始使用 YOLO 深度学习检测方案。

7.9.1 YOLO vs 传统方法

flowchart TD subgraph YOLO["YOLO 检测流程"] A1["📷 原始图像"] --> B1["📐 图像预处理
resize + 归一化"] B1 --> C1["🧠 OpenVINO 推理
YOLO 网络前向传播"] C1 --> D1["📦 输出解析
4角点 + 置信度 + 类别"] D1 --> E1["🔧 NMS 后处理
非极大值抑制"] E1 --> F1["✅ 装甲板结果"] end subgraph Traditional["传统检测流程"] A2["📷 原始图像"] --> B2["🔍 灰度 + 二值化"] B2 --> C2["📏 轮廓检测 + 筛选"] C2 --> D2["🔗 灯条配对"] D2 --> E2["🔢 数字识别 MLP"] E2 --> F2["✅ 装甲板结果"] end

7.9.2 YOLO 推理代码

yolo_detector.cpp - 推理(示意代码)
void YOLODetector::detect(const cv::Mat & img, std::vector<Armor> & armors)
{
  // 第一步:预处理
  cv::Mat resized;
  cv::resize(img, resized, cv::Size(input_width_, input_height_));
  resized.convertTo(resized, CV_32FC3, 1.0 / 255.0);

  // 创建 OpenVINO 输入张量
  ov::Tensor input_tensor(ov::element::f32,
                          {1, 3, input_height_, input_width_},
                          resized.data);

  // 第二步:OpenVINO 推理
  infer_request_.set_input_tensor(0, input_tensor);
  infer_request_.infer();

  // 第三步:获取输出并解析
  auto output = infer_request_.get_output_tensor(0);
  const float *data = output.data<const float>();

  int num_detections = output.get_shape()[1];
  int detection_size = output.get_shape()[2];

  std::vector<Armor> raw_detections;
  for (int i = 0; i < num_detections; i++) {
    const float *det = data + i * detection_size;
    float confidence = det[8];
    if (confidence < conf_threshold_) continue;

    float scale_x = static_cast<float>(img.cols) / input_width_;
    float scale_y = static_cast<float>(img.rows) / input_height_;

    std::vector<cv::Point2f> corners;
    for (int j = 0; j < 4; j++) {
      float x = det[j * 2]     * scale_x;
      float y = det[j * 2 + 1] * scale_y;
      corners.emplace_back(x, y);
    }

    int class_id = std::max_element(det + 9, det + detection_size) - (det + 9);

    Armor armor;
    armor.points    = corners;
    armor.confidence = confidence;
    armor.number     = class_names_[class_id];
    raw_detections.push_back(armor);
  }

  // 第四步:NMS
  armors = nms(raw_detections);
}

逐行解读

  • 预处理:将图像 resize 到 YOLO 输入尺寸(如 416x416),归一化到 [0, 1]。
  • ov::Tensor:创建 OpenVINO 输入张量,形状 [1, 3, H, W](NCHW 格式)。
  • infer_request_.infer():执行推理。
  • 输出解析:YOLO 输出形状通常为 [1, N, 85] 或类似。对装甲板检测自定义模型,可能是 [1, N, 17],其中 17 = 4x2(4个角点的xy)+ 1(置信度)+ 8(类别数)。
  • 坐标缩放:YOLO 输出的坐标是相对于输入图像尺寸的,需要乘以 scale_x/y 缩放回原始图像尺寸。
  • NMS 后处理:非极大值抑制去除同一目标的重复检测框(见下节)。

7.9.3 NMS 后处理

yolo_detector.cpp - NMS(示意代码)
std::vector<Armor> YOLODetector::nms(std::vector<Armor> & detections)
{
  std::sort(detections.begin(), detections.end(),
            [](const Armor &a, const Armor &b) {
                return a.confidence > b.confidence;
            });

  std::vector<bool> suppressed(detections.size(), false);
  std::vector<Armor> result;

  for (size_t i = 0; i < detections.size(); i++) {
    if (suppressed[i]) continue;
    result.push_back(detections[i]);

    for (size_t j = i + 1; j < detections.size(); j++) {
      if (suppressed[j]) continue;
      float iou = computeIoU(detections[i].points, detections[j].points);
      if (iou > nms_threshold_) suppressed[j] = true;
    }
  }
  return result;
}

逐行解读

  • 按置信度降序排列:优先保留高置信度的检测。
  • suppressed 数组:标记哪些检测已被抑制。
  • IoU 计算:计算两个四边形框的交并比(Intersection over Union)。IoU 超过阈值(通常 0.5)说明两个框重叠严重,视为同一目标,保留置信度高的那个。
  • 结果:去除重复检测后的装甲板列表。

7.9.4 YOLO 的优势

特性 传统方法 YOLO
灯条检测 手动阈值 + 轮廓 端到端学习
数字识别 单独的 DNN 分类器 与检测联合学习
角点定位 依赖灯条几何 直接回归角点
轻微遮挡 容易丢失 有一定鲁棒性
光照变化 需要调参 训练数据覆盖即可

7.10 两种方法对比

7.10.1 性能对比

指标 传统方法 YOLO
推理速度 ~2ms(纯 CPU) ~10ms(OpenVINO CPU)/ ~3ms(GPU)
精度 依赖参数调优 依赖训练数据质量
鲁棒性 对光照敏感 对光照更鲁棒
可解释性 高(每步可调试) 低(黑箱)
维护成本 需要手动调参 需要标注数据 + 训练
适配新场景 改阈值即可 可能需要重新训练

7.10.2 如何选择

该用哪种方案?

  • 起步阶段:建议先用传统方法,理解整个检测流程
  • 比赛阶段:如果调参困难或比赛环境多变,切换到 YOLO
  • 折中方案:用 YOLO 做检测 + 传统方法做位姿解算(YOLO 输出角点,PnP 解位姿)。仓库代码已经支持这种混合模式——solvePnP() 优先使用 armor.points(YOLO 角点),否则回退到灯条端点。

7.11 参数调优指南

传统检测方法的参数需要根据实际硬件和比赛环境进行调优。以下是关键参数的调优建议。

7.11.1 核心参数

```cpp // 对应代码位置: preprocessImage() cv::threshold(gray_img, binary_img, binary_thres, 255, cv::THRESH_BINARY);

```

| 值 | 效果 |
|----|------|
| **过低(< 50)** | 背景噪声被纳入,轮廓变多,误检率上升 |
| **适中(60-100)** | 灯条清晰,背景干净 |
| **过高(> 120)** | 灯条边缘被截断,灯条变小甚至消失 |

**建议**:从 80 开始,查看二值化图像,微调到灯条完整且背景干净。

```cpp // 对应代码位置: isLight() float ratio = light.width / light.length; bool ratio_ok = l.min_ratio < ratio && ratio < l.max_ratio;

```

| 参数 | 默认值 | 说明 |
|------|--------|------|
| `min_ratio` | 0.1 | 排除过窄的噪声线 |
| `max_ratio` | 0.4 | 排除过宽的反光区域 |

**建议**:近距离时灯条看起来更宽,可适当增大 `max_ratio`。

```cpp // 对应代码位置: isLight() bool angle_ok = light.tilt_angle < l.max_angle;

```

| 值 | 适用场景 |
|----|---------|
| **30** | 只检测接近竖直的灯条(严格) |
| **40** | 允许一定倾斜(默认) |
| **50** | 宽松,适合机器人高速旋转时 |

```cpp // 对应代码位置: isArmor() float light_length_ratio = min / max; bool light_ratio_ok = light_length_ratio > a.min_light_ratio;

```

**建议**:如果经常出现漏配对,可以放宽到 0.7;如果出现误配对,收紧到 0.9。

```cpp // 对应代码位置: classify() if (armor.confidence < threshold) return true;

```

| 值 | 效果 |
|----|------|
| **0.5** | 宽松,召回率高但可能有误识别 |
| **0.8** | 平衡(默认推荐) |
| **0.95** | 严格,只保留高置信度识别 |

```cpp // 对应代码位置: isArmor() float center_distance = cv::norm(light_1.center - light_2.center) / avg_light_length; bool center_distance_ok = (a.min_small_center_distance <= center_distance && center_distance < a.max_small_center_distance) || (a.min_large_center_distance <= center_distance && center_distance < a.max_large_center_distance);

```

| 参数 | 典型值 | 说明 |
|------|--------|------|
| `min_small_center_distance` | 0.8 | 小装甲最小归一化距离 |
| `max_small_center_distance` | 3.2 | 小装甲最大归一化距离 |
| `min_large_center_distance` | 3.2 | 大装甲最小归一化距离 |
| `max_large_center_distance` | 5.5 | 大装甲最大归一化距离 |

**注意**:小装甲的上界和大装甲的下界通常设为相同值(如 3.2),避免中间地带无法匹配。

7.11.2 不同光照条件的调参建议

比赛环境 vs 调试环境

比赛场馆的灯光条件通常与实验室完全不同。务必在比赛前去场馆实测调参

  • binary_thres:适当提高(80 -> 100-120),避免背景也被二值化
  • 灯条角度阈值:强光下反光更多,收紧角度到 35
  • 额外措施:考虑在镜头前加偏振片减少反光
  • binary_thres:适当降低(80 -> 50-60),确保灯条不被截断
  • 灯条宽高比:放宽 max_ratio 到 0.5(弱光下灯条可能显得更宽)
  • 额外措施:增大相机增益或延长曝光时间(注意不要过曝)
  • 使用 自适应阈值 替代固定阈值: ```cpp cv::adaptiveThreshold(gray, binary, 255, cv::ADAPTIVE_THRESH_GAUSSIAN_C, cv::THRESH_BINARY, 11, // 块大小 -2); // 常数偏移

``` - 注意:自适应阈值计算量更大,可能影响帧率

7.11.3 调参工具推荐

  1. ROS2 rqt_reconfigure:仓库使用 ROS2 参数系统,可通过 rqt_reconfigure 实时拖动滑块修改参数。

  2. 调试 Topic:代码中的 debug_lightsdebug_armors 会发布每个灯条和配对的详细信息,配合 rqt_plot 可视化: cpp // 发布的调试数据包含: // debug_lights: center_x, ratio, angle, is_light // debug_armors: type, center_x, light_ratio, center_distance, angle

  3. 录制回放:用 ros2 bag record 录制比赛场景的图像 topic,回放调参。


7.12 完整调用链总结

最后,让我们把所有函数串联起来,看看一次完整的检测是如何发生的:

flowchart TD A["📷 原始图像"] --> B["preprocessImage()
灰度 + 二值化"] B --> C["findLights()
轮廓 + isLight + 颜色"] C --> D["matchLights()
containLight + isArmor"] D --> E["extractNumbers()
透视变换 + OTSU"] E --> F["classify()
DNN 推理 + Softmax + 过滤"] F --> G["solvePnP()
3D位姿解算"] G --> H["✅ 输出: 位置 + 姿态 + 编号"] style A fill:#4a9eff,color:#fff style H fill:#4caf50,color:#fff

数据流总结:

原始图像
    ├── preprocessImage() ──→ binary_img(二值图)
    ├── findLights() ──→ lights_ [Light, Light, ...]
    │       ├── minAreaRect() ──→ RotatedRect
    │       ├── isLight() ──→ 宽高比 + 角度筛选
    │       └── R/B 通道统计 ──→ 颜色判断
    ├── matchLights() ──→ armors_ [Armor, Armor, ...]
    │       ├── containLight() ──→ 中间灯条排除
    │       └── isArmor() ──→ 距离比 + 类型判断
    ├── extractNumbers() ──→ armor.number_img(20x28 二值数字图)
    │       ├── warpPerspective() ──→ 透视变换
    │       └── OTSU 二值化
    ├── classify() ──→ armor.number + armor.confidence
    │       ├── DNN forward() ──→ logits
    │       ├── Softmax ──→ 概率分布
    │       └── 类型一致性过滤
    └── solvePnP() ──→ rvec + tvec(3D 位姿)

    最终输出: 装甲板的 3D 位置 + 姿态 + 编号

本章小结

阶段 函数 核心算法 关键参数
预处理 preprocessImage() 灰度化 + 固定阈值二值化 binary_thres
灯条检测 findLights() findContours + minAreaRect 宽高比、角度
灯条筛选 isLight() 宽高比 + 倾斜角度 min_ratio, max_ratio, max_angle
灯条匹配 matchLights() 双重循环 + 归一化距离比 min_light_ratio, 距离区间
假阳性排除 containLight() bounding box 内灯条检查 -
几何检查 isArmor() 长度比 + 中心距 + 连线角度 min_light_ratio, max_angle
数字提取 extractNumbers() 透视变换 + OTSU 二值化 light_length, roi_size
数字识别 classify() DNN 推理 + Softmax + 类型一致性 threshold, ignore_classes
位姿解算 solvePnP() SOLVEPNP_IPPE 3D 模型点, 相机内参
YOLO 检测 YOLODetector OpenVINO 推理 + NMS conf_threshold, nms_threshold

下一章预告

理解了装甲板检测之后,下一章我们将学习如何将检测结果转换为云台控制指令——自瞄控制器(Aim Controller),包括预测、跟踪和平滑。