From 5fdfd9791a8696156d0ea98b0171c27f6a29e731 Mon Sep 17 00:00:00 2001 From: TuxMonkey <8196772+tuxmonkey@user.noreply.gitee.com> Date: Mon, 27 Jul 2026 14:23:06 +0800 Subject: [PATCH] feat: init OpenCV C++ training course with 8 experiments and detailed tutorial --- .gitignore | 23 + README.md | 2281 +++++++++++++++++++++++++++++ exp1_read_write/CMakeLists.txt | 41 + exp1_read_write/main.cpp | 162 ++ exp2_color_space/CMakeLists.txt | 36 + exp2_color_space/main.cpp | 161 ++ exp3_gaussian_blur/CMakeLists.txt | 28 + exp3_gaussian_blur/main.cpp | 139 ++ exp4_canny/CMakeLists.txt | 11 + exp4_canny/main.cpp | 145 ++ exp5_threshold/CMakeLists.txt | 9 + exp5_threshold/main.cpp | 151 ++ exp6_morphology/CMakeLists.txt | 9 + exp6_morphology/main.cpp | 182 +++ exp7_inrange/CMakeLists.txt | 9 + exp7_inrange/main.cpp | 221 +++ exp8_pipeline/CMakeLists.txt | 9 + exp8_pipeline/main.cpp | 226 +++ 18 files changed, 3843 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 exp1_read_write/CMakeLists.txt create mode 100644 exp1_read_write/main.cpp create mode 100644 exp2_color_space/CMakeLists.txt create mode 100644 exp2_color_space/main.cpp create mode 100644 exp3_gaussian_blur/CMakeLists.txt create mode 100644 exp3_gaussian_blur/main.cpp create mode 100644 exp4_canny/CMakeLists.txt create mode 100644 exp4_canny/main.cpp create mode 100644 exp5_threshold/CMakeLists.txt create mode 100644 exp5_threshold/main.cpp create mode 100644 exp6_morphology/CMakeLists.txt create mode 100644 exp6_morphology/main.cpp create mode 100644 exp7_inrange/CMakeLists.txt create mode 100644 exp7_inrange/main.cpp create mode 100644 exp8_pipeline/CMakeLists.txt create mode 100644 exp8_pipeline/main.cpp diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1953332 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# 编译生成目录(每个实验的 build/) +build/ +# CMake 生成文件 +CMakeFiles/ +CMakeCache.txt +cmake_install.cmake +Makefile +# 编译产物 +*.o +*.obj +*.exe +*.out +# 测试输出图片(由程序运行时生成) +*.jpg +*.png +*.jpeg +*.bmp +# IDE 文件 +.vscode/ +.idea/ +*.swp +*.swo +*~ diff --git a/README.md b/README.md new file mode 100644 index 0000000..eb5a407 --- /dev/null +++ b/README.md @@ -0,0 +1,2281 @@ +# 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 终端中看到中文显示为乱码(比如 `�学�视觉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 这样的头文件 +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 + +// 包含标准输入输出流,用于打印信息到终端 +#include + +// 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 能被正确找到 +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 +#include +#include // 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 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 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 的动态数组 + std::vector 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 +#include +#include + +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 +#include + +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 +#include + +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(row, col) —— 访问/修改单个像素值 + // .at 中的 uchar = unsigned char = 8比特无符号整数 + gradient.at(row, col) = static_cast(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 +#include + +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 +#include + +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 灯条检测打下完整基础 + * + * 【RoboMaster 灯条检测的标准流水线】 + * 1. 读取摄像头采集的图像 + * 2. BGR -> HSV 颜色空间转换 + * 3. inRange 提取目标颜色(红/蓝) + * 4. 形态学操作(开运算去噪、闭运算填充) + * 5. 轮廓检测与筛选(按面积、长宽比、角度过滤) + * 6. 画出灯条的位置框 + */ + +#include +#include + +int main() +{ + std::cout << "===== 实验 8:综合流水线 开始 =====\n" << std::endl; + + // ========================================== + // 阶段 1:生成模拟场景的测试图片 + // ========================================== + std::cout << "[阶段 1/6] 生成模拟 RoboMaster 场景的测试图片..." << std::endl; + + cv::Mat scene(450, 600, CV_8UC3, cv::Scalar(40, 40, 40)); + + // 画两个"红色灯条"(竖直细长矩形) + cv::rectangle(scene, cv::Point(200, 120), cv::Point(240, 280), + cv::Scalar(0, 0, 255), -1); // 左灯条 + cv::rectangle(scene, cv::Point(360, 120), cv::Point(400, 280), + cv::Scalar(0, 0, 255), -1); // 右灯条 + + // 画装甲板连接横梁 + cv::rectangle(scene, cv::Point(200, 180), cv::Point(400, 220), + cv::Scalar(0, 0, 200), -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); // 黄色干扰圆 + + // 撒随机噪点 + for (int i = 0; i < 200; i++) + { + int x = rand() % 600; + int y = rand() % 450; + uchar noise_color = static_cast(rand() % 128 + 60); + cv::circle(scene, cv::Point(x, y), 1, + cv::Scalar(noise_color, noise_color, noise_color), -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); + + // ========================================== + // 阶段 3:高斯模糊(去噪预处理) + // ========================================== + std::cout << "[阶段 3/6] 高斯模糊..." << std::endl; + cv::Mat hsv_blurred; + cv::GaussianBlur(hsv, hsv_blurred, cv::Size(5, 5), 0); + cv::imwrite("03_hsv_blurred.jpg", hsv_blurred); + + // ========================================== + // 阶段 4:颜色提取(inRange 提取红色) + // ========================================== + std::cout << "[阶段 4/6] inRange 提取红色区域..." << std::endl; + 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); + + // ========================================== + // 阶段 5:形态学后处理(清理 mask) + // ========================================== + std::cout << "[阶段 5/6] 形态学操作清理 mask..." << std::endl; + 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); + cv::imwrite("05a_mask_opened.jpg", mask_opened); + + // 闭运算:填充内部空洞 + 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); + + // ========================================== + // 阶段 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); + + // 6b:Canny 边缘检测在 mask 上 + cv::Mat mask_edges; + cv::Canny(mask_cleaned, mask_edges, 50, 150); + cv::imwrite("06b_mask_edges.jpg", mask_edges); + + // 6c:findContours + boundingRect 画框 + std::vector> contours; + cv::findContours(mask_cleaned, contours, cv::RETR_EXTERNAL, + cv::CHAIN_APPROX_SIMPLE); + + cv::Mat result = scene.clone(); + int valid_count = 0; + + for (size_t i = 0; i < contours.size(); i++) + { + cv::Rect bbox = cv::boundingRect(contours[i]); + + // 筛选条件:面积 > 100 且 高度 > 宽度 x 2(竖直细长 = 灯条特征) + double area = bbox.width * bbox.height; + if (area < 100) continue; + if (bbox.height < bbox.width * 2) continue; + + cv::rectangle(result, bbox, cv::Scalar(0, 255, 0), 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; + + return 0; +} +``` + +--- + +> **教案结束** — 现在你掌握了 OpenCV C++ 图像处理的基础,准备好迎接 RoboMaster 视觉组的真正挑战吧! diff --git a/exp1_read_write/CMakeLists.txt b/exp1_read_write/CMakeLists.txt new file mode 100644 index 0000000..c5a55ce --- /dev/null +++ b/exp1_read_write/CMakeLists.txt @@ -0,0 +1,41 @@ +# ============================================================ +# 实验 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 这样的头文件 +include_directories(${OpenCV_INCLUDE_DIRS}) diff --git a/exp1_read_write/main.cpp b/exp1_read_write/main.cpp new file mode 100644 index 0000000..11b7769 --- /dev/null +++ b/exp1_read_write/main.cpp @@ -0,0 +1,162 @@ +/* + * ============================================================ + * 实验 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 + +// 包含标准输入输出流,用于打印信息到终端 +#include + +// 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; +} diff --git a/exp2_color_space/CMakeLists.txt b/exp2_color_space/CMakeLists.txt new file mode 100644 index 0000000..51354ef --- /dev/null +++ b/exp2_color_space/CMakeLists.txt @@ -0,0 +1,36 @@ +# ============================================================ +# 实验 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 能被正确找到 +include_directories(${OpenCV_INCLUDE_DIRS}) diff --git a/exp2_color_space/main.cpp b/exp2_color_space/main.cpp new file mode 100644 index 0000000..5192380 --- /dev/null +++ b/exp2_color_space/main.cpp @@ -0,0 +1,161 @@ +/* + * ============================================================ + * 实验 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 +#include +#include // 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 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 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 的动态数组 + std::vector 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; +} diff --git a/exp3_gaussian_blur/CMakeLists.txt b/exp3_gaussian_blur/CMakeLists.txt new file mode 100644 index 0000000..089e69e --- /dev/null +++ b/exp3_gaussian_blur/CMakeLists.txt @@ -0,0 +1,28 @@ +# ============================================================ +# 实验 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}) diff --git a/exp3_gaussian_blur/main.cpp b/exp3_gaussian_blur/main.cpp new file mode 100644 index 0000000..dab6821 --- /dev/null +++ b/exp3_gaussian_blur/main.cpp @@ -0,0 +1,139 @@ +/* + * ============================================================ + * 实验 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 +#include +#include + +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; +} diff --git a/exp4_canny/CMakeLists.txt b/exp4_canny/CMakeLists.txt new file mode 100644 index 0000000..ba3b8f6 --- /dev/null +++ b/exp4_canny/CMakeLists.txt @@ -0,0 +1,11 @@ +# ============================================================ +# 实验 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}) diff --git a/exp4_canny/main.cpp b/exp4_canny/main.cpp new file mode 100644 index 0000000..3f0468f --- /dev/null +++ b/exp4_canny/main.cpp @@ -0,0 +1,145 @@ +/* + * ============================================================ + * 实验 4:Canny 边缘检测 + * 知识点:cv::Canny + * ============================================================ + * + * 【实验目标】 + * 1. 理解什么是"边缘"——图像中亮度急剧变化的地方 + * 2. 掌握 Canny 边缘检测算法的原理和参数 + * 3. 观察不同阈值对边缘检测结果的影响 + * + * 【生活化比喻】 + * 边缘检测就像用铅笔"描边": + * - 给你一张彩色照片,让你把照片中所有物体的轮廓描出来 + * - Canny 算法就是一个"自动描边机器人" + * - threshold1 像是"最低标准":低于这个的统统不是边缘 + * - threshold2 像是"最高标准":高于这个的绝对是边缘 + * - 介于两者之间的,看它是否和已确认的边缘连着(连着的算,孤立的忽略) + * + * 这就是所谓的"迟滞阈值"(Hysteresis Threshold): + * - 高于高阈值 → 肯定是边缘(强边缘) + * - 低于低阈值 → 肯定不是边缘(直接丢弃) + * - 介于中间 → 如果和强边缘相邻,就算是边缘;否则丢弃 + * - 这就像"朋友的朋友也是朋友"——只要你能通过边缘连到确认真边缘,你就是边缘 + * + * 【Canny 算法的五个步骤(了解即可)】 + * 1. 高斯滤波:去除噪声(因为噪声会被误检测为边缘) + * 2. 计算梯度:找到亮度变化最大的方向和大小 + * 3. 非极大值抑制:只保留梯度方向上最大的那个像素(细化边缘) + * 4. 双阈值检测:用两个阈值筛选强弱边缘 + * 5. 边缘连接:通过滞后阈值连接断开的边缘 + * + * 【函数详解】 + * cv::Canny(src, dst, threshold1, threshold2, apertureSize, L2gradient) + * + * src :输入图像(必须是 8 位单通道灰度图!) + * dst :输出图像(二值图,边缘为白色 255,非边缘为黑色 0) + * threshold1 :低阈值(用于弱边缘判断) + * threshold2 :高阈值(用于强边缘判断) + * 推荐比例:threshold2 ≈ 2× threshold1 或 3× threshold1 + * apertureSize :Sobel 算子的孔径大小(默认 3,一般不用改) + * L2gradient :是否使用更精确的 L2 范数计算梯度(默认 false) + */ + +#include +#include + +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) --- + // threshold1=50 意味着:强度大于 50 的可能是弱边缘 + // threshold2=150 意味着:强度大于 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:先模糊再做边缘检测 --- + // 这是实际应用中最常见的做法:先高斯模糊去噪,再做 Canny + // 对比 edges_low 的结果,你会发现先模糊后边缘更"干净" + 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; +} diff --git a/exp5_threshold/CMakeLists.txt b/exp5_threshold/CMakeLists.txt new file mode 100644 index 0000000..d66f0d0 --- /dev/null +++ b/exp5_threshold/CMakeLists.txt @@ -0,0 +1,9 @@ +# 实验 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}) diff --git a/exp5_threshold/main.cpp b/exp5_threshold/main.cpp new file mode 100644 index 0000000..b1d063a --- /dev/null +++ b/exp5_threshold/main.cpp @@ -0,0 +1,151 @@ +/* + * ============================================================ + * 实验 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,相当于"不通过") + * - 整张图就只剩黑白两色了! + * + * 为什么需要这个操作? + * - 把"感兴趣的物体"从"背景"中分离出来 + * - 为后续的形态学操作(实验 6)做准备 + * - 很多算法(如轮廓检测)需要二值图像作为输入 + * + * 【函数详解】 + * cv::threshold(src, dst, thresh, maxval, type) + * + * src :输入图像(必须是单通道灰度图) + * dst :输出图像(和输入同样大小和类型) + * thresh :阈值(就是那个"及格线") + * maxval :最大值(当像素满足条件时,赋予它的新值,通常是 255) + * type :阈值类型(决定"怎么比较"和"比较后怎么赋值") + * + * OpenCV 支持的阈值类型: + * cv::THRESH_BINARY :>阈值→maxval,否则→0 (标准二值化) + * cv::THRESH_BINARY_INV :>阈值→0,否则→maxval (反二值化) + * cv::THRESH_TRUNC :>阈值→阈值,否则→原值不变 (截断) + * cv::THRESH_TOZERO :>阈值→原值不变,否则→0 (置零) + * cv::THRESH_TOZERO_INV :>阈值→0,否则→原值不变 (反置零) + */ + +#include +#include + +int main() +{ + // ========================================== + // 第一部分:生成一张灰度渐变图 + // ========================================== + std::cout << "[步骤 1] 正在生成灰度渐变测试图..." << std::endl; + + // 创建一张 300(高)× 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(row, col) —— 访问/修改单个像素值 + // .at 中的 uchar = unsigned char = 8比特无符号整数 + // 因为 CV_8UC1 的每个像素就是 uchar 类型 + gradient.at(row, col) = static_cast(col * 255 / 511); + } + } + + // 在渐变图上画几个标记,方便观察阈值效果 + // 画一条垂直线标记阈值 128 的位置 + cv::line(gradient, cv::Point(256, 0), cv::Point(256, 299), + cv::Scalar(255), 1); // 在 x=256 处画白线(灰度 128 的位置) + // 放一些文字 + 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; + std::cout << " (从左到右灰度从 0 渐变到 255)" << std::endl; + + // ========================================== + // 第二部分:演示各种阈值类型 + // ========================================== + // 统一使用阈值 128(中间值),maxval = 255(纯白) + + // --- 类型 1:THRESH_BINARY(标准二值化)--- + // 逻辑:如果 像素值 > 128,则赋值为 255;否则赋值为 0 + // 效果:左边一半(<128)全黑,右边一半(>128)全白 + 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); + std::cout << " -> 已保存为 thresh_binary.jpg" << std::endl; + + // --- 类型 2:THRESH_BINARY_INV(反二值化)--- + // 逻辑:如果 像素值 > 128,则赋值为 0;否则赋值为 255 + // 效果:和上面完全相反——左边白,右边黑 + 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); + std::cout << " -> 已保存为 thresh_binary_inv.jpg" << std::endl; + + // --- 类型 3:THRESH_TRUNC(截断)--- + // 逻辑:如果 像素值 > 128,则赋值为 128(被截断!);否则保持原值不变 + // 效果:左边(<128)保持渐变,右边(>128)全部变成 128(一片灰) + 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); + std::cout << " -> 已保存为 thresh_trunc.jpg" << std::endl; + + // --- 类型 4:THRESH_TOZERO(置零)--- + // 逻辑:如果 像素值 > 128,则保持原值不变;否则赋值为 0(变黑) + // 效果:左边(<128)全黑,右边(>128)保持渐变 + 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); + std::cout << " -> 已保存为 thresh_tozero.jpg" << std::endl; + + // --- 类型 5:THRESH_TOZERO_INV(反置零)--- + // 逻辑:如果 像素值 > 128,则赋值为 0(变黑);否则保持原值不变 + // 效果:左边(<128)保持渐变,右边(>128)全黑 + 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 << " -> 已保存为 thresh_tozero_inv.jpg" << std::endl; + + // 总结 + 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; +} diff --git a/exp6_morphology/CMakeLists.txt b/exp6_morphology/CMakeLists.txt new file mode 100644 index 0000000..5de34db --- /dev/null +++ b/exp6_morphology/CMakeLists.txt @@ -0,0 +1,9 @@ +# 实验 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}) diff --git a/exp6_morphology/main.cpp b/exp6_morphology/main.cpp new file mode 100644 index 0000000..9cbfb25 --- /dev/null +++ b/exp6_morphology/main.cpp @@ -0,0 +1,182 @@ +/* + * ============================================================ + * 实验 6:形态学操作 —— 腐蚀 (Erode) 与膨胀 (Dilate) + * 知识点:cv::erode、cv::dilate、cv::getStructuringElement + * ============================================================ + * + * 【实验目标】 + * 1. 理解"腐蚀"和"膨胀"对二值图像的作用 + * 2. 了解"开运算"和"闭运算"的组合效果 + * 3. 掌握结构元素 (kernel) 的概念 + * + * 【生活化比喻】 + * 腐蚀 (Erosion): + * 想象沙滩上写着一些字,涨潮时海水冲上来,把字的边缘"吃掉"一圈 + * → 白色区域被"侵蚀",变小了、断开了、甚至消失了 + * → 作用:消除小白点噪声、断开粘连的物体 + * + * 膨胀 (Dilation): + * 想象给一栋房子外围再砌一圈围墙,房子"胖了" + * → 白色区域被"扩张",变大了、连在一起了 + * → 作用:填充小黑洞、连接断裂的物体 + * + * 开运算 (Opening) = 先腐蚀,再膨胀: + * 想象洗菜时,先让虫子被水"冲掉"(腐蚀), + * 然后蔬菜可能会缩一点,再"泡水"让它恢复原大小(膨胀) + * → 去掉小白点噪声,同时保持物体大小基本不变 + * + * 闭运算 (Closing) = 先膨胀,再腐蚀: + * 想象一件有破洞的衣服,先"拉伸"布料让洞缩小(膨胀), + * 再"熨平"恢复形状(腐蚀) + * → 填充物体内部的黑色小洞,同时保持物体大小基本不变 + * + * 结构元素 (Structuring Element / Kernel): + * 腐蚀和膨胀不是乱"吃"乱"胀"的,而是用一个模板来做的 + * 这个模板就是"结构元素",它决定了"腐蚀/膨胀的形状和范围" + * - 矩形结构元素 (MORPH_RECT):像盖章一样方方正正 + * - 椭圆形结构元素 (MORPH_ELLIPSE):圆形范围 + * - 十字形结构元素 (MORPH_CROSS):只影响上下左右 + * + * 【函数详解】 + * cv::erode(src, dst, kernel, anchor, iterations) + * cv::dilate(src, dst, kernel, anchor, iterations) + * + * src :输入图像(通常是二值图) + * dst :输出图像 + * kernel :结构元素,用 cv::getStructuringElement() 创建 + * anchor :锚点位置,默认是核的中心 (-1,-1) + * iterations :操作重复次数(相当于执行几次腐蚀/膨胀) + * + * cv::getStructuringElement(shape, ksize) + * shape :MORPH_RECT(矩形)、MORPH_ELLIPSE(椭圆)、MORPH_CROSS(十字) + * ksize :核大小,如 cv::Size(5,5) + */ + +#include +#include + +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; + std::cout << " (白色矩形为主体,内部有小黑洞,背景有白色噪点)" << std::endl; + + // ========================================== + // 第二部分:创建结构元素 + // ========================================== + // 结构元素的大小会影响腐蚀/膨胀的效果范围 + // 核越大,每次操作影响的范围越大 + // 这里使用 5×5 的矩形核作为演示 + cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(5, 5)); + // 你也试试换成椭圆形核: + // cv::Mat kernel = cv::getStructuringElement(cv::MORPH_ELLIPSE, 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 << " -> 已保存为 eroded.jpg(白色区域变小,噪点可能消失)" << std::endl; + + // ========================================== + // 第四部分:膨胀操作 + // ========================================== + std::cout << "[步骤 3] 执行膨胀操作 (Dilation)..." << std::endl; + + cv::Mat dilated; + cv::dilate(binary, dilated, kernel); + // 效果预期: + // - 白色矩形整体"胖"了一圈(边缘被扩张) + // - 背景上的白色小噪点也变大了 + // - 矩形内部的黑洞变小了(因为黑洞外围的白色膨胀进来) + cv::imwrite("dilated.jpg", dilated); + std::cout << " -> 已保存为 dilated.jpg(白色区域变大,内部黑洞变小)" << std::endl; + + // ========================================== + // 第五部分:开运算(先腐蚀再膨胀) + // ========================================== + 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 << " -> 已保存为 opened.jpg(去掉了噪音,但内部黑洞变大了)" << std::endl; + + // ========================================== + // 第六部分:闭运算(先膨胀再腐蚀) + // ========================================== + 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 << " -> 已保存为 closed.jpg(填充了内部黑洞,但背景噪点变大了)" << std::endl; + + // ========================================== + // 第七部分:多次形态学操作 + // ========================================== + std::cout << "[步骤 6] 执行多次腐蚀 (iterations=3)..." << std::endl; + + cv::Mat eroded_multi; + // iterations=3 表示连续执行 3 次腐蚀 + // 等价于手动写三次 cv::erode(..., eroded_multi, kernel) + // 每腐蚀一次,白色区域就缩小一圈 + cv::erode(binary, eroded_multi, kernel, cv::Point(-1, -1), 3); + cv::imwrite("eroded_x3.jpg", eroded_multi); + std::cout << " -> 已保存为 eroded_x3.jpg(腐蚀 3 次,白色区域大幅缩小)" << std::endl; + + // 总结 + 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; +} diff --git a/exp7_inrange/CMakeLists.txt b/exp7_inrange/CMakeLists.txt new file mode 100644 index 0000000..ca290e8 --- /dev/null +++ b/exp7_inrange/CMakeLists.txt @@ -0,0 +1,9 @@ +# 实验 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}) diff --git a/exp7_inrange/main.cpp b/exp7_inrange/main.cpp new file mode 100644 index 0000000..9abc004 --- /dev/null +++ b/exp7_inrange/main.cpp @@ -0,0 +1,221 @@ +/* + * ============================================================ + * 实验 7:颜色提取 —— inRange + * 知识点:cv::inRange、HSV 颜色空间、颜色阈值 + * ============================================================ + * + * 【实验目标】 + * 1. 掌握 cv::inRange 函数的用法——按颜色范围提取目标 + * 2. 理解为什么要用 HSV 而不是 BGR 来做颜色提取 + * 3. 掌握 RoboMaster 红色灯条的 HSV 范围设定 + * + * 【生活化比喻】 + * inRange 就像是一个"颜色过滤器"(或者说"筛子"): + * - 你有一堆混在一起的彩色糖果(红、绿、蓝、黄...) + * - 你只想挑出红色的糖果 + * - inRange 就是那个"只能通过红色"的筛子 + * - 筛出来的就是红色糖果(二值 mask),其他颜色的都被丢掉了 + * + * 为什么要用 HSV? + * - 在 BGR 空间:红色 = (0, 0, 255),但光线暗的时候可能是 (0, 0, 80) + * 亮度一变化,三个值全变了!很难用一个范围同时框住亮红和暗红 + * - 在 HSV 空间:红色 = H≈0(或 H≈180,因为色相是环状的) + * H (色相) 不受亮度影响!亮红、暗红都是 H≈0 + * S (饱和度) 保证颜色够"纯",不要灰扑扑的 + * V (明度) 保证不是黑的或太暗 + * - 这样我们可以分别设定三个分量的范围,灵活又鲁棒 + * + * 【RoboMaster 红色灯条】 + * 在比赛场地中,机器人装甲板上有红色/蓝色的 LED 灯条 + * 视觉组需要通过摄像头找到这些灯条的位置 + * inRange 提取颜色 → 找到灯条的轮廓 → 计算位置 → 发送给云台瞄准 + * 这是视觉组最基础也最重要的操作之一! + * + * 【函数详解】 + * cv::inRange(src, lowerb, upperb, dst) + * + * src :输入图像(推荐用 HSV 格式) + * lowerb :颜色下界(Scalar(H_low, S_low, V_low)) + * 或灰度下界(对灰度图直接取 Scalar(low)) + * upperb :颜色上界(Scalar(H_high, S_high, V_high)) + * dst :输出二值 mask(满足条件 = 255 白色,不满足 = 0 黑色) + * + * 返回的 dst 是一个单通道二值图(CV_8UC1): + * - 白色区域(255):像素值在 [lowerb, upperb] 区间内 + * - 黑色区域(0) :像素值不在区间内 + * + * 【OpenCV HSV 取值范围(重要!)】 + * H (Hue):0 ~ 180(不是 360!OpenCV 把 360° 映射到 0~180 了) + * S (Sat):0 ~ 255 + * V (Val):0 ~ 255 + * + * 红色在 H 轴上的位置(因为色相是一个环): + * H = 0 附近:纯红色 + * H = 10 附近:偏橙的红色 + * H = 160~180:偏紫的红色(从紫色那一端绕过来的红色) + * + * 所以提取红色通常需要两个范围: + * 范围 1:H ∈ [0, 10],S ∈ [100, 255],V ∈ [100, 255] + * 范围 2:H ∈ [160, 180], S ∈ [100, 255], V ∈ [100, 255] + * 然后把两个范围的 mask 相加(按位或)得到最终结果 + */ + +#include +#include + +int main() +{ + // ========================================== + // 第一部分:生成包含多种颜色的测试图片 + // ========================================== + std::cout << "[步骤 1] 正在生成彩色测试图片..." << std::endl; + + // 400×600 画布,白色背景 + cv::Mat image(400, 600, CV_8UC3, cv::Scalar(255, 255, 255)); + + // 画 6 个彩色矩形,模拟不同颜色的物体 + // 颜色选择:红色、蓝色(RoboMaster 的两种对手颜色)、绿色、黄色、青色、品红 + cv::rectangle(image, cv::Point(20, 50), cv::Point(180, 130), + cv::Scalar(0, 0, 255), -1); // 红色 (B=0, G=0, R=255) + cv::rectangle(image, cv::Point(210, 50), cv::Point(370, 130), + cv::Scalar(255, 0, 0), -1); // 蓝色 (B=255, G=0, R=0) + cv::rectangle(image, cv::Point(400, 50), cv::Point(560, 130), + cv::Scalar(0, 255, 0), -1); // 绿色 (B=0, G=255, R=0) + + cv::rectangle(image, cv::Point(20, 160), cv::Point(180, 240), + cv::Scalar(0, 255, 255), -1); // 黄色 (B=0, G=255, R=255) + cv::rectangle(image, cv::Point(210, 160), cv::Point(370, 240), + cv::Scalar(255, 255, 0), -1); // 青色 (B=255, G=255, R=0) + cv::rectangle(image, cv::Point(400, 160), cv::Point(560, 240), + cv::Scalar(255, 0, 255), -1); // 品红 (B=255, G=0, R=255) + + // 再画几个不同深浅的红色(模拟实际场景中光照不均的情况) + // 深红色 + 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::putText(image, "Red", cv::Point(80, 105), + cv::FONT_HERSHEY_SIMPLEX, 0.7, cv::Scalar(255, 255, 255), 2); + cv::putText(image, "Blue", cv::Point(270, 105), + cv::FONT_HERSHEY_SIMPLEX, 0.7, cv::Scalar(255, 255, 255), 2); + cv::putText(image, "Green", cv::Point(450, 105), + cv::FONT_HERSHEY_SIMPLEX, 0.7, cv::Scalar(255, 255, 255), 2); + cv::putText(image, "Yellow", cv::Point(65, 215), + cv::FONT_HERSHEY_SIMPLEX, 0.7, cv::Scalar(0, 0, 0), 2); + cv::putText(image, "Cyan", cv::Point(270, 215), + cv::FONT_HERSHEY_SIMPLEX, 0.7, cv::Scalar(0, 0, 0), 2); + cv::putText(image, "Magenta", cv::Point(430, 215), + cv::FONT_HERSHEY_SIMPLEX, 0.7, cv::Scalar(0, 0, 0), 2); + cv::putText(image, "Dark Red / Mid Red / Bright / Purple Red", + cv::Point(20, 355), + cv::FONT_HERSHEY_SIMPLEX, 0.5, cv::Scalar(0, 0, 0), 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); + std::cout << " -> HSV 图片已保存为 hsv.jpg" << std::endl; + + // ========================================== + // 第三部分:使用 inRange 提取红色 + // ========================================== + std::cout << "[步骤 3] 使用 inRange 提取红色区域..." << std::endl; + + // 如前所述,红色的 H 值在 OpenCV 中分为两段: + // 第一段:H 在 0~10 之间(橙红色 ~ 正红色 ~ 偏红色) + // 第二段:H 在 160~180 之间(偏紫色 ~ 绕回到红色) + + // 【重要】S 和 V 的阈值设定说明: + // S(饱和度)不能设太低,否则灰白色区域也会被选进来 + // 通常设 S_low = 100(也就是约 40% 饱和度以上算"有颜色") + // V(明度)不能设太低,否则黑色/很暗的区域也会被选进来 + // 通常设 V_low = 100(也就是亮度 > 100/255 算"不是黑的") + + // --- 提取红色范围 1:H ∈ [0, 10] --- + cv::Scalar lower_red1(0, 100, 100); // H=0, S=100, V=100 + cv::Scalar upper_red1(10, 255, 255); // H=10, S=255, V=255 + cv::Mat mask_red1; + cv::inRange(hsv, lower_red1, upper_red1, mask_red1); + cv::imwrite("mask_red1.jpg", mask_red1); + std::cout << " -> 红色范围 1 的 mask 已保存为 mask_red1.jpg" << std::endl; + + // --- 提取红色范围 2:H ∈ [160, 180] --- + cv::Scalar lower_red2(160, 100, 100); // H=160, S=100, V=100 + cv::Scalar upper_red2(180, 255, 255); // H=180, S=255, V=255 + cv::Mat mask_red2; + cv::inRange(hsv, lower_red2, upper_red2, mask_red2); + cv::imwrite("mask_red2.jpg", mask_red2); + std::cout << " -> 红色范围 2 的 mask 已保存为 mask_red2.jpg" << std::endl; + + // --- 合并两个红色范围的 mask --- + // cv::bitwise_or:对两幅图像做"按位或"操作 + // 效果:mask 中任一为白色(255)的像素,结果就是白色 + // 相当于:"范围1的红色" 或者 "范围2的红色" 都算红色 + cv::Mat mask_red_combined; + cv::bitwise_or(mask_red1, mask_red2, mask_red_combined); + cv::imwrite("mask_red_combined.jpg", mask_red_combined); + std::cout << " -> 合并后的红色 mask 已保存为 mask_red_combined.jpg" << std::endl; + + // ========================================== + // 第四部分:把 mask 应用到原图(可视化提取结果) + // ========================================== + std::cout << "[步骤 4] 用 mask 提取原图中的红色部分..." << std::endl; + + // cv::bitwise_and:对两幅图像做"按位与"操作 + // 把 mask 当作"模板"盖在原图上: + // mask 中白色(255)的地方 → 保留原图该像素 + // mask 中黑色(0)的地方 → 变成黑色 + cv::Mat red_extracted; + cv::bitwise_and(image, image, red_extracted, mask_red_combined); + cv::imwrite("red_extracted.jpg", red_extracted); + std::cout << " -> 提取出的红色区域已保存为 red_extracted.jpg" << std::endl; + std::cout << " (只有红色物体保留,其他区域全黑)" << std::endl; + + // ========================================== + // 第五部分(附加):也提取蓝色(RoboMaster 中另一种常见目标) + // ========================================== + std::cout << "[步骤 5] 额外实验:提取蓝色区域..." << std::endl; + + // 蓝色在 HSV 中的 H 范围大约是 100~130 + // 蓝色不像红色那样跨 0 度,所以只需要一个范围 + cv::Scalar lower_blue(100, 100, 100); // H=100, S=100, V=100 + cv::Scalar upper_blue(130, 255, 255); // H=130, S=255, V=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 << " -> 蓝色 mask 已保存为 mask_blue.jpg" << std::endl; + std::cout << " -> 提取出的蓝色区域已保存为 blue_extracted.jpg" << std::endl; + + // 总结 + 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; +} diff --git a/exp8_pipeline/CMakeLists.txt b/exp8_pipeline/CMakeLists.txt new file mode 100644 index 0000000..cdffcbf --- /dev/null +++ b/exp8_pipeline/CMakeLists.txt @@ -0,0 +1,9 @@ +# 实验 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}) diff --git a/exp8_pipeline/main.cpp b/exp8_pipeline/main.cpp new file mode 100644 index 0000000..648c344 --- /dev/null +++ b/exp8_pipeline/main.cpp @@ -0,0 +1,226 @@ +/* + * ============================================================ + * 实验 8:综合流水线 (Pipeline) + * 知识点:串联前面 7 个实验的所有技术 + * ============================================================ + * + * 【实验目标】 + * 1. 将前面学到的所有技术串联成一个完整流程 + * 2. 理解图像处理中"预处理 → 提取 → 后处理"的标准套路 + * 3. 为 RoboMaster 灯条检测打下完整基础 + * + * 【生活化比喻】 + * 一条完整的图像处理流水线就像工厂的"生产流水线": + * - 原材料进厂(读取图像) + * - 清洗处理(模糊去噪、颜色转换) + * - 核心加工(颜色提取 inRange) + * - 精修打磨(形态学操作去掉毛刺) + * - 质检包装(边缘检测找轮廓、画框标记) + * - 成品出厂(保存结果图片) + * + * 每一步都依赖前一步的输出,整个流程像一个多米诺骨牌, + * 任何一个环节出问题,最终结果都会受影响。 + * + * 【RoboMaster 灯条检测的标准流水线】 + * 1. 读取摄像头采集的图像 + * 2. BGR → HSV 颜色空间转换 + * 3. inRange 提取目标颜色(红/蓝) + * 4. 形态学操作(开运算去噪、闭运算填充) + * 5. 轮廓检测与筛选(按面积、长宽比、角度过滤) + * 6. 画出灯条的位置框 + * + * 本实验完整演示整个流程! + */ + +#include +#include + +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)); + + // 画两个"红色灯条"(模拟 RoboMaster 装甲板上的 LED) + // 灯条是竖直的细长矩形,这是我们检测的目标 + // 左灯条 + cv::rectangle(scene, cv::Point(200, 120), cv::Point(240, 280), + cv::Scalar(0, 0, 255), -1); // 纯红色竖直矩形 + // 右灯条 + cv::rectangle(scene, cv::Point(360, 120), cv::Point(400, 280), + cv::Scalar(0, 0, 255), -1); // 纯红色竖直矩形 + + // 画一个红色"装甲板"(连接两个灯条的中间横梁) + cv::rectangle(scene, cv::Point(200, 180), cv::Point(400, 220), + cv::Scalar(0, 0, 200), -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); // 黄色干扰圆 + + // 撒一些随机噪点(模拟摄像头传感器噪点) + for (int i = 0; i < 200; i++) + { + int x = rand() % 600; + int y = rand() % 450; + // 随机颜色(模拟 CCD/CMOS 噪点) + uchar noise_color = static_cast(rand() % 128 + 60); + cv::circle(scene, cv::Point(x, y), 1, + cv::Scalar(noise_color, noise_color, noise_color), -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> contours; + // RETR_EXTERNAL: 只检索最外层轮廓 + // CHAIN_APPROX_SIMPLE: 压缩水平/垂直/对角线,只保留端点 + cv::findContours(mask_cleaned, contours, cv::RETR_EXTERNAL, + cv::CHAIN_APPROX_SIMPLE); + + // 创建结果图:在原图上画检测框 + cv::Mat result = scene.clone(); // clone() 创建一个完全相同的副本 + + 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; +}