☰
基于欧氏距离聚类的点云单木分割API工程实践
2026/10/27 2:20:44 网站建设 项目流程

简介:面向点云数据处理与林业遥感方向的开发者与学习者,这份资料围绕PCL欧氏距离聚类实现从点云数据中单木分割展开,适合具备C++基础、希望把聚类算法落地到真实点云场景的中级读者参考。包体共3个文件,包含一份可直接编译运行的cpp源码、一份xyz格式的点云样例数据以及一份txt聚类结果输出;压缩包约93KB,体量轻便,便于快速跑通流程,再对照结果验证分割效果。源码调用PCL中的欧氏距离聚类接口完成点云分组,读者可据此了解邻域半径、最小簇点数等参数对分割结果的影响,并结合样例数据观察单木点簇的分离情况与输出格式,为后续在自有扫描数据上调整参数、扩展处理流程提供可直接复用的脚本基础。目前已有813人学习下载,可配合作者博客中的原理说明与效果分析一并查阅,形成从理解思路到动手复现的完整链路。

1. 从一片林子到一棵棵树:欧氏距离聚类为什么是单木分割的第一块砖

一片 200 米见方的林地点云丢过来,几十万到上千万个点,需求方要的不是一张能旋转的彩色图,而是「第 17 号树在哪、多高、冠幅多大」。在点云数据上做单木分割,最直接的一刀就是欧氏距离聚类:两个点在三维空间里的距离小于容差就归到同一簇,一簇默认对应一棵树。这个项目把这套流程封成了 API,上传 las/ply,填几个参数,拿回带tree_id的点云和一份单木清单 JSON,前端和业务系统都能直接调。适合两类人:一类是拿到林业点云但不想从零写聚类的业务开发,一类是想找个能跑通的基线、再往上叠区域生长或深度学习的算法同学。

2. 工程结构与数据流:一次分割请求要经过哪几道工序

把算法做成 API,最容易翻车的地方不是聚类本身,而是中间那堆工程细节:点云怎么读、临时文件放哪、百万点跑多久、失败了怎么告诉调用方。所以先看结构,再看链路。

2.1 目录结构与模块边界

工程按「接口层 / 算法层 / IO 层」切,算法层不 import 任何 Web 框架的东西,这样以后换 FastAPI 换成 Flask、或者直接挂到 Celery 上,算法代码一行不用动。

tree-seg-api/ ├── app/ │ ├── main.py # FastAPI 实例、路由注册、启动钩子 │ ├── schemas.py # 请求/响应的 Pydantic 模型 │ ├── routers/ │ │ └── segment.py # /api/v1/segment 与 /api/v1/jobs/{id} │ └── jobs.py # 任务状态表、进程池、临时目录清理 ├── core/ │ ├── io_utils.py # las/ply/pcd 读写,坐标系与单位检查 │ ├── preprocess.py # 地面点剔除、体素下采样、离群点滤波 │ ├── cluster.py # 欧氏距离聚类(KD-Tree + BFS) │ ├── postprocess.py # 簇过滤、单木属性统计、标签回填 │ └── pipeline.py # 把上面几步串成一条流水线 ├── tests/ │ └── test_cluster.py # 拿小样本点云做回归 ├── requirements.txt └── README.md

这个切法的好处是边界清楚:core/里的函数签名都是「numpy 数组进、numpy 数组或 dict 出」,写测试时不需要起服务。cluster.py只认坐标数组,不认文件格式,所以后面换后端(Open3D、PCL、甚至自己写的区域生长)只要保证输出一份labels数组就行。app/jobs.py是唯一知道「任务」这个概念的地方,任务 id、状态、结果路径都存在这里,进程重启就丢,够用且不引入 Redis。

依赖装起来不复杂,核心就几个:

pip install fastapi uvicorn[standard] python-multipart \ numpy scipy laspy open3d

scipy提供cKDTree做半径搜索,laspy读 las/laz,open3d主要用来做备选的 DBSCAN 后端和 ply 读写。如果部署环境是纯 headless 容器,open3d可能会因为缺 OpenGL 库起不来,这一点在第 5 章会专门讲,稳妥起见主链路用scipy实现,open3d只当可选项。

