news 2026/10/1 6:03:21

C#实现DBSCAN聚类算法:直角坐标系点云分组与参数调优指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C#实现DBSCAN聚类算法:直角坐标系点云分组与参数调优指南

简介:一份基于 C# 的 DBSCAN 聚类算法 WinForm 示例工程,面向学习聚类算法、从事数据分析、大数据预处理或机器视觉开发的初学者。程序启动后可在界面随机生成散点,并实时执行 DBSCAN 聚类,通过调整邻域半径 Eps、最小样本数 MinPts 等参数直观观察聚类结果变化,非常适合理解密度聚类原理与参数敏感性。散点颜色会随簇动态变化,聚类结果一目了然。压缩包共 37 个文件,大小约 191KB,核心为 7 个 cs 源码文件,另含项目工程配置、可执行程序与调试符号、界面资源、缓存文件等,结构清晰,使用 Visual Studio 可直接编译运行。已有 515 人学习下载。借助该源码可快速掌握 C# 绘制散点图、距离计算与密度连通判定等要点,也可扩展噪声点标记、轮廓系数评估等功能,作为课程设计、算法对比或实际项目参考的起点。

1. 直角坐标系点聚类为什么用 DBSCAN:KMeans 答不上的三个场景

手头有一批直角坐标系下的坐标点,可能是激光雷达扫出来的点云,可能是视觉定位给的一串工件坐标,也可能是上位机从 PLC 里攒下来的位置记录。你要做的不是画点,而是把靠得近的点归成一组——货架上的料位是一组、传送带上的工件是一组。用 C# 写上位机的人遇到这类需求,第一个想到的往往是 KMeans,但 KMeans 要你事先说好分几组,坐标点场景里这个数根本不知道。DBSCAN 聚类算法的价值就在这:它不要求预设簇数,还能把孤立的离群点单独挑出来,天然适合直角坐标系里“按距离就近成团”的问题。这篇按落地顺序讲 C# 实现、参数选择和踩坑点,新手能跟着把代码跑起来,熟手可以直接拿走边界结论。

2. 定义点模型与数据入口:C# 的 Point2D、距离函数和 CSV 读取

DBSCAN 全程只做两件事:算距离、数邻居。所以第一步不是急着写聚类主流程,而是先把“点”这个对象在 C# 里定义好。很多从 Python 转过来的同事习惯用Tuple<double, double>或者直接用两个List<double>存坐标,短数据看不出问题,数据量一上来,维护和调试都难受。C# 是强类型语言,把点封装成结构体,后面的 RegionQuery、ExpandCluster 写起来才干净。

2.1 用 readonly struct 定义 Point2D:为什么不用 class

点坐标在 C# 里用readonly struct而不是class,理由是内存布局和 GC 压力。坐标点数据量动辄几万到几十万,如果用 class,每个点在托管堆上单独分配,GC 要频繁回收;用 struct 且放到List<Point2D>里,内存是连续排布的,遍历时 CPU 缓存命中率比 class 高一个量级。另一个理由是 Point2D 应该不可变——聚类过程中坐标不该被改写,readonly从编译期就堵住这个风险。

public readonly struct Point2D { public double X { get; } public double Y { get; } public Point2D(double x, double y) { X = x; Y = y; } public double DistanceTo(Point2D other) { double dx = X - other.X; double dy = Y - other.Y; return Math.Sqrt(dx * dx + dy * dy); } public override string ToString() { return $"({X:F3}, {Y:F3})"; } }

这段代码的要点:readonly struct声明值类型不可变,属性只读,构造时一次性赋值。DistanceTo里没有做参数校验,因为这是热路径,校验会拖慢每一次调用。ToString重载用来打日志和调试输出,格式化保留三位小数,避免一长串浮点数刷屏。如果你处理的是三维点云,在这个结构体里加一个Z属性,DistanceTo里补上dz * dz就行,其余代码不用动。

2.2 欧氏距离与平方距离:邻居查询的性能基础

