现在整个链路
真实世界
↓
RGB-D相机
↙ ↘
RGB图 Depth图
↓ ↓
OpenCV 深度值
↓ ↓
目标检测/轮廓 每像素距离
\ /
\ /
↓ ↓
像素位置 (u,v)
+
Depth
+
Camera Intrinsics
↓
X Y Z
↓
Point Cloud
↓
Open3D
Point Cloud 和 Depth Image 的区别
1. 深度图
仍然是一张二维图片:
u →
┌───────────────┐
│ 1.0 1.1 1.2 │
│ 1.0 0.8 1.3 │
│ 1.2 1.2 1.4 │
└───────────────┘
↓
v
每格存距离。
Depth Image
=
二维形式保存深度
Point Cloud
=
三维形式表达场景
2. 为什么不永远只用深度图?
因为很多三维问题,用点云更自然。
例如:
找桌面。
点云里:
---------------------
就是一大片三维平面。
可以用:
RANSAC
找出来。
再例如:
这个箱子到底有多大?
点云里直接有:
XYZ
可以算:
长
宽
高
再例如:
两次扫描怎么拼起来?
就可以用:
ICP
把两个点云对齐。
所以:
二维任务
↓
OpenCV很舒服
三维任务
↓
Open3D很舒服
3. Open3D 到底会替你做什么?
以后我们会把点云交给 Open3D,比如:
原始点云
↓
Open3D
↓
显示
↓
降采样
↓
去噪
↓
找桌面
↓
删除桌面
↓
聚类
↓
找物体
↓
算包围盒
↓
得到物体三维中心
最后可能告诉机器人:
目标位置:
X = 0.25 m
Y = -0.10 m
Z = 0.82 m
这才是机器人真正喜欢的数据。
现在要形成一个非常重要的分层意识,以后看到机器人视觉系统,不要混成一坨。可以分成:
第一层:采集
RGB
Depth
↓
第二层:二维处理
OpenCV
目标检测
图像分割
↓
第三层:三维恢复
(u,v)
+
depth
+
相机参数
↓
XYZ
↓
第四层:点云处理
Open3D
滤波
分割
聚类
配准
↓
第五层:机器人使用
目标三维位置
↓
机械臂 / 人形机器人
↓
移动 / 抓取 / 避障
这就是整个知识体系。
Point Cloud + Open3D
这一段先把一个特别关键的坑堵住:
一个点的
(X,Y,Z)没有脱离坐标系的意义。
这句话以后会贯穿相机、点云、机器人、ICP、外参、机械臂。
1. 相机坐标系
比如深度相机看到一个杯子,相机自己可以建立一个坐标系:
相机
O
/|\
然后所有点的位置都:
相对于相机来描述。
于是一个点:
P = (X, Y, Z)
大白话:
从相机原点出发,沿三个坐标轴分别走这些距离,就能找到 P。
注意:不同相机 SDK、图形系统和机器人系统对轴方向的约定可能不同,不要死记"X一定右、Y一定下、Z一定前";真正做项目时应看你使用的相机/SDK坐标约定,这条非常重要。
2. 世界坐标系
假设你的相机装在机器人头上。机器人走起来:
相机也跟着移动
那么相机坐标系也在动。
这时候我们可能想建立一个固定的坐标系:
实验室某个角落
↓
定义为世界原点
比如:
World
Z
↑
│
O ─────→ X
/
Y
以后不管机器人跑到哪里:
世界坐标系不动
所以:
Camera Frame
=
跟相机走
World Frame
=
通常作为固定参考
3. 机器人坐标系
机器人也会有自己的坐标系。比如:
机器人骨盆
机器人机身
机器人底盘
其中某个位置被定义为机器人基准坐标系:
Robot / Base Frame
于是以后可能出现:
世界坐标系
↓
机器人坐标系
↓
头部坐标系
↓
相机坐标系
甚至机械臂:
机器人Base
↓
肩
↓
大臂
↓
小臂
↓
手腕
↓
End Effector
每一级都可能有自己的坐标系。
4. 坐标转换主要就是两件事
1. 平移 Translation
例如:
相机比机器人原点高 1.5m
说明两个原点不在一起。
2. 旋转 Rotation
例如:
相机向下低头30°
说明两个坐标轴方向也不一样。
所以:
坐标转换
=
平移
+
旋转
先牢牢记住。
5. 相机外参
相机相对于另外一个坐标系放在哪里、朝哪个方向。
比如:
相机
↑
比机器人原点高1.4m
向前0.1m
向下倾斜15°
这些就是在描述:
两个坐标系之间是什么关系。
所以:
内参
=
相机自己怎么成像
外参
=
相机相对于别人怎么摆
相机内参解决:
图片和相机三维坐标之间怎么联系?
Camera → Robot
相机外参解决:
相机坐标怎么变成机器人坐标?
所以整体:
像素 (u,v)
+
Depth
+
Camera Intrinsics
↓
相机坐标 XYZ
相机坐标 XYZ
+
Camera Extrinsics
↓
机器人 / 世界坐标 XYZ
Open3D
我们现在已经知道:
一个 Point
=
(X,Y,Z)
而:
Point Cloud
=
大量 Point
1. Eigen::Vector3d 是什么?
Eigen
=
一个非常常用的 C++ 数学/线性代数库
Vector3d
=
装3个 double 的三维向量
你现在甚至可以暂时理解成:
Eigen::Vector3d
≈
一个装 XYZ 的小盒子
例如:
Eigen::Vector3d point(1.0, 2.0, 3.0);
你就读成:
创建一个三维数据
(1,2,3)。
先不用碰向量数学。
2. Open3D 一个点云内部是什么?
可以脑补:
PointCloud
│
├── points_
│
│ ├── (X,Y,Z)
│ ├── (X,Y,Z)
│ ├── (X,Y,Z)
│ └── ...
│
├── colors_
│ ├── (R,G,B)
│ ├── (R,G,B)
│ └── ...
│
└── normals_
├── (Nx,Ny,Nz)
├── (Nx,Ny,Nz)
└── ...
Open3D 官方文档也把一个点云描述为:至少包含点坐标,并可选包含颜色和法向量。(Open3D)
3. 创建我们的第一个 Open3D 点云
先看:
#include <open3d/Open3D.h>
int main()
{
open3d::geometry::PointCloud cloud;
cloud.points_.push_back(
Eigen::Vector3d(0.0, 0.0, 0.0)
);
cloud.points_.push_back(
Eigen::Vector3d(1.0, 0.0, 0.0)
);
cloud.points_.push_back(
Eigen::Vector3d(0.0, 1.0, 0.0)
);
return 0;
}
首先:
open3d::geometry::PointCloud cloud;
拆:
open3d
↓
Open3D工具箱
geometry
↓
几何模块
PointCloud
↓
点云类型
cloud
↓
我们给这个点云起的变量名
所以整句:
创建一个叫
cloud的点云对象。
然后:
cloud.points_
大白话:
进入 cloud 里面,找到存放所有三维点的容器。
再看:
cloud.points_.push_back(
Eigen::Vector3d(1.0, 2.0, 3.0)
);
给这个点云增加一个
(1,2,3)的三维点。
4. 怎么显示点云?
Open3D 自己就提供可视化工具。官方当前文档包含点云可视化模块,官方 C++ 示例也直接使用 visualization::DrawGeometries 显示 PointCloud。(Open3D)
不过这里有个问题。DrawGeometries 接收的通常是几何对象的智能指针集合,因此初学阶段我们更适合直接把点云创建成 shared_ptr。
例如:
#include <open3d/Open3D.h>
#include <memory>
int main()
{
auto cloud =
std::make_shared<open3d::geometry::PointCloud>();
cloud->points_.push_back(
Eigen::Vector3d(0.0, 0.0, 0.0)
);
cloud->points_.push_back(
Eigen::Vector3d(1.0, 0.0, 0.0)
);
cloud->points_.push_back(
Eigen::Vector3d(0.0, 1.0, 0.0)
);
open3d::visualization::DrawGeometries(
{cloud},
"My First Point Cloud"
);
return 0;
}
这种 shared_ptr<PointCloud> + DrawGeometries({cloud}) 的模式也出现在 Open3D 官方 C++ 示例中。(GitHub)
auto cloud =
std::make_shared<open3d::geometry::PointCloud>();
拆开:
PointCloud
=
我要创建的东西
make_shared
=
创建并交给智能指针管理
cloud
=
指向这个点云的智能指针
于是:
cloud
↓
┌────────────────────┐
│ PointCloud │
│ │
│ points_ │
│ colors_ │
│ normals_ │
└────────────────────┘
auto cloud = std::make_shared<PointCloud>();
cloud 是:
指向 PointCloud 的智能指针。
cloud
↓
点云智能指针
->
↓
进入它指向的点云对象
points_
↓
找到点的vector
push_back
↓
增加一个点
Eigen::Vector3d
↓
创建XYZ
整句:
往点云里加一个三维点
(1,2,3)。
就这么简单。
5. Crop
整个房间
↓
只截取桌子附近
这叫:
Crop,裁剪。
和 OpenCV 的 ROI 特别像。实际上 Open3D 当前 C++ PointCloud API 也提供 Crop(),通过包围盒裁掉区域外的点。(Open3D),所以:
OpenCV ROI
≈
二维裁剪
Open3D Crop
≈
三维裁剪
| OpenCV 二维 | Open3D 三维 |
|---|---|
| Pixel | Point |
(u,v) |
(X,Y,Z) |
| Image | PointCloud |
cv::Mat |
PointCloud |
| ROI | 3D Crop |
| 2D Bounding Box | 3D Bounding Box |
| 图像去噪 | 点云去噪 |
| 图像分割 | 点云分割 |
| 像素聚类 | 点云聚类 |
点云算法
1. 点云"降采样"
假设:
原始点云
=
1,000,000个点
太多。程序:
慢
占内存
ICP慢
分割慢
显示也重
但是:
杯子的基本形状
可能用:
100,000个点
就够了。所以我们想:
删除一部分重复、过密的点,但是大形状不要明显改变。
这叫:
Downsampling,降采样。
2. Voxel Downsample
很多密密麻麻的点
↓
划分三维小格
↓
每格合并
↓
点变少
这就是 Voxel Downsample 的直觉。Open3D 当前 C++ PointCloud API 提供 VoxelDownSample(voxel_size);文档说明 voxel_size 定义体素网格分辨率,值越小,输出点云通常越密。(Open3D)
原始点云
↓
VoxelDownSample
↓
降采样
↓
RemoveOutlier
↓
去噪
↓
SegmentPlane
↓
找桌面
↓
删除桌面
↓
剩下物体
↓
DBSCAN
↓
物体A / B / C
↓
BoundingBox
↓
三维位置
点云处理五件套
① Voxel Downsample
为什么降采样
↓
② Outlier Removal
怎么删除飘在外面的噪点
↓
③ Normal
什么叫"点的朝向"
↓
④ RANSAC
怎么自动把桌面找出来
↓
⑤ DBSCAN
桌子去掉以后,怎么把
杯子 / 手机 / 盒子 分成三堆
完整的 机器人 3D 视觉流程:
深度相机
↓
原始点云
↓
降采样
↓
去噪
↓
找桌面
↓
删桌面
↓
物体聚类
↓
3D包围盒
↓
物体中心 XYZ
↓
给机器人使用
现在正式进入 **Open3D 点云处理最核心的一段,**你先把这一阶段想成:
深度相机刚拿到的点云
↓
往往又多、又乱、还有噪声
↓
我们要把它"洗干净"
↓
找到桌子
↓
把桌子删掉
↓
把桌上的杯子、盒子、手机分开
↓
得到每个物体的 XYZ
当前 Open3D C++ PointCloud API 里,确实直接提供了我们接下来要学的 VoxelDownSample、离群点去除、EstimateNormals、SegmentPlane、ClusterDBSCAN、包围盒等接口。(Open3D)
1. 完整处理流水线
假设深度相机看桌面:
杯子 盒子
.. ....
...... ......
-------------------------------- 桌面
. . .
. .
. ← 噪声
你真正想得到:
杯子 盒子
...... ......
...... ......
所以处理一般可以想成:
原始点云
↓
① Crop
只保留感兴趣区域
↓
② Voxel Downsample
减少点数量
↓
③ Outlier Removal
删除乱飞的噪点
↓
④ RANSAC
找到桌面
↓
⑤ 删除桌面
↓
⑥ DBSCAN
把剩下物体分组
↓
⑦ Bounding Box
计算每个物体的位置和大小
2. Voxel Downsample
假设相机给你:
1,000,000 个点
一个杯子表面可能密密麻麻:
......................
......................
......................
......................
其实很多点挨得特别近,你没必要每一个都保留,所以我们把三维空间切成很多小立方体:
┌───┬───┬───┐
│...│...│...│
├───┼───┼───┤
│...│...│...│
├───┼───┼───┤
│...│...│...│
└───┴───┴───┘
一个小立方体里原来有:
. . . .
. . .
. . . .
最后用一个代表点:
●
于是:
100万个点
↓
可能变成几十万个
↓
大体形状还在
这就是:
Voxel Downsampling,体素降采样。
Open3D 当前 C++ API 的 VoxelDownSample(voxel_size) 会返回新的点云;voxel_size 越小,通常输出点云越密。(Open3D)
3. voxel_size
比如:
auto down_cloud = cloud->VoxelDownSample(0.02);
你现在可以把:
0.02
理解成:
三维小格子有多大。
如果你的坐标单位是米,那么 0.02 就对应 2 cm 的尺度。
概念上:
voxel 很小
↓
保留很多细节
↓
点比较多
↓
计算更慢
而:
voxel 很大
↓
点变少很多
↓
速度快
↓
但细节可能丢失
所以它不存在:
越大越好。
也不存在:
越小越好。
而是:
够用就好。
4. Radius Outlier Removal
第一种去噪思想特别直观:
看看这个点周围有没有朋友。
假设:
主体:
● ● ● ●
● ● ●
● ● ● ●
远处:
●
对于主体里的点:
附近一圈
↓
很多邻居
对于孤零零那个:
附近一圈
↓
没人
那我们就可以想:
你太孤单了,很可能是噪声,删掉。
Open3D 当前 C++ API 的 RemoveRadiusOutliers(nb_points, search_radius) 就采用这类逻辑:在给定搜索半径内邻居数不足的点会被作为离群点处理。(Open3D)
5. Statistical Outlier Removal
还有一种常见方法:
统计滤波
这个点跟周围点相比,是不是离得异常远?
例如:
● ● ● ● ●
● ● ● ●
● ● ● ●
●
主体里的点:
我跟邻居都挺近
孤立点:
我离谁都远
于是算法认为:
你比较异常。
Open3D 当前 RemoveStatisticalOutliers(nb_neighbors, std_ratio) 会根据点与邻居的平均距离来识别离群点。(Open3D)
Radius Outlier
↓
一定半径里
邻居够不够多?
而:
Statistical Outlier
↓
你和附近点的距离
是不是明显异常?
你可以把它们想成:
半径法:
"你周围有没有人?"
统计法:
"你是不是离大家异常远?"
这样就不会混了。
6. RANSAC
目标:
把桌面找出来。
可以理解成:
随机猜,然后让所有点来投票。
比如:第一次随机抓几个点:
●
●
●
猜:
这几个是不是属于同一个平面?
根据它们生成一个候选平面,然后问所有点:
谁离这个平面特别近?
结果:
只有30个点支持
那这个平面可能不靠谱。
再来。随机抓:
● ●
●
恰好三个都来自桌面,于是猜出:
-----------------------
然后所有点来投票:
50000 个点都很接近它
算法:
哦,这个平面支持者好多!
那它就非常可能是:
桌面
地面
墙面
这种大平面,这就是 RANSAC 最重要的直觉,RANSAC 不等于"找桌子",这一点非常重要,RANSAC 自己并不知道:
这是桌子。
它只知道:
我找到了一大群符合某个平面模型的点。
所以:
RANSAC
=
找符合模型的数据
而:
"这个平面就是桌子"
是我们根据场景进一步做出的解释。
Open3D 当前 SegmentPlane() 使用 RANSAC 进行平面分割,返回平面模型和属于这个平面的点索引;其中 distance_threshold 控制一个点离平面多远还算平面内点。(Open3D)
SegmentPlane() 的几个参数
当前 C++ API 大致是:
cloud->SegmentPlane(
distance_threshold,
ransac_n,
num_iterations
);
还有一个概率参数有默认值。(Open3D)
distance_threshold
大白话:
离我这个平面多近,才算"桌面上的点"?
ransac_n
大白话:
每次随机拿几个点来猜这个平面?
num_iterations
大白话:
随机猜多少次?
所以:
RANSAC
=
不断随机抽样
↓
不断猜平面
↓
不断让点投票
↓
找支持者最多/最合适的那个
SegmentPlane() 不只是告诉你平面是什么,还会给:
哪些点属于这个平面
也就是:
indices
点的编号
例如:
3
5
6
7
12
18
...
这些是:
桌面点。
Open3D 当前 SelectByIndex(indices, invert) 可以根据这些索引选择点;把 invert 设为 true 时,可以反过来保留不在这些索引里的点。(Open3D)
于是:
完整点云
↓
RANSAC
↓
桌面点索引
然后:
保留索引
↓
桌面
或者:
反选
↓
桌面以外的东西
杯子 盒子
...... ......
--------------------------------
桌面
删除平面:
杯子 盒子
...... ......
但是电脑仍然不知道:
左边这些点是一件物体,右边这些点是另一件。
于是 DBSCAN 登场。
DBSCAN
●●●●● ●●●●
●●● ●●●
●●●●● ●●●●
人一眼就知道:
左边一团
右边一团
为什么?
因为:
同一团里的点离得近,两团之间离得远。
DBSCAN 就利用这种"密集程度"做聚类,Open3D 当前 C++ 的 ClusterDBSCAN(eps, min_points) 会给每个点返回一个聚类标签,其中 -1 表示算法认为这个点属于噪声。(Open3D)
Cluster
一簇、一组、一团。
所以原来:
全部点
经过 DBSCAN:
Cluster 0
↓
杯子
Cluster 1
↓
盒子
Cluster 2
↓
手机
可能得到:
每一个点
↓
一个标签
例如:
点0 → 0
点1 → 0
点2 → 0
点3 → 1
点4 → 1
点5 → -1
意思:
0
=
第一组
1
=
第二组
-1
=
噪声
ClusterDBSCAN(eps, min_points)
eps
可以理解:
多近算邻居?
例如:
● ● ●
如果点之间距离足够近:
是一伙的。
min_points
可以理解:
至少聚集多少个点,我才承认这里形成了一团?
比如只有:
●
一个孤点:
不算物体。
但是:
●●●●●
●●●●●
很多点聚在一起:
这比较像一个真实物体。
这就是最简单的 DBSCAN 直觉。Open3D 当前 API 也把 eps 定义为寻找邻居时使用的密度参数,把 min_points 定义为形成一个聚类所需的最少点数。(Open3D)
3D Bounding Box
AABB
Axis-Aligned Bounding Box:
盒子的边始终跟 XYZ 坐标轴平行。
比如物体斜着:
/
/ 物体
/
AABB 可能:
┌───────────┐
│ / │
│ / │
│ / │
└───────────┘
OBB
Oriented Bounding Box:
框可以跟着物体旋转。
大概:
╱────╱
╱物体╱
╱────╱
所以 OBB 对倾斜物体往往描述得更贴。
AABB
=
不会旋转的盒子
OBB
=
可以顺着物体方向转的盒子
现在终于可以看一个完整系统:
真实世界
↓
RGB-D相机
↓
RGB + Depth
↓
深度恢复 XYZ
↓
原始点云
↓
Crop ROI
删除无关区域
↓
VoxelDownSample
减少点数
↓
Outlier Removal
去噪声
↓
RANSAC
找桌面平面
↓
删除桌面
↓
DBSCAN
┌────────┼────────┐
↓ ↓ ↓
杯子 盒子 手机
↓ ↓ ↓
Bounding Bounding Bounding
Box Box Box
↓ ↓ ↓
XYZ XYZ XYZ
└────────┼────────┘
↓
坐标系转换
↓
Robot Frame
↓
机器人控制
以后看到问题,你应该能开始这样反应:
| 问题 | 第一反应 |
|---|---|
| 点太多,程序慢 | Voxel Downsample |
| 有孤零零噪点 | Outlier Removal |
| 想知道表面朝向 | Normal |
| 想找桌面/地面 | RANSAC Plane |
| 想把几个物体分开 | DBSCAN |
| 想知道物体三维大小 | Bounding Box |
| 想知道物体在哪 | Center / XYZ |
| 相机 XYZ 不能直接给机器人 | 坐标变换 |
配准
现在我们处理的是:
一帧点云
接下来会出现一个新问题。
相机第一次拍:
点云A
相机移动一点,再拍:
点云B
两份点云其实都拍的是同一个房间,但:
A坐标系
≠
B坐标系
于是它们直接放一起:
□
□
对不上。
我们就需要:
Point Cloud Registration,点云配准。
1. Source 和 Target
Target
=
我要对齐到谁
Source
=
谁要移动过去
例如:
Target 点云
固定不动
Source 点云
旋转 + 平移
↓
尽量叠到 Target 上
Open3D 当前的 ICP 接口也是以 source、target、最大对应距离以及一个初始变换作为核心输入。(Open3D)
所以你以后看到:
Source → Target
脑子里直接翻译:
把 Source 搬过去跟 Target 对齐。
2. ICP
Iterative Closest Point
拆开特别好理解。
Iterative
=
反复做
Closest
=
最近的
Point
=
点
所以你可以把 ICP 暂时翻译成:
反复寻找最近点,然后不断调整位置。
Open3D 官方教程把 ICP 用于在已有粗略初始对齐的基础上,把 source 和 target 进一步精细对齐;因此 ICP 属于局部配准方法,而不是一个"随便扔两个完全错开的点云就一定能找到正确答案"的万能算法。(Open3D)
ICP 第一步会想:
Source 里的这个点,在 Target 里谁离它最近?
比如:
○ -------- ●
于是建立:
○ ↔ ●
这样的对应关系。再找第二个:
○ ↔ ●
第三个:
○ ↔ ●
最终得到很多:
Source点 ↔ Target点
这就是:
Correspondence,对应关系。
假设现在有很多配对:
○1 ↔ ●1
○2 ↔ ●2
○3 ↔ ●3
○4 ↔ ●4
ICP 会问:
Source 应该怎么旋转、怎么移动,才能让这些配对整体更接近?
于是算一次:
旋转一点
+
平移一点
Source 变成:
○○○○
○○○
○○
● ● ● ●
● ● ●
● ●
比之前近了。
但还没完全重合。
怎么办?
再来一次。
这就是 Iterative。
整个 ICP 可以先记成这张图:
Source + Target
↓
找最近的对应点
↓
计算怎么旋转、平移
↓
移动 Source
↓
重新找最近点
↓
再算旋转、平移
↓
再移动
↓
......
↓
变化已经很小
↓
停止
所以 ICP 并不是:
一算
↓
完美对齐
而是:
猜一点
↓
靠近一点
↓
重新判断
↓
再靠近一点
↓
逐步收敛
Open3D 的 registration_icp 也是按照迭代收敛条件运行,并允许设置最大迭代次数。(Open3D)
3. KD-Tree
Nearest Neighbor Search,最近邻搜索。
Open3D 提供 KDTreeFlann 用于 KNN、半径等邻域查询,也提供更现代的 nearest-neighbor search 接口;它们的作用就是避免每次都做最朴素的全量比较。(Open3D)
配准过程
完全没对齐
↓
先粗配准
↓
差不多对齐
↓
ICP
↓
精细对齐
Open3D 官方的全局配准流程就是这种思想:先对点云降采样、估计法向量、计算 FPFH 特征,再使用 RANSAC 等方法获得粗略全局对齐,最后可用 point-to-plane ICP 进一步精修。(Open3D)
这就像你停车:
全球配准
=
先把车开到停车位附近
ICP
=
最后一点一点调整
直到停正
4. FPFH
FPFH = 描述一个点附近"长什么样"的一种三维特征。
比如某个点附近:
很平
另一个:
像角
另一个:
像弯曲表面
算法会尝试描述这些局部几何特征,然后:
Source里的这个局部形状
去 Target 里找:
有没有长得很像的地方?
Open3D 官方全局配准教程当前使用 FPFH,并将其描述为每个点的 33 维局部几何特征,用于在特征空间中寻找可能的对应点。(Open3D)
现阶段你只需要知道:
XYZ
=
点在哪里
Normal
=
表面朝哪
FPFH
=
附近的几何形状大概有什么特点
后面再深入。
5. Point-to-Point ICP
Source点 ○
↕
Target点 ●
目标就是:
让对应点之间的距离越来越小。
Open3D 当前提供 TransformationEstimationPointToPoint 来进行这类 ICP 变换估计。(Open3D)
所以大白话:
点
↓
对点
6. Point-to-Plane ICP
假设 Target 是一面墙:
│
│ ●
│
│
Source 有个点:
○
Point-to-Point 更关注:
○ → 某一个 ●
而 Point-to-Plane 更关注:
○
←────────墙面
也就是:
Source 这个点离 Target 的局部表面还有多远?
这时候就会用到前面学过的:
Normal
法向量
因为你必须知道:
这个表面朝哪个方向。
Open3D 官方 ICP 教程提供 TransformationEstimationPointToPlane;这种方法使用目标点的法向量,官方教程示例也说明 point-to-plane 在其测试中比 point-to-point 更快达到紧密对齐。(Open3D)
所以你现在可以这样记:
Point-to-Point
=
点找点
Point-to-Plane
=
点贴表面
7. Fitness
Open3D 的 RegistrationResult 中:
fitness
用来反映满足距离条件的对应关系所覆盖的比例,越高通常越好 。(Open3D)
"有多少东西能够比较合理地对上?"
例如:
Fitness 很低
○ ○ ○ ○ ○
● ● ● ● ●
两个点云可能没多少对应区域。
而:
Fitness 较高
○● ○● ○● ○● ○●
大量区域可以对应。
但是注意:
Fitness 绝对不能单独判断配准一定正确。
例如重复结构、错误但恰好重合的区域,也可能骗人。
8. inlier RMSE
它反映配准中那些被认为有效的对应点之间,整体还有多大的残差;Open3D 文档明确给出的判断方向是 越低越好 。(Open3D)
大白话:
Fitness
=
"有多少人成功配对?"
RMSE
=
"已经配上的这些人,贴得紧不紧?"
所以理想上:
Fitness
↑ 较高
inlier RMSE
↓ 较低
但最终仍然要结合:
场景
可视化
初始位姿
重叠区域
一起判断。
9. Odometry
Odometry,里程计。
先理解成:
估计机器人从上一时刻到这一时刻移动了多少。
例如:
t0
机器人在 A
↓
移动
↓
t1
机器人在 B
↓
移动
↓
t2
机器人在 C
每一步都估计:
A → B
B → C
然后累积:
A → B → C → D...
就能大致知道自己一路怎么走。
10. SLAM
Simultaneous Localization and Mapping
暂时翻译:
一边判断自己在哪里,一边建立地图。
假设深度相机一帧只能看到:
房间的一部分
第一次:
墙A + 桌子
转一下:
桌子 + 墙B
再转:
墙B + 门
如果你知道每次相机的位姿,就可以把所有点转换到:
同一个 World Frame
于是:
Frame1
\
Frame2 → World → 完整地图
/
Frame3
这就是三维重建最核心的直觉之一。
Point Cloud A
↓
Camera Frame A
Point Cloud B
↓
Camera Frame B
ICP 求:
A 和 B
之间的变换关系
得到:
Rotation
+
Translation
然后我们就可以:
把B中的点
↓
转换到A坐标系
或者进一步全部转换到:
World Frame
所以之前讲的:
"XYZ 一定要问相对于哪个坐标系。"
现在已经开始真正发挥作用。
ICP 脑图
两个点云
Source + Target
↓
初始位置大概接近
↓
寻找最近邻
↓
建立 Correspondence
↓
计算 Rotation + Translation
↓
移动 Source
↓
再次找 Correspondence
↓
再次调整
↓
反复 ICP
↓
收敛
↓
Transformation
↓
把 Source 转到 Target 坐标系
registration_icp(
source,
target,
max_distance,
initial_transform,
estimation_method
);
当前 Open3D 的 ICP API 结构就是围绕这些元素:source、target、最大对应距离、初始变换、误差估计方法和收敛条件。(Open3D)
谁移动?
↓
source
对齐到谁?
↓
target
多远还算可能对应?
↓
max distance
一开始大概在哪?
↓
initial transform
怎么衡量"贴得好不好"?
↓
point-to-point / point-to-plane
深度相机
↓
RGB + Depth
↓
相机内参
↓
XYZ
↓
Point Cloud
↓
Crop
↓
Voxel Downsample
↓
Outlier Removal
↓
Estimate Normal
↓
RANSAC
↓
去桌面/地面
↓
DBSCAN
↓
分离物体
↓
Bounding Box
↓
目标 XYZ
另一条:
连续点云 Frame A / Frame B
↓
粗略初始位置
↓
ICP
↓
Transformation
↓
相机运动
↓
多帧统一坐标系
↓
三维重建 / 定位 / 建图