2.2 一次请求的完整链路:从 upload 到 tree_id

把流水线摊开看,一次分割请求经过这些步骤:上传文件落到临时目录 → 读取点云并做单位与坐标系检查 → 剔除地面点 → 体素下采样 → 统计离群点滤波 → 欧氏距离聚类 → 簇过滤(按高度、点数、冠幅)→ 单木属性统计 → 标签回填到原始点 → 写出带tree_id的 las 和结果 JSON → 更新任务状态。

每一步都可能失败,所以pipeline.py里用显式的阶段名把异常包一层,报错时能告诉调用方是「读文件失败」还是「聚类超时」,而不是一个干巴巴的 500。

# core/pipeline.py import numpy as np from core.io_utils import load_points, write_labeled_las from core.preprocess import strip_ground, voxel_down, statistical_outlier from core.cluster import euclidean_cluster from core.postprocess import cluster_stats, propagate_labels def run_pipeline(src_path: str, out_dir: str, params: dict) -> dict: # 1. 读点云,(N, 3) float64 xyz = load_points(src_path) # 2. 去地面:留下的才是植被点,记录被保留的原始索引 veg_mask = strip_ground_mask(xyz, cell=params["ground_cell"], height_thresh=params["height_thresh"]) veg_idx = np.where(veg_mask)[0] veg = xyz[veg_idx] # 3. 下采样,keep 是 veg 里的局部索引 keep = voxel_down(veg, voxel=params["voxel"]) down = veg[keep] # 4. 去离群点(扫描噪点、飞点),返回布尔掩码 inlier = statistical_outlier(down, k=params["sor_k"], std_ratio=params["sor_std"]) down_clean = down[inlier] keep_clean = keep[inlier] # 映射回 veg 的局部索引 # 5. 欧氏距离聚类,只在干净的下采样点上跑 labels_down = euclidean_cluster( down_clean, tolerance=params["tolerance"], min_points=params["min_points"], max_points=params["max_points"], ) # 6. 簇过滤 + 属性统计 trees = cluster_stats(down_clean, labels_down, min_height=params["min_height"], min_crown_area=params["min_crown_area"]) # 7. 把下采样点的标签回填到全部植被点,再合并回原始点序 labels_veg = propagate_labels(veg, down_clean, labels_down, radius=params["tolerance"] * 1.5) labels_full = np.full(len(xyz), -1, dtype=np.int32) labels_full[veg_idx] = labels_veg # 8. 落盘 las_path = write_labeled_las(out_dir, xyz, labels_full) return {"tree_count": len(trees), "trees": trees, "las_path": las_path}

这段是整个项目的骨架,几个参数值得单独说明。ground_cell是去地面时划分格网的边长,取 1.0 米对大多数林分够用,地形起伏大的地方要调小到 0.5;height_thresh是「高于局部地面多少米才算植被」,1.5 米能滤掉大部分灌木草本;voxel决定下采样后还剩多少点,一般取tolerance的 1/3 到 1/5,保证簇内不会因为太稀疏而断开;sor_k和sor_std是统计离群点滤波的邻居数和标准差倍数,默认 20 和 2.0。

提示:propagate_labels这一步很关键,也是新手最容易漏的。聚类是在下采样点云上做的,如果直接把labels_down当成结果输出,原始点云里会有一大半点的tree_id是 -1,下游按树提取点时就会缺一大块。

链路里还有一个隐性的顺序问题:先下采样再聚类,比先聚类再下采样快一个数量级,但代价是空间分辨率下降,两棵挨得很近的小树可能被合并。我的做法是把voxel作为接口参数暴露出去,默认 0.1 米,遇到密林小径级的数据让调用方自己降到 0.05。

3. 核心算法拆解:欧氏距离聚类的四步实现

聚类本身只有二十来行,但真正决定结果好坏的是它前后的三步:去地面、下采样、簇过滤。这一章按执行顺序拆。

3.1 读点云与地面点剔除:别让地面把整片林子缝在一起

欧氏距离聚类最典型的翻车现场是:跑完了,tree_count = 1。原因八成是地面点没去掉——树和树之间在地面这一层是连着的,容差再小也能顺着地面一路连过去,最后整片林子在算法眼里就是一坨。

