机器学习基础1——C++、OpenCV、点云、Open3D
可以把整个学习过程理解成:
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 跟我的程序连接起来。
更多推荐
所有评论(0)