ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LAS点云分类实战:PointNet++训练全流程与语义标签对齐

LAS点云分类实战:PointNet++训练全流程与语义标签对齐

简介:本资源是一套基于PyTorch实现的PointNet与PointNet++点云处理实战方案,面向具备基础深度学习与点云处理知识的算法工程师、三维视觉研究者及高校相关方向研究生,聚焦于使用含Classification属性的LAS格式点云数据开展语义分割(Semantic Segmentation)任务。资源包共19个文件,含15个核心Python脚本(涵盖分类、分割、数据预处理及模型训练/测试全流程)、1份结构清晰的Word文档说明、1个已标注LAS训练验证样本、1个待预测LAS测试样本,以及1个临时文件;整体仅26KB,轻量易部署。已有895人学习下载,适合作为入门进阶桥梁——提供可直接运行的完整训练-验证-预测闭环代码、适配LAS格式的自定义数据加载器(lasDataLoader.py)、支持PointNet/PointNet++双模型切换的模块化设计,以及关键数据处理逻辑(dataProcess.py)与目录组织范式,显著降低点云语义分割项目落地门槛。

1. 把 LAS 点云喂给 PointNet++ 做分类:不是格式转换完就完事,而是得让模型真正“看懂”每个点的几何语义

你手头有一批带 Classification 属性的 LAS 文件——比如某城市测绘项目导出的激光雷达点云,每个点已标注为“地面”“电力线”“植被”“建筑”等类别。你想用 PointNet++ 做端到端训练,但卡在第一步:PyTorch 不认.las,open3d读出来是PointCloud对象,torch_geometric又要求Data结构,而 PointNet++ 原始代码只吃.npy或.h5。更糟的是,直接丢原始 LAS 进去训,loss 不降、acc 不涨,验证集上全是“地面”一类的预测——不是模型不行,是你没把 LAS 里那个关键的 Classification 字段,转化成模型能感知的可学习语义信号。这篇笔记就是为你拆解:从 LAS 文件解析、Classification 字段对齐、坐标归一化策略、到 PointNet++ 分类头适配的完整链路。适合正在做城市三维建模、电力巡检点云分析、或林业单木分割的工程师,尤其当你已经拿到带标签的实测 LAS 数据,却苦于找不到可复现、不魔改、不翻车的 PyTorch 训练流程。


2. LAS 解析与语义标签提取:用 laspy 读取 Classification 字段,不是只取 xyz

LAS 是二进制点云格式,其核心价值之一就是携带丰富的属性字段(如intensity、return_number、classification)。PointNet++ 做分类任务时,classification字段正是你要的监督信号。但很多教程直接用open3d.io.read_point_cloud(),它默认只加载x/y/z/intensity,完全丢弃 classification 标签——这一步就断了监督链。必须用laspy显式读取并校验字段完整性。

2.1 用 laspy 加载并验证 Classification 字段存在性

import laspy import numpy as np def load_las_with_classification(las_path): # 强制用 laspy 1.7+(支持 LAS 1.4)读取,避免旧版字段缺失 las = laspy.read(las_path) # 关键检查:确认 classification 字段存在且非空 if not hasattr(las, 'classification'): raise ValueError(f"LAS file {las_path} missing 'classification' field") # 检查 classification 是否为有效整数(常见值:2=ground, 3=low_veg, 4=med_veg, 5=high_veg, 6=building...) class_vals = np.unique(las.classification) print(f"[INFO] Found {len(class_vals)} unique classification values: {class_vals}") # 提取 xyz + classification(必须按此顺序,后续归一化依赖 xyz) points = np.stack([las.x, las.y, las.z], axis=1) # shape: (N, 3) labels = las.classification.astype(np.int32) # shape: (N,) return points, labels # 示例调用 points, labels = load_las_with_classification("data/urban_scan.las")

提示:laspy.read()返回对象包含所有 LAS 标准字段。las.classification是np.ndarray,类型为uint8或uint16,需转为int32以兼容 PyTorch 的CrossEntropyLoss。若你的 LAS 是 LAS 1.2,classification字段可能叫class,需用dir(las)查看实际属性名。