去地面有轻重两档。重的那档用布料模拟滤波(CSF),效果好在能贴合起伏地形,但要额外装包、调参;轻的那档就是格网最低点法:把 XY 平面切成 1 米见方的格子,每个格子取最低点当局部地面高程,再用地面上方高度做阈值。林地里树冠遮挡不严重时,后者足够用,而且纯 numpy 几行就写完了。

import numpy as np import laspy def load_points(path: str) -> np.ndarray: """读 las/laz,返回 (N, 3) 的 float64 真实坐标。""" las = laspy.read(path) # las.x 是 scale/offset 还原后的真实坐标,别去读 las.X(那是整型原始值) return np.column_stack([las.x, las.y, las.z]).astype(np.float64) def strip_ground_mask(xyz, cell=1.0, height_thresh=1.5): """格网最低点法:返回布尔掩码,True 表示是植被点。""" key = np.floor(xyz[:, :2] / cell).astype(np.int64) # 把二维格网坐标压成唯一键,再按 key 求每组的 z 最小值 uniq, inv = np.unique(key, axis=0, return_inverse=True) ground = np.full(len(uniq), np.inf, dtype=np.float64) np.minimum.at(ground, inv, xyz[:, 2]) return (xyz[:, 2] - ground[inv]) > height_thresh def voxel_down(xyz, voxel=0.1): """体素下采样,返回保留下来的点在原数组里的索引。""" key = np.floor(xyz / voxel).astype(np.int64) _, idx = np.unique(key, axis=0, return_index=True) return idx

np.minimum.at是这里的性能关键,它按inv索引把每个点的 z 值散射到对应的格网槽里取最小,避免了 Python 层的 for 循环,百万点也就一两秒。strip_ground_mask返回掩码而不是直接返回点,是为了让上层能记录原始索引,后面标签回填时要用。voxel_down返回索引而不是点,同理——下采样只改变「参与聚类的点集」,最终输出还是要回到原始点序上。

单位问题必须在这里堵住。有些数据是厘米坐标,x的取值范围到几万;有些是经纬度加高程,x只有小数点后几位。前者会让tolerance=0.5变成 0.5 厘米,一个簇都聚不出来;后者会让所有点在平面上挤成一团。稳妥的做法是在load_points之后加一道检查:如果 XY 的跨度小于 1,基本可以判定是经纬度,直接报错让调用方先投影;如果跨度大于 10000,提示可能是厘米单位。

注意:格网最低点法在陡坡上会把坡面误判成地面,导致坡上植被被削掉一截。遇到地形起伏超过 30 米的数据,老老实实换 CSF,别硬扛。

3.2 欧氏聚类主循环:KD-Tree 加广度优先

算法本身不复杂:对每个还没被访问的点,以它为种子做一次半径搜索,把半径内的点拉进同一个簇,再对新入队的点重复半径搜索,直到队列空。用cKDTree做半径查询,复杂度从 O(N²) 降到 O(N log N)。

from collections import deque import numpy as np from scipy.spatial import cKDTree def euclidean_cluster(xyz, tolerance=0.5, min_points=30, max_points=500000): """欧氏距离聚类,返回 (N,) 的 int32 标签,-1 表示噪声/被过滤。""" n = len(xyz) labels = np.full(n, -1, dtype=np.int32) visited = np.zeros(n, dtype=bool) tree = cKDTree(xyz) cluster_id = 0 for seed in range(n): if visited[seed]: continue # 从种子点开一次 BFS visited[seed] = True queue = deque([seed]) members = [] while queue: cur = queue.popleft() members.append(cur) # 半径内的所有邻居,返回的是索引列表 nbrs = tree.query_ball_point(xyz[cur], r=tolerance) for nb in nbrs: if not visited[nb]: visited[nb] = True queue.append(nb) # 簇大小过滤:太小是噪点,太大基本是地面残留或参数给错了 if min_points <= len(members) <= max_points: labels[members] = cluster_id cluster_id += 1 return labels

