☰
点云单木分割实战:欧氏距离聚类与FastAPI封装指南
2026/10/15 17:24:00 网站建设 项目流程

简介:面向激光雷达点云处理与林业遥感初学者的一份可直接运行的源码包,借助PCL库中的欧氏距离聚类算法实现单木分割。压缩包内共3个文件:一个C++实现的主程序(cpp),一份用于测试的xyz格式点云数据,以及聚类后生成的txt结果文件,整个资源包仅93KB,轻量且聚焦,便于快速复盘整个算法流程。目前已有811人学习下载,适合正在入门点云分割、希望深入理解欧氏聚类参数(如聚类容差、最小/最大点数)如何影响分割效果的研究者与开发者。通过对照配套博文中的原理讲解与示例输出,读者能掌握从点云读取、欧氏聚类调用到单木结果导出的完整链路,并可根据自身数据微调阈值参数,为后续开展株数统计、单木结构参数提取等应用打下基础。资源整体结构紧凑,无冗余文件,适合作为算法入门或课程实验的参考实现。

1. 从点云到单木分割:为什么欧氏距离聚类是首选的 API 内核

做林业激光扫描数据处理的人,几乎都会卡在同一道坎上:一片样地的点云扫出来有几十万到几千万个点,每一棵树都一堆点,但软件里呈现的是一整片没有 ID 的点海。要算平均树高、冠幅、株数密度,第一步必须是“单木分割”——把每棵树的点从整片点云里独立拆出来。欧氏距离聚类是这个环节最常用的无监督方案,逻辑直白:空间距离近的点归成一棵树,距离远的点属于另一棵树。它的优势在于不依赖训练数据、不生成栅格、参数少且物理含义明确,因此特别适合做成一个可复用的 API 服务。这篇文章就按我从数据读取到接口封装、再到参数调优的实际操作顺序,把欧氏距离实现点云单木分割的完整链路拆开讲清楚,哪里容易翻车也会一并说明。

2. 欧氏距离聚类分割单木的原理:核心参数与实现代码

2.1 单木分割的三条技术路线,欧氏距离为什么值得先用

单木分割的主流方案大概分三类,各自适用场景差别很大。第一类是 CHM 分水岭:先把点云投影成冠层高度模型栅格,再对倒置的 CHM 做分水岭分割。它的优点是成熟、计算快,缺点是栅格化会丢失冠层内部的垂直结构信息,低矮树木容易被高大邻居遮挡,树冠粘连时分割边界容易溢出。第二类是区域生长:从树顶种子点出发,按邻域规则逐步合并点。这个方法需要先可靠地检测种子点,种子位置稍有偏差,树冠就会长歪。第三类就是欧氏距离聚类,直接在三维空间里做连通域分析,完全绕开栅格化和种子点问题。

从 API 服务的角度看,默认算法应该满足一个条件:输入数据不确定、场景变化大时仍然稳定。欧氏距离聚类恰好符合这一点,它不针对特定树种建模,不要求预先训练,只依赖“树冠内部点间距显著小于树间间距”这个普遍成立的物理事实。遇到树冠紧密相接的场景,它确实容易把两棵树合并到一起,但这个短板可以通过后处理弥补,后面避坑章节会专门讲。相比之下,CHM 分水岭和区域生长在树种变化、点密度变化时调整成本更高。

我在实际项目里选择欧氏距离聚类还有一个现实理由:它便于逐步调试。整个流程可以拆成“读取点云→体素下采样→聚类→后处理输出”四段,每一段都能单独检查中间结果,这对接 API 接口排障特别重要。即使后续要换更复杂的语义分割模型,欧氏距离聚类的结果也可以作为训练样本的初筛基线,并不浪费。

2.2 三个核心参数:eps、min_points 与体素尺寸的联动关系

