
简介这份资源面向希望用PyTorch将PointNet与PointNet落地到自有数据的点云学习者与算法工程师重点解决带Classification属性的LAS格式点云如何直接用于分类与语义分割训练的问题。包内共19个文件以15个Python脚本为核心覆盖pointnet_cls、pointnet2_cls_ssg、pointnet2_sem_seg、pointnet_utils、pointnet2_utils等模型与工具模块并配有lasDataLoader、lasTrainSS、lasTestSS等数据加载与训练预测脚本另含一份已分类LAS文件用于语义分割训练与验证、一份LAS文件用于预测以及代码结构说明文档压缩包约26KB。已有894人学习下载。读者可据此理解从LAS读取、分类属性解析到数据集构建、模型训练与推理的完整链路并可在PointNet与PointNet之间灵活切换快速迁移到自己的点云项目。1. 从 LAS 到语义分割为什么你的点云训练总在第一步就翻车手里有一份带 Classification 属性的 LAS 点云想直接喂给 PointNet 或 PointNet 做训练结果打开代码一看数据加载器只认 HDF5 或者 txtLAS 文件根本进不去。这不是个别现象我见过太多人卡在这一步点云数据下载了一堆标注也做了最后死在格式转换上。PointNet 和 PointNet 是点云深度学习里绕不开的两个基线。PointNet 用对称函数解决点云无序性问题PointNet 在此基础上引入分层采样和局部特征聚合对语义分割任务更友好。但这两个网络对输入格式有明确要求每个点需要包含坐标信息分割任务还需要每个点的类别标签。LAS 文件里虽然有 Classification 字段但它的组织方式和网络期望的输入之间隔着一层转换。这份资源要解决的就是这个断层。它面向的是手里有 LAS 格式点云、想用 PyTorch 跑 PointNet/PointNet 语义分割的从业者。不管你是做测绘、电力巡检还是自动驾驶场景的点云处理只要数据是 LAS 且带 Classification 属性这套流程就能用。接下来我会把从 LAS 解析到模型训练再到推理验证的完整链路拆开每一步都落到可执行的代码和参数上。2. LAS 解析与 Classification 属性提取从二进制到可用数组2.1 LAS 文件结构与 Classification 字段的读取方式LAS 是 ASPRS 定义的激光雷达点云交换格式本质是二进制文件。它由公共头、可变长度记录和点记录三部分组成。公共头里存了点数量、坐标范围、缩放因子和偏移量点记录里每个点包含 X、Y、Z 坐标、强度、回波信息以及 Classification 字段。Classification 是一个 8 位无符号整数不同值代表不同地物类别。常见编码2 表示地面3 表示低植被4 表示中植被5 表示高植被6 表示建筑物9 表示水体。但这不是强制的很多项目会自定义类别映射。所以拿到 LAS 之后第一件事是确认 Classification 的取值分布而不是直接假设它符合 ASPRS 标准。读取 LAS 最常用的库是 laspy。它比 PDAL 轻量纯 Python 实现安装不折腾。下面这段代码读取 LAS 文件并提取坐标和分类标签import laspy import numpy as np def read_las_to_array(las_path, class_fieldclassification): 读取 LAS 文件返回点坐标数组和分类标签数组 :param las_path: LAS 文件路径 :param class_field: 分类字段名laspy 2.x 中为 classification :return: points (N, 3) float64, labels (N,) int64 las laspy.read(las_path) # 提取 XYZ 坐标laspy 2.x 直接返回 numpy 数组 points np.vstack((las.x, las.y, las.z)).transpose().astype(np.float64) # 提取 Classification 字段 labels np.array(las[class_field], dtypenp.int64) # 打印类别分布确认标签是否合理 unique, counts np.unique(labels, return_countsTrue) print(类别分布) for u, c in zip(unique, counts): print(f 类别 {u}: {c} 个点 ({c/len(labels)*100:.2f}%)) return points, labels逻辑说明laspy.read() 一次性加载整个文件las.x、las.y、las.z 返回的是缩放后的真实坐标不需要手动乘 scale 再加 offset。las[class_field] 直接按字段名索引laspy 2.x 里 Classification 字段名是小写的 classification。打印类别分布是为了让你在转换前就发现标签异常比如全是 0 或者只有一两个类别。参数说明class_field 默认 classification如果你的 LAS 用了非标准字段名需要改成实际名称。points 返回 float64 是为了后续归一化时避免精度损失labels 用 int64 是因为 PyTorch 的 CrossEntropyLoss 要求标签为 int64。2.2 坐标归一化与类别重映射原始 LAS 的坐标通常是 UTM 投影坐标数值在几十万到几百万量级。直接送进网络会导致梯度爆炸或收敛困难。标准做法是减去质心再除以最大范围把坐标缩放到 [-1, 1] 或 [0, 1] 区间。类别重映射是另一个必须做的步骤。PointNet 系列网络的分类头输出维度等于类别数且要求标签从 0 开始连续编号。如果你的 LAS 里类别是 2、3、4、5、6就需要映射成 0、1、2、3、4。映射关系必须保存下来推理时才能还原成原始类别。def normalize_points(points): 将点云坐标归一化到 [-1, 1] 区间 :param points: (N, 3) 原始坐标 :return: (N, 3) 归一化坐标, 质心, 缩放因子 centroid np.mean(points, axis0) points_centered points - centroid max_dist np.max(np.sqrt(np.sum(points_centered ** 2, axis1))) points_normalized points_centered / max_dist return points_normalized, centroid, max_dist def remap_labels(labels, class_mapping): 将原始类别映射为从 0 开始的连续整数 :param labels: (N,) 原始标签 :param class_mapping: dict, 原始类别 - 新类别 :return: (N,) 重映射后的标签, 反向映射 dict remapped np.zeros_like(labels) for original, new in class_mapping.items(): remapped[labels original] new inverse_mapping {v: k for k, v in class_mapping.items()} return remapped, inverse_mapping # 示例假设 LAS 中类别为 2,3,4,5,6 class_mapping {2: 0, 3: 1, 4: 2, 5: 3, 6: 4} points, labels read_las_to_array(your_data.las) points_norm, centroid, scale normalize_points(points) labels_remapped, inv_map remap_labels(labels, class_mapping)逻辑说明归一化用最大距离而不是标准差是因为点云分布不均匀标准差会被密集区域主导。减去质心再除以最大距离保证所有点都在单位球内。类别重映射用字典遍历简单直接类别数少的时候效率足够。inverse_mapping 在推理可视化时用来把预测结果还原成原始类别。参数说明class_mapping 需要根据你实际 LAS 的类别分布来定。如果类别不连续比如只有 2、6、9就映射成 0、1、2。归一化后的坐标范围是 [-1, 1]这是 PointNet 论文里的标准做法。2.3 分块与采样让大场景点云能进显存一整块 LAS 可能有几百万甚至上千万个点直接送进网络显存扛不住。PointNet 的语义分割模式支持对整个场景做推理但训练时通常需要分块。常见做法是把大场景切成固定边长的立方体块每块采样固定数量的点。分块有两个策略按空间网格切分和按点数切分。按空间网格切分能保留局部结构适合建筑物、植被这类有空间聚集性的类别。按点数切分实现简单但可能把同一个物体切散。我一般用空间网格块大小根据点密度来定保证每块有 4096 到 8192 个点。def split_into_blocks(points, labels, block_size10.0, sample_num4096): 将点云按空间网格切块每块采样固定点数 :param points: (N, 3) 归一化坐标 :param labels: (N,) 标签 :param block_size: 块边长归一化后的单位 :param sample_num: 每块采样点数 :return: blocks (M, sample_num, 3), block_labels (M, sample_num) # 计算网格索引 min_coord np.min(points, axis0) grid_indices np.floor((points - min_coord) / block_size).astype(np.int32) # 按网格分组 from collections import defaultdict grid_dict defaultdict(list) for i, idx in enumerate(map(tuple, grid_indices)): grid_dict[idx].append(i) blocks [] block_labels [] for grid_key, indices in grid_dict.items(): if len(indices) sample_num // 2: continue # 点数太少的块丢弃 block_points points[indices] block_lbl labels[indices] # 随机采样或重复采样到固定点数 if len(indices) sample_num: choice np.random.choice(len(indices), sample_num, replaceFalse) else: choice np.random.choice(len(indices), sample_num, replaceTrue) blocks.append(block_points[choice]) block_labels.append(block_lbl[choice]) return np.array(blocks), np.array(block_labels)逻辑说明先算每个点在网格中的索引用 defaultdict 按网格 key 分组。点数不足 sample_num 一半的块直接丢弃避免引入太多填充。点数够就无放回采样不够就有放回采样。这样每块都是固定大小可以直接组成 batch。参数说明block_size 需要根据归一化后的坐标范围调整。如果归一化到 [-1, 1]block_size 设 0.1 到 0.2 比较合适对应实际场景中几米到十几米的块。sample_num 根据显存定4096 是 PointNet 的常用值显存够可以上 8192。3. 搭一个 PyTorch 训练管线Dataset、DataLoader 与模型对接3.1 自定义 Dataset 类的三个关键方法PyTorch 的 Dataset 类需要实现init、len和getitem。点云数据的特殊之处在于每个样本是变长的但分块之后每块点数固定就可以按常规方式组织。import torch from torch.utils.data import Dataset, DataLoader class PointCloudDataset(Dataset): def __init__(self, blocks, labels, transformNone): :param blocks: (M, N, 3) 点云块 :param labels: (M, N) 每点标签 :param transform: 可选的数据增强 self.blocks torch.FloatTensor(blocks) # (M, N, 3) self.labels torch.LongTensor(labels) # (M, N) self.transform transform def __len__(self): return len(self.blocks) def __getitem__(self, idx): points self.blocks[idx] # (N, 3) label self.labels[idx] # (N,) if self.transform: points, label self.transform(points, label) return points, label逻辑说明init里把 numpy 数组转成 tensor避免每次getitem都转换。len返回块的数量。getitem返回单块的点坐标和标签形状分别是 (N, 3) 和 (N,)。transform 用于数据增强比如随机旋转、抖动后面会讲。参数说明blocks 和 labels 是上一步分块函数的输出。如果数据量大init里不要一次性转 tensor可以在getitem里按需转但会增加 IO 开销。我一般数据量在几万块以内都直接转。3.2 数据增强旋转、抖动与随机丢弃点云训练容易过拟合尤其是标注数据少的时候。常用的增强手段有三种绕 Z 轴随机旋转、坐标加高斯噪声、随机丢弃部分点。这三种在 PointNet 论文里都有验证能提升泛化能力。import torch import numpy as np class PointCloudAugment: def __init__(self, rotation_range(-np.pi, np.pi), jitter_sigma0.01, jitter_clip0.05, drop_prob0.1): self.rotation_range rotation_range self.jitter_sigma jitter_sigma self.jitter_clip jitter_clip self.drop_prob drop_prob def __call__(self, points, label): # 绕 Z 轴随机旋转 theta np.random.uniform(*self.rotation_range) rot_matrix torch.tensor([ [np.cos(theta), -np.sin(theta), 0], [np.sin(theta), np.cos(theta), 0], [0, 0, 1] ], dtypetorch.float32) points points rot_matrix.T # 坐标抖动 jitter torch.clamp( torch.randn_like(points) * self.jitter_sigma, -self.jitter_clip, self.jitter_clip ) points points jitter # 随机丢弃点训练时模拟缺失 if self.drop_prob 0: mask torch.rand(len(points)) self.drop_prob points points[mask] label label[mask] return points, label逻辑说明旋转矩阵只绕 Z 轴因为点云场景中 Z 轴通常是垂直方向绕 Z 旋转符合实际场景变化。抖动用 clamp 限制范围避免噪声过大破坏局部结构。随机丢弃模拟点云缺失让网络对不完整输入更鲁棒。参数说明rotation_range 默认全角度如果场景有方向性比如道路可以缩小到 (-np.pi/4, np.pi/4)。jitter_sigma 控制噪声强度0.01 对应归一化坐标下的轻微扰动。drop_prob 一般不超过 0.2太高会丢失太多信息。3.3 模型输出与损失函数的对齐PointNet 和 PointNet 的语义分割模式输出形状是 (B, N, C)其中 B 是 batch sizeN 是点数C 是类别数。标签形状是 (B, N)。CrossEntropyLoss 要求输入 (B, C, N) 或 (BN, C)标签 (BN,)。所以需要做维度变换。import torch.nn as nn def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss 0 for points, labels in dataloader: points points.to(device) # (B, N, 3) labels labels.to(device) # (B, N) # 调整维度PointNet 输出 (B, N, C) - (B, C, N) points points.transpose(2, 1) # (B, 3, N) pred model(points) # (B, N, C) pred pred.transpose(2, 1) # (B, C, N) loss criterion(pred, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() return total_loss / len(dataloader)逻辑说明PointNet 的 forward 期望输入 (B, C, N)所以先 transpose。模型输出 (B, N, C)再 transpose 成 (B, C, N) 以匹配 CrossEntropyLoss。这两次 transpose 是固定操作写进训练循环里就行。参数说明criterion 用 nn.CrossEntropyLoss()默认 reductionmean。如果类别不平衡严重可以加 weight 参数weight 按类别频率的倒数设置。device 根据是否有 GPU 来定有 GPU 就 cuda没有就 cpu。4. 避坑与排查LAS 转点云训练里最容易翻车的五个地方4.1 现象训练 loss 不下降一直卡在某个值附近原因最常见的是标签没有重映射类别编号不连续。比如 LAS 里只有类别 2 和 6没映射成 0 和 1CrossEntropyLoss 的 num_classes 设成 7但实际只有两个类别有梯度其他类别全是零梯度导致输出层学不动。解决在 Dataset 构建之前打印 np.unique(labels)确认标签从 0 开始连续。如果不连续用 2.2 节的 remap_labels 做映射。同时检查模型的 num_classes 是否等于映射后的类别数。4.2 现象显存溢出batch size 降到 1 还是 OOM原因分块时 sample_num 设太大或者没有做归一化坐标数值太大导致中间特征图占用显存暴涨。另一个可能是 DataLoader 的 num_workers 设太高每个 worker 都复制了一份数据。解决先把 sample_num 降到 2048 试一下确认是点数问题还是模型问题。归一化必须做原始 UTM 坐标直接进网络第一层卷积的激活值会非常大。num_workers 设 4 到 8 就够了再高收益递减还吃内存。4.3 现象推理结果全是同一类或者类别预测明显错乱原因归一化参数没有保存。训练时用了质心和缩放因子做归一化推理时如果重新算质心坐标分布和训练时不一致网络看到的输入分布变了输出自然乱。解决把训练集算出的 centroid 和 max_dist 保存成 npy 文件推理时加载同一组参数。如果推理数据是新的 LAS要么用训练集的参数归一化要么重新训练。我一般会在训练脚本里把这两个值存下来推理脚本强制加载。4.4 现象训练集准确率很高验证集惨不忍睹原因数据增强太弱或者没有做增强网络把训练集的每个点都记住了。点云数据标注成本高样本量通常不大过拟合是常态。解决加上 3.2 节的旋转、抖动、随机丢弃。如果还不行加 Dropout 层PointNet 的分类头里通常有 dropout分割头也可以加。另外检查训练集和验证集是不是从同一个 LAS 里切的块如果是验证集没有意义必须按场景划分。4.5 现象LAS 读取报错提示字段不存在或版本不兼容原因laspy 1.x 和 2.x 的 API 不兼容。1.x 里用 las.classification2.x 里用 las[classification] 或 las.classification 属性。另外有些 LAS 文件是 1.0 版本Classification 字段可能不存在或者叫别的名字。解决先确认 laspy 版本pip show laspy 看一下。2.x 推荐用 laspy.read() 而不是 laspy.file.File()。如果字段名不对用 las.point_format.dimension_names 打印所有可用字段名找到实际的分类字段。5. 从训练到推理验证模型是否真的学会了5.1 用混淆矩阵和 IoU 判断分割质量训练 loss 下降不代表模型能用。语义分割任务要看每个类别的 IoU交并比。IoU 低于 0.3 的类别基本不可用需要检查标注质量或者增加该类别的样本。def compute_iou(pred_labels, true_labels, num_classes): 计算每个类别的 IoU :param pred_labels: (N,) 预测标签 :param true_labels: (N,) 真实标签 :param num_classes: 类别数 :return: iou_list, mean_iou iou_list [] for cls in range(num_classes): pred_mask (pred_labels cls) true_mask (true_labels cls) intersection np.sum(pred_mask true_mask) union np.sum(pred_mask | true_mask) if union 0: iou_list.append(float(nan)) else: iou_list.append(intersection / union) mean_iou np.nanmean(iou_list) return iou_list, mean_iou逻辑说明对每个类别分别算交集和并集。union 为 0 表示该类别在预测和真实中都没出现IoU 记为 nan计算均值时忽略。mean_iou 是语义分割的核心指标PointNet 在 S3DIS 数据集上能到 50% 以上你自己的数据如果低于 30%需要排查。参数说明pred_labels 是模型输出取 argmax 后的结果true_labels 是验证集的标签。num_classes 要和训练时一致。5.2 推理脚本的完整流程推理和训练的区别在于不需要计算梯度不需要数据增强需要把分块预测结果拼回完整场景。拼接时用投票法或者直接按块覆盖投票法更稳但慢直接覆盖快但块边界可能有跳变。def inference(model, las_path, centroid, max_dist, class_mapping, device): 对单个 LAS 文件做推理 :param model: 训练好的模型 :param las_path: LAS 文件路径 :param centroid: 训练集质心 :param max_dist: 训练集缩放因子 :param class_mapping: 原始类别到新类别的映射 :return: 每个点的预测类别原始类别 model.eval() points, _ read_las_to_array(las_path) points_norm (points - centroid) / max_dist # 分块 blocks, _ split_into_blocks(points_norm, np.zeros(len(points_norm)), block_size0.15, sample_num4096) all_preds [] with torch.no_grad(): for block in blocks: block_tensor torch.FloatTensor(block).unsqueeze(0).to(device) block_tensor block_tensor.transpose(2, 1) pred model(block_tensor) # (1, N, C) pred_labels pred.argmax(dim2).squeeze(0).cpu().numpy() all_preds.append(pred_labels) # 这里简化处理实际需要记录每个块对应的原始点索引 # 完整实现需要维护索引映射把预测结果填回原始点顺序 inverse_mapping {v: k for k, v in class_mapping.items()} # 假设 all_preds 已经按原始顺序排列 final_labels np.concatenate(all_preds) final_labels_original np.array([inverse_mapping[l] for l in final_labels]) return final_labels_original逻辑说明推理时用训练集的 centroid 和 max_dist 做归一化保证输入分布一致。分块参数和训练时保持一致。argmax 取每个点概率最大的类别。最后用反向映射还原成原始类别编号。参数说明block_size 和 sample_num 必须和训练时一致否则模型看到的局部结构和训练时不匹配。inverse_mapping 从 class_mapping 反转得到。实际部署时还需要维护块索引到原始点索引的映射这里为了简洁省略了完整实现需要在 split_into_blocks 里返回索引。5.3 可视化验证用 Open3D 快速看结果数字指标之外可视化是最直接的验证方式。Open3D 可以按类别着色显示点云一眼就能看出哪些区域分错了。import open3d as o3d import numpy as np def visualize_prediction(points, pred_labels, class_colors): 用 Open3D 可视化预测结果 :param points: (N, 3) 坐标 :param pred_labels: (N,) 预测类别 :param class_colors: dict, 类别 - (R, G, B) 0-1 范围 colors np.zeros((len(points), 3)) for cls, color in class_colors.items(): colors[pred_labels cls] color pcd o3d.geometry.PointCloud() pcd.points o3d.utility.Vector3dVector(points) pcd.colors o3d.utility.Vector3dVector(colors) o3d.visualization.draw_geometries([pcd]) # 示例颜色映射 class_colors { 0: (0.5, 0.5, 0.5), # 地面-灰色 1: (0.0, 0.8, 0.0), # 植被-绿色 2: (0.8, 0.0, 0.0), # 建筑物-红色 3: (0.0, 0.0, 0.8), # 水体-蓝色 }逻辑说明按类别给每个点赋颜色Open3D 的 draw_geometries 会打开一个交互窗口。旋转、缩放看不同角度重点检查建筑物边缘和植被交界处这些地方最容易分错。参数说明class_colors 的 key 要和预测标签的取值对应。颜色值在 0 到 1 之间。如果点太多可视化卡顿可以随机采样一部分点显示。从那以后我每次跑新的点云数据都强制先跑一遍类别分布统计和归一化参数保存这两个步骤花不了五分钟但能省掉后面几小时的排查。希望帮到你。本文还有配套的精品资源点击获取