进了平台,第一个门槛不是算法,是文件格式。这一页先把库、格式、读数这三层分清, 再把 PCD 的头部字段、数据布局、与 ROS 消息的映射拆到底,最后给出四个真实会咬人的坑。
打开 PCD 之前,有三组概念容易混。分清它们,后面所有格式名词都会自己各就各位。
| 层 | 例子 | 它负责什么 |
|---|---|---|
| 库 | PCL、Open3D、ROS 的 pcl_ros | 提供算法和读写接口,不定义文件长什么样 |
| 格式 | PCD、PLY、LAS、二进制 blob | 定义文件里的字节怎么排 |
| 传感器读数 | LaserScan、PointCloud2 | 定义运行时怎么把数据交给下游程序 |
混乱通常来自把这三层当成一层。比如"PCD 是不是 PCL 的格式",答案是可以这么说, 但准确的说法是 PCL 最早推广了它,而格式本身和库是分开的。
这个词很关键。PCD 不存场景、不存对象、不存时间线,它只存一张表: 每行一个点,每列一个字段。所以它不知道"哪个点属于哪辆车", 也不知道"这一帧和第几帧相邻"。这些都要在外面另存,这就是后面讲的"三件套"里 json 的作用。
有组织的点云像一个二维网格,长宽对应激光雷达的线数和每条线上的采样数; 无组织的就是一串无序的点。同一个格式能装这两种,所以读代码时必须先看头部有没有标尺寸。
格式层只解决一件事:把一堆数字按约定的顺序摆进文件。 语义、时序、标定,全都在这层之外。
下面是实际生成的、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。两边用同一套行配色,所以能直接对上。
64 × 48 的矩形塌成一条一维长条,右边所有点变成同一个颜色
因为"第 k 个点属于第几行"这件事,只存在于头部的声明里。
组织性是b的声明,不是几何性质。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 |
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] 的连续内存,一个字节不多不少
勾选字段,实时看头部怎么变、单点的字节布局怎么排、以及 point_step 是多少。
重点关注:加了 ring 之后总字节数怎么变(这是很多人算错偏移的地方)。
SIZE × COUNT 依次累加推出来的。这一点在写转换脚本时是致命的(见第十一节 · 坑 2)。ring 用 2 字节就够(最多 128 线),但若为省事写成 4 字节,
point_step 就变了,所有后续字段的偏移全部错位。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 | |
|---|---|---|
HEIGHT | 1 | >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 无从谈起
缺一条,"形状"就是一句空头支票,只能退回几何检索。第一节那个交互演示,做的正是①②被破坏的过程。
第一节那个交互演示把这条规则当场拆开看了,拖动"乱序"和"无效像素"两个滑块, 就能看到「下标邻居」的连线如何从整齐网格退化成一团乱麻,以及头部何时自相矛盾。 这里的表格只是把同一个现象归纳成两种形态。
RealSense 这类深度相机反投影出的点云是有组织的,但它的坐标是 strong(x 右 / y 下 / z 前)。
而 Xtreme1 文档声明点云坐标系按世界坐标系处理,Z 朝上。
所以把 RealSense 点云直接扔给 Xtreme1,Z 轴是反的,整个场景会躺倒 / 上下颠倒。 必须先做一个固定的坐标变换把它转成 Z-up。
实测数据(同样 6 个点、4 个字段):
| 单点开销 | 12 万点的 64 线 LiDAR 单帧 | |
|---|---|---|
DATA ascii | 41.3 字节/点 | ≈ 4.7 MB(文本,人眼可读,适合调试) |
DATA binary | 16 字节/点 | ≈ 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。
PointCloud2 的映射关系这张表把 PCD 头部和 ROS 消息的字段一一对上,它是整页最重要的一张表。实际数据里 MCAP 装的是 PointCloud2,要转成 PCD 就得走这张映射。
| PCD 头部 | PointCloud2 字段 |
|---|---|
FIELDS | fields[].name |
SIZE + TYPE | fields[].datatype |
COUNT | fields[].count |
WIDTH / HEIGHT | width / height |
POINTS | width × height |
DATA ascii/binary | is_bigendian / point_step / row_step / data |
VIEWPOINT | ❌ 无对应(实际无意义) |
| ❌ 没有 | header.stamp,丢了时间! |
| ❌ 没有 | header.frame_id,丢了坐标系名字! |
MCAP ←→ PCD
MP4 ←→ JPEG
时间轴 / 多路 ←→ 单帧
有时间戳 ←→ 没时间戳
有 frame_id ←→ 没 frame_id
从 MCAP 导出 PCD,等于把一段视频抽成一堆 JPEG,顺带把时间戳和音轨全扔了。
1. 为什么它要一个单独的文件放 camera_config,因为 PCD 里装不下外参。
2. 为什么官方文档要特意声明"点云数据的坐标系就是世界坐标系" 因为 PCD 自己不会声明它在哪个系里,只能由平台约定死。
还有第 3 个:它只能用文件名来配对模态(同名 = 同一帧), 因为 PCD 里没有时间戳可用。这意味着时间同步误差在平台内部完全不可见(见模块 06)。
看官方文档给的目录结构:
├── 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。
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
看 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"的时间同步误差, 在平台内部完全不可见。
| PCD | camera_config.json | |
|---|---|---|
| 谁定的 | PCL(有公开规范) | 平台自己 |
| 自描述吗 | ✅ 头部写清 FIELDS/SIZE/TYPE | ❌ 字段名靠文档 |
| 读法 | 任何 PCD 库都能读 | 得知道这家的字段名 |
| 换平台 | 通用 | 字段名要改 |
PCD 至少是"自描述"的(谁都能解出结构),而 json 是"私有约定"(必须知道对方怎么命名)。
所以税不是交在格式转换上,是交在每家的 json 约定都不一样上。
pcd + jpg + json = 【标注用的工作副本】
不是【数据的事实来源】
MCAP(或 bag) = 真正的归档格式
三条理由:
frame_id → 坐标系只能靠约定,多机 / 多雷达场景会表述不清不要拿 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:标注结果
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),必须实际验证,不能猜。
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"只在紧凑 + 小端时才成立。
open3d 会悄悄丢掉自定义字段open3d.io.write_point_cloud() 是读写最省事的路径,但它默认只认 xyz / normals / colors,intensity、ring、label 这类字段strong。
对标注工作流这是致命的(intensity 是点云质检的重要依据,
label 是标注结果本身)。要保留自定义字段,用 pypcd4 或自己写解析。
VIEWPOINT 不是外参字段名叫"视点",容易让人以为存的是传感器位姿。实践上它几乎永远是 0 0 0 1 0 0 0,
因为写文件的库基本不填。
外参必须从别处来,又一次回到标定。这就是为什么 Xtreme1 要一个独立的
camera_config/*.json。
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],
)
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
// 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(做运动畸变补偿)