欧氏距离聚类直接相关的参数只有两个:聚类半径阈值 eps 和最小点数 min_points。但点云数据往往有几十万甚至上千万个点,直接聚类速度慢且内存压力大,所以通常要先做体素下采样,这又引入了第三个参数 voxel_size。这三个参数不是独立的,它们的联动关系决定了分割成败。

先看 eps。它代表“距离多近的点算同一个簇”,单位与点云坐标一致,通常为米。eps 太小,一棵树内部的点因为枝条间有空隙,会被拆成几块;eps 太大,相邻两棵树的树冠会被合并。树的冠层内部点平均间距一般在 0.5 到 1.5 米之间,而相邻树冠之间的最小间距经常只有 1 到 3 米,这意味着 eps 的理想取值窗口并不宽。我一般会先估算树冠内部点的平均间距,再把 eps 取为该间距的 2 到 3 倍,这样既保证树冠内部连通,又不容易跨过树间间隙。

min_points 的作用是过滤小噪点簇。它的合理值取决于体素下采样后的点密度。假设体素尺寸 0.3 米,一棵 10 米高的树冠大约能保留几百到上千个点,那么 min_points 取 30 到 50 比较合适;如果体素放到 1 米,一棵树的点可能只剩几十个,min_points 就得降到 10 到 20。voxel_size 决定下采样后点云的均匀程度,一般取 0.2 到 0.5 米。体素越大,数据量越小,但树冠形状细节丢失越严重。

参数之间的经验关系可以总结成一张表:

参数含义经验取值调整方向
voxel_size体素边长,决定下采样后点间距0.2–0.5 m点云越密取越小
eps聚类半径阈值体素尺寸的 2–3 倍树冠越密取越小
min_points簇最少点数30–50(0.3 m 体素)噪点多时调大

调参顺序也有讲究。我习惯先定 voxel_size,再根据下采样后的点间距定 eps,最后根据单棵树保留的点数定 min_points。反过来调会经常出现“eps 看着合理但分割结果一团糟”的玄学问题。

2.3 用 cKDTree 实现欧氏距离聚类:代码与复杂度分析

欧氏距离聚类在实现层面等价于“半径邻域连通域分析”。对点云中的任意一点,半径为 eps 范围内所有点属于同一个簇,簇继续向外扩展,直到边界处没有新的点加入。这个逻辑直接用双重循环遍历所有点对是行不通的,N 个点的距离矩阵是 N 的平方量级,百万点会直接卡死。正确做法是构建 KD 树加速最近邻查询,代码里使用 scipy.spatial.cKDTree。

import numpy as np from scipy.spatial import cKDTree def euclidean_cluster(points, eps, min_points): """ 基于欧氏距离的连通域聚类。 points: (N, 3) 数组,通常为 XY Z 坐标,单位为米 eps: 聚类半径阈值 min_points: 聚类最少点数,小于该值视为噪声 返回: labels 数组,每个点对应一个簇编号,-1 表示噪声 """ n = len(points) if n == 0: return np.array([], dtype=int) tree = cKDTree(points) labels = np.full(n, -1, dtype=int) visited = np.zeros(n, dtype=bool) cluster_id = 0 for i in range(n): if visited[i]: continue stack = [i] visited[i] = True members = [] while stack: idx = stack.pop() members.append(idx) # 查询半径 eps 内所有邻居索引,包含自身 neighbors = tree.query_ball_point(points[idx], eps) for j in neighbors: if not visited[j]: visited[j] = True stack.append(j) if len(members) >= min_points: labels[members] = cluster_id cluster_id += 1 return labels

这段代码的核心是tree.query_ball_point,它一次返回某个点半径 eps 内的所有邻居索引,比逐点计算距离快几个数量级。cKDTree 建树复杂度大约是 O(N log N),每个点的邻域查询平均 O(log N),整体聚类过程接近线性,百万点规模在普通笔记本上几秒内能跑完。labels 数组中值为 -1 的是噪点,其余簇编号从 0 开始递增。这个实现其实等价于一个 mini 版 DBSCAN,只不过固定了欧氏距离度量,不需要额外依赖 scikit-learn,封装成 API 时依赖更少。

