OpenCV 第一阶段
我们接下来会从:
第1课:图片到底是什么
↓
第2课:cv::Mat 到底是什么
↓
第3课:imread 读取图片
↓
第4课:imshow 显示图片
↓
第5课:rows / cols / channels
↓
第6课:访问一个像素
↓
第7课:BGR 为什么不是 RGB
↓
第8课:灰度图
↓
第9课:resize
↓
第10课:裁剪 ROI
↓
第11课:模糊与去噪
↓
第12课:二值化
↓
第13课:边缘检测
↓
第14课:轮廓
↓
第15课:打开摄像头
然后做我们的第一个完整小项目:
摄像头
↓
读取图像
↓
灰度化
↓
去噪
↓
边缘检测
↓
寻找物体轮廓
↓
画框
↓
实时显示
图片到底是什么
电脑眼里的图片,本质就是:
一大堆数字排成的表格。
比如一张非常小的灰度图:
20 30 50 80
40 60 90 100
10 20 30 255
这些数字代表每个位置有多亮。
一般来说:
0 = 黑
255 = 白
中间数字 = 不同程度的灰
所以:
0 50 128 200 255
黑 ───────────────────────────────→ 白
这就是最基础的"像素"。
1. 什么是像素 pixel?
你看一张照片:
┌──────────────────────┐
│ 一只杯子 │
│ │
│ │
└──────────────────────┘
实际上把它无限放大:
┌───┬───┬───┬───┐
│ ■ │ ■ │ ■ │ ■ │
├───┼───┼───┼───┤
│ ■ │ ■ │ ■ │ ■ │
├───┼───┼───┼───┤
│ ■ │ ■ │ ■ │ ■ │
└───┴───┴───┴───┘
每个小格子:
就是一个 pixel,像素。
所以一张:
1920 × 1080
的图片,大概就是:
横向 1920 个像素
纵向 1080 个像素
总共:
约 207 万个像素
2. 灰度图片:一个像素,一个数字
灰度图最简单。
比如:
┌─────┬─────┬─────┐
│ 0 │ 100 │ 255 │
├─────┼─────┼─────┤
│ 30 │ 150 │ 220 │
└─────┴─────┴─────┘
每个位置只有一个数字。
所以:
像素
↓
亮度
例如:
像素值 = 0
↓
黑
像素值 = 255
↓
白
3. 彩色图片:一个像素,三个数字
彩色图就复杂一点。每一个像素通常有三个数字:
红色
绿色
蓝色
也就是:
R
G
B
例如一个像素:
R = 255
G = 0
B = 0
你可以理解成:
红色非常强,绿色没有,蓝色没有。
所以看起来就是红色。
4. OpenCV 为什么经常是 BGR?
这点以后你一定会碰到。
我们正常说:
RGB
但 OpenCV 传统上很多地方使用:
BGR
顺序反过来了:
B = Blue
G = Green
R = Red
所以一个纯红色像素,在 OpenCV 里可能写成:
B = 0
G = 0
R = 255
也就是:
(0, 0, 255)
而不是:
(255, 0, 0)
这以后非常容易搞错。你现在先记:
OpenCV 默认彩色图经常按 BGR 顺序。
cv::Mat 到底是什么?
这是整个 OpenCV 最重要的东西之一。
cv::Mat image;
cv::Mat
大白话就是:
去 OpenCV 这个工具箱里,找一个叫 Mat 的东西。
Mat 原来是 Matrix 的缩写,Matrix:
矩阵。
Mat 是 OpenCV 用来装图像数据的核心容器。
例如:
cv::Mat image;
相当于:
创建一个叫 image 的图片盒子
以后把图片放进去:
image
│
├── 图片宽度
├── 图片高度
├── 像素数据
├── 通道数
└── 数据类型
所以 cv::Mat 不只是"图片",它里面还保存很多信息。
1. 第一段真正的 OpenCV 代码
以后你会写:
#include <opencv2/opencv.hpp>
int main()
{
cv::Mat image = cv::imread("test.jpg");
cv::imshow("image", image);
cv::waitKey(0);
return 0;
}
#include <opencv2/opencv.hpp>
imread():读取图片
这行:
cv::Mat image = cv::imread("test.jpg");
可以拆成两边。
左边:
cv::Mat image
意思:
创建一个叫
image的 Mat 图片对象。
右边:
cv::imread("test.jpg")
意思:
OpenCV,帮我读取
test.jpg。
所以整句:
读取
test.jpg,然后把结果放进image。
流程:
硬盘上的 test.jpg
↓
cv::imread
↓
解码图片
↓
cv::Mat
↓
image
2. 注意:JPG 文件不是直接等于 Mat
这个概念很重要。硬盘里:
test.jpg
是一个压缩过的图片文件。
OpenCV 读取以后:
cv::imread(...)
会把它解码成:
大量像素数据
然后放进:
cv::Mat
所以:
jpg/png
=
磁盘上的图片文件
cv::Mat
=
程序运行时内存里的图片
这两个概念一定要区分。
3. imshow():显示图片
cv::imshow("image", image);
大白话:
开一个窗口,把
image显示出来。
这里两个参数:
"image"
是:
窗口名字。
后面的:
image
是:
真正要显示的图片。
比如:
cv::imshow("我的照片", image);
就是窗口标题叫:
我的照片
4. waitKey(0) 为什么需要?
可以先理解成:
程序停在这里,等你按键。
如果没有它:
打开窗口
↓
程序马上结束
↓
窗口瞬间关闭
可能你都看不到图片。所以:
cv::waitKey(0);
相当于:
图片显示着,等我按键再结束。
5. 0 是什么意思?
这里:
cv::waitKey(0);
可以先理解:
无限等。
以后如果:
cv::waitKey(10);
可以粗略理解成:
等一小会儿。
这在视频处理中很重要。
因为视频本质上就是:
一张图片
↓
下一张
↓
下一张
↓
下一张
快速连续播放。
怎么知道图片读成功没有?
image.empty()
可以判断:
这张图片是不是空的。
所以我们应该写:
cv::Mat image = cv::imread("test.jpg");
if (image.empty())
{
std::cout << "图片读取失败" << std::endl;
return -1;
}
1. 一个比较完整的第一程序
#include <iostream>
#include <opencv2/opencv.hpp>
int main()
{
cv::Mat image = cv::imread("test.jpg");
if (image.empty())
{
std::cout << "图片读取失败" << std::endl;
return -1;
}
cv::imshow("image", image);
cv::waitKey(0);
return 0;
}
导入iostream
↓
导入OpenCV
↓
进入main
↓
读取test.jpg
↓
存进image
↓
判断image是不是空
│
├── 是 → 报错 → 程序结束
│
└── 否
↓
显示图片
↓
等待按键
↓
程序正常结束
channel 通道是什么?
一张灰度图:
每个像素
↓
1个数字
所以可以说:
1 channel
也就是:
单通道。
彩色图:
每个像素
↓
B
G
R
所以:
3 channels
即:
三通道。
OpenCV 可以:
std::cout << image.channels() << std::endl;
如果输出:
3
说明:
彩色三通道图片。
假设同一张图片拆开:
彩色图片
│
├── B通道
├── G通道
└── R通道
就像三张灰度图叠在一起。每一张记录一种颜色有多强。最后组合:
B
+
G
+
R
↓
彩色图片
1. 一张彩色图到底长什么样?
假设一张极小图片:
2 × 2
它可能不是:
10 20
30 40
而是:
(10,20,30) (50,60,70)
(80,90,100) (120,130,140)
每一个:
(B, G, R)
就是一个彩色像素。
所以图像其实可以理解为:
二维网格 + 每个格子里面有三个数字。
灰度化到底是在干什么?
cv::Mat gray;
cv::cvtColor(
image,
gray,
cv::COLOR_BGR2GRAY
);
看起来有点复杂。翻译成人话:
image
=
原始彩色图
gray
=
用来保存灰度图
COLOR_BGR2GRAY
=
把BGR彩色图转成灰度图
流程:
彩色图片
B G R
↓
cvtColor
↓
灰度图片
一个亮度值
1. OpenCV 代码常常不是一步完成
原图
↓
灰度图
↓
模糊图
↓
二值图
↓
边缘图
↓
轮廓
↓
目标
每一步都生成一个新的 cv::Mat。例如:
cv::Mat image;
cv::Mat gray;
cv::Mat blurred;
cv::Mat edges;
image
=
原始图
gray
=
灰度结果
blurred
=
模糊结果
edges
=
边缘结果
这些都是:
cv::Mat
只是里面装的图不同。现在你只需要把这张关系图建立起来:
现实中的画面
↓
相机 / 图片文件
↓
JPG / PNG
↓
cv::imread
↓
cv::Mat
↓
像素矩阵
│
├── rows 高度
├── cols 宽度
├── channels 通道
└── 像素值
然后:
cv::Mat
↓
cvtColor
↓
灰度图
↓
blur
↓
去噪
↓
Canny
↓
边缘
↓
findContours
↓
轮廓
↓
rectangle
↓
画框
这就是我们后面 OpenCV 的主线。
怎么读取一个像素
先看一张灰度图。假设:
gray
x →
0 1 2 3
y ┌───┬───┬───┬───┐
0 │20 │30 │40 │50 │
├───┼───┼───┼───┤
1 │60 │70 │80 │90 │
├───┼───┼───┼───┤
2 │10 │15 │25 │35 │
└───┴───┴───┴───┘
如果我们想拿:
x = 2
y = 1
这个位置的像素。
你看表格:
第1行
第2列
↓
80
OpenCV 可以这样:
uchar value = gray.at<uchar>(1, 2);
先别怕这一长串。
大白话:
从 gray 图片里,找到第 1 行、第 2 列,把那个灰度值拿出来。
结果:
value = 80
1. 为什么写 (1, 2) 而不是 (2, 1)?
因为 cv::Mat 这里通常是:
at(行, 列)
也就是:
at(y, x)
不是:
at(x, y)
这非常容易搞反。
所以你可以先记一句:
OpenCV Mat访问像素:
先 y
后 x
例如:
gray.at<uchar>(50, 100);
表示:
y = 50
x = 100
也就是:
从顶部往下 50,从左边往右 100。
2. uchar 是什么鬼?
它可以先理解成:
专门用来装 0~255 这种小整数的数据类型。
灰度图片里的像素经常就是:
0 ~ 255
所以特别适合。
比如:
uchar value = 200;
大概就像:
一个非常小的整数盒子
↓
里面放200
3. at<uchar> 里面为什么还有尖括号?
你会看到:
gray.at<uchar>(y, x)
可以先翻译成:
我告诉 OpenCV,这张图里的每个位置,我希望按
uchar这种数据来读取。
因为 cv::Mat 很灵活,它里面不一定永远是:
0~255 的灰度图
以后还可能装:
整数
小数
深度值
三通道颜色
其他矩阵数据
所以你要告诉它:
我现在按什么类型拿数据。
4. 读取灰度像素完整例子
比如:
#include <iostream>
#include <opencv2/opencv.hpp>
int main()
{
cv::Mat image = cv::imread("test.jpg");
if (image.empty())
{
std::cout << "读取失败" << std::endl;
return -1;
}
cv::Mat gray;
cv::cvtColor(
image,
gray,
cv::COLOR_BGR2GRAY
);
uchar value = gray.at<uchar>(100, 200);
std::cout << "像素值 = "
<< static_cast<int>(value)
<< std::endl;
return 0;
}
你现在先看故事:
读入彩色图片
↓
转灰度图
↓
找到 y=100, x=200
↓
读取那个像素
↓
打印出来
5. 为什么打印时还要 static_cast<int>?
这里:
static_cast<int>(value)
把 uchar 临时当成普通整数显示。
因为某些情况下:
std::cout << value;
可能不按你想要的数字方式输出。
所以我们经常:
std::cout << static_cast<int>(value);
你先把它理解成:
"把像素值按整数打印"。
6. 怎么修改一个像素
读取是:
uchar value = gray.at<uchar>(100, 200);
修改其实更简单:
gray.at<uchar>(100, 200) = 0;
意思:
把 y=100、x=200 这个像素改成黑色。
因为:
0 = 黑
如果:
gray.at<uchar>(100, 200) = 255;
就是:
改成白色。
7. 这其实就是"直接改图片"
你可以想象:
原来:
100 120 130
140 150 160
170 180 190
把中间像素:
150
改成:
0
就变:
100 120 130
140 0 160
170 180 190
图片上那个位置就会黑掉。
二值图是什么?
二值图很简单,整张图片只有两种值:
0
255
也就是:
黑
白
比如:
原灰度图:
30 60 200
90 180 220
10 140 250
假设规定:
> 128 → 255
<=128 → 0
结果:
0 0 255
0 255 255
0 255 255
视觉上就是:
黑 黑 白
黑 白 白
黑 白 白
这就是:
binary image,二值图。
原图
↓
灰度图
↓
二值图
↓
目标区域
threshold() 是什么?
我们刚才自己手写:
如果 > 128
变255
否则
变0
OpenCV 已经给你现成工具了:
cv::threshold(
gray,
binary,
128,
255,
cv::THRESH_BINARY
);
先翻译成人话:
gray
=
输入灰度图
binary
=
输出二值图
128
=
判断门槛
255
=
超过门槛后设置成多少
THRESH_BINARY
=
普通二值化
所以:
gray
↓
threshold
↓
binary
彩色像素怎么访问?
前面灰度图一个像素:
一个数字
但彩色图一个像素:
B G R
所以不能再简单用:
uchar
而常见会用:
cv::Vec3b
例如:
cv::Vec3b pixel = image.at<cv::Vec3b>(100, 200);
意思:
读取彩色图 y=100、x=200 的那个像素。
这个 pixel 里面有三个小数字。
1. Vec3b 可以理解成"三个 uchar 装一起"
例如:
pixel
┌─────┬─────┬─────┐
│ B │ G │ R │
└─────┴─────┴─────┘
你可以访问:
pixel[0]
是:
B
pixel[1]
是:
G
pixel[2]
是:
R
所以:
cv::Vec3b pixel = image.at<cv::Vec3b>(100, 200);
int b = pixel[0];
int g = pixel[1];
int r = pixel[2];
大白话:
拿出这个彩色像素里的蓝、绿、红三个值。
2. 把一个像素改成纯红色
OpenCV 使用 BGR。所以纯红:
B = 0
G = 0
R = 255
代码:
image.at<cv::Vec3b>(100, 200)[0] = 0;
image.at<cv::Vec3b>(100, 200)[1] = 0;
image.at<cv::Vec3b>(100, 200)[2] = 255;
这个位置就变红了。不过实际工程中我们一般不会这么啰嗦,后面还会学更方便的写法。
clone() 是什么?
假设:
cv::Mat image = cv::imread("test.jpg");
然后:
cv::Mat copy = image;
很多新手会以为:
现在完整复制了一张图片。
但 cv::Mat 有自己的内存管理机制,这种赋值很多时候并不是把所有像素重新完整复制一份。
你现阶段可以简单理解:
image
↘
同一份底层图像数据
↗
copy
所以有时候:
copy.at<...>() = ...
可能也会影响 image 对应的数据。
如果你就是想:
真正复制一份独立图片。
常见写:
cv::Mat copy = image.clone();
大白话:
给我完整复印一份。
于是:
image
↓
原图
copy
↓
独立副本
这个以后特别实用。
resize ------ 改图片大小
例如原图:
1920 × 1080
太大。
想缩成:
640 × 360
OpenCV:
cv::Mat small;
cv::resize(
image,
small,
cv::Size(640, 360)
);
翻译:
image
=
输入
small
=
输出
Size(640,360)
=
新尺寸
注意这里:
cv::Size(640, 360)
顺序是:
宽,高
也就是:
width, height
这和:
at(y, x)
又不一样。所以以后一定注意函数参数含义,不要凭感觉。
ROI------只处理图片的一小块
ROI 是以后非常常见的词:
Region of Interest
大白话:
我感兴趣的那一块区域。
比如:
┌───────────────────────┐
│ │
│ ┌───────┐ │
│ │ 杯子 │ │
│ └───────┘ │
│ │
└───────────────────────┘
我们不想处理整张图。只处理:
┌───────┐
│ 杯子 │
└───────┘
这就是 ROI。
1. OpenCV 里矩形怎么表示?
cv::Rect roi(100, 50, 200, 150);
你可以理解为:
x = 100
y = 50
宽 = 200
高 = 150
所以:
从 (100,50) 开始
↓
往右200
↓
往下150
这块矩形就是 ROI。然后:
cv::Mat cropped = image(roi);
就是:
从 image 里面取这一块。
什么叫"噪声"?
你可以把图像噪声理解成:
图片里那些你不想要的小杂点、小抖动、小干扰。
比如你本来拍的是一个很平整的桌面。
理想情况可能像:
████████████████
████████████████
████████████████
但真实相机拍出来可能:
██████▓█████████
██████████▒█████
████▓███████████
这些小变化可能来自:
光照
传感器
压缩
环境
电子噪声
电脑会把这些变化也当成"信息"。
这就麻烦了。
1. 为什么噪声会影响边缘检测?
假设真实物体只有一个边界:
████████│
████████│
████████│
电脑真正想找的是:
│
│
│
但如果图片里有很多乱七八糟的小变化:
██▓██▒██│
████▓███│
█▒██████│
边缘算法可能就会认为:
这里有边
那里也有边
那边还有边
最后得到很多垃圾边缘。所以经常先做:
去噪。
2. 模糊为什么反而能去噪?
第一次听可能很反直觉:
图片不是应该越清楚越好吗?为什么还主动模糊?
因为我们不是为了欣赏照片。
我们是为了:
让算法更稳定。
可以想象一个区域:
100 102 101
99 180 100
101 98 102
中间突然来了一个:
180
可能只是噪声。
如果看看周围,大部分都在:
100左右
那就可以想:
中间这个值不太靠谱,应该参考一下邻居。
于是模糊处理大概是在干:
当前像素
+
周围像素
↓
综合一下
↓
得到一个更平滑的结果
所以:
小的随机变化
↓
被压下去
而大的物体结构:
杯子
桌面
墙
基本还保留。
3. GaussianBlur
OpenCV 里很常见:
cv::GaussianBlur(
gray,
blurred,
cv::Size(5, 5),
0
);
GaussianBlur = 一种很常见的平滑去噪方法。
这里:
gray
是输入。
blurred
是输出。
cv::Size(5, 5)
可以先理解成:
每次处理一个像素时,看看它附近的一小块区域。
大概就是:
┌─────────┐
│ 邻居邻居 │
│ 邻居●邻居│
│ 邻居邻居 │
└─────────┘
中间的 ● 就是当前像素。
如果区域太小:
去噪效果弱
如果区域太大:
图像被糊得太厉害
所以以后做工程时,本质就是:
找一个合适程度。
初学阶段先用:
cv::Size(5, 5)
很常见。
4. 什么是"边缘"?
这个概念非常重要。
你看到一个杯子:
______
/ \
| |
| |
\______/
电脑怎么知道哪里是杯子?
一个非常朴素的方法就是:
找图片里"变化特别大的地方"。
例如:
背景背景背景 | 杯子杯子杯子
左边比较暗,右边比较亮。
这里:
颜色/亮度突然变化
于是就可能是:
边缘。
所以边缘可以先理解:
图像内容突然发生明显变化的位置。
5. Canny 是什么?
以后你会经常看到:
cv::Canny(
blurred,
edges,
50,
150
);
Canny 是一种经典边缘检测算法。
输入一张图,让 OpenCV 帮你把明显的边缘提出来。
输入:
正常图片
输出:
黑底 + 白色边缘
比如原图:
█████
█ █
█ █
█████
Canny 后大概:
┌─────┐
│ │
│ │
└─────┘
6. findContours
OpenCV 可以:
std::vector<std::vector<cv::Point>> contours;
cv::findContours(
edges,
contours,
cv::RETR_EXTERNAL,
cv::CHAIN_APPROX_SIMPLE
);
输入一张适合找形状的图
↓
把里面的轮廓找出来
↓
保存进 contours
所以调用:
cv::findContours(...);
之后:
contours.size()
就可以知道:
找到了多少个轮廓。
7. 有轮廓以后怎么框住物体?
假设找到一个轮廓:
●●●
● ●
● ●
● ●
●●●
我们可以问:
能不能找一个最简单的矩形,把它包住?
当然可以。
这就叫:
Bounding Box
包围框
OpenCV 可以:
cv::Rect box = cv::boundingRect(contours[i]);
大白话:
给第 i 个轮廓算一个矩形框。
8. box 里面有什么?
我们之前已经学过 cv::Rect。
它里面大概有:
x
y
width
height
于是:
box.x
box.y
box.width
box.height
你应该已经能看懂了。
这就是:
框从哪里开始,以及框有多大。
9. 怎么把框画到图上?
使用:
cv::rectangle(
image,
box,
cv::Scalar(0, 255, 0),
2
);
大白话:
image
=
画在哪张图上
box
=
画哪个矩形
Scalar(0,255,0)
=
颜色
2
=
线条粗细
10. YOLO 这种更像"学会认东西"
比如:
猫
狗
人
汽车
很难靠:
if
边缘
颜色
简单规则写出来。
于是深度学习会:
看大量图片
↓
学习特征
↓
最后判断
"这像一个人"
所以以后你的学习路线其实会逐渐变成:
OpenCV基础
↓
传统图像处理
↓
相机
↓
三维视觉
↓
Open3D
如果以后需要物体识别,再接:
YOLO
形态学
先不讲函数。
你看一个二值图:
██████
██ ██
██████
中间可能莫名其妙有个小洞。
或者:
██████ ▪
██████
██████
旁边有个小白点。
我们会希望:
小洞补上
小杂点删掉
OpenCV 里有一类操作叫:
形态学处理。
最常见:
腐蚀 erosion
膨胀 dilation
开运算
闭运算
名字有点怪,但大白话很好懂。
1. 腐蚀是什么?
可以先理解:
把白色区域往里缩一点。
比如:
██████
██████
██████
腐蚀后:
████
████
作用之一:
去掉细小白色噪点。
2. 膨胀是什么?
相反:
把白色区域向外扩一点。
比如:
██
██
膨胀后:
████
████
作用之一:
填小缝隙、让断开的区域连接一些。
好,继续。现在我们正式从"处理一张照片"升级成:
让程序一直看摄像头,并实时处理画面。
OpenCV 官方文档中,cv::VideoCapture 就是用来从摄像头、视频文件或图像序列 读取画面的核心类。(OpenCV 文档)
视频到底是什么?
先不要把"视频"想得太复杂。
你看到:
🎥 一段视频
电脑可以把它理解成:
图片1
↓
图片2
↓
图片3
↓
图片4
↓
图片5
↓
......
只不过这些图片切换得非常快。
比如:
30 FPS
意思大致就是:
1 秒钟显示 30 张图片。
所以:
视频
=
连续很多张图片
而视频里的每一张图片有个特别重要的名字:
Frame,帧。
1. Frame 是什么?
以后你做机器人一定会疯狂看到:
frame
frame_id
frame_count
frame_rate
这里的 frame 在视频场景里可以先理解成:
当前这一张画面。
例如:
第1帧
第2帧
第3帧
第4帧
...
每一帧其实都可以是:
cv::Mat
所以马上你就会发现:
我们前面学的所有 OpenCV 图片处理技术,都可以直接用到视频的一帧上。
这特别重要。
2. 摄像头的核心思路
我们以前处理照片:
test.jpg
↓
imread
↓
image
↓
处理
现在摄像头变成:
摄像头
↓
获取第1帧
↓
frame
↓
处理
摄像头
↓
获取第2帧
↓
frame
↓
处理
摄像头
↓
获取第3帧
↓
frame
↓
处理
......
所以你有没有发现?我们需要:
不断重复
3. VideoCapture
最经典的一行:
cv::VideoCapture cap(0);
大白话:
OpenCV,帮我打开一个摄像头。
这里:
cap
是我们给摄像头对象起的名字。
你可以脑补:
cap
│
└── 摄像头
而:
0
通常表示一个摄像头设备编号;OpenCV 也支持显式选择视频 I/O 后端。具体哪个编号对应哪个物理摄像头,取决于机器和系统。(OpenCV 文档)
4. 为什么是 0?
比如电脑里:
摄像头0 → 笔记本内置摄像头
摄像头1 → USB摄像头
摄像头2 → 另一个摄像头
那么:
cv::VideoCapture cap(0);
就是尝试打开编号 0 的设备。
如果以后你:
cv::VideoCapture cap(1);
就可能是在尝试另一个摄像头。
注意:
0并不等于"世界上永远都是内置摄像头"。
它只是设备索引。
5. 怎么知道摄像头打开成功没有?
这和之前:
image.empty()
非常像。
可以:
if (!cap.isOpened())
{
std::cout << "摄像头打开失败" << std::endl;
return -1;
}
OpenCV 官方的 VideoCapture 示例也会用 isOpened() 检查设备是否成功打开。(OpenCV 文档)
我们翻译:
cap.isOpened()
就是:
摄像头开了吗?
而:
!
可以理解:
取反。
所以:
!cap.isOpened()
就是:
摄像头没有打开。
于是:
if (!cap.isOpened())
就是:
如果摄像头没打开......
6. 怎么从摄像头拿一帧?
先准备:
cv::Mat frame;
大白话:
准备一个叫
frame的图片盒子。
然后:
cap.read(frame);
意思:
从摄像头拿一张当前画面,放进 frame。
官方 API 的典型流程也是通过 VideoCapture::read() 获取下一帧,并把它存入 cv::Mat。(OpenCV 文档)
所以:
摄像头
↓
cap.read()
↓
frame
注意:
frame
就是:
cv::Mat
这意味着后面可以直接:
cv::cvtColor(frame, ...);
cv::GaussianBlur(frame, ...);
cv::Canny(frame, ...);
前面学的全部可以接上。
7. 第一个真正的摄像头程序
#include <iostream>
#include <opencv2/opencv.hpp>
int main()
{
cv::VideoCapture cap(0);
if (!cap.isOpened())
{
std::cout << "摄像头打开失败" << std::endl;
return -1;
}
cv::Mat frame;
while (true)
{
cap.read(frame);
if (frame.empty())
{
std::cout << "读取画面失败" << std::endl;
break;
}
cv::imshow("Camera", frame);
cv::waitKey(1);
}
return 0;
}
创建摄像头对象
↓
打开摄像头0
↓
检查成功没
↓
准备 frame
↓
进入无限循环
↓
从摄像头拿一帧
↓
检查这一帧
↓
显示
↓
再循环
↓
拿下一帧
就是这么简单,所以摄像头程序核心本质其实是:
while
+
cv::Mat
+
VideoCapture
8. 怎么按键退出?
我们刚才:
cv::waitKey(1);
没有处理返回值。
实际上我们可以:
int key = cv::waitKey(1);
然后判断:
if (key == 27)
{
break;
}
这里:
27
通常用来对应 Esc 键的键码。
于是:
不断读取摄像头
↓
如果按 Esc
↓
break
↓
退出
waitKey() 除了等待按键,还承担 OpenCV HighGUI 窗口事件处理;官方文档说明,当使用 HighGUI 窗口时,需要周期性调用 waitKey 或 pollKey 来处理 GUI 事件。(OpenCV 文档)
9. 为什么摄像头里不是 waitKey(0)?
之前图片:
cv::waitKey(0);
意思可以粗略理解:
一直等按键。
问题来了。
视频如果:
第1帧
↓
waitKey(0)
↓
一直等
那就停在第一张图片了。
所以实时视频通常:
cv::waitKey(1);
也就是:
短暂给窗口处理事件,然后赶紧继续下一轮循环。
这就形成连续画面。
实时灰度化
我们把前面学的:
cv::cvtColor
塞进摄像头循环。
cv::Mat frame;
cv::Mat gray;
while (true)
{
cap.read(frame);
if (frame.empty())
{
break;
}
cv::cvtColor(
frame,
gray,
cv::COLOR_BGR2GRAY
);
cv::imshow("Original", frame);
cv::imshow("Gray", gray);
if (cv::waitKey(1) == 27)
{
break;
}
}
你看整个过程:
摄像头
↓
第1帧
↓
灰度化
↓
显示
摄像头
↓
第2帧
↓
灰度化
↓
显示
摄像头
↓
第3帧
↓
灰度化
↓
显示
所以你现在已经做出了:
实时图像处理。
实时边缘检测
再继续:
cv::Mat frame;
cv::Mat gray;
cv::Mat blurred;
cv::Mat edges;
然后循环:
while (true)
{
cap.read(frame);
if (frame.empty())
{
break;
}
cv::cvtColor(
frame,
gray,
cv::COLOR_BGR2GRAY
);
cv::GaussianBlur(
gray,
blurred,
cv::Size(5, 5),
0
);
cv::Canny(
blurred,
edges,
50,
150
);
cv::imshow("Camera", frame);
cv::imshow("Edges", edges);
if (cv::waitKey(1) == 27)
{
break;
}
}
现在整个数据流:
真实世界
↓
摄像头
↓
frame
↓
灰度
↓
gray
↓
去噪
↓
blurred
↓
边缘检测
↓
edges
↓
显示
注意这里一个特别重要的思想:
不是"处理视频",而是在不断处理每一帧图片。
这句话以后特别重要。
机器人摄像头程序可能是:
while机器人运行
{
获取相机画面
找目标
计算目标位置
判断目标在哪里
生成机器人命令
}
也就是:
感知
↓
理解
↓
决策
↓
动作
↓
下一帧
再循环:
感知
↓
理解
↓
决策
↓
动作
这已经开始接近机器人控制程序的整体思想了。
FPS 是什么?
Frames Per Second
就是:
每秒处理/显示多少帧。
例如:
10 FPS
大致:
1秒10张
30 FPS
大致:
1秒30张
60 FPS
大致:
1秒60张
相机看到障碍物
↓
程序花1秒处理
↓
机器人1秒以后才知道
如果机器人正在走:
1 秒可能已经非常晚了。
所以机器人视觉会非常关心:
采集速度
处理时间
网络延迟
控制周期
这和你以后理解:
50 Hz
20 ms
掉帧
延迟
抖动
其实都是连起来的。
例如:
50 Hz
大白话就是理想情况下:
1秒50次
平均大约:
20 ms 一次
这是机器人系统里非常重要的"时间意识"。
好,继续。现在我们正式从"看见边缘和轮廓"进入:
电脑知道目标在画面哪里。
这一步非常重要,因为它是从 OpenCV 二维视觉 走向 三维点云 / Open3D 的桥梁。
怎么找到目标中心?
现在我们知道框:
width
←────────→
┌────────┐
│ │
│ ● │
│ │
└────────┘
↑
目标中心
中心位置很好理解:
左边位置
+
半个宽度
以及:
顶部位置
+
半个高度
代码:
int centerX = box.x + box.width / 2;
int centerY = box.y + box.height / 2;
先不要把它当数学公式。
就想:
从框左上角出发,往右走一半,往下走一半,就到了中心。
1. 把中心画出来
OpenCV 可以直接画圆:
cv::circle(
image,
cv::Point(centerX, centerY),
5,
cv::Scalar(0, 0, 255),
-1
);
这里:
image
=
画在哪张图上
cv::Point(centerX, centerY)
表示:
圆心位置。
5
=
圆的大小
cv::Scalar(0,0,255)
OpenCV 是 BGR:
红色
最后:
-1
可以先理解成:
把这个圆填满。
于是:
┌────────────────────┐
│ │
│ ┌──────┐ │
│ │ ● │ │
│ └──────┘ │
│ │
└────────────────────┘
现在电脑已经有:
目标中心 = (centerX, centerY)
了。
什么是深度?
深度就是:
这个像素对应的东西,离相机有多远。
比如一张普通 RGB 图:
这个位置是杯子
↓
(x=320, y=240)
如果深度相机还能告诉你:
这个位置距离相机
↓
0.8米
那我们就多了:
Z ≈ 0.8m
这就非常有用了。
1. 什么是深度图 Depth Image?
普通彩色图:
每个像素
↓
B G R
深度图:
每个像素
↓
距离
比如一个极简深度图:
1.2 1.2 1.3
1.1 0.8 1.2
1.2 1.2 1.3
中间:
0.8
表示:
这个位置对应的物体比较近。
周围:
1.2
表示:
周围背景比较远。
所以深度图虽然看起来也是"一张图",但里面存的不是颜色,而是:
距离信息。
2. RGB 图和 Depth 图结合起来
这时候机器人视觉突然变得很强。RGB 图告诉你:
这是谁?
在哪里?
什么颜色?
什么形状?
Depth 图告诉你:
它离我多远?
所以:
RGB
+
Depth
=
RGB-D
这里的:
D
=
Depth
于是你以后会经常听见:
RGB-D Camera
就是:
彩色 + 深度相机。
3. RGB-D 相机是什么?
常见的 RGB-D / 深度相机类别包括:
结构光
双目
ToF
RGB相机
↓
颜色图片
深度模块
↓
深度图片
组合:
RGB图
+
Depth图
↓
三维信息
4. 为什么有了深度,还不能直接叫 Point Cloud?
因为深度图还是:
二维网格
例如:
像素 (100, 50)
↓
深度 0.8m
现在我们知道:
图片哪个位置
+
离相机多远
但还需要把它转换成真正的:
X
Y
Z
这个过程可以叫:
反投影 / back-projection
现在名字不用背。你先理解成:
把图片里的点"还原"回三维空间。
5. 怎么从像素变成三维点?
现在我们有:
像素位置
(u, v)
+
深度
Z
为什么这里突然写:
u, v
而不是:
x, y
因为工程里经常为了避免和三维的 X,Y,Z 搞混,会写:
u, v
=
图片像素坐标
而:
X, Y, Z
=
三维坐标
这个习惯很好。
所以以后你可以这样区分:
二维图片:
u → 横向像素
v → 纵向像素
三维:
X
Y
Z
这能避免非常多混乱。
6. 相机内参
这是进入三维视觉后第一个新概念。
Camera Intrinsics,相机内参。
先把相机想成眼睛。两个人站在同一个地方拍同一个杯子:
相机A:广角
相机B:长焦
最后杯子在图片里的大小可能完全不同。
说明:
同样的三维世界,经过不同相机,投影到图片上的结果不一样。
因此电脑必须知道:
你这台相机"怎么看世界"。
这些描述相机成像特性的参数,就是内参。内参里最常见的几个东西
fx
fy
cx
cy
fx / fy可以先理解:
相机在横向和纵向上的"成像尺度"。
和焦距有关。它会影响:
现实中的东西
↓
在图片里看起来有多大
cx / cy可以理解:
相机光轴大概落在图片哪个位置。
通常接近图片中心。例如:
640 × 480
那么中心附近:
cx ≈ 320
cy ≈ 240
但真实相机不一定正好完美等于中心。
我们已经知道:
像素位置 u,v
深度 Z
相机参数 fx,fy,cx,cy
于是电脑就可以推回:
X
Y
Z
整个故事:
这个像素在哪里?
↓
(u,v)
这个像素离相机多远?
↓
depth
相机本身怎么成像?
↓
intrinsics
三者结合
↓
算出三维位置
(X,Y,Z)
这就是以后从深度图生成点云的核心逻辑。