跳转至

OpenCV 图像处理

📍 本章导读

  • 篇:基础篇 | 难度:★★ | 通读:约 55 分钟
  • 前置:ch02 C++ 基础
  • 配套练习:M1 跑起来 · 练习 1.4
  • 读完你能:独立写出灯条检测流水线(灰度→二值化→轮廓→筛选);看懂 detector.cpp 的完整检测代码

基础篇主线:ch01 → ch02 → ch03 → ch04 → ch05(你在这里),学完进入端到端走读

从像素到装甲板:视觉组的必备武器库


5.1 OpenCV 简介

什么是 OpenCV?

OpenCV(Open Source Computer Vision Library)是全球最大的开源计算机视觉库,由 Intel 于 1999 年发起,目前由 Itseez(被 Intel 收购)维护。它提供了 2500+ 个优化过的算法,覆盖图像处理、特征检测、目标跟踪、相机标定等领域。

一句话总结:OpenCV 就是"图像处理的 NumPy"——几乎所有和图像相关的操作,它都帮你封装好了。

为什么视觉组离不开它?

在 RoboMaster 自瞄系统中,OpenCV 贯穿整个视觉流水线:

graph LR A[相机获取图像] --> B[OpenCV: 颜色转换] B --> C[OpenCV: 二值化] C --> D[OpenCV: 轮廓检测] D --> E[OpenCV: 最小外接矩形] E --> F[OpenCV: 几何筛选] F --> G[OpenCV: 透视变换] G --> H[神经网络分类] H --> I[PnP 位姿解算] style B fill:#e3f2fd style C fill:#e3f2fd style D fill:#e3f2fd style E fill:#e3f2fd style F fill:#e3f2fd style G fill:#e3f2fd

你即将学到的每一个函数,都在 detector.cpp 中有对应的实际使用。 学完本章,你就能完整看懂检测器代码。

安装 OpenCV

在 Ubuntu 上安装 OpenCV 开发库(C++ 头文件 + 共享库):

```bash tab="Ubuntu"

Ubuntu 22.04 / 24.04

sudo apt update sudo apt install libopencv-dev

验证安装

pkg-config --modversion opencv4

应输出类似: 4.6.0

```bash tab="macOS"
brew install opencv
# 验证安装
pkg-config --modversion opencv4

关于版本

Ubuntu 22.04 自带 OpenCV 4.5.4,Ubuntu 24.04 自带 4.6.0。不需要从源码编译,除非你特殊需求(比如 CUDA 加速)。apt install 装的版本完全够用。

在 CMakeLists.txt 中引用 OpenCV:

find_package(OpenCV REQUIRED)
target_link_libraries(your_target ${OpenCV_LIBS})

在代码中包含头文件:

#include <opencv2/opencv.hpp>  // 包含所有 OpenCV 模块

模块化的 include

opencv2/opencv.hpp 是一个"万能头文件",包含了所有子模块。实际项目中也可以按需包含:

#include <opencv2/core.hpp>       // Mat, Point, Scalar 等基础类型
#include <opencv2/imgproc.hpp>    // cvtColor, threshold, findContours 等
#include <opencv2/highgui.hpp>    // imshow, waitKey 等
#include <opencv2/dnn.hpp>        // 神经网络推理


5.2 图像基础:像素、通道、颜色空间

cv::Mat 是什么?

cv::Mat 是 OpenCV 的核心数据结构——一个多维矩阵。对于一张图像来说,它就是一个二维矩阵,每个元素就是一个像素。

graph TB subgraph "一张 640x480 的彩色图像" A["cv::Mat img (480行 x 640列 x 3通道)"] end subgraph "矩阵结构示意" B["img(row, col) = [B, G, R]"] B --> C["img(0, 0) = [128, 64, 32] ← 左上角像素"] B --> D["img(239, 319) = [200, 180, 160] ← 中心像素"] end
// 创建一个 640x480 的空白图像(黑色)
cv::Mat img = cv::Mat::zeros(480, 640, CV_8UC3);

// 访问某个像素(第 row 行,第 col 列)
cv::Vec3b pixel = img.at<cv::Vec3b>(row, col);
// pixel[0] = B(蓝色通道)
// pixel[1] = G(绿色通道)
// pixel[2] = R(红色通道)

// 修改像素
img.at<cv::Vec3b>(100, 200) = cv::Vec3b(255, 0, 0);  // 设为纯蓝色

Vec3b 是什么?

cv::Vec3b 就是"3 个 unsigned char 组成的向量"。b 代表 byte(即 unsigned char,范围 0~255)。类似地: - cv::Vec3f = 3 个 float - cv::Vec3d = 3 个 double - cv::Vec2f = 2 个 float(常用于 2D 坐标)

cv::Mat 的关键属性:

属性 含义 代码获取
rows 图像高度(行数) img.rows
cols 图像宽度(列数) img.cols
channels() 通道数 img.channels()
type() 数据类型 img.type()
empty() 是否为空 img.empty()
size() 尺寸 img.size()

BGR vs RGB vs HSV vs GRAY

图像在计算机中有多种"表示方式",叫做颜色空间(Color Space):

颜色空间 通道 用途 每像素大小
BGR 蓝-绿-红 OpenCV 默认格式 3 字节
RGB 红-绿-蓝 显示器、PyTorch 默认 3 字节
HSV 色相-饱和-明度 颜色过滤(如灯条颜色判断) 3 字节
GRAY 灰度(单通道) 二值化、特征检测 1 字节

为什么 OpenCV 默认用 BGR 而不是 RGB?

历史原因。OpenCV 最初用的是当时 Windows 和相机驱动的通用格式 BGR(跟 BMP 文件格式一致)。后来 RGB 变成了主流,但 OpenCV 为了向后兼容,一直没改。

实际影响:当你用 cv::imread 读入图像时,通道顺序是 BGR。如果直接传给 matplotlib 或 PyTorch 显示,颜色会反转(红蓝对调)。需要手动转换:

cv::Mat rgb_img;
cv::cvtColor(img, rgb_img, cv::COLOR_BGR2RGB);

在自瞄系统中:我们几乎全程用 BGR 格式处理,只在送入神经网络前可能转为灰度。不需要纠结 RGB。

读取和显示图像

// 读取图像(默认 BGR 格式)
cv::Mat img = cv::imread("test.jpg");

// 检查是否读取成功
if (img.empty()) {
    std::cerr << "无法读取图像!" << std::endl;
    return -1;
}

// 显示图像
cv::imshow("窗口名", img);
cv::waitKey(0);  // 等待按键,0 = 无限等待

// 保存图像
cv::imwrite("output.jpg", img);

参数解释:

函数 参数 含义
imread(path) path 图像文件路径
imread(path, flag) flag = cv::IMREAD_GRAYSCALE 以灰度模式读取
imshow(name, img) name 窗口标题字符串
waitKey(ms) ms 等待毫秒数,0 = 永久等待
imwrite(path, img) path, img 保存路径和图像数据

5.3 图像预处理:灰度转换与二值化

灰度转换:从 3 通道变 1 通道

cv::Mat gray_img;
cv::cvtColor(bgr_img, gray_img, cv::COLOR_BGR2GRAY);

语法:

void cv::cvtColor(InputArray src, OutputArray dst, int code)
参数 含义
src 输入图像(源)
dst 输出图像(目标),函数会自动分配内存
code 颜色转换代码

常用转换代码:

code 含义
cv::COLOR_BGR2GRAY BGR → 灰度
cv::COLOR_BGR2HSV BGR → HSV
cv::COLOR_BGR2RGB BGR → RGB
cv::COLOR_GRAY2BGR 灰度 → BGR

灰度转换的数学原理:

\[Gray = 0.114 \times B + 0.587 \times G + 0.299 \times R\]

为什么系数不同?

人眼对绿色最敏感、红色次之、蓝色最弱。这些系数反映了人眼的感知特性(亮度感知模型)。但这不是我们需要关心的——OpenCV 帮你算好了。

在检测器中的实际代码(来自 detector.cpp):

tasks/auto_aim/detector.cpp — Detector::detect()
// 彩色图转灰度图
cv::Mat gray_img;
cv::cvtColor(bgr_img, gray_img, cv::COLOR_BGR2GRAY);

在代码走读时注意这个模式

在 detector.cpp 中,灰度转换 + 二值化 总是成对出现。这是整个检测流水线的第一步。相机拿到的 BGR 图像有 3 个通道,但灯条检测只需要亮度信息(灯条比背景亮得多),所以转灰度后操作更快、更简单。

二值化:把世界变成黑白

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

语法:

double cv::threshold(InputArray src, OutputArray dst,
                     double thresh, double maxval, int type)
参数 含义
src 输入灰度图像(必须是单通道)
dst 输出二值图像
thresh 阈值(比如 80)
maxval 超过阈值后赋的值(一般设 255,即白色)
type 二值化类型
返回值 实际使用的阈值(对于 OTSU 等方法会自动计算)

二值化类型:

类型 规则 图示
THRESH_BINARY 像素 > 阈值 → 255,否则 → 0 亮的变白,暗的变黑
THRESH_BINARY_INV 像素 > 阈值 → 0,否则 → 255 反转
THRESH_OTSU 自动计算最优阈值 常与 BINARY 组合

二值化的效果:

graph LR subgraph "灰度图 (0~255)" A["像素值: 20, 45, 80, 120, 200, 240"] end A -- "阈值=80" --> B["二值化后: 0, 0, 0, 255, 255, 255"] B --> C["纯黑白图像:灯条是白色区域"]

在检测器中的实际代码(来自 detector.cpp):

tasks/auto_aim/detector.cpp — Detector::detect()
// 进行二值化
cv::Mat binary_img;
cv::threshold(gray_img, binary_img, threshold_, 255, cv::THRESH_BINARY);

注意:这里的阈值 threshold_ 不是硬编码的,而是从 YAML 配置文件中读取的:

tasks/auto_aim/detector.cpp — Detector 构造函数
threshold_ = yaml["threshold"].as<double>();

为什么用灰度 + 二值化处理灯条?

关键原因:工业相机的动态范围有限。灯条 LED 发光区域在图像上非常亮(尤其是中心区域),而周围的装甲板、车身则较暗。二值化能把"亮的灯条"和"暗的背景"彻底分开。

过曝问题:灯条中心太亮,R、G、B 三个通道都会饱和到 255,所以你看到的颜色发白(R=G=B=255)。这也是为什么颜色判断不是在二值化后做,而是在轮廓上做——轮廓区域包含灯条边缘(未过曝的部分),那里的 B/R 比值才有参考意义。

OTSU 自动阈值

如果你不确定阈值该设多少,可以用 OTSU 算法自动计算:

// OTSU 自动确定最佳阈值(后面加 | THRESH_OTSU)
double otsu_thresh = cv::threshold(gray_img, binary_img, 0, 255,
                                   cv::THRESH_BINARY | cv::THRESH_OTSU);
// otsu_thresh 就是自动算出的最优阈值
std::cout << "OTSU 自动阈值: " << otsu_thresh << std::endl;

什么时候用 OTSU?

OTSU 假设图像的灰度分布是双峰的(一个峰是背景,一个峰是目标)。灯条场景正好符合——暗背景一个峰,亮灯条一个峰。但比赛中灯光环境变化大,固定阈值更稳定可调,所以实际代码一般用配置文件中的固定值。


5.4 轮廓检测:找到灯条的位置

findContours:从二值图中提取轮廓

这是整个自瞄检测器最核心的一步!二值化之后,图像中的白色区域就是"候选灯条",findContours 负责把这些白色区域的边界提取出来。

std::vector<std::vector<cv::Point>> contours;
cv::findContours(binary_img, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_NONE);

语法:

void cv::findContours(InputArray image,
                      OutputArrayOfArrays contours,
                      int mode,
                      int method)
