可以把整个学习过程理解成:

C++          = 你和电脑沟通的语言
    ↓
OpenCV       = 让电脑“看懂二维图片”
    ↓
点云 PC      = 让电脑理解“三维空间里的点”
    ↓
Open3D       = 专门处理、显示、分析三维点云的工具箱
    ↓
最终
相机/深度相机/雷达
    ↓
获得图像 + 点云
    ↓
处理
    ↓
识别物体 / 测距离 / 定位 / 建图 / 机器人感知

一、先搞明白你到底在学什么

假设桌子上放着一个水杯。电脑拿到的东西可能只是:

图片:
1920 × 1080 个小方格

点云:
(x1, y1, z1)
(x2, y2, z2)
(x3, y3, z3)
...
几十万个点

你的任务就是写程序,让电脑逐渐从:

一堆数字

变成:

这是一个杯子
它在这里
它距离相机 0.8 米
它高度约 15 cm

二、OpenCV 是什么?

OpenCV 可以理解为:

别人已经用 C++ 帮你写好的“图像处理工具箱”。

例如正常情况下,如果让你自己从零写:

读取 JPG
解析 JPG
存储像素
调整图片尺寸
转换灰度
检测边缘

会非常麻烦。

OpenCV 已经给你做好了。

于是:

cv::Mat image = cv::imread("test.jpg");

大概就是:

OpenCV,帮我把 test.jpg 图片读进来。

然后:

cv::imshow("image", image);

就是:

把这张图片显示出来。


图片到底是什么?

这是以后 OpenCV 最重要的一个思想。你看到:一张照片电脑看到的其实是:

一张二维表格。

比如一张极小的灰度图片:

50    60    70
80    100   120
150   180   200

这些数字表示亮暗程度。通常:

0   = 黑色
255 = 白色

于是:

0        100        255

黑  ←──────────→  白

彩色图片也差不多,只不过一个格子里不再只有一个数字,而通常是:

B
G
R

也就是蓝、绿、红三个数。

所以 OpenCV 本质上一直在干:

处理一个巨大的数字矩阵。


三、什么是 Point Cloud 点云?

这一步非常重要。照片解决的是:

二维世界

例如:

       x →
   ┌──────────┐
 y │          │
 ↓ │  杯子    │
   │          │
   └──────────┘

里面只有:

x
y

但真实世界是三维的:

x
y
z

其中 z 可以理解为深度/距离。

于是我们可以得到:

点1 = (0.1, 0.5, 1.2)
点2 = (0.2, 0.5, 1.1)
点3 = (0.3, 0.6, 1.0)
...

几十万个这样的点组合起来:

         . . .
      .       .
    .    杯子   .
      .       .
         . .
────────────────── 桌面

就形成了:

Point Cloud,点云。


为什么叫“云”?

因为数据长这样:

. .    .       .
   .  .  . .
 .      .     .
    .       .
 .    .   .

大量的点漂浮在三维空间中,看起来像一团云。

所以叫:

Point Cloud
点    云

一个点最基础的信息就是:

x
y
z

有时候还有:

颜色 RGB

于是一个点可能是:

x = 0.53
y = 1.21
z = 2.14

R = 120
G = 200
B = 80

也就是说:

空间中这个位置,有一个绿色偏亮的点。


四、Open3D 又是什么?

和 OpenCV 类似。OpenCV:

二维图像工具箱

Open3D:

三维数据工具箱

比如你有 100 万个点。你想:

显示点云
删除噪点
减少点数量
计算法向量
找平面
找物体
对齐两个点云
计算距离

不用全部自己写。Open3D 已经给你很多工具。所以:

OpenCV
    ↓
2D

Open3D
    ↓
3D

这是目前非常重要的区别。


五、把四个东西串起来

你以后真正写程序,可能长这样:

               真实世界
                  ↓
           相机 / 深度相机
             ↙           ↘
         RGB图片         深度数据
            ↓               ↓
         OpenCV          生成点云
            ↓               ↓
       图像处理          Open3D
            ↓               ↓
       识别目标          三维处理
             ↘           ↙
               C++
                ↓
             你的程序
                ↓
              机器人

所以你最终不是为了:

学 C++。

而是为了:

用 C++ 操作 OpenCV 和 Open3D,做机器人视觉与三维感知。

这个学习目标会清晰很多。小白最容易一上来就:

OpenCV
Open3D
PCL
Eigen
CMake
ROS
CUDA
SLAM
YOLO
ICP
RANSAC

我们按照这条顺序来:

第 0 阶段
建立整个地图
        ↓
第 1 阶段
C++ 基础、现代 C++ 必要知识
        ↓
第 2 阶段
CMake + 项目结构
        ↓
第 3 阶段
OpenCV 基础、图像处理
        ↓