tolerance是唯一真正影响分割粒度的参数,物理含义是「两个点距离多近才算同一棵树的一部分」。取值要看点密度:机载雷达点云点间距大,取 0.8~1.5 米;背包或地基扫描点间距小,取 0.2~0.5 米。经验公式是先估一下平均最近邻距离,tolerance取它的 2~3 倍。

min_points是噪点闸门,30 是个保守值,密点云可以提到 100;max_points是熔断阀,防止某个簇把整片林子吞了还傻乎乎地算下去,设成总点数的 20% 左右比较合理。被过滤掉的簇,其成员标签会被置回 -1,但它们已经被标记过visited,不会在后续种子里重复处理——这是用visited而不是用labels != -1做判断的原因,避免一个大簇被反复扫描。

纯 Python 的双层循环在百万点上会明显变慢,实测 100 万点跑到几分钟。生产环境有三条路:一是把下采样做狠一点,voxel提到 0.2 米,点数直接掉一个量级;二是换成 Open3D 的 C++ 实现,一行pcd.cluster_dbscan(eps=tolerance, min_points=min_points),语义几乎等价,速度快好几倍;三是按 XY 分块并行,块之间留一圈重叠区防止把跨块的树切断,最后按簇质心做一次合并。

import open3d as o3d def cluster_open3d(xyz, tolerance=0.5, min_points=30): pcd = o3d.geometry.PointCloud() pcd.points = o3d.utility.Vector3dVector(xyz) labels = np.asarray(pcd.cluster_dbscan( eps=tolerance, min_points=min_points, print_progress=False )).astype(np.int32) return labels # -1 是噪声,其余从 0 开始

两个后端的差别在于:Open3D 的cluster_dbscan会额外要求「核心点」概念(邻域内点数够多的点才能扩展簇),对小而稀疏的树可能更保守,有时会把一棵树打散。切后端的时候要用同一份样本对比树的数量和点召回率,不能想当然认为两者等价。

3.3 簇过滤与单木属性统计:从簇到一棵树

聚类出来的簇不一定都是树。可能是残留的地面块、可能是灌木丛、可能是扫描杆。过滤规则按业务定,最常用的三条是:树高、点数、冠幅面积。

import numpy as np def cluster_stats(xyz, labels, min_height=2.0, min_crown_area=0.5): """对每个簇计算单木属性,返回通过的树列表。""" trees = [] for cid in np.unique(labels): if cid < 0: continue pts = xyz[labels == cid] z = pts[:, 2] height = float(z.max() - z.min()) if height < min_height: # 太矮,灌木 continue # 冠幅用 5%~95% 分位跨度,比 min/max 抗飞点 span_x = np.percentile(pts[:, 0], 95) - np.percentile(pts[:, 0], 5) span_y = np.percentile(pts[:, 1], 95) - np.percentile(pts[:, 1], 5) if span_x * span_y < min_crown_area: continue trees.append({ "cluster_id": int(cid), "point_count": int(len(pts)), "cx": float(pts[:, 0].mean()), "cy": float(pts[:, 1].mean()), "z_min": float(z.min()), "z_max": float(z.max()), "height": height, "crown_w": float(max(span_x, span_y)), }) return trees

树高用簇内 z 的最大值减最小值,比用均值靠谱,因为树冠顶部的几个点就代表树高。冠幅如果直接用x.max() - x.min(),一个飞点就能把冠幅拉到几十米,所以用分位区间。min_height默认 2.0 米是林业上区分乔木和灌木的常见门限,苗圃或幼林数据要降到 0.5。

统计出来的cx/cy是单木位置,可以转成 GeoJSON 点图层直接丢进 GIS;tree_id写回 las 之后,可以在 CloudCompare 里按标量字段着色,一眼看出哪棵树被切碎了、哪两棵被粘在一起。

标签回填这一步单独拿出来说,因为它是「下采样聚类」这套做法必须配的补丁:

from scipy.spatial import cKDTree import numpy as np def propagate_labels(full_xyz, down_xyz, down_labels, radius=0.75): """把下采样点的标签按最近邻回填到全部植被点。""" valid = down_labels >= 0 tree = cKDTree(down_xyz[valid]) dist, idx = tree.query(full_xyz, k=1, distance_upper_bound=radius) out = np.full(len(full_xyz), -1, dtype=np.int32) hit = np.isfinite(dist) out[hit] = down_labels[valid][idx[hit]] # 索引对齐到有效点集 return out