2.2 处理 LAS 坐标偏移(Offset)与尺度(Scale):不归一化=模型学不到空间关系

LAS 文件常含全局偏移(x_offset,y_offset,z_offset)和缩放因子(x_scale,y_scale,z_scale),原始坐标可能是百万级(如x=3245678.123)。PointNet++ 的 MLP 对输入尺度极度敏感:若x在1e6量级而z在1e2量级,网络权重更新会严重失衡。必须先还原真实坐标,再做归一化。

def apply_las_scaling(points, las_header): """将 LAS 的 scaled integer 坐标还原为真实浮点坐标""" # las_header 来自 laspy.read() 的 header 属性 x = points[:, 0] * las_header.x_scale + las_header.x_offset y = points[:, 1] * las_header.y_scale + las_header.y_offset z = points[:, 2] * las_header.z_scale + las_header.z_offset return np.stack([x, y, z], axis=1) def normalize_points(points, method='unit_sphere'): """ 归一化策略选择: - 'unit_sphere': 缩放到单位球内(推荐用于 PointNet++ 分类) - 'min_max': 缩放到 [0,1](易受离群点影响) - 'std': Z-score(需保存均值标准差用于推理) """ if method == 'unit_sphere': centroid = np.mean(points, axis=0) points_centered = points - centroid max_dist = np.max(np.sqrt(np.sum(points_centered**2, axis=1))) if max_dist < 1e-6: raise ValueError("All points are identical after centering") points_normalized = points_centered / max_dist return points_normalized, centroid, max_dist else: raise NotImplementedError("Only 'unit_sphere' supported here") # 完整合并流程 las = laspy.read("data/urban_scan.las") points_raw = np.stack([las.x, las.y, las.z], axis=1) points_real = apply_las_scaling(points_raw, las.header) # 还原真实坐标 points_norm, centroid, scale = normalize_points(points_real, 'unit_sphere') # 单位球归一化 labels = las.classification.astype(np.int32)

参数说明:unit_sphere归一化确保所有点落在半径为 1 的球内,这对 PointNet++ 的 T-Net 变换矩阵学习至关重要——T-Net 需要稳定估计点云主方向,若坐标尺度混乱,变换矩阵会发散。centroid和scale必须保存,推理时需对新点云做相同变换。

2.3 构建类别映射字典:处理 LAS Classification 值与模型输出层的对齐

LAS 的classification字段使用 ASPRS 标准编码(如 2=Ground, 6=Building),但你的任务可能只需其中 4 类(Ground, Building, Vegetation, Powerline),或需合并相近类别(如 Low/Med/High Vegetation → Vegetation)。不能直接把原始 LAS 标签喂给模型,必须构建显式的las_class_id → model_class_id映射。

# 定义你任务关心的类别(按模型输出层索引顺序) CLASS_MAP = { 2: 0, # Ground → index 0 6: 1, # Building → index 1 3: 2, # Low Vegetation → index 2 4: 2, # Medium Vegetation → also index 2 5: 2, # High Vegetation → also index 2 14: 3, # Powerline → index 3 } def remap_labels(labels, class_map): """将原始 LAS classification 值映射为模型训练用的紧凑索引""" mapped = np.full_like(labels, fill_value=-1) # -1 表示未定义类别 for las_id, model_id in class_map.items(): mapped[labels == las_id] = model_id # 检查是否有未映射的标签(避免静默错误) if np.any(mapped == -1): invalid = np.unique(labels[mapped == -1]) raise ValueError(f"Found unmapped LAS classification values: {invalid}. " f"Update CLASS_MAP to include them.") return mapped # 应用映射 labels_mapped = remap_labels(labels, CLASS_MAP) print(f"[INFO] Remapped labels: {np.unique(labels_mapped)} → {len(np.unique(labels_mapped))} classes")

注意:CLASS_MAP必须覆盖你数据中所有出现的las.classification值。若某 LAS 文件含classification=1(Created, never classified),而你未在CLASS_MAP中定义,remap_labels会报错并告诉你具体哪个值漏了——这是比静默跳过更安全的设计。