第 4 阶段
三维坐标基本概念
        ↓
第 5 阶段
点云 Point Cloud
        ↓
第 6 阶段
Open3D
        ↓
第 7 阶段
点云算法
        ↓
第 8 阶段
图像 + 点云融合
        ↓
第 9 阶段
实际相机
        ↓
第 10 阶段
机器人视觉项目

六、第一阶段:C++ 我们具体学什么

不用把一本 1000 页 C++ Primer 全啃完。我们第一轮只学以后做 OpenCV / Open3D特别需要的:

vector
引用 &
指针 *
class
const

我会一直用机器人视觉、OpenCV、点云里的真实小例子来教,而不是先让你背一大堆和目标无关的 C++ 语法。

假设以后你做 OpenCV:

读取图片
↓
灰度化
↓
去噪
↓
边缘检测
↓
找轮廓
↓
显示结果


vector —— 装很多数据的“大盒子”

std::vector<int> numbers = {10, 20, 30, 40};
numbers

下标     0     1     2     3
        ┌────┬────┬────┬────┐
数据    │ 10 │ 20 │ 30 │ 40 │
        └────┴────┴────┴────┘

 往 vector 里加东西

例如:

std::vector<int> numbers;

numbers.push_back(10);
numbers.push_back(20);
numbers.push_back(30);

最后:

numbers

┌────┬────┬────┐
│ 10 │ 20 │ 30 │
└────┴────┴────┘

这里:

push_back()

大白话:

往最后面塞一个。

比如:

numbers.push_back(40);

就变成:

10  20  30  40


struct —— 自己定义一种“数据”

struct Point
{
    double x;
    double y;
    double z;
};

大白话:

我规定以后有一种东西叫 Point

一个 Point 里面必须有:

x
y
z

多个 Point 放进 vector

std::vector<Point> points;

你可以理解:

points
↓
一大堆三维点

比如:

points[0] = (1, 2, 3)
points[1] = (2, 3, 4)
points[2] = (5, 1, 2)
...

是不是开始接近 Point Cloud 了?其实点云最朴素的理解就是:

Point
=
一个XYZ点

vector<Point>
=
很多XYZ点

很多XYZ点
=
点云

这一步非常关键。


我们现在自己“造一个迷你点云”

例如:

#include <iostream>
#include <vector>

struct Point
{
    double x;
    double y;
    double z;
};

int main()
{
    std::vector<Point> points;

    Point p1;
    p1.x = 1.0;
    p1.y = 2.0;
    p1.z = 3.0;

    Point p2;
    p2.x = 4.0;
    p2.y = 5.0;
    p2.z = 6.0;

    points.push_back(p1);
    points.push_back(p2);

    for (int i = 0; i < points.size(); i++)
    {
        std::cout
            << points[i].x << " "
            << points[i].y << " "
            << points[i].z
            << std::endl;
    }

    return 0;
}

你现在不用自己完整写出来。

只需要看懂它的故事。

创建一种 Point
↓
Point里面有xyz
↓
创建一个 points 大盒子
↓
创建 p1
↓
填入 xyz
↓
创建 p2
↓
填入 xyz
↓
把 p1、p2 放进 points
↓
for 一个个取出来
↓
打印

输出:

1 2 3
4 5 6

这已经是一个:

超级简化版点云程序。

现在进入 C++ 第一个“小坎”:

&

假设你有一张很大的图片:

1920 × 1080

甚至更大。如果每调用一次函数,都:

把整张图片复制一份

很浪费。所以 C++ 可以说:

不复制,你直接用原来的那个。

这就是“引用”最直观的用途之一。

void change(int& a)
{
    a = 100;
}

然后:

int x = 10;

change(x);

最后:

x = 100

为什么?

因为:

int& a

不是新复制一个 a。

而是:

a 直接代表 x 本人。

可以想:

x
┌─────┐
│ 10  │
└─────┘
  ↑
  │
  a

a 和 x 指向同一个东西。

于是:

a = 100;

就把 x 改掉了。


不加 & 会怎样?

void change(int a)
{
    a = 100;
}

然后:

int x = 10;

change(x);

结果:

x 还是 10

因为发生的是:

x = 10

复制一份

a = 10

然后函数里:

a = 100

改的只是复制品,x 没变。

普通参数
=
复制一份给函数

引用 &
=
直接让函数操作原来的东西

以后处理大图片、大点云时,这个非常重要。


const & 

你以后会看到:

void process(const Image& image)

先翻译成:

我想直接看原图,不想复制,但我保证不乱改它。

其中:

&
=
不复制

const
=
不允许修改

所以:

const xxx&

以后先脑补成:

只读借用。

这个理解非常实用。

知识已经能串成:

变量
↓
存一个数据

struct
↓
把几个数据组合起来

vector
↓
存很多个数据

