第七章 装甲板检测:从像素到位姿¶
本章概要
装甲板检测是 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,是对一根灯条的完整描述。
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,自动拥有center、size、angle等成员。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 是检测流水线的核心输出数据结构,承载了一块装甲板的所有信息。
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:装甲板类型,取值SMALL、LARGE或INVALID。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 颜色常量与类型枚举¶
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 即可运行,延迟稳定在几毫秒级别。下面是完整的检测流程:
灰度 + 二值化"] 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() 串联了整个流水线。这是仓库中的真实代码:
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 真实代码¶
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:单通道二值图,白色区域即为候选灯条区域。
变量含义:
| 变量 | 含义 |
|---|---|
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 整体思路¶
在二值化图像中,灯条表现为白色的细长矩形区域。我们用轮廓检测找到这些区域,然后用旋转矩形拟合,最后用几何条件和颜色统计筛选出真正的灯条。
R/B 通道像素遍历"] E --> F["✅ 灯条"] D -->|否| G["❌ 丢弃"]
7.5.2 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构造函数,从旋转矩形中自动计算top、bottom、length、width、tilt_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.x和rect.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()¶
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:宽高比范围检查。l是LightParams结构体,从参数文件加载。典型值:min_ratio=0.1,max_ratio=0.4。太窄可能是噪声线,太宽可能是反光区域。light.tilt_angle < l.max_angle:倾斜角度检查。灯条应该接近竖直,倾斜角度过大的不是灯条。max_angle典型值为 40 度。- 调试信息填充:将每个候选灯条的
center_x、ratio、angle、is_light记录下来,通过 ROS2 topic 发布。可以用rqt_plot或rqt_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 整体思路¶
中间有其他灯条?"} E -->|是| D E -->|否| F{"isArmor() 几何检查?"} F -->|INVALID| D F -->|SMALL / LARGE| G["✅ 构造 Armor"]
7.6.2 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()检查灯条对的几何关系(长度比、距离比、角度),返回SMALL、LARGE或INVALID。if (type != ArmorType::INVALID):只保留通过几何检查的灯条对。auto armor = Armor(*light_1, *light_2):调用 Armor 构造函数,自动按 x 坐标确定左右灯条,计算装甲板中心。armor.type = type:设置装甲板类型。armors.emplace_back(armor):加入结果列表。
7.6.3 假阳性排除: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 才是正确的配对。
`containLight` 通过检查 A-D 之间的 bounding box 内是否有 B 和 C,从而拒绝 A-D 这个错误配对。
7.6.4 几何检查: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 整体流程¶
(两根灯条)"] --> 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 模型¶
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()¶
这是数字识别最关键的预处理步骤。为什么需要透视变换?因为相机视角可能倾斜,拍摄到的装甲板不是正的矩形。透视变换把它"拉正",方便后续数字识别。
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 个端点。顺序是左灯条底 -> 左灯条顶 -> 右灯条顶 -> 右灯条底,构成一个从左下角开始逆时针的四边形。
- **`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 结构体,供后续分类器使用。
透视变换前后对比:
7.7.4 DNN 推理与 Softmax: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)。减去最大值保证最大的exp为e^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¶
有了装甲板在图像中的 2D 坐标和物理尺寸,我们就可以用 PnP(Perspective-n-Point)算法求解相机到装甲板的 3D 位姿。
7.8.1 原理概述¶
PnP 算法需要两组输入:
- 3D 世界坐标点:装甲板在真实世界中的 4 个角点坐标(米)
- 2D 图像坐标点:这 4 个角点在图像中的像素位置
算法输出:
- 旋转向量
rvec:3x1 向量,描述装甲板相对于相机的旋转(轴-角表示) - 平移向量
tvec:3x1 向量,描述装甲板相对于相机的平移(米)
4 个角点(米)"] --> C["cv::solvePnP
SOLVEPNP_IPPE"] B["2D 图像点
4 个角点(像素)"] --> C C --> D["rvec: 旋转向量"] C --> E["tvec: 平移向量"] E --> F["距离 = norm(tvec)"]
7.8.2 构造函数:定义 3D 模型点¶
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::Mat。const_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() 真实代码¶
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()¶
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 传统方法¶
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 推理代码¶
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 后处理¶
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 调参工具推荐¶
-
ROS2 rqt_reconfigure:仓库使用 ROS2 参数系统,可通过
rqt_reconfigure实时拖动滑块修改参数。 -
调试 Topic:代码中的
debug_lights和debug_armors会发布每个灯条和配对的详细信息,配合rqt_plot可视化:cpp // 发布的调试数据包含: // debug_lights: center_x, ratio, angle, is_light // debug_armors: type, center_x, light_ratio, center_distance, angle -
录制回放:用
ros2 bag record录制比赛场景的图像 topic,回放调参。
7.12 完整调用链总结¶
最后,让我们把所有函数串联起来,看看一次完整的检测是如何发生的:
灰度 + 二值化"] 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),包括预测、跟踪和平滑。