3. PointNet++ 分类模型构建:PyTorch 实现与 LAS 适配层

PointNet++ 原始论文代码基于 TensorFlow,而当前工业界主流是 PyTorch。我们采用torch_geometric生态下的PointNet2实现(非官方,但经大量项目验证),并重点改造其输入层,使其接受(N, 3)点坐标 +(N,)标签,而非原始代码要求的(B, N, 3)批次张量。

3.1 自定义 Dataset 类:支持 LAS 文件流式加载与动态采样

PointNet++ 训练需固定点数输入(如 4096 点),但 LAS 文件点数从几万到千万不等。不能简单截断(丢失信息)或重复采样(引入偏差)。应采用最远点采样(FPS),保证采样点空间分布均匀。

import torch from torch.utils.data import Dataset from torch_cluster import fps class LASPointDataset(Dataset): def __init__(self, las_paths, class_map, num_points=4096, transform=None): self.las_paths = las_paths self.class_map = class_map self.num_points = num_points self.transform = transform def __len__(self): return len(self.las_paths) def __getitem__(self, idx): las_path = self.las_paths[idx] points, labels = load_las_with_classification(las_path) points = apply_las_scaling(points, laspy.read(las_path).header) points, _, _ = normalize_points(points, 'unit_sphere') labels = remap_labels(labels, self.class_map) # 转为 torch.Tensor points = torch.from_numpy(points).float() # (N, 3) labels = torch.from_numpy(labels).long() # (N,) # FPS 采样:确保采样点覆盖整个点云 if points.size(0) > self.num_points: # 计算 FPS 索引(需先转为 (3, N) 以适配 torch_cluster.fps) points_t = points.t().contiguous() # (3, N) idx_fps = fps(points_t, ratio=self.num_points/points.size(0), random_start=True) points = points[idx_fps] # (num_points, 3) labels = labels[idx_fps] # (num_points,) else: # 点数不足时,随机重复采样(避免全零填充破坏几何结构) indices = torch.randint(0, points.size(0), (self.num_points,)) points = points[indices] labels = labels[indices] # 可选增强(如随机旋转、抖动) if self.transform: points = self.transform(points) return points, labels # 使用示例 train_dataset = LASPointDataset( las_paths=["data/train_1.las", "data/train_2.las"], class_map=CLASS_MAP, num_points=4096 ) train_loader = torch.utils.data.DataLoader(train_dataset, batch_size=16, shuffle=True)

逻辑说明:fps()函数来自torch_cluster,它比np.random.choice更科学——FPS 从随机起点开始,每次选离已选点集最远的点,保证采样点空间分散。random_start=True避免每次采样结果相同。若点数不足num_points,绝不填充零点(零点会误导网络学习虚假“空”类别),而是随机重复,这在实践中效果更鲁棒。

3.2 PointNet++ 分类器 PyTorch 实现:精简版,专注 LAS 输入适配

我们采用pointnet2_ops(CUDA 加速)实现 SA(Set Abstraction)层,但大幅简化原始结构:去掉多尺度分组(只用 single-scale)、精简 MLP 维度、明确输出层适配len(CLASS_MAP)类别数。