DBSCAN 的“临近”在直角坐标系里默认指欧氏距离,这类基于欧氏距离的聚类在工业场景里也常被直接叫欧氏聚类。但实现时有个性能细节:比较两个点是否在 eps 范围内,不需要真的开平方。DistanceTo返回真实距离是为了给人看,而 RegionQuery 内部比较的是平方距离,避免Math.Sqrt的开销。几万点规模感觉不明显,几十万点时,少调用一次 Sqrt 能省下可观的时间。

// 邻居判断用平方距离,epsSqr 在进入循环前就算好 double epsSqr = eps * eps; double dx = points[i].X - points[j].X; double dy = points[i].Y - points[j].Y; if (dx * dx + dy * dy <= epsSqr) { // 是邻居 }

这个写法在经典 DBSCAN 论文和 sklearn 的实现里都是一致的:半径比较用平方,输出距离才开方。你自己写的时候要留意,不要在每个点上都重新Math.Pow,dx * dx比Math.Pow(dx, 2)快一个数量级,而且没有浮点误差累积的问题。

2.3 从 CSV 读入坐标点:InvariantCulture 与惰性读取

坐标数据最常见的落盘格式是 CSV,两列分别是 x 和 y。读取时有一个 C# 特有的坑:double.Parse默认使用当前线程的 CultureInfo。中文 Windows 系统下,小数点可能是全角字符或逗号规则不同,直接double.Parse("3.14")在某些区域设置下会抛异常或解析出错误的值。我一般统一用CultureInfo.InvariantCulture,强制按英文小数点规则解析。

public static List<Point2D> LoadPoints(string csvPath) { var points = new List<Point2D>(); foreach (var line in File.ReadLines(csvPath)) { if (string.IsNullOrWhiteSpace(line)) continue; var parts = line.Split(','); if (parts.Length < 2) continue; if (double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out double x) && double.TryParse(parts[1], NumberStyles.Float, CultureInfo.InvariantCulture, out double y)) { points.Add(new Point2D(x, y)); } } return points; }

File.ReadLines是惰性读取,不会一次性把整个文件加载进内存,文件有几十万行时这个差异很明显。Split(',')之后如果行首有空格,TryParse的NumberStyles.Float已经包含了允许前导空格。这里没有处理逗号以外的分隔符,如果你的数据是制表符或分号,把','换掉即可。解析失败的行直接跳过,不中断整个流程,这个策略在处理脏数据时比抛异常实用得多。

数据入口准备好之后,就可以进入 DBSCAN 的核心实现了。

3. 用 C# 实现 DBSCAN 主流程:RegionQuery、ExpandCluster 与噪声标签

DBSCAN 的原理用一句话说就是:以每个点为中心画一个半径为 eps 的圆,圆内点数达到 minPts 的点是核心点,核心点周围能互相 reach 到的点连成同一个簇,孤立的点标为噪声。实现上分成三个函数:RegionQuery 负责查邻居,ExpandCluster 负责把核心点连成簇,主循环负责遍历所有点。这一章直接给出可以编译运行的 C# 代码,并解释每个关键分支为什么这么写。

3.1 DBSCAN 的三类点和两个全局参数:先明确 minPts 含不含自身

动手写代码前,先把概念对齐。一个点只有三种身份:核心点(Core)、边界点(Border)、噪声点(Noise)。核心点指 eps 半径内邻居数大于等于 minPts 的点;边界点指自身不是核心点,但落在某个核心点的 eps 范围内;噪声点是两者都不满足的点。边界点紧贴核心点所在的簇,噪声点哪也不属于。

这里有一个几乎所有初学者都会问的问题:minPts 包不包括自己?包括。RegionQuery 返回的邻居列表里包含查询点自身,所以minPts = 5的实际含义是“包括自己在内至少 5 个点”。如果不包含自身,同样的参数会得到完全不同的聚类结果,而且和 sklearn 的 DBSCAN 对不上,验证结果时会很困惑。

两个全局参数里,eps 是最敏感的。eps 稍微调大一点,本来分开的簇可能就粘连了;eps 稍微调小一点,一个大簇会被拆成碎片。minPts 相对稳定,二维坐标点里我一般从 4 起步,第 4 章会详细讲取值方法。

