08点云格式与 PCD

进了平台,第一个门槛不是算法,是文件格式。这一页先把库、格式、读数这三层分清, 再把 PCD 的头部字段、数据布局、与 ROS 消息的映射拆到底,最后给出四个真实会咬人的坑。

格式的层级 2 个交互演示 含读写代码

一、先弄清三层概念

打开 PCD 之前,有三组概念容易混。分清它们,后面所有格式名词都会自己各就各位。

① 库、格式、传感器读数,是三件不同的事

层例子它负责什么
库PCL、Open3D、ROS 的 pcl_ros提供算法和读写接口,不定义文件长什么样
格式PCD、PLY、LAS、二进制 blob定义文件里的字节怎么排
传感器读数LaserScan、PointCloud2定义运行时怎么把数据交给下游程序

混乱通常来自把这三层当成一层。比如"PCD 是不是 PCL 的格式",答案是可以这么说, 但准确的说法是 PCL 最早推广了它,而格式本身和库是分开的。

② PCD 是"记录的数组",不是"容器"

这个词很关键。PCD 不存场景、不存对象、不存时间线,它只存一张表: 每行一个点,每列一个字段。所以它不知道"哪个点属于哪辆车", 也不知道"这一帧和第几帧相邻"。这些都要在外面另存,这就是后面讲的"三件套"里 json 的作用。

③ 同样是点云,有"有组织"和"无组织"两种形状

有组织的点云像一个二维网格,长宽对应激光雷达的线数和每条线上的采样数; 无组织的就是一串无序的点。同一个格式能装这两种,所以读代码时必须先看头部有没有标尺寸。

先记住这一句

格式层只解决一件事:把一堆数字按约定的顺序摆进文件。 语义、时序、标定,全都在这层之外。

二、先看一个真实的 PCD 文件

下面是实际生成的、248 字节、6 个点的完整文件内容:

# .PCD v0.7 - Point Cloud Data file format  ← 注释行:读取时被跳过,删掉也能读
VERSION 0.7                                 ← PCD 文件格式版本
FIELDS x y z intensity                      ← 每个点有 4 个字段,名字是这些
SIZE 4 4 4 4                                ← 每个字段占 4 字节
TYPE F F F F                                ← 字段类型:F = float32
COUNT 1 1 1 1                               ← 每个字段有 1 个分量
WIDTH 6                                     ← 一行有 6 个点
HEIGHT 1                                    ← 只有 1 行 → 不是网格
VIEWPOINT 0 0 0 1 0 0 0                     ← 传感器获取位姿,几乎永远是默认值
POINTS 6                                    ← 总数 = WIDTH × HEIGHT
DATA ascii                                  ← 数据段起点,且是 ASCII 编码
10 -1 0 120                                 ← 每点一行,按 FIELDS 顺序:x y z intensity
10 1 0 95                                   ← x=10, y=1, z=0, 强度=95
10 1 2 88                                   ← x=10, y=1, z=2, 强度=88
10 -1 2 91                                  ← x=10, y=-1, z=2, 强度=91
5 0.5 0.3 200                               ← x=5, y=0.5, z=0.3, 强度=200
3 -2 1.2 45                                 ← x=3, y=-2, z=1.2, 强度=45
⚠️ 右边那些浅色斜体是【讲解】,不是文件内容

真实的 .pcd 文件里只有左边那一列,从 # 那行开始, 到最后一个 45 结束。浅色斜体的说明文字是为了逐行讲解额外加的,读文件时并不存在。

格式极简:一堆 KEY VALUE 的头 + DATA 之后是裸数据。

不过头部里有三行值得单独拎出来说:WIDTH / HEIGHT / POINTS。 它们不是三个互相独立的数字,而是同一件事的三种说法,这些点按什么形状排列。

一个最容易读错的地方:WIDTH 不是"点数"

WIDTH 的含义始终是「strong」,不是「一共有几个点」。 它看起来像"点数",只是因为这份文件的 HEIGHT = 1,strong。

WIDTH 6   HEIGHT 1    →   6 个点,形状 (6,)       ← 退化成计数
WIDTH 6   HEIGHT 10   →  60 个点,形状 (10, 6)    ← 这才是网格
                          ↑                ↑
                       几行          一行几个

所以 WIDTH 单独看是歧义的,它的意思要靠 HEIGHT 才能定下来。 这也是为什么strong。