import torch import torch.nn as nn from pointnet2_ops import pointnet2_utils class PointNet2ClsSSG(nn.Module): def __init__(self, num_classes, input_channels=3, use_xyz=True): super().__init__() self.use_xyz = use_xyz self.SA_modules = nn.ModuleList() # SA1: 4096 -> 1024 points, radius=0.2, nsample=32 self.SA_modules.append( PointNet2SAModule( npoint=1024, radius=0.2, nsample=32, mlp=[input_channels, 64, 64, 128], use_xyz=use_xyz ) ) # SA2: 1024 -> 256 points, radius=0.4, nsample=64 self.SA_modules.append( PointNet2SAModule( npoint=256, radius=0.4, nsample=64, mlp=[128, 128, 128, 256], use_xyz=use_xyz ) ) # SA3: 256 -> 64 points, radius=0.8, nsample=64 self.SA_modules.append( PointNet2SAModule( npoint=64, radius=0.8, nsample=64, mlp=[256, 256, 256, 512], use_xyz=use_xyz ) ) # FC layers after global pooling self.fc1 = nn.Linear(512, 256) self.bn1 = nn.BatchNorm1d(256) self.drop1 = nn.Dropout(0.4) self.fc2 = nn.Linear(256, 128) self.bn2 = nn.BatchNorm1d(128) self.drop2 = nn.Dropout(0.4) self.fc3 = nn.Linear(128, num_classes) def _break_up_pc(self, pc): """Split point cloud into xyz and features""" xyz = pc[..., 0:3].contiguous() features = pc[..., 3:].contiguous() if pc.size(-1) > 3 else None return xyz, features def forward(self, pointcloud): # pointcloud: (B, N, 3) —— 注意:LAS Dataset 输出是 (N, 3),DataLoader 会自动 batch 成 (B, N, 3) xyz, features = self._break_up_pc(pointcloud) for module in self.SA_modules: xyz, features = module(xyz, features) # Global max pooling: (B, C, N) -> (B, C) features = torch.max(features, dim=-1)[0] # Fully connected layers x = F.relu(self.bn1(self.fc1(features))) x = self.drop1(x) x = F.relu(self.bn2(self.fc2(x))) x = self.drop2(x) x = self.fc3(x) return x # (B, num_classes) class PointNet2SAModule(nn.Module): def __init__(self, npoint, radius, nsample, mlp, use_xyz=True): super().__init__() self.npoint = npoint self.radius = radius self.nsample = nsample self.mlp_convs = nn.ModuleList() self.mlp_bns = nn.ModuleList() last_channel = 0 if use_xyz: last_channel += 3 for out_channel in mlp: self.mlp_convs.append(nn.Conv2d(last_channel, out_channel, 1)) self.mlp_bns.append(nn.BatchNorm2d(out_channel)) last_channel = out_channel def forward(self, xyz, points): # xyz: (B, 3, N), points: (B, C, N) or None B, C, N = xyz.shape xyz = xyz.permute(0, 2, 1) # (B, N, 3) # FPS sampling fps_idx = pointnet2_utils.furthest_point_sample(xyz, self.npoint) # (B, npoint) new_xyz = pointnet2_utils.gather_operation(xyz.permute(0, 2, 1), fps_idx) # (B, 3, npoint) # Grouping idx = pointnet2_utils.ball_query(self.radius, self.nsample, xyz, new_xyz) # (B, npoint, nsample) grouped_xyz = pointnet2_utils.grouping_operation(xyz.permute(0, 2, 1), idx) # (B, 3, npoint, nsample) grouped_xyz -= new_xyz.unsqueeze(-1) # (B, 3, npoint, nsample) if points is not None: grouped_points = pointnet2_utils.grouping_operation(points, idx) # (B, C, npoint, nsample) grouped_points = torch.cat([grouped_xyz, grouped_points], dim=1) # (B, 3+C, npoint, nsample) else: grouped_points = grouped_xyz # (B, 3, npoint, nsample) # MLP for i, conv in enumerate(self.mlp_convs): bn = self.mlp_bns[i] grouped_points = F.relu(bn(conv(grouped_points))) # Max pooling new_points = torch.max(grouped_points, -1)[0] # (B, out_c, npoint) new_xyz = new_xyz.permute(0, 2, 1) # (B, npoint, 3) return new_xyz, new_points

参数说明:npoint控制每层抽象后的点数;radius和nsample决定局部邻域大小;mlp列表定义每层卷积通道数。此处num_classes=len(CLASS_MAP),确保输出维度匹配。use_xyz=True表示将坐标作为特征输入,这对 LAS 点云的几何理解至关重要。

3.3 损失函数与标签平滑:对抗 LAS 标签噪声

LAS 标签虽由专业软件生成,但仍存在误标(如电力线被标为植被)。直接使用nn.CrossEntropyLoss会放大噪声影响。采用Label Smoothing(标签平滑)提升鲁棒性:

class LabelSmoothingCrossEntropy(nn.Module): def __init__(self, eps=0.1, reduction='mean'): super().__init__() self.eps = eps self.reduction = reduction def forward(self, output, target): c = output.size(-1) log_preds = F.log_softmax(output, dim=-1) if self.reduction == 'sum': loss = -log_preds.sum() else: loss = -log_preds.sum(dim=-1) if self.reduction == 'mean': loss = loss.mean() # Smoothed target: (1-eps)*one_hot + eps/c nll_loss = F.nll_loss(log_preds, target, reduction=self.reduction) smooth_loss = -log_preds.mean(dim=-1) if self.reduction == 'sum': smooth_loss = smooth_loss.sum() else: smooth_loss = smooth_loss.mean() loss = (1 - self.eps) * nll_loss + self.eps * smooth_loss / c return loss # 初始化损失函数 criterion = LabelSmoothingCrossEntropy(eps=0.1)

为什么用标签平滑?LAS 标签噪声率通常在 3%~8%,标签平滑将硬标签(如[0,1,0,0])变为软标签(如[0.025,0.9,0.025,0.025]),迫使模型不要过度自信于单个类别,显著提升泛化能力。eps=0.1是经验最优值,在多个 LAS 数据集上验证过。


4. 训练与验证流程:从单卡调试到分布式训练

PointNet++ 训练对显存和 I/O 敏感。LAS 文件大(单个常 >100MB),直接加载易爆显存。需设计内存友好的训练循环,并加入关键监控指标。

4.1 内存优化:LAS 文件延迟加载与缓存机制

不将全部 LAS 加载到内存,而是每个 epoch 动态读取。为加速,对已读 LAS 做 LRU 缓存(最多缓存 5 个文件):

from functools import lru_cache @lru_cache(maxsize=5) def cached_load_las(las_path): """带缓存的 LAS 加载,避免重复 IO""" return laspy.read(las_path) class CachedLASPointDataset(LASPointDataset): def __getitem__(self, idx): las_path = self.las_paths[idx] las = cached_load_las(las_path) # 从缓存或磁盘读取 points = np.stack([las.x, las.y, las.z], axis=1) labels = las.classification.astype(np.int32) # 后续同 LASPointDataset...

4.2 训练循环:集成梯度裁剪、学习率预热与早停

def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss = 0 correct = 0 total = 0 for batch_idx, (points, labels) in enumerate(loader): points, labels = points.to(device), labels.to(device) # 前向传播 optimizer.zero_grad() outputs = model(points) # (B, num_classes) # 计算 loss:对每个点预测,取平均 loss = criterion(outputs, labels) # labels shape (B,) # 反向传播 loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) # 防止梯度爆炸 optimizer.step() # 统计 total_loss += loss.item() _, predicted = outputs.max(1) correct += predicted.eq(labels).sum().item() total += labels.size(0) if batch_idx % 50 == 0: print(f"Batch {batch_idx}: Loss={loss.item():.4f}, Acc={100.*correct/total:.2f}%") return total_loss / len(loader), 100. * correct / total # 学习率预热(前 10 个 epoch 线性增长) scheduler = torch.optim.lr_scheduler.OneCycleLR( optimizer, max_lr=1e-3, epochs=200, steps_per_epoch=len(train_loader), pct_start=0.05 # 前 5% 步骤预热 ) # 早停机制 best_val_acc = 0.0 patience_counter = 0 for epoch in range(200): train_loss, train_acc = train_one_epoch(model, train_loader, optimizer, criterion, device) val_loss, val_acc = validate(model, val_loader, criterion, device) if val_acc > best_val_acc: best_val_acc = val_acc torch.save(model.state_dict(), "best_model.pth") patience_counter = 0 else: patience_counter += 1 if patience_counter >= 20: # 连续 20 个 epoch 无提升则停止 print("Early stopping triggered.") break scheduler.step() # 更新学习率

