1.3 基本数据结构:点云、网格、体素与 RGB-D 本节摘要:Open3D 用四种核心数据结构表达三维世界——PointCloud(离散点集)、TriangleMesh(三角面表面)、VoxelGrid(规则体素网格)、RGBDImage(对齐的彩色+深度图)。本节逐一拆解它们的字段构成、NumPy 视角下的内存形态、各自擅长的场景,以及结构之间的转换路径,为第二章的全部实战操作打地基。 你能学到什么 阅读完本节,你应当能够: 说出每种数据结构的必备字段与可选字段,解释"可选字段为空"时哪些操作会失败; 在 NumPy 数组与 Open3D 数据结构之间自由搬运数据; 根据任务(渲染、近邻、布尔、融合)判断该用哪种结构表达数据; 画出或口述四种结构之间的转换路径与信息损耗方向。
本节摘要:Open3D 用四种核心数据结构表达三维世界——PointCloud(离散点集)、TriangleMesh(三角面表面)、VoxelGrid(规则体素网格)、RGBDImage(对齐的彩色+深度图)。本节逐一拆解它们的字段构成、NumPy 视角下的内存形态、各自擅长的场景,以及结构之间的转换路径,为第二章的全部实战操作打地基。
阅读完本节,你应当能够:
同一把椅子,在深度相机眼里是一帧 RGB-D 图像,转成三维是一团点云,扫描仪多角度拼接重建后是网格模型,塞进物理引擎做碰撞检测时又被切成体素。数据结构不是天然存在的,是任务逼出来的:
理解了每种结构"为谁服务",字段设计就变得理所当然,不用死记硬背。
先建立"数据结构家族"的类图视角,再去逐个拆解:
点云内部只有三个成员,全部可为空:
import open3d as o3d import numpy as np pts = np.random.rand(1000, 3) # N×3 坐标数组 pcd = o3d.geometry.PointCloud() pcd.points = o3d.utility.Vector3dVector(pts) # 装入点云 pcd.colors = o3d.utility.Vector3dVector(np.random.rand(1000, 3)) # 可选:颜色 pcd.estimate_normals() # 可选:法线,很多算法的前置 print(pcd) # 点数 + 三个字段是否有值 print(np.asarray(pcd.points).shape) # (1000, 3) —— 随时取回 NumPy
三个字段里,points 是存在的前提,colors 影响观感,normals 决定你能不能做配准中的点到平面误差、能不能跑泊松重建。字段为空时报错信息往往出现在算法内部(比如泊松重建直接崩溃),所以拿到陌生点云的第一件事是 print(pcd) 体检——三个字段各是 True 还是 False,一目了然。
与 NumPy 的互转是日常动作:np.asarray(pcd.points) 取出的是只读视图,改值要取出后修改再赋回去。这个"取出-改-装回"的节奏会贯穿全书。
网格 = 顶点表 + 面片表。顶点存坐标(和可选的法线、颜色、UV),面片存"哪三个顶点连成一个三角形"的索引:
mesh = o3d.geometry.TriangleMesh.create_sphere(radius=1.0, resolution=20) print(mesh.vertices) # 顶点数 print(mesh.triangles) # 面片数 print(mesh.is_watertight()) # 是否水密(封闭无洞)——能否算体积的前提 print(mesh.get_volume()) # 水密网格才有定义良好的体积
"水密"(watertight)这个词值得单独记:网格每条边恰好被两个面共享、无孔无重叠,才算水密。只有水密网格才能可靠计算体积、做布尔运算、3D 打印。扫描重建出来的网格十有八九不水密,这是第二章重建一节要解决的核心麻烦。
体素化把连续空间切成边长 voxel_size 的立方格,每个格子记"有没有点、平均颜色是多少"。它牺牲形状精度换取规则性:查一个点的邻居从"KD 树搜索"退化为"算格子坐标",天然适合碰撞检测、占据地图、大场景压缩。
pcd = o3d.io.read_point_cloud(o3d.data.PLYPointCloud().path) grid = o3d.geometry.VoxelGrid.create_from_point_cloud(pcd, voxel_size=0.05) print(grid) # 体素个数 centers = grid.get_voxel_centers() # 每个体素的中心坐标
体素边长是最重要的参数:太细退回点云规模(没省事),太粗丢几何细节。经验起点是"包围盒最长边 ÷ 256",再按需倍增减半。
RGB-D = 同一视角、像素级对齐的一张彩色图 + 一张深度图。它本质还是二维图像,配上相机内参(焦距、光心)才能反投影成点云——这份"图纸"决定了每个像素飞向三维空间的哪个位置:
rgbd = o3d.io.read_rgbd_image(路径) # 或从两幅图合成 K = o3d.camera.PinholeCameraIntrinsic( # 内参:焦距与光心 640, 480, 525.0, 525.0, 319.5, 239.5) pcd = o3d.geometry.PointCloud.create_from_rgbd_image(rgbd, K)
| 结构 | 必备字段 | 可选字段 | 天生适合 | 天生不适合 |
|---|---|---|---|---|
| PointCloud | points | colors、normals | 原始数据、滤波配准、特征提取 | 直接渲染(观感差)、体积计算 |
| TriangleMesh | vertices、triangles | 法线/颜色/UV | 渲染交付、3D 打印、布尔运算 | 海量点初筛(先要重建) |
| VoxelGrid | voxel_size、voxels | 颜色 | 空间索引、碰撞、压缩 | 精细几何(有量化误差) |
| RGBDImage | color、depth | — | 帧级融合(TSDF)、快速预览 | 多视角表达(单帧视角) |