radius控制回填范围,一般取tolerance的 1.5 倍;超出范围的点保持 -1,代表「离所有树都太远,不归属任何单木」。别把radius设得太大,否则相邻两棵树的点会被互相吸过去,边界变得模糊。

4. API 服务层封装:把算法包成能并发调用的 HTTP 接口

算法跑通只是第一步,包成接口后要面对的是:几十兆的 las 怎么上传、一次跑三分钟怎么不超时、两个请求同时进来内存会不会炸。

4.1 接口契约:上传、参数与任务状态

分割是重计算,同步返回必然超时,所以设计成「提交拿任务 id,轮询查结果」的异步模式。两个接口:POST /api/v1/segment提交,GET /api/v1/jobs/{job_id}查询。

# app/routers/segment.py import uuid from pathlib import Path from fastapi import APIRouter, UploadFile, File, Form, HTTPException from app.jobs import submit_job, get_job router = APIRouter(prefix="/api/v1") UPLOAD_DIR = Path("/data/uploads") @router.post("/segment") async def segment( file: UploadFile = File(...), tolerance: float = Form(0.5), min_points: int = Form(30), voxel: float = Form(0.1), height_thresh: float = Form(1.5), min_height: float = Form(2.0), ): if not file.filename.lower().endswith((".las", ".laz", ".ply", ".pcd")): raise HTTPException(400, "unsupported format") job_id = uuid.uuid4().hex dst = UPLOAD_DIR / f"{job_id}_{file.filename}" # 流式落盘,别 await file.read() 整个读进内存 with dst.open("wb") as f: while chunk := await file.read(1 << 20): f.write(chunk) params = {"tolerance": tolerance, "min_points": min_points, "voxel": voxel, "height_thresh": height_thresh, "min_height": min_height} submit_job(job_id, dst, params) return {"job_id": job_id, "status": "queued"} @router.get("/jobs/{job_id}") def job_status(job_id: str): job = get_job(job_id) if job is None: raise HTTPException(404, "job not found") return job # {"status": "running|done|failed", "tree_count": .., "result_url": ..}

Form参数直接对应聚类参数,调用方不用改配置文件。上传用 1MB 分块写盘,是为了避免 500MB 的 las 一次性进内存——这一点在容器内存限制下非常致命。文件名前缀加job_id,是为了防止同名文件互相覆盖。

返回体里只给job_id和状态,树列表和结果文件地址放在查询接口里。这样即使结果有几百棵树、几十 KB 的 JSON,也不会卡在提交响应上。

4.2 执行层:进程池、临时文件与任务状态

任务执行用进程池而不是线程池。理由很直接:聚类是纯 CPU 计算,Python 有 GIL,线程池开了也只有一个核在跑;而cKDTree这种 C 扩展虽然会释放 GIL,但预处理里的 numpy 运算和 Python 循环还是吃单核。用ProcessPoolExecutor,worker 数量按「内存 / 单份点云大小」定,比按核数定更靠谱。

# app/jobs.py import time, traceback from concurrent.futures import ProcessPoolExecutor from pathlib import Path from core.pipeline import run_pipeline _EXECUTOR = ProcessPoolExecutor(max_workers=2) # 每 worker 吃一份点云,按内存定 _JOBS: dict[str, dict] = {} def _run(job_id: str, src: Path, params: dict): _JOBS[job_id].update(status="running", started_at=time.time()) try: out_dir = src.parent / job_id out_dir.mkdir(exist_ok=True) result = run_pipeline(str(src), str(out_dir), params) _JOBS[job_id].update(status="done", tree_count=result["tree_count"], result_url=f"/files/{job_id}/labeled.las", trees=result["trees"]) except Exception as e: _JOBS[job_id].update(status="failed", error=f"{type(e).__name__}: {e}", trace=traceback.format_exc()[-2000:]) def submit_job(job_id, src, params): _JOBS[job_id] = {"status": "queued", "job_id": job_id} _EXECUTOR.submit(_run, job_id, src, params) def get_job(job_id): return _JOBS.get(job_id)