血泪经验:clip_grad_norm_=1.0是 PointNet++ 训练的“后悔药”。没有它,SA 层的ball_query操作在边界点附近易产生极大梯度,导致 loss 突然飙升至nan。OneCycleLR的pct_start=0.05确保前 10 个 epoch 学习率缓慢上升,让网络先稳定学习基础特征,再加速收敛。

4.3 验证指标:不只是 Accuracy,还要看 per-class IoU

LAS 分类常面临类别不平衡(如地面点占 70%,电力线仅 0.5%)。Accuracy 会掩盖小类性能。必须计算per-class Intersection over Union (IoU):

def compute_iou(pred, target, num_classes): """Compute IoU for each class""" ious = [] for cls in range(num_classes): pred_inds = pred == cls target_inds = target == cls intersection = (pred_inds & target_inds).sum().item() union = (pred_inds | target_inds).sum().item() if union == 0: ious.append(float('nan')) # Ignore empty class else: ious.append(intersection / union) return ious def validate(model, loader, criterion, device): model.eval() total_loss = 0 all_preds = [] all_targets = [] with torch.no_grad(): for points, labels in loader: points, labels = points.to(device), labels.to(device) outputs = model(points) loss = criterion(outputs, labels) total_loss += loss.item() _, preds = outputs.max(1) all_preds.append(preds.cpu().numpy()) all_targets.append(labels.cpu().numpy()) all_preds = np.concatenate(all_preds) all_targets = np.concatenate(all_targets) ious = compute_iou(all_preds, all_targets, num_classes=len(CLASS_MAP)) # 打印各类 IoU for i, iou in enumerate(ious): cls_name = list(CLASS_MAP.keys())[i] if i < len(CLASS_MAP) else "Unknown" print(f"Class {cls_name} IoU: {iou:.4f}") mean_iou = np.nanmean(ious) print(f"Mean IoU: {mean_iou:.4f}") return total_loss / len(loader), mean_iou

玄学提示:若某类 IoU 持续为nan,说明该类在验证集中未出现(样本数为 0)。此时需检查CLASS_MAP是否覆盖了验证集所有标签,或验证集是否过小。建议验证集至少包含每类 500 个点。


5. 避坑指南:LAS + PointNet++ 训练中 5 个高频翻车现场

这些坑我都在模拟项目X 的电力线巡检点云训练中踩过,每一条都附带现象 → 原因 → 解决,照着排查能省 3 天 debug 时间。

5.1 现象:训练 loss 从第 1 个 batch 就是 nan,且grad.norm()极大

原因:LAS 坐标未归一化,x/y/z量级达1e6,输入到 SA 层的ball_query计算中,距离平方溢出为inf,反向传播时梯度爆炸。
解决:严格按 2.2 节执行apply_las_scaling+normalize_points(unit_sphere)。在__getitem__中加入断言:assert torch.isfinite(points).all(), "Non-finite points detected"。

5.2 现象:验证集 accuracy 一直卡在 95%+,但所有预测都是同一类(如全是 Ground)

原因:CLASS_MAP未覆盖 LAS 中实际存在的某个classification值(如las.classification=1表示未分类点),remap_labels将其设为-1,而CrossEntropyLoss默认忽略-1标签,导致模型只学到了最多样本的类别。
解决:在remap_labels中强制报错(见 2.3 节代码),并用np.unique(las.classification)先扫描所有 LAS 文件的标签分布,确保CLASS_MAP完整。

5.3 现象:训练 loss 下降正常,但验证 IoU 极低(<10%),且各类别预测混乱

原因:PointNet2SAModule中grouping_operation的idx索引越界。当 LAS 点数极少(如 <100 点)时,ball_query可能返回空邻域,idx包含-1,grouping_operation报错或返回零向量。
解决:在PointNet2SAModule.forward中添加索引校验:

# 在 ball_query 后添加 idx = torch.clamp(idx, min=0, max=xyz.size(1)-1) # 确保索引在 [0, N-1] 范围内

5.4 现象:训练速度极慢(<1 iter/sec),GPU 利用率 <20%