参数 含义
image 输入的二值图像(必须是单通道、8位,白色为前景)
contours 输出的轮廓集合。每个轮廓是 vector<cv::Point>,即一组边界点坐标
mode 轮廓检索模式
method 轮廓近似方法

mode(检索模式):

模式 含义
RETR_EXTERNAL 只检测最外层轮廓(忽略嵌套)
RETR_LIST 检测所有轮廓,不建立层级关系
RETR_TREE 检测所有轮廓,建立完整的层级树

method(近似方法):

方法 含义
CHAIN_APPROX_NONE 保存轮廓上的所有点(最精确)
CHAIN_APPROX_SIMPLE 只保存关键端点(如矩形只存 4 个角点,更省内存)

输出数据结构:

contours = [
    [Point(10,20), Point(11,21), Point(12,22), ...],   // 轮廓 0:有 N 个点
    [Point(100,200), Point(101,201), ...],              // 轮廓 1:有 M 个点
    ...
]

在检测器中的实际代码(来自 detector.cpp):

tasks/auto_aim/detector.cpp — Detector::detect()
// 获取轮廓点
std::vector<std::vector<cv::Point>> contours;
cv::findContours(binary_img, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_NONE);

为什么用 RETR_EXTERNAL?

灯条在二值化后是独立的白色区域,不存在嵌套关系。用 RETR_EXTERNAL 只提取最外层轮廓,避免内部噪声(比如灯条上的反光点)被误识别为独立轮廓。如果用 RETR_TREE,可能会提取出很多无用的内层轮廓,增加后续处理负担。

为什么用 CHAIN_APPROX_NONE?

保存所有轮廓点,是为了后面做 minAreaRect 和颜色统计时有更精确的数据。虽然内存开销大一点,但现代工控机内存完全够用。CHAIN_APPROX_SIMPLE 虽然省空间,但丢失了中间点,可能导致后续的面积计算和颜色统计不准确。

minAreaRect:获取最小外接旋转矩形

找到轮廓之后,我们需要一个紧凑的几何描述——灯条是长条形的,用旋转矩形(RotatedRect)来描述最合适。

cv::RotatedRect rotated_rect = cv::minAreaRect(contour);

语法:

RotatedRect cv::minAreaRect(InputArray points)
参数 含义
points 输入的点集(可以是轮廓、点数组等)
返回值 cv::RotatedRect 对象

RotatedRect 的成员:

成员 类型 含义
center cv::Point2f 矩形中心点
size cv::Size2f 矩形的宽和高(size.width, size.height)
angle float 旋转角度(注意:OpenCV 的角度规则很特殊)
points(pts) 方法 获取矩形的 4 个角点

OpenCV 的 RotatedRect.angle 规则

angle 的范围是 [-90, 0)。它表示矩形的一条边与水平轴的夹角。width 不一定是"短边"——OpenCV 会把旋转矩形的某条边定义为 width,但这取决于角度。

在自瞄代码中,我们不直接使用 angle,而是用 atan2 自己算角度(见下文),因为自己算的更可靠。

在检测器中的实际代码(来自 detector.cpp):

tasks/auto_aim/detector.cpp — Detector::detect()
// 在 for 循环中遍历每个轮廓
for (const auto & contour : contours) {
    auto rotated_rect = cv::minAreaRect(contour);  // ← 获取旋转矩形
    auto lightbar = Lightbar(rotated_rect, lightbar_id);
    // ...
}

而在 armor.cpp 中,Lightbar 的构造函数对 RotatedRect 做了详细解析:

tasks/auto_aim/armor.cpp — Lightbar::Lightbar()
Lightbar::Lightbar(const cv::RotatedRect & rotated_rect, std::size_t id)
: id(id), rotated_rect(rotated_rect)
{
  // 获取 4 个角点
  std::vector<cv::Point2f> corners(4);
  rotated_rect.points(&corners[0]);

  // 按 Y 坐标排序:Y 小的在上面
  std::sort(corners.begin(), corners.end(), [](const cv::Point2f & a, const cv::Point2f & b) {
    return a.y < b.y;
  });

  // 计算关键几何量
  center = rotated_rect.center;
  top = (corners[0] + corners[1]) / 2;      // 上边中点
  bottom = (corners[2] + corners[3]) / 2;   // 下边中点
  top2bottom = bottom - top;                 // 从上到下的方向向量

  width = cv::norm(corners[0] - corners[1]); // 短边长度
  angle = std::atan2(top2bottom.y, top2bottom.x);  // 自己算角度!
  angle_error = std::abs(angle - CV_PI / 2);       // 与竖直方向的偏差
  length = cv::norm(top2bottom);                    // 长边长度(灯条长度)
  ratio = length / width;                           // 宽高比
}

完整检测流程示意

graph TB A[BGR 图像] --> B["cvtColor → 灰度图"] B --> C["threshold → 二值图"] C --> D["findContours → 轮廓集合"] D --> E["遍历每个轮廓"] E --> F["minAreaRect → 旋转矩形"] F --> G["check_geometry → 筛选灯条"] G --> H["get_color → 红蓝判断"] H --> I["灯条列表"] I --> J["配对灯条 → 装甲板"]

5.5 几何约束:筛选真正的灯条

findContours 会找到很多轮廓,但不是每个都是灯条——噪声、反光、其他物体都可能产生轮廓。我们需要用几何条件过滤掉不符合灯条特征的候选项。

灯条的几何特征

一条真正的灯条应该满足:

条件 含义 为什么
宽高比 ratio 在 2~7 之间 灯条是长条形的 太胖的是噪声,太瘦的是噪点
角度接近竖直 灯条在装甲板上是竖直安装的 允许一定倾斜(机器人运动导致)
长度大于阈值 灯条不能太小 太小的是远处噪点

宽高比计算

float ratio = rect.size.width / rect.size.height;

或者在代码中更常见的写法——用 cv::norm 计算两点距离:

// 灯条的长边(top 到 bottom)和短边(corners 之间)
length = cv::norm(top2bottom);   // 长边
width = cv::norm(corners[0] - corners[1]);  // 短边
ratio = length / width;          // 宽高比 = 长/宽

