1. 从一堆散点说起:为什么边界排序是个真问题
做过点云处理、轮廓提取或者地理围栏的朋友,大概率都遇到过这样一个场景:算法给你吐出来一堆边界点,坐标是对的,但顺序是乱的。你拿这堆点直接去画多边形,得到的是一团乱麻——线条交叉、区域重叠,完全没法看。更麻烦的是,如果你要把这个边界拿去做面积计算、做区域填充、做碰撞检测,无序的点集根本没法用。
这就是“对无序的边界点排序”要解决的核心问题。说白了一句话:给你一组位于某个区域边缘的散点,你要把它们按照顺时针(或逆时针)的顺序串成一条闭合的边界线。
这件事听起来简单,但实际操作中坑不少。点可能分布不均匀,可能有噪声点混在里面,可能边界本身有凹陷甚至多个连通区域。不同的应用场景对排序结果的要求也不一样——有的只要求视觉上能连成一条线,有的要求严格凸包,有的要求保留原始边界的凹凸细节。
我最早接触这个问题是在一个图像处理的小项目里,从二值图像提取轮廓之后,拿到的就是一堆无序的边缘像素坐标。当时想当然地以为按角度排个序就完事了,结果被现实狠狠教育了一顿。后来陆续在点云边界提取、地理数据可视化、甚至手工切割路径规划里都碰到类似的需求,才慢慢总结出一套比较靠谱的处理思路。
这篇文章适合谁看?如果你正在做以下任何一件事,那接下来的内容应该对你有用:
- 从图像或点云中提取了边界点,需要把它们串成有序的轮廓线
- 需要计算不规则区域的面积或周长
- 要做区域填充、路径规划或者多边形简化
- 单纯对计算几何里的排序算法感兴趣
我会从最朴素的思路讲起,一步步拆解不同方案的适用条件和坑点,最后给出可以直接拿来用的代码实现。整个过程中,我会重点解释“为什么这么做”而不是只给一个结论——因为边界排序这件事,没有银弹,只有根据具体场景选最合适的方案。
2. 核心思路拆解:从“找中心”到“走边界”
2.1 角度排序法:最直觉但也最容易翻车
拿到一堆无序边界点,绝大多数人的第一反应是:找一个中心点,然后按每个点相对于中心点的极角排序。这个思路非常自然——如果边界大致是个凸形,中心点在内部,那么按角度排序确实能得到正确的顺时针顺序。
具体做法是:
- 计算所有点的质心(或者取包围盒中心)
- 对每个点,计算它相对于质心的极角,用
atan2(dy, dx)就行 - 按极角从小到大排序
- 根据需要决定是顺时针还是逆时针(极角递增通常是逆时针,递减是顺时针)
这个方法的代码大概长这样:
import math def sort_by_angle(points): # 计算质心 cx = sum(p[0] for p in points) / len(points) cy = sum(p[1] for p in points) / len(points) # 按极角排序 def angle_key(p): return math.atan2(p[1] - cy, p[0] - cx) return sorted(points, key=angle_key)看起来很简单对吧?但它有几个致命问题。
第一个问题:质心可能不在边界内部。如果边界是一个C形或者月牙形,质心很可能落在区域外面。这时候按角度排序,靠近凹陷处的点会被排到错误的位置,画出来的线会自交。
第二个问题:角度相同的点无法区分。如果两个点相对于质心在同一条射线上(比如质心在原点,点分别在(1,0)和(2,0)),它们的极角完全一样,排序结果就不确定了。虽然实际数据中这种情况不多,但一旦出现就会导致顺序错乱。
第三个问题:对噪声极其敏感。如果边界点里混入了一个远离边界的离群点,质心会被拉偏,整个排序结果都可能受影响。
我当初就是栽在第一个问题上。那是一个类似肾脏形状的区域,质心恰好在凹陷处附近,排序结果惨不忍睹。后来才明白,角度排序法只适用于凸形或近似凸形的边界。对于凹形边界,必须换思路。
2.2 最近邻搜索法:贪心策略的利与弊
既然角度排序对凹形不行,那换个思路:从一个确定的边界点出发,每次找离当前点最近的未访问点作为下一个点。这就是最近邻搜索(Nearest Neighbor)的思路。
具体步骤:
- 选一个起始点(通常选最左下角的点,或者x坐标最小的点)
- 从当前点出发,在未访问的点中找到距离最近的那个
- 把它加入序列,标记为已访问
- 重复直到所有点都被访问
这个方法的优点是:不需要知道中心点在哪,对凹形边界也能工作,而且天然地会沿着边界“走”。
但它也有明显的缺点:
- 贪心策略容易走进死胡同。如果边界上有两个分支靠得很近,最近邻可能会“跳”到另一个分支上去,导致顺序错乱。
- 对点密度不均匀敏感。如果某段边界点很密集,另一段很稀疏,最近邻可能会在密集区域来回跳。
- 计算量大。每次都要找最近点,朴素实现是O(n²),点多了会很慢。
我实测下来,最近邻法在点分布比较均匀、边界比较清晰的情况下效果还不错,但一旦有噪声或者边界复杂,就需要配合其他手段来修正。
2.3 凸包与凹包:先简化再排序
还有一种思路是分两步走:先求凸包(Convex Hull),把凸包上的点排好序,然后再把不在凸包上的点插入到合适的位置。
凸包的好处是:它的顺序是天然确定的,用Graham扫描或者Andrew算法都能在O(n log n)时间内搞定。而且凸包上的点一定是边界点,不会出错。
但问题在于:凸包会丢失凹陷细节。如果你的边界是凹的,凸包会把凹陷处“填平”,那些凹陷处的点就不在凸包上了。你需要额外一步把它们插回去。
插入的策略通常是:对于每个不在凸包上的点,找到它在凸包边上最近的边,然后插入到这条边的两个端点之间。但这里又有讲究——插入顺序不对的话,仍然可能导致自交。
凹包(Concave Hull)算法就是专门解决这个问题的,它试图在凸包的基础上“挖掉”不属于边界的区域。常见的实现有Alpha Shape和Chi Shape等。不过凹包算法通常需要调参(比如Alpha Shape的alpha值),参数选不好结果差异很大。
2.4 方案选型:没有最好,只有最合适
把上面几种思路放在一起对比一下:
| 方法 | 适用场景 | 优点 | 缺点 | 复杂度 |
|---|---|---|---|---|
| 角度排序 | 凸形或近似凸形边界 | 简单、快 | 凹形失效、对噪声敏感 | O(n log n) |
| 最近邻搜索 | 点分布均匀的任意边界 | 无需中心点、能处理凹形 | 贪心易出错、O(n²) | O(n²) |
| 凸包+插入 | 边界接近凸形 | 顺序可靠 | 丢失凹陷细节 | O(n log n) |
| 凹包算法 | 任意形状 | 保留凹陷 | 需要调参 | O(n log n)~O(n²) |
实际项目中怎么选?我的经验是:
- 如果边界明显是凸的,直接用角度排序,省事。
- 如果边界有凹陷但不太复杂,用最近邻+后续修正。
- 如果边界复杂且对精度要求高,考虑凹包算法或者基于图的方法。
- 如果点集本身有拓扑信息(比如来自网格),优先利用拓扑关系而不是纯几何排序。
接下来我会重点讲两种最实用的方案:改进的角度排序法和基于最近邻的边界追踪法,并给出完整的代码实现。
3. 实操细节:手把手实现边界排序
3.1 数据准备与预处理
在开始排序之前,有几件预处理的事情必须做。
去重。边界点里经常有重复坐标,尤其是从图像提取的轮廓,相邻像素可能被重复记录。重复点会导致排序时出现零距离,影响最近邻的判断。去重很简单:
def deduplicate(points, tolerance=1e-6): unique = [] for p in points: is_dup = False for q in unique: if abs(p[0]-q[0]) < tolerance and abs(p[1]-q[1]) < tolerance: is_dup = True break if not is_dup: unique.append(p) return unique如果点很多,可以用空间哈希或者KD-Tree来加速,但一般边界点数量不会太大(几百到几万),朴素去重也能接受。
去噪。如果边界点里有明显的离群点(远离主体边界的点),最好先剔除。常用的方法是计算每个点到其k个最近邻的平均距离,距离明显偏大的就认为是噪声。或者用RANSAC之类的拟合方法来识别主体边界。
确定起始点。对于闭合边界,起始点选哪里不影响最终形状,但会影响输出的旋转。通常选x坐标最小(最左)的点作为起点,如果有多个,选其中y最小的。这样结果比较稳定。
注意:如果边界不是闭合的(比如一条曲线),那就不存在“顺时针”的概念,排序目标变成沿着曲线方向排列。这种情况通常需要额外的信息(比如曲线端点)来确定方向。
3.2 改进的角度排序:处理凹形边界
针对角度排序法在凹形边界上失效的问题,我总结了一个改进方案:分区角度排序。
核心思想是:不直接用全局质心,而是把边界点分成若干段,每段内部用局部中心做角度排序,然后再把各段拼接起来。
具体怎么做?
第一步,用最近邻法快速走一遍,得到一个粗略的顺序。这个顺序可能不完全正确,但大致的走向是对的。
第二步,在粗略顺序上检测“回折”点——也就是方向发生剧烈变化的地方。这些地方往往是凹形区域的入口。
第三步,以回折点为分界,把点集分成若干段。每段内部用该段的质心做角度排序。
第四步,按粗略顺序把各段拼接起来,段与段之间在回折点处连接。
这个方法听起来复杂,但实现起来并不难。关键是要准确检测回折点。我通常用相邻三个点的转向角来判断:如果转向角超过某个阈值(比如120度),就认为是一个回折点。
def detect_turns(points, angle_threshold=120): turns = [] n = len(points) for i in range(n): p0 = points[(i-1) % n] p1 = points[i] p2 = points[(i+1) % n] v1 = (p1[0]-p0[0], p1[1]-p0[1]) v2 = (p2[0]-p1[0], p2[1]-p1[1]) # 计算转向角 dot = v1[0]*v2[0] + v1[1]*v2[1] det = v1[0]*v2[1] - v1[1]*v2[0] angle = math.degrees(math.atan2(det, dot)) if abs(angle) > angle_threshold: turns.append(i) return turns这个改进方案在我处理肾脏形、C形边界时效果不错,但代码量比朴素角度排序大了不少。如果你的边界只是轻微凹陷,可能不需要这么复杂,直接用最近邻法就够了。
3.3 最近邻边界追踪:从起点一步步走
最近邻法的完整实现如下:
def nearest_neighbor_sort(points): if len(points) <= 2: return points[:] # 选起始点:最左下的点 start = min(points, key=lambda p: (p[0], p[1])) sorted_points = [start] remaining = [p for p in points if p != start] current = start while remaining: # 找最近的未访问点 nearest = min(remaining, key=lambda p: (p[0]-current[0])**2 + (p[1]-current[1])**2) sorted_points.append(nearest) remaining.remove(nearest) current = nearest return sorted_points这个朴素实现有几个可以优化的地方:
优化一:用KD-Tree加速最近邻查询。当点数量超过几千时,每次线性扫描remaining列表会很慢。用scipy的KDTree或者自己实现一个简单的空间网格,可以把单次查询从O(n)降到O(log n)。
优化二:限制搜索范围。如果当前点已经走到了边界的一端,下一个点应该在附近,不需要全局搜索。可以设置一个搜索半径,只在这个半径内找最近点。如果半径内没有点,再扩大范围。
优化三:方向约束。纯最近邻法可能会“回头”——下一个点虽然是最近的,但方向跟当前走向相反。可以加入方向判断:优先选择与当前方向夹角较小的点。
def directional_nearest_neighbor(points, start=None): if len(points) <= 2: return points[:] if start is None: start = min(points, key=lambda p: (p[0], p[1])) sorted_points = [start] remaining = set(range(len(points))) remaining.remove(points.index(start)) current_idx = points.index(start) prev_direction = None while remaining: current = points[current_idx] best_idx = None best_score = float('inf') for idx in remaining: p = points[idx] dist = math.hypot(p[0]-current[0], p[1]-current[1]) if prev_direction is not None: # 计算方向一致性 new_dir = (p[0]-current[0], p[1]-current[1]) dot = prev_direction[0]*new_dir[0] + prev_direction[1]*new_dir[1] norm = math.hypot(*new_dir) if norm > 0: cos_angle = dot / (math.hypot(*prev_direction) * norm) # 方向越一致,惩罚越小 score = dist * (2 - cos_angle) else: score = dist else: score = dist if score < best_score: best_score = score best_idx = idx if best_idx is None: break next_point = points[best_idx] prev_direction = (next_point[0]-current[0], next_point[1]-current[1]) sorted_points.append(next_point) remaining.remove(best_idx) current_idx = best_idx return sorted_points这个方向约束版本在实际测试中比纯最近邻稳定很多,尤其是在边界有分叉或者点密度不均匀的情况下。
3.4 顺时针与逆时针的判定
排序完成后,还需要确认方向是顺时针还是逆时针。计算多边形有向面积(Shoelace公式)就能判断:
def polygon_signed_area(points): n = len(points) area = 0 for i in range(n): j = (i + 1) % n area += points[i][0] * points[j][1] area -= points[j][0] * points[i][1] return area / 2如果面积为正,说明是逆时针;为负则是顺时针。如果结果跟预期相反,把点序列反转一下就行。
注意:Shoelace公式要求点序列是简单多边形(不自交)。如果排序结果有自交,面积计算会出错。所以最好先检查自交,再判断方向。
3.5 自交检测与修正
自交是边界排序中最常见的问题。检测自交的朴素方法是两两检查线段是否相交,复杂度O(n²)。对于大规模点集,可以用Bentley-Ottmann算法或者扫描线方法,但实现复杂。实际项目中,如果点数不多(几千以内),O(n²)的检测也能接受。
def segments_intersect(p1, p2, p3, p4): def ccw(a, b, c): return (c[1]-a[1])*(b[0]-a[0]) > (b[1]-a[1])*(c[0]-a[0]) return ccw(p1,p3,p4) != ccw(p2,p3,p4) and ccw(p1,p2,p3) != ccw(p1,p2,p4) def has_self_intersection(points): n = len(points) for i in range(n): p1, p2 = points[i], points[(i+1)%n] for j in range(i+2, n): if j == i or (j+1)%n == i: continue p3, p4 = points[j], points[(j+1)%n] if segments_intersect(p1, p2, p3, p4): return True, (i, j) return False, None如果检测到自交,修正的方法通常是:找到自交的两条线段,交换它们之间的点序列。但这只是局部修正,可能引入新的自交。更稳妥的做法是回到最近邻法,调整方向约束的权重,重新排序。
我踩过的一个坑是:自交检测时没有排除相邻线段。相邻线段共享端点,用严格的相交判断会误报。所以循环里要跳过相邻的线段对。
4. 常见问题与排查技巧实录
4.1 排序结果“打结”怎么办
这是最常见的问题。画出来的边界线像一团乱麻,到处交叉。原因通常有三个:
原因一:点集本身不是简单边界。比如边界有分叉,或者有内部空洞。这种情况下,任何单一序列都无法正确表示边界,需要先做拓扑分析,把边界拆成多条简单曲线。
原因二:噪声点干扰。一个远离边界的噪声点会被最近邻法优先选中,导致顺序跳变。解决办法是先做离群点检测,把明显偏离主体的点剔除。
原因三:点密度差异过大。密集区域和稀疏区域交界处,最近邻容易“跳”到稀疏区域再跳回来。可以引入密度归一化:在计算距离时,除以该点周围点的平均间距。
排查步骤我一般是这样:
- 先把点画出来,肉眼看看有没有明显离群点
- 用不同颜色画出排序后的连线,观察在哪里打结
- 定位到打结处的几个点,单独分析它们的坐标和邻居关系
- 如果是局部问题,手动调整这几个点的顺序;如果是全局问题,换排序算法
4.2 起始点选择导致的方向不一致
同一个边界,两次运行得到的方向可能相反(一次顺时针一次逆时针)。这通常是因为起始点选得不够稳定。如果最左点有多个(x坐标相同),选不同的y会导致起始点不同,进而影响整体方向。
解决办法是固定起始点的选择规则:先按x排序,x相同再按y排序,取第一个。这样每次结果都一样。如果还是不稳定,可以在排序完成后统一用Shoelace公式判断方向,强制转为顺时针。
4.3 大规模点集的性能优化
当边界点超过一万个时,朴素最近邻法的O(n²)会变得很慢。我实测过,一万个点大概需要几秒到十几秒,十万个点就基本跑不动了。
优化方案:
- 空间网格加速。把空间划分成网格,每个格子记录落在里面的点。最近邻搜索时只检查当前格子及相邻格子。这样单次查询接近O(1)。
- KD-Tree。scipy.spatial.KDTree提供了高效的最近邻查询,构建O(n log n),查询O(log n)。配合增量式删除(标记已访问而不是真正删除),可以实现O(n log n)的排序。
- 分治策略。把点集按空间位置分成若干块,每块内部先排序,再合并。合并时只需要处理块边界处的连接。
from scipy.spatial import KDTree def fast_nearest_neighbor_sort(points): if len(points) <= 2: return points[:] points_array = np.array(points) tree = KDTree(points_array) start_idx = np.lexsort((points_array[:,1], points_array[:,0]))[0] sorted_indices = [start_idx] visited = np.zeros(len(points), dtype=bool) visited[start_idx] = True current = start_idx for _ in range(len(points)-1): # 查询最近的未访问点 distances, indices = tree.query(points_array[current], k=min(20, len(points))) next_idx = None for idx in indices: if not visited[idx]: next_idx = idx break if next_idx is None: # 扩大搜索范围 distances, indices = tree.query(points_array[current], k=len(points)) for idx in indices: if not visited[idx]: next_idx = idx break if next_idx is None: break sorted_indices.append(next_idx) visited[next_idx] = True current = next_idx return [points[i] for i in sorted_indices]这个版本在十万点级别也能在秒级完成排序。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 边界线自交 | 凹形区域角度排序错误 | 画出连线观察交叉位置 | 改用最近邻法或分区角度排序 |
| 顺序跳变 | 噪声点或离群点 | 检查点间距分布 | 先做离群点剔除 |
| 方向不一致 | 起始点选择不稳定 | 多次运行对比 | 固定起始点规则+方向归一化 |
| 排序速度慢 | 点数量大+朴素算法 | 计时各步骤 | 用KD-Tree或空间网格加速 |
| 局部顺序错乱 | 点密度差异大 | 观察密集/稀疏交界处 | 距离归一化或方向约束 |
| 闭合处断裂 | 首尾点距离过大 | 检查首尾点坐标 | 确认边界是否真的闭合 |
4.5 几个实用的避坑技巧
技巧一:先可视化再排序。拿到点集后,先用散点图画出来。很多问题(离群点、分叉、空洞)肉眼一看就知道,比盲目调算法高效得多。
技巧二:保留中间结果。排序过程中,把每一步的当前序列画出来(或者存下来),出问题时可以回放,快速定位是哪一步开始错的。
技巧三:用合成数据测试。自己生成一些标准形状(圆、椭圆、星形、C形)的边界点,加上不同级别的噪声,测试算法的鲁棒性。这比拿真实数据调试快得多。
技巧四:不要追求完美。实际项目中,边界排序的结果往往不需要100%正确。如果只有个别点顺序不对,但整体形状没问题,可能就够用了。把精力花在关键问题上。
技巧五:考虑下游用途。如果排序后的边界要拿去做面积计算,那自交是绝对不能有的;如果只是可视化,轻微的自交可能可以接受。根据下游需求决定投入多少精力去修正。
5. 不同场景下的方案选择与扩展
5.1 图像轮廓提取后的排序
从二值图像提取轮廓(比如用OpenCV的findContours),拿到的点通常已经是有序的。但如果你自己写边缘检测,或者从分割结果里提取边界像素,拿到的可能就是无序的。
图像边界点的特点是:点密集、分布均匀、可能有锯齿。这种情况下,最近邻法效果很好,因为点间距很小,贪心策略不容易出错。而且图像边界通常是简单闭合曲线,不太会有复杂的分叉。
一个实用的技巧是:利用像素的8邻域关系。如果两个点互为8邻域,它们大概率在边界上是相邻的。可以先用邻域关系构建一个图,然后在图上做深度优先搜索,得到的顺序比纯几何排序更可靠。
5.2 点云边界排序
点云边界提取(比如用Alpha Shape或者基于法向量的方法)得到的边界点,分布往往不均匀,而且可能有噪声。这种情况下,纯最近邻法容易出问题。
我的做法是:先用RANSAC拟合一个粗略的边界曲线(比如样条曲线),然后把点投影到曲线上,按曲线参数排序。这样即使点分布不均匀,排序结果也是稳定的。
如果边界不是单条曲线,而是有多个连通区域,那就需要先做聚类,把不同区域的点分开,再分别排序。
5.3 地理围栏与区域规划
地理数据里的边界点(比如行政区划边界、地块边界)通常有拓扑约束:边界不能自交,相邻区域共享边界。这种情况下,排序不能只考虑几何距离,还要考虑拓扑关系。
一个常用的方法是:把边界点按照它们所属的边(Edge)分组,每条边内部排序,然后按边的连接关系把各段拼接起来。这需要额外的拓扑信息,但结果最可靠。
5.4 手工切割与路径规划
如果你在做激光切割、数控雕刻或者手工切割的路径规划,边界排序直接决定了切割路径。这时候不仅要顺序正确,还要考虑路径长度最短、抬刀次数最少。
这其实变成了一个旅行商问题(TSP)的变体。对于单个闭合边界,顺序是确定的(顺时针或逆时针走一圈);对于多个边界,需要决定先切哪个、怎么连接。常用的方法是最近邻+2-opt优化。
提示:路径规划场景下,边界排序只是第一步。后续还需要做路径平滑、速度规划、碰撞检测等。不要指望一个排序算法解决所有问题。
6. 代码整合:一个可直接使用的边界排序工具
把前面讲的内容整合成一个完整的工具类,方便直接调用:
import math import numpy as np from scipy.spatial import KDTree class BoundarySorter: def __init__(self, points): self.points = [(float(p[0]), float(p[1])) for p in points] self.sorted_points = None def deduplicate(self, tolerance=1e-6): unique = [] for p in self.points: is_dup = False for q in unique: if abs(p[0]-q[0]) < tolerance and abs(p[1]-q[1]) < tolerance: is_dup = True break if not is_dup: unique.append(p) self.points = unique return self def remove_outliers(self, k=5, threshold=2.0): if len(self.points) < k + 1: return self tree = KDTree(np.array(self.points)) distances, _ = tree.query(np.array(self.points), k=k+1) mean_dist = np.mean(distances[:, 1:], axis=1) global_mean = np.mean(mean_dist) global_std = np.std(mean_dist) mask = mean_dist < global_mean + threshold * global_std self.points = [p for p, m in zip(self.points, mask) if m] return self def sort_nearest_neighbor(self): if len(self.points) <= 2: self.sorted_points = self.points[:] return self points_array = np.array(self.points) tree = KDTree(points_array) start_idx = int(np.lexsort((points_array[:,1], points_array[:,0]))[0]) sorted_indices = [start_idx] visited = np.zeros(len(self.points), dtype=bool) visited[start_idx] = True current = start_idx prev_dir = None for _ in range(len(self.points)-1): distances, indices = tree.query(points_array[current], k=min(30, len(self.points))) best_idx = None best_score = float('inf') for dist, idx in zip(distances, indices): if visited[idx]: continue if prev_dir is not None: new_dir = points_array[idx] - points_array[current] norm = np.linalg.norm(new_dir) if norm > 0: cos_angle = np.dot(prev_dir, new_dir) / (np.linalg.norm(prev_dir) * norm) score = dist * (2 - cos_angle) else: score = dist else: score = dist if score < best_score: best_score = score best_idx = idx if best_idx is None: distances, indices = tree.query(points_array[current], k=len(self.points)) for dist, idx in zip(distances, indices): if not visited[idx]: best_idx = idx break if best_idx is None: break prev_dir = points_array[best_idx] - points_array[current] sorted_indices.append(best_idx) visited[best_idx] = True current = best_idx self.sorted_points = [self.points[i] for i in sorted_indices] return self def ensure_clockwise(self): if self.sorted_points is None: return self area = self._signed_area(self.sorted_points) if area > 0: self.sorted_points = self.sorted_points[::-1] return self def _signed_area(self, pts): n = len(pts) area = 0 for i in range(n): j = (i + 1) % n area += pts[i][0] * pts[j][1] area -= pts[j][0] * pts[i][1] return area / 2 def get_result(self): return self.sorted_points使用方式:
sorter = BoundarySorter(raw_points) result = sorter.deduplicate().remove_outliers().sort_nearest_neighbor().ensure_clockwise().get_result()这个工具类涵盖了去重、去噪、排序、方向归一化四个步骤,基本能应对大部分场景。当然,如果你的边界特别复杂,可能还需要额外的处理,但作为起点已经够用了。
我在实际使用中发现,remove_outliers的阈值参数需要根据数据调整。对于噪声大的数据,阈值可以设大一点(比如3.0),避免误删正常边界点;对于干净的数据,阈值可以小一点(比如1.5),更激进地剔除离群点。建议先用默认值跑一遍,看看结果再调。
另外,sort_nearest_neighbor里的方向约束权重(那个2 - cos_angle)也是可以调的。如果发现排序结果过于“保守”,总是沿着一个方向走而忽略了更近的点,可以把权重调小;如果发现顺序经常跳变,可以把权重调大。这个需要根据具体数据来试。
最后再分享一个小技巧:如果你的边界点是从某个网格或者规则采样中来的,点与点之间的拓扑关系可能比几何距离更可靠。比如,如果两个点在原始网格中是相邻的,那它们在边界上大概率也是相邻的。利用这种拓扑信息,可以大幅提高排序的准确率,尤其是在点分布不均匀的情况下。