_run把异常兜住并截断 traceback 存进任务状态,是为了让失败可诊断——线上最常见的失败是文件格式不对和内存不足,前者抛laspy的解析异常,后者直接被 OOM Killer 干掉、连异常都没有,所以还得在容器层配memory限制和重启策略。

临时文件的清理策略是「任务完成后保留 24 小时」。用APScheduler或者一个后台线程定时扫UPLOAD_DIR,按 mtime 删掉过期的job_id目录。别在请求线程里删,一是会拖慢响应,二是用户可能还要重下结果。

提示:_JOBS存在进程内存里,uvicorn 开多 worker 时每个 worker 各有一份,轮询可能打到没有这个任务的进程上返回 404。要么用--workers 1加进程内池,要么把这层换成 Redis 存状态。小规模内部用前者足够。

并发这块还有一个容易忽略的点:ProcessPoolExecutor的max_workers=2意味着第三个请求会排队,而排队期间上传的文件已经落盘了。如果前端不轮询、用户反复点提交,临时目录很快就会堆满。稳妥做法是在提交接口里加一层轻量的限流,按 IP 或 token 记一个「进行中任务数」,超过阈值直接返回 429,让调用方等一等。

5. 踩坑与排查:从服务起不来,到整片林子只输出一棵树

这套东西我按不同数据反复跑过,问题基本集中在两类:环境和数据进不来,以及结果不对。下面按「现象 → 原因 → 解决」记。

5.1 起服务与读数据阶段的翻车点

现象一:容器里import open3d直接报libGL.so.1: cannot open shared object file,服务启动即退出。原因是 Open3D 的可视化模块链接了 OpenGL,headless 镜像里没有这些系统库,而import时就会加载。解决办法有两条:装libgl1和libglib2.0-0,或者干脆不用 Open3D——把主链路的聚类留在scipy实现上,Open3D 只在需要读 ply 时按需导入。我一般选后者,镜像小一半,启动也快。

现象二:接口返回 200,但tree_count = 0,日志里聚类阶段耗时极短。八成是坐标单位问题。厘米坐标下tolerance=0.5相当于 5 毫米,任何两个点都连不上;经纬度坐标下 XY 跨度只有零点几,所有点挤成一个簇然后被max_points一刀切掉。排查方法是读进来先打印xyz.min(0)和xyz.max(0),XY 跨度小于 10 基本就是经纬度,大于 10000 大概率是厘米。解决是在load_points之后做单位归一化:厘米除以 100,经纬度直接拒绝并提示先投影到平面坐标。

现象三:小文件跑得飞快,一上传 300MB 的 las 服务就被杀,日志里没有任何异常。这是 OOM,被系统直接 kill,Python 层抓不到。原因有两层:laspy.read把整份点云读成数组,下采样之前峰值内存是原始点数的好几倍;进程池里同时跑两个任务,内存直接翻倍。解决是控制并发数(max_workers按「可用内存 / 单份点云峰值」算,不是按 CPU 核数),以及在大文件上先做一次分块读取或者降采样。laspy支持分块流式读,但代码复杂度会上来,内部工具没必要。

现象四:FileNotFoundError,路径里带着中文或者空格。上传文件名直接拼进路径,遇到中文或特殊字符时,某些文件系统 API 会出问题。解决是把落盘文件名改成job_id + 扩展名,原始文件名只存在任务元数据里用于展示。这样还能顺带避免路径穿越。

5.2 结果质量阶段的排查路径

现象五:整片林子只输出一棵树,tree_count = 1,点云上所有点一个颜色。原因通常是地面点没剔干净,或者tolerance给大了。判断方法很简单:把height_thresh调到 0,看是不是所有点都成了植被——如果是,说明去地面那一步没生效,可能是cell太大或者数据本身没有地面点。另一个可能是max_points设得太大,一个吞掉全场的簇被当成合法结果保留了下来。解决顺序是:先确认去地面后的点数占比(林地里植被点一般占 60%~90%),再看tolerance是不是超过了平均株距的一半,最后把max_points压到总点数的 10% 当熔断。