角度计算

// top2bottom 是从灯条顶端到底端的方向向量
angle = std::atan2(top2bottom.y, top2bottom.x);

// 与竖直方向(90度 = PI/2 弧度)的偏差
angle_error = std::abs(angle - CV_PI / 2);

为什么用 atan2 而不是 RotatedRect.angle?

RotatedRect.angle 的定义规则复杂(范围 [-90, 0),且 width/height 分配不确定)。用 atan2 基于灯条的实际方向向量计算角度,结果唯一、可解释。竖直灯条的角度应该是 \(\frac{\pi}{2}\)(90度),偏差越小越可能是真正的灯条。

check_geometry:在检测器中的实际筛选

tasks/auto_aim/detector.cpp — Detector::check_geometry()
bool Detector::check_geometry(const Lightbar & lightbar) const
{
  auto angle_ok = lightbar.angle_error < max_angle_error_;
  auto ratio_ok = lightbar.ratio > min_lightbar_ratio_
               && lightbar.ratio < max_lightbar_ratio_;
  auto length_ok = lightbar.length > min_lightbar_length_;
  return angle_ok && ratio_ok && length_ok;
}

三个条件同时满足才认为是合法灯条。阈值从配置文件读取:

max_angle_error_ = yaml["max_angle_error"].as<double>() / 57.3;  // degree to rad
min_lightbar_ratio_ = yaml["min_lightbar_ratio"].as<double>();
max_lightbar_ratio_ = yaml["max_lightbar_ratio"].as<double>();
min_lightbar_length_ = yaml["min_lightbar_length"].as<double>();

注意单位转换

YAML 配置文件中角度用度(degree),代码中用弧度(radian)。转换公式:\(rad = deg / 57.3\)(即 \(rad = deg \times \frac{\pi}{180}\))。57.3 ≈ 180/π。

装甲板的几何约束

灯条找到后,需要两两配对组成装甲板。装甲板也有自己的几何约束:

tasks/auto_aim/detector.cpp — Detector::check_geometry(Armor)
bool Detector::check_geometry(const Armor & armor) const
{
  auto ratio_ok = armor.ratio > min_armor_ratio_
               && armor.ratio < max_armor_ratio_;
  auto side_ratio_ok = armor.side_ratio < max_side_ratio_;
  auto rectangular_error_ok = armor.rectangular_error < max_rectangular_error_;
  return ratio_ok && side_ratio_ok && rectangular_error_ok;
}

在 armor.cpp 中,装甲板的几何量计算:

tasks/auto_aim/armor.cpp — Armor 构造函数(部分)
auto left2right = right.center - left.center;   // 左灯条到右灯条的向量
auto width = cv::norm(left2right);               // 两灯条中心距离
auto max_lightbar_length = std::max(left.length, right.length);

ratio = width / max_lightbar_length;             // 装甲板宽高比
side_ratio = max_lightbar_length / min_lightbar_length;  // 两侧灯条长度比

// 矩形度误差:灯条应该垂直于连线方向
auto roll = std::atan2(left2right.y, left2right.x);
auto left_rectangular_error = std::abs(left.angle - roll - CV_PI / 2);
auto right_rectangular_error = std::abs(right.angle - roll - CV_PI / 2);
rectangular_error = std::max(left_rectangular_error, right_rectangular_error);
装甲板几何量 含义 筛选意义
ratio 两灯条中心距 / 灯条长度 小装甲约 2.5,大装甲约 3.0+
side_ratio 左右灯条长度比 应该接近 1.0(两侧对称)
rectangular_error 灯条是否垂直于连线 真实装甲板的灯条应互相平行且垂直于连线

5.6 透视变换:校正倾斜的数字

为什么需要透视变换?

在分类器识别装甲板数字时,数字可能是倾斜的——因为机器人相对相机有角度。神经网络训练时的样本通常是正视图,如果输入倾斜的数字,识别率会下降。

解决方案:用透视变换把倾斜的四边形校正为正视的矩形。

graph LR subgraph "倾斜的装甲板区域" A["四边形 (不规则)"] end A --> B["getPerspectiveTransform"] B --> C["warpPerspective"] C --> subgraph "校正后的数字图案" D["矩形 (正视)"] end

getPerspectiveTransform:计算变换矩阵

// src: 原图中的 4 个点(倾斜的四边形)
// dst: 目标图中的 4 个点(正视的矩形)
cv::Mat M = cv::getPerspectiveTransform(src, dst);

语法:

Mat cv::getPerspectiveTransform(InputArray src, InputArray dst)
参数 含义
src 源图像中的 4 个点(vector<Point2f>,共 4 个)
dst 目标图像中的 4 个点(vector<Point2f>,共 4 个)
返回值 3x3 的透视变换矩阵 M

数学原理

透视变换求解的是一个 3x3 矩阵 \(M\),使得: $\(\begin{bmatrix} x' \\ y' \\ w \end{bmatrix} = M \times \begin{bmatrix} x \\ y \\ 1 \end{bmatrix}\)$ 其中 \((x', y') = (x'/w, y'/w)\) 是变换后的坐标。4 个点对应可以提供 8 个方程,刚好解出 \(M\) 的 8 个自由度(第 9 个固定为 1)。

warpPerspective:应用变换

cv::Mat dst_img;
cv::warpPerspective(src_img, dst_img, M, cv::Size(width, height));

语法:

void cv::warpPerspective(InputArray src, OutputArray dst,
                         InputArray M, Size dsize)
参数 含义
src 输入图像
dst 输出图像
M 3x3 透视变换矩阵(由 getPerspectiveTransform 返回)
dsize 输出图像的尺寸 Size(width, height)

在检测器中的应用:裁剪装甲板数字区域

在 detector.cpp 中,虽然不直接调用 warpPerspective(而是用 ROI 裁剪),但 get_pattern 函数实现了类似的功能——根据灯条位置计算出装甲板区域并裁剪:

tasks/auto_aim/detector.cpp — Detector::get_pattern()
cv::Mat Detector::get_pattern(const cv::Mat & bgr_img, const Armor & armor) const
{
  // 延长灯条获得装甲板角点
  // 1.125 ≈ armor_half_width / lightbar_length = 67.5mm / 55mm ≈ 1.227 (近似值)
  auto tl = armor.left.center - armor.left.top2bottom * 1.125;
  auto bl = armor.left.center + armor.left.top2bottom * 1.125;
  auto tr = armor.right.center - armor.right.top2bottom * 1.125;
  auto br = armor.right.center + armor.right.top2bottom * 1.125;

  // 计算包围盒 ROI
  auto roi_left = std::max<int>(std::min(tl.x, bl.x), 0);
  auto roi_top = std::max<int>(std::min(tl.y, tr.y), 0);
  auto roi_right = std::min<int>(std::max(tr.x, br.x), bgr_img.cols);
  auto roi_bottom = std::min<int>(std::max(bl.y, br.y), bgr_img.rows);
  auto roi = cv::Rect(cv::Point(roi_left, roi_top), cv::Point(roi_right, roi_bottom));

  return bgr_img(roi);  // 用 Rect 裁剪图像
}

然后在分类器 classifier.cpp 中,裁剪出的 pattern 被转为灰度、缩放到 32x32,送入神经网络:

tasks/auto_aim/classifier.cpp — Classifier::classify()
cv::Mat gray;
cv::cvtColor(armor.pattern, gray, cv::COLOR_BGR2GRAY);

auto input = cv::Mat(32, 32, CV_8UC1, cv::Scalar(0));
auto x_scale = static_cast<double>(32) / gray.cols;
auto y_scale = static_cast<double>(32) / gray.rows;
auto scale = std::min(x_scale, y_scale);
auto h = static_cast<int>(gray.rows * scale);
auto w = static_cast<int>(gray.cols * scale);

auto roi = cv::Rect(0, 0, w, h);
cv::resize(gray, input(roi), {w, h});

什么时候用 warpPerspective vs ROI 裁剪?

  • ROI 裁剪(Rect 裁剪):速度快,适用于装甲板倾斜不大的情况。代码简单,是当前方案的做法。
  • warpPerspective:可以校正大角度倾斜,但计算量更大。如果你需要把倾斜 30 度以上的数字校正为正视图,就需要透视变换。
  • 很多参赛队伍会在数字识别前用透视变换把装甲板数字校正为正视图,以提高神经网络的识别率。

完整透视变换示例

// 假设装甲板四个角点(从图像中检测到)
std::vector<cv::Point2f> src_pts = {
    cv::Point2f(100, 50),   // 左上
    cv::Point2f(200, 40),   // 右上
    cv::Point2f(210, 150),  // 右下
    cv::Point2f(90, 160)    // 左下
};

// 目标矩形(正视图)
std::vector<cv::Point2f> dst_pts = {
    cv::Point2f(0, 0),
    cv::Point2f(100, 0),
    cv::Point2f(100, 100),
    cv::Point2f(0, 100)
};

// 计算变换矩阵
cv::Mat M = cv::getPerspectiveTransform(src_pts, dst_pts);

// 应用变换
cv::Mat warped;
cv::warpPerspective(bgr_img, warped, M, cv::Size(100, 100));
// warped 现在是正视的 100x100 灰度图

5.7 形态学操作:清理噪声、连接断裂

为什么需要形态学操作?

二值化后的图像往往不完美:

  • 噪声:一些小的白色噪点被误保留
  • 断裂:灯条区域可能有小的黑色间隙

形态学操作可以"修补"这些问题。

腐蚀(Erode):去掉小噪点

cv::Mat eroded;
cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(3, 3));
cv::erode(binary_img, eroded, kernel);

原理:用一个小矩形(kernel)在图像上滑动,只有当 kernel 覆盖的区域全部是白色时,中心像素才保留白色。效果:白色区域缩小,小的白色噪点被消除。

膨胀(Dilate):填补小空洞

cv::Mat dilated;
cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(3, 3));
cv::dilate(binary_img, dilated, kernel);

原理:只要 kernel 覆盖的区域有任何白色,中心像素就变白色。效果:白色区域扩大,小的黑色空洞被填补。

开运算 = 先腐蚀再膨胀

cv::Mat opened;
cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(3, 3));
cv::morphologyEx(binary_img, opened, cv::MORPH_OPEN, kernel);

效果:去掉小的白色噪点,同时保持大目标的形状基本不变。适合去噪。

闭运算 = 先膨胀再腐蚀

cv::Mat closed;
cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(3, 3));
cv::morphologyEx(binary_img, closed, cv::MORPH_CLOSE, kernel);

效果:填补小的黑色空洞,同时保持大目标的形状基本不变。适合连接断裂区域。

四种操作对比:

graph TB A["原始二值图
有噪点 + 有断裂"] --> B["腐蚀 → 白区缩小 → 噪点消失"] A --> C["膨胀 → 白区扩大 → 空洞填补"] B --> D["开运算 = 腐蚀+膨胀
去噪,保持形状"] C --> E["闭运算 = 膨胀+腐蚀
填补,保持形状"]

getStructuringElement 参数:

cv::Mat kernel = cv::getStructuringElement(shape, ksize);
shape 含义
MORPH_RECT 矩形核(最常用)
MORPH_ELLIPSE 椭圆形核
MORPH_CROSS 十字形核

ksize 是核的大小,如 cv::Size(3, 3) 或 cv::Size(5, 5)。越大效果越强。

在自瞄系统中的应用

在实际的 sp_vision 代码中,detector.cpp 的二值化结果比较干净(因为工业相机 + 固定阈值),所以没有显式使用形态学操作。但当你在不同光照条件下调试时,形态学操作是非常有用的预处理手段。特别是: - 开运算:去除阈值化后的细小噪点(比如墙面反光) - 闭运算:连接灯条中间可能断裂的区域(比如灯条被螺丝遮挡)


5.8 绘图和可视化:调试时的"眼睛"

为什么要学绘图?

调试视觉算法时,你需要在图像上画出检测结果,直观地看到算法"看到了什么"。这是调试的核心手段。

cv::rectangle:画矩形

cv::rectangle(img, pt1, pt2, color, thickness);

参数:

参数 含义
img 目标图像(会被修改)
pt1 矩形左上角
pt2 矩形右下角
color 颜色,BGR 格式,如 cv::Scalar(0, 255, 0) = 绿色
thickness 线宽,-1 = 填充
// 在图像上画一个绿色矩形
cv::rectangle(img, cv::Point(100, 50), cv::Point(200, 150),
              cv::Scalar(0, 255, 0), 2);  // 绿色,线宽 2

cv::circle:画圆

cv::circle(img, center, radius, color, thickness);
// 在中心画一个红色实心圆
cv::circle(img, cv::Point(320, 240), 5, cv::Scalar(0, 0, 255), -1);
// -1 表示填充(实心)

cv::putText:写文字

cv::putText(img, text, org, fontFace, fontScale, color, thickness);

参数:

参数 含义
img 目标图像
text 要写的文字(std::string)
org 文字左下角坐标
fontFace 字体,一般用 cv::FONT_HERSHEY_SIMPLEX
fontScale 字体大小(1.0 = 标准大小)
color 颜色
thickness 线宽
// 在图像左上角写帧号
cv::putText(img, "Frame: 42", cv::Point(10, 30),
            cv::FONT_HERSHEY_SIMPLEX, 1.0, cv::Scalar(255, 255, 255), 2);

cv::line:画线

cv::line(img, pt1, pt2, color, thickness);
// 画一条黄色的对角线
cv::line(img, cv::Point(0, 0), cv::Point(640, 480),
         cv::Scalar(0, 255, 255), 1);

drawContours:画轮廓

cv::drawContours(img, contours, contourIdx, color, thickness);
参数 含义
contours 轮廓集合
contourIdx 要画的轮廓索引,-1 = 画全部
color 颜色
thickness 线宽

在检测器中的实际调试可视化

detector.cpp 中有一个专门的 show_result 函数用于调试:

tasks/auto_aim/detector.cpp — Detector::show_result()
void Detector::show_result(
  const cv::Mat & binary_img, const cv::Mat & bgr_img,
  const std::list<Lightbar> & lightbars,
  const std::list<Armor> & armors, int frame_count) const
{
  auto detection = bgr_img.clone();  // 克隆原图,避免修改原图

  // 画帧号
  tools::draw_text(detection, fmt::format("[{}]", frame_count),
                   {10, 30}, {255, 255, 255});

  // 画每个灯条
  for (const auto & lightbar : lightbars) {
    auto info = fmt::format("{:.1f} {:.1f} {:.1f} {}",
        lightbar.angle_error * 57.3,  // 角度误差(度)
        lightbar.ratio,               // 宽高比
        lightbar.length,              // 长度
        COLORS[lightbar.color]);      // 颜色名称
    tools::draw_text(detection, info, lightbar.top, {0, 255, 255});    // 黄色文字
    tools::draw_points(detection, lightbar.points, {0, 255, 255}, 3);  // 黄色轮廓
  }

  // 画每个装甲板
  for (const auto & armor : armors) {
    auto info = fmt::format("{:.2f} {:.2f} {:.1f} {:.2f} {} {}",
        armor.ratio, armor.side_ratio,
        armor.rectangular_error * 57.3, armor.confidence,
        ARMOR_NAMES[armor.name], ARMOR_TYPES[armor.type]);
    tools::draw_points(detection, armor.points, {0, 255, 0});       // 绿色轮廓
    tools::draw_text(detection, info, armor.left.bottom, {0, 255, 0}); // 绿色文字
  }

  // 缩小显示(节省屏幕空间)
  cv::Mat binary_img2;
  cv::resize(binary_img, binary_img2, {}, 0.5, 0.5);
  cv::resize(detection, detection, {}, 0.5, 0.5);

  cv::imshow("detection", detection);
}

对应的辅助函数 img_tools.cpp:

tools/img_tools.cpp
void draw_point(cv::Mat & img, const cv::Point & point,
                const cv::Scalar & color, int radius)
{
  cv::circle(img, point, radius, color, -1);  // 实心圆
}

void draw_points(cv::Mat & img, const std::vector<cv::Point2f> & points,
                 const cv::Scalar & color, int thickness)
{
  std::vector<cv::Point> int_points(points.begin(), points.end());
  std::vector<std::vector<cv::Point>> contours = {int_points};
  cv::drawContours(img, contours, -1, color, thickness);  // 画轮廓
}

void draw_text(cv::Mat & img, const std::string & text,
               const cv::Point & point, const cv::Scalar & color,
               double font_scale, int thickness)
{
  cv::putText(img, text, point, cv::FONT_HERSHEY_SIMPLEX,
              font_scale, color, thickness);
}

调试可视化是你的救命稻草

在实际比赛中,你不可能盯着终端输出的数字来判断算法效果。把检测结果画在图像上才是最直观的调试方式。建议在开发阶段保留 show_result,上线时通过 debug_ 开关关闭。颜色约定: - 黄色:灯条候选 - 绿色:确认的装甲板 - 红色:被过滤掉的目标(如果需要的话) - 白色:帧信息


5.9 图像算术:红蓝灯条颜色判断

为什么颜色判断如此重要?

RoboMaster 比赛中,己方和敌方的灯条颜色不同(红色或蓝色)。算法必须能正确判断灯条颜色,才能只攻击敌方。

通道分离

std::vector<cv::Mat> channels;
cv::split(bgr_img, channels);
// channels[0] = B 通道(蓝色)
// channels[1] = G 通道(绿色通道)
// channels[2] = R 通道(红色通道)

语法:

void cv::split(InputArray m, OutputArrayOfArrays mv)
参数 含义
m 输入的多通道图像
mv 输出的单通道图像数组(vector 或数组)

反过来,合并通道用 cv::merge:

cv::Mat merged;
cv::merge(channels, merged);

位运算

OpenCV 提供了图像级别的位运算,常用于掩码(mask)操作:

cv::bitwise_and(img1, img2, dst);   // 位与
cv::bitwise_or(img1, img2, dst);    // 位或
cv::bitwise_xor(img1, img2, dst);   // 位异或
cv::bitwise_not(img, dst);          // 位取反

典型用法——用掩码提取感兴趣区域:

// 创建一个圆形掩码
cv::Mat mask = cv::Mat::zeros(img.size(), CV_8UC1);
cv::circle(mask, cv::Point(320, 240), 100, cv::Scalar(255), -1);

// 用掩码提取圆形区域
cv::Mat result;
cv::bitwise_and(img, img, result, mask);
// result 中只有圆形区域有内容,其余为黑色

R 通道和 B 通道求和比较:判断灯条颜色

这是自瞄系统中颜色判断的核心逻辑!

在 detector.cpp 中:

tasks/auto_aim/detector.cpp — Detector::get_color()
Color Detector::get_color(const cv::Mat & bgr_img,
                          const std::vector<cv::Point> & contour) const
{
  int red_sum = 0, blue_sum = 0;

  for (const auto & point : contour) {
    red_sum += bgr_img.at<cv::Vec3b>(point)[2];   // [2] = R 通道
    blue_sum += bgr_img.at<cv::Vec3b>(point)[0];  // [0] = B 通道
  }

  return blue_sum > red_sum ? Color::blue : Color::red;
}

逻辑解释:

  1. 遍历轮廓上的所有像素点
  2. 累加每个像素的 R 通道值和 B 通道值
  3. 比较总和:blue_sum > red_sum → 蓝色灯条,否则红色

为什么要在轮廓上统计而不是整张图?

因为整张图包含大量背景像素,会干扰判断。轮廓区域就是灯条的边界,包含灯条边缘像素(未过曝的区域),那里的 B/R 比值才反映真实颜色。

过曝问题再强调

灯条中心太亮,三个通道都会饱和到 255(白色),R=B=G=255,无法区分颜色。所以颜色判断要在轮廓边界上做,那里亮度适中,B/R 比值有意义。

另一种常见方法:分离通道后分别求和

std::vector<cv::Mat> channels;
cv::split(bgr_img, channels);

// 对灯条区域内的 R 通道和 B 通道分别求均值
cv::Mat lightbar_mask = ...;  // 灯条区域掩码
double red_mean = cv::mean(channels[2], lightbar_mask)[0];
double blue_mean = cv::mean(channels[0], lightbar_mask)[0];

if (blue_mean > red_mean) {
    // 蓝色灯条
}

图像加减运算

cv::Mat result;
cv::add(img1, img2, result);        // 像素相加(饱和运算,超过255截断为255)
cv::subtract(img1, img2, result);   // 像素相减
cv::absdiff(img1, img2, result);    // 绝对差值

实际应用场景——帧差法运动检测:

// 两帧之间的差异
cv::Mat diff;
cv::absdiff(frame1, frame2, diff);

// 转灰度、二值化,找出运动区域
cv::Mat gray_diff;
cv::cvtColor(diff, gray_diff, cv::COLOR_BGR2GRAY);
cv::threshold(gray_diff, motion_mask, 30, 255, cv::THRESH_BINARY);

5.10 检测器完整流程:从图像到装甲板

把上面所有知识点串起来,看 detector.cpp 中 Detector::detect() 的完整流程:

graph TB START["输入: BGR 图像"] --> STEP1["1. cvtColor → 灰度图"] STEP1 --> STEP2["2. threshold → 二值图"] STEP2 --> STEP3["3. findContours → 轮廓集合"] STEP3 --> LOOP1["4. 遍历每个轮廓"] LOOP1 --> LOOP1A["4a. minAreaRect → 旋转矩形"] LOOP1A --> LOOP1B["4b. check_geometry → 灯条几何筛选"] LOOP1B --> LOOP1C["4c. get_color → 红蓝判断"] LOOP1C --> LOOP1D["4d. 加入灯条列表"] LOOP1D --> LOOP1 LOOP1 --> SORT["5. 灯条按 X 坐标排序"] SORT --> LOOP2["6. 双重循环配对灯条"] LOOP2 --> LOOP2A["6a. 颜色相同?"] LOOP2A --> LOOP2B["6b. check_geometry → 装甲板几何筛选"] LOOP2B --> LOOP2C["6c. get_pattern → 裁剪数字区域"] LOOP2C --> LOOP2D["6d. classify → 神经网络识别"] LOOP2D --> LOOP2E["6e. check_name → 置信度筛选"] LOOP2E --> LOOP2F["6f. get_type → 大小装甲判断"] LOOP2F --> LOOP2 LOOP2 --> DEDUP["7. 去除重叠装甲板"] DEDUP --> SHOW["8. show_result → 调试可视化"] SHOW --> OUTPUT["输出: 装甲板列表"]

对应的核心代码骨架:

tasks/auto_aim/detector.cpp — Detector::detect() 骨架
std::list<Armor> Detector::detect(const cv::Mat & bgr_img, int frame_count)
{
  // ====== 第一步:预处理 ======
  cv::Mat gray_img;
  cv::cvtColor(bgr_img, gray_img, cv::COLOR_BGR2GRAY);    // 灰度转换

  cv::Mat binary_img;
  cv::threshold(gray_img, binary_img, threshold_, 255,
                cv::THRESH_BINARY);                        // 二值化

  // ====== 第二步:轮廓检测 ======
  std::vector<std::vector<cv::Point>> contours;
  cv::findContours(binary_img, contours, cv::RETR_EXTERNAL,
                   cv::CHAIN_APPROX_NONE);                 // 提取轮廓

  // ====== 第三步:灯条检测 ======
  std::list<Lightbar> lightbars;
  for (const auto & contour : contours) {
    auto rotated_rect = cv::minAreaRect(contour);          // 最小外接矩形
    auto lightbar = Lightbar(rotated_rect, lightbar_id);   // 创建灯条对象

    if (!check_geometry(lightbar)) continue;               // 几何筛选

    lightbar.color = get_color(bgr_img, contour);          // 颜色判断
    lightbars.emplace_back(lightbar);
  }

  // ====== 第四步:灯条配对 → 装甲板 ======
  lightbars.sort([](auto & a, auto & b) { return a.center.x < b.center.x; });

  std::list<Armor> armors;
  for (auto left = lightbars.begin(); left != lightbars.end(); left++) {
    for (auto right = std::next(left); right != lightbars.end(); right++) {
      if (left->color != right->color) continue;          // 颜色必须相同

      auto armor = Armor(*left, *right);                   // 创建装甲板
      if (!check_geometry(armor)) continue;                // 装甲板几何筛选

      armor.pattern = get_pattern(bgr_img, armor);         // 裁剪数字图案
      classifier_.classify(armor);                         // 神经网络分类
      if (!check_name(armor)) continue;                    // 置信度筛选

      armor.type = get_type(armor);                        // 判断大小装甲
      armors.emplace_back(armor);
    }
  }

  // ====== 第五步:去重 + 可视化 ======
  // ... 去除共用灯条的重叠装甲板 ...

  if (debug_) show_result(binary_img, bgr_img, lightbars, armors, frame_count);
  return armors;
}

本章总结

函数 作用 在检测器中的位置
cv::cvtColor 颜色空间转换(BGR→灰度) 检测流水线第一步
cv::threshold 二值化(分离灯条和背景) 紧跟灰度转换
cv::findContours 提取轮廓(找到候选灯条) 二值化之后
cv::minAreaRect 最小外接旋转矩形 对每个轮廓计算
cv::split 通道分离 颜色判断(间接使用)
cv::rectangle 画矩形 调试可视化
cv::circle 画圆 调试可视化
cv::putText 写文字 调试可视化
cv::drawContours 画轮廓 调试可视化
cv::erode/dilate 形态学操作 预处理(按需使用)
cv::getPerspectiveTransform 透视变换矩阵 数字校正(进阶)
cv::warpPerspective 应用透视变换 数字校正(进阶)

练习

动手实践

准备工作

创建项目目录和 CMakeLists.txt:

mkdir -p ~/cv_practice/src ~/cv_practice/build

CMakeLists.txt:

cmake_minimum_required(VERSION 3.10)
project(cv_practice)
find_package(OpenCV REQUIRED)
add_executable(detect src/detect.cpp)
target_link_libraries(detect ${OpenCV_LIBS})

src/detect.cpp 模板:

#include <opencv2/opencv.hpp>
#include <iostream>

int main() {
    // TODO: 读取图像
    cv::Mat img = cv::imread("test.jpg");
    if (img.empty()) {
        std::cerr << "无法读取图像!" << std::endl;
        return -1;
    }

    // TODO: 你的代码写在这里

    cv::imshow("result", img);
    cv::waitKey(0);
    return 0;
}

测试图像

可以使用 sp_vision_25 仓库的演示视频:sp_vision_25/assets/demo/demo.avi 或自己用手机拍一张含红色/蓝色 LED 灯条的照片。


练习 1:写一个程序,读取一张含灯条的图像,依次执行灰度转换、二值化、轮廓检测,并用 imshow 显示每一步的结果。

预期结果: 灰度图中灯条为亮白色,二值化后灯条区域为白色(255)其余为黑色(0),轮廓检测后灯条被绿色矩形框包围。

练习 2:在练习 1 的基础上,对每个轮廓用 minAreaRect 获取旋转矩形,计算宽高比和角度,只保留宽高比在 2~7 之间的轮廓,将结果画在原图上。

练习 3:实现 get_color 函数——遍历轮廓上的像素,累加 R 和 B 通道值,判断红蓝。用不同颜色画出红色和蓝色灯条。

练习 4:用 cv::split 分离图像的 B、G、R 三个通道,分别显示为灰度图,观察灯条在哪个通道最亮。

答案: 红色灯条在 R 通道最亮(值接近 255),蓝色灯条在 B 通道最亮。

练习 5:写一个程序实现完整的灯条检测流程(灰度→二值化→轮廓→几何筛选→颜色判断→配对),用绿框画出配对成功的装甲板。

提示

可以分步实现:先完成灰度→二值化→轮廓(练习1-2),再加颜色判断(练习3),最后加配对逻辑。不要试图一次写完全部。


常见问题

findContours 的输入图像必须是二值图吗?

是的,必须是单通道 8 位图像(CV_8UC1)。像素值只有 0(黑)和非零(白)。如果你传入灰度图(有 0~255 的中间值),函数会把所有非零像素都当作前景,结果可能不对。

minAreaRect 返回的 angle 为什么有时候很奇怪?

OpenCV 的 RotatedRect.angle 定义比较复杂:范围是 [-90, 0),且 width 和 height 的分配取决于角度。建议用 atan2 自己算角度(就像 armor.cpp 中那样),更可靠。

二值化的阈值怎么确定?

在实际比赛中,建议先用 cv::imshow 显示二值化结果,手动调整阈值直到灯条被干净地分离出来,然后把这个值写入 YAML 配置文件。不同光照环境下阈值可能不同,所以要用配置文件而不是硬编码。

为什么我的灯条检测有大量误检?

检查以下几点: 1. 二值化阈值是否太低(保留了过多噪声) 2. check_geometry 的条件是否太宽松(放宽了宽高比和角度限制) 3. 图像是否有大面积反光(考虑加形态学开运算去噪) 4. 灯条长度阈值 min_lightbar_length_ 是否太小(远处小噪点被保留)

imshow 不显示窗口怎么办?

在 Ubuntu 上,如果你用 SSH 远程连接,imshow 需要 X11 转发(ssh -X)才能显示窗口。在工控机上通常通过 VNC 或者接显示器查看。如果在无 GUI 环境下,可以用 cv::imwrite 保存图像到文件代替显示。