☰
bin转pcd点云格式转换:字节布局、转换脚本与常见坑
2026/10/14 10:30:51 网站建设 项目流程

简介:面向三维点云处理初级开发者的一份二进制格式转点云格式工具包,解决激光雷达原始点云与点云库标准格式之间的互换需求。在自动驾驶、机器人定位与环境感知中,不同采集设备输出的点云格式往往不统一,二进制格式紧凑但缺少元数据,点云格式支持颜色、法线和结构信息,两者转换是数据预处理常见环节。资源共265个文件,压缩包约4.45MB,以101个JSON配置、100个TXT说明和2个Python脚本为主,同时包含bin、pcd样例数据与相机参数文件。其中JSON多用于相机标定参数或项目配置,TXT文档说明转换步骤,Python脚本则为实际转换与标定工具。bin2pcd.py承担格式转换核心功能,使用前需设置输入输出路径,也可修改代码适配自有数据的字段顺序;create_calib_json.py用于生成标定配置,便于处理内外参矩阵和坐标系对齐。包内还附带坐标校准相关文档与样例,可直接结合传感器预处理流程复用。已有2100余人学习下载,对刚接触点云格式转换和相机标定的开发者具有直接参考价值。

1. bin转pcd不是换后缀:一个点云格式迁移的真实场景

一个 .bin 文件从某个公开自动驾驶数据集解压出来,只有几十兆,拖进可视化工具却一片黑;用 Open3D 读回来点数对不上,或者转完的 pcd 在 PCL 里直接加载失败。这不是文件损坏,而是格式不匹配。bin 格式转换成 pcd,是点云预处理里最基础的一步:bin 是激光雷达原始点云的裸字节流,只有一串 float32 数据;pcd 是带完整头部描述的点云容器,点云库靠头部才知道怎么解析。这篇文章从 bin 的字节布局和 pcd 的头部协议讲起,给出手写转换脚本、参数设计、批量转换,以及我踩过的几个高频坑。适合正在做点云标注、配准、分割或 SLAM 数据预处理的人,看完能直接照着把数据集转成自己工具链能用的格式。

2. 字节布局对比:bin到底存了什么,pcd头部又描述了什么

2.1 bin文件是裸数据流:每16个字节一个点

bin 点云文件没有头部,没有字段说明,没有版本号,就是一段连续的二进制数据。最常见的布局是每个点占 16 字节,由 4 个 float32 组成,依次是 x、y、z、intensity。float32 是 4 字节,4 个字段就是 16 字节,所以一个 160,000 字节的 bin 文件,理论上恰好装 10,000 个点。

判断 bin 文件里一个点有几个字段,最朴素的办法是拿文件大小去试。文件总字节数如果能被字段数 × 4整除,这个字段数就是候选。比如 160,000 除以 16 得 10,000,除以 12 除不尽,那 4 字段的可能性就很高。实际操作中我习惯把候选字段数都跑一遍,先把能整除的列出来。

import os import numpy as np bin_path = "raw_cloud.bin" file_bytes = os.path.getsize(bin_path) for fields in (3, 4, 5, 6): bytes_per_point = fields * 4 if file_bytes % bytes_per_point == 0: point_count = file_bytes // bytes_per_point print(f"按 {fields} 字段 float32 解析,点数 = {point_count}")

这段代码的逻辑是:用文件大小对字段数 * 4取余,余数为 0 才说明能整除。整除是必要条件,但不是充分条件——比如一个 6 字段的文件,按 3 字段也能整除,因为 6 是 3 的倍数。所以整除结果出来后还要再验证数值分布,这点在第 4 章会展开。

不同来源的 bin 文件,字段差异很大。常见的有这么几类:

字段数每点字节数典型含义
312x y z
416x y z intensity
520x y z intensity ring
624x y z r g b 或 x y z intensity ring label

读取时统一用np.fromfile(bin_path, dtype=np.float32),因为激光雷达数据基本都按小端存储,numpy 在 x86 平台上默认就是小端解释,直接读就行。如果你遇到的数据是某款传感器厂商自定义导出的大端格式,后面第 5 章的坑里会专门说。

2.2 pcd的头部协议:字段名、类型、尺寸缺一不可

pcd 之所以能被 PCL、Open3D 这类库正确解析,靠的是文件头部的一堆描述行。以最常用的 PCD v0.7 为例,头部长这样:

# .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 10000 HEIGHT 1 VIEWPOINT 0 0 0 1 0 0 0 POINTS 10000 DATA binary

每一行都有明确作用。FIELDS 说明每个点有哪些字段,SIZE 用字节数说明每个字段占多长,TYPE 用 F/I/U 区分浮点、有符号整数、无符号整数,COUNT 一般填 1。WIDTH 和 HEIGHT 决定了点云是有组织还是无组织:激光雷达转出来的散点,HEIGHT 固定为 1,WIDTH 等于点数。POINTS 必须和实际写入的数据点数一致,不一致时 PCL 加载会直接失败。

头部行作用常见值
VERSION文件格式版本0.7
FIELDS每个点的字段名x y z / x y z intensity
SIZE每个字段的字节数4 4 4 4
TYPE每个字段的类型F F F F
COUNT每个字段的元素数1 1 1 1
WIDTH无组织点云的点数实际点数
HEIGHT1 表示无组织点云1
VIEWPOINT传感器位姿0 0 0 1 0 0 0
POINTS点云总点数与 WIDTH 一致
DATA数据段存储方式binary / ascii

DATA 行有三个可选值:ascii、binary、binary_compressed。ascii 是纯文本,每行一个点,人能直接读,但文件体积会膨胀 2 到 3 倍,加载也慢;binary 是裸二进制,体积最小,读写最快;binary_compressed 是 LZF 压缩,体积更小但读写时要额外解压,自己写转换脚本没必要碰。我默认只用 binary。

现在能回答标题里的根本问题了:bin 和 pcd 差的不只是后缀,而是 pcd 多了这一段描述头部。直接把 .bin 改成 .pcd,PCL 会把头部区域当二进制数据解析,出来的点云全是噪声。这也是为什么转换脚本必须自己生成头部,而不是复制粘贴文件。

3. Python手写bin转pcd:从最小脚本到两种落地实现

3.1 不依赖点云库的最小脚本:numpy读bin,手工拼pcd头

有些部署环境没有图形界面,装 Open3D 这种带可视化依赖的库很麻烦。最小方案是用 numpy 读 bin,手工拼 pcd 头部,再写文件,全程只依赖 numpy 和标准库。

import numpy as np import os def bin_to_pcd(bin_path, pcd_path, fields=("x", "y", "z", "intensity")): # 整块读入二进制,按字段数切分成 N x fields 的矩阵 raw = np.fromfile(bin_path, dtype=np.float32).reshape(-1, len(fields)) # 过滤 NaN / Inf 点,激光雷达常有无效回波 valid = np.isfinite(raw).all(axis=1) data = raw[valid] # 手工构造 PCD v0.7 头部 header = ( "# .PCD v0.7 - Point Cloud Data file format\n" "VERSION 0.7\n" f"FIELDS {' '.join(fields)}\n" f"SIZE {' '.join(['4'] * len(fields))}\n" f"TYPE {' '.join(['F'] * len(fields))}\n" f"COUNT {' '.join(['1'] * len(fields))}\n" f"WIDTH {data.shape[0]}\n" "HEIGHT 1\n" "VIEWPOINT 0 0 0 1 0 0 0\n" f"POINTS {data.shape[0]}\n" "DATA binary\n" ) with open(pcd_path, "wb") as f: f.write(header.encode("ascii")) f.write(data.astype("<f4").tobytes()) # 强制小端 float32 return data.shape[0]

核心逻辑有三处。第一,np.fromfile必须指定dtype=np.float32,否则 numpy 默认按 float64 读,文件字节数直接翻倍解释,出来的数据全部错乱。第二,reshape(-1, len(fields))要求文件字节数能被字段数整除,所以调用前最好先确认字段数。第三,过滤 NaN/Inf 是雷达点云转换的标准动作,不过滤的话,PCL 加载后做体素滤波或配准时经常出奇怪结果。

参数说明:fields元组决定 pcd 的 FIELDS 行内容,如果 bin 是纯 xyz 就传("x", "y", "z"),带强度就传("x", "y", "z", "intensity")。"<f4"里的<表示小端,f4表示 4 字节浮点,如果你的源数据确认是大端,改成">f4",但绝大多数公开数据集都是小端,不要乱改。

3.2 用Open3D转换:适合只关心几何坐标的场景