现象六:一棵大树被切成三四块,输出里同一个位置有好几个tree_id。过分割,原因通常是点云稀疏或tolerance小于点间距。机载数据在树冠边缘点很稀,半径搜索连不上,BFS 自然断开。三个办法按代价从低到高:把voxel调小让下采样保留更多点(但同时更慢);把tolerance提到平均最近邻距离的 3 倍;或者在聚类后加一步合并——如果两个簇的质心水平距离小于 1.5 米、且 z 区间重叠超过 50%,就合并成一个tree_id。合并逻辑十行代码,能救回大部分过分割。

现象七:结果 las 在 CloudCompare 里打开,大部分点的tree_id是 -1。这就是 3.3 里说的标签回填漏了。聚类在下采样点上做,输出却直接用了labels_down,原始点对不上。检查pipeline.py里有没有propagate_labels这一步,以及回填用的radius是不是小于了点间距。还有一个隐性原因:回填时down_labels[valid][idx[hit]]的索引没对齐——cKDTree是在down_xyz[valid]上建的,查询得到的idx是有效点子集的局部索引,必须用同一个valid掩码去取标签,顺序错了就会把标签串到别的树上。

现象八:同一份数据跑两次,树的数量差好几棵。如果后端用的是 Open3D 的 DBSCAN,结果本身是确定的;差异通常来自上一步的统计离群点滤波——statistical_outlier里如果用了随机采样或者浮点排序不稳定,掩码会变。解决是把所有涉及排序的地方固定kind="stable",并且不要在流水线里引入任何随机数。可复现性对调试太重要了,宁可贵一点也别引入随机。

6. 二次开发:换聚类后端、加分层策略和一套能回归的验证脚本

真正要在项目里长期用,第一件事是把聚类后端抽象出来,别让pipeline.py绑死在某一个实现上。用一个简单的注册表就够了:

# core/cluster.py 里追加 import numpy as np from typing import Protocol class ClusterBackend(Protocol): name: str def fit(self, xyz: np.ndarray, tolerance: float, min_points: int) -> np.ndarray: ... def _fit_ours(xyz, tolerance, min_points): return euclidean_cluster(xyz, tolerance=tolerance, min_points=min_points) def _fit_open3d(xyz, tolerance, min_points): import open3d as o3d pcd = o3d.geometry.PointCloud() pcd.points = o3d.utility.Vector3dVector(xyz) return np.asarray(pcd.cluster_dbscan(eps=tolerance, min_points=min_points)).astype(np.int32) BACKENDS = {"kdtree": _fit_ours, "open3d": _fit_open3d} def get_backend(name: str): if name not in BACKENDS: raise ValueError(f"unknown backend: {name}") return BACKENDS[name]

接口上加一个backend参数,默认kdtree,遇到大文件让调用方切open3d。这样加区域生长、加分层聚类,都只是往BACKENDS里塞一个函数。

第二个改造点是分层。单一容差对付不了「大树挨着小树」的林分:容差小了,大树在稀疏的树冠处断成几截;容差大了,小树被吸进旁边的大树。做法是先按高度切片,0~5 米用 0.3 的容差,5~15 米用 0.6,15 米以上用 1.0,每层单独聚类,最后按 XY 质心做跨层合并——同一位置的簇归到同一棵树,tree_id取最低层的。这套在混交林上比单层方案稳定不少,代价是要多调三个参数。

第三个是验证。别靠肉眼在 CloudCompare 里数树,做一个最小回归集:挑三块 20 米见方的小样地,手工标出树的位置和大致冠幅,存成 CSV。每次改参数或换后端,跑一遍脚本,把预测簇和真值按质心距离做贪心匹配(质心距离小于 2 米算匹配上),算检出率和误检率。检出率掉了就说明参数调过头了。这个脚本不到一百行,但能省掉大量「感觉这版效果好一点」的扯皮。

从那以后我每接一份新数据,都先切出 5000 个点跑一遍最小闭环,确认tree_count是个合理数字、tree_id没有大面积 -1,再谈调参。跳过这一步直接上全量数据,翻车了连是数据问题还是参数问题都分不清。希望帮到你。

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

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

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

立即咨询