
简介这是一份基于Python实现的三维点云激光分类项目源码与使用说明面向计算机、通信、人工智能、自动化等专业的学生、教师及从业者可用于毕业设计、课程大作业或期末课程设计也适合作为小白入门与进阶的学习案例。项目围绕建筑、树木等典型地物类别展开点云分类涵盖特征提取、邻域分析、KNN近邻、PCA降维与SVM分类等核心环节整体方案完整、逻辑清晰。资源包共15个文件以10个py源码文件为主另含txt特征向量说明、trees与buildings点云数据、docx使用手册及testdata测试数据压缩包约5.84MB目录结构便于按模块查阅。目前已有78人学习下载。代码经过调试测试可正常运行读者可据此理解三维点云分类的完整流程掌握特征工程与分类器调参思路并在此基础上修改调整以实现不同功能具备较高的学习借鉴价值。1. 三维点云激光分类到底在分什么从建筑墙面到树冠的一次工程拆解拿到一份三维点云数据里面混着建筑立面、屋顶、树冠、灌木、地面和电线要按类别把每个点打上标签这就是三维点云激光分类要干的事。它和二维图像分类最大的区别在于点云没有规则网格点与点之间的邻接关系靠空间距离定义同一个物体在不同扫描角度下密度差异极大建筑墙面可能每平方米几百个点远处树冠可能只有几十个点。我接触过的实际项目里机载激光扫描和地面站扫描混在一起时这种密度差异能到两个数量级。Python 在这个方向上的优势不是性能而是生态Open3D 做可视化和几何计算NumPy 做矩阵运算scikit-learn 做传统特征分类PyTorch 做深度模型整条链路都能在 Python 里串起来。这篇文章面向的是手里已经有一份点云、想用 Python 跑通分类流程的从业者从环境配置讲到特征工程、模型训练和结果导出中间会给出可直接复现的代码和参数。如果你之前只做过图像分类点云分类的坑主要集中在邻域搜索和特征尺度上后面会逐条拆开。2. 环境搭建与点云数据读取把 LAS/PCD 变成 NumPy 数组2.1 Python 环境与核心库的版本选择三维点云处理对库版本比较敏感尤其是 Open3D 和 NumPy 的搭配。我一般用 conda 建独立环境避免和系统里的其他包冲突。Python 版本选 3.9 或 3.10这两个版本在 Open3D、PyTorch、scikit-learn 上的轮子最全。Open3D 选 0.17 或 0.180.19 之后 API 有调整部分老教程里的read_point_cloud参数变了。NumPy 不要超过 1.26否则某些 Open3D 版本会报 ABI 不兼容。PyTorch 按 CUDA 版本装如果只做传统特征分类CPU 版就够。conda create -n pointcloud python3.10 -y conda activate pointcloud pip install open3d0.18.0 numpy1.24.4 scikit-learn1.3.2 pip install laspy2.5.1 # 读 LAS/LAZ 格式 pip install matplotlib3.8.2 # 可视化备用这段命令建了一个叫 pointcloud 的环境装的是我实测比较稳的版本组合。laspy 用来读机载激光常见的 LAS 格式Open3D 自带 PCD、PLY、XYZ 的读写。如果你拿到的数据是 LAS用 laspy 读进来再转成 Open3D 的 PointCloud 对象比直接用 Open3D 读 LAS 更可控因为 laspy 能直接拿到分类字段和强度字段。2.2 读取 LAS 并转成 NumPy 数组实际项目里拿到的往往是 LAS 或 LAZ里面除了 XYZ 还有 intensity、classification、return number 等字段。下面这段代码把 LAS 读成 NumPy 数组并保留强度信息后面做特征时用得上。import laspy import numpy as np import open3d as o3d def read_las_to_numpy(las_path): las laspy.read(las_path) # 提取 XYZlaspy 的 xyz 是缩放后的实际坐标 points np.vstack((las.x, las.y, las.z)).transpose().astype(np.float64) # 强度归一化到 0-1不同传感器量纲不同归一化后再做特征 intensity np.array(las.intensity, dtypenp.float64) if intensity.max() intensity.min(): intensity (intensity - intensity.min()) / (intensity.max() - intensity.min()) # 如果文件里已有分类标签一并取出用于监督学习 labels np.array(las.classification, dtypenp.int32) if hasattr(las, classification) else None return points, intensity, labels def numpy_to_o3d(points, colorsNone): pcd o3d.geometry.PointCloud() pcd.points o3d.utility.Vector3dVector(points) if colors is not None: pcd.colors o3d.utility.Vector3dVector(colors) return pcd points, intensity, labels read_las_to_numpy(sample.las) print(点数:, points.shape[0], 坐标范围:, points.min(axis0), points.max(axis0)) pcd numpy_to_o3d(points) o3d.visualization.draw_geometries([pcd])逻辑说明las.x、las.y、las.z返回的是缩放后的浮点坐标直接 vstack 再转置得到 N×3 数组。强度归一化是因为不同厂商的激光传感器强度量纲差异大不归一化会干扰后续特征。labels取的是 LAS 标准里的 classification 字段常见取值 2 是地面、6 是建筑、5 是高植被但不同数据集标注体系不一样用之前先看数据说明。参数上如果点云超过千万级draw_geometries会卡建议先随机降采样到 200 万点再看。2.3 降采样与坐标归一化原始点云动辄几千万点直接做邻域搜索内存扛不住。我一般先体素降采样到 5 到 10 厘米的体素大小建筑和树木的几何结构在这个尺度下还能保留。坐标归一化是把点云平移到原点附近避免大坐标值在计算协方差时产生数值误差。def preprocess(points, voxel_size0.08): pcd numpy_to_o3d(points) # 体素降采样每个体素取重心 downsampled pcd.voxel_down_sample(voxel_sizevoxel_size) pts np.asarray(downsampled.points) # 平移到质心消除绝对坐标带来的数值问题 centroid pts.mean(axis0) pts_centered pts - centroid return pts_centered, centroid pts_ds, centroid preprocess(points, voxel_size0.08) print(降采样后点数:, pts_ds.shape[0])体素大小是关键参数0.05 米适合地面站精细扫描0.1 米适合机载。降采样后点数一般降到原来的 10% 到 30%。质心要保存下来最后导出结果时加回去否则坐标对不上原始数据。3. 特征工程建筑和树木在几何特征上怎么区分3.1 邻域搜索与协方差特征点云分类的传统路线靠几何特征核心是每个点的邻域协方差矩阵。建筑墙面点的邻域在一个平面上协方差矩阵有一个特征值接近零树冠点的邻域散乱三个特征值量级接近。Open3D 的 KDTree 做邻域搜索半径一般取 0.3 到 0.5 米对应建筑墙面和树冠的局部结构尺度。from sklearn.neighbors import KDTree def compute_geom_features(points, radius0.4, max_nn30): tree KDTree(points) n points.shape[0] feats np.zeros((n, 8), dtypenp.float32) for i in range(n): idx tree.query_radius(points[i:i1], rradius)[0] if len(idx) 3: continue neigh points[idx] # 协方差矩阵的特征值升序 cov np.cov(neigh - neigh.mean(axis0), rowvarFalse) eigvals np.linalg.eigvalsh(cov) eigvals np.sort(eigvals) # lambda1 lambda2 lambda3 s eigvals.sum() 1e-12 # 线性度、平面度、散射度、曲率 feats[i, 0] (eigvals[2] - eigvals[1]) / s # 线性度 feats[i, 1] (eigvals[1] - eigvals[0]) / s # 平面度 feats[i, 2] eigvals[0] / s # 散射度 feats[i, 3] eigvals[0] / s # 曲率 feats[i, 4] eigvals[2] # 最大特征值 feats[i, 5] eigvals[1] feats[i, 6] eigvals[0] feats[i, 7] len(idx) # 邻域点数 return feats逻辑说明query_radius返回半径内的点索引max_nn在 query_radius 里不生效如果要限制点数得用query。协方差矩阵用np.cov算rowvarFalse表示每行是一个样本。特征值升序排列后线性度衡量点沿一条线分布的程度平面度衡量点在一个面上的程度散射度衡量各向同性程度。建筑墙面平面度高树冠散射度高这是最直接的两个判别特征。参数上半径 0.4 米是经验值点云密度高可以降到 0.2密度低要加到 0.6。这个循环在 Python 里跑百万点会很慢实际项目我会用 Open3D 的compute_nearest_neighbor_distance或者转成 C 扩展但作为可复现的基线这个写法最清楚。3.2 法向量与高度特征除了协方差特征法向量和高度也是强判别特征。建筑墙面法向量水平屋顶法向量垂直树冠法向量方向杂乱。高度特征用点相对于局部地面的高度建筑比树木高但树冠顶部也可能很高所以高度要结合邻域高度方差一起用。def compute_normal_and_height(points, radius0.4): pcd numpy_to_o3d(points) pcd.estimate_normals(search_paramo3d.geometry.KDTreeSearchParamHybrid(radiusradius, max_nn30)) normals np.asarray(pcd.normals) # 法向量与 Z 轴夹角的余弦垂直面接近 0水平面接近 1 z_axis np.array([0, 0, 1]) cos_angle np.abs(normals z_axis) # 局部高度方差邻域内 Z 值的标准差 tree KDTree(points) height_std np.zeros(points.shape[0], dtypenp.float32) for i in range(points.shape[0]): idx tree.query_radius(points[i:i1], rradius)[0] if len(idx) 1: height_std[i] points[idx, 2].std() return cos_angle, height_stdestimate_normals的max_nn30限制邻域点数避免在密度不均时法向量跳变。cos_angle接近 1 说明法向量垂直是屋顶或地面接近 0 说明法向量水平是墙面。height_std大说明邻域内高度变化剧烈树冠和建筑边缘都会大但树冠的height_std通常比建筑边缘更大且更连续。3.3 特征拼接与标准化把上面几组特征拼成一个矩阵做标准化后送分类器。标准化用 StandardScaler因为不同特征量纲差异大比如邻域点数是几十到几百曲率是 0 到 1 之间。from sklearn.preprocessing import StandardScaler geom_feats compute_geom_features(pts_ds, radius0.4) cos_angle, height_std compute_normal_and_height(pts_ds, radius0.4) # 拼接8 个几何特征 法向量夹角 高度标准差 强度 intensity_ds intensity[:pts_ds.shape[0]] # 注意降采样后要对齐实际项目用体素索引映射 feature_matrix np.hstack([geom_feats, cos_angle.reshape(-1,1), height_std.reshape(-1,1)]) scaler StandardScaler() feature_matrix scaler.fit_transform(feature_matrix) print(特征矩阵形状:, feature_matrix.shape)这里有个坑降采样后点的顺序和原始点云不一致强度字段不能直接切片对齐。实际项目里我会在降采样时记录每个降采样点对应的原始点索引用索引去取强度。上面代码为了简洁用了切片读者复现时要注意这一点。特征矩阵标准化后均值为 0 方差为 1RandomForest 和 SVM 对尺度不敏感但标准化后收敛更快。4. 分类模型训练与调参从随机森林到 PointNet 的取舍4.1 随机森林基线快速验证特征有效性特征工程做完先用随机森林跑一个基线看特征能不能把建筑和树木分开。随机森林对特征尺度不敏感训练快还能输出特征重要性方便判断哪些特征在起作用。from sklearn.ensemble import RandomForestClassifier from sklearn.model_selection import train_test_split from sklearn.metrics import classification_report, confusion_matrix # 假设 labels_ds 是降采样后每个点的标签2地面6建筑5高植被 X_train, X_test, y_train, y_test train_test_split( feature_matrix, labels_ds, test_size0.3, random_state42, stratifylabels_ds ) rf RandomForestClassifier( n_estimators200, max_depth20, min_samples_leaf5, class_weightbalanced, n_jobs-1, random_state42 ) rf.fit(X_train, y_train) y_pred rf.predict(X_test) print(classification_report(y_test, y_pred)) print(特征重要性:, rf.feature_importances_)参数说明n_estimators200是树的数量点云分类任务一般 100 到 300 够用再多收益递减。max_depth20限制树深防止过拟合点云特征噪声大树太深会记住噪声。min_samples_leaf5保证叶子节点至少 5 个样本同样防过拟合。class_weightbalanced处理类别不平衡建筑点通常比树木点多不加这个参数分类器会偏向多数类。n_jobs-1用满 CPU 核。特征重要性输出后如果平面度和散射度排前两位说明特征设计合理如果邻域点数排第一说明模型在靠密度差异分类换数据集可能翻车。4.2 参数调优网格搜索与学习曲线随机森林有几个关键参数需要调max_depth、min_samples_leaf、max_features。用 GridSearchCV 在小样本上先搜一遍找到大致范围再在全量数据上训练。from sklearn.model_selection import GridSearchCV param_grid { max_depth: [10, 15, 20, 25], min_samples_leaf: [1, 3, 5, 10], max_features: [sqrt, log2, 0.5] } grid GridSearchCV( RandomForestClassifier(n_estimators100, class_weightbalanced, n_jobs-1, random_state42), param_grid, cv3, scoringf1_macro, n_jobs-1, verbose1 ) grid.fit(X_train, y_train) print(最佳参数:, grid.best_params_) print(最佳 F1:, grid.best_score_)scoringf1_macro是因为类别不平衡用 accuracy 会虚高。cv3在点云数据上够用因为样本量大三折交叉验证的方差已经很小。搜索空间不要太大点云特征维度低参数组合超过 50 组就浪费时间。我一般先粗搜max_depth和min_samples_leaf确定范围后再细搜max_features。4.3 深度学习路线PointNet 的适用边界如果传统特征在复杂场景下精度不够比如建筑和树木交错、有藤蔓覆盖墙面可以考虑 PointNet 或 PointNet。PointNet 直接吃原始点云不需要手工特征但需要更多标注数据和 GPU。PyTorch 实现一个简化版 PointNet 分类头输入是 N×3 的点云块。import torch import torch.nn as nn import torch.nn.functional as F class PointNetCls(nn.Module): def __init__(self, num_classes3): super().__init__() self.conv1 nn.Conv1d(3, 64, 1) self.conv2 nn.Conv1d(64, 128, 1) self.conv3 nn.Conv1d(128, 1024, 1) self.fc1 nn.Linear(1024, 512) self.fc2 nn.Linear(512, 256) self.fc3 nn.Linear(256, num_classes) self.bn1 nn.BatchNorm1d(64) self.bn2 nn.BatchNorm1d(128) self.bn3 nn.BatchNorm1d(1024) def forward(self, x): # x: (B, 3, N) x F.relu(self.bn1(self.conv1(x))) x F.relu(self.bn2(self.conv2(x))) x self.bn3(self.conv3(x)) x torch.max(x, dim2)[0] # 全局最大池化得到 (B, 1024) x F.relu(self.fc1(x)) x F.relu(self.fc2(x)) x self.fc3(x) return x逻辑说明Conv1d 的卷积核大小为 1等价于对每个点独立做全连接但权重共享。torch.max(x, dim2)是 PointNet 的核心把 N 个点的特征取最大值得到全局特征保证置换不变性。训练时把点云切成 1 米见方的块每块采样 1024 个点标签取块内多数点的类别。PointNet 在建筑和树木二分类上标注 500 块左右能到 90% 以上精度但训练需要 GPU推理速度比随机森林慢一个数量级。如果项目对实时性有要求传统特征加随机森林仍然是首选。5. 避坑与排查点云分类里最容易翻车的五个地方5.1 降采样后标签对不上现象训练时精度很高推理时结果错乱建筑点被分成树木。原因降采样后点的顺序变了标签数组还是按原始顺序切的导致标签和特征错位。解决降采样时用voxel_down_sample_and_trace拿到每个降采样点对应的原始点索引用索引去取标签和强度。downsampled, _, index_map pcd.voxel_down_sample_and_trace( voxel_size0.08, min_boundpcd.get_min_bound(), max_boundpcd.get_max_bound() ) # index_map 是每个降采样点对应的原始点索引列表取第一个作为代表 orig_indices np.array([idx[0] for idx in index_map]) labels_ds labels[orig_indices] intensity_ds intensity[orig_indices]5.2 邻域半径选错导致特征失效现象平面度和散射度区分度低建筑和树木特征混在一起。原因邻域半径太大建筑墙面点的邻域包含了地面和窗户协方差特征被平均掉半径太小树冠点邻域点数不足特征值不稳定。解决先统计点云的平均点间距半径取平均间距的 3 到 5 倍。用compute_nearest_neighbor_distance算平均间距。dists pcd.compute_nearest_neighbor_distance() avg_dist np.mean(dists) print(平均点间距:, avg_dist) radius avg_dist * 4 # 经验倍数5.3 类别不平衡导致小类被吞现象树木点被大量分成建筑混淆矩阵里树木召回率低于 0.5。原因建筑点数量是树木的几倍分类器偏向多数类。解决class_weightbalanced是最简单的还可以对少数类过采样或者用 focal loss 替换交叉熵。随机森林里class_weightbalanced_subsample在每棵树的子样本上重新计算权重效果更稳。5.4 法向量方向不一致现象同一面墙的法向量有的朝里有的朝外cos_angle特征出现双峰。原因estimate_normals不保证法向量朝向一致。解决用orient_normals_to_align_with_direction统一朝向或者直接用np.abs(cos_angle)取绝对值因为墙面法向量水平正负不影响判别。pcd.orient_normals_to_align_with_direction(orientation_referencenp.array([0, 0, 1]))5.5 训练集和测试集来自不同区域现象交叉验证精度 0.95换一个区域测试掉到 0.6。原因点云分类对扫描角度和密度敏感同一区域切分训练测试会高估精度。解决按空间块切分比如按 X 坐标分前后两半一半训练一半测试。如果数据来自不同飞行架次按架次切分。这个坑最隐蔽血泪经验是永远不要随机切分点云。6. 结果导出与精度验证把分类标签写回 LAS 并做混淆矩阵分类跑完最终要输出带标签的点云通常是写回 LAS 的 classification 字段或者导出 PLY 带颜色。写回 LAS 时注意降采样后的标签要映射回原始点不能只导出降采样点。def export_las_with_labels(original_las_path, orig_indices, pred_labels, out_path): las laspy.read(original_las_path) # 原始点默认标签未分类设为 1 full_labels np.ones(len(las.points), dtypenp.uint8) full_labels[orig_indices] pred_labels.astype(np.uint8) las.classification full_labels las.write(out_path) export_las_with_labels(sample.las, orig_indices, y_pred_full, classified.las)orig_indices是降采样时记录的原始点索引y_pred_full是对全部降采样点的预测。未参与降采样的点保持默认标签 1实际项目里可以用最近邻把标签传播过去。精度验证除了混淆矩阵我还会算每类的 IoU因为点云分类里 IoU 比 accuracy 更能反映真实效果。建筑类的 IoU 一般能到 0.85 以上树木类因为边界模糊0.7 左右就算不错。from sklearn.metrics import confusion_matrix import numpy as np cm confusion_matrix(y_test, y_pred) # 计算每类 IoU for i in range(cm.shape[0]): tp cm[i, i] fp cm[:, i].sum() - tp fn cm[i, :].sum() - tp iou tp / (tp fp fn 1e-12) print(f类别 {i} IoU: {iou:.3f})最后说一个我自己的习惯每次跑完分类先随机抽 20 个点人工检查标签再画一张按类别着色的点云图建筑用灰色、树木用绿色、地面用棕色。肉眼过一遍比看数字更快发现系统性问题。这个方案从环境搭建到导出结果一台普通笔记本跑百万点级别的数据传统特征加随机森林大概十几分钟能出第一版结果值得先跑通再优化。希望帮到你。本文还有配套的精品资源点击获取