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

现在整个链路

复制代码
            真实世界
                ↓
            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、离群点去除、EstimateNormalsSegmentPlaneClusterDBSCAN、包围盒等接口。(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 接口也是以 sourcetarget、最大对应距离以及一个初始变换作为核心输入。(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
↓
相机运动
↓
多帧统一坐标系
↓
三维重建 / 定位 / 建图
相关推荐
COOLMO研究AI1 小时前
Python 如何在 AI 接口中实现请求幂等性:防止重复提交与重复扣费
人工智能·python·php
微三云-张梅1 小时前
东莞企业做GEO:AI信任体系的三个建设层级
大数据·人工智能·微三云geo·东莞系统开发·东莞geo
CTA量化套保1 小时前
新手学量化,先做能复查的小流程
人工智能·python
狂奔蜗牛(bradley)1 小时前
RKNN Toolkit2开发环境搭建实操
人工智能
weixin_446260852 小时前
AutoDesign:面向长时序智能体设计的元调度优化框架
人工智能
Rocktech_ruixun2 小时前
机器人端侧大模型部署对主板有哪些要求?瑞迅主控板分级方案对比
人工智能·嵌入式硬件·机器人
ZGi.ai2 小时前
多模型回答不稳定:路由规则与评测方法
网络·人工智能·大模型评测·多模型·模型路由·zgi·模型网关
dogstarhuang2 小时前
大模型 API 停服怎么办:用 API 网关实现多模型统一接入与可切换架构
人工智能·后端·架构·大模型·api·数字化转型·ai应用
AndrewHZ2 小时前
图像处理入门(第005期):颜色空间入门——RGB/HSV/CMYK/Lab
图像处理·python·opencv·hsv·rgb·色彩空间
紫禁玄科2 小时前
MCP协议安全深度剖析:AI Agent时代的隐形攻击面
人工智能