Files
vision-training-2026-opencvcpp/README.md

2660 lines
112 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 🎯 RoboMaster 视觉组 OpenCV C++ 零基础入门讲义
> **读者对象**:刚刚接触 Linux 仅一节课的大学新生
> **学习时长**:约 3~4 小时(含上机操作)
> **唯一前置要求**:会使用 `cd`、`ls`、`mkdir` 这几个最基本的 Linux 命令
> **你将获得**:从"完全不懂 OpenCV"到"能写出一个简化的 RoboMaster 红色灯条检测程序"的能力
---
## 📖 目录
1. [课前准备:你的第一个任务](#1-课前准备你的第一个任务)
2. [什么是 OpenCV?为什么要学它?](#2-什么是-opencv为什么要学它)
3. [环境准备:在你的电脑上安装 OpenCV](#3-环境准备在你的电脑上安装-opencv)
4. [下载本次课程代码](#4-下载本次课程代码)
5. [课程目录结构说明](#5-课程目录结构说明)
6. [第 1 讲:图像读取与保存](#6-第-1-讲图像读取与保存)
7. [第 2 讲:颜色空间转换](#7-第-2-讲颜色空间转换)
8. [第 3 讲:高斯模糊](#8-第-3-讲高斯模糊)
9. [第 4 讲:Canny 边缘检测](#9-第-4-讲canny-边缘检测)
10. [第 5 讲:图像阈值化](#10-第-5-讲图像阈值化)
11. [第 6 讲:形态学操作——腐蚀与膨胀](#11-第-6-讲形态学操作腐蚀与膨胀)
12. [第 7 讲:颜色提取——inRange(核心!)](#12-第-7-讲颜色提取inrange核心)
13. [第 8 讲:综合流水线——串联一切](#13-第-8-讲综合流水线串联一切)
14. [课堂总结与下一步](#14-课堂总结与下一步)
15. [附录:完整源代码](#15-附录完整源代码)
---
## 1. 课前准备:你的第一个任务
### 1.1 确认你的电脑上已经有 WSL Ubuntu
按下键盘上的 `Win + R`,输入 `wsl`,回车。如果出现一个 Linux 风格的终端(黑底白字,提示符类似 `username@DESKTOP:~$`),说明 WSL 已经装好了。
如果没有,你需要先在 Microsoft Store 搜索并安装 "Ubuntu"。
### 1.2 快速复习:会用到的 Linux 命令
| 命令 | 含义 | 示例 |
|------|------|------|
| `pwd` | 打印当前所在目录(Print Working Directory) | 看自己在哪里 |
| `ls` | 列出当前目录下的文件和文件夹(List) | `ls -l` 看详细信息 |
| `cd 路径` | 切换到指定目录(Change Directory) | `cd /mnt/d/` 进入 D 盘 |
| `mkdir 名字` | 创建新文件夹(Make Directory) | `mkdir my_experiment` |
| `cd ..` | 返回上一级目录 | 回退一步 |
这些命令你在 Linux 第一节课应该已经学过了。如果还不熟练,建议先打开 WSL 终端练习一下。
---
## 2. 什么是 OpenCV?为什么要学它?
### 2.1 从一个生活场景说起
想象你走在校园里,看到前方有一位同学。你的大脑在不到 0.1 秒内就能完成以下分析:
- "那是一个人"(而不是一棵树或一辆车)
- "他穿着红色衣服"(区分颜色)
- "他在朝我走来"(识别运动方向)
- "是张三"(人脸识别)
这一系列"看懂世界"的能力,是人脑天生的。但对计算机来说,摄像头采集到的只是一大堆数字(像素值),它根本不知道"红色衣服"是哪几个数字组成的,更不知道"张三"长什么样。
**计算机视觉(Computer Vision)**这门学科,就是要让计算机从一堆数字中"看懂"图像内容。
### 2.2 OpenCV 是什么
**OpenCV** = **Open** Source **C**omputer **V**ision Library(开源计算机视觉库)
它是由 Intel 最早发起的开源项目,里面包含了**上千个已经写好的图像处理函数**。你不需要从零写一个"如何检测边缘"的算法——直接调用 `cv::Canny()` 函数就行。
### 2.3 RoboMaster 中视觉组做什么?
在 RoboMaster 比赛中,每个机器人装甲板的四个角上有 **LED 灯条**(红色或蓝色)。视觉组的工作流程是:
```
摄像头拍画面 → 找到灯条的颜色 → 确定灯条位置 → 计算三维坐标 → 发给云台瞄准射击
```
今天这堂课要教你的,就是整个流程的**前两步**——如何用 OpenCV 处理图像、提取颜色。这是整个视觉系统的基础。
---
## 3. 环境准备:在你的电脑上安装 OpenCV
### 3.1 打开 WSL 终端
在 Windows 开始菜单搜索 "WSL" 或 "Ubuntu",打开终端。
### 3.2 逐条执行以下命令(不要一次性粘贴!)
```bash
# 第一步:更新软件源列表
# 就像你打开应用商店,先刷新一下有哪些可用的软件
sudo apt update
# 第二步:安装编译工具和 OpenCV
# build-essential : 包含 g++ 编译器(把代码变成可执行文件的工具)
# cmake : 管理编译过程的工具(我们用它来告诉编译器怎么编译)
# libopencv-dev : OpenCV 的 C++ 开发库(头文件 + 函数实现)
sudo apt install -y build-essential cmake libopencv-dev
# 第三步:验证安装
# 如果输出一个版本号(如 4.5.4),就说明安装成功了
pkg-config --modversion opencv4
```
> **Q: `sudo` 是什么?**
> A: `sudo` = Super User DO,意思是"以管理员身份执行"。安装软件需要修改系统文件,普通用户没有权限,所以要加 `sudo`。第一次用会提示你输入密码(输入时**看不见任何字符**,这是 Linux 的安全设计,盲输完按回车就行)。
### 3.3 关于 Windows 和 WSL 的文件互通
这个知识点非常实用,请你**仔细看**:
- **你在 WSL 中的家目录**是 `/home/你的用户名/`,对应 Windows 中的 `\\wsl$\Ubuntu\home\你的用户名\`
- **Windows 的 C 盘**在 WSL 中是 `/mnt/c/`,D 盘是 `/mnt/d/`
- 在 Windows 文件资源管理器地址栏输入 `\\wsl$\Ubuntu` 可以浏览 WSL 的整个文件系统
> **实用技巧**:把 `\\wsl$\Ubuntu` 收藏到资源管理器的"快速访问"栏,后面你会频繁用到它来查看程序输出的图片。
---
## 4. 下载本次课程代码
本次课程的所有代码已经上传到了 Git 仓库。你需要把它下载到你的电脑上。
### 方法一:在 WSL 中用 git clone(推荐)
```bash
# 先进入一个合适的位置(比如你的 WSL 家目录)
cd ~
# 克隆仓库
git clone https://code.tuxmonkey.cn/TuxMonkey/vision-training-2026-opencvcpp.git
# 进入仓库目录
cd vision-training-2026-opencvcpp
# 看看里面有什么
ls -l
```
你会看到类似这样的输出:
```
exp1_read_write/
exp2_color_space/
exp3_gaussian_blur/
exp4_canny/
exp5_threshold/
exp6_morphology/
exp7_inrange/
exp8_pipeline/
images/
README.md
```
> **Q: 如果 WSL 中没有 git 怎么办?**
> A: 运行 `sudo apt install -y git` 安装即可。
### 方法二:用 Windows 下载后放到 WSL 能访问的位置
1. 在浏览器中打开 `https://code.tuxmonkey.cn/TuxMonkey/vision-training-2026-opencvcpp`
2. 点击页面上的"下载"或"Clone"按钮,下载 ZIP 压缩包
3. 解压到 D 盘某个位置(比如 `D:\vision-training-2026-opencvcpp`)
4. 在 WSL 中通过 `/mnt/d/vision-training-2026-opencvcpp` 访问
> **推荐方法一**,因为后续课程更新时你只需 `git pull` 就能同步最新代码。
---
## 5. 课程目录结构说明
```
vision-training-2026-opencvcpp/
│
├── README.md ← 给讲师看的详细教案
├── STUDENT_GUIDE.md ← 你正在看的这份学生讲义
├── .gitignore ← Git 配置文件(不用管)
│
├── images/ ← 示例图片素材
│ ├── exp1_sample.jpg ← 实验 1 用:彩色水果
│ ├── exp2_colorful.jpg ← 实验 2 用:彩色蝴蝶
│ ├── exp3_texture.jpg ← 实验 3 用:有纹理的狒狒
│ ├── exp4_edges.jpg ← 实验 4 用:有清晰边缘的建筑
│ ├── exp5_brightness.jpg ← 实验 5 用:明暗变化的室内
│ ├── exp6_shapes.jpg ← 实验 6 用:简单图形(Linux 企鹅)
│ ├── exp7_red.jpg ← 实验 7 用:红色苹果
│ └── exp8_complex.jpg ← 实验 8 用:复杂场景(棋盘)
│
├── exp1_read_write/ ← 第 1 讲:读取和保存图像
│ ├── CMakeLists.txt ← 编译配置文件
│ └── main.cpp ← C++ 源代码
│
├── exp2_color_space/ ← 第 2 讲:颜色空间转换
│ ├── CMakeLists.txt
│ └── main.cpp
│
├── exp3_gaussian_blur/ ← 第 3 讲:高斯模糊
│ ├── CMakeLists.txt
│ └── main.cpp
│
├── exp4_canny/ ← 第 4 讲:Canny 边缘检测
│ ├── CMakeLists.txt
│ └── main.cpp
│
├── exp5_threshold/ ← 第 5 讲:图像阈值化
│ ├── CMakeLists.txt
│ └── main.cpp
│
├── exp6_morphology/ ← 第 6 讲:形态学操作
│ ├── CMakeLists.txt
│ └── main.cpp
│
├── exp7_inrange/ ← 第 7 讲:颜色提取(核心!)
│ ├── CMakeLists.txt
│ └── main.cpp
│
└── exp8_pipeline/ ← 第 8 讲:综合流水线
├── CMakeLists.txt
└── main.cpp
```
每个实验文件夹都是**独立**的——你可以单独进入任意一个文件夹,编译运行它,不依赖其他实验。
---
## 6. 第 1 讲:图像读取与保存
> **你想解决的问题**:如何让程序"看到"一张图片,然后再"存起来"?
### 6.1 什么是图像?(用初中数学就能理解)
在计算机的世界里,**图像就是一个二维数组(矩阵)**。就像一张坐标纸:
- 纸上有横排(行,row)和竖排(列,col)
- 每个交叉点的小格子叫一个**像素**(pixel)
- 小格子里填的数字代表这个点的颜色
比如一张 640×480 的彩色照片,就是 480 行 × 640 列的矩阵,每个格子里有 **3 个数字**(蓝、绿、红各一个,范围都是 0~255)。
- `0` = 完全没有这个颜色
- `255` = 这个颜色最亮
- 所以 `(0, 0, 255)` 在 BGR 中代表"没有蓝色、没有绿色、红色最亮"= **纯红色**
> **重要**:OpenCV 中的颜色顺序是 **BGR**(蓝、绿、红),不是我们习惯的 RGB!这是历史原因造成的,记住就好。
### 6.2 本次实验用到的函数
| 函数 | 作用 | 比喻 |
|------|------|------|
| `cv::Mat` | 在内存中存储图像 | 棋盘格纸 |
| `cv::imread("文件", 模式)` | 从硬盘读取图片到内存 | 扫描仪扫描照片 |
| `cv::imwrite("文件", 图像)` | 把内存中的图片存到硬盘 | 打印机打印照片 |
| `image.empty()` | 检查图片是否读取成功 | "牛奶买到了吗?" |
### 6.3 跟我一起看代码(逐行讲解)
> 请在 WSL 终端中运行以下命令打开源文件(或直接用 Windows 记事本打开):
> ```bash
> cat exp1_read_write/main.cpp
> ```
下面我们逐段讲解关键代码:
#### (1) 头文件
```cpp
#include <opencv2/opencv.hpp> // OpenCV 的"总头文件",包含了所有常用功能
#include <iostream> // 让你能在终端打印文字(cout、cerr)
```
`#include` 的意思是"把某个文件的内容复制粘贴到这里"。`<opencv2/opencv.hpp>` 这个文件里声明了所有 OpenCV 函数,没有它,编译器就不知道 `cv::imread` 是什么意思。
#### (2) main 函数
```cpp
int main()
```
所有 C++ 程序的入口点。程序启动后,操作系统会调用这个函数。`int` 表示这个函数返回一个整数(`return 0` 表示正常结束,`return -1` 表示异常结束)。
#### (3) 创建一张"画布"
```cpp
cv::Mat canvas(480, 640, CV_8UC3, cv::Scalar(50, 50, 50));
```
这一行干了什么?让我们拆解每个参数:
| 参数 | 值 | 含义 |
|------|-----|------|
| 第 1 个 | `480` | **行数(高度)** = 480 像素 |
| 第 2 个 | `640` | **列数(宽度)** = 640 像素 |
| 第 3 个 | `CV_8UC3` | **像素类型** = 8 位无符号整数(U)、3 个通道(C3) |
| 第 4 个 | `cv::Scalar(50,50,50)` | **初始颜色** = B=50, G=50, R=50(深灰色) |
`CV_8UC3` 拆解:
- `8U` = 8-bit Unsigned(8 位无符号整数,取值范围 0~255)
- `C3` = 3 Channels(3 个颜色通道,即 B、G、R 各一个)
所以每个像素需要 3 个字节存储,整张图需要 480 × 640 × 3 = 921,600 字节 ≈ 0.88 MB。
#### (4) 在画布上画画
```cpp
cv::circle(canvas, cv::Point(150, 120), 80, cv::Scalar(255, 0, 0), -1);
// ↑画布 ↑圆心坐标(x=150,y=120) ↑半径80 ↑颜色(B=255纯蓝) ↑线宽-1=填充
```
`cv::Point(x, y)`:注意 x 是水平方向(第几列),y 是垂直方向(第几行)。
```cpp
cv::rectangle(canvas, cv::Point(100, 280), cv::Point(540, 440),
cv::Scalar(0, 255, 255), 3); // 黄色空心矩形,线宽=3像素
```
`cv::Scalar(B, G, R)`:记住 BGR 顺序——`(0, 255, 255)` = 蓝色 0 + 绿色 255 + 红色 255 = **黄色**(因为绿+红=黄,这是加色法原理)。
#### (5) 保存为文件
```cpp
bool write_ok = cv::imwrite("test_image.jpg", canvas);
```
- 第一个参数 `"test_image.jpg"`:文件名。扩展名 `.jpg` 决定了保存格式(JPEG 压缩)
- 第二个参数 `canvas`:要保存的图像(刚才画了圆和矩形的那个 Mat)
- 返回值 `bool`:`true` = 保存成功,`false` = 失败
#### (6) 读回刚才保存的文件
```cpp
cv::Mat image = cv::imread("test_image.jpg", cv::IMREAD_COLOR);
```
第二个参数 `cv::IMREAD_COLOR`(或数字 `1`)表示"以彩色模式读取"。其他选项:
| 参数 | 数字 | 含义 |
|------|------|------|
| `cv::IMREAD_COLOR` | `1` | 始终读成 3 通道彩色图(默认) |
| `cv::IMREAD_GRAYSCALE` | `0` | 始终读成 1 通道灰度图 |
| `cv::IMREAD_UNCHANGED` | `-1` | 保持原样(含透明通道) |
#### (7) 极其重要的检查
```cpp
if (image.empty())
{
std::cerr << "[错误] 无法读取图像!" << std::endl;
return -1; // -1 表示程序异常退出
}
```
**如果不写这段检查会怎样?** 如果文件不存在,`imread` 不报错,而是返回一个"空"的 Mat。后续任何对空 Mat 的操作都会导致程序**崩溃**。所以**每次 imread 之后必须检查 empty()**——养成这个习惯!
### 6.4 动手编译运行
```bash
# 确保你在仓库根目录
cd ~/vision-training-2026-opencvcpp/exp1_read_write
# 创建编译目录并进入
mkdir -p build && cd build
# 用 cmake 生成编译规则,然后用 make 编译
cmake .. && make -j$(nproc)
# 运行程序
./exp1
```
> **为什么要在 build 目录中操作?**
> 编译会产生很多中间文件(.o、Makefile 等),放在单独的 build 目录中,方便清理(直接 `rm -rf build` 就行),也不会把源代码目录弄乱。
### 6.5 查看结果
程序运行后,在 `build/` 目录下生成了 `test_image.jpg`(程序画的测试图)和 `output.jpg`(读取后再保存的图)。
在 WSL 终端中,用以下命令确认文件存在:
```bash
ls -l *.jpg
```
如何**在 Windows 中看图**?打开文件资源管理器,在地址栏输入:
```
\\wsl$\Ubuntu\home\你的用户名\vision-training-2026-opencvcpp\exp1_read_write\build
```
或者直接在 WSL 中运行:
```bash
explorer.exe .
```
这会在 Windows 资源管理器中打开当前 WSL 目录。
> **提示**:把 `\\wsl$\Ubuntu\home\你的用户名\vision-training-2026-opencvcpp` 收藏到快速访问栏,后面每个实验都能快速查看输出图片。
**运行结果预览**:
| 程序生成的测试图 | 读取后再保存的验证图 |
|---|---|
| ![程序生成的原图(蓝/绿/红圆形 + 黄色矩形 + 文字)](asset/exp1_read_write_test_image.jpg) | ![读取后再保存的验证图](asset/exp1_read_write_output.jpg) |
### 6.6 试试用自己的图片
把任何一张 `.jpg` 图片复制到 `exp1_read_write/build/` 目录下,然后修改 `main.cpp` 中的文件名,重新编译运行,看看能不能读写你自己的图片。
---
## 7. 第 2 讲:颜色空间转换
> **你想解决的问题**:计算机有好几种不同的方式描述颜色,每种方式适合不同的任务。怎么在这些"颜色语言"之间翻译?
### 7.1 什么是"颜色空间"?
**颜色空间**就是"用什么样的坐标系来描述颜色"。同一个颜色,可以用不同的"语言"来表达:
| 颜色空间 | 描述方式 | 类比 |
|----------|----------|------|
| **BGR** | 蓝、绿、红三个分量各用 0~255 表示 | 经纬度坐标(三维精确定位) |
| **Gray(灰度)** | 只有一个 0~255 的值,表示亮度 | 海拔高度(一维数值) |
| **HSV** | 色相(H)、饱和度(S)、明度(V) | 方向+距离+高度 |
**重点理解 HSV**——这是 RoboMaster 中最重要的颜色空间:
- **H(Hue,色相)**:是什么颜色。0=红,30=黄,60=绿,120=蓝——就像色环上的角度
- **S(Saturation,饱和度)**:颜色有多"纯"。0=灰色(没颜色),255=最鲜艳
- **V(Value,明度)**:有多亮。0=黑色,255=最亮
> **为什么 RoboMaster 用 HSV 而不是 BGR?**
>
> 想象一个红色灯条——在暗光下 BGR 值可能是 `(0, 0, 80)`,在强光下可能是 `(0, 50, 255)`。BGR 三个值全变了!但在 HSV 中,两者的 **H 值都是 0**(都是红色),只有 **V 值**不同。所以你只需设定 H 的范围就能框住"红色",不受光照影响。这是 inRange 颜色提取的关键原理。
### 7.2 本次实验用到的函数
| 函数 | 作用 |
|------|------|
| `cv::cvtColor(src, dst, 转换代码)` | 颜色空间转换 |
| `cv::split(多通道图, 单通道数组)` | 把多通道拆成独立的单通道 |
### 7.3 跟我一起看代码
#### 核心代码
```cpp
// 转换 1:BGR → 灰度
cv::Mat gray;
cv::cvtColor(color_image, gray, cv::COLOR_BGR2GRAY);
// 内部公式:Gray = 0.299×R + 0.587×G + 0.114×B
// 人眼对绿色最敏感(权重最高),对蓝色最不敏感(权重最低)
// 转换 2:BGR → HSV(RoboMaster 核心!)
cv::Mat hsv;
cv::cvtColor(color_image, hsv, cv::COLOR_BGR2HSV);
// 转换后 H∈[0,180], S∈[0,255], V∈[0,255]
// 转换 3:BGR → Lab(设备无关的均匀颜色空间)
cv::Mat lab;
cv::cvtColor(color_image, lab, cv::COLOR_BGR2Lab);
// 转换 4:BGR → YCrCb(常用于视频压缩)
cv::Mat ycrcb;
cv::cvtColor(color_image, ycrcb, cv::COLOR_BGR2YCrCb);
```
#### 拆分 HSV 通道
```cpp
std::vector<cv::Mat> hsv_channels; // 创建 3 个 Mat 的容器
cv::split(hsv, hsv_channels); // 拆分!
// hsv_channels[0] = H 通道(色相图)
// hsv_channels[1] = S 通道(饱和度图)
// hsv_channels[2] = V 通道(明度图)
```
拆分后保存的 `hsv_channel_S.jpg` 非常值得一看——你会发现白色区域的 S 值接近 0(因为没有"颜色"),而彩色区域的 S 值很高。**这就是为什么 inRange 时要把 S 下界设高,来排除白色背景。**
### 7.4 编译运行
```bash
cd ~/vision-training-2026-opencvcpp/exp2_color_space
mkdir -p build && cd build
cmake .. && make -j$(nproc)
./exp2
```
### 7.5 查看结果
重点看 `hsv_channel_H.jpg`、`hsv_channel_S.jpg`、`hsv_channel_V.jpg` 三张图,理解 HSV 每个通道"长什么样"。
**运行结果预览**:
| 原始彩色图 | 灰度转换 | HSV转换 |
|---|---|---|
| ![原始彩色图](asset/exp2_color_space_original.jpg) | ![灰度转换结果](asset/exp2_color_space_gray.jpg) | ![HSV转换结果](asset/exp2_color_space_hsv.jpg) |
| H通道(色相) | S通道(饱和度) | V通道(明度) |
|---|---|---|
| ![H通道(色相)](asset/exp2_color_space_hsv_channel_H.jpg) | ![S通道(饱和度)](asset/exp2_color_space_hsv_channel_S.jpg) | ![V通道(明度)](asset/exp2_color_space_hsv_channel_V.jpg) |
| Lab转换 | YCrCb转换 |
|---|---|
| ![Lab转换](asset/exp2_color_space_lab.jpg) | ![YCrCb转换](asset/exp2_color_space_ycrcb.jpg) |
---
## 8. 第 3 讲:高斯模糊
> **你想解决的问题**:摄像头拍到的画面总有噪点(就像手机在暗光下拍照会有颗粒感),怎么把这些噪点"抹平"?
### 8.1 什么是模糊?
**模糊 = 让每个像素和周围像素的颜色"混在一起"**。
具体做法是:对于图像中的每个像素,看一眼它周围的邻居,按距离远近加权取平均,把结果作为这个像素的新颜色值。
**为什么叫"高斯"模糊?**
德国数学家高斯(Gauss)发现了一种"钟形曲线"(正态分布)。用这个曲线来决定周围像素各占多大权重——离中心越近的像素,权重越大,影响越大。这种加权方式比"简单取平均"更自然。
### 8.2 函数详解
```cpp
cv::GaussianBlur(输入, 输出, 核大小, X方向标准差, Y方向标准差);
// src dst ksize sigmaX sigmaY
```
| 参数 | 含义 | 注意事项 |
|------|------|----------|
| `ksize` | 模糊范围,如 `cv::Size(3,3)` | **必须是奇数!** 3,5,7,9... |
| `sigmaX` | 水平方向模糊程度 | 设为 0 让 OpenCV 自动计算(推荐) |
| `sigmaY` | 垂直方向模糊程度 | 设为 0 则等于 sigmaX |
> **为什么 ksize 必须是奇数?**
> 因为核需要一个明确的"中心像素"。3×3 的中心是第 2 行第 2 列。4×4?没有中心,没法定义。
### 8.3 跟我一起看代码
```cpp
// 轻微模糊:3×3 核,只混合最近的 8 个邻居
cv::Mat blur_light;
cv::GaussianBlur(original, blur_light, cv::Size(3, 3), 0, 0);
// 中度模糊:9×9 核,覆盖 81 个像素
cv::Mat blur_medium;
cv::GaussianBlur(original, blur_medium, cv::Size(9, 9), 0, 0);
// 重度模糊:21×21 核(实际中很少用这么大)
cv::Mat blur_heavy;
cv::GaussianBlur(original, blur_heavy, cv::Size(21, 21), 0, 0);
// 各向异性模糊:水平方向模糊大,垂直方向模糊小
cv::Mat blur_aniso;
cv::GaussianBlur(original, blur_aniso, cv::Size(0, 0), 15, 3);
// ksize=(0,0) 时,OpenCV 会根据 sigma 自动计算核大小
// sigmaX=15 远大于 sigmaY=3,所以水平方向被"抹平"得厉害
```
程序会生成一张带有圆、三角形、文字和**随机散点**的测试图(散点用来模拟噪声),然后用 4 种不同参数做模糊,方便你对比效果。
### 8.4 在 RoboMaster 中的实际用途
在提取红色灯条之前,我们通常会先做一次**轻微高斯模糊**(核大小 3~7)。原因是:
- 摄像头传感器本身有噪点
- 噪点在 HSV 空间中也可能被误判为红色
- 先模糊可以"抹平"这些随机噪点,让后续 inRange 的结果更干净
### 8.5 编译运行
```bash
cd ~/vision-training-2026-opencvcpp/exp3_gaussian_blur
mkdir -p build && cd build
cmake .. && make -j$(nproc)
./exp3
```
查看 `original.jpg` → `blur_3x3.jpg` → `blur_9x9.jpg` → `blur_21x21.jpg`,对比噪点的消失过程。
**运行结果预览**:
| 原始图片(含散点噪声) | 轻微模糊 (ksize=3×3) | 中度模糊 (ksize=9×9) |
|---|---|---|
| ![原始图片(含散点噪声)](asset/exp3_gaussian_blur_original.jpg) | ![轻微模糊 ksize=3×3](asset/exp3_gaussian_blur_blur_3x3.jpg) | ![中度模糊 ksize=9×9](asset/exp3_gaussian_blur_blur_9x9.jpg) |
| 重度模糊 (ksize=21×21) | 各向异性模糊 (sigmaX=15, sigmaY=3) |
|---|---|
| ![重度模糊 ksize=21×21](asset/exp3_gaussian_blur_blur_21x21.jpg) | ![各向异性模糊 sigmaX=15 sigmaY=3](asset/exp3_gaussian_blur_blur_anisotropic.jpg) |
---
## 9. 第 4 讲:Canny 边缘检测
> **你想解决的问题**:如何找出图像中"物体的轮廓"?就像用铅笔把照片中所有物体的边界描出来。
### 9.1 什么是"边缘"?
**边缘 = 图像中亮度急剧变化的地方**。
比如一张白纸上画了一条黑线——白纸和黑线交界处的像素值从"接近 255"突变成"接近 0",这个"突变位置"就是边缘。
### 9.2 Canny 算法速览
Canny 是迄今为止最经典的边缘检测算法,发表于 1986 年。它分 5 个步骤:
1. **高斯滤波**:先模糊去噪(因为噪点也会被误检为边缘)
2. **计算梯度**:找出每个像素亮度变化最大的方向和变化幅度
3. **非极大值抑制**:只保留梯度方向上的最大值,把边缘"细化"为单像素宽度
4. **双阈值检测**:用两个阈值筛选强弱边缘
5. **边缘连接**:孤立的弱边缘丢弃,与强边缘相连的弱边缘保留
你不需要背这 5 步,但需要理解**第 4 步的双阈值机制**,因为这是你需要手动调参的地方。
### 9.3 双阈值机制——"迟滞阈值"
```
threshold2(高阈值)→ 梯度强度 > 这个值的:绝对是边缘(强边缘),直接保留
threshold1(低阈值)→ 梯度强度 < 这个值的:绝对不是边缘,直接丢弃
介于两者之间的 → 看情况:和强边缘连着的就算边缘,孤立的就丢弃
```
**生活比喻**:这就像一些社团招新——
- threshold2 = "面试官一眼看中的优秀新生"(直接录取)
- threshold1 = "明显不合格的"(直接淘汰)
- 介于两者之间的,看他们有没有认识的学长学姐(是否和已录取的人相邻)。有关系就录取,没关系的刷掉。
**推荐参数比例**:`threshold2 ≈ 2 × threshold1` 或 `3 × threshold1`,比如 (50, 150) 或 (100, 200)。
### 9.4 跟我一起看代码
```cpp
// Canny 输入必须是灰度图!
cv::Mat gray;
cv::cvtColor(image, gray, cv::COLOR_BGR2GRAY);
// 低阈值(50, 150):检出大量边缘,包括细节纹理
cv::Mat edges_low;
cv::Canny(gray, edges_low, 50, 150);
// 中等阈值(100, 200):去掉了大部分噪声,保留主要轮廓
cv::Mat edges_mid;
cv::Canny(gray, edges_mid, 100, 200);
// 高阈值(200, 400):只保留最强烈的边缘
cv::Mat edges_high;
cv::Canny(gray, edges_high, 200, 400);
// 最佳实践:先模糊再 Canny(边缘更干净!)
cv::Mat blurred;
cv::GaussianBlur(gray, blurred, cv::Size(5, 5), 0);
cv::Mat edges_blurred;
cv::Canny(blurred, edges_blurred, 50, 150);
```
> **重要提醒**:`cv::Canny` 的第一个参数**必须是 8 位单通道灰度图**!如果你传入彩色图,函数会静默失败或产生奇怪的结果。确保先用 `cv::cvtColor` 转成灰度。
### 9.5 编译运行
```bash
cd ~/vision-training-2026-opencvcpp/exp4_canny
mkdir -p build && cd build
cmake .. && make -j$(nproc)
./exp4
```
对比 `edges_low.jpg`、`edges_mid.jpg`、`edges_high.jpg`、`edges_after_blur.jpg`,理解阈值如何影响结果。
**运行结果预览**:
| 原始彩色图 | 灰度转换 |
|---|---|
| ![原始彩色图](asset/exp4_canny_original.jpg) | ![灰度转换](asset/exp4_canny_gray.jpg) |
| 低阈值 (50/150) | 中等阈值 (100/200) | 高阈值 (200/400) |
|---|---|---|
| ![低阈值 50/150](asset/exp4_canny_edges_low.jpg) | ![中等阈值 100/200](asset/exp4_canny_edges_mid.jpg) | ![高阈值 200/400](asset/exp4_canny_edges_high.jpg) |
| 高斯模糊后 | 先模糊再Canny |
|---|---|
| ![高斯模糊后](asset/exp4_canny_blurred.jpg) | ![先模糊再Canny](asset/exp4_canny_edges_after_blur.jpg) |
---
## 10. 第 5 讲:图像阈值化
> **你想解决的问题**:怎么把一张灰度图变成"非黑即白"的二值图?就像把照片复印成只有黑白两色的文件。
### 10.1 什么是阈值化?
**阈值化 = 设一个"分界线",高于它的全变白,低于它的全变黑。**
想象一次考试:
- 成绩 = 0~100 分
- 及格线 = 60 分(这就是 **threshold**,阈值)
- ≥60 分 → "通过" → 在图像中对应变白色(255)
- <60 分 → "不通过" → 在图像中对应变黑色(0)
应用到图像:
- 每个像素灰度值 = 0~255
- 设阈值 = 128
- ≥128 → 白色
- <128 → 黑色
### 10.2 5 种阈值类型
OpenCV 提供了 5 种不同的"切法",假设阈值 = 128, 最大值 = 255:
| 类型 | 像素 > 128 时 | 像素 ≤ 128 时 | 视觉效果 |
|------|-------------|-------------|----------|
| `THRESH_BINARY` | 设为 255(白) | 设为 0(黑) | 标准黑白图 |
| `THRESH_BINARY_INV` | 设为 0(黑) | 设为 255(白) | 黑白反转 |
| `THRESH_TRUNC` | 设为 128(截断) | 保持原值 | 亮部被削平 |
| `THRESH_TOZERO` | 保持原值 | 设为 0(黑) | 暗部清零 |
| `THRESH_TOZERO_INV` | 设为 0(黑) | 保持原值 | 亮部清零 |
### 10.3 跟我一起看代码
```cpp
cv::threshold(src, dst, thresh, maxval, type);
// 输入 输出 阈值 最大值 类型
// 示例:标准二值化
cv::Mat binary;
cv::threshold(gray, binary, 128, 255, cv::THRESH_BINARY);
// 灰度 >= 128 → 变为 255(白)
// 灰度 < 128 → 变为 0(黑)
// 反二值化
cv::Mat binary_inv;
cv::threshold(gray, binary_inv, 128, 255, cv::THRESH_BINARY_INV);
// 效果和上面完全反过来
```
本实验生成一张从左到右灰度从 0 渐变到 255 的测试图,然后分别用 5 种类型做阈值化,让你直观看到每种"切法"的不同。
> **RoboMaster 中常用哪个?** `THRESH_BINARY`——直接把灰度图转成非黑即白的二值图,这是形态学处理和轮廓检测的前置步骤。
### 10.4 编译运行
```bash
cd ~/vision-training-2026-opencvcpp/exp5_threshold
mkdir -p build && cd build
cmake .. && make -j$(nproc)
./exp5
```
**运行结果预览**:
| 灰度渐变图 | THRESH_BINARY | THRESH_BINARY_INV |
|---|---|---|
| ![灰度渐变图](asset/exp5_threshold_gradient.jpg) | ![THRESH_BINARY](asset/exp5_threshold_thresh_binary.jpg) | ![THRESH_BINARY_INV](asset/exp5_threshold_thresh_binary_inv.jpg) |
| THRESH_TRUNC | THRESH_TOZERO | THRESH_TOZERO_INV |
|---|---|---|
| ![THRESH_TRUNC](asset/exp5_threshold_thresh_trunc.jpg) | ![THRESH_TOZERO](asset/exp5_threshold_thresh_tozero.jpg) | ![THRESH_TOZERO_INV](asset/exp5_threshold_thresh_tozero_inv.jpg) |
---
## 11. 第 6 讲:形态学操作——腐蚀与膨胀
> **你想解决的问题**:二值图中有很多杂散的小白点(噪声)和小黑洞(空洞),怎么去掉它们而不大幅改变物体的大小?
### 11.1 腐蚀(Erosion)
**白的东西变瘦一圈**。
**比喻**:沙滩上的字被涨潮的海水冲——字的边缘被海水"吃掉"一圈,字变瘦了甚至消失了。
**在 RoboMaster 中的用途**:inRange 提取颜色后,背景上可能有一些零散的白色噪点。这些噪点本来就很小,一次腐蚀就能让它们消失,而灯条比较大,只是边缘瘦了一小圈,不会消失。
### 11.2 膨胀(Dilation)
**白的东西变胖一圈**。
**比喻**:给房子外围砌了一圈围墙——房子范围变大了。
**在 RoboMaster 中的用途**:灯条内部可能因为光线不均匀出现一些小黑洞,膨胀能把周围的白色区域推进来,把黑洞填上。
### 11.3 开运算与闭运算
| 名称 | 操作顺序 | 效果 | 适用场景 |
|------|----------|------|----------|
| **开运算** | 先腐蚀,再膨胀 | 去掉小白点,目标大小基本恢复 | 去背景噪点 |
| **闭运算** | 先膨胀,再腐蚀 | 填上小黑洞,目标大小基本恢复 | 填内部空洞 |
**关键概念**:单独做腐蚀会让物体变瘦,单独做膨胀会让物体变胖。但**先腐蚀再膨胀**(或反过来),物体大小基本恢复原样,只保留了"消噪声"或"填空洞"的效果——就像用橡皮擦擦掉污渍,字本身大小不变。
### 11.4 跟我一起看代码
```cpp
// 创建结构元素("腐蚀/膨胀的模板")
cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(5, 5));
// ↑函数名 ↑矩形核 ↑5×5 像素大小
// 腐蚀:白色区域变小
cv::Mat eroded;
cv::erode(binary, eroded, kernel);
// 膨胀:白色区域变大
cv::Mat dilated;
cv::dilate(binary, dilated, kernel);
// 开运算 = 先腐蚀 + 再膨胀(需手动两步)
cv::Mat opened;
cv::erode(binary, opened, kernel);
cv::dilate(opened, opened, kernel);
// 闭运算 = 先膨胀 + 再腐蚀
cv::Mat closed;
cv::dilate(binary, closed, kernel);
cv::erode(closed, closed, kernel);
// 多次腐蚀:iterations 参数
cv::Mat eroded_multi;
cv::erode(binary, eroded_multi, kernel, cv::Point(-1, -1), 3);
// ↑默认锚点 ↑做 3 次
```
### 11.5 一个重要的观察
本实验的测试图设计得很巧妙:白色矩形内有两个**黑色圆洞**(模拟目标内部的空洞),背景上有大量**白色散点**(模拟噪声)。
- 开运算后:噪点消失 ✓,但矩形内部的黑洞**变大了** ✗
- 闭运算后:黑洞填上了 ✓,但背景噪点**变大了** ✗
**结论**:实际 RoboMaster 项目中,通常**先开运算去噪,再闭运算填空**,组合使用达到最佳效果。
### 11.6 编译运行
```bash
cd ~/vision-training-2026-opencvcpp/exp6_morphology
mkdir -p build && cd build
cmake .. && make -j$(nproc)
./exp6
```
依次查看 `binary.jpg` → `eroded.jpg` → `dilated.jpg` → `opened.jpg` → `closed.jpg`。
**运行结果预览**:
| 原始二值图 | 腐蚀后 | 膨胀后 |
|---|---|---|
| ![原始二值图(白色矩形+内部黑洞+背景噪点)](asset/exp6_morphology_binary.jpg) | ![腐蚀后](asset/exp6_morphology_eroded.jpg) | ![膨胀后](asset/exp6_morphology_dilated.jpg) |
| 开运算 | 闭运算 | 3次腐蚀 |
|---|---|---|
| ![开运算](asset/exp6_morphology_opened.jpg) | ![闭运算](asset/exp6_morphology_closed.jpg) | ![3次腐蚀](asset/exp6_morphology_eroded_x3.jpg) |
---
## 12. 第 7 讲:颜色提取——inRange(核心!)
> 🚨 **这是整堂课最核心的章节!**
> 学完这一讲,你就掌握了 RoboMaster 灯条检测的关键技术基础。
### 12.1 你想解决什么问题?
RoboMaster 机器人装甲板上有**红色**或**蓝色**的 LED 灯条。摄像头拍到的画面中包含各种颜色(场地背景、对手机器人、裁判系统等)。**你需要从这堆颜色中精确地找出"哪些像素是红色的"**,忽略其他所有颜色。
这就是 `cv::inRange` 要做的事情。
### 12.2 inRange 是什么?
`inRange` 是一个**按颜色范围过滤像素**的函数。
**比喻**:你有一堆混在一起的彩色糖果(红、绿、蓝、黄...),你只想挑出红色的。`inRange` 就是那个"只能让红色通过"的筛子——红色的留下(标记为白色),其他颜色的丢掉(标记为黑色)。
### 12.3 函数详解
```cpp
cv::inRange(src, lowerb, upperb, dst);
// 输入 下界 上界 输出 mask
```
- **src**:输入图像(必须先用 `cv::cvtColor` 转为 HSV!)
- **lowerb**:颜色范围的下界,一个 `cv::Scalar(H_min, S_min, V_min)`
- **upperb**:颜色范围的上界,一个 `cv::Scalar(H_max, S_max, V_max)`
- **dst**:输出二值 mask,满足条件 = 255(白),不满足 = 0(黑)
### 12.4 红色的 HSV 范围(重中之重!)
OpenCV 中 HSV 的取值范围:
- H:0~180(注意不是 360!OpenCV 把 360° 压缩到了 180°)
- S:0~255
- V:0~255
红色在 H 轴上比较特殊——它**跨越了 0°**的边界(因为色相是一个环,0° 和 360° 都是红色)。所以提取红色需要**两个 H 区间**:
```
┌─────────────────────────────────────────────┐
│ 红色在色相环上的位置 │
│ │
│ H = 0° ──── 正红色 ──── H = 10°(偏橙) │
│ H = 160°(偏紫)──── 绕回到 ──── H = 180° │
│ │
│ 区间 1:H ∈ [0, 10] │
│ 区间 2:H ∈ [160, 180] │
└─────────────────────────────────────────────┘
```
**完整的推荐参数**:
```
区间 1:lower_red1 = Scalar(0, 100, 100)
upper_red1 = Scalar(10, 255, 255)
区间 2:lower_red2 = Scalar(160, 100, 100)
upper_red2 = Scalar(180, 255, 255)
```
| 参数 | 值 | 为什么这样设 |
|------|-----|-------------|
| S 下界 = 100 | ≥40% 饱和度 | 排除灰白色(白色墙壁、灰色地面等没有"颜色"的东西) |
| S 上界 = 255 | 最鲜艳 | 一般不动 |
| V 下界 = 100 | ≥40% 亮度 | 排除太暗/黑色的区域(阴影、黑色机器人机身) |
| V 上界 = 255 | 最亮 | 一般不动 |
> **‼️ 重要警告**:这些值不是写死的!实际比赛场地灯光、相机参数不同,最优值也不同。所以后续课程会教你们用**滑动条(Trackbar)实时调参**——在比赛前花几分钟现场调整,找到最适合当前场地的阈值。
### 12.5 跟我一起看完整代码流程
```cpp
// ========== 第 1 步:BGR → HSV ==========
cv::Mat hsv;
cv::cvtColor(image, hsv, cv::COLOR_BGR2HSV);
// image 是 BGR 彩色图,hsv 是转换后的 HSV 图
// ========== 第 2 步:inRange 提取红色区间 1 ==========
cv::Scalar lower_red1(0, 100, 100); // H_min=0, S_min=100, V_min=100
cv::Scalar upper_red1(10, 255, 255); // H_max=10, S_max=255, V_max=255
cv::Mat mask1;
cv::inRange(hsv, lower_red1, upper_red1, mask1);
// mask1 中:H 在 0~10 且 S>100 且 V>100 的像素 = 白色(255)
// ========== 第 3 步:inRange 提取红色区间 2 ==========
cv::Scalar lower_red2(160, 100, 100); // H_min=160, S_min=100, V_min=100
cv::Scalar upper_red2(180, 255, 255); // H_max=180, S_max=255, V_max=255
cv::Mat mask2;
cv::inRange(hsv, lower_red2, upper_red2, mask2);
// mask2 中:H 在 160~180 且 S>100 且 V>100 的像素 = 白色(255)
// ========== 第 4 步:合并两个 mask ==========
cv::Mat red_mask;
cv::bitwise_or(mask1, mask2, red_mask);
// "或"运算:mask1 是白的或者 mask2 是白的,结果就是白的
// 等效于:区间 1 的红色 或 区间 2 的红色,都是我们要的红色
// ========== 第 5 步:把 mask 覆在原始图上,提取红色区域 ==========
cv::Mat red_extracted;
cv::bitwise_and(image, image, red_extracted, red_mask);
// "与"运算:mask 为白(255)的地方,保留原图像素
// mask 为黑(0) 的地方,变黑
// 参数解释:第一个 image = "与"操作的第一输入
// 第二个 image = "与"操作的第二输入(这里用同一张图)
// red_extracted = 输出
// red_mask = 掩码(只在 mask 为白的地方做"与")
```
### 12.6 为什么不在 BGR 空间做 inRange?
你可能会问:红色在 BGR 中不就是 `(0, 0, 255)` 吗?直接设 BGR 范围不就行了?
答案是**不行**——因为光照变化会导致 BGR 三个值**同时变化**:
| 光照 | BGR 值 | 三个值全变了! |
|------|--------|---------------|
| 亮光下的红色 | (0, 50, 255) | → 很难用一个范围同时框住 |
| 暗光下的红色 | (0, 0, 80) | → B 和 R 差很多 |
但在 HSV 中:
- 亮红:H≈0, S≈200, V≈255
- 暗红:H≈0, S≈200, V≈80
**H 和 S 基本不变,只有 V 变了**。所以我们可以把 H 和 S 固定在一个范围内,V 设宽一点就行。这就是 HSV 的威力——**把颜色和亮度解耦**。
### 12.7 编译运行
```bash
cd ~/vision-training-2026-opencvcpp/exp7_inrange
mkdir -p build && cd build
cmake .. && make -j$(nproc)
./exp7
```
### 12.8 查看结果
打开 build 目录,重点看这几张图:
| 图片 | 内容 |
|------|------|
| `original.jpg` | 原始图(6 种颜色 + 4 种深浅红色) |
| `mask_red1.jpg` | 红色区间 1 的结果(H 0~10) |
| `mask_red2.jpg` | 红色区间 2 的结果(H 160~180) |
| `mask_red_combined.jpg` | 合并后的完整红色 mask |
| `red_extracted.jpg` | 从原图中提取的红色区域(其他区域全黑) |
| `blue_extracted.jpg` | 同样方法提取的蓝色区域 |
**思考题**:为什么 `mask_red1.jpg` 和 `mask_red2.jpg` 分别只能捕获一部分红色?合并后才完整?你能在色相环上画出来吗?
**运行结果预览**:
| 原始彩色图 | HSV转换 |
|---|---|
| ![原始彩色图(6色+4种深浅红色)](asset/exp7_inrange_original.jpg) | ![HSV转换](asset/exp7_inrange_hsv.jpg) |
| 红色范围1 (H 0~10) | 红色范围2 (H 160~180) | 合并后的红色mask |
|---|---|---|
| ![红色范围1 H 0~10](asset/exp7_inrange_mask_red1.jpg) | ![红色范围2 H 160~180](asset/exp7_inrange_mask_red2.jpg) | ![合并后的红色mask](asset/exp7_inrange_mask_red_combined.jpg) |
| 提取的红色区域 | 蓝色mask | 提取的蓝色区域 |
|---|---|---|
| ![提取的红色区域](asset/exp7_inrange_red_extracted.jpg) | ![蓝色mask](asset/exp7_inrange_mask_blue.jpg) | ![提取的蓝色区域](asset/exp7_inrange_blue_extracted.jpg) |
---
## 13. 第 8 讲:综合流水线——串联一切
> **你想实现的目标**:把前面学到的所有技术串起来,做成一个完整的"从图像到灯条位置"的流程。
### 13.1 流水线设计
在工业生产中,**流水线(Pipeline)** 指的是原材料经过多道工序,一步步变成成品。图像处理也是一样的思路:
```
原始图像(原材料)
│
├──[步骤 1]── 如果是真实相机,这里是"读取摄像头画面"
│
├──[步骤 2]── BGR → HSV 颜色空间转换
│
├──[步骤 3]── 高斯模糊(去掉噪点,让后续处理更稳定)
│
├──[步骤 4]── inRange 提取目标颜色(红色灯条)
│
├──[步骤 5]── 形态学操作(清理 mask)
│ ├── 开运算:去掉背景散点误检
│ └── 闭运算:填充灯条内部断裂
│
└──[步骤 6]── 在原始图上标记检测结果
├── 找到 mask 中白色区域的轮廓
├── 用矩形框围住每个轮廓
├── 筛选:只保留"竖直细长"的框(灯条特征)
└── 在原图上画出绿色边框
```
每一步都依赖前一步的输出,就像多米诺骨牌——任何一个环节出问题,最终结果都会受影响。这也就是为什么每个步骤的**参数调优**如此重要。
### 13.2 新出场的函数
#### (1) `cv::findContours` —— 找轮廓
```cpp
std::vector<std::vector<cv::Point>> contours;
// ↑外层 vector:每个轮廓 ↑内层 vector:轮廓上的每个点
cv::findContours(二值图, contours, 检索模式, 近似方法);
// mask 输出容器 RETR_EXTERNAL CHAIN_APPROX_SIMPLE
```
- **二值图**:inRange 或形态学处理后的 mask(白色区域 = 目标)
- **RETR_EXTERNAL**:只检索最外层轮廓(不关心嵌套的内部轮廓)
- **CHAIN_APPROX_SIMPLE**:压缩水平/垂直/对角线线段,只保留端点,节约内存
#### (2) `cv::boundingRect` —— 用矩形围住轮廓
```cpp
cv::Rect bbox = cv::boundingRect(contours[i]);
// bbox.x 矩形左上角 X 坐标
// bbox.y 矩形左上角 Y 坐标
// bbox.width 矩形宽度(像素)
// bbox.height 矩形高度(像素)
```
得到一个能围住该轮廓的**最小直立矩形**(不是旋转矩形,只是水平/垂直的矩形)。
### 13.3 筛选条件:什么才算"灯条"?
找到的轮廓不全是灯条——可能有一些形状不规则的红色碎片。我们需要按**灯条的特征**做筛选:
```cpp
double area = bbox.width * bbox.height; // 包围框面积
// 条件 1:面积不能太小(过滤残留的零星噪点)
if (area < 100) continue; // 小于 100 像素²,忽略
// 条件 2:必须"竖直细长"——高度要明显大于宽度
// 真正的装甲板灯条是竖直安装的细长 LED 条
if (bbox.height < bbox.width * 2) continue; // 高度至少是宽度的 2 倍
```
> 后续课程中,筛选条件会更加丰富:长宽比、倾斜角度、面积、两个灯条的相对位置关系等。
### 13.4 跟我一起看核心代码
```cpp
// ===== 完整流水线(伪代码串联) =====
// 1. 生成测试场景(实际项目中是读取摄像头画面)
cv::Mat scene = generateScene();
// 2. BGR → HSV
cv::Mat hsv;
cv::cvtColor(scene, hsv, cv::COLOR_BGR2HSV);
// 3. 高斯模糊
cv::Mat hsv_blurred;
cv::GaussianBlur(hsv, hsv_blurred, cv::Size(5, 5), 0);
// 4. inRange 提取红色(两个区间合并)
cv::Mat mask1, mask2, red_mask;
cv::inRange(hsv_blurred, Scalar(0, 100, 100), Scalar(10, 255, 255), mask1);
cv::inRange(hsv_blurred, Scalar(160, 100, 100), Scalar(180, 255, 255), mask2);
cv::bitwise_or(mask1, mask2, red_mask);
// 5a. 开运算(去噪点)
cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(5, 5));
cv::Mat mask_opened;
cv::erode(red_mask, mask_opened, kernel);
cv::dilate(mask_opened, mask_opened, kernel);
// 5b. 闭运算(填充空洞)
cv::Mat mask_cleaned;
cv::dilate(mask_opened, mask_cleaned, kernel);
cv::erode(mask_cleaned, mask_cleaned, kernel);
// 6. 找轮廓 + 画框
std::vector<std::vector<cv::Point>> contours;
cv::findContours(mask_cleaned, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE);
cv::Mat result = scene.clone(); // clone() 创建一个完全相同的副本
int count = 0;
for (size_t i = 0; i < contours.size(); i++) {
cv::Rect bbox = cv::boundingRect(contours[i]);
if (bbox.width * bbox.height < 100) continue; // 太小,忽略
if (bbox.height < bbox.width * 2) continue; // 不够瘦长,忽略
cv::rectangle(result, bbox, Scalar(0, 255, 0), 3); // 绿色框,线宽=3
count++;
}
// 到这里,result 上就画好了绿色检测框
```
### 13.5 编译运行
```bash
cd ~/vision-training-2026-opencvcpp/exp8_pipeline
mkdir -p build && cd build
cmake .. && make -j$(nproc)
./exp8
```
### 13.6 查看结果
所有中间结果都被保存在了 build 目录下,按文件名中的序号查看:
| 文件 | 内容 |
|------|------|
| `01_original_scene.jpg` | 原始模拟场景(深灰背景 + 红色灯条 + 干扰物) |
| `02_hsv.jpg` | HSV 转换后 |
| `03_hsv_blurred.jpg` | 高斯模糊后 |
| `04_red_mask_raw.jpg` | inRange 原始 mask(有些噪点) |
| `05a_mask_opened.jpg` | 开运算后(噪点没了) |
| `05b_mask_cleaned.jpg` | 闭运算后(灯条完整了) |
| `06a_red_extracted.jpg` | 原图中提取的红色区域 |
| `06b_mask_edges.jpg` | mask 的边缘检测 |
| **`06c_result.jpg`** | **最终结果!绿色框 = 检测到的灯条** |
重点看 `06c_result.jpg`——你应该能看到两个红色灯条被绿色矩形框标记出来,旁边还标注了 "Target 1" 和 "Target 2"。
> **挑战题**:如果检测到的框数量不对(比如只有一个或零个),试着修改 `main.cpp` 中的筛选参数(面积阈值 100 和长宽比阈值 2),重新编译运行,看效果怎么变化。这就是"调参"——RoboMaster 视觉组的基本功。
**运行结果预览**:
| 原始模拟场景 | HSV转换 | 高斯模糊后 |
|---|---|---|
| ![原始模拟场景](asset/exp8_pipeline_01_original_scene.jpg) | ![HSV转换](asset/exp8_pipeline_02_hsv.jpg) | ![高斯模糊后](asset/exp8_pipeline_03_hsv_blurred.jpg) |
| inRange原始mask | 开运算去噪 | 闭运算填充 |
|---|---|---|
| ![inRange原始mask](asset/exp8_pipeline_04_red_mask_raw.jpg) | ![开运算去噪](asset/exp8_pipeline_05a_mask_opened.jpg) | ![闭运算填充](asset/exp8_pipeline_05b_mask_cleaned.jpg) |
| 提取的红色区域 | mask边缘检测 | 最终结果(绿框=检测到的灯条) |
|---|---|---|
| ![提取的红色区域](asset/exp8_pipeline_06a_red_extracted.jpg) | ![mask边缘检测](asset/exp8_pipeline_06b_mask_edges.jpg) | ![最终结果(绿色框=检测到的灯条)](asset/exp8_pipeline_06c_result.jpg) |
---
## 14. 课堂总结与下一步
### 14.1 你学会了什么
| 技能 | 对应的 OpenCV 函数 | 在 RoboMaster 中的用途 |
|------|-------------------|----------------------|
| 图像读写 | `imread` / `imwrite` / `Mat` | 读取摄像头画面,保存调试截图 |
| 颜色空间转换 | `cvtColor`(BGR→HSV) | 为颜色提取做准备 |
| 图像去噪 | `GaussianBlur` | 减少摄像头噪点对 inRange 的干扰 |
| 边缘检测 | `Canny` | 找出物体轮廓 |
| 图像二值化 | `threshold` | 把灰度图变成黑白,为后续处理准备 |
| 形态学清理 | `erode` / `dilate` | 清理 inRange 后的 mask |
| **颜色提取** | **`inRange` + `bitwise_or`** | **找出红色/蓝色灯条!核心!** |
| 轮廓提取 | `findContours` + `boundingRect` | 从 mask 中找到灯条位置并画框 |
### 14.2 最重要的 3 件事
1. **`imread` 之后必须检查 `empty()`**——否则程序可能悄悄崩溃。
2. **颜色提取用 HSV,不是 BGR**——HSV 把颜色和亮度分开,对光照变化更鲁棒。
3. **红色需要两个 H 区间**——因为色相环在 0° 处断裂,H ∈ [0,10] 和 [160,180] 要合并。
### 14.3 下一步学什么?
在后续课程中,我们将逐步学习:
1. **Trackbar 实时调参**:用 `cv::createTrackbar` 创建滑动条,实时调整 HSV 阈值、模糊核大小、Canny 阈值,快速找到最佳参数
2. **工业相机**:不再用 `imread` 读文件,而是用 SDK 从**真实相机**实时取流
3. **灯条匹配**:如何将两个灯条配对成一个装甲板,按面积、角度、距离筛选
4. **PNP 位姿解算**:根据灯条在图像中的位置和已知的物理尺寸,计算装甲板在三维空间中的位置(这就是云台瞄准需要的数据!)
5. **串口通信**:将坐标数据通过 USB 转 TTL 串口发送给云台控制板
> 如果你对以上内容感兴趣,欢迎加入 RoboMaster 视觉组,和学长学姐一起写代码、打比赛!
---
## 15. 附录:完整源代码
> **如何使用本附录**:以下列出了所有实验的完整源代码。在做每个实验时,你可以对照着讲义中的讲解,在代码中找到对应的行进行理解。如果讲义中某个部分没讲清楚,直接看代码中的**中文注释**——每一行关键代码都有详细解释。
### 15.1 CMakeLists.txt 通用结构说明
每个实验文件夹下的 `CMakeLists.txt` 文件结构相同,这里以实验 1 为例逐行解释:
```cmake
# 第 1 行:指定 CMake 最低版本
cmake_minimum_required(VERSION 3.10)
# 第 2 行:给项目起个名字(exp1_read_write)
project(exp1_read_write)
# 第 3-4 行:要求编译器使用 C++11 标准
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# 第 5 行:告诉 CMake 去找 OpenCV 库
find_package(OpenCV REQUIRED)
# 第 6 行:生成名为 exp1 的可执行文件(运行命令为 ./exp1)
add_executable(exp1 main.cpp)
# 第 7 行:把 OpenCV 的库链接到 exp1 上(不写这行编译会报"未定义引用"错误)
target_link_libraries(exp1 ${OpenCV_LIBS})
# 第 8 行:告诉编译器 OpenCV 的头文件在哪(不然找不到 #include <opencv2...>)
include_directories(${OpenCV_INCLUDE_DIRS})
```
**每行的作用**:
| 命令 | 作用 | 如果不写的后果 |
|------|------|---------------|
| `cmake_minimum_required` | 声明最低 CMake 版本 | CMake 可能会用旧版语法解析,导致兼容问题 |
| `project` | 给项目命名 | 不影响功能,但生成的文件缺少标识 |
| `set(CXX_STANDARD)` | 指定 C++ 标准 | 代码中的 C++11 特性(如 `auto`)可能无法编译 |
| `find_package(OpenCV)` | 找到 OpenCV 库的位置 | 后续的 `${OpenCV_LIBS}` 和 `${OpenCV_INCLUDE_DIRS}` 变量为空 |
| `add_executable` | 声明要生成的可执行文件 | 没有编译目标,cmake 不知道要生成什么 |
| `target_link_libraries` | 把库文件链接到程序 | 链接错误:"undefined reference to cv::imread" |
| `include_directories` | 添加头文件搜索路径 | 编译错误:"fatal error: opencv2/opencv.hpp: No such file" |
> **理解链接(Link)**:代码中写 `#include <opencv2/opencv.hpp>` 只是**声明**了函数存在("有个叫 imread 的函数,它长这样")。但函数的**实现**(实际干活的代码)放在 OpenCV 的 `.so` 库文件里。`target_link_libraries` 就是把这两者**对接**起来——告诉编译器,声明在头文件里,实现在这些 `.so` 文件里。
### 15.2 exp1_read_write/CMakeLists.txt
```cmake
# ============================================================
# 实验 1:图像读取与保存 - CMake 构建配置文件
# ============================================================
# cmake_minimum_required: 指定构建此项目所需的最低 CMake 版本
# VERSION 3.10 是一个相对较老但足够稳定的版本(WSL Ubuntu 默认通常满足)
cmake_minimum_required(VERSION 3.10)
# project: 给你的项目起一个名字
# 这个名字会出现在生成的解决方案中,也方便你辨认
# 命名规则:exp + 实验编号 + 下划线 + 实验英文名
project(exp1_read_write)
# set(CMAKE_CXX_STANDARD 11): 指定使用 C++11 标准
# C++11 提供了 auto、范围 for 等现代特性,代码写起来更舒服
set(CMAKE_CXX_STANDARD 11)
# CMAKE_CXX_STANDARD_REQUIRED: 设为 ON 表示"必须"使用 C++11
# 如果编译器不支持 C++11,CMake 会直接报错,而不是偷偷降级
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# find_package: 寻找外部库,这里是寻找 OpenCV
# REQUIRED 表示 OpenCV 是必须的,找不到就报错并停止配置
# CMake 会自动查找 OpenCV 的头文件路径和库文件路径
find_package(OpenCV REQUIRED)
# add_executable: 告诉 CMake 我们要生成一个可执行文件
# 第一个参数 "exp1" 是可执行文件的名字(编译后运行 ./exp1)
# 第二个参数 "main.cpp" 是参与编译的源文件(可以有多个)
add_executable(exp1 main.cpp)
# target_link_libraries: 将 OpenCV 的库文件链接到我们的可执行文件上
# ${OpenCV_LIBS} 是 find_package(OpenCV) 自动填充的变量
# 它包含了 OpenCV 所有需要的 .so 库文件路径
# 不写这一行的话,编译时会出现"未定义引用"的链接错误
target_link_libraries(exp1 ${OpenCV_LIBS})
# include_directories: 将 OpenCV 的头文件目录添加到编译器的搜索路径中
# ${OpenCV_INCLUDE_DIRS} 也是 find_package 自动填充的
# 这样编译器才能找到 #include <opencv2/opencv.hpp> 这样的头文件
include_directories(${OpenCV_INCLUDE_DIRS})
```
### 15.3 exp1_read_write/main.cpp
```cpp
/*
* ============================================================
* 实验 1:图像读取与保存
* 知识点:cv::Mat、cv::imread、cv::imwrite
* ============================================================
*
* 【实验目标】
* 1. 理解 cv::Mat —— OpenCV 中存储图像的核心数据类型
* 2. 学会用 cv::imread 从文件中读取图像
* 3. 学会用 cv::imwrite 将图像保存到文件
* 4. 养成检查图像是否读取成功的习惯
*
* 【生活化比喻】
* 把 cv::Mat 想象成一张"棋盘格纸":
* - 每个格子是一个"像素"(就是图像上的一个小点)
* - 格子里填的数字代表颜色(比如 0=黑, 255=白)
* - 整张纸就是一幅图像,OpenCV 把它存在计算机内存里
*
* cv::imread 就像是"用扫描仪把一张打印好的照片扫进电脑"
* cv::imwrite 就像是"用打印机把电脑里的图打印出来"
*/
// 包含 OpenCV 核心头文件
// opencv.hpp 是一个"总头文件",包含了 OpenCV 所有常用模块
// 这是最方便的写法,学习阶段推荐使用
#include <opencv2/opencv.hpp>
// 包含标准输入输出流,用于打印信息到终端
#include <iostream>
// main 函数 —— 所有 C++ 程序的入口点
// argc 是命令行参数个数,argv 是参数内容数组
// 在本实验中我们不使用命令行参数,所以参数名可以省略
int main()
{
// ==========================================
// 第一部分:生成一张测试图片
// ==========================================
// 为什么先生成一张图片而不是直接读取?
// 因为我们要确保每个同学都能运行成功,不依赖任何外部图片文件
// 等学会读取之后,你可以把自己的照片放到这个文件夹里来测试!
// cv::Mat(行数, 列数, 类型, 颜色值) —— 创建一个矩阵/图像
// - 480:图像高度 480 像素(行数),也就是垂直方向有 480 个点
// - 640:图像宽度 640 像素(列数),也就是水平方向有 640 个点
// - CV_8UC3:每个像素用 8 位无符号整数 × 3 个通道表示
// - 8U = 8-bit Unsigned(取值范围 0~255)
// - C3 = 3 个通道(Channel),也就是 B(蓝)、G(绿)、R(红)
// - 所以每个像素需要 3 个字节来存储颜色信息
// - cv::Scalar(255, 255, 255):Scalar 是一个四元组 (B, G, R, Alpha)
// - 这里 (255,255,255) 就是纯白色(B=255, G=255, R=255)
// 注意 OpenCV 的颜色顺序是 BGR,不是我们常见的 RGB!
// 这是历史原因造成的,记住就好:OpenCV 里永远是 BGR 顺序
std::cout << "[步骤 1] 正在生成一张 640x480 的彩色测试图片..." << std::endl;
cv::Mat canvas(480, 640, CV_8UC3, cv::Scalar(50, 50, 50)); // 深灰色背景
// 在画布上画一些彩色图形,让图片看起来有趣
// cv::circle(图像, 圆心, 半径, 颜色, 线宽) —— 画圆
// 圆心用 cv::Point(x, y) 表示,注意 x 是水平方向(列),y 是垂直方向(行)
// 线宽 = -1 表示填充整个圆(实心圆)
cv::circle(canvas, cv::Point(150, 120), 80, cv::Scalar(255, 0, 0), -1); // 蓝色实心圆(B=255)
cv::circle(canvas, cv::Point(320, 120), 80, cv::Scalar(0, 255, 0), -1); // 绿色实心圆(G=255)
cv::circle(canvas, cv::Point(490, 120), 80, cv::Scalar(0, 0, 255), -1); // 红色实心圆(R=255)
// cv::rectangle(图像, 左上角, 右下角, 颜色, 线宽) —— 画矩形
// 线宽 = 3 表示空心矩形,边框宽度为 3 像素
cv::rectangle(canvas, cv::Point(100, 280), cv::Point(540, 440),
cv::Scalar(0, 255, 255), 3); // 黄色空心矩形(B=0, G=255, R=255)
// cv::putText(图像, 文字, 起始位置, 字体, 字号, 颜色, 线宽) —— 写文字
cv::putText(canvas, "OpenCV Experiment 1", cv::Point(130, 350),
cv::FONT_HERSHEY_SIMPLEX, 1.0, cv::Scalar(255, 255, 255), 2); // 白色文字
// 将生成的图片保存为文件
// cv::imwrite(文件路径, 图像矩阵) —— 把内存中的图像写入磁盘
// 返回值是 bool 类型:true 表示保存成功,false 表示失败
// 文件格式由扩展名决定,这里 .jpg 表示用 JPEG 格式压缩保存
bool write_ok = cv::imwrite("test_image.jpg", canvas);
if (write_ok)
{
std::cout << " -> 测试图片已保存为 test_image.jpg" << std::endl;
}
else
{
std::cerr << " [错误] 保存测试图片失败!" << std::endl;
return -1; // 返回 -1 表示程序异常退出
}
// ==========================================
// 第二部分:读取图片
// ==========================================
std::cout << "[步骤 2] 正在读取 test_image.jpg..." << std::endl;
// cv::imread(文件路径, 读取模式) —— 从磁盘读取图像到内存
// 返回值是一个 cv::Mat 对象,包含了图像的像素数据
//
// 读取模式(第二个参数):
// cv::IMREAD_COLOR = 1 :始终读取为 3 通道彩色图(默认值)
// cv::IMREAD_GRAYSCALE = 0 :始终读取为单通道灰度图
// cv::IMREAD_UNCHANGED = -1 :保持原样(包括 Alpha 透明通道)
//
// 这里使用 cv::IMREAD_COLOR 确保读进来是彩色图像
cv::Mat image = cv::imread("test_image.jpg", cv::IMREAD_COLOR);
// 【极其重要】检查图像是否读取成功!
// image.empty() 返回 true 表示 Mat 对象中没有数据
// 常见失败原因:
// 1. 文件名写错了(大小写、拼写错误)
// 2. 文件不在程序运行的当前目录下
// 3. 文件损坏或格式不支持
// 养成好习惯:每次 imread 之后都要检查 empty()!
if (image.empty())
{
std::cerr << "[错误] 无法读取图像!请检查:" << std::endl;
std::cerr << " 1. test_image.jpg 是否在当前目录下?" << std::endl;
std::cerr << " 2. 文件名是否正确(注意大小写)?" << std::endl;
return -1; // 返回 -1 表示程序因错误退出
}
// 打印图像的基本信息
// image.rows —— 图像高度(行数,垂直像素数)
// image.cols —— 图像宽度(列数,水平像素数)
// image.channels() —— 通道数(彩色=3,灰度=1)
// image.type() —— 像素数据类型编号
std::cout << " -> 读取成功!图像信息:" << std::endl;
std::cout << " 宽度 (cols): " << image.cols << " 像素" << std::endl;
std::cout << " 高度 (rows): " << image.rows << " 像素" << std::endl;
std::cout << " 通道数: " << image.channels() << " (3=彩色, 1=灰度)" << std::endl;
// ==========================================
// 第三部分:保存读到的图片(验证读取无误)
// ==========================================
std::cout << "[步骤 3] 正在保存为 output.jpg..." << std::endl;
// 把读到的图像原封不动地保存为新文件
// 这样可以验证"读取 → 保存"整个流程是否正常
bool save_ok = cv::imwrite("output.jpg", image);
if (save_ok)
{
std::cout << " -> 图片已成功保存为 output.jpg" << std::endl;
std::cout << " -> 请在 Windows 资源管理器中打开该文件查看结果!" << std::endl;
}
else
{
std::cerr << " [错误] 保存图片失败!请检查磁盘空间和写入权限。" << std::endl;
return -1;
}
// ==========================================
// 总结
// ==========================================
std::cout << std::endl;
std::cout << "===== 实验 1 完成 =====" << std::endl;
std::cout << "你学会了:" << std::endl;
std::cout << " 1. cv::Mat 是 OpenCV 存储图像的数据类型" << std::endl;
std::cout << " 2. cv::imread() 从文件读取图像" << std::endl;
std::cout << " 3. cv::imwrite() 将图像写入文件" << std::endl;
std::cout << " 4. 读取后一定要检查 empty()" << std::endl;
// return 0 表示程序正常结束
return 0;
}
```
### 15.4 exp2_color_space/CMakeLists.txt
```cmake
# ============================================================
# 实验 2:颜色空间转换 - CMake 构建配置文件
# ============================================================
# 指定 CMake 最低版本要求(3.10 是一个广泛兼容的版本)
cmake_minimum_required(VERSION 3.10)
# 项目名称:exp2_color_space
# 这个名称用于标识整个 CMake 项目
project(exp2_color_space)
# 设置 C++ 标准为 C++11
# C++11 引入了许多现代语言特性,使代码更简洁、更安全
set(CMAKE_CXX_STANDARD 11)
# 强制要求编译器必须支持 C++11,否则停止配置
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# 查找并加载 OpenCV 库
# REQUIRED 参数:如果找不到 OpenCV,CMake 会立即报错退出
# 这一步会设置 ${OpenCV_LIBS} 和 ${OpenCV_INCLUDE_DIRS} 等变量
find_package(OpenCV REQUIRED)
# 定义可执行文件目标
# 可执行文件名:exp2(运行命令为 ./exp2)
# 源文件:main.cpp
add_executable(exp2 main.cpp)
# 将 OpenCV 的动态库/静态库链接到 exp2 可执行文件
# 没有这行代码,编译器会报告大量"未定义符号"错误
# 因为 main.cpp 中调用的 OpenCV 函数实现都在这些库里
target_link_libraries(exp2 ${OpenCV_LIBS})
# 将 OpenCV 的头文件目录添加到 include 路径
# 使 #include <opencv2/opencv.hpp> 能被正确找到
include_directories(${OpenCV_INCLUDE_DIRS})
```
### 15.5 exp2_color_space/main.cpp
```cpp
/*
* ============================================================
* 实验 2:颜色空间转换(BGR → Gray、HSV、Lab、YCrCb)
* 知识点:cv::cvtColor
* ============================================================
*
* 【实验目标】
* 1. 理解"颜色空间"的概念 —— 同一张图片的不同"坐标系"描述
* 2. 掌握 cv::cvtColor 函数的使用方法
* 3. 了解 Gray、HSV、Lab、YCrCb 各自的特点和用途
*
* 【生活化比喻】
* "颜色空间"就像是描述一个人位置的不同方式:
* - BGR 像"经纬度坐标"(给出精确的三维数值)
* - Gray 像"海拔高度"(一维数值,只有亮度)
* - HSV 像"方向+距离+高度"(色相、饱和度、明度)
* 色相(H) :是什么颜色(红/黄/绿/蓝...),像钟表上的角度
* 饱和度(S):颜色有多"纯"(鲜艳还是发灰),像果汁的浓度
* 明度(V) :颜色有多亮(亮还是暗),像灯光的亮度
* - 不同场景用不同描述方式更方便!
*
* RoboMaster 任务中,HSV 是最常用的颜色空间
* 因为它把"颜色"和"亮度"分开了,不受光照变化的影响
*/
#include <opencv2/opencv.hpp>
#include <iostream>
#include <vector> // std::vector —— 动态数组,用于生成渐变色块
int main()
{
// ==========================================
// 第一部分:生成一张色彩丰富的测试图片
// ==========================================
std::cout << "[步骤 1] 正在生成彩色测试图片..." << std::endl;
// 创建一个 300×600 的彩色画布,背景为黑色
cv::Mat color_image(300, 600, CV_8UC3, cv::Scalar(0, 0, 0));
// 画出 6 个彩色矩形,代表不同颜色
// 颜色顺序:红、绿、蓝、黄、青、品红
// 每个矩形宽 100 像素,高 300 像素
std::vector<cv::Scalar> colors = {
cv::Scalar(0, 0, 255), // 红色 (B=0, G=0, R=255)
cv::Scalar(0, 255, 0), // 绿色 (B=0, G=255, R=0)
cv::Scalar(255, 0, 0), // 蓝色 (B=255, G=0, R=0)
cv::Scalar(0, 255, 255), // 黄色 (B=0, G=255, R=255)
cv::Scalar(255, 255, 0), // 青色 (B=255, G=255, R=0)
cv::Scalar(255, 0, 255) // 品红色 (B=255, G=0, R=255)
};
// 用循环依次画出每个色块
for (int i = 0; i < 6; i++)
{
// 每个矩形宽 100 像素,所以左上角 x = i * 100
int x_start = i * 100;
cv::rectangle(color_image,
cv::Point(x_start, 0), // 左上角
cv::Point(x_start + 100, 300), // 右下角
colors[i], // 填充颜色
-1); // -1 = 填充
}
// 在每个色块上写颜色名称
std::vector<std::string> color_names = {"Red", "Green", "Blue", "Yellow", "Cyan", "Magenta"};
for (int i = 0; i < 6; i++)
{
cv::putText(color_image, color_names[i],
cv::Point(i * 100 + 10, 150),
cv::FONT_HERSHEY_SIMPLEX, 0.6,
cv::Scalar(255, 255, 255), // 白色文字
2);
}
// 保存原始彩色图
cv::imwrite("original.jpg", color_image);
std::cout << " -> 原始彩色图已保存为 original.jpg" << std::endl;
// ==========================================
// 第二部分:执行各种颜色空间转换
// ==========================================
// --- 转换 1:BGR → 灰度 (Gray) ---
// 灰度图像只有一个通道,每个像素的取值范围是 0~255
// 转换公式(心理学灰度):
// Gray = 0.299 × R + 0.587 × G + 0.114 × B
// 人眼对绿色最敏感(权重 0.587),对蓝色最不敏感(权重 0.114)
std::cout << "[步骤 2] BGR -> Gray(灰度)..." << std::endl;
cv::Mat gray_image;
cv::cvtColor(color_image, gray_image, cv::COLOR_BGR2GRAY);
cv::imwrite("gray.jpg", gray_image);
std::cout << " -> 已保存为 gray.jpg(单通道灰度图)" << std::endl;
// --- 转换 2:BGR → HSV ---
// HSV 三分量:
// H (Hue,色相) :0~180(OpenCV 中缩放到此范围,实际是 0~360 度)
// S (Saturation,饱和度):0~255(越大越鲜艳,0 就是灰色)
// V (Value,明度) :0~255(越大越亮)
std::cout << "[步骤 3] BGR -> HSV..." << std::endl;
cv::Mat hsv_image;
cv::cvtColor(color_image, hsv_image, cv::COLOR_BGR2HSV);
cv::imwrite("hsv.jpg", hsv_image);
std::cout << " -> 已保存为 hsv.jpg(注意:HSV 图片直接查看颜色会怪怪的,"
<< "因为查看器把它当 BGR 解释了)" << std::endl;
// --- 转换 3:BGR → Lab ---
// Lab 是设备无关的颜色空间,设计目标是接近人类视觉感知
// L (Lightness,亮度) :0~100(实际 OpenCV 映射到 0~255)
// a (绿-红轴) :负值偏绿,正值偏红
// b (蓝-黄轴) :负值偏蓝,正值偏黄
std::cout << "[步骤 4] BGR -> Lab..." << std::endl;
cv::Mat lab_image;
cv::cvtColor(color_image, lab_image, cv::COLOR_BGR2Lab);
cv::imwrite("lab.jpg", lab_image);
std::cout << " -> 已保存为 lab.jpg" << std::endl;
// --- 转换 4:BGR → YCrCb ---
// YCrCb 常用于图像/视频压缩(如 JPEG、MPEG)
// Y (亮度分量) :类似灰度
// Cr (红色色度分量) :红色与亮度的差异
// Cb (蓝色色度分量) :蓝色与亮度的差异
// 人眼对亮度敏感,对色度不敏感,所以压缩时可以对 CbCr 做更多压缩
std::cout << "[步骤 5] BGR -> YCrCb..." << std::endl;
cv::Mat ycrcb_image;
cv::cvtColor(color_image, ycrcb_image, cv::COLOR_BGR2YCrCb);
cv::imwrite("ycrcb.jpg", ycrcb_image);
std::cout << " -> 已保存为 ycrcb.jpg" << std::endl;
// ==========================================
// 第三部分:演示 HSV 各通道的分离
// ==========================================
std::cout << "[步骤 6] 分离 HSV 各通道..." << std::endl;
// cv::split(输入图像, 输出通道数组) —— 将多通道图像拆分成多个单通道图像
// 例如 3 通道的 HSV 图像可以拆成 H、S、V 三张独立的灰度图
// std::vector<cv::Mat> 是一个能存放多个 cv::Mat 的动态数组
std::vector<cv::Mat> hsv_channels;
cv::split(hsv_image, hsv_channels);
// 拆分后:
// hsv_channels[0] = H 通道(色相)
// hsv_channels[1] = S 通道(饱和度)
// hsv_channels[2] = V 通道(明度)
// 分别保存三个通道
cv::imwrite("hsv_channel_H.jpg", hsv_channels[0]); // 色相通道
cv::imwrite("hsv_channel_S.jpg", hsv_channels[1]); // 饱和度通道
cv::imwrite("hsv_channel_V.jpg", hsv_channels[2]); // 明度通道
std::cout << " -> 已保存 hsv_channel_H.jpg / S.jpg / V.jpg" << std::endl;
// 总结
std::cout << std::endl;
std::cout << "===== 实验 2 完成 =====" << std::endl;
std::cout << "你学会了:" << std::endl;
std::cout << " 1. cv::cvtColor() 可以在不同颜色空间之间转换" << std::endl;
std::cout << " 2. BGR → Gray:加权平均得到亮度" << std::endl;
std::cout << " 3. BGR → HSV:分开颜色和亮度,对光照变化更鲁棒" << std::endl;
std::cout << " 4. cv::split() 把多通道图像拆成单通道" << std::endl;
std::cout << " 5. RoboMaster 中 HSV 是核心!后面实验 7 会用到" << std::endl;
return 0;
}
```
### 15.6 exp3_gaussian_blur/CMakeLists.txt
```cmake
# ============================================================
# 实验 3:高斯模糊 - CMake 构建配置文件
# ============================================================
# 最低 CMake 版本要求
cmake_minimum_required(VERSION 3.10)
# 项目标识名称
project(exp3_gaussian_blur)
# 使用 C++11 标准进行编译
set(CMAKE_CXX_STANDARD 11)
# 强制 C++11,不可降级
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# 自动搜索并配置 OpenCV 的头文件和库文件路径
find_package(OpenCV REQUIRED)
# 生成名为 "exp3" 的可执行文件,编译源文件 main.cpp
add_executable(exp3 main.cpp)
# 链接 OpenCV 库到可执行文件
# ${OpenCV_LIBS} 变量由 find_package 自动设置,包含所有必需的 OpenCV 模块
target_link_libraries(exp3 ${OpenCV_LIBS})
# 添加 OpenCV 头文件搜索路径
include_directories(${OpenCV_INCLUDE_DIRS})
```
### 15.7 exp3_gaussian_blur/main.cpp
```cpp
/*
* ============================================================
* 实验 3:高斯模糊 (Gaussian Blur)
* 知识点:cv::GaussianBlur
* ============================================================
*
* 【实验目标】
* 1. 理解"模糊"本质上是什么(图像怎么变模糊的)
* 2. 掌握 cv::GaussianBlur 函数的参数含义
* 3. 观察不同卷积核大小对模糊效果的影响
*
* 【生活化比喻】
* 高斯模糊就像透过一块"毛玻璃"或"近视眼镜"看东西:
* - 原来的图像每个像素都很清晰(边界分明)
* - 模糊后,每个像素和它周围的像素颜色混在一起了
* - 核(kernel)越大,相当于毛玻璃越厚,混的范围越大,越模糊
*
* 为什么叫"高斯"?
* - 高斯(Gauss)是一位伟大的数学家
* - 他发现了一种"钟形曲线"(正态分布),用来描述自然界的很多现象
* - 在图像上,就是用这个钟形曲线来决定"周围的像素各占多大权重"
* - 距离中心越近的像素权重越大(理所当然嘛,最近的邻居影响最大)
*
* 在 RoboMaster 中为什么需要模糊?
* - 消除图像噪声(摄像头传感器本身的噪点)
* - 让后续的边缘检测、颜色提取更稳定
* - 像是"先把细节捋平,再找关键特征"
*
* 【函数详解】
* cv::GaussianBlur(src, dst, ksize, sigmaX, sigmaY, borderType)
*
* src :输入图像(可以是一张有明显边缘的图)
* dst :输出图像(大小和类型与输入相同)
* ksize :高斯核的大小,必须是奇数!(如 (3,3), (5,5), (15,15))
* 为什么是奇数?因为要有一个"中心像素",核以它为中心对称展开
* sigmaX :X 方向上的高斯标准差(控制左右方向的模糊程度)
* 如果设为 0,OpenCV 会根据 ksize 自动帮你算一个合适的值
* sigmaY :Y 方向上的高斯标准差(控制上下方向的模糊程度)
* 如果设为 0,等于 sigmaX
* borderType :边界处理方式(可选参数,默认即可)
*/
#include <opencv2/opencv.hpp>
#include <iostream>
#include <vector>
int main()
{
std::cout << "[步骤 1] 正在生成测试图片(含清晰边缘和纹理)..." << std::endl;
// 创建一张 400×400 的白色画布
cv::Mat original(400, 400, CV_8UC3, cv::Scalar(255, 255, 255));
// 画各种形状 —— 这些形状有清晰的边缘,模糊后效果明显
// 蓝色实心圆
cv::circle(original, cv::Point(100, 100), 60, cv::Scalar(255, 0, 0), -1);
// 绿色矩形框
cv::rectangle(original, cv::Point(200, 50), cv::Point(350, 150),
cv::Scalar(0, 200, 0), 3);
// 红色三角形(用 line 画三条线)
cv::line(original, cv::Point(50, 320), cv::Point(200, 200),
cv::Scalar(0, 0, 255), 5);
cv::line(original, cv::Point(200, 200), cv::Point(350, 320),
cv::Scalar(0, 0, 255), 5);
cv::line(original, cv::Point(350, 320), cv::Point(50, 320),
cv::Scalar(0, 0, 255), 5);
// 写文字(文字边缘也是测试模糊的好素材)
cv::putText(original, "OpenCV", cv::Point(80, 370),
cv::FONT_HERSHEY_SIMPLEX, 1.2, cv::Scalar(0, 0, 0), 3);
// 画一些散点(模拟噪声,模糊后会被消除)
for (int i = 0; i < 300; i++)
{
int x = rand() % 400; // 随机 X 坐标(0~399)
int y = rand() % 400; // 随机 Y 坐标(0~399)
// 每个点画一个 2×2 的小方块(因为单个像素可能看不清)
cv::rectangle(original, cv::Point(x, y), cv::Point(x + 2, y + 2),
cv::Scalar(0, 0, 0), -1);
}
cv::imwrite("original.jpg", original);
std::cout << " -> 原始图片已保存为 original.jpg(注意看上面的散点噪声)" << std::endl;
// ==========================================
// 核心实验:对比不同模糊程度
// ==========================================
// --- 模糊程度 1:轻微模糊(ksize = 3×3)---
// 3×3 的核非常小,只混合当前像素和它最近的 8 个邻居
// 效果:能消除最细小的噪点,但基本保留图像结构
std::cout << "[步骤 2] 应用轻微高斯模糊 (ksize = 3x3)..." << std::endl;
cv::Mat blur_light;
cv::GaussianBlur(original, blur_light, cv::Size(3, 3), 0, 0);
cv::imwrite("blur_3x3.jpg", blur_light);
std::cout << " -> 已保存为 blur_3x3.jpg(轻微模糊,细节基本保留)" << std::endl;
// --- 模糊程度 2:中度模糊(ksize = 9×9)---
// 9×9 的核覆盖了 81 个像素的范围
// 效果:散点噪声明显消失,边缘变得柔和
std::cout << "[步骤 3] 应用中度高模糊 (ksize = 9x9)..." << std::endl;
cv::Mat blur_medium;
cv::GaussianBlur(original, blur_medium, cv::Size(9, 9), 0, 0);
cv::imwrite("blur_9x9.jpg", blur_medium);
std::cout << " -> 已保存为 blur_9x9.jpg(中度模糊,噪声消失,边缘变软)" << std::endl;
// --- 模糊程度 3:重度模糊(ksize = 21×21)---
// 21×21 的核非常大,覆盖了 441 个像素的范围
// 效果:图像变得严重模糊,细节几乎全部丢失
// 这在实际应用中通常不会用这么大的核,这里纯粹为了演示
std::cout << "[步骤 4] 应用重度高斯模糊 (ksize = 21x21)..." << std::endl;
cv::Mat blur_heavy;
cv::GaussianBlur(original, blur_heavy, cv::Size(21, 21), 0, 0);
cv::imwrite("blur_21x21.jpg", blur_heavy);
std::cout << " -> 已保存为 blur_21x21.jpg(重度模糊,近乎一片朦胧)" << std::endl;
// --- 模糊程度 4:X/Y 方向不同模糊程度 ---
// 前面三个例子 sigmaX 和 sigmaY 都设为 0(自动计算)
// 这里我们手动指定不同方向的标准差:X 方向模糊大,Y 方向模糊小
std::cout << "[步骤 5] 应用各向异性模糊 (sigmaX=15, sigmaY=3)..." << std::endl;
cv::Mat blur_aniso;
cv::GaussianBlur(original, blur_aniso, cv::Size(0, 0), 15, 3);
// 注意:当 ksize 设为 (0,0) 时,OpenCV 会根据 sigma 自动计算合适的核大小
// sigmaX=15(水平方向大模糊)比 sigmaY=3(垂直方向小模糊)大很多
// 所以效果是:水平方向被"抹平"得很厉害,垂直方向相对清晰
cv::imwrite("blur_anisotropic.jpg", blur_aniso);
std::cout << " -> 已保存为 blur_anisotropic.jpg(水平方向很糊,垂直方向较清晰)" << std::endl;
// 总结
std::cout << std::endl;
std::cout << "===== 实验 3 完成 =====" << std::endl;
std::cout << "你学会了:" << std::endl;
std::cout << " 1. GaussianBlur 用高斯分布对像素进行加权平均" << std::endl;
std::cout << " 2. ksize 越大,模糊范围越大(必须是奇数!)" << std::endl;
std::cout << " 3. sigma 控制模糊的标准差(0 表示自动计算)" << std::endl;
std::cout << " 4. 模糊是后续边缘检测、颜色提取的重要预处理步骤" << std::endl;
return 0;
}
```
### 15.8 exp4_canny/CMakeLists.txt
```cmake
# 实验 4:Canny 边缘检测 - CMake 构建配置
cmake_minimum_required(VERSION 3.10)
project(exp4_canny)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(OpenCV REQUIRED)
add_executable(exp4 main.cpp)
target_link_libraries(exp4 ${OpenCV_LIBS})
include_directories(${OpenCV_INCLUDE_DIRS})
```
### 15.9 exp4_canny/main.cpp
```cpp
/*
* ============================================================
* 实验 4:Canny 边缘检测
* 知识点:cv::Canny
* ============================================================
*
* 【实验目标】
* 1. 理解什么是"边缘"——图像中亮度急剧变化的地方
* 2. 掌握 Canny 边缘检测算法的原理和参数
* 3. 观察不同阈值对边缘检测结果的影响
*
* 【生活化比喻】
* 边缘检测就像用铅笔"描边":
* - 给你一张彩色照片,让你把照片中所有物体的轮廓描出来
* - Canny 算法就是一个"自动描边机器人"
* - threshold1 像是"最低标准":低于这个的统统不是边缘
* - threshold2 像是"最高标准":高于这个的绝对是边缘
* - 介于两者之间的,看它是否和已确认的边缘连着(连着的算,孤立的忽略)
*
* 这就是所谓的"迟滞阈值"(Hysteresis Threshold):
* - 高于高阈值 → 肯定是边缘(强边缘)
* - 低于低阈值 → 肯定不是边缘(直接丢弃)
* - 介于中间 → 如果和强边缘相邻,就算是边缘;否则丢弃
* - 这就像"朋友的朋友也是朋友"——只要你能通过边缘连到确认真边缘,你就是边缘
*/
#include <opencv2/opencv.hpp>
#include <iostream>
int main()
{
// ==========================================
// 第一部分:生成测试图片
// ==========================================
std::cout << "[步骤 1] 正在生成测试图片(包含各种边缘结构)..." << std::endl;
// 创建一个 400×500 的白色画布
cv::Mat image(400, 500, CV_8UC3, cv::Scalar(255, 255, 255));
// 画各种几何图形 —— 这些都有清晰的边缘
cv::circle(image, cv::Point(100, 100), 60, cv::Scalar(50, 50, 50), -1); // 深灰实心圆
cv::rectangle(image, cv::Point(220, 40), cv::Point(420, 160),
cv::Scalar(100, 100, 100), -1); // 灰实心矩形
cv::rectangle(image, cv::Point(300, 60), cv::Point(360, 140),
cv::Scalar(200, 200, 200), -1); // 浅灰小矩形(内嵌)
// 画一些粗细不等的线条
cv::line(image, cv::Point(20, 200), cv::Point(480, 200),
cv::Scalar(0, 0, 0), 2); // 细横线
cv::line(image, cv::Point(20, 250), cv::Point(480, 250),
cv::Scalar(0, 0, 0), 8); // 粗横线
cv::line(image, cv::Point(250, 200), cv::Point(250, 380),
cv::Scalar(0, 0, 0), 2); // 细竖线
cv::line(image, cv::Point(100, 300), cv::Point(400, 380),
cv::Scalar(50, 50, 50), 4); // 斜线
// 画一些文字
cv::putText(image, "Canny", cv::Point(150, 370),
cv::FONT_HERSHEY_SIMPLEX, 1.5, cv::Scalar(0, 0, 0), 3);
cv::imwrite("original.jpg", image);
std::cout << " -> 原始图片已保存为 original.jpg" << std::endl;
// ==========================================
// 第二部分:转为灰度图(Canny 输入必须是灰度图!)
// ==========================================
std::cout << "[步骤 2] 转换为灰度图..." << std::endl;
cv::Mat gray;
cv::cvtColor(image, gray, cv::COLOR_BGR2GRAY);
cv::imwrite("gray.jpg", gray);
std::cout << " -> 灰度图已保存为 gray.jpg" << std::endl;
// ==========================================
// 第三部分:Canny 边缘检测 —— 不同阈值对比
// ==========================================
// --- Canny 场景 1:低阈值 (50, 150) ---
std::cout << "[步骤 3] Canny 边缘检测 (阈值 50 / 150)..." << std::endl;
cv::Mat edges_low;
cv::Canny(gray, edges_low, 50, 150);
cv::imwrite("edges_low.jpg", edges_low);
std::cout << " -> 已保存为 edges_low.jpg(低阈值,细节丰富)" << std::endl;
// --- Canny 场景 2:中等阈值 (100, 200) ---
std::cout << "[步骤 4] Canny 边缘检测 (阈值 100 / 200)..." << std::endl;
cv::Mat edges_mid;
cv::Canny(gray, edges_mid, 100, 200);
cv::imwrite("edges_mid.jpg", edges_mid);
std::cout << " -> 已保存为 edges_mid.jpg(中等阈值,适中结果)" << std::endl;
// --- Canny 场景 3:高阈值 (200, 400) ---
std::cout << "[步骤 5] Canny 边缘检测 (阈值 200 / 400)..." << std::endl;
cv::Mat edges_high;
cv::Canny(gray, edges_high, 200, 400);
cv::imwrite("edges_high.jpg", edges_high);
std::cout << " -> 已保存为 edges_high.jpg(高阈值,只保留强边缘)" << std::endl;
// --- Canny 场景 4:先模糊再做边缘检测 ---
std::cout << "[步骤 6] 先高斯模糊,再 Canny 边缘检测..." << std::endl;
cv::Mat blurred;
cv::GaussianBlur(gray, blurred, cv::Size(5, 5), 0);
cv::Mat edges_blurred;
cv::Canny(blurred, edges_blurred, 50, 150);
cv::imwrite("blurred.jpg", blurred);
cv::imwrite("edges_after_blur.jpg", edges_blurred);
std::cout << " -> 已保存为 blurred.jpg(高斯模糊后)" << std::endl;
std::cout << " -> 已保存为 edges_after_blur.jpg(模糊后再做 Canny,边缘更干净)" << std::endl;
// 总结
std::cout << std::endl;
std::cout << "===== 实验 4 完成 =====" << std::endl;
std::cout << "你学会了:" << std::endl;
std::cout << " 1. Canny 是一种经典的边缘检测算法" << std::endl;
std::cout << " 2. threshold1 和 threshold2 控制检测灵敏度" << std::endl;
std::cout << " 3. 输入必须是灰度图(单通道!)" << std::endl;
std::cout << " 4. 先模糊 + 再 Canny = 更干净的边缘" << std::endl;
return 0;
}
```
### 15.10 exp5_threshold/CMakeLists.txt
```cmake
# 实验 5:图像阈值化 - CMake 构建配置
cmake_minimum_required(VERSION 3.10)
project(exp5_threshold)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(OpenCV REQUIRED)
add_executable(exp5 main.cpp)
target_link_libraries(exp5 ${OpenCV_LIBS})
include_directories(${OpenCV_INCLUDE_DIRS})
```
### 15.11 exp5_threshold/main.cpp
```cpp
/*
* ============================================================
* 实验 5:图像阈值化 (Thresholding)
* 知识点:cv::threshold
* ============================================================
*
* 【实验目标】
* 1. 理解"阈值化"是什么——把灰度图变成黑白图
* 2. 掌握 cv::threshold 函数的各种阈值类型
* 3. 理解阈值化在图像分割中的基础地位
*
* 【生活化比喻】
* 阈值化就像是"考试及格线":
* - 考试成绩 0~100 分
* - 学校规定 60 分及格:>= 60 算"通过",< 60 算"不通过"
* - 这里的"60"就是阈值 (threshold)
*
* 应用到图像上:
* - 每个像素的灰度值 0~255(相当于 0~100 分)
* - 设定阈值 128(相当于 60 分)
* - 灰度 >= 128 的像素 -> 变白色(255,相当于"通过")
* - 灰度 < 128 的像素 -> 变黑色(0,相当于"不通过")
* - 整张图就只剩黑白两色了!
*/
#include <opencv2/opencv.hpp>
#include <iostream>
int main()
{
// ==========================================
// 第一部分:生成一张灰度渐变图
// ==========================================
std::cout << "[步骤 1] 正在生成灰度渐变测试图..." << std::endl;
// 创建一张 300(高)x 512(宽)的灰度图
// CV_8UC1 表示:8位无符号整数、1通道(灰度)
cv::Mat gradient(300, 512, CV_8UC1);
// 用手工方式生成从左到右的灰度渐变
// 左边的像素灰度值为 0(全黑),右边为 255(全白),逐渐过渡
for (int row = 0; row < gradient.rows; row++)
{
for (int col = 0; col < gradient.cols; col++)
{
// 灰度值 = 列索引 * 255 / 511(比例映射)
// 这样 col=0 对应灰度 = 0,col=511 对应灰度 = 255
// gradient.at<uchar>(row, col) —— 访问/修改单个像素值
// .at<uchar> 中的 uchar = unsigned char = 8比特无符号整数
gradient.at<uchar>(row, col) = static_cast<uchar>(col * 255 / 511);
}
}
// 在渐变图上画一条垂直线标记阈值 128 的位置
cv::line(gradient, cv::Point(256, 0), cv::Point(256, 299),
cv::Scalar(255), 1);
// 放一些标注文字
cv::putText(gradient, "0", cv::Point(5, 150),
cv::FONT_HERSHEY_SIMPLEX, 0.6, cv::Scalar(255));
cv::putText(gradient, "128", cv::Point(240, 150),
cv::FONT_HERSHEY_SIMPLEX, 0.6, cv::Scalar(255));
cv::putText(gradient, "255", cv::Point(470, 150),
cv::FONT_HERSHEY_SIMPLEX, 0.6, cv::Scalar(0));
cv::imwrite("gradient.jpg", gradient);
std::cout << " -> 原始渐变图已保存为 gradient.jpg" << std::endl;
// ==========================================
// 第二部分:演示各种阈值类型
// ==========================================
// 统一使用阈值 128(中间值),maxval = 255(纯白)
// --- 类型 1:THRESH_BINARY(标准二值化)---
std::cout << "[步骤 2] THRESH_BINARY(标准二值化)..." << std::endl;
cv::Mat thresh_binary;
cv::threshold(gradient, thresh_binary, 128, 255, cv::THRESH_BINARY);
cv::imwrite("thresh_binary.jpg", thresh_binary);
// --- 类型 2:THRESH_BINARY_INV(反二值化)---
std::cout << "[步骤 3] THRESH_BINARY_INV(反二值化)..." << std::endl;
cv::Mat thresh_binary_inv;
cv::threshold(gradient, thresh_binary_inv, 128, 255, cv::THRESH_BINARY_INV);
cv::imwrite("thresh_binary_inv.jpg", thresh_binary_inv);
// --- 类型 3:THRESH_TRUNC(截断)---
std::cout << "[步骤 4] THRESH_TRUNC(截断)..." << std::endl;
cv::Mat thresh_trunc;
cv::threshold(gradient, thresh_trunc, 128, 255, cv::THRESH_TRUNC);
cv::imwrite("thresh_trunc.jpg", thresh_trunc);
// --- 类型 4:THRESH_TOZERO(置零)---
std::cout << "[步骤 5] THRESH_TOZERO(置零)..." << std::endl;
cv::Mat thresh_tozero;
cv::threshold(gradient, thresh_tozero, 128, 255, cv::THRESH_TOZERO);
cv::imwrite("thresh_tozero.jpg", thresh_tozero);
// --- 类型 5:THRESH_TOZERO_INV(反置零)---
std::cout << "[步骤 6] THRESH_TOZERO_INV(反置零)..." << std::endl;
cv::Mat thresh_tozero_inv;
cv::threshold(gradient, thresh_tozero_inv, 128, 255, cv::THRESH_TOZERO_INV);
cv::imwrite("thresh_tozero_inv.jpg", thresh_tozero_inv);
// 总结
std::cout << std::endl;
std::cout << "===== 实验 5 完成 =====" << std::endl;
std::cout << "你学会了:" << std::endl;
std::cout << " 1. 阈值化就是把灰度图按某个界限分成两部分" << std::endl;
std::cout << " 2. 有 5 种阈值类型,最常用的是 THRESH_BINARY" << std::endl;
std::cout << " 3. 阈值化是图像分割的基础操作" << std::endl;
return 0;
}
```
### 15.12 exp6_morphology/CMakeLists.txt
```cmake
# 实验 6:形态学操作:腐蚀与膨胀 - CMake 构建配置
cmake_minimum_required(VERSION 3.10)
project(exp6_morphology)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(OpenCV REQUIRED)
add_executable(exp6 main.cpp)
target_link_libraries(exp6 ${OpenCV_LIBS})
include_directories(${OpenCV_INCLUDE_DIRS})
```
### 15.13 exp6_morphology/main.cpp
```cpp
/*
* ============================================================
* 实验 6:形态学操作 —— 腐蚀 (Erode) 与膨胀 (Dilate)
* 知识点:cv::erode、cv::dilate、cv::getStructuringElement
* ============================================================
*
* 【实验目标】
* 1. 理解"腐蚀"和"膨胀"对二值图像的作用
* 2. 了解"开运算"和"闭运算"的组合效果
* 3. 掌握结构元素 (kernel) 的概念
*
* 【生活化比喻】
* 腐蚀 (Erosion):
* 想象沙滩上写着一些字,涨潮时海水冲上来,把字的边缘"吃掉"一圈
* -> 白色区域被"侵蚀",变小了、断开了、甚至消失了
* -> 作用:消除小白点噪声、断开粘连的物体
*
* 膨胀 (Dilation):
* 想象给一栋房子外围再砌一圈围墙,房子"胖了"
* -> 白色区域被"扩张",变大了、连在一起了
* -> 作用:填充小黑洞、连接断裂的物体
*/
#include <opencv2/opencv.hpp>
#include <iostream>
int main()
{
// ==========================================
// 第一部分:生成一张带噪声的二值测试图
// ==========================================
std::cout << "[步骤 1] 正在生成二值测试图片..." << std::endl;
// 创建黑色背景
cv::Mat binary(300, 500, CV_8UC1, cv::Scalar(0));
// 画一个大的白色矩形(作为"主体目标")
cv::rectangle(binary, cv::Point(80, 60), cv::Point(420, 240),
cv::Scalar(255), -1); // 白色填充矩形
// 在矩形内部打几个孔(黑色小圆,模拟"目标内部的空洞")
cv::circle(binary, cv::Point(180, 150), 20, cv::Scalar(0), -1); // 内部孔洞 1
cv::circle(binary, cv::Point(320, 150), 20, cv::Scalar(0), -1); // 内部孔洞 2
// 在背景中撒一些白色噪点(模拟"背景中的噪声")
for (int i = 0; i < 50; i++)
{
int x = rand() % 500;
int y = rand() % 300;
cv::circle(binary, cv::Point(x, y), 3, cv::Scalar(255), -1);
}
cv::imwrite("binary.jpg", binary);
std::cout << " -> 原始二值图已保存为 binary.jpg" << std::endl;
// ==========================================
// 第二部分:创建结构元素
// ==========================================
// 结构元素的大小会影响腐蚀/膨胀的效果范围
cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(5, 5));
// ==========================================
// 第三部分:腐蚀操作
// ==========================================
std::cout << "[步骤 2] 执行腐蚀操作 (Erosion)..." << std::endl;
cv::Mat eroded;
cv::erode(binary, eroded, kernel);
cv::imwrite("eroded.jpg", eroded);
// ==========================================
// 第四部分:膨胀操作
// ==========================================
std::cout << "[步骤 3] 执行膨胀操作 (Dilation)..." << std::endl;
cv::Mat dilated;
cv::dilate(binary, dilated, kernel);
cv::imwrite("dilated.jpg", dilated);
// ==========================================
// 第五部分:开运算(先腐蚀再膨胀)
// ==========================================
std::cout << "[步骤 4] 执行开运算 (Opening = 腐蚀 + 膨胀)..." << std::endl;
cv::Mat opened;
cv::erode(binary, opened, kernel);
cv::dilate(opened, opened, kernel);
cv::imwrite("opened.jpg", opened);
// ==========================================
// 第六部分:闭运算(先膨胀再腐蚀)
// ==========================================
std::cout << "[步骤 5] 执行闭运算 (Closing = 膨胀 + 腐蚀)..." << std::endl;
cv::Mat closed;
cv::dilate(binary, closed, kernel);
cv::erode(closed, closed, kernel);
cv::imwrite("closed.jpg", closed);
// ==========================================
// 第七部分:多次形态学操作
// ==========================================
std::cout << "[步骤 6] 执行多次腐蚀 (iterations=3)..." << std::endl;
cv::Mat eroded_multi;
cv::erode(binary, eroded_multi, kernel, cv::Point(-1, -1), 3);
cv::imwrite("eroded_x3.jpg", eroded_multi);
// 总结
std::cout << std::endl;
std::cout << "===== 实验 6 完成 =====" << std::endl;
std::cout << "你学会了:" << std::endl;
std::cout << " 1. 腐蚀:白色区域缩小,可以去噪点" << std::endl;
std::cout << " 2. 膨胀:白色区域扩大,可以填黑洞" << std::endl;
std::cout << " 3. 开运算:去噪点但大体保持目标大小" << std::endl;
std::cout << " 4. 闭运算:填空洞但大体保持目标大小" << std::endl;
std::cout << " 5. 结构元素决定了操作的形状和范围" << std::endl;
return 0;
}
```
### 15.14 exp7_inrange/CMakeLists.txt
```cmake
# 实验 7:颜色提取:inRange - CMake 构建配置
cmake_minimum_required(VERSION 3.10)
project(exp7_inrange)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(OpenCV REQUIRED)
add_executable(exp7 main.cpp)
target_link_libraries(exp7 ${OpenCV_LIBS})
include_directories(${OpenCV_INCLUDE_DIRS})
```
### 15.15 exp7_inrange/main.cpp
```cpp
/*
* ============================================================
* 实验 7:颜色提取 —— inRange
* 知识点:cv::inRange、HSV 颜色空间、颜色阈值
* ============================================================
*
* 【实验目标】
* 1. 掌握 cv::inRange 函数的用法——按颜色范围提取目标
* 2. 理解为什么要用 HSV 而不是 BGR 来做颜色提取
* 3. 掌握 RoboMaster 红色灯条的 HSV 范围设定
*
* 【生活化比喻】
* inRange 就像是一个"颜色过滤器"(或者说"筛子"):
* - 你有一堆混在一起的彩色糖果(红、绿、蓝、黄...)
* - 你只想挑出红色的糖果
* - inRange 就是那个"只能通过红色"的筛子
* - 筛出来的就是红色糖果(二值 mask),其他颜色的都被丢掉了
*/
#include <opencv2/opencv.hpp>
#include <iostream>
int main()
{
// ==========================================
// 第一部分:生成包含多种颜色的测试图片
// ==========================================
std::cout << "[步骤 1] 正在生成彩色测试图片..." << std::endl;
// 400x600 画布,白色背景
cv::Mat image(400, 600, CV_8UC3, cv::Scalar(255, 255, 255));
// 画 6 个彩色矩形,模拟不同颜色的物体(含红色系多深浅)
cv::rectangle(image, cv::Point(20, 50), cv::Point(180, 130),
cv::Scalar(0, 0, 255), -1); // 红色
cv::rectangle(image, cv::Point(210, 50), cv::Point(370, 130),
cv::Scalar(255, 0, 0), -1); // 蓝色
cv::rectangle(image, cv::Point(400, 50), cv::Point(560, 130),
cv::Scalar(0, 255, 0), -1); // 绿色
cv::rectangle(image, cv::Point(20, 160), cv::Point(180, 240),
cv::Scalar(0, 255, 255), -1); // 黄色
cv::rectangle(image, cv::Point(210, 160), cv::Point(370, 240),
cv::Scalar(255, 255, 0), -1); // 青色
cv::rectangle(image, cv::Point(400, 160), cv::Point(560, 240),
cv::Scalar(255, 0, 255), -1); // 品红
// 不同深浅的红色矩形(模拟光照变化)
cv::rectangle(image, cv::Point(20, 260), cv::Point(100, 320),
cv::Scalar(0, 0, 120), -1); // 深红
cv::rectangle(image, cv::Point(120, 260), cv::Point(200, 320),
cv::Scalar(0, 0, 200), -1); // 中红
cv::rectangle(image, cv::Point(220, 260), cv::Point(300, 320),
cv::Scalar(0, 50, 255), -1); // 亮红
cv::rectangle(image, cv::Point(320, 260), cv::Point(400, 320),
cv::Scalar(100, 0, 200), -1); // 紫红
cv::imwrite("original.jpg", image);
std::cout << " -> 原始彩色图片已保存为 original.jpg" << std::endl;
// ==========================================
// 第二部分:转换为 HSV
// ==========================================
std::cout << "[步骤 2] 将 BGR 图片转换为 HSV..." << std::endl;
cv::Mat hsv;
cv::cvtColor(image, hsv, cv::COLOR_BGR2HSV);
cv::imwrite("hsv.jpg", hsv);
// ==========================================
// 第三部分:使用 inRange 提取红色
// ==========================================
std::cout << "[步骤 3] 使用 inRange 提取红色区域..." << std::endl;
// 红色需要两个范围(色相环跨越 0 度)
cv::Scalar lower_red1(0, 100, 100);
cv::Scalar upper_red1(10, 255, 255);
cv::Scalar lower_red2(160, 100, 100);
cv::Scalar upper_red2(180, 255, 255);
cv::Mat mask_red1, mask_red2, mask_red_combined;
cv::inRange(hsv, lower_red1, upper_red1, mask_red1);
cv::inRange(hsv, lower_red2, upper_red2, mask_red2);
cv::bitwise_or(mask_red1, mask_red2, mask_red_combined);
cv::imwrite("mask_red1.jpg", mask_red1);
cv::imwrite("mask_red2.jpg", mask_red2);
cv::imwrite("mask_red_combined.jpg", mask_red_combined);
// ==========================================
// 第四部分:把 mask 应用到原图(可视化提取结果)
// ==========================================
std::cout << "[步骤 4] 用 mask 提取原图中的红色部分..." << std::endl;
cv::Mat red_extracted;
cv::bitwise_and(image, image, red_extracted, mask_red_combined);
cv::imwrite("red_extracted.jpg", red_extracted);
// ==========================================
// 第五部分(附加):也提取蓝色
// ==========================================
std::cout << "[步骤 5] 额外实验:提取蓝色区域..." << std::endl;
cv::Scalar lower_blue(100, 100, 100);
cv::Scalar upper_blue(130, 255, 255);
cv::Mat mask_blue;
cv::inRange(hsv, lower_blue, upper_blue, mask_blue);
cv::imwrite("mask_blue.jpg", mask_blue);
cv::Mat blue_extracted;
cv::bitwise_and(image, image, blue_extracted, mask_blue);
cv::imwrite("blue_extracted.jpg", blue_extracted);
// 总结
std::cout << std::endl;
std::cout << "===== 实验 7 完成 =====" << std::endl;
std::cout << "你学会了:" << std::endl;
std::cout << " 1. inRange 按颜色范围提取目标区域" << std::endl;
std::cout << " 2. 为什么要用 HSV:分离颜色和亮度" << std::endl;
std::cout << " 3. 红色需要两个 H 范围(因为色相是环状)" << std::endl;
std::cout << " 4. bitwise_or 合并 mask,bitwise_and 提取原图" << std::endl;
std::cout << " 5. 这是 RoboMaster 视觉灯条检测的基础!" << std::endl;
return 0;
}
```
### 15.16 exp8_pipeline/CMakeLists.txt
```cmake
# 实验 8:综合流水线 - CMake 构建配置
cmake_minimum_required(VERSION 3.10)
project(exp8_pipeline)
set(CMAKE_CXX_STANDARD 11)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(OpenCV REQUIRED)
add_executable(exp8 main.cpp)
target_link_libraries(exp8 ${OpenCV_LIBS})
include_directories(${OpenCV_INCLUDE_DIRS})
```
### 15.17 exp8_pipeline/main.cpp
```cpp
/*
* ============================================================
* 实验 8:综合流水线 (Pipeline)
* 知识点:串联前面 7 个实验的所有技术
* ============================================================
*
* 【实验目标】
* 1. 将前面学到的所有技术串联成一个完整流程
* 2. 理解图像处理中"预处理 → 提取 → 后处理"的标准套路
* 3. 为 RoboMaster 灯条检测打下完整基础
*
* 【生活化比喻】
* 一条完整的图像处理流水线就像工厂的"生产流水线":
* - 原材料进厂(读取图像)
* - 清洗处理(模糊去噪、颜色转换)
* - 核心加工(颜色提取 inRange)
* - 精修打磨(形态学操作去掉毛刺)
* - 质检包装(边缘检测找轮廓、画框标记)
* - 成品出厂(保存结果图片)
*
* 每一步都依赖前一步的输出,整个流程像一个多米诺骨牌,
* 任何一个环节出问题,最终结果都会受影响。
*
* 【RoboMaster 灯条检测的标准流水线】
* 1. 读取摄像头采集的图像
* 2. BGR → HSV 颜色空间转换
* 3. inRange 提取目标颜色(红/蓝)
* 4. 形态学操作(开运算去噪、闭运算填充)
* 5. 轮廓检测与筛选(按面积、长宽比、角度过滤)
* 6. 画出灯条的位置框
*
* 本实验完整演示整个流程!
*/
#include <opencv2/opencv.hpp>
#include <iostream>
int main()
{
std::cout << "===== 实验 8:综合流水线 开始 =====\n" << std::endl;
// ==========================================
// 阶段 1:生成模拟场景的测试图片
// ==========================================
std::cout << "[阶段 1/6] 生成模拟 RoboMaster 场景的测试图片..." << std::endl;
// 600×450 画布,深灰色背景(模拟比赛场地的昏暗环境)
cv::Mat scene(450, 600, CV_8UC3, cv::Scalar(40, 40, 40));
// --- 先画背景噪点(必须在灯条之前!否则噪点会覆盖在灯条上)---
for (int i = 0; i < 200; i++)
{
int x = rand() % 600;
int y = rand() % 450;
uchar noise_color = static_cast<uchar>(rand() % 128 + 60);
cv::circle(scene, cv::Point(x, y), 1,
cv::Scalar(noise_color, noise_color, noise_color), -1);
}
// 画一些干扰物(场地上可能有其他颜色的物体)
cv::circle(scene, cv::Point(100, 100), 30, cv::Scalar(255, 0, 0), -1); // 蓝色干扰圆
cv::rectangle(scene, cv::Point(450, 300), cv::Point(550, 380),
cv::Scalar(0, 255, 0), -1); // 绿色干扰矩形
cv::circle(scene, cv::Point(80, 350), 40, cv::Scalar(0, 255, 255), -1); // 黄色干扰圆
// 画装甲板的中间横梁(深灰色,不会被 inRange 误检为红色)
// 注意:横梁必须在灯条之前画!这样红色灯条覆盖在横梁上方,
// 保证灯条从顶到底都是纯红色,不被横梁切断
cv::rectangle(scene, cv::Point(200, 180), cv::Point(400, 220),
cv::Scalar(80, 80, 80), -1); // 深灰色横梁
// 在上面画红色灯条(最后画,确保不被其他元素覆盖)
// 灯条是竖直的细长矩形,模拟 RoboMaster 装甲板侧面的 LED 灯条
// 左灯条
cv::rectangle(scene, cv::Point(200, 120), cv::Point(240, 280),
cv::Scalar(0, 0, 255), -1); // 纯红色竖直矩形 (B=0,G=0,R=255)
// 右灯条
cv::rectangle(scene, cv::Point(360, 120), cv::Point(400, 280),
cv::Scalar(0, 0, 255), -1); // 纯红色竖直矩形
cv::imwrite("01_original_scene.jpg", scene);
std::cout << " -> 已保存 01_original_scene.jpg(原始场景图)" << std::endl;
// ==========================================
// 阶段 2:颜色空间转换 BGR → HSV
// ==========================================
std::cout << "[阶段 2/6] BGR → HSV 颜色空间转换..." << std::endl;
cv::Mat hsv;
cv::cvtColor(scene, hsv, cv::COLOR_BGR2HSV);
cv::imwrite("02_hsv.jpg", hsv);
std::cout << " -> 已保存 02_hsv.jpg" << std::endl;
// ==========================================
// 阶段 3:高斯模糊(去噪预处理)
// ==========================================
std::cout << "[阶段 3/6] 高斯模糊(减少噪点对后续处理的影响)..." << std::endl;
cv::Mat hsv_blurred;
// 使用 5×5 的高斯核对 HSV 图像进行模糊
// 为什么在 inRange 之前模糊?
// - 噪点在 HSV 空间中也可能落在红色范围内,产生误检
// - 先模糊可以平滑掉这些随机噪点,减少误检
// 注意:核不要太大(5~7 比较合适),否则会模糊掉灯条的边缘
cv::GaussianBlur(hsv, hsv_blurred, cv::Size(5, 5), 0);
cv::imwrite("03_hsv_blurred.jpg", hsv_blurred);
std::cout << " -> 已保存 03_hsv_blurred.jpg(模糊后的 HSV 图)" << std::endl;
// ==========================================
// 阶段 4:颜色提取(inRange 提取红色)
// ==========================================
std::cout << "[阶段 4/6] inRange 提取红色区域..." << std::endl;
// 红色需要两个范围(如实验 7 所述)
cv::Scalar lower_red1(0, 100, 100);
cv::Scalar upper_red1(10, 255, 255);
cv::Scalar lower_red2(160, 100, 100);
cv::Scalar upper_red2(180, 255, 255);
cv::Mat mask1, mask2, red_mask;
cv::inRange(hsv_blurred, lower_red1, upper_red1, mask1);
cv::inRange(hsv_blurred, lower_red2, upper_red2, mask2);
cv::bitwise_or(mask1, mask2, red_mask);
cv::imwrite("04_red_mask_raw.jpg", red_mask);
std::cout << " -> 已保存 04_red_mask_raw.jpg(原始红色 mask)" << std::endl;
// ==========================================
// 阶段 5:形态学后处理(清理 mask)
// ==========================================
std::cout << "[阶段 5/6] 形态学操作清理 mask..." << std::endl;
// 创建结构元素(5×5 矩形核)
cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(5, 5));
// 步骤 5a:开运算(先腐蚀再膨胀)—— 消除小白点噪声
// 那些随机噪点中落在红色范围内的,经过腐蚀会消失
cv::Mat mask_opened;
cv::erode(red_mask, mask_opened, kernel);
cv::dilate(mask_opened, mask_opened, kernel);
cv::imwrite("05a_mask_opened.jpg", mask_opened);
std::cout << " -> 已保存 05a_mask_opened.jpg(开运算:去除噪点)" << std::endl;
// 步骤 5b:闭运算(先膨胀再腐蚀)—— 填充灯条内部的小黑洞
cv::Mat mask_cleaned;
cv::dilate(mask_opened, mask_cleaned, kernel);
cv::erode(mask_cleaned, mask_cleaned, kernel);
cv::imwrite("05b_mask_cleaned.jpg", mask_cleaned);
std::cout << " -> 已保存 05b_mask_cleaned.jpg(闭运算:填充内部空洞)" << std::endl;
// ==========================================
// 阶段 6:在原始场景上展示检测结果
// ==========================================
std::cout << "[阶段 6/6] 在原图上标记检测结果..." << std::endl;
// 步骤 6a:用 mask 提取原图中的红色区域
cv::Mat red_only;
cv::bitwise_and(scene, scene, red_only, mask_cleaned);
cv::imwrite("06a_red_extracted.jpg", red_only);
std::cout << " -> 已保存 06a_red_extracted.jpg(仅保留红色区域)" << std::endl;
// 步骤 6b:用 Canny 边缘检测在 mask 上找灯条边缘
// 这样可以看到灯条的轮廓是否被正确提取
cv::Mat mask_edges;
cv::Canny(mask_cleaned, mask_edges, 50, 150);
cv::imwrite("06b_mask_edges.jpg", mask_edges);
std::cout << " -> 已保存 06b_mask_edges.jpg(mask 的边缘检测结果)" << std::endl;
// 步骤 6c:使用 findContours 找到灯条的轮廓并画框(进阶内容预览)
// 这是 RoboMaster 视觉的下一步:从 mask 中提取灯条的位置
// findContours: 在二值图中寻找所有白色区域的轮廓
std::vector<std::vector<cv::Point>> contours;
// RETR_EXTERNAL: 只检索最外层轮廓
// CHAIN_APPROX_SIMPLE: 压缩水平/垂直/对角线,只保留端点
cv::findContours(mask_cleaned, contours, cv::RETR_EXTERNAL,
cv::CHAIN_APPROX_SIMPLE);
// 创建结果图:在原图上画检测框
cv::Mat result = scene.clone(); // clone() 创建一个完全相同的副本
std::cout << " -> 共找到 " << contours.size() << " 个轮廓" << std::endl;
int valid_count = 0; // 统计有效灯条的个数
for (size_t i = 0; i < contours.size(); i++)
{
// 用 boundingRect 获取能包围轮廓的最小直立矩形
cv::Rect bbox = cv::boundingRect(contours[i]);
// 筛选条件:灯条应该是细长竖直的矩形
// - 面积不能太小(过滤残留的噪点)
double area = bbox.width * bbox.height;
if (area < 100) continue; // 面积小于 100 像素,忽略
// - 高度应该大于宽度(竖直灯条的特征)
if (bbox.height < bbox.width * 2) continue; // 不满足"细长"条件,忽略
// 通过筛选,画框标记
cv::rectangle(result, bbox, cv::Scalar(0, 255, 0), 3); // 绿色边框,线宽 3
valid_count++;
// 在框上方写标签
cv::putText(result, "Target " + std::to_string(valid_count),
cv::Point(bbox.x, bbox.y - 10),
cv::FONT_HERSHEY_SIMPLEX, 0.6,
cv::Scalar(0, 255, 0), 2);
}
cv::imwrite("06c_result.jpg", result);
std::cout << " -> 已保存 06c_result.jpg(最终结果:绿灯条框标记 = 检测到的灯条)" << std::endl;
std::cout << " -> 共检测到 " << valid_count << " 个有效灯条" << std::endl;
// ==========================================
// 总结
// ==========================================
std::cout << std::endl;
std::cout << "===== 实验 8 完成 =====" << std::endl;
std::cout << "流水线回顾(你完整的加工步骤):" << std::endl;
std::cout << " 1. 读取 / 生成图像" << std::endl;
std::cout << " 2. BGR → HSV 颜色空间转换" << std::endl;
std::cout << " 3. 高斯模糊(去噪)" << std::endl;
std::cout << " 4. inRange 提取目标颜色" << std::endl;
std::cout << " 5. 形态学操作(清理 mask)" << std::endl;
std::cout << " 6. 轮廓检测 + 画框(定位目标)" << std::endl;
std::cout << std::endl;
std::cout << "恭喜你!这已经是一个简化的 RoboMaster 灯条检测流程了!" << std::endl;
std::cout << "接下来的课程将学习:颜色空间调参、轮廓筛选优化、以及用工业相机实时处理" << std::endl;
return 0;
}
```
---
> **📚 讲义到此结束**
> 记住:编程不是背出来的,是**敲**出来的。每个实验都亲手敲一遍代码、编译一遍、运行一遍,你才能真正掌握。遇到问题先自己排查,再问学长学姐。祝你在 RoboMaster 视觉组学有所成!