简介面向三维点云处理初级开发者的一份二进制格式转点云格式工具包解决激光雷达原始点云与点云库标准格式之间的互换需求。在自动驾驶、机器人定位与环境感知中不同采集设备输出的点云格式往往不统一二进制格式紧凑但缺少元数据点云格式支持颜色、法线和结构信息两者转换是数据预处理常见环节。资源共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 z416x y z intensity520x y z intensity ring624x y z r g b 或 x y z intensity ring label读取时统一用np.fromfile(bin_path, dtypenp.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 固定为 1WIDTH 等于点数。POINTS 必须和实际写入的数据点数一致不一致时 PCL 加载会直接失败。头部行作用常见值VERSION文件格式版本0.7FIELDS每个点的字段名x y z / x y z intensitySIZE每个字段的字节数4 4 4 4TYPE每个字段的类型F F F FCOUNT每个字段的元素数1 1 1 1WIDTH无组织点云的点数实际点数HEIGHT1 表示无组织点云1VIEWPOINT传感器位姿0 0 0 1 0 0 0POINTS点云总点数与 WIDTH 一致DATA数据段存储方式binary / asciiDATA 行有三个可选值ascii、binary、binary_compressed。ascii 是纯文本每行一个点人能直接读但文件体积会膨胀 2 到 3 倍加载也慢binary 是裸二进制体积最小读写最快binary_compressed 是 LZF 压缩体积更小但读写时要额外解压自己写转换脚本没必要碰。我默认只用 binary。现在能回答标题里的根本问题了bin 和 pcd 差的不只是后缀而是 pcd 多了这一段描述头部。直接把 .bin 改成 .pcdPCL 会把头部区域当二进制数据解析出来的点云全是噪声。这也是为什么转换脚本必须自己生成头部而不是复制粘贴文件。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, dtypenp.float32).reshape(-1, len(fields)) # 过滤 NaN / Inf 点激光雷达常有无效回波 valid np.isfinite(raw).all(axis1) data raw[valid] # 手工构造 PCD v0.7 头部 header ( # .PCD v0.7 - Point Cloud Data file format\n VERSION 0.7\n fFIELDS { .join(fields)}\n fSIZE { .join([4] * len(fields))}\n fTYPE { .join([F] * len(fields))}\n fCOUNT { .join([1] * len(fields))}\n fWIDTH {data.shape[0]}\n HEIGHT 1\n VIEWPOINT 0 0 0 1 0 0 0\n fPOINTS {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必须指定dtypenp.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, dtypenp.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_asciiFalse, compressedFalse)这段代码里raw[:, :3]把前三列坐标喂给Vector3dVectorOpen3D 内部会转成自己的点云结构。write_asciiFalse对应上一章说的 binary 存储compressedFalse不启用 LZF 压缩。需要注意一个边界Open3D 的 pcd 写入对自定义字段的支持很有限主要保留 xyz 和 rgbintensity 这类字段写进去之后再读回来大概率被丢掉。所以如果你的算法需要强度值做分割或分类不建议用 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::PointCloudpcl::PointXYZIFIELDS 是x y z r g b就用pcl::PointCloudpcl::PointXYZRGB。字段名和点类型对不上PCL 会报字段不匹配加载返回 -1。#include pcl/io/pcd_io.h #include pcl/point_types.h pcl::PointCloudpcl::PointXYZI::Ptr cloud(new pcl::PointCloudpcl::PointXYZI); if (pcl::io::loadPCDFilepcl::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, dtypenp.float32, countmin(10000, file_bytes // 4)).reshape(-1, n) finite_ratio np.isfinite(sample).all(axis1).mean() if finite_ratio 0.5: viable.append((n, round(finite_ratio, 3))) return viable逻辑说明countmin(10000, file_bytes // 4)是控制采样量文件很大时只读前一万个点就够判断了没必要把整个文件载入内存。np.isfinite(sample).all(axis1).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对应 PointXYZIx 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 填 4TYPE 填 UCOUNT 填 1。如果直接把三个 float 的 r/g/b 当三个字段写进 pcdPCL 加载 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_okTrue) 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 就在渲染器里设强度范围 vmin0、vmax1如果已经是 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 虽然是个小工具但它是整条点云管线的入口入口脏了后面每一步都在为它买单。希望帮到你。本文还有配套的精品资源点击获取