
做点云处理的人应该都遇到过这个场景从KITTI数据集下载一帧Velodyne扫描数据np.fromfile读进来得到一个N行4列的数组心里想着“要把它按64线拆开做地面分割或者生成Range Image”结果盯着数组看了半天——里面没有线束序号没有时间戳只有干干净净的x、y、z和intensity四列。我第一次碰到“KITTI点云bin文件分成64线”这个需求时也愣了一下传感器明明是64线的为什么文件里却看不出“线”来后来把原理梳理清楚才明白bin文件只是把一帧内所有激光点的三维坐标和反射强度按顺序攒在一起落盘时把跟线束相关的通道信息直接省掉了。要想还原回64条线束只能利用每个点相对雷达坐标系的角度关系去逆向推算。这篇文章就把这件事从原理到代码完整拆一遍内容包括bin文件结构、垂直角度换算线束ID的数学依据、不用for循环的NumPy实现、分线后的几个典型应用以及一些容易翻车的细节。适合刚接触多线LiDAR数据、需要把KITTI点云用于地面分割、障碍物聚类或Range Image生成的同学参考。1. 拿到bin文件先别急着分数据格式和“线”之间的断层1.1 一个bin文件里到底装了什么KITTI的Odometry数据集里点云文件路径一般是sequences/00/velodyne/000000.bin这样的结构。用下面三行代码就能读出来import numpy as np bin_path sequences/00/velodyne/000000.bin scan np.fromfile(bin_path, dtypenp.float32).reshape(-1, 4) print(scan.shape, scan.dtype)读出来是一个(N, 4)的float32数组N通常接近12万。四列的含义分别是列索引字段含义0x前方距离米1y左侧距离米2z上方高度米3intensity反射强度这套坐标系是从雷达自身出发定义的x轴指向车头前方y轴指向左侧z轴垂直向上。注意它不是相机坐标系那种z向前的结构做可视化或者坐标变换时最容易在这里翻车。文件里没有任何一列叫ring或者scan_id。这和很多从ROS话题里保存下来的PointCloud2格式不一样——PointCloud2的channel里通常会带一个整型ring字段表示每个点来自第几条线束。KITTI转存的时候把这个通道丢了。1.2 为什么原始点云看不出“线”来多线激光雷达的原理是一圈激光发射器以不同的俯仰角固定电机带着它们绕垂直轴旋转。一帧数据里点与点本来是有严格时间顺序的先扫到哪个水平角度、再由哪个俯仰角的激光头发射理论上都能还原。但KITTI提供的bin文件已经把所有点的原始采集顺序打散重排了。更准确地说它保存的是传感器回调里一帧结束时的聚合点云每个点没有精确时间戳也没有发射器编号。时间信息一丢唯一能用的线索就只剩下每个点相对于雷达坐标系的角度。此时要做的分线本质上是几何分线根据每个点的三维坐标算出它相对雷达的垂直俯仰角再把俯仰角跟64个激光头的出厂安装角一一对应。1.3 别误解ring ID本来就是驱动算出来的有朋友问过我“KITTI数据集这么正规为什么bin文件里不直接存线束ID”这里有个容易误解的点HDL-64E这类机械式激光雷达每个点原始输出本身并不带“我是第几条线”这个标签。驱动拿到原始数据包后要么根据数据包里的firing sequence要么根据点云的角度信息才能给每个点附上ring通道。所以KITTI bin文件里没有ring不代表数据不完整只代表它跳过了驱动层的后处理。我们自己分线的过程实际上就是在手动补上驱动层本来要做的那一步。2. 分线原理从三维坐标还原每束激光的出厂角度2.1 水平角和垂直角的数学关系任何一个点P(x, y, z)在雷达球坐标系里都有两个关键角度水平方位角 yawatan2(y, x)表示这个点相对车头方向转了多少度垂直俯仰角 pitchatan2(z, sqrt(x^2 y^2))表示这个点和雷达水平面的夹角距离则可以写成r sqrt(x^2 y^2 z^2)。垂直俯仰角就是分线的核心依据。因为64个激光发射器是固定在不同俯仰角上的每个点既然由某个发射器打出它的pitch角度就应该和那个发射器的安装角几乎一致。用生活化的类比来说站在山坡上一排路灯从山脚延伸到山顶每盏灯的安装高度不一样。你只看某一个路灯光照到地上的光斑就能反推出它是哪一盏灯照的——因为每盏灯的光斑高度范围是固定的。点云的pitch角就是那个“光斑高度”。2.2 HDL-64E的“两段式”扫描结构KITTI最早用的是Velodyne HDL-64E垂直视场角大约从2°到-24.9°一共64线。这个64不是简单的一条直线均匀分布而是上下两组各32线拼接出来的上组通常编号0-31垂直角从约2°往下扫到约-8.67°下组通常编号32-63垂直角从约-8.87°继续往下扫到约-24.97°两组之间在-8°到-9°附近有一个重叠带。有的文档会说这个重叠是为了保证近距离覆盖无盲区但副作用是落在重叠带附近的点同时靠近上下两条线的角度分线时会有少量“串线”风险。每两条相邻线之间的垂直角分辨率大约在0.33°到0.35°之间。这个角度差很小意味着pitch角度的计算精度要足够稳尤其远距离点云一点点数值误差就可能把点归到相邻线上。2.3 用出厂标定角度表而不是用聚类网上不少教程会推荐用K-Means对pitch角度做64类聚类把每个点聚到某个类里当成分线结果。我第一次也是这么干的后来发现这在很多时候并不可靠。原因很简单激光发射器的安装角度是出厂固化的物理属性不是数据驱动的统计规律。K-Means是拿当前帧点的分布去凑64个中心遇到场景里有大片地面、建筑立面点的pitch分布会严重偏向某个区间聚类中心会整体偏移分出来的“线”和真实的物理线束对不上。正确的做法是维护一张长度64的角度表这张表来自传感器手册里的垂直角度标定值。每个点的pitch算出来后去角度表里找最近的那个角度对应的索引就是线束ID。如果没有现成的标定表可以先用近似方式生成一组角度作为参考def approx_hdl64e_angles(): # 上组 32 线2° 到 -8.67° upper np.linspace(2.0, -8.67, 32) # 下组 32 线-8.87° 到 -24.97° lower np.linspace(-8.87, -24.97, 32) angles_deg np.concatenate([upper, lower]) return angles_deg工程上线时建议以实际传感器的出厂标定文件为准。这就是为什么很多成熟代码库把角度表单独放成一个配置文件而不是写死在分线函数里。3. 实战代码用NumPy把bin文件拆成64条线束3.1 整体流程分线任务可以拆成四步读取bin文件得到(N, 4)的点云数组计算每个点的pitch角度在64维角度表中找到每个点最近的角度输出线束ID数组供后续可视化、Range Image、地面分割使用下面这份代码就是完整实现核心逻辑全部向量化不需要对12万个点写for循环。import numpy as np def compute_pitch(points): # points: (N, 4) 或 (N, 3) xy2 points[:, 0] ** 2 points[:, 1] ** 2 r np.sqrt(xy2 points[:, 2] ** 2) pitch np.arcsin(points[:, 2] / r) return pitch def assign_line_id(pitch, angle_table_rad): # 角度表按从小到大排序 sorted_angles np.sort(angle_table_rad) # 每个 pitch 在角度表中的插入位置 pos np.searchsorted(sorted_angles, pitch) pos np.clip(pos, 1, len(sorted_angles) - 1) left_angle sorted_angles[pos - 1] right_angle sorted_angles[pos] left_diff np.abs(pitch - left_angle) right_diff np.abs(right_angle - pitch) # 哪个近就选哪个 line_id np.where(left_diff right_diff, pos - 1, pos) return line_id def split_kitti_bin_to_lines(bin_path, angle_table_rad): scan np.fromfile(bin_path, dtypenp.float32).reshape(-1, 4) # 过滤无效点 valid np.isfinite(scan[:, 0]) np.isfinite(scan[:, 1]) np.isfinite(scan[:, 2]) valid np.linalg.norm(scan[:, :3], axis1) 0.1 scan scan[valid] pitch compute_pitch(scan) line_id assign_line_id(pitch, angle_table_rad) # 按线束维度组织点云 lines {i: scan[line_id i] for i in range(len(angle_table_rad))} return scan, line_id, lines3.2 为什么用searchsorted而不是全距离矩阵最直观的分线思路是计算每个点和64个角度的差然后取argmin。但假设一帧有12万个点构造一个(120000, 64)的差矩阵需要约61MB内存单帧还好跑序列时一帧一帧累积释放开销不小。而且实际情况里pitch角在(-25°, 2°)之间分布很集中直接用全矩阵很浪费。searchsorted先在排序后的角度表里二分查找插入位置再只比较左右两个邻居复杂度从O(N*64)降到了O(N*log64)内存占用也几乎为零。这里有个边界细节searchsorted返回的pos表示“pitch应该插到哪个位置之前”pos-1是左边最近角度pos是右边最近角度。如果pitch小于角度表的最小值或大于最大值clip会把pos限制在边界内强制分到第一线或最后一线上。对于正常雷达数据不会触发但如果点云里混入了异常点这个处理能让结果不至于越界报错。3.3 输出结构保留索引比直接切点更重要上面代码用了lines {i: scan[line_id i] ...}这种字典方式方便直观查看每条线。但实际工程中我建议额外保存一份全局的line_id数组。因为后续无论是做Range Image生成、逐线可视化还是基于线束的近邻搜索都需要“原有点云顺序”和“线束ID”一一对应。如果你把所有点按线束重新排列原顺序就丢了再想跟别的数据批次对齐会很麻烦。保存时可以用npy格式np.save(000000_lines.npy, line_id.astype(np.uint8))uint8最大255足够装下64而且比int32省了4倍空间。跑完一整个序列再回头看这个习惯能帮你省下不少磁盘和加载时间。4. 分线之后能做什么三个高频应用场景4.1 逐线可视化检查数据质量分完线之后第一件事我建议先做逐线可视化。给每条线分配一个固定颜色把同一线束的点渲染成同色几十秒就能看出分线效果好不好。如果第n条线和第n1条线的颜色在远端点互相穿插得很厉害说明角度表跟这台传感器标定有偏差。比如某条线在垂直方向整体偏了0.1°远距离就会偏出好几米颜色带会明显歪掉。用Open3D可以很快出图import open3d as o3d def visualize_lines(scan, line_id, num_lines64): pcd o3d.geometry.PointCloud() pcd.points o3d.utility.Vector3dVector(scan[:, :3]) cmap plt.get_cmap(gist_rainbow) colors cmap(line_id / max(num_lines - 1, 1))[:, :3] pcd.colors o3d.utility.Vector3dVector(colors) o3d.visualization.draw_geometries([pcd])运行起来会看到上下两组线束以不同颜色铺开。上组在车周围形成较平缓的覆盖下组则密集地扫向地面。如果颜色分层清晰每条线在俯视图上呈连贯的径向条纹说明分线逻辑没问题。4.2 生成Range Image作为深度学习输入很多点云神经网络不直接吃原始3D坐标而是吃鸟瞰图或者Range Image。Range Image的思路是把点云投影到以线束为行、水平角度为列的固定网格上行索引线束ID0到63列索引水平方位角等分比如2048列像素值每个格子里存放最近的点的距离或者x、y、z、intensity多通道生成方式很简单核心是一个np.minimum.at解决“多个点投影到同一个像素”时的最近距离取舍def build_range_image(scan, line_id, num_lines64, num_cols2048): yaw np.arctan2(scan[:, 1], scan[:, 0]) col_idx ((yaw np.pi) / (2 * np.pi) * num_cols).astype(int) col_idx np.clip(col_idx, 0, num_cols - 1) distances np.linalg.norm(scan[:, :3], axis1) img np.full((num_lines, num_cols), np.inf, dtypenp.float32) np.minimum.at(img, (line_id, col_idx), distances) img[img np.inf] 0.0 return img这里有个细节水平角度从-π到π映射到[0, 2048)的列索引时(yaw np.pi)把负角度平移成正数再乘列数归一化。不做这一步左半边点的列索引会变成负数。有了Range Image之后就能用2D卷积网络处理或者用图像语义分割的思路对点云做识别。这是分线带来的最直接价值把无序点云变成了有序的网格数据。4.3 基于线束的地面分割与目标聚类分线另一个好处是地面分割会简单很多。64线雷达中真正打到地面的主要是下组那30来条线。地面点在局部区域内高度变化平缓同一线束上远端点和近端点的高度差基本符合一个平滑曲线。最常见的做法是从下往上取几条线比如line 60、61、62、63用这些最低线束的点估计地面高度模型再把高度超过阈值一定范围的点判为非地面点。def simple_ground_removal(scan, line_id, ground_lines(60, 61, 62, 63), thresh0.3): is_ground_candidate np.isin(line_id, ground_lines) ground_z scan[:, 2][is_ground_candidate] if ground_z.size 0: return np.ones(len(scan), dtypebool) ground_level np.percentile(ground_z, 50) # 用中位数抗离群点 is_non_ground (scan[:, 2] - ground_level) thresh return is_non_ground这个思路比全局RANSAC平面拟合快得多而且不需要每次迭代随机采样。它本质是用了“同一根线束朝向固定”这个物理约束把三维拟合问题简化成了高度比较问题。路面起伏、坡道场景下适当增加thresh就能稳住分割效果。5. 踩坑记录分线过程中最容易翻车的几个细节5.1 第四列不是反射率而是intensity读取顺序别搞反很多刚接触KITTI的同学会把第四列当成“标签”或者“类别ID”实际上它就是激光反射强度。而且不同版本KITTI数据里intensity的取值范围并不统一。有些数据里是0到1的小数有些是0到255的整数还有些直接是未标定的原始强度值。分线本身不依赖这一列但做可视化或者给模型加特征时最好先统计一下当前数据集的intensity分布再决定要不要归一化。我见过有人直接把255当成最大值做归一化结果遇到0到1的数据整套特征全被压缩到了0附近。5.2 无效点过滤要放在分线之前bin文件里偶尔会出现NaN或者Inf的点尤其在个别扫描帧的边缘区域。如果不先过滤np.arcsin会直接产出NaNsearchsorted遇到NaN也会一路传导最后分出来的line_id全是乱的。过滤条件至少要包含三件事坐标值有限、距离大于一个极小阈值比如0.1米避免传感器自身的近场噪声点、z值没有明显超出雷达垂直视场合理范围。valid np.isfinite(scan[:, 0]) np.isfinite(scan[:, 1]) np.isfinite(scan[:, 2]) valid np.linalg.norm(scan[:, :3], axis1) 0.1这一步建议在读取后立刻执行。别偷懒等到分线后再清理因为分线阶段的索引变化会和后面所有下游逻辑纠缠在一起。5.3 重叠带导致的分线串扰HDL-64E上下两组在-8°到-9°附近有重叠落在重叠带里的点可能跟两条线的角度差都在0.2°以内。这时候无论选哪条都有一半的可能是“错”的。解决思路有两个。一是直接接受这个误差因为重叠带本身极窄影响点数量在整个点云中占比很低。二是用点的深度连续性做后处理如果帧相邻时刻的扫描角度已知可以用水平方位角的顺序来消除歧义但这在bin文件里做不到因为时间信息已经丢了。所以结论是KITTI bin文件的分线只能做到“几何最近”无法做到绝对精确。这在绝大多数分割、检测任务中都够用但如果你做的方向对线束精度要求极高比如标定、雷达内参估计建议换用带完整时间戳的rosbag数据。5.4 为什么有人用K-Means会得到“看着合理但一换场景就崩”的结果用K-Means分线本质上是在“猜”当前帧最可能的群组分布。它聚类出来的结果可能视觉上看每条线都很整齐但换一帧点云分布差异大的数据比如空旷高速路vs.密集城区聚类中心就会飘。激光雷达的物理参数是固定的不因场景而变。正确做法永远是“从标定表里读角度再把点归类”而不是“从点里重新学出角度”。这也是我反复建议维护一张角度表配置文件的原因一帧一帧地跑稳定性和可复现性都远高于聚类方案。最后分享一点实用经验我在实际跑分线的时候最深的体会是先把角度表校准对后面所有流程都会很顺角度表一旦有偏差后面做Range Image、做地面分割问题会一环扣一环地冒出来。一个小技巧如果你手头有KITTI原始数据或者rosbag里的点云可以先统计一帧点云的pitch直方图看峰值位置和角度表的差值。峰值偏移量可以当作风角补偿值加到角度表里分线效果会立刻改善。没有原始数据时用近似角度表也能跑但记得给每个线束留一个可配置的偏移项。另外这套分线逻辑不只适用于KITTI的HDL-64E。换个传感器只需要把角度表改成对应型号的垂直标定角整个代码框架几乎不用动。保存的line_id直接以uint8格式落盘往后所有下游任务读取都方便。这就是把“分线”这一步做扎实带来的长期收益。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。