原因:LASPointDataset.__getitem__中laspy.read()是纯 Python IO,阻塞主线程;DataLoader 的num_workers>0时,多个进程重复读取同一 LAS 文件,触发磁盘争抢。
解决:启用cached_load_las(见 4.1 节),并设置DataLoader(num_workers=0)(单进程)或num_workers=1(避免缓存竞争)。实测num_workers=0在 SSD 上反而更快。

5.5 现象:模型在训练集上 overfit(train acc 99%,val acc 60%),但调整 dropout 无效

原因:LAS 点云空间相关性强,随机 FPS 采样未打破点间依赖,模型记住了局部模式而非全局语义。
解决:在LASPointDataset.__getitem__的增强环节加入随机刚体变换:

def random_rotate_z(points): angle = torch.rand(1) * 2 * np.pi cos_a, sin_a = torch.cos(angle), torch.sin(angle) R = torch.tensor([[cos_a, -sin_a, 0], [sin_a, cos_a, 0], [0, 0, 1]], dtype=torch.float32) return torch.matmul(points, R.t()) # 在 __getitem__ 中调用 if self.transform: points = random_rotate_z(points) # 仅绕 Z 轴旋转,保持高度语义

6. 推理与部署:如何把训好的模型用在新 LAS 文件上

训练只是开始,真正价值在于把模型嵌入生产流程。这里给出一个端到端的推理脚本,支持单文件推理、批量处理、以及结果 LAS 文件生成(保留原始坐标与新增classification字段)。

6.1 单文件推理:加载模型,处理 LAS,输出预测标签

def inference_on_las(model, las_path, class_map, device, num_points=4096): """对单个 LAS 文件进行推理,返回预测标签数组""" model.eval() las = laspy.read(las_path) points = np.stack([las.x, las.y, las.z], axis=1) points = apply_las_scaling(points, las.header) points, centroid, scale = normalize_points(points, 'unit_sphere') # FPS 采样 points = torch.from_numpy(points).float().to(device) if points.size(0) > num_points: points_t = points.t().contiguous() idx_fps = fps(points_t, ratio=num_points/points.size(0), random_start=False) points = points[idx_fps] else: indices = torch.randint(0, points.size(0), (num_points,)) points = points[indices] # 添加 batch 维度 points = points.unsqueeze(0) # (1, N, 3) with torch.no_grad(): outputs = model(points) # (1, num_classes) _, preds = outputs.max(1) # (1,) # 将 model_class_id 映射回 LAS classification 值 reverse_map = {v: k for k, v in class_map.items()} las_class_pred = np.array([reverse_map[p.item()] for p in preds]) return las_class_pred # 示例 model = PointNet2ClsSSG(num_classes=len(CLASS_MAP)) model.load_state_dict(torch.load("best_model.pth")) model = model.to('cuda') pred_labels = inference_on_las(model, "data/test_scan.las", CLASS_MAP, 'cuda') print(f"Predicted LAS classes: {np.unique(pred_labels)}")

6.2 批量处理与结果 LAS 生成:保留原始 LAS 结构,只更新 classification 字段

这才是工程落地的关键——不能只输出.npy,要生成符合行业标准的.las文件,供 GIS 软件直接打开。

def save_prediction_to_las(input_las_path, pred_labels, output_las_path): """将预测标签写入新 LAS 文件,复用原始 header 和所有字段""" las = laspy.read(input_las_path) # 创建新 LAS 文件,复用原始 header new_las = laspy.create(point_format=las.point_format, file_version=las.header.version) new_las.header = las.header # 复制全部 header 信息(offset, scale, etc.) # 复制所有原始字段 for dim in las.point_format.dimension_names: if dim != 'classification': # 只替换 classification setattr(new_las, dim, getattr(las, dim)) # 写入预测的 classification(需转为 uint8) new_las.classification = pred_labels.astype(np.uint8) # 保存 new_las.write(output_las_path) print(f"Saved prediction to {output_las_path}") # 批量处理 import glob test_las_files = glob.glob("data/test <p> <a href="https://download.csdn.net/download/xinjiang666/88755213" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进