要注意一个细节:query_ball_point返回的邻居包含点自身,但因为当前点已经标记为 visited,不会重复入栈。这个写法在面对点云密度不均时表现尚可,但遇到两棵树的树冠在空间上交错、点间距小于 eps 的场景,仍然会合并,这是算法本身的局限,后处理环节再解决。

3. 从 LAS 到单木点云:一条完整的最小实现流程

3.1 LAS 读取、体素下采样与地面点过滤

林业激光扫描点云最常见的格式是 LAS/LAZ,读取用 laspy 库。读完之后拿到的是三维坐标数组,后续所有计算都基于 numpy 数组完成。这里有个容易忽略的问题:laspy 1.x 和 2.x 的属性接口不一样,2.x 使用las.x、las.y、las.z,老代码里常见的las.X在新版本已经废弃,后面避坑章节会细说。

体素下采样这一步,我用质心代替体素内的所有点,而不是随机取一个点。原因是随机取点会引入抖动,导致树冠轮廓不均匀,质心则能保留体素内的平均位置。np.add.at 是处理重复索引累加的关键函数,比循环快很多。

import laspy import numpy as np def read_las_points(file_path): """读取 LAS/LAZ 点云,返回 (N, 3) 的 XYZ 数组,单位通常为米。""" las = laspy.read(file_path) points = np.vstack((las.x, las.y, las.z)).T return las, points def voxel_downsample(points, voxel_size): """体素下采样:每个体素内用质心点代替该体素内所有点。""" if voxel_size <= 0: return points voxel_coords = np.floor(points / voxel_size).astype(np.int64) _, inverse_indices = np.unique(voxel_coords, axis=0, return_inverse=True) summed = np.zeros((inverse_indices.max() + 1, 3), dtype=np.float64) np.add.at(summed, inverse_indices, points) counts = np.bincount(inverse_indices).reshape(-1, 1) return summed / counts

voxel_size的单位和点云坐标一致,一般给 0.3 米。为什么用np.floor而不是np.round?因为 floor 能保证同一个体素内的点坐标范围一致(左闭右开),round 在边界处可能把相邻两个体素的点混到一起。质心下采样后点数大幅减少,聚类速度明显提升,同时树冠形状的关键特征仍然保留。

地面点和低矮植被是单木分割的干扰项。LAS 标准里类别 2 是地面点,如果点云已经做过分类,直接按类别过滤。很多数据没有分类信息,就用高度阈值过滤。

def remove_ground_and_low(las, points, min_height=2.0): """剔除地面点与低矮点:优先用 LAS 分类字段,其次用高度阈值。""" mask = np.ones(len(points), dtype=bool) try: cls = np.asarray(las.classification).astype(np.int8) mask &= (cls != 2) except AttributeError: pass if min_height > 0: mask &= (points[:, 2] >= min_height) return points[mask]

高度阈值 2.0 米是经验值,主要目的是排除地面低矮灌木。如果你的目标区域是灌木林或者幼林,这个值要下调到 0.5 米左右,否则会把真正的目标植株过滤掉。这段顺序上放在体素下采样之后,可以少处理一些点,体素尺寸不变的情况下不影响过滤效果。

3.2 核心聚类:把欧氏距离分割函数接入完整流程

有了预处理基础,核心分割流程可以串起来。输入是 LAS 文件路径,输出是每个簇的点云和统计信息。流程顺序是:读点云 → 体素下采样 → 过滤地面和低矮点 → 欧氏聚类 → 按簇拆分输出。