3.2 RegionQuery:线性扫描与平方距离判断

RegionQuery 的逻辑最简单但也最常被写错。它接收一个点索引,返回该点在 eps 范围内的所有邻居索引列表。注意返回的是索引而不是 Point2D 对象,原因有两点:一是索引可以直接操作labels数组,二是避免值类型 struct 在返回时反复复制。

private static List<int> RegionQuery(List<Point2D> points, int index, double epsSqr) { var neighbors = new List<int> { index }; // 包含自身 for (int j = 0; j < points.Count; j++) { if (j == index) continue; double dx = points[index].X - points[j].X; double dy = points[index].Y - points[j].Y; if (dx * dx + dy * dy <= epsSqr) neighbors.Add(j); } return neighbors; }

逻辑说明:初始化时就把index自身加进列表,这是对 3.1 里 minPts 含自身这个约定的代码落实。循环里先跳过自身,避免重复添加。epsSqr作为参数传入而不是每次重新算,是因为它在整个聚类过程中是常量。这个实现的时间复杂度是 O(n),如果主流程对每个点都调用一次,总复杂度就是 O(n^2)。数据量在一万以内这个写法完全够用,超过十万就得换网格索引,3.5 会给出思路。

3.3 ExpandCluster 用 Queue 代替递归:为什么递归会翻车

ExpandCluster 是从一个核心点出发,沿着邻居关系向外扩展,把整条连通链上的点都归入当前簇。网上很多 C# 示例用的是递归,代码看起来短,但实际跑起来会翻车:一个簇如果包含几千上万个点,递归深度就达到几千层,C# 默认栈空间很快被打爆,抛StackOverflowException且无法捕获。我统一用Queue<int>做广度优先遍历,循环代替递归,性能和安全性都更好。

private static void ExpandCluster( List<Point2D> points, int[] labels, bool[] visited, int seedIndex, List<int> seeds, int clusterId, double epsSqr, int minPts) { labels[seedIndex] = clusterId; var queue = new Queue<int>(seeds); while (queue.Count > 0) { int q = queue.Dequeue(); if (labels[q] == -1) labels[q] = clusterId; // 之前被临时标为噪声,现在吸收为边界点 if (visited[q]) continue; // 已经扩展过,不需要再查邻居 visited[q] = true; List<int> qNeighbors = RegionQuery(points, q, epsSqr); if (qNeighbors.Count < minPts) continue; // q 是边界点,没有扩展能力 // q 是核心点,它的邻居里所有未分类的点都进队列 foreach (int nb in qNeighbors) { if (labels[nb] == 0 || labels[nb] == -1) { labels[nb] = clusterId; queue.Enqueue(nb); } } } }

参数说明:seedIndex是触发扩展的核心点,seeds是它的邻居列表。进入循环后先看labels[q] == -1,这个分支覆盖一种常见情况:某个点在主循环里先被标记为噪声,后来发现它位于某个核心点的 eps 范围内,于是被吸收为边界点。visited[q]表示这个点是否已经作为核心点扩展过,扩展过就直接跳过,避免重复调用 RegionQuery。

核心点判断在qNeighbors.Count < minPts这一行:如果邻居数不够,说明 q 只是边界点,它的邻居已经在入队时被处理过,不需要再向外扩展。后面的 foreach 才是真正的“扩展”:把 q 的邻居中未分类的点全部标上当前簇号并入队。注意入队条件包含labels[nb] == -1,这意味着已经被临时标为噪声的点,也会被吸收进簇。

3.4 主循环与标签状态机:0 未分类、-1 噪声、正数簇 ID

主循环遍历所有点,对每个未访问的点做一次核心点判定。为了让代码清晰,我用一个int[] labels表达三种状态:0 表示未分类,-1 表示噪声,正数表示簇 ID。这个状态机的设计贯穿整个算法,调试时在 Watch 窗口看 labels 数组就能快速定位问题。

public static int Run(List<Point2D> points, double eps, int minPts, out int[] labels) { int n = points.Count; labels = new int[n]; bool[] visited = new bool[n]; double epsSqr = eps * eps; int clusterId = 0; for (int i = 0; i < n; i++) { if (visited[i]) continue; visited[i] = true; List<int> neighbors = RegionQuery(points, i, epsSqr); if (neighbors.Count < minPts) { labels[i] = -1; // 先临时标记为噪声 continue; } clusterId++; ExpandCluster(points, labels, visited, i, neighbors, clusterId, epsSqr, minPts); } return clusterId; // 返回簇的数量,方便上层判断是否合理 }

流程拆解:visited数组记录“是否已参与过核心点判定”,避免同一个点被反复处理。RegionQuery 返回邻居后,如果数量不足 minPts,先标记为噪声但不立刻排除——因为后面的 ExpandCluster 可能把它吸收为边界点。如果数量足够,簇号加一,进入扩展流程。函数返回簇总数,这个值可以用来做快速合理性判断:如果簇数等于点的数量,说明 eps 太小,几乎每个点都是独立簇。

到这里,一个能跑的 DBSCAN 就完成了。但如果你要处理十万级以上的点,线性 RegionQuery 的 O(n^2) 会让人等到怀疑人生。下面给一个网格索引的简版思路,这也是工业场景里激光雷达点云聚类的常见优化方向。

3.5 超过 10 万点时的网格索引简版:把邻居查询从全表扫描变成局部查找

线性扫描的问题是每个点都要跟所有其他点算距离。一个简单的优化思路是把平面按 eps 边长的网格切分,每个点只可能落在自身所在格子及相邻的 8 个格子里,只需要查这 9 个格子里的点。C# 里可以用Dictionary<long, List<int>>实现,key 是格子的二维坐标编码。

private static long GetCellKey(double x, double y, double eps) { int cx = (int)Math.Floor(x / eps); int cy = (int)Math.Floor(y / eps); return ((long)cx << 32) ^ (cy & 0xFFFFFFFFL); }

这个编码把 cx 放进高 32 位,cy 放进低 32 位,合成一个 long 作为字典 key。查询时遍历当前格子周围 9 个格子的 key,只在这几个桶里做距离比较。每个格子里的点数量大致可控,整体复杂度从 O(n^2) 降到接近 O(n)。注意Math.Floor必须用而不是直接取整,因为负坐标下直接(int)(x / eps)会向零取整,导致相邻负半轴的点落错格子。

网格索引不是这一章的主线,如果你的数据量在一万以内,线性扫描完全够用,先跑通再优化。

4. 调参先算后拍:eps 与 minPts 的估算、取值范围和退化测试

DBSCAN 的实现代码本身不难,真正让人头疼的是两个参数。很多人在这一步靠感觉拍:eps 先填个 5,minPts 填个 4,跑出来一塌糊涂,然后开始怀疑算法有 bug。实际上 DBSCAN 的调参是有方法可循的,尤其针对直角坐标系下的坐标点数据,可以通过 k-距离图先估算 eps,再根据数据维度确定 minPts,最后用退化测试验证参数是否在合理区间。

4.1 用 k-距离图先估算 eps:从第 k 近邻距离找拐点

k-距离图是 DBSCAN 论文里给出的经典选参方法。做法是:对每个点,计算它到第 k 近的那个点的距离(k 一般取 minPts 减一),然后把这些距离从小到大排序画成曲线。曲线在某个位置出现明显拐点,拐点对应的距离就是 eps 的参考值。原理是:簇内点的第 k 近邻距离小,簇间点的第 k 近邻距离大,拐点正是两种密度的分界。

public static double[] ComputeKthDistances(List<Point2D> points, int k) { var kthDistances = new double[points.Count]; for (int i = 0; i < points.Count; i++) { var distances = new List<double>(points.Count); for (int j = 0; j < points.Count; j++) { if (i == j) continue; distances.Add(points[i].DistanceTo(points[j])); } distances.Sort(); kthDistances[i] = distances[k - 1]; // 第 k 近,不含自身 } Array.Sort(kthDistances); return kthDistances; }

这段代码计算每个点到第 k 近邻居的距离。k的取值与 minPts 对应:如果 minPts = 4,k 取 3,因为 minPts 包含自身,实际要看的是除自己外的第 3 近点。返回的数组排序后可以直接输出成 CSV,在 Excel 里画折线图,找拐点位置。拐点的特征是斜率突然变大:前半段曲线平缓,后半段急剧上升,拐点处的纵坐标就是 eps。

注意这个方法在数据分布均匀、没有明显密度差异时拐点会模糊,此时可以结合业务含义确定 eps。比如激光雷达点云里,同一个物体的点间距通常在某个物理尺度内,这个尺度直接换算成坐标单位就是 eps。

4.2 minPts 的取值范围:二维坐标点从 4 起步

minPts 作为密度阈值,基本原则是越大越不容易产生碎簇,但太大也会把本应分开的簇合并。经验规则是:minPts 至少取维度数加一,常用 2 倍维度。二维坐标点就是至少 3,我一般从 4 起步,最大不超过 10。如果数据里有大量离群点,可以适当调大 minPts,让噪声点更不容易被吸收进簇。

minPts 和 eps 是联动的,不是独立调节。minPts 增大,密度要求变高,同一个 eps 下核心点变少,簇可能收缩或分裂;minPts 减小,核心点变多,簇更容易扩张。所以调参时先定 minPts,再去 k-距离图里找对应的 k 值算 eps,不要来回同时动两个参数,那样永远找不到规律。

我个人的流程是:先按 4 定 minPts,跑一遍 k-距离图得到 eps 初值,聚类一次看结果。如果碎簇太多,minPts 加到 6 或 8,重新算 eps。如果簇粘连严重,eps 调小 20% 再看。每次只动一个参数,记录结果,这是最不玄学的做法。

4.3 直角坐标系下的单位陷阱:像素、毫米与归一化

直角坐标系里的坐标值是有单位的,这直接影响 eps 的含义。同一批点,用像素表示时 x 范围是 0 到 1920,用毫米表示时变成 0 到 500,eps 的绝对值完全不同。更麻烦的是 x 和 y 的单位不一致——视觉系统返回的坐标 x 是毫米、y 是像素,两个维度的尺度差 10 倍,欧氏距离会被数值大的那个维度主导,簇的形状被拉成条状。

处理办法有两个:一是统一单位,把像素换算成物理尺寸;二是做归一化,把每个维度的值都缩放到相同的范围。归一化对 DBSCAN 的影响是改变了距离的物理含义,如果聚类结果只用于分组而不是精确的几何分析,归一化是更稳的选择。我的经验是:坐标点来自同一个传感器且单位一致,优先不归一化,直接用物理单位定 eps,结果可解释性强;多传感器融合的数据,先归一化再聚类,避免某一维主导。

4.4 退化测试:用极端 eps 快速验证参数区间

调参过程中最容易遇到的情况是:代码有 bug,但表现的像是参数没调好。区分这两者的最快办法是退化测试。把 eps 设置成数据集对角线长度的十倍,此时所有点都应该被归入同一个簇;把 eps 设置成所有点之间最小距离的十分之一,此时所有点都应该被标记为噪声。

如果这两种极端情况下的输出不符合预期,说明算法实现有问题,参数怎么调都没用。测试通过后,再回到正常参数区间做细调。这个测试我每次写完聚类代码都会先跑一遍,它相当于给算法本身做了一次冒烟测试。不需要写额外代码,用现成的 Run 方法换 eps 值跑两次就行。

5. C# 里跑 DBSCAN 的避坑指南:递归爆栈到重复点的 5 个教训

这一章是把前面实现里容易踩的坑集中起来,每一条都是实际跑数据时遇到过的问题,按「现象 → 原因 → 解决」的方式记录下来。有些坑在 Python 里不明显,但换成 C# 以后因为语言特性被放大了,值得单独写出来。

5.1 现象:数据量一上去就抛 StackOverflowException,程序直接崩溃

原因:网上很多 C# DBSCAN 示例把 ExpandCluster 写成递归函数。递归深度等于簇的大小,一个簇里有一万个点,递归就有一万层,C# 默认线程栈只有 1MB 左右,几千层就爆了。StackOverflowException 在 .NET 里是没法 try-catch 的,进程会直接终止,连日志都来不及写。

解决:用 Queue + while 循环代替递归,也就是第 3 章 ExpandCluster 的写法。这个修改对正确性没有影响,因为 DBSCAN 的扩展本身是广度优先逻辑,Queue 只是换了一种遍历顺序。改完之后十万点级别的数据再没出现过栈溢出。

5.2 现象:同一位置有多个重复点,聚类结果出现大量碎簇或整个区域变成一个簇

原因:激光雷达或视觉定位在扫描静态物体时,同一个坐标点可能被多次记录。DBSCAN 把距离为 0 的点全部算作邻居,一堆重复点会瞬间满足 minPts,导致它们所在的区域被错误标记为核心区域。反过来,如果重复点恰好落在两个簇的交界处,会把两个本应分开的簇缝合起来。

解决:聚类前先做去重。C# 里可以用HashSet<Point2D>,但需要给 Point2D 实现相等比较。我用更直接的办法:按坐标排序后相邻比较,距离小于一个极小阈值(比如 1e-9)就当作重复点合并。去重后如果还需要知道原始数量,可以在去重时保留每个点的权重,DBSCAN 主流程不感知权重,但输出结果后按权重重新展开。

5.3 现象:簇的形状明显被拉长,沿着坐标轴方向变成长条形

原因:x 和 y 的数值尺度不一致。典型场景是 x 单位是毫米、y 单位是像素,像素值普遍比毫米值大几十倍,欧氏距离几乎完全由 y 方向的差值决定,聚类结果自然偏向 y 轴方向。

解决:先检查两个维度的数值范围,max - min 差异超过一个数量级就做归一化处理。处理完再聚类,然后观察簇形是否恢复为近圆形。注意归一化后的 eps 失去了物理单位含义,此时的 eps 是归一化空间里的距离,和原始坐标的毫米或像素不再直接对应,输出结果时要把归一化后的坐标还原回去再可视化。

5.4 现象:两个明显分开的簇被连成一个簇,怎么调 eps 都分不开

原因:eps 设置得过大,超过了两个簇之间的间隔距离。簇间最小距离是 8,eps 设成 10,两个簇边缘的点就互相成了邻居,桥梁一搭,两个簇就被缝合。调小 eps 能解决,但很多时候是因为偷懒没有做 k-距离图,直接凭经验填了一个偏大的值。

解决:用 4.1 的 ComputeKthDistances 算一遍 k-距离图,找到拐点再设 eps。如果拐点不明显,退回到业务层面判断:两个簇之间的物理间隔大概是多少,这个间隔就是 eps 的上限。设置一个合理的上限值,比如取簇间最小距离的 60%,不是拍脑袋而是有参照的拍。

5.5 现象:minPts 设成 1 或 2,结果几乎每个点都是一个簇

原因:minPts 太小,导致几乎所有点都满足核心点条件,聚类退化成“每个点自带一个邻居圈”,完全失去密度聚类的意义。minPts 是 1 时,RegionQuery 返回的邻居里至少包含自身,必然满足条件,每个点都成为核心点。

解决:minPts 至少取 3,二维坐标点推荐 4 起步。如果业务上确实需要识别非常小的簇,宁可把 eps 调小,也不要让 minPts 小于 3。从统计意义上看,minPts 代表一个簇里至少需要包含多少个点才算有意义,小于 3 的“簇”和噪声没有区别。

6. 验证聚类结果的两个技巧:导出 CSV 和 SVG,不开 IDE 也能看图

聚类跑完之后,第一件事不是分析参数,而是确认结果长什么样。很多上位机环境里没有 Python、没有 matplotlib,System.Drawing 在某些精简版系统上还可能缺少 GDI+ 组件。我习惯用两种免依赖的导出方式验证结果:导出 CSV 给表格工具或外部绘图程序,导出 SVG 用浏览器直接看散点分布。

public static void ExportClustersCsv(string csvPath, List<Point2D> points, int[] labels) { var sb = new StringBuilder(); sb.AppendLine("x,y,cluster"); for (int i = 0; i < points.Count; i++) { sb.Append(points[i].X.ToString("F6", CultureInfo.InvariantCulture)).Append(','); sb.Append(points[i].Y.ToString("F6", CultureInfo.InvariantCulture)).Append(','); sb.AppendLine(labels[i].ToString(CultureInfo.InvariantCulture)); } File.WriteAllText(csvPath, sb.ToString(), Encoding.UTF8); }

这个 CSV 里有原始坐标和簇号三列,Excel 可以直接打开,用“插入散点图”按 cluster 列着色。所有数字都用CultureInfo.InvariantCulture格式化,避免系统区域设置把小数点改成逗号导致 Excel 分列出错。文件落盘用 UTF-8 编码,否则 Excel 打开中文路径或内容时会乱码。

看散点图更快的方式是直接生成 SVG,代码量比 CSV 还少,浏览器双击就开:

public static void ExportClustersSvg(string svgPath, List<Point2D> points, int[] labels) { double scale = 10; // 放大倍数,避免点叠在一起看不清 var sb = new StringBuilder(); sb.AppendLine("<svg xmlns='http://www.w3.org/2000/svg' width='800' height='800'>"); for (int i = 0; i < points.Count; i++) { int cluster = labels[i]; string color = cluster <= 0 ? "#cccccc" : $"hsl({(cluster * 47) % 360},70%,50%)"; double cx = points[i].X * scale + 400; double cy = points[i].Y * scale + 400; sb.AppendLine($"<circle cx='{cx:F1}' cy='{cy:F1}' r='2' fill='{color}'/>"); } sb.AppendLine("</svg>"); File.WriteAllText(svgPath, sb.ToString(), Encoding.UTF8); }

噪声点统一用灰色,簇点用 HSL 色相按簇号循环着色,最多可以区分不同色相的颜色。加 400 的偏移量是为了把坐标平移到画布中心,避免负坐标跑到画布外。SVG 的好处是零依赖,任何浏览器都能渲染,还能直接截图贴进故障报告里。

我现在的习惯是:拿到一批坐标先跑退化测试确认实现没毛病,导出 CSV 在表格里看一眼簇号是否连续合理,再导出 SVG 检查簇的形状和边界。两步走完,参数问题还是算法问题基本就定位了。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 6:03:13

Codex CLI 本地工作流实战:从协议原理到 Ollama 集成

1. OpenRig 不是 Codex&#xff0c;也不是 CLI 工具——先厘清一个被严重混淆的命名陷阱 最近在多个技术社区和开发者群聊里&#xff0c;频繁看到有人发问&#xff1a;“OpenRig 怎么安装&#xff1f;”“OpenRig 支持 Codex 吗&#xff1f;”“OpenRig CLI 报错 cc switch l…

作者头像 李华
网站建设 2026/10/1 6:02:04

WorkBuddy+微信企业级AI日报自动化架构设计

1. 这不是“发个消息”&#xff0c;而是一套轻量级企业级自动化工作流“我给 WorkBuddy 设了个闹钟&#xff1a;每天上午十点半&#xff0c;一份 AI 日报自动送进微信”——这句话乍看像极了某个程序员朋友在茶水间随口聊起的小技巧&#xff0c;但拆开来看&#xff0c;它其实浓…

作者头像 李华
网站建设 2026/10/1 6:01:56

苹果瑕疵检测数据集:开箱即用的YOLOv5/v8工业质检样本

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/1 5:58:07

iOS 上运行 Windows 程序:Wine + FEX-Emu + DXMT 兼容层实战

1. 项目缘起&#xff1a;为什么要在 iOS 上折腾 Wine 和 FEX-Emu“Madeira”这个项目名&#xff0c;乍一看像是个地名&#xff0c;但在我们这圈子里&#xff0c;它指的是一套在 iOS 设备上运行 Windows 应用程序的兼容层方案。核心思路是把Wine、FEX-Emu和DXMT这三样东西串起来…

作者头像 李华