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

2340 lines
101 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.
# OpenCV C++ 零基础入门培训教案
> **适用对象**:仅接触过一节 Linux 课程的大学新生
> **教学环境**:WSL (Ubuntu)、不依赖 X11 图形界面
> **图像查看方式**:`cv::imwrite` 保存图片 → Windows 资源管理器直接浏览
> **课程时长**:约 3~4 小时(含上机操作)
---
## 目录
1. [开场白与课程目标](#1-开场白与课程目标)
2. [环境准备](#2-环境准备)
- [2.1 确认 WSL Ubuntu 已安装](#21-确认-wsl-ubuntu-已安装)
- [2.2 安装 OpenCV 开发库](#22-安装-opencv-开发库)
- [2.3 WSL 与 Windows 文件互访](#23-wsl-与-windows-文件互访)
- [2.4 进入课程根目录](#24-进入课程根目录)
- [2.5 创建所有实验文件夹](#25-创建所有实验文件夹)
- [2.6 中文乱码处理](#26-中文乱码处理)
3. [目录结构总览](#3-目录结构总览)
4. [逐一展开每个实验](#4-逐一展开每个实验)
- [4.1 实验 1:图像读取与保存](#41-实验-1图像读取与保存)
- [4.2 实验 2:颜色空间转换](#42-实验-2颜色空间转换)
- [4.3 实验 3:高斯模糊](#43-实验-3高斯模糊)
- [4.4 实验 4:Canny 边缘检测](#44-实验-4canny-边缘检测)
- [4.5 实验 5:图像阈值化](#45-实验-5图像阈值化)
- [4.6 实验 6:形态学操作——腐蚀与膨胀](#46-实验-6形态学操作腐蚀与膨胀)
- [4.7 实验 7:颜色提取——inRange](#47-实验-7颜色提取inrange)
- [4.8 实验 8:综合流水线](#48-实验-8综合流水线)
5. [结语与下一步预告](#5-结语与下一步预告)
6. [附录:所有实验源文件完整代码](#6-附录所有实验源文件完整代码)
---
## 1. 开场白与课程目标
### 开场白
同学们好!欢迎来到 RoboMaster 视觉组的第一堂 OpenCV 入门课。
在正式开始之前,我想先聊一个大家一定会问的问题——**"什么是计算机视觉?"**
我们每时每刻都在用眼睛看世界:你看到屏幕上有字、窗外有树、桌上有水杯。你的大脑能立刻识别出"这是一行字""那是一棵树""那是我的杯子"。这个过程对人来一眨眼就完成了,但对计算机来说却极其困难——因为计算机看到的只是一个个数字(像素值),它不知道哪些数字凑在一起是"杯子",哪些是"桌子"。
而 **OpenCV**(Open Source Computer Vision Library,开源计算机视觉库)就是帮计算机"看懂"图像的工具箱。它提供了数千个经过优化的图像处理函数,让**我们不需要从零发明轮子**,就能快速实现各种视觉任务。
在 RoboMaster 比赛中,视觉组的核心工作就是用 OpenCV 处理摄像头采集的图像,**识别出对方机器人的装甲板灯条**,然后计算它的三维位置,发送给云台射击。今天这堂课,就是这一切的起点。
### 课程目标
通过本次课程,你将学会:
| 编号 | 技能 | 对应实验 |
|------|------|----------|
| ① | 在 WSL 中安装 OpenCV,使用 CMake 编译 C++ 程序 | 全部 |
| ② | 读取、保存图像,理解 `cv::Mat` 数据类型 | 实验 1 |
| ③ | 在不同颜色空间(BGR、Gray、HSV)之间转换 | 实验 2 |
| ④ | 使用高斯模糊去除图像噪点 | 实验 3 |
| ⑤ | 用 Canny 算法检测图像中的边缘 | 实验 4 |
| ⑥ | 对灰度图做阈值化处理,生成二值图 | 实验 5 |
| ⑦ | 使用腐蚀和膨胀对二值图做形态学处理 | 实验 6 |
| ⑧ | 用 inRange 在 HSV 空间中提取特定颜色区域 | 实验 7 |
| ⑨ | 将以上所有技术串联成完整处理流水线 | 实验 8 |
> [!NOTE]
> 本教案假设你**已经在 Windows 上安装了 WSL Ubuntu**,并且完成了第一堂 Linux 基础课(知道 `cd`、`ls`、`mkdir` 等基本命令)。如果还没安装 WSL,请参考学校提供的 WSL 安装指南。
---
## 2. 环境准备
### 2.1 确认 WSL Ubuntu 已安装
打开 Windows Terminal(或 PowerShell),输入:
```bash
wsl -l -v
```
你应该看到类似这样的输出,表示 Ubuntu 已安装并正在运行:
```
NAME STATE VERSION
* Ubuntu Running 2
```
> [!WARNING]
> 如果这里显示的是 `Stopped`,输入 `wsl` 即可启动 Ubuntu。如果根本没有 Ubuntu,你需先在 Microsoft Store 搜索 "Ubuntu" 安装。
现在进入 WSL 终端:
```bash
wsl
```
你会看到命令行提示符变成了类似 `username@DESKTOP:~$` 的样式,这表示你已经进入了 WSL Ubuntu 环境。
### 2.2 安装 OpenCV 开发库
在 WSL Ubuntu 终端中,依次执行以下命令(**每行一条,逐行复制粘贴**):
```bash
# 第 1 步:更新软件包列表(就像是刷新应用商店的目录)
sudo apt update
# 第 2 步:安装 OpenCV C++ 开发库
# libopencv-dev 包含了头文件和 .so 动态库
# build-essential 包含了 g++ 编译器和 make 工具
# cmake 是我们用来管理编译过程的工具
sudo apt install -y build-essential cmake libopencv-dev
# 第 3 步:验证安装是否成功
# 这条命令会打印 OpenCV 的版本号,比如 4.5.4
pkg-config --modversion opencv4
```
> [!TIP]
> **`sudo` 是什么?**
> 在 Linux 中,普通用户不能随意修改系统文件。`sudo` 的意思是"以管理员身份执行后面的命令"(Super User DO)。安装软件属于系统级操作,所以需要 `sudo`。第一次使用时会提示你输入密码(不会显示任何字符,这是正常的安全设计,盲输即可)。
如果 `pkg-config --modversion opencv4` 输出了一个版本号(例如 `4.5.4`),恭喜,OpenCV 安装成功!
### 2.3 WSL 与 Windows 文件互访
这是很重要的知识点:**WSL 和 Windows 虽然是两个系统,但它们的硬盘是互通的。**
- **在 WSL 中访问 Windows 文件**:Windows 的 C 盘对应 WSL 中的 `/mnt/c`,D 盘对应 `/mnt/d`。所以你在 Windows 下的 `D:\RM\25教学\视觉1\OpenCV-CPP` 在 WSL 中的路径是 `/mnt/d/RM/25教学/视觉1/OpenCV-CPP`。
- **在 Windows 中访问 WSL 文件**:在 Windows 文件资源管理器的地址栏输入 `\\wsl$\Ubuntu`,然后回车,你就能看到整个 WSL 的文件系统。进入 `/home/你的用户名/` 就能找到你在 WSL 中的家目录。
> [!TIP]
> **为什么要把代码放在 `/mnt/d/...` 而不是 WSL 的 `/home/...`?**
> 放在 `/mnt/d/...` 下的文件,可以**直接通过 Windows 资源管理器**打开和查看(包括图片),非常方便。如果放在 `/home/...` 下,虽然也能通过 `\\wsl$` 访问,但路径更深,而且跨文件系统读写性能稍差。
### 2.4 进入课程根目录
假设你已经通过 GitHub 把本次课程的代码仓库下载到了 `D:\RM\25教学\视觉1\OpenCV-CPP`。
在 WSL 终端中执行:
```bash
# 进入课程根目录(注意:路径中的"25教学"等中文是文件夹名)
cd /mnt/d/RM/25教学/视觉1/OpenCV-CPP
# 确认当前目录正确(查看当前路径)
pwd
# 查看目录内容,应该能看到 8 个 exp 开头的子文件夹
ls -l
```
如果 `ls -l` 显示了 `exp1_read_write`、`exp2_color_space` ... 等信息,说明你已经在正确的位置了。
### 2.5 创建所有实验文件夹
如果代码仓库尚未包含这些文件夹,你可以一次性创建它们:
```bash
mkdir -p exp1_read_write exp2_color_space exp3_gaussian_blur exp4_canny \
exp5_threshold exp6_morphology exp7_inrange exp8_pipeline
```
> `mkdir -p` 的 `-p` 参数:如果父目录不存在就自动创建(这里所有实验文件夹都在当前目录下,所以 `-p` 主要是防止"目录已存在"的错误)。
### 2.6 中文乱码处理
如果你在 WSL 终端中看到中文显示为乱码(比如 `<60>学<EFBFBD>视觉1`),执行以下命令可以临时修复:
```bash
export LANG=zh_CN.UTF-8
```
这只是临时生效(当前终端窗口关闭后失效)。如果想永久生效,可以把它加入 `~/.bashrc` 文件:
```bash
echo 'export LANG=zh_CN.UTF-8' >> ~/.bashrc
source ~/.bashrc
```
> [!NOTE]
> 中文乱码不影响代码的编译和运行,只是终端显示不好看而已。如果赶时间,可以跳过这一步。
---
## 3. 目录结构总览
本课程的文件夹结构如下。每个实验文件夹中都有且仅有两个文件:`CMakeLists.txt`(编译配置)和 `main.cpp`(源代码),**均可独立编译运行**。
```
OpenCV-CPP/
├── README.md ← 你正在看的这份教案
│
├── exp1_read_write/ ← 实验 1:图像读取与保存
│ ├── CMakeLists.txt
│ └── main.cpp
│
├── 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:颜色提取(inRange)
│ ├── CMakeLists.txt
│ └── main.cpp
│
└── exp8_pipeline/ ← 实验 8:综合流水线
├── CMakeLists.txt
└── main.cpp
```
每个实验的可执行文件命名规则:
| 实验 | 项目名 (CMake) | 可执行文件名 | 运行命令 |
|------|---------------|-------------|----------|
| 1 | `exp1_read_write` | `exp1` | `./exp1` |
| 2 | `exp2_color_space` | `exp2` | `./exp2` |
| 3 | `exp3_gaussian_blur` | `exp3` | `./exp3` |
| 4 | `exp4_canny` | `exp4` | `./exp4` |
| 5 | `exp5_threshold` | `exp5` | `./exp5` |
| 6 | `exp6_morphology` | `exp6` | `./exp6` |
| 7 | `exp7_inrange` | `exp7` | `./exp7` |
| 8 | `exp8_pipeline` | `exp8` | `./exp8` |
---
## 4. 逐一展开每个实验
### 统一的编译与运行流程
为了避免在每个实验小节中重复说明,这里先给出**标准操作步骤**。之后每个实验的"编译与运行"小节将直接引用这套流程。
```bash
# 第 1 步:进入实验文件夹
cd expX_xxxxxx
# 第 2 步:创建编译目录 build 并进入
# 为什么要单独建一个 build 目录?
# 因为编译过程会生成很多中间文件(.o 文件、Makefile 等),
# 把它们和源代码混在一起会很乱,所以统一放在 build/ 里。
mkdir -p build
cd build
# 第 3 步:用 CMake 生成 Makefile
# cmake .. 中的 ".." 表示上级目录(即源代码所在的 expX_xxxxxx/)
# cmake 会根据 CMakeLists.txt 检查依赖、找到 OpenCV、生成编译规则
cmake ..
# 第 4 步:编译
# make 命令读取 cmake 生成的 Makefile,调用 g++ 编译源代码
# -j$(nproc) 表示"用所有 CPU 核心并行编译",加速编译过程
make -j$(nproc)
# 第 5 步:运行可执行文件
./expX
# 第 6 步:返回根目录
cd ../..
```
> [!IMPORTANT]
> **一定要在 build 目录中编译和运行!**
> 原因是:程序用 `imwrite("xxx.jpg", image)` 保存图片时,图片文件会保存在**程序运行时的当前工作目录**(即 `build/` 下),而不是源代码所在的目录。这样做的好处是——生成的图片文件和编译中间文件在一起,方便清理(直接删掉 `build/` 目录就全干净了)。
---
### 4.1 实验 1:图像读取与保存
#### 目标
- 理解 `cv::Mat` —— OpenCV 中存储图像的"万能容器"
- 学会 `cv::imread` 从文件中读取图像
- 学会 `cv::imwrite` 将图像存入文件
- 养成 `empty()` 检查的好习惯
#### 涉及函数及参数详解
##### (1) `cv::Mat`
```
cv::Mat(行数, 列数, 类型, 初始值)
```
`cv::Mat` 是 OpenCV 最基本的数据类型。"Mat" 是 Matrix(矩阵)的缩写——因为**图像本质上就是一个二维矩阵**:
- 矩阵的每一行对应图像的一行像素
- 矩阵的每一列对应图像的一列像素
- 矩阵中每个元素的值就是该像素的亮度/颜色
**生活比喻**:把 `cv::Mat` 想象成一张**坐标纸**(就是数学课上用的那种方格纸)。每个小格子是一个像素,格子里填的数字就是颜色值。OpenCV 在内存中就是把图像存储为这样的"数字矩阵"。
##### (2) `cv::imread`
```cpp
cv::Mat image = cv::imread("文件名", 读取模式);
```
- **第 1 个参数**:文件路径(相对路径或绝对路径)。如果只写文件名如 `"test.jpg"`,程序会在**当前工作目录**(你运行程序时所在的目录)下查找。
- **第 2 个参数**(可选,默认 `cv::IMREAD_COLOR`):
- `cv::IMREAD_COLOR`(或数字 `1`):以**彩色**模式读取(BGR,3 通道)。即使原图是灰度的,也会被转换为 3 通道。
- `cv::IMREAD_GRAYSCALE`(或数字 `0`):以**灰度**模式读取(单通道)。
- `cv::IMREAD_UNCHANGED`(或数字 `-1`):保持原样不转换(如果图片包含 Alpha 透明通道,会一起读进来)。
**生活比喻**:`imread` 像是一台"扫描仪"——把纸上的照片(文件)转换为电脑里的数字矩阵(`cv::Mat`)。
##### (3) `cv::imwrite`
```cpp
bool ok = cv::imwrite("文件名", 图像矩阵);
```
- **第 1 个参数**:保存路径。文件格式由扩展名自动决定(`.jpg`、`.png`、`.bmp` 等)。
- **第 2 个参数**:要保存的 `cv::Mat` 图像。
- **返回值**:`true` = 保存成功,`false` = 失败(通常是因为路径不存在或磁盘满了)。
**生活比喻**:`imwrite` 像是一台"打印机"——把电脑里的数字矩阵(`cv::Mat`)打印成纸上的照片(文件)。
##### (4) `image.empty()` —— 不可忽略的检查
```cpp
if (image.empty()) { /* 出错处理 */ }
```
如果 `imread` 找不到文件、文件损坏或格式不支持,它不会报错,而是返回一个"空"的 `cv::Mat`。这个空的 Mat 内部没有任何数据。如果你不检查 `empty()` 就直接用 `image`,后续任何操作(如显示、保存)都会导致程序崩溃。
**生活比喻**:就像你让朋友帮忙去超市买牛奶,结果超市卖完了,朋友空手回来。如果你不检查就拧开瓶盖喝,会喝到空气。`empty()` 就是那句"牛奶买到了吗?"的确认。
#### 代码关键片段解释
本实验的 `main.cpp` 分为三个部分:
1. **生成测试图片**:用 `cv::circle`、`cv::rectangle`、`cv::putText` 等画图函数在 640×480 的画布上画出彩色图形,然后用 `imwrite` 保存为 `test_image.jpg`。这样做的目的是**不依赖外部图片**——程序自己生成测试素材。
2. **读取图片**:用 `cv::imread("test_image.jpg", cv::IMREAD_COLOR)` 读回刚才保存的文件。**紧接着**就用 `image.empty()` 检查——如果因为某种原因读取失败,程序会打印清晰的错误提示并退出,而不是糊里糊涂地崩掉。
3. **保存验证**:把读到的 `cv::Mat` 用 `imwrite` 保存为 `output.jpg`。如果 `output.jpg` 和 `test_image.jpg` 一模一样,就证明了"读取 → 保存"的整个链条工作正常。
#### 编译与运行
```bash
cd /mnt/d/RM/25教学/视觉1/OpenCV-CPP/exp1_read_write
mkdir -p build && cd build
cmake .. && make -j$(nproc)
./exp1
```
#### 查看结果
在 Windows 文件资源管理器的地址栏输入:
```
\\wsl$\Ubuntu\mnt\d\RM\25教学\视觉1\OpenCV-CPP\exp1_read_write\build
```
你会发现这里面多了两个文件:`test_image.jpg`(程序生成的测试图)和 `output.jpg`(读取后再保存的图)。双击打开,看看是不是有蓝色、绿色、红色的圆和一个黄色矩形框?
> [!NOTE]
> 这条路径比较长,建议你把它收藏到资源管理器的快速访问栏,后面每个实验都要用。你只需要修改其中的实验文件夹名就能访问不同实验的输出。
---
### 4.2 实验 2:颜色空间转换
#### 目标
- 理解"颜色空间"的含义——描述颜色的不同"语言"
- 掌握 `cv::cvtColor` 函数
- 了解 Gray(灰度)、HSV、Lab、YCrCb 各自的用途
- 为后续的 inRange 颜色提取做好准备
#### 涉及函数及参数详解
##### (1) `cv::cvtColor`
```cpp
cv::cvtColor(输入图, 输出图, 转换代码);
```
这是 OpenCV 中**最常用的函数之一**。只要你想在颜色空间之间切换,就会用到它。
- **第 1 个参数**:输入图像
- **第 2 个参数**:输出图像(函数会自动分配空间,不需要手动创建)
- **第 3 个参数**:转换代码,格式为 `cv::COLOR_原空间2目标空间`
最常用的转换代码:
| 转换代码 | 含义 | 典型用途 |
|----------|------|----------|
| `cv::COLOR_BGR2GRAY` | BGR → 灰度 | 边缘检测、阈值化的前置步骤 |
| `cv::COLOR_BGR2HSV` | BGR → HSV | 颜色提取(如 inRange) |
| `cv::COLOR_BGR2Lab` | BGR → Lab | 颜色距离计算 |
| `cv::COLOR_BGR2YCrCb` | BGR → YCrCb | 图像/视频压缩 |
| `cv::COLOR_GRAY2BGR` | 灰度 → BGR | 把灰度图转回 3 通道便于拼接显示 |
| `cv::COLOR_HSV2BGR` | HSV → BGR | 在 HSV 空间调色后转回正常显示 |
##### (2) HSV 颜色空间详解
这是 **RoboMaster 中最关键的颜色空间**,一定要理解透彻。
- **H(Hue,色相)**:描述"什么颜色"。取值范围 0~180(OpenCV 把标准的 360° 压缩了一半)。0 是红色,60 是绿色,120 是蓝色——就像色环上的角度。
- **S(Saturation,饱和度)**:描述"颜色有多纯"。0 是灰色(没颜色),255 是纯色(最鲜艳)。就像往颜料里掺水——水越多,颜色越淡。
- **V(Value,明度)**:描述"有多亮"。0 是黑色(没光),255 是最亮。就像调灯光亮度。
**为什么 HSV 比 BGR 更适合做颜色提取?**
想象你有一件红衣服:在阳光下看是鲜红色的,在室内暗光下看是暗红色的。在 BGR 空间中,这两种红色对应的 (B, G, R) 数值差异巨大——亮红是 `(0, 0, 255)`,暗红可能是 `(0, 0, 80)`。但在 HSV 空间中,它们的 **H(色相)值几乎不变**(都是 0 左右),只是 **V(明度)值不同**。所以我们可以设定 H 的范围来框住"红色",通过调节 S、V 的上下界来控制挑选条件,**把颜色和亮度解耦**——这是 inRange 提取颜色的核心原理。
##### (3) `cv::split`
```cpp
cv::split(多通道图像, 单通道图像数组);
```
将一幅多通道图像(如 3 通道的 HSV 图)拆成多个独立的单通道图像。比如把 HSV 图拆成 H 图、S 图、V 图三张灰度图,方便你观察每个分量的样子。
#### 代码关键片段解释
1. 生成 6 个彩色矩形(红、绿、蓝、黄、青、品红),作为测试素材。
2. 逐一对原始彩色图做 `cv::cvtColor`,分别转换为 Gray、HSV、Lab、YCrCb 空间并保存。
3. 用 `cv::split` 把 HSV 图拆分成 H、S、V 三个通道单独保存——观察 S 通道你会发现,白色文字区域的 S 值很低(因为白色没有"颜色"这个概念),这解释了为什么 inRange 时要把 S 下界设得足够高来排除白色。
#### 编译与运行
```bash
cd /mnt/d/RM/25教学/视觉1/OpenCV-CPP/exp2_color_space
mkdir -p build && cd build
cmake .. && make -j$(nproc)
./exp2
```
#### 查看结果
进入 `\\wsl$\Ubuntu\mnt\d\RM\25教学\视觉1\OpenCV-CPP\exp2_color_space\build`,你会看到 `original.jpg`、`gray.jpg`、`hsv.jpg`、`hsv_channel_H.jpg`、`hsv_channel_S.jpg`、`hsv_channel_V.jpg` 等图片。
---
### 4.3 实验 3:高斯模糊
#### 目标
- 理解"模糊"的数学本质——邻域加权平均
- 掌握 `cv::GaussianBlur` 及参数含义
- 观察不同核大小和 sigma 值对模糊效果的影响
#### 涉及函数及参数详解
##### `cv::GaussianBlur`
```cpp
cv::GaussianBlur(src, dst, ksize, sigmaX, sigmaY, borderType);
```
| 参数 | 类型 | 说明 |
|------|------|------|
| `src` | `cv::Mat` | 输入图像 |
| `dst` | `cv::Mat` | 输出图像(函数自动分配空间) |
| `ksize` | `cv::Size(w, h)` | 高斯核大小(**必须为奇数!**) |
| `sigmaX` | `double` | X 方向标准差(0 = 根据 ksize 自动算) |
| `sigmaY` | `double` | Y 方向标准差(0 = 等于 sigmaX) |
| `borderType` | `int` | 边界填充方式(可选,默认即可) |
**核心参数:ksize 和 sigma**
- **ksize**:决定了"模糊的范围"——每个像素要和周围多大范围内的邻居做平均。`(3, 3)` 很小,几乎看不出模糊;`(21, 21)` 很大,图片糊成一片。必须设**奇数**(3, 5, 7, 9, ...),因为核必须有一个明确的"中心像素"。
- **sigma**:决定了"权重分配的陡缓"。sigma 越大,远处像素的权重越大,模糊越均匀(看起来更"绵软");sigma 越小,只有很近的像素才被计入,模糊效果较弱。一般来说**直接设 0 让 OpenCV 自动算**就可以了。
**生活比喻**:高斯模糊就像在 Photoshop 里拖动"模糊"滑块。ksize 是"模糊半径"——半径越大,糊得越厉害。高斯函数保证了距离中心越近的像素权重越大,这比简单的"取平均"更自然——毕竟你的好朋友对你的影响力,肯定比隔壁班不认识的同学大。
**为什么核大小必须是奇数?**
因为核需要有一个对称的"中心点",就像靶子需要一个靶心。偶数核没有中心,没法对称。`cv::Size(3, 3)` 的中心是第 2 行第 2 列那个像素;`cv::Size(4, 4)` 呢?第 2 行和第 3 行的分界线上?这就没法定义了。
#### 代码关键片段解释
本实验在 400×400 画布上画了圆、矩形、三角形、文字和**随机散点**(模拟噪声),然后用四种不同参数做高斯模糊:
- `blur_light`:ksize=(3,3)——轻微模糊,只消掉最小的噪声
- `blur_medium`:ksize=(9,9)——中度模糊,散点开始消失,边缘变软
- `blur_heavy`:ksize=(21,21)——重度模糊,细节全无
- `blur_aniso`:ksize=(0,0),sigmaX=15,sigmaY=3——**各向异性模糊**(horizontal 方向糊得很厉害,vertical 方向保持了较多的清晰度),演示了 sigmaX 和 sigmaY 独立控制的效果
#### 编译与运行
```bash
cd /mnt/d/RM/25教学/视觉1/OpenCV-CPP/exp3_gaussian_blur
mkdir -p build && cd build
cmake .. && make -j$(nproc)
./exp3
```
#### 查看结果
进入 `\\wsl$\Ubuntu\mnt\d\RM\25教学\视觉1\OpenCV-CPP\exp3_gaussian_blur\build`,对比 `original.jpg`、`blur_3x3.jpg`、`blur_9x9.jpg`、`blur_21x21.jpg` 四张图,观察散点噪声如何逐渐消失。
---
### 4.4 实验 4:Canny 边缘检测
#### 目标
- 理解什么是"边缘"——图像中亮度剧变的地方
- 初步了解 Canny 算法的工作流程
- 掌握 `cv::Canny` 的用法及双阈值的含义
#### 涉及函数及参数详解
##### `cv::Canny`
```cpp
cv::Canny(src, dst, threshold1, threshold2, apertureSize, L2gradient);
```
| 参数 | 说明 |
|------|------|
| `src` | 输入图像(**必须是 8 位单通道灰度图!**)彩色图必须先转灰度 |
| `dst` | 输出图像(二值图,边缘 = 255 白色,非边缘 = 0 黑色) |
| `threshold1` | 低阈值(弱边缘的最低门槛) |
| `threshold2` | 高阈值(强边缘的门槛) |
| `apertureSize` | Sobel 算子孔径(默认 3,一般不改) |
| `L2gradient` | 是否用更精确的梯度计算(默认 false,用更快的近似) |
**threshold1 和 threshold2 的关系(迟滞阈值)**:
Canny 算法最精妙的设计就是这个"双阈值 + 迟滞"机制:
- 梯度强度 **> threshold2** 的像素 → **必然是边缘**(强边缘,保留)
- 梯度强度 **< threshold1** 的像素 → **必然不是边缘**(直接丢弃)
- 梯度强度在 threshold1 和 threshold2 **之间**的像素 → **看情况**:如果它与某个强边缘像素相邻(连成一条线),就保留;如果孤零零地没有"大腿抱",就丢弃。
**生活比喻**:这就像大学社团招新——threshold2 是"面试官直接拍了板的优秀新生"(直接录取),threshold1 是"明显不合格的"(直接淘汰)。介于两者之间的,看他们**有没有认识的学长学姐**(是否和已录取的人有关系)来决定。有关系的人优先录取,完全没背景的就算了。
> 推荐 threshold2 大约为 threshold1 的 **2~3 倍**。`(50, 150)` 和 `(100, 200)` 都是常用组合。
**边缘检测前为什么要先高斯模糊?**
噪点也是"亮度剧变",如果不先模糊,Canny 会把噪点也当作边缘。先模糊能消除这些假边缘(所以实验 8 的 pipeline 里是"先模糊再 Canny")。
#### 代码关键片段解释
1. 生成包含圆形、矩形、线条、文字和各种图形边缘的测试图
2. 转灰度(因为 Canny 只接受单通道输入)
3. 做三组不同阈值的 Canny:
- `(50, 150)`:低阈值 → 大量细节边缘
- `(100, 200)`:中等阈值 → 适中结果
- `(200, 400)`:高阈值 → 只保留最强烈的边缘
4. 多做一组:先 `GaussianBlur` 再用 `(50, 150)` 做 Canny,与不做模糊的结果对比——你会发现模糊后的边缘更干净、连续
#### 编译与运行
```bash
cd /mnt/d/RM/25教学/视觉1/OpenCV-CPP/exp4_canny
mkdir -p build && cd build
cmake .. && make -j$(nproc)
./exp4
```
#### 查看结果
进入 `\\wsl$\Ubuntu\mnt\d\RM\25教学\视觉1\OpenCV-CPP\exp4_canny\build`,对比 `edges_low.jpg`、`edges_mid.jpg`、`edges_high.jpg`,以及 `edges_after_blur.jpg`,观察阈值和预处理对结果的影响。
---
### 4.5 实验 5:图像阈值化
#### 目标
- 理解"阈值化"——把灰度图按照某个界限一刀切成两部分
- 掌握 `cv::threshold` 的 5 种阈值类型
- 理解二值图在后续处理中的基础地位
#### 涉及函数及参数详解
##### `cv::threshold`
```cpp
cv::threshold(src, dst, thresh, maxval, type);
```
| 参数 | 说明 |
|------|------|
| `src` | 输入图像(**必须是单通道灰度图**) |
| `dst` | 输出图像 |
| `thresh` | 阈值(那个"分界线") |
| `maxval` | 满足条件时赋予的最大值(通常为 255) |
| `type` | 阈值类型(决定"怎么切") |
**5 种阈值类型(假设 thresh=128, maxval=255)**:
| 类型 | 像素 > 128 时 | 像素 ≤ 128 时 | 效果 |
|------|-------------|-------------|------|
| `THRESH_BINARY` | 设为 255(白) | 设为 0(黑) | 标准二值化 |
| `THRESH_BINARY_INV` | 设为 0(黑) | 设为 255(白) | 反色二值化 |
| `THRESH_TRUNC` | 设为 128(截断) | 保持原值 | 亮部被"削平" |
| `THRESH_TOZERO` | 保持原值 | 设为 0(黑) | 暗部清零 |
| `THRESH_TOZERO_INV` | 设为 0(黑) | 保持原值 | 亮部清零 |
**生活比喻**:把 0~255 的灰度值想象成 0~100 分的考试成绩。阈值 = 60 分(及格线)。
- `THRESH_BINARY`:≥60 分的算"通过(=255)",<60 分的算"不通过(=0)"。成绩单上只有"通过/不通过"两种结果。
- `THRESH_BINARY_INV`:反过来,≥60 分的算"不通过",<60 分的算"通过"。
- `THRESH_TRUNC`:≥60 分的全部记作 60 分(分数被"截断"了,不允许超过 60)。这就是某些竞赛里"满分就按 100 算,超过也按 100 算"。
- `THRESH_TOZERO`:≥60 分的保留原始分数,<60 分的全部记 0 分。
- `THRESH_TOZERO_INV`:反过来,<60 分的保留原始分数,≥60 分的全部记 0 分。
**RoboMaster 中最常用哪个?**
`THRESH_BINARY`——将灰度图直接转化为非黑即白的二值图,这是形态学处理和轮廓检测的前置要求。
#### 代码关键片段解释
1. 手工用双重循环生成一张从左到右灰度渐变的图(0 → 255)
2. 在 x=256 处画一条白线标记阈值 128 的位置
3. 对同一张渐变图分别应用 5 种阈值类型并保存——打开图片对比,你能直观地看到每种类型的"切法"完全不同
#### 编译与运行
```bash
cd /mnt/d/RM/25教学/视觉1/OpenCV-CPP/exp5_threshold
mkdir -p build && cd build
cmake .. && make -j$(nproc)
./exp5
```
---
### 4.6 实验 6:形态学操作——腐蚀与膨胀
#### 目标
- 理解"腐蚀"和"膨胀"对二值图的作用
- 掌握 `cv::erode`、`cv::dilate`、`cv::getStructuringElement`
- 了解"开运算"和"闭运算"及其应用场景
#### 涉及函数及参数详解
##### (1) `cv::erode`(腐蚀)
```cpp
cv::erode(src, dst, kernel, anchor, iterations);
```
**效果**:白色区域"瘦一圈"。腐蚀会**吃掉白色区域的外层**。如果白色物体本身就很小(如背景噪声点),腐蚀后可能直接消失。
**生活比喻**:海边的沙堡被涨潮的海水冲——沙堡边缘的沙子被海水"吃掉"了。
##### (2) `cv::dilate`(膨胀)
```cpp
cv::dilate(src, dst, kernel, anchor, iterations);
```
**效果**:白色区域"胖一圈"。膨胀会把白色区域**向外扩张**,如果两个白色物体离得很近,膨胀后可能连在一起。
**生活比喻**:给房子外围砌了一圈围墙——房子变大了。
##### (3) `cv::getStructuringElement`
```cpp
cv::Mat kernel = cv::getStructuringElement(形状, cv::Size(w, h));
```
| 形状 | 说明 |
|------|------|
| `MORPH_RECT` | 矩形核(最常用) |
| `MORPH_ELLIPSE` | 椭圆核(圆形范围) |
| `MORPH_CROSS` | 十字形核(只影响上下左右方向) |
`cv::Size(w, h)` 越大,每次操作的"波及范围"越大。
##### (4) 开运算与闭运算
| 名称 | 步骤 | 效果 | RoboMaster 用途 |
|------|------|------|----------------|
| **开运算** | 先腐蚀,再膨胀 | 去掉小白点噪声,保持物体大小不变 | 去掉 inRange 后的散点误检 |
| **闭运算** | 先膨胀,再腐蚀 | 填充物体内部的黑色小洞,保持物体大小不变 | 填补灯条提取后出现的内部断裂 |
> OpenCV 也提供了 `cv::morphologyEx` 函数可以一步完成开/闭运算(参数 `MORPH_OPEN` / `MORPH_CLOSE`),但为了让你理解原理,实验代码中**手动**实现了这两步。
#### 代码关键片段解释
1. 生成测试图:白色矩形(目标)+ 矩形内部两个黑色圆洞(模拟空洞)+ 背景大量白色小散点(模拟噪声)
2. **腐蚀**:背景散点被消除或缩小(因为太小),矩形变瘦,内部黑洞变大
3. **膨胀**:背景散点变大(坏事),矩形变胖,内部黑洞变小甚至被填充
4. **开运算**:先腐蚀消灭散点,再膨胀把矩形恢复原大小——但内部的黑洞被腐蚀放大了,恢复不了
5. **闭运算**:先膨胀填充黑洞,再腐蚀把矩形恢复原大小——但背景散点也被膨胀放大了,收不回来
6. **3 次腐蚀**:演示 `iterations` 参数的作用
**一个重要的观察**:开运算擅长"去噪但保持大小",闭运算擅长"填空但保持大小"——它们各有所长,实际应用中经常**先开后闭**,结合使用。
#### 编译与运行
```bash
cd /mnt/d/RM/25教学/视觉1/OpenCV-CPP/exp6_morphology
mkdir -p build && cd build
cmake .. && make -j$(nproc)
./exp6
```
#### 查看结果
进入 `\\wsl$\Ubuntu\mnt\d\RM\25教学\视觉1\OpenCV-CPP\exp6_morphology\build`,依次对比 `binary.jpg`(原始)→ `eroded.jpg`(腐蚀)→ `dilated.jpg`(膨胀)→ `opened.jpg`(开运算)→ `closed.jpg`(闭运算),感受每种操作的效果。
---
### 4.7 实验 7:颜色提取——inRange
> [!IMPORTANT]
> **这是 RoboMaster 最核心的实验!** 本节课结束后,你将对"如何在图像中找到红色灯条"有完整的概念。
#### 目标
- 掌握 `cv::inRange` 的用法
- 理解为什么要在 HSV 空间而非 BGR 空间做颜色提取
- 掌握红色的典型 HSV 范围——**这是 RoboMaster 红色灯条检测的基础**
- 学会用 `bitwise_and` 和 `bitwise_or` 操作 mask
#### 涉及函数及参数详解
##### (1) `cv::inRange`
```cpp
cv::inRange(src, lowerb, upperb, dst);
```
- **`src`**:输入图像(强烈推荐先转为 HSV!)
- **`lowerb`**:颜色范围的**下界**——`cv::Scalar(H_min, S_min, V_min)`
- **`upperb`**:颜色范围的**上界**——`cv::Scalar(H_max, S_max, V_max)`
- **`dst`**:输出 mask 二值图
- 白色(255)→ 该像素的 HSV 值在 [lowerb, upperb] 区间内
- 黑色(0)→ 不在区间内
##### (2) 红色的 HSV 范围(RoboMaster 红色灯条)
这是一个非常关键的知识点。在 OpenCV 中,**红色的 H 值跨越了 0° 的分界线**(因为色相是一个环,0°=360°=红色)。所以提取红色需要**两个区间合并**:
```
区间 1:H ∈ [0, 10] 纯红 ~ 偏橙红
区间 2:H ∈ [160, 180] 偏紫红 ~ 绕回到纯红
```
| 参数 | 推荐范围 | 说明 |
|------|----------|------|
| **H 下限** | 0 或 160 | 红色的两个 Hue 区间的起点 |
| **H 上限** | 10 或 180 | 红色的两个 Hue 区间的终点 |
| **S 下限** | 100 | 排除接近灰白的颜色(饱和度太低 = 没颜色) |
| **S 上限** | 255 | 最鲜艳(一般不动) |
| **V 下限** | 100 | 排除太暗的像素(可能是阴影或黑色物体) |
| **V 上限** | 255 | 最亮(一般是 255) |
> [!WARNING]
> 这些值**不是一成不变的**!实际比赛时,场地灯光、相机白平衡、曝光参数都会影响 HSV 值。你需要在比赛前做**现场调参**——用滑动条工具(比如 `cv::createTrackbar`)实时调整这些阈值,找到最适合当前场地的参数。这个会在后续的"调参实战"课中教学。
##### (3) `cv::bitwise_or` 和 `cv::bitwise_and`
```cpp
cv::bitwise_or(src1, src2, dst); // 两个 mask 做"或"操作 → 合并
cv::bitwise_and(src1, src2, dst, mask); // 按 mask 提取原图
```
- **bitwise_or**:用于合并"红色区间 1"和"红色区间 2"的 mask
- **bitwise_and**:将 mask 覆盖在原图上,提取出"原图中满足 mask 的区域"
**生活比喻**:
- `bitwise_or`:你有两张透明塑料片,片 1 上挖了"范围 1"的洞,片 2 上挖了"范围 2"的洞。把两张叠在一起,**只要有任意一张有洞**的位置,光就能透过去。这就是"或"。
- `bitwise_and`:你有一张完整的照片(src1 = src2 = 原图),在上面盖一张挖了洞的黑色卡纸(mask)。只有**洞的位置**你能看到照片内容,其他地方是黑的。
#### 代码关键片段解释
1. 生成包含**多种颜色**(红、蓝、绿、黄、青、品红)和**多种深浅红色**的测试图
2. BGR → HSV 转换
3. 用两个 `inRange` 分别提取两个红色区间,用 `bitwise_or` 合并——得到完整的红色 mask
4. 用 `bitwise_and` 将 mask 盖回原图,直观地看到"只保留了红色物体"
5. 附加实验:同样方法提取蓝色(为 RoboMaster 蓝方视觉组做铺垫)
#### 编译与运行
```bash
cd /mnt/d/RM/25教学/视觉1/OpenCV-CPP/exp7_inrange
mkdir -p build && cd build
cmake .. && make -j$(nproc)
./exp7
```
#### 查看结果
进入 `\\wsl$\Ubuntu\mnt\d\RM\25教学\视觉1\OpenCV-CPP\exp7_inrange\build`,依次查看:
- `original.jpg`:原始彩色图
- `mask_red1.jpg` / `mask_red2.jpg`:两个红色区间的 mask
- `mask_red_combined.jpg`:合并后的完整红色 mask
- `red_extracted.jpg`:原图中只有红色的部分
- `mask_blue.jpg` / `blue_extracted.jpg`:蓝色提取
---
### 4.8 实验 8:综合流水线
#### 目标
- 将前 7 个实验的技术**串联**成一个完整流程
- 理解"预处理 → 提取 → 后处理 → 检测"的标准套路
- 体验一个简化的 RoboMaster 灯条检测全流程
#### 流水线设计
```
原始场景图
│
├──[阶段 1]── 生成模拟 RoboMaster 场景的测试图 ──► 01_original_scene.jpg
│
├──[阶段 2]── BGR → HSV 转换 ──► 02_hsv.jpg
│
├──[阶段 3]── 高斯模糊(去噪) ──► 03_hsv_blurred.jpg
│
├──[阶段 4]── inRange 提取红色 ──► 04_red_mask_raw.jpg
│
├──[阶段 5]── 形态学清理 ──► 05a_mask_opened.jpg → 05b_mask_cleaned.jpg
│ ├── 开运算:去掉噪点
│ └── 闭运算:填充空洞
│
└──[阶段 6]── 结果呈现 ──► 06a → 06b → 06c_result.jpg
├── bitwise_and 提取红色区域
├── Canny 边缘检测(可视化 mask 的边缘)
└── findContours + boundingRect 画框标记
```
#### 新涉及函数
##### (1) `cv::findContours`
```cpp
cv::findContours(二值图, 轮廓容器, 检索模式, 近似方法);
```
在二值图中找到所有白色区域的轮廓。
- **检索模式**:`cv::RETR_EXTERNAL` = 只找最外层轮廓(不关心嵌套的内部轮廓)
- **近似方法**:`cv::CHAIN_APPROX_SIMPLE` = 压缩水平/垂直/对角线,只保留拐点(节省内存)
##### (2) `cv::boundingRect`
```cpp
cv::Rect bbox = cv::boundingRect(轮廓);
```
计算能包围该轮廓的**最小直立矩形**。返回的 `cv::Rect` 包含 `x, y, width, height` 四个属性,可以直接用于 `cv::rectangle` 画框。
#### 代码关键片段解释
1. **阶段 1**:生成模拟场景——深灰背景(模拟暗光比赛场地)+ 两个红色竖直灯条 + 装甲板连接 + 蓝色/绿色/黄色的干扰物 + 随机噪点
2. **阶段 2-3**:HSV 转换 + 高斯模糊(与实验 2、3 一致)
3. **阶段 4**:inRange 提取红色(与实验 7 一致,双范围合并)
4. **阶段 5**:形态学清理——先开运算去噪点,再闭运算填充灯条内的小黑洞,得到一个干净的 mask
5. **阶段 6**:
- 用 `bitwise_and` 提取原图中的红色区域
- 用 `Canny` 在 mask 上做边缘检测(可选,展示 mask 的轮廓质量)
- 用 `findContours` 找到所有白色区域的轮廓
- 对每个轮廓用 `boundingRect` 计算外接矩形
- **筛选条件**:面积 > 100 且 高度 > 宽度×2(竖直细长——灯条的特征)
- 在结果图上画绿色边框和 `Target N` 标签
#### 编译与运行
```bash
cd /mnt/d/RM/25教学/视觉1/OpenCV-CPP/exp8_pipeline
mkdir -p build && cd build
cmake .. && make -j$(nproc)
./exp8
```
#### 查看结果
进入 `\\wsl$\Ubuntu\mnt\d\RM\25教学\视觉1\OpenCV-CPP\exp8_pipeline\build`:
1. `01_original_scene.jpg` → 原始场景(找找红色灯条在哪儿)
2. `04_red_mask_raw.jpg` → inRange 的原始 mask(能看出哪些是红色的)
3. `05a_mask_opened.jpg` → 开运算后(噪点应该少了)
4. `05b_mask_cleaned.jpg` → 闭运算后(灯条更完整)
5. `06c_result.jpg` → **最终结果**!绿灯条框标出的就是检测到的灯条!
> [!TIP]
> 如果你看 `06c_result.jpg` 发现只有 0 个或 1 个检测框(预期应该是 2 个),别担心!这是因为代码中筛选条件比较严格。你可以在 `main.cpp` 中调整筛选参数(面积阈值和长宽比阈值),重新编译运行看看效果变化——这对后续的调参能力是很好的练习。
---
## 5. 结语与下一步预告
### 恭喜你!
完成了以上 8 个实验,你已经掌握了 OpenCV 图像处理的基础"武器库":
| 你掌握的技能 | 对应的 OpenCV 函数 |
|-------------|-------------------|
| 读取和保存图像 | `imread` / `imwrite` / `Mat` |
| 颜色空间转换 | `cvtColor` |
| 图像去噪 | `GaussianBlur` |
| 边缘检测 | `Canny` |
| 图像二值化 | `threshold` |
| 形态学去噪/填充 | `erode` / `dilate` |
| 颜色提取 | `inRange` + `bitwise_or` + `bitwise_and` |
| 流水线串联 | 全部以上函数 + `findContours` + `boundingRect` |
这些技能每一项单独看可能不太起眼,但**串联在一起**,你就已经有能力写出一个简化的 RoboMaster 红色灯条检测程序了——这正是今天实验 8 所做的事情。
### 接下来的课程预告
在后续的视觉组培训课程中,我们将逐步深入学习:
1. **调参实战**:如何在比赛现场快速调整 HSV 阈值、高斯核大小、Canny 阈值
2. **工业相机使用**:不再用 `imread` 读文件,而是用 SDK 从真实相机实时取流
3. **灯条匹配与筛选**:如何将两个灯条配对成一个装甲板,并按面积、角度、长宽比过滤
4. **PNP 位姿解算**:根据灯条在图像中的位置和已知的物理尺寸,解算装甲板的三维坐标
5. **串口通信**:将解算出的坐标(Yaw, Pitch)通过串口发送给云台控制板
6. **多线程架构**:图像采集、处理、通信并行运行,提高帧率
希望在接下来的课程中继续见到你!有任何问题,随时在答疑群或训练时向学长学姐提问。
---
## 6. 附录:所有实验源文件完整代码
> **说明**:以下按文件夹列出每个实验的 `CMakeLists.txt` 和 `main.cpp` 完整源代码。你可以对照教案中的讲解,在代码中找到对应的行。每个代码块上方的标题标明了相对路径。
---
### 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})
```
### 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;
}
```
---
### 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})
```
### 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;
}
```
---
### 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})
```
### 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;
}
```
---
### 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})
```
### 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;
}
```
---
### 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})
```
### 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;
}
```
---
### 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})
```
### 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;
}
```
---
### 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})
```
### 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;
}
```
---
### 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})
```
### 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;
}
```
---
> **教案结束** — 现在你掌握了 OpenCV C++ 图像处理的基础,准备好迎接 RoboMaster 视觉组的真正挑战吧!