字段体检是第一习惯。外来数据先 print:点云三字段状态、网格是否水密、体素边长多少。第二章一半的"算法崩溃"都能在体检阶段拦截。
转换有方向,逆行要付代价。点云重建网格是"从离散样点猜连续面",算法要引入平滑假设,猜错了就是洞或假面;网格采样回点云是容易的(sample_points_uniformly)。所以流程设计上,能晚点转网格就晚点转,原始点云多留一阵,处理余地更大。
内存估算帮你避开爆内存。百万点 × 3 坐标 × 8 字节双精度 ≈ 24 MB,听着不大;但一个工业扫描动辄上亿点,再叠加法线、颜色,内存按 GB 起跳。体素化或降采样不只是算法需要,也是内存现实。
⚠️ 常见坑:
pcd.points换成新数组后,normals和colors还是旧的——三个字段各自独立,点数变了必须同步重建法线/重设颜色,否则索引错位,渲染出诡异条纹或算法静默出错。
💡 关键直觉:把四种结构想成同一片地形的不同地图——点云是测绘原始碎点,网格是等高线成图,体素是方格网统计,RGB-D 是站在某个山头拍的一张带深度的照片。地图能互转,但每次转换都在"重新解释"数据。
读完字段不等于有手感,下面这段代码把四种结构在一根流水线里串起来——从网格出发,采样成点云,再体素化,最后回看结果:
import open3d as o3d import numpy as np # 1. 网格:生成一个球 mesh = o3d.geometry.TriangleMesh.create_sphere(radius=1.0) mesh.compute_vertex_normals() # 2. 网格 → 点云:均匀采样 5000 点 pcd = mesh.sample_points_uniformly(number_of_points=5000) print("采样点数:", len(pcd.points)) # 3. 点云 → 体素:边长 0.1 grid = o3d.geometry.VoxelGrid.create_from_point_cloud(pcd, voxel_size=0.1) print("体素数:", len(grid.voxels)) # 4. 体素中心 → 新点云:观察量化效果 centers = grid.get_voxel_centers() voxel_pcd = o3d.geometry.PointCloud() voxel_pcd.points = o3d.utility.Vector3dVector(centers) voxel_pcd.paint_uniform_color([1, 0.706, 0]) # 涂成黄色,与原点云对比 o3d.visualization.draw_geometries([pcd, voxel_pcd])
跑完把两组点云同时画出来:原始采样点均匀铺满球面(默认灰白),体素中心点则一颗颗落在网格格点上(黄色)——这就是量化误差的可视化,球面上任何细节只要小于 0.1,在体素世界里就不存在了。把 voxel_size 改成 0.05、0.02 再跑,肉眼看球面从"像素风"逐渐变圆润,比任何文字都直观。
顺带体会"字段体检":把第 2 步改成读外来的 PLY 文件,先 print(pcd) 看三字段状态。多数公开数据集只有 points 没有 normals,这个空字段就是第 2 章很多操作的前置缺口。
问:点云可以既有 normals 又没有 colors 吗?
答:可以,三个字段完全独立。有些算法只要法线(配准),有些只要颜色(可视化分类结果),体检时分开看。
问:一个网格文件里顶点带颜色,转成点云后颜色还在吗?
答:在。sample_points_uniformly 会把顶点颜色插值带给采样点,这是"网格烘焙出彩色点云"的标准路径。
问:体素网格能转回三角网格吗?
答:可以走"体素中心提取成点云 → 表面重建(2.2 节)"的绕行路径,得到的是方块感明显的重建面。Open3D 的 legacy VoxelGrid 没有直接的 marching cubes 接口,体素到网格的标准做法在 TSDF 体积那一侧(2.3 节)。
问:RGBDImage 为什么要"对齐"?
答:深度相机里彩色镜头和深度镜头物理上是两个传感器,视角不同,出厂前要做一次配准对齐。Open3D 假定你给的是已对齐的帧——用 RealSense 等设备时要在采集端开启对齐选项,否则反投影出来的点云会"错色"。
最后补一个每个初学者都要挨一次的教训:单位。Open3D 本身不管单位,米制是约定俗成(参数 0.05 默认被理解为米),但数据源五花八门——激光雷达常以米输出,CAD 导出的网格常是毫米,摄影测量点云可能连单位都没定义过。拿到数据的第一件事,看包围盒量级:一把椅子扫出来包围盒几十、几百,那必是毫米或厘米,先换算再处理;否则后续所有以"米"为默认假设的参数(体素边长、ICP 阈值、法线半径)全体错位,而且错得很安静——不报错,只给你一个"看起来还行其实全错"的结果。1.3 节建议的体检三件套(点数、字段、包围盒)里,包围盒就是专门用来拦这个的。
字段和转换都认识了,最后练"判断力"——三个真实场景的选择题,做完你对四种结构的适用边界就有肌肉记忆了。
判断一:质检系统要"每天对比今天与昨天的工件点云",数据存什么格式? 存点云(PLY)而不是网格。原因有二:对比的最小单位是点(配准后的逐点距离),网格化引入重建假设反而污染对比基准;点云跳过重建工序,流水线更短更快。原则:在能满足需求的结构里选最"原始"的那个,处理余地永远最大。
判断二:Web 展示要一个能流畅拖拽的三维模型。 网格,且要简化到五万面以内(2.2 节的简化技能)。浏览器渲染管线吃网格,点云百万点在普通设备上拖不动。原则:看消费端要什么形态,倒推存储形态。
判断三:AGV 小车要实时回答"前方格子有没有障碍"。 体素占据网格。查询是除法加查表,微秒级;KD 树虽然精确但每个查询都要走树。原则:高频固定模式的查询,值得预先把数据组织成查询友好的形态——这是一条普适的工程法则,不止三维领域。
三个判断的共同内核:数据结构的选择由"下游怎么消费"决定,而不是"上游给什么就用什么"。传感器给的是点云,但那是它的方便,不是你的。
| 结构 | 查点数 | 查包围盒 | 判空字段 |
|---|---|---|---|
| PointCloud | len(pcd.points) |
pcd.get_max_bound() |
pcd.has_normals() 等 |
| TriangleMesh | len(mesh.vertices) |
mesh.get_max_bound() |
mesh.has_vertex_normals() |
| VoxelGrid | len(grid.voxels) |
— | — |
| RGBDImage | .color / .depth 尺寸 |
— | — |
名词认完了,下一节拉远镜头看整张模块地图,知道每个需求该去哪个房间找人。