当 HEIGHT > 1 时,点云是有组织(organized)的: 它其实是一张b摊平成的点阵,每个点都能对应到图像上的一个像素 (row, col)。 而这份"对应关系"就是头部在做的一句承诺,strong。

下面这个演示专门拆这句承诺。(两种形态的正式定义在第五节,这里先看它为什么会崩。)

看得见的分界:一行到底是什么相同

前面那份文件只有 6 个点、HEIGHT 1,看不出"行"有什么意义。 所以这个演示换成一台虚拟深度相机去拍一个模拟场景 用盒子拼一辆车、用圆柱加球做一棵树,再放一根电线杆和远处的楼。

得到的是一份真实的、有物理意义的 organized 点云:WIDTH 64 / HEIGHT 48, 每个点都真的来自一个像素。strong(深度图本来的二维形状), strong。两边用同一套行配色,所以能直接对上。

DEMO 1 · 组织性:一行到底是什么相同 切换有/无组织 · 拖动高亮行 · 两个窗格都可旋转
数据:深度图 64 × 48 · 颜色 = 行号
同一份数据的 3D · 同样的配色
颜色 = 行号 v 无效点(天空 / 超量程 → nan) 高亮行 场景物体轮廓

从演示里能读出的三件事

  1. 左边是一条条整齐的横带,右边是一条条弯曲的扫掠线。 同一种颜色在深度图里是"第 v 行";在 3D 里它是b 掠过地面时是横线,爬上车尾时立起来,扫过树冠时弯成弧。
  2. 切到"无组织",3D 的点一个都没动,但两张图都失去了结构。 左边从 64 × 48 的矩形塌成一条一维长条,右边所有点变成同一个颜色 因为"第 k 个点属于第几行"这件事,只存在于头部的声明里。 组织性是b的声明,不是几何性质。
  3. 高亮任意一行看读数:只有「垂直角」永远恒定。 把行拖到b的地方(比如 v = 20),你会看到距离和高度b恒定 三个候选同时成立。这就是"同一行 = 同一个距离"这个错觉的来源。 但只要那行穿过车或电线杆(比如默认的 v = 12),前两个立刻破: 深度从 6 m 跳到 17 m,高度从 0 爬到 0.8 m。 strong
把这一节收成一句
WIDTH  = 水平角分成几档     (一行有几个点)
HEIGHT = 垂直角分成几档     (有几行)
POINTS = WIDTH × HEIGHT

同一行内恒定的是【垂直角】—— 不是距离,也不是高度
   在平地上,"同一个距离"会【碰巧】也成立,这就是错觉的来源
   一旦有车、有杆、有坡,距离和高度都会在行内变化

三、一句话定义:文本头 + 裸内存

把上面那份文件从 DATA 那一行劈开,前半段是人眼可读的纯文本头部, 后半段是strong。整份文件只有这两块。

核心定义

PCD = Point Cloud Data,是 PCL(Point Cloud Library)的原生点云文件格式。

它是一个 「单帧点云 + 一段文本头部」的文件。文本头部自己声明了数据怎么解释。

关键:它没有时间,没有坐标系名字。

四、头部字段逐个详解