def run_segmentation(input_path, eps=1.2, min_points=30, voxel_size=0.3, min_height=2.0): """执行完整的分割流程,返回每个树簇的点云列表和统计信息。""" las, points = read_las_points(input_path) points = voxel_downsample(points, voxel_size) points = remove_ground_and_low(las, points, min_height) if len(points) == 0: return {"status": "empty", "tree_count": 0, "tree_clusters": [], "stats": []} labels = euclidean_cluster(points, eps, min_points) n_clusters = labels.max() + 1 tree_clusters = [] stats = [] for cid in range(n_clusters): cluster_points = points[labels == cid] if cluster_points.shape[0] < min_points: continue max_z = cluster_points[:, 2].max() min_z = cluster_points[:, 2].min() # 冠幅用 XY 平面上最远两点距离近似 xy = cluster_points[:, :2] crown_diameter = float(np.linalg.norm(xy.max(axis=0) - xy.min(axis=0))) tree_clusters.append(cluster_points) stats.append({ "tree_id": cid + 1, "point_count": cluster_points.shape[0], "max_height": round(float(max_z), 3), "height_range": round(float(max_z - min_z), 3), "crown_diameter": round(crown_diameter, 3), }) return {"status": "ok", "tree_count": len(tree_clusters), "tree_clusters": tree_clusters, "stats": stats}

这段流程里,eps=1.2、min_points=30、voxel_size=0.3是一组在机载激光雷达点云上常见的起点值。如果你用的是地基扫描仪,点密度高很多,eps 要降到 0.3 到 0.5 米,min_points 可以提高到 50 以上。冠幅这里用 XY 坐标最大值和最小值的差来近似,方向固定,只适合粗略统计;要更准确可以计算凸包或 PCA 主方向直径,但对多数林业调查场景,这个近似已经够用了。

注意epsilon和min_points的匹配关系。如果 eps 设置过大,两棵树的树冠直接合并成一个簇,stats 里 tree_count 会明显少于实际株数,点云密度大时这个现象尤其明显。如果 stats 里出现大量 point_count 只有十几的簇,多半是 eps 太小把一棵树切碎了,或者灌木没过滤干净。

3.3 聚类结果后处理与文件输出:写出 LAS 并生成汇总表

分割完成只是第一步,API 使用者需要拿到可交付的结果。常见做法是把每棵树的点云单独导出为一个 LAS 文件,同时生成一个 CSV 汇总表,包含每棵树的编号、点数、树高和冠幅。写 LAS 文件时有两个关键设置:scale 和 offset。LAS 格式内部用 int32 存储坐标,浮点坐标要乘以 scale 并减去 offset 才能转成整数,如果 scale 太大或 offset 不设置,输出文件的坐标会偏移甚至溢出。

import csv def write_tree_las(output_path, points): """把单个树簇的点云写成 LAS 文件。""" header = laspy.LasHeader(point_format=3, version="1.2") header.scales = np.array([0.001, 0.001, 0.001]) header.offsets = points.min(axis=0) las = laspy.LasData(header) las.x = points[:, 0] las.y = points[:, 1] las.z = points[:, 2] las.write(output_path) def write_summary_csv(stats, output_path): """把每棵树的统计信息写入 CSV。""" with open(output_path, "w", newline="") as f: writer = csv.DictWriter(f, fieldnames=["tree_id", "point_count", "max_height", "height_range", "crown_diameter"]) writer.writeheader() writer.writerows(stats)

scale 设为 0.001 表示坐标精度到毫米,满足林业测量的常规需求。offset 设为当前树簇点云的坐标最小值,这样单棵树的局部坐标数值较小,不会触发 int32 溢出。如果整个样地一起输出一个文件,offset 应该用原始点云的全局最小值,而不是单棵树的最小值。

文件输出后,整个最小实现已经闭环:输入一个 LAS,得到一组单木 LAS 文件和一张汇总表。到这一步,你已经可以在本地脚本里完成单木分割。但脚本每次都要手动改参数、手动跑,换成 API 服务后,别人就能直接调用你的分割能力,这也是标题里 API 的含义。

4. 把分割流程封装成 API:FastAPI 接口、异步任务与参数设计