如果你的下游只用到 xyz,不在乎强度字段,Open3D 是最省事的路径。它把读写封装在write_point_cloud里,不需要手工拼头部。

import numpy as np import open3d as o3d def bin_to_pcd_o3d(bin_path, pcd_path): raw = np.fromfile(bin_path, dtype=np.float32).reshape(-1, 4) pcd = o3d.geometry.PointCloud() pcd.points = o3d.utility.Vector3dVector(raw[:, :3]) # 只取 xyz o3d.io.write_point_cloud(pcd_path, pcd, write_ascii=False, compressed=False)

这段代码里,raw[:, :3]把前三列坐标喂给Vector3dVector,Open3D 内部会转成自己的点云结构。write_ascii=False对应上一章说的 binary 存储,compressed=False不启用 LZF 压缩。

需要注意一个边界:Open3D 的 pcd 写入对自定义字段的支持很有限,主要保留 xyz 和 rgb,intensity 这类字段写进去之后再读回来大概率被丢掉。所以如果你的算法需要强度值做分割或分类,不建议用 Open3D 做转换,回到 3.1 的手写方案更可控。

3.3 PCL怎么参与:官方io不认bin,先转再load

很多刚接触 PCL 的开发者会去找loadBinFile之类的接口,翻遍文档会发现 PCL 的 io 模块没有直接读这种裸 bin 的入口。常见做法是:先用脚本把 bin 转成 pcd,再用loadPCDFile加载。

PCL 加载带强度的 pcd 时,点类型要对应上。pcd 头部 FIELDS 是x y z intensity,代码里就用pcl::PointCloud<pcl::PointXYZI>;FIELDS 是x y z r g b,就用pcl::PointCloud<pcl::PointXYZRGB>。字段名和点类型对不上,PCL 会报字段不匹配,加载返回 -1。

#include <pcl/io/pcd_io.h> #include <pcl/point_types.h> pcl::PointCloud<pcl::PointXYZI>::Ptr cloud(new pcl::PointCloud<pcl::PointXYZI>); if (pcl::io::loadPCDFile<pcl::PointXYZI>("out.pcd", *cloud) == -1) { // 头部字段与点类型不匹配,检查 FIELDS 行 }

这一步是很多 C++ 管线的接入点。转换脚本输出 pcd 后,C++ 侧只要保证点类型和 FIELDS 一致,就能直接接进滤波、配准、分割的后续流程。PCL 本身不参与 bin 的读取,它只负责消费转换结果,这个职责边界很多人一开始没搞清楚。

4. 参数设计:自动识别字段数、强度缩放与批量转换

4.1 自动识别bin的点字段数:不靠猜,靠整除和数值分布

人工确认字段数只适合一两个文件,面对几百个 bin 时,脚本要能自己判断。我的做法是两步:先整除筛一遍,再对每个候选读前一部分数据统计有限值比例。

