简介:这份资源是一套基于Python实现的三维点云激光分类项目源码,面向计算机、通信、人工智能、自动化等专业的学生与从业者,可用于毕业设计、课程大作业或进阶学习。项目聚焦点云场景中建筑、树木等地物的自动分类,涵盖KNN近邻搜索、PCA特征分析、SVM分类、表面检测等典型算法模块,并配有使用说明文档与测试数据,帮助读者理解从特征提取到分类输出的完整流程。压缩包共15个文件,以10个py脚本为核心,辅以txt特征向量文件、trees与buildings样本数据、docx说明文档及testdata测试集,整体约5.84MB,结构清晰便于按模块查阅。目前已有78人学习关注。代码经过调试测试,可直接运行,适合基础薄弱者对照学习,也便于能力较强者在此基础上修改调整,实现不同地物分类功能。
1. 三维点云激光分类到底在做什么:从一帧散点到建筑树木的语义标签
手里拿到一帧室外激光扫描数据时,屏幕上就是几百万个带 XYZ 坐标的散点,肉眼能看出哪里是墙、哪里是树冠,但机器不知道。三维点云激光分类要解决的就是这件事:给每个点分配一个语义标签,建筑、树木、地面、车辆各归其类。这类任务在测绘、自动驾驶、城市建模里都是刚需,而基于 Python 实现的三维点云激光分类,恰好是入门门槛最低、又能跑出高分效果的一条路。
标题里说的「包含建筑、树木等」,指向的是典型的室外场景多类别分割,不是简单的二分类。它适合谁?适合已经会一点 Python、想找一个能写进简历或课程设计的完整项目的人,也适合做测绘、遥感、机器人感知方向、需要快速验证点云分类可行性的工程师。源码加使用说明的组合,意味着你不必从零推导算法,但要真正跑通并理解每一步在干什么,还是得把数据格式、特征、模型、评估这条链路走一遍。下面按「先立住原理、再动手复现、最后避坑」的顺序拆开讲。
2. 点云分类的技术底座:从原始点云到可训练样本
2.1 为什么点云不能直接丢进普通 CNN
图像是规则网格,卷积核滑动时邻居关系固定。点云是一堆无序的 (x, y, z),同一个物体旋转一下,点的排列顺序全变,坐标数值也全变。这就是点云处理的第一个难点:置换不变性和旋转不变性。早期做法是把点云体素化成 3D 网格再套 3D CNN,但体素化会丢精度、显存爆炸,室外场景动辄上百万点根本扛不住。
后来 PointNet 系列直接吃原始点,用对称函数(max pooling)解决无序问题,用 T-Net 对齐解决旋转问题,才算把这条路走通。但纯 PointNet 对局部结构感知弱,室外场景里建筑墙面和树冠的点密度、法向差异很大,所以实际项目里常见做法是「局部特征 + 全局特征」结合,比如 PointNet++ 的 set abstraction 层,或者把点云投影到多视角再融合。你拿到的源码如果用的是 PointNet++ 或 RandLA-Net 这类结构,属于正常选择;如果只是最朴素的 PointNet,那在建筑树木这种类别边界模糊的场景上,mIoU 通常会低一截,这点心里要有数。
2.2 数据准备:标签体系和文件格式先对齐
室外点云分类常用的公开数据集是 ISPRS Vaihingen、Semantic3D、Toronto3D 这几类,标签体系一般包含地面、建筑、树木、低矮植被、车辆、电力线等。你自己采的数据往往没有标签,需要先人工标注或用半自动方式生成。文件格式上,.las/.laz 是测绘行业标准,.ply/.pcd 在算法圈更常见,.txt/.xyz 最朴素但没头信息。
下面这段代码演示用 Python 读取 .las 并转成 numpy 数组,同时把标签列单独拎出来。依赖 laspy 和 numpy,安装命令是pip install laspy numpy。
import laspy import numpy as np # 读取 las 文件,mode='r' 表示只读,避免误改原始数据 las = laspy.read("scene.las") # 提取 XYZ 坐标,las.x 等返回的是缩放后的真实坐标 xyz = np.vstack((las.x, las.y, las.z)).transpose().astype(np.float32) # 如果文件里带分类标签,字段名通常是 classification if hasattr(las, "classification"): labels = np.array(las.classification, dtype=np.int64) else: labels = None print("该文件没有分类标签,需要先标注") print("点数:", xyz.shape[0], "坐标范围:", xyz.min(axis=0), xyz.max(axis=0))逻辑说明:laspy 读进来的坐标已经应用了 scale 和 offset,直接拿到的就是米制坐标,不用再手动乘缩放因子。参数上,las.x返回的是 numpy 数组,vstack 后转置得到 (N, 3)。如果标签字段不叫 classification,用las.point_format.dimension_names查一下实际字段名。这一步的坑在于:有些 las 文件的 classification 全是 0 或 1,那是未分类状态,不能直接拿来训练。
2.3 特征工程:法向量、高程、回波强度怎么用
原始 XYZ 信息量有限,室外场景里几个特征特别管用:高程(z 值归一化后能区分地面和建筑)、法向量(墙面法向水平、地面法向垂直、树冠法向杂乱)、回波强度(激光反射率,建筑和植被差异明显)、点密度(局部邻域点数)。这些特征算出来拼到 XYZ 后面,作为模型输入,往往比单纯调网络结构涨点更快。
用 open3d 算法向量是常见做法,pip install open3d。下面代码计算每个点的法向量并拼接到特征里。
import open3d as o3d import numpy as np pcd = o3d.geometry.PointCloud() pcd.points = o3d.utility.Vector3dVector(xyz) # 搜索邻域大小,室外点云一般 0.5~1.0 米比较合适 pcd.estimate_normals(search_param=o3d.geometry.KDTreeSearchParamHybrid(radius=0.8, max_nn=30)) normals = np.asarray(pcd.normals, dtype=np.float32) # 高程归一化:减去局部地面高度,这里简化为减去全局最小值 z_norm = (xyz[:, 2] - xyz[:, 2].min()).reshape(-1, 1) # 拼接特征:XYZ + 法向量 + 归一化高程 features = np.hstack((xyz, normals, z_norm)) print("特征维度:", features.shape)逻辑说明:radius=0.8是搜索半径,太小法向量噪声大,太大墙面和树冠会被混在一起。max_nn=30限制邻域点数上限,防止密集区域计算量爆炸。z_norm 这里做了简化,严谨做法是先做地面滤波(比如 CSF 算法)再算每个点到地面的高度。特征拼接后维度从 3 变成 7,训练时输入层要对应改。
提示:法向量方向有正负歧义,open3d 默认朝向视点,做特征时最好统一朝向或只取绝对值,否则同一面墙可能一半正一半负,模型会学乱。
3. 用 Python 把分类模型跑起来:训练、验证、推理三步
3.1 环境配置与依赖安装
先把环境弄干净,避免版本冲突。推荐 Python 3.8~3.10,太新的版本有些点云库轮子还没跟上。用 conda 建虚拟环境最省事:
conda create -n pointcloud python=3.9 -y conda activate pointcloud pip install numpy laspy open3d scikit-learn torch torchvision如果你用 vscode 或 pycharm 配置 python 环境,记得把解释器指到这个 conda 环境,不然跑代码时 import 报错会让人怀疑人生。torch 装 CPU 版就够跑中小规模点云,有 GPU 的话按官网命令装对应 CUDA 版本。安装完用python -c "import torch; print(torch.__version__)"验证一下。
3.2 数据加载与预处理流水线
训练前要把点云切成块。室外场景整帧太大,直接喂进网络显存扛不住,常见做法是滑动窗口切块,每块比如 1 米 × 1 米或 2 米 × 2 米,块内采样固定点数(如 4096 个)。下面是一个可复用的切块函数。
import numpy as np def split_blocks(xyz, labels, block_size=2.0, num_points=4096): """把大场景切成固定点数的小块,返回 (blocks, block_labels)""" blocks, block_labels = [], [] # 按 block_size 划分网格 min_xy = xyz[:, :2].min(axis=0) grid = ((xyz[:, :2] - min_xy) / block_size).astype(np.int32) # 用网格坐标做唯一索引,把点分组 keys = grid[:, 0] * 100000 + grid[:, 1] for k in np.unique(keys): mask = keys == k pts = xyz[mask] lbs = labels[mask] # 点数不足则重复采样,过多则随机下采样 if pts.shape[0] < num_points: idx = np.random.choice(pts.shape[0], num_points, replace=True) else: idx = np.random.choice(pts.shape[0], num_points, replace=False) blocks.append(pts[idx]) block_labels.append(lbs[idx]) return np.array(blocks), np.array(block_labels)逻辑说明:block_size=2.0控制每块的空间范围,太小则单块内类别单一、模型学不到边界,太大则点数过多。num_points=4096是 PointNet 系列的常见输入点数,可按显存调整。keys用网格坐标编码成唯一整数,避免用字典循环太慢。注意重复采样会让某些点被多次训练,类别不平衡时反而有点用,但别过度依赖。
3.3 模型搭建与训练循环
下面给一个精简版 PointNet 分类头,输入 (B, N, C),输出 (B, num_classes)。真实项目里可以换成 PointNet++ 或加注意力模块,但训练流程是一样的。
import torch import torch.nn as nn import torch.nn.functional as F class PointNetCls(nn.Module): def __init__(self, in_dim=7, num_classes=6): super().__init__() # 逐点 MLP,共享权重 self.mlp1 = nn.Sequential(nn.Linear(in_dim, 64), nn.ReLU(), nn.Linear(64, 128), nn.ReLU(), nn.Linear(128, 1024)) # 全局特征后接分类头 self.head = nn.Sequential(nn.Linear(1024, 512), nn.ReLU(), nn.Dropout(0.3), nn.Linear(512, 256), nn.ReLU(), nn.Linear(256, num_classes)) def forward(self, x): # x: (B, N, C) feat = self.mlp1(x) # (B, N, 1024) global_feat = feat.max(dim=1)[0] # 对称函数,解决无序性 return self.head(global_feat) # 训练循环骨架 device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = PointNetCls(in_dim=7, num_classes=6).to(device) optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) criterion = nn.CrossEntropyLoss() for epoch in range(50): model.train() for pts, lbs in train_loader: # pts: (B, N, C), lbs: (B, N) pts, lbs = pts.to(device), lbs.to(device) logits = model(pts) # (B, num_classes) # 块级标签:取块内多数类作为该块标签 block_label = torch.mode(lbs, dim=1)[0] loss = criterion(logits, block_label) optimizer.zero_grad(); loss.backward(); optimizer.step()逻辑说明:max(dim=1)是 PointNet 的核心,对每个特征维度取所有点的最大值,这样无论点怎么排列,结果不变。Dropout(0.3)缓解过拟合,室外点云标注噪声大时很必要。这里做的是块级分类,如果要做逐点分类,把 global_feat 广播回每个点再拼接局部特征即可。学习率 1e-3 是 Adam 的常规起点,loss 不降就降到 1e-4。
3.4 评估指标与推理输出
分类任务不能只看准确率,类别不平衡时准确率会骗人。常用 mIoU(各类 IoU 平均)和 OA(总体准确率)一起看。推理时把每个块的预测标签映射回原始点,再写回 las 文件,方便在 CloudCompare 里可视化检查。
from sklearn.metrics import confusion_matrix import numpy as np def compute_miou(y_true, y_pred, num_classes=6): cm = confusion_matrix(y_true, y_pred, labels=list(range(num_classes))) ious = [] for i in range(num_classes): tp = cm[i, i] fp = cm[:, i].sum() - tp fn = cm[i, :].sum() - tp iou = tp / (tp + fp + fn + 1e-8) ious.append(iou) return np.mean(ious), ious # 推理后写回 las las.classification = pred_labels.astype(np.uint8) las.write("scene_predicted.las")逻辑说明:confusion_matrix的 labels 参数要显式指定,否则缺失类别会导致矩阵维度错位。mIoU 对每个类算完再平均,小类别(如车辆)的 IoU 低会拉低整体,这正是你需要的敏感度。写回 las 时 classification 字段是 uint8,标签值别超过 255。
注意:训练集和验证集要按空间块划分,不能随机打乱所有点。相邻块高度相关,随机划分会让验证集泄漏训练信息,mIoU 虚高十几个点很常见。
4. 避坑与排查:三维点云分类最容易翻车的五件事
4.1 现象:训练 loss 正常下降,验证 mIoU 却只有 0.2 左右
原因:最常见的是标签泄漏或类别映射错位。比如训练时把标签 0 当地面,验证时数据集里 0 是未分类,模型学的东西和评估对不上。另一个原因是块级标签用多数类,边界块里建筑和树木混在一起,多数类投票把边界样本全带偏。
解决:先打印训练集和验证集各类别的点数分布,确认标签体系一致。块级分类改成逐点分类,或者在块内做加权投票而不是简单多数。验证时单独算每个类的 IoU,看是哪一类拖后腿。
4.2 现象:模型对建筑识别很好,树木几乎全错
原因:树木点云法向量杂乱、边界模糊,和低矮植被容易混。如果特征里只有 XYZ,模型很难区分树冠和灌木。另外树木样本在数据集中占比低,CrossEntropyLoss 不加权时模型倾向于全预测成建筑。
解决:加入回波强度、法向量、点密度特征。损失函数用带权重的 CrossEntropyLoss,权重按类别频率倒数设置。数据增强时对树木类别做额外过采样。
4.3 现象:推理时显存溢出,或单帧处理要几分钟
原因:整帧点云没切块直接送进网络,或者切块时 num_points 设得太大。室外一帧上百万点,PointNet 的 max pooling 虽然轻量,但逐点 MLP 在 N=100 万时中间特征就是 100 万 × 1024,显存直接爆。
解决:推理也必须切块,块大小和训练保持一致。num_points 控制在 4096~8192。如果还慢,用 open3d 先做体素下采样,pcd.voxel_down_sample(voxel_size=0.1),点数能降一个数量级,精度损失通常可接受。
4.4 现象:换一个场景测试,mIoU 断崖式下跌
原因:模型过拟合到训练场景的坐标范围和高程分布。比如训练数据全是平原,测试数据有山坡,归一化高程全乱。法向量的搜索半径也是按训练场景点密度定的,换密度不同的数据就失效。
解决:训练时做坐标归一化,把每个块的点云平移到块中心、缩放到单位球。法向量搜索半径改成自适应,按局部点密度动态调整。数据增强加入随机旋转、随机缩放、随机丢弃点,提升泛化。
4.5 现象:写回的 las 文件在 CloudCompare 里打开全是同一个颜色
原因:classification 字段写了但没写 color,或者标签值超出了 CloudCompare 的默认分类色表范围。另一个可能是写回时点顺序和原始文件不一致,导致标签错位。
解决:写回前确认las.classification长度和原始点数一致。标签值用标准分类码(2 地面、6 建筑、5 植被等)。如果要在 CloudCompare 里看,可以同时写 RGB 颜色字段,按类别赋色。点顺序不要重排,切块推理后按原始索引映射回去。
5. 让分类结果再涨几个点:后处理与工程化技巧
模型输出从来不是终点。逐点预测结果里总有一些孤立的错误标签,比如建筑中间冒出几个树木点,或者地面里混进建筑点。这时候用点云的空间连续性做后处理,往往比调网络结构涨点更快。我一般会做两步:先做基于 KNN 的多数投票平滑,再做基于连通域的小簇过滤。
KNN 平滑的思路是,对每个点找最近的 k 个邻居,如果邻居里某个类别占绝对多数,就把当前点改成那个类别。k 取 10~20 比较合适,太小没效果,太大边界会被抹平。连通域过滤则是把同一类别的点做聚类,小于一定点数的簇判定为噪声,改回周围最多的类别。这两步用 sklearn 的 KDTree 和 scipy 的 label 就能实现,不需要额外依赖。
from sklearn.neighbors import KDTree import numpy as np from scipy import ndimage def knn_smooth(xyz, pred, k=15): """KNN 多数投票平滑,xyz: (N,3), pred: (N,)""" tree = KDTree(xyz) _, idx = tree.query(xyz, k=k) # idx: (N, k) neighbor_labels = pred[idx] # (N, k) # 对每个点的邻居标签做多数投票 smoothed = np.array([np.bincount(row).argmax() for row in neighbor_labels]) return smoothed def remove_small_clusters(xyz, pred, min_size=50, voxel=0.3): """体素化后做连通域,过滤小簇""" # 体素坐标作为连通域索引 voxel_coord = np.floor(xyz / voxel).astype(np.int32) # 用类别 + 体素坐标做联合标记,保证不同类别不连通 combined = pred.astype(np.int64) * 10**9 + \ voxel_coord[:, 0] * 10**6 + voxel_coord[:, 1] * 10**3 + voxel_coord[:, 2] # 简化处理:对每个类别单独做连通域 result = pred.copy() for cls in np.unique(pred): mask = pred == cls if mask.sum() < min_size: continue labeled, num = ndimage.label(mask.reshape(-1, 1)) for i in range(1, num + 1): cluster = labeled == i if cluster.sum() < min_size: # 小簇改回邻居多数类,这里简化为改回地面类 2 result[cluster] = 2 return result逻辑说明:k=15是平滑邻域大小,点云密度高时可以调大。np.bincount(row).argmax()做多数投票,比手动统计快。连通域部分用体素坐标而不是原始点坐标,是因为原始点之间没有显式邻接关系,体素化后 3D 网格的连通性可以用 ndimage.label 处理。min_size=50是簇点数阈值,小于这个数的簇视为噪声。voxel=0.3是体素边长,室外场景 0.2~0.5 米比较合理。
后处理的效果因数据而异,我自己的经验是 KNN 平滑通常能涨 1~3 个点 mIoU,连通域过滤再涨 0.5~1 个点,但边界会稍微变钝。如果做的是精细建模,边界比噪声更重要,那就只做 KNN 平滑,跳过连通域过滤。验证后处理有没有用,不能只看整体 mIoU,要单独看建筑和树木这两类的 IoU 变化,因为平滑对大面积类别(地面、建筑)提升小,对小类别和边界类别提升明显。
工程化落地时还有一件事值得做:把训练好的模型导出成 TorchScript 或 ONNX,推理时不再依赖 Python 训练环境,速度也能快一截。导出后用几张典型场景做回归测试,确认导出前后输出一致。我吃过一次亏,模型里用了动态 shape 的 max 操作,导出 ONNX 后行为变了,mIoU 掉了 5 个点,后来固定输入点数才解决。所以导出后一定要用同一批数据对比输出,别嫌麻烦。
最后说个习惯:每次改完特征或模型,把配置、数据版本、mIoU 记在一个表格里,哪怕只是本地 txt。点云分类的实验变量太多,不记录的话两周后自己都忘了哪组参数最好。这个项目作为课程设计或入门练手,把上面这条链路完整走一遍,比单纯跑通源码收获大得多。希望帮到你。
本文还有配套的精品资源,点击获取