4.1 FastAPI 接口设计:输入输出与参数模型

把分割流程封装成 HTTP 服务,常见做法是用 FastAPI,配合 python-multipart 处理文件上传。接口接收一个 LAS/LAZ 文件和四个参数,返回一个 zip 包和一棵树的 CSV 汇总。下面是一个最小可用实现。

from fastapi import FastAPI, File, UploadFile, Form, BackgroundTasks from fastapi.responses import FileResponse, JSONResponse import uuid, zipfile, shutil, os app = FastAPI(title="Point Cloud Tree Segmentation API") JOBS = {} BASE_DIR = "workdir" os.makedirs(BASE_DIR, exist_ok=True) @app.post("/segment") async def create_segmentation_job( background_tasks: BackgroundTasks, file: UploadFile = File(...), eps: float = Form(1.2), min_points: int = Form(30), voxel_size: float = Form(0.3), min_height: float = Form(2.0), ): """提交单木分割任务,返回任务 ID。""" job_id = uuid.uuid4().hex input_path = os.path.join(BASE_DIR, f"{job_id}_input.las") with open(input_path, "wb") as f: shutil.copyfileobj(file.file, f) JOBS[job_id] = {"status": "pending", "input_path": input_path} background_tasks.add_task(run_job, job_id, input_path, eps, min_points, voxel_size, min_height) return {"job_id": job_id, "status": "pending"}

这里四个参数用Form(...)声明,是因为文件用 multipart 上传时,普通字段会以表单形式提交。eps和voxel_size是浮点数,min_points是整数,min_height用于过滤低矮灌木。上传文件先写到本地磁盘再处理,避免大文件长时间占用内存。

FastAPI 自动生成接口文档,启动后访问/docs就能看到参数说明和测试界面。接口返回的job_id是后续查询任务状态的钥匙,不要在前端直接等接口返回结果,因为大点云文件分割可能要几十秒到几分钟,HTTP 请求很容易超时。

4.2 异步任务设计:大点云文件不在请求线程里分割

同步处理大文件是 API 设计里最容易翻车的地方。如果直接在 POST 请求里执行分割,一个百万点的点云可能让请求挂起几分钟,前端以为服务挂了。正确做法是后台任务加任务状态查询:提交后立刻返回任务 ID,前端轮询任务状态,完成后从下载接口拿结果。

def run_job(job_id, input_path, eps, min_points, voxel_size, min_height): """后台执行分割并打包结果。""" try: JOBS[job_id] = {"status": "running", "input_path": input_path} result = run_segmentation(input_path, eps, min_points, voxel_size, min_height) if result["status"] != "ok": JOBS[job_id] = {"status": "failed", "error": "no points remain after filtering"} return output_dir = os.path.join(BASE_DIR, job_id) os.makedirs(output_dir, exist_ok=True) zip_path = os.path.join(output_dir, "trees.zip") with zipfile.ZipFile(zip_path, "w") as zf: for i, cluster_points in enumerate(result["tree_clusters"]): las_name = f"tree_{i + 1:03d}.las" las_path = os.path.join(output_dir, las_name) write_tree_las(las_path, cluster_points) zf.write(las_path, arcname=las_name) write_summary_csv(result["stats"], os.path.join(output_dir, "summary.csv")) zf_path = os.path.join(output_dir, "trees.zip") with zipfile.ZipFile(zf_path, "a") as zf: zf.write(os.path.join(output_dir, "summary.csv"), arcname="summary.csv") JOBS[job_id] = { "status": "done", "tree_count": result["tree_count"], "zip_path": zf_path, "input_path": input_path, } except Exception as exc: JOBS[job_id] = {"status": "failed", "error": str(exc)} @app.get("/jobs/{job_id}") async def get_job(job_id: str): """查询任务状态。""" job = JOBS.get(job_id) if job is None: return JSONResponse({"error": "not found"}, status_code=404) if job["status"] == "done": return {"status": "done", "tree_count": job["tree_count"], "download_url": f"/jobs/{job_id}/download"} return {"status": job["status"]} @app.get("/jobs/{job_id}/download") async def download_result(job_id: str): """下载分割结果 zip 包。""" job = JOBS.get(job_id) if job is None or job["status"] != "done": return JSONResponse({"error": "not ready"}, status_code=404) return FileResponse(job["zip_path"], filename=f"trees_{job_id}.zip")