import numpy as np import os def detect_fields(bin_path, candidates=(3, 4, 5, 6)): file_bytes = os.path.getsize(bin_path) viable = [] for n in candidates: bytes_per_point = n * 4 if file_bytes % bytes_per_point != 0: continue sample = np.fromfile(bin_path, dtype=np.float32, count=min(10000, file_bytes // 4)).reshape(-1, n) finite_ratio = np.isfinite(sample).all(axis=1).mean() if finite_ratio > 0.5: viable.append((n, round(finite_ratio, 3))) return viable

逻辑说明:count=min(10000, file_bytes // 4)是控制采样量,文件很大时只读前一万个点就够判断了,没必要把整个文件载入内存。np.isfinite(sample).all(axis=1).mean()算出前 10000 个点里所有字段都是有限值的比例,雷达数据通常有少量无效回波,所以阈值放在 0.5 而不是 1.0。

这个函数的返回值可能同时有多个候选。比如一个 6 字段文件,按 3 字段解析也能整除且有限值比例很高。这时候再看数据分布:3 字段时只有 xyz,第 4 列不存在;如果按 6 字段解析后,第 4 列数值集中在 0 到 1 或 0 到 255,基本就是强度,第 5、6 列是环号或标签。拿不准时打印前 5 行数据人工瞄一眼,比猜快得多。

4.2 字段名的选择:intensity与rgb不要在pcd里混写

pcd 的 FIELDS 行必须和下游点类型严格对应,不能乱写。常见约定是x y z intensity对应 PointXYZI,x y z rgb对应 PointXYZRGB。字段顺序错了,PCL 会把强度当成 z,点云直接变形。

RGB-D 相机导出的 bin 有时是x y z r g b,三个颜色各自是 0 到 1 的浮点。这时转 pcd 要先把颜色打包成 uint32,因为 PCL 的 PointXYZRGB 里 rgb 是个 32 位整数,不是三个独立 float。打包方法很固定:

def pack_rgb(r, g, b): r = int(r * 255) if r <= 1.0 else int(r) g = int(g * 255) if g <= 1.0 else int(g) b = int(b * 255) if b <= 1.0 else int(b) return (r << 16) | (g << 8) | b

打包完的 rgb 写进 pcd 时,SIZE 填 4,TYPE 填 U,COUNT 填 1。如果直接把三个 float 的 r/g/b 当三个字段写进 pcd,PCL 加载 PointXYZRGB 时会解析出完全错误的颜色,而且这种错误肉眼在点云视图里很难发现,因为几何坐标是对的,只是颜色不对。

4.3 强度值缩放:0~1还是0~255,显示端要设范围

强度字段的数值范围,不同数据集习惯不一样,有归一化到 0 到 1 的,也有直接给 0 到 255 的。pcd 格式本身不规定强度范围,所以转换时我不建议做缩放,保留原始值最安全。真正的坑在显示端:强度全在 0 到 1,可视化工具默认按 0 到 255 映射,显示出来就是一片黑,很多人误以为转换出错。

检查强度范围的代码很简单:

intensity = raw[:, 3] print("intensity range:", intensity.min(), intensity.max(), "mean:", intensity.mean())

如果 min 是 0、max 是 1,显示时把渲染器的强度范围设为 0 到 1 即可。如果一定要转成 0 到 255,可以在转换脚本里乘 255,但要记得这是有损操作,强度分布会被拉伸。我的习惯是转换脚本永远保留原始强度,显示参数的问题留给可视化阶段解决。

4.4 批量转换:一个目录的bin一次转完

实际项目里 bin 文件都是成百上千个,单个文件手动转不现实。批量脚本注意两点:输出目录要先创建;文件名保持和源文件一致,只换后缀。

import glob import os def batch_bin_to_pcd(src_dir, dst_dir): os.makedirs(dst_dir, exist_ok=True) for bin_path in sorted(glob.glob(os.path.join(src_dir, "*.bin"))): name = os.path.basename(bin_path).replace(".bin", ".pcd") pcd_path = os.path.join(dst_dir, name) count = bin_to_pcd(bin_path, pcd_path) # 复用 3.1 的函数 print(f"{name}: {count} points") batch_bin_to_pcd("raw", "pcd")

sorted(glob.glob(...))保证输出顺序稳定,这样日志和后续处理能对上文件序列。性能方面,numpy 整块读、整块写,几万个文件也就几分钟的事,千万不要用循环逐点 append,那会慢到怀疑人生。

5. bin转pcd的常见坑与排查:五条高频翻车记录

5.1 点云在Z轴被拉成一条竖线

现象:转出来的 pcd 用可视化工具打开,所有点沿着 Z 轴排成一条很长很细的竖线,X 和 Y 几乎为 0,看起来像一根毛线。

原因:字段数判断错了。源 bin 实际是 6 字段(比如 xyz 加强度加环号加标签),却被当成 4 字段读,每个点的第 5、6 个字段被挤到下一个点的前两个坐标里,整体错位。Z 轴成线,通常是强度值被当成了 Z 坐标写进 pcd。

解决:先跑detect_fields拿到候选字段数;再打印 reshape 后前 5 行的数值。正常雷达点云 X 在负几十到正几十米之间,Y 类似,Z 在地面附近(-3 到 3),如果第 3 列全是 0 到 1 的小数,说明 Z 的位置实际放的是强度,字段顺序要调整。

5.2 读回pcd点数减半或变少

现象:bin 文件 160,000 字节,按 4 字段算应该是 10,000 个点,转出的 pcd 用 PCL 读回来只有 9,000 多点,不是预期值。

原因:转换脚本过滤了 NaN/Inf。激光雷达在无回波区域会写 NaN,这些点被np.isfinite过滤掉,所以点数变少是正常现象,不是 bug。

解决:转换时打印过滤前后的数量,确认过滤掉的确实是无效点。如果你下游算法要求固定点数,可以在过滤前做记录,但更合理的做法是转换时就清掉 NaN,因为 PCL 加载 pcd 后遇到 NaN 点,体素滤波和配准的表现都不稳定。这个坑真正要注意的,是不要因为点数对不上就怀疑脚本写错而关闭过滤——那是拿下游算法的稳定性换一个数字好看。

5.3 Open3D读pcd报错或返回空点云

现象:o3d.io.read_point_cloud读转出来的 pcd,点数为 0,或者点全在原点。

原因:手工拼接的头部语法有问题。最常见的两种:POINTS 行数字和实际 DATA 段字节数对不上;VIEWPOINT 行少了值。PCD v0.7 要求 VIEWPOINT 后面有 7 个数字(位置 3 个加四元数 4 个),少了或者多了都会让解析器直接放弃。

解决:用文本编辑器打开 pcd,看头部从 VERSION 到 DATA 是不是完整,POINTS 和 WIDTH 是否一致;再用 PCL 的loadPCDFile读一遍做交叉验证。两个库都读不通,基本就是头部拼接错;只有一个库报错,可能是那个库对某些头部写法更严格,以报错信息为准逐行改。

5.4 文件膨胀好几倍,加载慢得离谱

现象:bin 文件 100MB,转出来的 pcd 变成 300MB 甚至更大,加载要等好几秒,后续处理全被拖慢。

原因:大概率写成了DATA ascii。文本格式把每个 float32 变成一串可读字符,体积膨胀 2 到 3 倍;如果转换脚本里把dtype从 float32 换成了 float64,再膨胀一倍。

解决:头部 DATA 行固定写binary,写入时astype("<f4")保持 4 字节浮点。SIZE 行如果出现 8,说明某处把 float64 写进去了。检查完这两处,文件体积应该和 bin 差不多。

5.5 强度显示一片黑,几何却正常

现象:点云的形状、位置完全正确,但按强度着色时整个点云是黑的,什么都看不清。

原因:bin 里的强度是 0 到 1 的归一化值,显示端默认按 0 到 255 映射,所有强度都落在最暗区间。这是显示参数问题,不是转换错误,但第一次遇到的人很容易误判成转换脚本写坏了强度字段。

解决:先打印强度 min/max 确认范围。0 到 1 就在渲染器里设强度范围 vmin=0、vmax=1;如果已经是 0 到 255 但还是黑,检查可视化工具的强度通道是不是被关了,或者 pcd 里强度字段名是不是被写成了别的名字导致渲染器没识别到。

6. 进阶验证:转换完别急着用,先过这三道检查

6.1 用双库交叉验证点数与坐标

转换完不要直接丢进算法,先用 Open3D 和 PCL 各读一遍,确认点数一致、坐标范围合理。Open3D 读回自定义字段可能丢失,但 xyz 点数是准的。

import open3d as o3d pcd = o3d.io.read_point_cloud("out.pcd") print("Open3D points:", len(pcd.points))

PCL 侧读到的点数和这个数字一致,说明头部和二进制数据对得上。两边不一致,先查 POINTS 行,再查 DATA 段有没有多写或少写。

6.2 检查坐标范围是否匹配传感器物理量程

激光雷达点云的 X/Y 范围应该和传感器量程相符,一般是几十米到一百多米,Z 在地面附近,不会出现几千的值。转换后打印一次 xyz 的 min/max,如果 Z 最大值超过 100,几乎可以断定字段顺序错了。这个习惯能在数据进入配准或分割前拦住 90% 的格式问题。

6.3 把VIEWPOINT写进pcd:给下游配准留一个位姿

VIEWPOINT 默认值0 0 0 1 0 0 0表示无旋转、无平移。如果你的 bin 数据来自固定安装的雷达,可以从标定文件里读出传感器位置,填进 VIEWPOINT。下游做配准或地面分割时,这个位姿能帮助算法做初始对齐,省掉一次猜测。不需要的时候保持默认即可,但头部这一行必须存在。

我之前有一次没做坐标范围检查,把一个字段顺序错位的点云直接喂给配准模块,迭代发散了一下午才意识到是 Z 轴把强度当成坐标写了。从那以后,转换脚本跑完必然先打印 xyz 范围,再进算法流程。bin 转 pcd 虽然是个小工具,但它是整条点云管线的入口,入口脏了,后面每一步都在为它买单。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询