for
↓
逐个处理

if
↓
判断要不要处理

函数
↓
把处理逻辑封装起来

&
↓
避免没必要的复制

而点云:

Point
=
x y z

vector<Point>
=
很多点

for
=
遍历所有点

if
=
筛掉不需要的点

函数
=
封装滤波、检测、计算

最终
=
点云处理

接下来我们应该学一个特别关键、也是很多 C++ 初学者真正开始卡住的部分:

内存是什么
↓
变量到底放在哪里
↓
地址是什么
↓
指针 * 到底是什么
↓
& 和 * 到底什么关系
↓
为什么 OpenCV / Open3D / C++ 工程里到处都是指针
↓
智能指针 shared_ptr 是什么

& 还有另外一个意思:取地址

之前我们学过:

void change(int& a)

这里的 & 表示“引用”。

但如果这样写:

int age = 20;

std::cout << &age << std::endl;

这里:

&age

意思变成:

告诉我 age 住在哪里。

也就是:

age = 20

age 的地址
↓
比如 0x1234abcd

真实地址一般不是简单的 1000,而是可能看到:

0x000000A15FF7F8AC

别被它吓到。它本质只是:

一个门牌号。


指针是什么?

int* p = &age;

p 里面装的不是 20而是:

age 的地址

所以:

age

地址1000
┌─────────────┐
│     20      │
└─────────────┘
      ↑
      │
      │
      p

p 保存的是:

1000

也就是说:

p 知道 age 在哪里。

所以:

int* p

大白话:

创建一个可以保存 int 地址的变量 p。

int* p = &age;

那么:

p

表示:

age 的地址。

而:

*p

表示:

顺着这个地址找到那个东西,然后拿出里面的数据。

比如:

p
↓
1000
↓
找到1000号房
↓
里面是20

所以:

std::cout << *p << std::endl;

输出:

20

&* 放一起看

age = 20
地址 = 1000

&age
↓
获得 1000

p = &age
↓
p 里面保存 1000

*p
↓
沿着 1000 找回去
↓
获得 20

所以:

&变量
=
问“你住哪?”

*指针
=
问“你指着的房间里是什么?”

如果你能先记住这两句话,就非常够用了。


什么是 nullptr

假设:

int* p;

这是一个指针。

但是现在它还没有明确指向哪个对象。

现代 C++ 通常会写:

int* p = nullptr;

大白话:

p 现在谁都没指。

可以想象:

p
↓
空

这叫:

空指针

为什么空指针很重要?

假设你以后写:

Camera* camera = nullptr;

意思:

现在还没有真正连接相机。

以后成功创建:

camera
↓
真正的相机对象

所以很多程序会判断:

if (camera != nullptr)
{
    // 使用相机
}

大白话:

如果这个指针确实指着一个东西,才去使用。

否则你拿一个什么都没指的指针乱操作,程序很可能直接出问题。


-> 是什么

你可以把:

p->x

理解成:

顺着 p 找到对象,然后拿它里面的 x。

逻辑:

p
↓
找到 Point
↓
进入 Point
↓
拿 x

所以:

p->x

很好理解。


动态内存先知道概念

以前:

int age = 20;

通常变量自己创建,作用范围结束以后系统会帮你清理。但有些时候我们希望:

程序运行过程中,临时申请一个对象。

以前 C++ 经常看到:

int* p = new int(20);

大白话:

运行的时候帮我找一块内存,放一个整数 20,然后把地址给 p。

结构:

p
│
↓
某块新申请的内存
┌───────┐
│  20   │
└───────┘

以前用完还要:

delete p;

也就是:

这块房间不用了,还给系统。


为什么新手经常被 new/delete 搞崩?因为你可能:

new

申请了很多东西。但忘记:

delete

于是:

申请
申请
申请
申请
申请
...

一直不释放。这就是大家经常听到的:

内存泄漏。

可以想象成:

不断租仓库
↓
从来不退租
↓
仓库越来越少

程序长期运行以后可能越来越占内存。


智能指针是什么?

现代 C++ 为了解决这个问题,提供了:

std::shared_ptr
std::unique_ptr

现在不要研究标准。只先理解一句:

智能指针 = 帮你管理对象生命周期的指针。

也就是说,它尽量帮你处理:

什么时候创建
什么时候不用了
什么时候释放

你以后 Open3D 很可能看到类似:

std::shared_ptr<PointCloud>

拆开:

PointCloud
=
点云对象

shared_ptr
=
可以共享这个对象的智能指针

可以直接翻译成:

cloud 是一个指向点云对象的智能指针。


为什么叫 shared?

因为可能有很多地方都在使用同一个对象。比如:

          点云对象
             ↑
       ┌─────┼─────┐
       │     │     │
     模块A  模块B  模块C

大家共享:

同一个 PointCloud