这里用一个内存字典JOBS保存任务状态。单进程、单 worker 测试没问题,但生产环境如果开了多个 worker,每个进程的字典不共享,任务状态会互相看不到。常见做法是把任务状态放到 Redis 等外部存储,或者用 Celery/ARQ 这类任务队列。对个人工具和小团队内部系统,内存字典足够;对外提供服务,强烈建议换外部存储。

轮询间隔建议 2 到 3 秒查一次。任务状态从 pending 到 running 再到 done,失败时会有 error 字段说明原因。整个异步模型让大文件分割变得可靠,这是 API 化与本地脚本最大的区别。

4.3 本地跑通与调用测试:uvicorn 启动和 curl 验证

本地验证整条链路,先启动服务,再用 curl 提交一个 LAS 文件。uvicorn 启动命令如下,监听 8000 端口。

uvicorn main:app --host 0.0.0.0 --port 8000

启动后用 curl 提交分割任务,参数通过-F传递,文件用@指定路径。

curl -X POST http://127.0.0.1:8000/segment \ -F "file=@./sample.las" \ -F "eps=1.2" \ -F "min_points=30" \ -F "voxel_size=0.3" \ -F "min_height=2.0"

返回结果是一个 JSON,里面包含job_id和status=pending。拿到 job_id 后轮询任务状态:

curl http://127.0.0.1:8000/jobs/<job_id>

状态变为 done 后,下载结果包:

curl -O -L http://127.0.0.1:8000/jobs/<job_id>/download

下载得到的 zip 包里是每棵树的 LAS 文件和 summary.csv。用 CloudCompare 或 QGIS 打开,可以快速检查分割结果是否符合预期。第一次跑通后,建议用一个小样地数据(几百棵树规模)做基准测试,记录不同参数下的 tree_count 和运行时间,后续调参就有参照了。

5. 单木分割避坑指南:5 个踩坑点与参数调优方法

5.1 参数相关的三个坑:过分割、欠分割与灌木误判

坑一:体素下采样后 eps 没有跟着调,一棵树被切成七八块。现象是分割结果里出现大量点数为几十的小簇,同一棵树的树冠明显被拆散。原因是体素下采样把点间距拉大了,原来的 eps 小于采样后点间距的 2 倍,树冠内部的点无法连通成簇。解决办法是调参顺序固定为“先定 voxel_size,再算下采样后的点间距,最后定 eps”。点间距可以用np.min(pdist)估算,也可以直接用经验公式:eps 不小于体素尺寸的 2 倍,机载数据通常 1.0 到 1.5 米。如果你的样地是密植人工林,树间距小,eps 要相应下调。

坑二:相邻树冠交错合并,两个树冠输出成一个簇。现象是 stats 里出现点数量特别大的簇,空间上明显覆盖两棵树的位置。原因是树冠枝条交错后,两棵树之间的最近点距离小于 eps,聚类把中间的空隙也连接起来了。单纯调小 eps 能缓解,但容易导致过分割。我一般会加一个高度约束的二次处理:把簇内点按高度排序,只保留上部 70% 的点重新聚类,树冠上部的间隙通常比下部明显,更容易分开。如果你用的是机载数据,还可以尝试对点云做高程归一化后,单独在树冠层做一次聚类,效果比直接全点聚类要好。