头部一共 10 行字段(外加一行 # 注释),每一行都在回答同一件事:后面的数据该怎么解释。逐个看:

字段含义常见值 / 注意
VERSION 规范版本 0.7 最常见
FIELDS 列名。x y z 必有,其余看传感器 intensity / rgb / normal_x / ring / time / label
SIZE 每个元素占几字节 4(float32)最常用;1/2/4/8
TYPE 数据类型 F=float, I=有符号整数, U=无符号整数
COUNT 每个字段含几个元素 一般 1;>1 表示是向量(如 FPFH 的 33 维特征)
WIDTH 一行有几个点 始终是"每行的点数"。HEIGHT = 1 时"一行"就是全部,所以看起来刚好等于总点数,这是个容易读错的地方
HEIGHT 高度 1 = 无组织;>1 = 有组织(见第五节)
VIEWPOINT 采集视点(3 平移 + 4 四元数) 几乎永远是 0 0 0 1 0 0 0。别拿它当外参
POINTS 总点数 应满足 WIDTH × HEIGHT
DATA 数据编码 ascii / binary / binary_compressed
头部 = 一个 C 结构体声明
FIELDS x y z intensity
SIZE   4 4 4 4
TYPE   F F F F
COUNT  1 1 1 1
        ↓ 等价于
struct Point {          // SIZE + TYPE 决定每个字段的类型
    float x;            // FIELDS 决定字段名和顺序
    float y;
    float z;
    float intensity;    // COUNT=1 表示标量(>1 就是数组/向量)
};                      // sizeof(struct Point) = 16 字节

// 然后 DATA binary 之后就是 struct Point[N] 的连续内存,一个字节不多不少

五、交互演示:PCD 字节解剖器

勾选字段,实时看头部怎么变、单点的字节布局怎么排、以及 point_step 是多少。 重点关注:加了 ring 之后总字节数怎么变(这是很多人算错偏移的地方)。

DEMO 2 · PCD 字节解剖器 勾选字段 / 切换 DATA 类型
生成的 PCD 头部(可用于实际写文件)
单点的字节布局

从演示里能读出的三件事

  1. 字段顺序就是内存顺序。PCD 没有 padding、没有显式 offset 偏移是从 SIZE × COUNT 依次累加推出来的。这一点在写转换脚本时是致命的(见第十一节 · 坑 2)。
  2. ring 用 2 字节就够(最多 128 线),但若为省事写成 4 字节, point_step 就变了,所有后续字段的偏移全部错位。
  3. ASCII 和 BINARY 的头部完全一样的,只有 DATA 那一行不同。 所以"能不能读"取决于解析器,不取决于头部。

六、有组织与无组织:一种格式,两种形状

// 无组织(unorganized)—— 旋转式 LiDAR
FIELDS x y z
WIDTH 6
HEIGHT 1          ← = 1
POINTS 6

// 有组织(organized)—— 深度相机 RGB-D
FIELDS x y z
WIDTH 4
HEIGHT 3          ← > 1
POINTS 12         ← 4 × 3,不是 12 × 1
无组织 unorganized有组织 organized
HEIGHT1>1
WIDTH一行 6 个(因为只有 1 行,它恰好 = 总点数)一行 4 个(= 图像宽)
来源旋转式 LiDAR、采样后的点云strong(RealSense / Kinect)
结构一堆无序的点strong,天然带 2D 索引
无效点不存在写成 nan(遮挡 / 超量程像素)
换个说法:WIDTH / HEIGHT 做的就是一次 reshape

不改一个字节,只换一种看法。同一块内存:

# HEIGHT = 1  —— 一维点列表
pts.shape            ==  (768, 3)           # 只有"第几个",没有"第几行第几列"
pts[100]                                    # 和 pts[101] 的空间关系【没有保证】

# WIDTH 32 / HEIGHT 24 —— 同一块内存,零成本 reshape
a = pts.reshape(24, 32, 3)                  # 字节数完全不变
a[5, 10]                                    # 第 5 行第 10 列
a[5,10] 的上下左右 = a[4,10] / a[6,10] / a[5,9] / a[5,11]    # O(1),纯下标运算

这也解释了为什么 WIDTH 会被误读成"点数":

只有 WIDTH       →  它是个【计数】  → 邻域必须靠 KD-tree 检索
WIDTH + HEIGHT   →  它是个【形状】  → 邻居 = 下标加减,O(1)
⚠️ 但"声明"不等于"成立"

有 WIDTH 和 HEIGHT,只说明头部声明了网格。要让这次 reshape 真的成立,还需要:

① WIDTH × HEIGHT  ==  POINTS     ← 数据完整(删点会破坏)
② 数组顺序 确实是 网格顺序         ← 顺序正确(乱序会破坏)
③ 记录定长                        ← 否则 reshape 无从谈起

缺一条,"形状"就是一句空头支票,只能退回几何检索。第一节那个交互演示,做的正是①②被破坏的过程。

第一节那个交互演示把这条规则当场拆开看了,拖动"乱序"和"无效像素"两个滑块, 就能看到「下标邻居」的连线如何从整齐网格退化成一团乱麻,以及头部何时自相矛盾。 这里的表格只是把同一个现象归纳成两种形态。

RGB-D 场景的第一个 bug

RealSense 这类深度相机反投影出的点云是有组织的,但它的坐标是 strong(x 右 / y 下 / z 前)。

而 Xtreme1 文档声明点云坐标系按世界坐标系处理,Z 朝上。

所以把 RealSense 点云直接扔给 Xtreme1,Z 轴是反的,整个场景会躺倒 / 上下颠倒。 必须先做一个固定的坐标变换把它转成 Z-up。

七、ASCII 与 BINARY:同一份数据的两种写法

实测数据(同样 6 个点、4 个字段):

单点开销12 万点的 64 线 LiDAR 单帧
DATA ascii41.3 字节/点≈ 4.7 MB(文本,人眼可读,适合调试)
DATA binary16 字节/点≈ 1.8 MB(工业默认)
比值2.6 倍—

注意:头部只占约 180 字节,点多了就相对忽略不计。 所以"ASCII 更小"是错的,它只是更可读。

精度警告

ASCII 常用 %.6g 格式化,会丢精度。 对精度敏感的场景(比如毫米级抓取),要么加位数,要么直接用 binary。

一个 10 m 外的点,%.6g 大约是 1e-5 的相对精度,看起来够, 但经过外参变换和多次累加后会在毫米级任务上显形。

二进制文件的实际字节(前 240 字节):

# .PCD v0.7 - Point Cloud Data file format
VERSION 0.7
FIELDS x y z intensity
SIZE 4 4 4 4
TYPE F F F F
COUNT 1 1 1 1
WIDTH 6
HEIGHT 1
VIEWPOINT 0 0 0 1 0 0 0
POINTS 6
DATA binary
[...紧接着是 96 字节的 float32 原始数据,没有任何分隔符...]

注意:DATA binary 那个换行符之后就是裸字节流。 解析时要精确找到 "DATA binary\n" 的位置作为数据起点,差一个字节,整片点云就全错位了。

八、ring 与 time:两个最不该丢的字段

ring = 这个点来自第几条激光线

1 条激光线转一圈  = 1 个 LaserScan        (极坐标,1D 距离数组)
N 条线都转一圈    = 1 帧 PointCloud       (笛卡尔,N×3 数组)
ring 字段         = "这个点来自第几条线"   (拆分的钥匙)

有了它才能:

time = per-point 时间,PCD 里唯一能放"时间"的地方

难点:旋转式 LiDAR 采一帧要 ~100ms
      这 100ms 里车在动
      → 这一帧的两端(扫描起点 vs 终点)其实是在不同位置采的
      → 点云整个被"扭"了

解法:用每个点的采集时刻 (time 字段) + 当时的速度
     把每个点"搬"回同一个时刻
PCD 里有没有 time后果
有✅ 可以做去畸变
没有❌ 永远做不了,只能忍受畸变
转换时最不能丢的两个字段

PointCloud2 里如果有 time / ring,转 PCD 时千万别丢掉。

那两个字段看起来"多余",其实是精度基础设施,丢了不可恢复。 好的 LiDAR 数据集会保留它们,而不是只存 xyz + intensity。

九、PCD 与 ROS PointCloud2 的映射关系

这张表把 PCD 头部和 ROS 消息的字段一一对上,它是整页最重要的一张表。实际数据里 MCAP 装的是 PointCloud2,要转成 PCD 就得走这张映射。

PCD 头部PointCloud2 字段
FIELDSfields[].name
SIZE + TYPEfields[].datatype
COUNTfields[].count
WIDTH / HEIGHTwidth / height
POINTSwidth × height
DATA ascii/binaryis_bigendian / point_step / row_step / data
VIEWPOINT❌ 无对应(实际无意义)
❌ 没有header.stamp,丢了时间!
❌ 没有header.frame_id,丢了坐标系名字!

它是"点云界的 JPEG"

MCAP          ←→  PCD
 MP4          ←→  JPEG
时间轴 / 多路   ←→  单帧
有时间戳        ←→  没时间戳
有 frame_id     ←→  没 frame_id

从 MCAP 导出 PCD,等于把一段视频抽成一堆 JPEG,顺带把时间戳和音轨全扔了。

这直接解释了 Xtreme1 的两个设计

1. 为什么它要一个单独的文件放 camera_config,因为 PCD 里装不下外参。

2. 为什么官方文档要特意声明"点云数据的坐标系就是世界坐标系" 因为 PCD 自己不会声明它在哪个系里,只能由平台约定死。

还有第 3 个:它只能用文件名来配对模态(同名 = 同一帧), 因为 PCD 里没有时间戳可用。这意味着时间同步误差在平台内部完全不可见(见模块 06)。

十、Xtreme1 里 PCD 装的两样东西

看官方文档给的目录结构:

├── lidar_point_cloud_0/
│   ├── data1.pcd                    ← ① 原始点云
│   └── data2.pcd
└── result/
    ├── data1.json
    ├── data1_lidar_point_cloud_0_segmentation.pcd   ← ② 分割标注结果,也是 PCD!
    └── data2_lidar_point_cloud_0_segmentation.pcd

标注结果(3D 语义/实例分割)也是存成 .pcd 的。 那它怎么装"这一万个点各属于哪个类别"?

原始点云:  FIELDS x y z intensity
分割结果:  FIELDS x y z intensity label      ← 多了一列
                    ↑ 点的几何没变,每个点多了一个属性
意外优势:「标注」就等于「加一列」
PCD 是记录数组
  → 同一批点,加一列属性 = 完成一次标注
  → 点的顺序、几何完全不动,天然一一对应

这是 L1(记录数组)相比 L2(容器)的一个反直觉优势:容器格式要表达"每个点的标签", 得嵌套或者开第二条流;PCD 只要改一下 FIELDS 那一行。

所以在点云标注这个场景下,"PCD 表达力弱"反而变成了"简单直接"。

十一、三件套里的 json:标定外挂与转换税

模块 01 那张对比表里,Xtreme1 的数据源写的是「只吃 pcd + jpg + json」。 pcd 是点云、jpg 是图像都清楚,strong。

⚠️ 先分清:Xtreme1 的 json 不止一种

名字都叫 .json,位置和作用完全不同:

位置内容作用
输入侧
camera_config/*.json
相机内参 + 外参 4×4 + 图像尺寸 + rowMajor建立 pcd ↔ jpg 的投影关系 ← 「只吃 pcd+jpg+json」指的就是它
输入侧
lidar_config/*.json
雷达自身参数雷达侧配置(融合数据集里有这一层)
输入侧
data/*.json
Data info(数据信息)官方注明 "only for exporting",用于导出
输出侧
result/*.json
3D 框 / 2D 框、类别、属性、trackId、来源真值本身

⚠️ 而且目录名在"上传格式"和"导出格式"里还不一样: 上传时是 lidar_point_cloud_0 / camera_image_0 / image_0, 导出时变成 point_cloud / image0 / image1。同一套数据,两套命名。

输入侧的 camera_config:标定的载体

官方给的完整示例(字段名照抄):

{
  // 内参
  "cameraInternal": {
    "fx": 933.4667, "fy": 934.6754,
    "cx": 896.4692, "cy": 507.3557
  },
  "width": 1920,
  "height": 1080,
  // 外参:4×4,16 个数,展平成一维数组
  "cameraExternal": [
    -0.7209, -0.0400, -0.6918, 0,
     0.6911,  0.0318, -0.7220, 0,
     0.0509, -0.9987,  0.0048, 0,
     0.0093,  1.6581, -1.0198, 1
  ],
  "rowMajor": false      // 这 16 个数是行优先还是列优先
}
⚠️ 三个真实的坑(都在官方文档里)

1. 官方文档把内外参的说明写反了。 文档写的是 "cameraExternal: Intrinsic parameters of camera"、 "cameraInternal: Camera matrix with 4x4",但字段名和实际内容恰好相反: cameraInternal 装的是内参(fx/fy/cx/cy),cameraExternal 装的是那个 4×4 外参矩阵。 strong

2. 没有任何畸变系数(没有 k1/k2/p1/p2/k3)。也就是说 strong,或者根本不建模畸变。鱼眼 / 广角相机必须先自行去畸变再传进来(见模块 07)。

3. rowMajor 默认 true,但示例里写的是 false。 16 个数展平成一维数组 + 一个转置标志位,这是最经典的转置 bug 来源。

它的投影约定(官方明写)

S(u,v) = M(internal) × M(external) × P

  P           点云坐标系下的点(齐次,4 维)
  M(internal) 4×4 内参矩阵(把 fx/fy/cx/cy 填进 4×4)
  M(external) 4×4 外参矩阵
  S(u,v)      图像坐标

注意两个细节:内参被写成 4×4(不是常见的 3×3 K), 而且官方特别提醒「4×3 或 3×3 的矩阵需要补成 4×4」。strong (官方还给了一段 Three.js 代码,把 KITTI 的 P_rect / R_rect / RT_velo_to_cam 合成为这一格式。)

坐标系约定(官方明写,必须记住)
点云:右手系,Z 轴向上
      ★【点云坐标系就是世界坐标系】

图像:原点在【左上角】,x 向右,y 向下

这就解释了为什么官方要特意声明"点云坐标系就是世界坐标系" 因为 PCD 自己不会声明它在哪个系里,只能由平台约定死。

它是「逐帧」的,而且这一点很有用
camera_config/
├── 00-1.json     ← 配 00-1.pcd / 00-1.jpg
└── 01-2.json     ← 配 01-2.pcd / 01-2.jpg

官方目录结构显示每一帧配一个自己的 camera_config/*.json。

这意味着它可以表达「逐帧变化的外参」,比如云台、可动传感器、或在标定漂移后重新标定的那一段。 这正好补上了另一个问题里的缺口:strong。

它必须外挂成一个文件的根因

根因和第二节那个定义是同一件事

PCD 的本质 = 一个"带名字的定长记录数组"。

→ 它只能装每个点都有的、定长的字段(x y z intensity …)
→ 装不下"相机内参"这种strong信息(内参是整幅图共享的一个换算关系,不属于任何strong)

PCD 装不下标定,不是因为设计得不好,是因为它不是容器,没有放元数据的地方。 于是标定只能外挂,strong

再深一层:json 是「MCAP 丢掉的东西」的外挂位

看 MCAP 里有什么:

MCAP 自带的(自描述):
  ✓ topic 名          /camera/front/image
  ✓ timestamp         每帧一个时刻
  ✓ frame_id          坐标系名字
  ✓ TF / 标定话题     传感器之间的变换
  ✓ 多路传感器的同步关系

一旦拆成裸文件:

pcd   →  只有几何    ✗ 没有 topic、没有 timestamp、没有 frame_id
jpg   →  只有像素    ✗ 同上
json  →  【捡回一部分】

json 就是那个"把 MCAP 里散落的上下文捡回来"的文件。但注意它捡得回什么、捡不回什么:

MCAP 里的东西拆文件后
标定(内外参)✅ 能进 json
文件配对关系✅ 能进 json
坐标系约定🟡 能约定死(官方特意声明"点云坐标系就是世界坐标系")
每帧的时间戳❌ 彻底丢了
时间丢失的后果
pcd 里没有时间戳
jpg 里没有传感器时间戳(EXIF 那个不是)
camera_config 虽然是【逐帧】的,但里面【没有时间戳字段】
   —— 它只靠文件名与同一帧的 pcd / jpg 配对
result/*.json 里也没有时间

→ 整个三元组【没有时间轴】
→ "时间"只能靠【文件名排序】隐式表达:
   data1.pcd ↔ data1.jpg ↔ data2.pcd …

而这意味着模块 06 讲的那个"33 ms → 102 px"的时间同步误差, 在平台内部完全不可见。

⚠️ 而且它是「私有约定」,不是标准

PCDcamera_config.json
谁定的PCL(有公开规范)平台自己
自描述吗✅ 头部写清 FIELDS/SIZE/TYPE❌ 字段名靠文档
读法任何 PCD 库都能读得知道这家的字段名
换平台通用字段名要改
这就是「转换税」的本体

PCD 至少是"自描述"的(谁都能解出结构),而 json 是"私有约定"(必须知道对方怎么命名)。

所以税不是交在格式转换上,是交在每家的 json 约定都不一样上。

由此得到一条工程结论

pcd + jpg + json  =  【标注用的工作副本】
                      不是【数据的事实来源】

MCAP(或 bag)    =  真正的归档格式

三条理由:

  1. 它无时间轴 → 跨传感器时间同步、跨帧轨迹、时序分析都做不了
  2. 它丢 frame_id → 坐标系只能靠约定,多机 / 多雷达场景会表述不清
  3. json 是私有约定 → 换平台要改,长期存档有风险
结论

不要拿 pcd+jpg+json 当唯一存档。 保留 MCAP 作为 source of truth,把三元组当成strong(可以随时从 MCAP 重新生成)。

这也回到模块 02 那个核心区分,后端是"唯一真相",而三元组只是一次转换的产物。 把产物当真相,是这类系统最常见的结构性错误。

一张图收束

        MCAP(容器 · 自描述 · 有时间轴)
         │
         │  转换脚本  ← 转换税就交在这里(json 的字段约定)
         ▼
   ┌─────────────┬─────────────┬────────────────────┐
   │  data1.pcd  │  data1.jpg  │ camera_config/     │
   │  几何        │  外观        │  data1.json        │
   │  ✗时间戳     │  ✗时间戳     │  逐帧,但无时间戳    │
   └─────────────┴─────────────┴────────────────────┘
         │                              ↑
         │  靠【同名】配对                │  内外参在这里
         ▼                              │
     标注员标注                    (不是 result/*.json)
         │
         ▼
   result/data1.json   ← 另一个 json:标注结果

十二、四个真实的坑

坑 1:rgb 字段被塞进 float32(最经典)

真实颜色 RGB(200,100,50)
写进 pcd 的字节解释成 float32 → 1.8403043e-38
  // 直接当浮点数用/打印,会得到 1.840304e-38 —— 看起来像"奇怪的深度值"

正确读法:按 uint32 重新解释 → 13132850 = 0xC86432
  解包 R=200 G=100 B=50   ✓ 还原成功

PCL 为了保持"统一 float32 列",把 RGB 三个字节硬塞进一个 float32 的内存里。 读取时必须 view(np.uint32) 重新解释,才能拿到颜色。

而且字节序还有两种约定((R<<16)|(G<<8)|B 和 (B<<16)|(G<<8)|R),必须实际验证,不能猜。

坑 2:不能直接 dump msg.data

很多人以为"把 PointCloud2.data 抠出来就是 PCD 的载荷"。三种情况会出错:

#情况要做的事
①PointCloud2 是 big-endian要逐字段字节翻转
②字段带 padding(fields[].offset 不连续)要按 PCD 的紧凑规则重排
③row_step ≠ width × point_step要按行拆开重新紧凑拼接

因为 PCD 的 offset 是从 SIZE × COUNT 依次推出来的,无法表达 padding。 而 PointCloud2 有显式的 fields[].offset,strong。

结论

PointCloud2 是"更严谨的内存布局规范 + 时间 + 坐标系",PCD 是"简化版内存布局 + 文件封装"。

"直接把内存倒出来当 PCD"只在紧凑 + 小端时才成立。

坑 3:open3d 会悄悄丢掉自定义字段

open3d.io.write_point_cloud() 是读写最省事的路径,但它默认只认 xyz / normals / colors,intensity、ring、label 这类字段strong。

对标注工作流这是致命的(intensity 是点云质检的重要依据, label 是标注结果本身)。要保留自定义字段,用 pypcd4 或自己写解析。

坑 4:VIEWPOINT 不是外参

字段名叫"视点",容易让人以为存的是传感器位姿。实践上它几乎永远是 0 0 0 1 0 0 0, 因为写文件的库基本不填。

外参必须从别处来,又一次回到标定。这就是为什么 Xtreme1 要一个独立的 camera_config/*.json。

十三、读写代码

写 ASCII PCD(不依赖任何点云库)

import numpy as np

def write_pcd_ascii(path, fields, arrays, width=None, height=1):
    """fields: [(name, size, type, count), ...];arrays: [(N,) ...]"""
    n = len(arrays[0])
    width = width or n
    lines = [
        "# .PCD v0.7 - Point Cloud Data file format",
        "VERSION 0.7",
        "FIELDS " + " ".join(f[0] for f in fields),
        "SIZE "   + " ".join(str(f[1]) for f in fields),
        "TYPE "   + " ".join(f[2] for f in fields),
        "COUNT "  + " ".join(str(f[3]) for f in fields),
        f"WIDTH {width}",
        f"HEIGHT {height}",
        "VIEWPOINT 0 0 0 1 0 0 0",
        f"POINTS {n}",
        "DATA ascii",
    ]
    mat = np.hstack([np.atleast_2d(a).reshape(n, -1) for a in arrays])
    for row in mat:
        lines.append(" ".join(f"{v:.6g}" for v in row))
    with open(path, "w") as f:
        f.write("\n".join(lines) + "\n")


write_pcd_ascii(
    "frame.pcd",
    fields=[("x",4,"F",1), ("y",4,"F",1), ("z",4,"F",1), ("intensity",4,"F",1)],
    arrays=[pts[:,0], pts[:,1], pts[:,2], intensity],
)

写 BINARY PCD(工业用法)

def write_pcd_binary(path, fields, mat):
    """mat: (N, total_cols) float32;字段必须紧凑排列"""
    n = mat.shape[0]
    header = (
        "# .PCD v0.7 - Point Cloud Data file format\n"
        "VERSION 0.7\n"
        "FIELDS " + " ".join(f[0] for f in fields) + "\n"
        + "SIZE "  + " ".join(str(f[1]) for f in fields) + "\n"
        + "TYPE "  + " ".join(f[2] for f in fields) + "\n"
        + "COUNT " + " ".join(str(f[3]) for f in fields) + "\n"
        + f"WIDTH {n}\nHEIGHT 1\n"
        + "VIEWPOINT 0 0 0 1 0 0 0\n"
        + f"POINTS {n}\nDATA binary\n"
    ).encode()
    with open(path, "wb") as f:
        f.write(header)
        f.write(mat.astype(np.float32).tobytes())   # ← 裸字节流,无分隔符

读回来

def read_pcd_ascii(path):
    with open(path) as f:
        lines = [l.rstrip("\n") for l in f]
    meta, i = {}, 0
    while not lines[i].startswith("DATA"):
        if lines[i] and not lines[i].startswith("#"):
            k, _, v = lines[i].partition(" ")
            meta[k] = v.split()
        i += 1
    meta["DATA"] = lines[i].split(" ", 1)[1].split()   # DATA 行本身要单独收
    data = np.array([[float(x) for x in l.split()] for l in lines[i+1:] if l.strip()])
    return meta, data

而二进制 PCD 可以零拷贝读

// C:mmap 之后直接强转,不需要任何解析
int fd = open("frame.pcd", O_RDONLY);
char *base = mmap(NULL, size, PROT_READ, MAP_PRIVATE, fd, 0);
char *payload = strstr(base, "DATA binary\n") + 12;   // 跳过头部
struct Point *pts = (struct Point *)payload;          // 直接用!
printf("%f\n", pts[999999].x);                         // O(1) 随机访问

能这么干,就说明它是内存布局,不是容器。(详见模块 04)

一句话总结
PCD = 【一段声明 struct 的文本头】 + 【那个 struct 数组的裸内存】

头部 10 个字段,核心是 4 个:
  FIELDS / SIZE / TYPE / COUNT  →  等价于一个 C 结构体声明
  WIDTH / HEIGHT                →  WIDTH = 一行几个,HEIGHT = 几行(等价于 reshape)
                                   HEIGHT=1 → 退化成计数,无组织;>1 → 网格,有组织

它装什么:
  点云本体      x y z (+ intensity / rgb / ring / time)
  每点属性      加一列即可(label = 分割结果)  ← 这是它在标注场景的意外优势
  装不了        面片、多流、时间序列、坐标系

它丢了两样(相比 PointCloud2):
  header.stamp      → 时间   必须另行保存(frame_index.csv)
  header.frame_id   → 坐标系 必须靠 camera_config 约定

pcd + jpg + json 三件套:
  json 不止一种 —— 输入侧 camera_config(逐帧的内外参)/ lidar_config / data(Data info)
                  输出侧 result/*.json(标注结果本身)
  json 存在的根因:PCD 是记录数组,装不下非逐点的信息 → 只能外挂
  ⚠️ 官方文档把 cameraExternal / cameraInternal 的说明写反了
  ⚠️ camera_config 里【没有畸变系数】→ 它假设图像已去畸变
  ⚠️ rowMajor 决定那个 4×4 是否要转置 —— 经典 bug 源
  它捡回了 MCAP 丢掉的【标定 + 配对】,捡不回【每帧时间戳】
  ⚠️ 所以三元组是【工作副本】,不是【事实来源】—— 别当唯一存档

四个坑:
  ① rgb 塞进 float32,要 view(uint32) 才能读
  ② 不能直接 dump msg.data(big-endian / padding / row_step 三种情况)
  ③ open3d 会静默丢弃 intensity / label 等自定义字段
  ④ VIEWPOINT 不是外参,几乎永远是单位值

最不能丢的字段:ring(拆回扫描平面)· time(做运动畸变补偿)
← 02 平台的工程真相
下一模块 04 点云格式全景 →
← 上一页:传感器体系 ·  下一页:实时与离线 →
京ICP备19013239号