最后所有人都不用了,这块对象才会被释放。这就是 shared_ptr 最粗略但非常实用的理解。


unique_ptr 又是什么?

它更像:

这个对象主要归一个主人管。

可以想:

unique_ptr
   ↓
点云对象

原则上不让一堆地方共同拥有。

所以:

shared_ptr
=
大家一起用
unique_ptr
=
主要一个人负责

现阶段理解到这里就可以。


栈 Stack

更像:

临时工作台。

例如函数里面:

void test()
{
    int a = 10;
    int b = 20;
}

函数结束:

a
b

通常也就跟着结束了。可以理解:

进入函数
↓
摆上临时东西

函数结束
↓
自动收走

堆 Heap

更像:

大仓库。

需要的时候:

申请
↓
使用
↓
适当时候释放

很多大型对象、动态对象可能跟这里有关。现阶段只知道:

栈
=
更偏自动管理的临时区域

堆
=
更偏运行时动态申请的大仓库

够了。


现在把 .->&* 一次串起来

这是非常值得保存的一张脑图:

int age = 20;

age
↓
变量本人

&age
↓
age 的地址


int* p = &age;

p
↓
地址

*p
↓
地址指向的数据
↓
20

如果是对象:

Point point;

访问:

point.x

如果是对象指针:

Point* p = &point;

访问:

p->x

所以:

.   → 对象本人访问成员

->  → 指针访问对象成员

&变量 → 取得地址

*指针 → 访问它指向的数据

这四个以后特别常见。


auto 是什么?

但是有时候类型特别长:

std::shared_ptr<open3d::geometry::PointCloud>

于是 C++ 可以:

auto cloud = ...

大白话:

编译器,你自己看右边是什么类型吧。

所以:

auto age = 20;

编译器可以推断:

20
↓
int

于是 age 就是 int。这一点要注意,它不是 Python 那种“完全不管类型”,它只是:

C++ 根据右边帮你推断类型。

编译以后它仍然是一个明确类型。

所以:

auto x = 10;

本质:

x 是 int
auto y = 1.5;

本质:

y 是 double

头文件 .h 和源文件 .cpp

真实项目绝对不会把全部代码塞进:

main.cpp

不然机器人项目可能:

5000 行
10000 行
几万行

根本没法维护。

所以 C++ 项目通常会拆:

project
│
├── main.cpp
│
├── camera.h
├── camera.cpp
│
├── point_cloud.h
├── point_cloud.cpp
│
├── detector.h
└── detector.cpp

大白话:

main.cpp
=
总指挥

camera
=
负责相机

point_cloud
=
负责点云

detector
=
负责检测

这就是大型程序真正的组织方法。

.h 更像:

菜单 / 说明书。

告诉别人:

我这里有一个 add 功能
它需要两个 int
它会返回一个 int

.cpp

int add(int a, int b)
{
    return a + b;
}

更像:

厨房。

真正告诉电脑:

这个菜到底怎么做

所以:

.h
=
告诉别人“有什么”

.cpp
=
告诉电脑“怎么实现”

这个解释虽然不完整,但特别适合现在。


为什么一定要拆?

比如以后:

robot_vision/
│
├── main.cpp
│
├── camera.cpp
│
├── camera.h
│
├── image_processor.cpp
│
├── image_processor.h
│
├── point_cloud_processor.cpp
│
└── point_cloud_processor.h

你想改点云:

point_cloud_processor

你想改相机:

camera

程序就非常清楚。


CMake

我不是已经写好 cpp 了吗?为什么还要 CMake?

因为项目越来越大以后:

main.cpp
camera.cpp
opencv
open3d
其他库

编译器需要知道:

哪些 cpp 要一起编译?

头文件在哪里?

OpenCV 在哪里?

Open3D 在哪里?

链接哪些库?

最终生成什么 exe?

CMake 就是帮你管理这些事情。你写:

源代码
库
头文件

CMake 负责告诉编译系统:

这些材料怎么组合
↓
最后造出一个程序

所以整个流程大概是:

你的 C++ 源代码
        ↓
   CMakeLists.txt
        ↓
      CMake
        ↓
生成编译规则
        ↓
编译器 Visual C++ / GCC
        ↓
      .exe

为什么装 OpenCV 后还不够?

很多新手会觉得:

我都安装 OpenCV 了,为什么代码还是报错?

因为:

安装 OpenCV

只代表:

你电脑上有这套工具。

但是你的 C++ 项目还得知道:

OpenCV 在哪?

头文件在哪?

库文件在哪?

我要链接哪些库?

所以以后才会有:

find_package(OpenCV REQUIRED)

大白话:

CMake,帮我找到 OpenCV。

然后:

target_link_libraries(...)

大白话:

把 OpenCV 跟我的程序连接起来。

更多推荐