坑三:低矮灌木被当成树输出。现象是结果里有大量点数量在 20 到 80、高度只有 1 到 3 米的簇。原因是欧氏距离聚类只认距离,不区分乔木和灌木。解决办法是组合使用高度阈值和点数阈值:min_height过滤低矮点,min_points过滤小簇,输出阶段再加一道按树高筛选。比如设定只有 max_height 大于 5 米的簇才写入最终结果。矿业和林业项目里,这道筛选能滤掉一半以上的假目标。

5.2 数据与实现相关的两个坑:点密度不均与 LAS 写入异常

坑四:机载点云密度不均匀,稀疏区域的大树被拆分,稠密区域的小树被合并。现象是同一片样地里,有些树输出为多个簇,有些树两棵合并。原因是扫描角度和遮挡导致树冠各部位点密度差异太大,稀疏区域树冠内部点间距超过 eps,稠密区域树间间隙又被点填平。解决思路是先统计整体点密度,再做体素下采样把密度拉均匀。如果单棵树平均点数低于 50,说明数据本身太稀,欧氏聚类已经不是合适工具,可以考虑改用 CHM 分水岭。对密度差异特别大的数据,可以按区域分块设置不同 eps,但这会让 API 参数设计复杂化,我通常优先保证均匀下采样。

坑五:laspy 版本差异导致读取报错,或者写出的 LAS 坐标偏移。现象是老脚本在新环境跑直接报AttributeError,或者在 GIS 里打开输出文件位置偏离真实坐标。原因是 laspy 1.x 和 2.x 接口不兼容,以及写文件时没有正确设置 scale 和 offset。解决办法是统一用 2.x 语法las.x / las.y / las.z,写文件时显式设置header.scales = (0.001, 0.001, 0.001)和header.offsets。另外 LAZ 压缩格式需要 laszip 支持,没有安装对应扩展会读取失败,遇到这种情况可以先转成 LAS 再处理。

6. 验证分割效果与批量调参:让结果可交付

6.1 先做人工对照,再用指标说话

分割结果可不可靠,不能只看 tree_count。我一般会先在点云可视化工具里加载原始点云和分割结果,把每个簇用不同颜色渲染,快速判断有没有明显欠分割或过分割。然后选一个 100 棵树左右的子区域,人工标出树顶位置作为参考,再对比算法输出。人工标注只需要在点云缩略图里点出明显的树冠最高点,这一步不追求像素级准确,足够评估算法效果。

6.2 完整率、正确率与 F1 三个指标

评估单木分割,三个指标最常用。完整率等于正确分割的树的数量除以人工参考树的总数,衡量有没有漏树;正确率等于正确分割的树的数量除以算法输出的簇总数,衡量有没有多分;F1 是两者的调和平均,综合反映分割质量。判定“一棵树分割正确”的标准是:算法输出的某个簇与某棵人工参考树在水平位置上的偏差小于该树冠幅的一半。

6.3 批量参数搜索脚本:快速找到最优组合

参数调优靠手试效率太低,写一个简单网格搜索循环,把所有组合跑一遍,把 tree_count 和指标输出成表,一眼就能看出参数趋势。

import itertools param_grid = { "eps": [0.8, 1.0, 1.2, 1.5], "min_points": [20, 30, 50], "voxel_size": [0.2, 0.3, 0.5], } for eps, min_points, voxel_size in itertools.product( param_grid["eps"], param_grid["min_points"], param_grid["voxel_size"] ): result = run_segmentation("sample.las", eps, min_points, voxel_size, min_height=2.0) print(eps, min_points, voxel_size, result["tree_count"])

跑完一遍,你会直观看到 eps 从 0.8 升到 1.5 时 tree_count 的变化曲线,多数数据会呈现“快速增长后趋稳再下降”的形态,拐点附近就是较优参数。我现在的习惯是先跑一组小体素网格试探趋势,再在拐点附近加密搜索。算法本身不复杂,但参数调优的流程和验证方法才是 API 稳定输出的保障。希望这篇文章能帮你在自己的点云数据上少走几步弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询