机器学习基础2——C++、OpenCV、点云、Open3D

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 窗口时,需要周期性调用 waitKeypollKey 来处理 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)

这就是以后从深度图生成点云的核心逻辑。

相关推荐
DeepIntelli1 小时前
品牌内容AI友好化改造:从被搜索引擎收录到被AI引擎引用的技术路径
人工智能·chatgpt
测试_AI_一辰1 小时前
AI Agent 评测最隐蔽的坑-记忆
人工智能·算法·ai·自动化·ai编程
躺不平的理查德1 小时前
Windows C++ 第三方库使用流程备忘录--OpenCV
开发语言·c++
Hotchip_MEMS1 小时前
TWS耳机微米级较量:超薄LGA封装如何助力MEMS麦克风小型化?
人工智能·笔记·物联网·电脑
余额瞒着我当琳1 小时前
C++STL容器string--迭代器,string的接口,string的遍历,访问方式
c++
郭涤生1 小时前
rootfs 详解与裁剪优化记录
linux·c++·bsp
lucas_AI1 小时前
给YOLO检测器插 LoRA,'插对地方'比'插什么'更要命
人工智能·算法
墨_浅-1 小时前
20260811金融科技动向:国金证券deepseek金融适配度评测方法
人工智能·科技·金融
菜冻鱼1 小时前
Python-sklearn-评估指标
开发语言·人工智能·python·机器学习·numpy·pandas·sklearn