Files

3007 lines
122 KiB
Markdown
Raw Permalink Normal View History

# 🎯 RoboMaster 视觉组 OpenCV C++ 零基础入门讲义
> **读者对象**:刚刚接触 Linux 仅一节课的大学新生
> **学习时长**:约 3~4 小时(含上机操作)
> **唯一前置要求**:会使用 `cd`、`ls`、`mkdir` 这几个最基本的 Linux 命令
> **你将获得**:从"完全不懂 OpenCV"到"能写出一个简化的 RoboMaster 红色灯条检测程序"的能力
---
## 📖 目录
0. [完整实操演练:从下载到运行(跟随操作)](#0-完整实操演练从下载到运行跟随操作)
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-附录完整源代码)
---
## 0. 完整实操演练:从下载到运行(跟随操作)
> 如果你是第一次接触这个项目,请**一步一步跟随本节的命令操作**。你会看到完整的终端输出,帮助确认每一步是否正确。
> 建议打开一个 WSL 终端窗口,**看到一条命令就敲一条**,不要一次性全部粘贴。
---
### Step 0:打开 WSL 并克隆仓库
首先按 `Win + R`,输入 `wsl`,回车,打开 WSL 终端。
```bash
# 进入你的 WSL 家目录
cd ~
# 克隆仓库
git clone https://code.tuxmonkey.cn/TuxMonkey/vision-training-2026-opencvcpp.git
# 进入仓库
cd vision-training-2026-opencvcpp
# 看看里面有什么
ls
```
**你应该看到的输出:**
```
exp1_read_write/ exp2_color_space/ exp3_gaussian_blur/ exp4_canny/
exp5_threshold/ exp6_morphology/ exp7_inrange/ exp8_pipeline/
images/ README.md
```
> **如果提示 `git: command not found`**,先运行 `sudo apt install -y git`,再重新 clone。
---
### Step 1:编译运行 exp1(图像读取与保存)
```bash
cd ~/vision-training-2026-opencvcpp/exp1_read_write
mkdir build && cd build
cmake ..
make -j4
./exp1
```
**你应该看到的关键输出:**
```
[步骤 1] 正在生成一张 640x480 的彩色测试图片...
-> 测试图片已保存为 test_image.jpg
[步骤 2] 正在读取 test_image.jpg...
-> 读取成功!图像信息:
宽度 (cols): 640 像素
高度 (rows): 480 像素
通道数: 3 (3=彩色, 1=灰度)
[步骤 3] 正在保存为 output.jpg...
-> 图片已成功保存为 output.jpg
-> 请在 Windows 资源管理器中打开该文件查看结果!
===== 实验 1 完成 =====
你学会了:
1. cv::Mat 是 OpenCV 存储图像的数据类型
2. cv::imread() 从文件读取图像
3. cv::imwrite() 将图像写入文件
4. 读取后一定要检查 empty()
```
---
### Step 2:编译运行 exp2(颜色空间转换)
```bash
cd ~/vision-training-2026-opencvcpp/exp2_color_space
mkdir build && cd build
cmake ..
make -j4
./exp2
```
**关键输出:**
```
[步骤 1] 正在生成彩色测试图片...
-> 原始彩色图已保存为 original.jpg
[步骤 2] BGR -> Gray(灰度)...
-> 已保存为 gray.jpg(单通道灰度图)
[步骤 3] BGR -> HSV...
-> 已保存为 hsv.jpg(注意:HSV 图片直接查看颜色会怪怪的...)
[步骤 4] BGR -> Lab...
-> 已保存为 lab.jpg
[步骤 5] BGR -> YCrCb...
-> 已保存为 ycrcb.jpg
[步骤 6] 分离 HSV 各通道...
-> 已保存 hsv_channel_H.jpg / S.jpg / V.jpg
===== 实验 2 完成 =====
你学会了:
1. cv::cvtColor() 可以在不同颜色空间之间转换
2. BGR → Gray:加权平均得到亮度
3. BGR → HSV:分开颜色和亮度,对光照变化更鲁棒
4. cv::split() 把多通道图像拆成单通道
5. RoboMaster 中 HSV 是核心!后面实验 7 会用到
```
---
### Step 3:编译运行 exp3(高斯模糊)
```bash
cd ~/vision-training-2026-opencvcpp/exp3_gaussian_blur
mkdir build && cd build
cmake ..
make -j4
./exp3
```
**关键输出:**
```
[步骤 1] 正在生成测试图片(含清晰边缘和纹理)...
-> 原始图片已保存为 original.jpg(注意看上面的散点噪声)
[步骤 2] 应用轻微高斯模糊 (ksize = 3x3)...
-> 已保存为 blur_3x3.jpg(轻微模糊,细节基本保留)
[步骤 3] 应用中度高斯模糊 (ksize = 9x9)...
-> 已保存为 blur_9x9.jpg(中度模糊,噪声消失,边缘变软)
[步骤 4] 应用重度高斯模糊 (ksize = 21x21)...
-> 已保存为 blur_21x21.jpg(重度模糊,近乎一片朦胧)
[步骤 5] 应用各向异性模糊 (sigmaX=15, sigmaY=3)...
-> 已保存为 blur_anisotropic.jpg(水平方向很糊,垂直方向较清晰)
===== 实验 3 完成 =====
```
---
### Step 4:编译运行 exp4(Canny 边缘检测)
```bash
cd ~/vision-training-2026-opencvcpp/exp4_canny
mkdir build && cd build
cmake ..
make -j4
./exp4
```
**关键输出:**
```
[步骤 1] 正在生成测试图片(包含各种边缘结构)...
-> 原始图片已保存为 original.jpg
[步骤 2] 转换为灰度图...
-> 灰度图已保存为 gray.jpg
[步骤 3] Canny 边缘检测 (阈值 50 / 150)...
-> 已保存为 edges_low.jpg(低阈值,细节丰富)
[步骤 4] Canny 边缘检测 (阈值 100 / 200)...
-> 已保存为 edges_mid.jpg(中等阈值,适中结果)
[步骤 5] Canny 边缘检测 (阈值 200 / 400)...
-> 已保存为 edges_high.jpg(高阈值,只保留强边缘)
[步骤 6] 先高斯模糊,再 Canny 边缘检测...
-> 已保存为 blurred.jpg(高斯模糊后)
-> 已保存为 edges_after_blur.jpg(模糊后再做 Canny,边缘更干净)
===== 实验 4 完成 =====
```
---
### Step 5:编译运行 exp5(图像阈值化)
```bash
cd ~/vision-training-2026-opencvcpp/exp5_threshold
mkdir build && cd build
cmake ..
make -j4
./exp5
```
**关键输出:**
```
[步骤 1] 正在生成灰度渐变测试图...
-> 原始渐变图已保存为 gradient.jpg
[步骤 2] THRESH_BINARY(标准二值化)...
[步骤 3] THRESH_BINARY_INV(反二值化)...
[步骤 4] THRESH_TRUNC(截断)...
[步骤 5] THRESH_TOZERO(置零)...
[步骤 6] THRESH_TOZERO_INV(反置零)...
===== 实验 5 完成 =====
你学会了:
1. 阈值化就是把灰度图按某个界限分成两部分
2. 有 5 种阈值类型,最常用的是 THRESH_BINARY
3. 阈值化是图像分割的基础操作
```
---
### Step 6:编译运行 exp6(形态学操作)
```bash
cd ~/vision-training-2026-opencvcpp/exp6_morphology
mkdir build && cd build
cmake ..
make -j4
./exp6
```
**关键输出:**
```
[步骤 1] 正在生成二值测试图片...
-> 原始二值图已保存为 binary.jpg
[步骤 2] 执行腐蚀操作 (Erosion)...
[步骤 3] 执行膨胀操作 (Dilation)...
[步骤 4] 执行开运算 (Opening = 腐蚀 + 膨胀)...
[步骤 5] 执行闭运算 (Closing = 膨胀 + 腐蚀)...
[步骤 6] 执行多次腐蚀 (iterations=3)...
===== 实验 6 完成 =====
你学会了:
1. 腐蚀:白色区域缩小,可以去噪点
2. 膨胀:白色区域扩大,可以填黑洞
3. 开运算:去噪点但大体保持目标大小
4. 闭运算:填空洞但大体保持目标大小
5. 结构元素决定了操作的形状和范围
```
---
### Step 7:编译运行 exp7(颜色提取——核心!)
```bash
cd ~/vision-training-2026-opencvcpp/exp7_inrange
mkdir build && cd build
cmake ..
make -j4
./exp7
```
**关键输出:**
```
[步骤 1] 正在生成彩色测试图片...
-> 原始彩色图片已保存为 original.jpg
[步骤 2] 将 BGR 图片转换为 HSV...
[步骤 3] 使用 inRange 提取红色区域...
[步骤 4] 用 mask 提取原图中的红色部分...
[步骤 5] 额外实验:提取蓝色区域...
===== 实验 7 完成 =====
你学会了:
1. inRange 按颜色范围提取目标区域
2. 为什么要用 HSV:分离颜色和亮度
3. 红色需要两个 H 范围(因为色相是环状)
4. bitwise_or 合并 mask,bitwise_and 提取原图
5. 这是 RoboMaster 视觉灯条检测的基础!
```
---
### Step 8:编译运行 exp8(综合流水线——串联一切!)
```bash
cd ~/vision-training-2026-opencvcpp/exp8_pipeline
mkdir build && cd build
cmake ..
make -j4
./exp8
```
**这是最重要的一个实验!你应该看到如下输出:**
```
===== 实验 8:综合流水线 开始 =====
[阶段 1/6] 生成模拟 RoboMaster 场景的测试图片...
-> 已保存 01_original_scene.jpg(原始场景图)
[阶段 2/6] BGR → HSV 颜色空间转换...
-> 已保存 02_hsv.jpg
[阶段 3/6] 高斯模糊(减少噪点对后续处理的影响)...
-> 已保存 03_hsv_blurred.jpg(模糊后的 HSV 图)
[阶段 4/6] inRange 提取红色区域...
-> 已保存 04_red_mask_raw.jpg(原始红色 mask)
[阶段 5/6] 形态学操作清理 mask...
-> 已保存 05a_mask_opened.jpg(开运算:去除噪点)
-> 已保存 05b_mask_cleaned.jpg(闭运算:填充内部空洞)
[阶段 6/6] 在原图上标记检测结果...
-> 已保存 06a_red_extracted.jpg(仅保留红色区域)
-> 已保存 06b_mask_edges.jpg(mask 的边缘检测结果)
-> 共找到 5 个轮廓
-> 已保存 06c_result.jpg(最终结果:绿灯条框标记 = 检测到的灯条)
-> 共检测到 2 个有效灯条
===== 实验 8 完成 =====
流水线回顾(你完整的加工步骤):
1. 读取 / 生成图像
2. BGR → HSV 颜色空间转换
3. 高斯模糊(去噪)
4. inRange 提取目标颜色
5. 形态学操作(清理 mask)
6. 轮廓检测 + 画框(定位目标)
恭喜你!这已经是一个简化的 RoboMaster 灯条检测流程了!
```
> **重点看这一行**:`-> 共检测到 2 个有效灯条`
> 这说明程序成功从图中找出了两个红色灯条!如果看到的是其他数字,说明筛选参数需要微调(参见讲义第 13 节)。
---
### 如何查看输出的图片
每个实验运行后,输出图片都保存在各自的 `build/` 文件夹中。有两种方式查看:
**方式一(推荐):用 Windows 资源管理器打开**
在 WSL 终端的 build 目录下运行:
```bash
explorer.exe .
```
会在 Windows 资源管理器中打开当前目录,你可以直接双击 `.jpg` 文件查看图片。
**方式二:在 WSL 中确认文件存在**
```bash
ls *.jpg
```
---
### 重要提醒
1. **每个实验完全独立**——只需要进入对应的 exp 文件夹,`mkdir build && cd build && cmake .. && make -j4 && ./expX` 即可运行。不需要按顺序执行。
2. **`mkdir build && cd build && cmake .. && make -j4` 是重复出现的"标准四步"**,每个实验都一样,请记住这套编译流程。
3. **生成的图片请仔细对比**——讲义中每个实验末尾都有"运行结果预览"表格,列出了 `asset/` 文件夹中的参考图片。把你自己生成的图片和参考图片做对比,确保效果一致。
4. **如果编译报错**:
- 最常见的错误是 OpenCV 没装好,运行 `pkg-config --modversion opencv4` 确认版本号能正确输出。
- 其次是在错误的目录运行了 cmake,请确保你**在 `build/` 目录内**运行 `cmake ..`(注意是两个点 `..`,表示上一级目录)。
5. **所有实验的运行结果图片都可在 `asset/` 文件夹找到参考版本**,如果你不确定自己的输出是否正确,可以对比参考图。
---
## 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 视觉组学有所成!