
简介这份 JavaScript 工具包围绕 alpha-shape 算法实现面向需要从离散点集提取几何轮廓的前端开发、数据可视化与图形学学习者可在二维乃至更高维数据上依据 alpha 参数生成形状单元用于散点边界识别、空间聚类和图形渲染等场景。压缩包共 7 个文件约 39KB包含核心算法脚本、示例查看器、package.json 依赖清单、README 说明、许可证、版本忽略配置及效果示意图体量轻巧且目录清晰便于直接引入项目或按需改写。已有 1517 人学习下载说明其在点集轮廓计算这一细分方向具有一定参考价值。读者可获得 npm 安装与 require 调用的完整示例理解 alpha 参数对形状粒度的影响示例以随机点集为输入调用 alphaShape(alpha, points) 返回单元数组便于观察不同 alpha 值下轮廓的收缩与扩张并借助查看器与说明文档快速验证输出适合用作可视化原型、算法学习或相关功能模块的起步材料。1. 从散点轮廓到任意维凹包alpha-shape 到底解决什么问题上周有位做点云处理的朋友找我说他手上几千个散点想画一条「贴着点走」的外轮廓线随手调了凸包 API结果画出来像个盖在点集上的大饼——所有凹进去的地方全被抹平了。问题不在代码在算法选型凸包只关心最外层天生就看不见凹陷。alpha-shape在 JavaScript 社区里通常就叫 alpha shape凹包 / 凹壳的另一种叫法解决的就是这件事。你可以把它想成一把半径可调的圆规圆规的半径就是 alpha 的倒数滚过点集时凡是能被这根圆规「够到」的区域就算内部够不到的凹口就被挖掉。alpha 越大挖得越狠轮廓贴得越紧alpha 趋于 0退化成凸包。这个库的关键卖点在于「任何维度」——二维画轮廓、三维出网格面、四维以上照样跑因为它内部走的是 Delaunay 单纯形那一套维度只是循环变量。适合做点云建模、散点区域边界、聚类外轮廓、仿真结果的可视化包络的开发同学。2. 项目结构与技术选型一个百来行的核心如何撑起 N 维先看组织方式。alpha-shape 属于那种「小而锋利」的库没有构建产物没有 TypeScript 类型声明文件package.json里挂了主入口require进来就是一个函数。这种结构的好处是你读源码不用先配 webpack坏处是它默认你在 Node 或打包器里用直接在浏览器script标签里引会需要一层 UMD 包装。2.1 目录结构与模块职责按功能拆这类库的目录基本是这个形状。我没法给你贴官方 README但把它拆开看职责划分是很清楚的alpha-shape/ ├── index.js # 主入口参数校验、维度推断、串联整个流程 ├── lib/ │ ├── circumcenter.js # 求单纯形的外接球球心解 D 元一次方程组 │ ├── boundary.js # 从保留的单纯形里提取 (D-1) 维边界面 │ └── solve.js # 高斯消元处理奇异矩阵 ├── test/ │ └── test.js # 二维方块、三维四面体的断言用例 ├── package.json └── README.mdindex.js是唯一的门面它只干三件事拿到点集后确认维度一致、调用三角剖分拿到单纯形列表、按 alpha 过滤后交给边界提取。剩下的都在lib/里函数式写法没有类、没有状态纯输入纯输出。这种「无状态 小函数」的选型很重要——它让整个库可以被安全地在 worker 里反复调用也不会有实例复用带来的脏数据。提示上面的文件名是按这个库承担的职责推的典型布局你拿到手上如果文件名不同按「入口→剖分→求心→提边界」这条链去找一定能对上。2.2 依赖链路三角剖分、外接球、线性求解技术栈就三层层层往下层次承担的工作关键点几何层把点集切成覆盖整个点集的单纯形线段 / 三角形 / 四面体 / 更高维必须是 Delaunay 剖分空球性质是整个算法成立的前提数值层求每个单纯形的外接球球心与半径解一个 D×D 的线性方程组D 就是数据维度拓扑层判断哪些 (D-1) 面只属于一个保留单纯形只出现一次的面就是边界面Delaunay 剖分这一层是整个库的底座。为什么非 Delaunay 不可因为只有 Delaunay 剖分满足「每个单纯形的外接球内部不含其他输入点」这个空球性质这才让「外接球半径小于阈值就保留」这一刀切得有几何意义。换成随便一种三角化半径过滤就变成玄学——你根本说不清被剔掉的那块是不是真的该剔。数值层是很多人忽略的部分。二维的时候外接圆圆心可以用公式直接写三维开始就得解方程组了四维以上更是必须靠通用高斯消元。这个库能号称「任何维度」靠的就是把求心这一步抽象成线性求解而不是给每个维度写死公式。2.3 输入输出的数据契约用之前必须搞清楚两件事点怎么给结果怎么拿。输入是「数组的数组」每个内层数组是一个点的坐标长度就是维度。所有点的维度必须一致因为维度是取第一个点的长度推断的。const points2d [[0, 0], [1, 0], [1, 1], [0, 1]] const points3d [[0, 0, 0], [1, 0, 0], [0, 1, 0], [0, 0, 1]]输出是一组「单纯形」每个单纯形是顶点下标的数组。注意这个细节二维输入返回的是边长度为 2 的下标数组三维输入返回的是三角面长度为 3D 维输入返回长度为 D 的边界单形。它返回的不是多边形、不是闭合环、不是网格对象就是下标对/下标组。// 二维返回示例包围方块的四条边 // [[0, 1], [1, 2], [2, 3], [3, 0]]这个契约决定了后面的接入方式想画多边形你得自己把边串成环想渲染网格直接用下标去取原始点构造顶点缓冲就行。明白了这一点后面写代码就不会到处找「为什么没有现成的 GeoJSON 输出」——它本来就不提供。3. 核心算法拆解一个点集从输入到边界输出的完整链路这一章按执行顺序走一遍。我把它拆成四段参数处理、剖分、过滤、提边界。每一段我都给出可用代码注意看每段之后的参数说明。3.1 参数归一与维度推断入口第一件事是把 alpha 这个参数翻译成「半径阈值的平方」。这不是数学上的化简是有实打实的考虑半径比较时开根号既慢又容易踩浮点误差平方之后全程整数级比较稳定得多。const EPS 1e-9 function alphaShape(alpha, points) { // 空输入直接短路避免后面取 points[0] 报错 if (!points || points.length 0) return [] const dim points[0].length // 维度一致性检查这里不做容错宁可早报错 for (const p of points) { if (p.length ! dim) { throw new Error(维度不一致: 期望 ${dim}实际 ${p.length}) } } // alpha 0 视为凸包模式半径阈值设为无穷大即不过滤 // 否则阈值为 (1/alpha)^2即半径上限为 1/alpha const radius2 alpha 0 ? 1 / (alpha * alpha) : Infinity return computeBoundary(points, dim, radius2) }这里最关键的一行是1 / (alpha * alpha)。它说明 alpha 不是「半径」是半径的倒数。之所以这么设计是为了让alpha 0这个特殊值能顺手表达「无穷大半径 凸包」不用额外传一个布尔开关。你调参的时候心里要有一张换算表alpha 1 对应半径 1alpha 2 对应半径 0.5alpha 10 对应半径 0.1。EPS是给后面判断「面是否被共享」用的容差常量不是给 alpha 用的。3.2 Delaunay 剖分把点集切成单纯形剖分这一步库内部调的是一套通用 Delaunay 实现不做任何维度特化。输入端点数很少时它走增量法点多时会退化到 O(n^⌈d/2⌉) 那一档这也是高维容易卡死的原因第 5 章会专门讲。function computeBoundary(points, dim, radius2) { // 步骤一Delaunay 剖分得到覆盖所有点的单纯形列表 // 二维返回三角形 [i,j,k]三维返回四面体 [i,j,k,l] const cells delaunayTriangulate(points) // 步骤二按外接球半径过滤 const kept [] for (const cell of cells) { const r2 circumradius2(points, cell) // 注意这里用严格小于边界情况让 alpha 大一点点更安全 if (r2 radius2) kept.push(cell) } // 步骤三从保留的单纯形里提出边界面 return extractBoundary(kept) }三个步骤的顺序不能换。有人会想「先把点抽稀再剖分是不是更快」——不行因为 alpha shape 的边界是依附于剖分结构的抽稀之后拓扑就变了。也有人想「先按半径筛点」——更不行局部密度不均的时候你会把关键支撑点筛掉边界直接塌掉一块。delaunayTriangulate返回的单纯形数量在二维大约是 2n三维大约是 5n~7n整体可控。真正吃时间的不是它是下一步的求心。3.3 外接球半径用高斯消元解一个 D 元一次方程组这是整个库最值得读的一段。D 维空间里 D1 个点的外接球球心 c满足到每一点距离相等|c - p0|² |c - pi|² (i 1..D)两边展开、消掉 |c|² 项整理成线性方程组2(pi - p0) · c |pi|² - |p0|²这是个 D 元一次方程组D 个方程 D 个未知数正好可以高斯消元。这样写的好处是二维三维和十七维的代码完全一样。function circumradius2(points, cell) { const d points[0].length const p0 points[cell[0]] // 组装 d x d 的系数矩阵 A 和右端向量 b const A [] const b [] for (let i 1; i d; i) { const pi points[cell[i]] const row new Array(d) let rhs 0 for (let k 0; k d; k) { row[k] 2 * (pi[k] - p0[k]) rhs pi[k] * pi[k] - p0[k] * p0[k] } A.push(row) b.push(rhs) } // 解方程奇异矩阵共线 / 共面 / 重复点返回 null const c solveGauss(A, b) if (c null) return Infinity // 退化单纯形直接判为不合格 // 半径平方 球心到 p0 的距离平方 let r2 0 for (let k 0; k d; k) { const t c[k] - p0[k] r2 t * t } return r2 }参数与返回值说明cell长度必须是d 1这是单纯形的定义如果剖分实现返回的元数不对这里会越界所以入口的维度校验要严。solveGauss返回null表示矩阵奇异典型场景是三点共线、四点共面、或者有重复点。返回null之后这里转成Infinity意味着该单纯形永远不满足 radius2被自然剔除。如果 alpha 极小radius2是有限小值Infinity radius2恒为 false退化单形就进不来这是符合预期的但要注意它会造成「边界上有洞」第 5 章会展开。p0选哪个点当基准随便选结果一样。我一般固定取cell[0]这样调试时矩阵可复现。3.4 alpha 过滤与边界提取拿到合格的单纯形列表后最后一关是拓扑判断一个(D-1)维的面如果只被一个保留的单纯形包含它就是边界面如果被两个包含说明它是内部的不输出。function extractBoundary(cells) { const count new Map() // key: 排序后的顶点下标串 - 出现次数 const raw new Map() // key: 同上 - 未排序的原始顶点顺序 for (const cell of cells) { const n cell.length // 枚举「去掉一个顶点」得到的所有 (D-1) 维面 for (let skip 0; skip n; skip) { const facet [] for (let i 0; i n; i) { if (i ! skip) facet.push(cell[i]) } const key facet.slice().sort((a, b) a - b).join(,) count.set(key, (count.get(key) || 0) 1) if (!raw.has(key)) raw.set(key, facet) } } // 只被一个单纯形引用的面就是边界面 const result [] for (const [key, c] of count) { if (c 1) result.push(raw.get(key)) } return result }这里三个细节值得说第一sort是必须的。相邻两个单纯形共享一个面时它们的顶点顺序很可能是反的[0,1]和[1,0]不排序直接join会把同一个面算成两个导致每个面都被判定为边界输出翻倍。第二用join(,)而不是对象当 key是为了避免字符串化时的哈希冲突虽然会有一定字符串开销但在这个数据量级下完全够用。第三返回的是raw里保存的原始顺序不是排序后的。这个顺序在后面串边成环、构造三角面法向时会用到——法向朝外还是朝内取决于顶点顺序。4. 跑起来二维散点与三维点云的完整可复现步骤到了能跑起来的环节。这套流程我从零开始重复过很多次按顺序走基本不会翻车。4.1 环境准备与依赖安装Node 环境建议 14 以上主要是为了**运算符和可选的?.。库本身没用到什么新语法但你的示例代码大概率会用。mkdir alpha-demo cd alpha-demo npm init -y npm install alpha-shape再装一个可视化依赖只在演示时用正式接入可以不要npm install three如果是纯计算场景比如后端算完直接入库Three.js 完全可以不装输出就是下标数组序列化成 JSON 存起来即可。4.2 二维最小可跑示例先造一组一眼能看出凹包形状的点一个缺了一块的方块。const alphaShape require(alpha-shape) // 单位方块的四角 中心附近的散点故意留一个凹口 const points [ [0, 0], [1, 0], [1, 1], [0, 1], [0.2, 0.2], [0.4, 0.3], [0.6, 0.5], [0.3, 0.7], [0.8, 0.2] ] // alpha 0 → 凸包能看到所有点都被包住 console.log(凸包:, JSON.stringify(alphaShape(0, points))) // alpha 4 → 半径 0.25开始挖凹口 console.log(凹包:, JSON.stringify(alphaShape(4, points)))跑完之后你会看到alpha 0输出的是方正的四条外包边而alpha 4输出里出现了一些斜切的边——这就是被挖掉的凹口。多打印几个 alpha 值对比一下比看任何文档都快。要把边画出来得自己串成环。串环的方法很朴素建一个邻接表从任意一个端点出发每次走没走过的边。function chainEdges(edges) { const adj new Map() for (const [a, b] of edges) { if (!adj.has(a)) adj.set(a, []) if (!adj.has(b)) adj.set(b, []) adj.get(a).push(b) adj.get(b).push(a) } const used new Set() const loops [] for (const [start] of adj) { if (used.has(start)) continue const loop [start] let cur start while (true) { const next adj.get(cur).find(n { const key cur n ? ${cur},${n} : ${n},${cur} return !used.has(key) }) if (next undefined) break used.add(cur next ? ${cur},${next} : ${next},${cur}) if (next start) break // 闭环 loop.push(next) cur next } if (loop.length 1) loops.push(loop) } return loops }这段代码返回的是若干条闭合环的顶点序列直接丢给 canvas 或 SVG 的 path 就能画。注意它处理不了「一个点接三条边」的分叉情况——那种情况意味着 alpha 取值让形状自交了属于参数选得不合适得回头调 alpha而不是在串环逻辑里打补丁。4.3 三维点云接入三维的用法一模一样只是点变成三元组输出变成三角面。下面这段把结果直接塞进 Three.js 的缓冲几何体。const alphaShape require(alpha-shape) const THREE require(three) // 造一个球面上的采样点云 const points [] for (let i 0; i 200; i) { const theta Math.random() * Math.PI * 2 const phi Math.acos(2 * Math.random() - 1) const r 1 (Math.random() - 0.5) * 0.05 // 加点噪声 points.push([ r * Math.sin(phi) * Math.cos(theta), r * Math.sin(phi) * Math.sin(theta), r * Math.cos(phi) ]) } // 三维下 alpha 是「表面曲率的倒数」半径 0.5 左右比较合适 const faces alphaShape(2, points) // 把三角面下标展开成扁平的顶点坐标数组 const positions [] for (const [a, b, c] of faces) { positions.push(...points[a], ...points[b], ...points[c]) } const geom new THREE.BufferGeometry() geom.setAttribute(position, new THREE.Float32BufferAttribute(positions, 3)) geom.computeVertexNormals() const mesh new THREE.Mesh( geom, new THREE.MeshStandardMaterial({ color: 0x4488cc, side: THREE.DoubleSide }) ) // mesh 加入场景即可省略场景与相机代码positions是Float32Array的友好来源直接给Float32BufferAttribute就行不用再手动类型转换。这里side: THREE.DoubleSide是有意加的——alpha shape 返回的三角面顶点顺序不保证统一朝外单面渲染会看到一半黑掉的模型这是新手最容易卡住的地方。4.4 alpha 参数怎么调我把常用的经验值列成表直接抄场景建议 alpha 初值说明二维轮廓点间距约 13 ~ 5对应半径 0.2~0.33能挖掉明显的凹口二维轮廓点很密间距 0.0550 ~ 100绝对尺度点密了 alpha 必须跟着放大三维表面重建1 ~ 3对应半径 0.33~1太大会碎成小片只要凸包0走特殊分支不计算半径最快求最贴合形状从 1 开始倍增每次乘 2观察什么时候开始出现空洞调参的核心心法alpha 是绝对尺度不是相对比例。同一个形状你把所有坐标乘 10alpha 也得乘 10 才能得到同样的结果。所以标准流程是先归一化。// 归一化到单位包围盒让 alpha 变得可移植 function normalize(points) { const dim points[0].length const min new Array(dim).fill(Infinity) const max new Array(dim).fill(-Infinity) for (const p of points) { for (let k 0; k dim; k) { if (p[k] min[k]) min[k] p[k] if (p[k] max[k]) max[k] p[k] } } const scale Math.max(...max.map((v, k) v - min[k])) || 1 return points.map(p p.map((v, k) (v - min[k]) / scale)) }归一化之后坐标落在 [0,1] 区间alpha 的取值就变成了「跟点数密度挂钩」的相对量换数据集时不用每次都重新试。5. 常见问题排查alpha 尺度、退化单形和高维爆炸这几条是我自己踩过或者帮别人排过的按「现象 → 原因 → 解决」写遇到对得上的直接照做。5.1 现象调了 alpha 但输出跟凸包一模一样原因alpha 数值相对于坐标尺度太小1/alpha换算出来的半径比所有外接球半径都大等于全部保留。常见于坐标已经是毫米级、甚至经纬度量级数值跨度几十万的数据。解决先把点集归一化到单位包围盒再传进去然后用相对 alpha 试参。如果不想改数据结构就按点间平均最近邻距离估算一个基准值function medianNearestDist(points) { const ds points.map((p, i) { let best Infinity points.forEach((q, j) { if (i j) return let d2 0 for (let k 0; k p.length; k) d2 (p[k] - q[k]) ** 2 if (d2 best) best d2 }) return Math.sqrt(best) }) ds.sort((a, b) a - b) return ds[Math.floor(ds.length / 2)] } // 经验值alpha 取 1 / (2 ~ 3 倍中位最近邻距离)5.2 现象输出里有洞模型缺了几块原因这些位置上的单纯形外接球半径超阈值被剔了而它们周围的点被保留于是形成一个孤立缺口。更多时候是退化单纯形导致的——三点共线、四点共面、或者有重复点solveGauss返回null半径记为Infinity直接出局。原始数据里带扫描重复点的时候特别常见。解决入口处先做一次去重和抖动。function dedupe(points, eps 1e-7) { const seen new Set() const out [] for (const p of points) { const key p.map(v Math.round(v / eps)).join(|) if (!seen.has(key)) { seen.add(key) out.push(p) } } return out } // 对共线共面风险高的数据加微小随机扰动 function jitter(points, amp 1e-6) { return points.map(p p.map(v v (Math.random() - 0.5) * amp)) }5.3 现象点数一多就卡死进程内存飙到几个 G原因Delaunay 剖分的复杂度随维度指数上升。二维接近 O(n log n) 还好三维是 O(n²) 量级四维以上基本就不可用了。这是算法本身的复杂度不是实现写得差。解决三条路。一是降维——对高维数据先做 PCA取前三维做 alpha shape剩余维度当属性挂在顶点上。二是分层——先对点集做一次 K-means 把簇心算出来簇内单独跑最后合并边界。三是接受稀疏化——用体素下采样把点降到几万以内几何精度损失在可视化场景里基本看不出来。5.4 现象拿到的不是多边形画出来是一堆散线原因输出契约就是单形列表二维给边、三维给面它不负责拓扑整理。很多同学按「返回闭合多边形」的预期去用自然会懵。解决要么自己串环4.2 里的chainEdges要么换个思路——直接把边渲染成线段配合线宽和端点圆头视觉上也是闭合轮廓。如果下游必须吃多边形建议在串环之后加一步面积符号判断把顺时针、逆时针的环分开再把内环当洞处理。5.5 现象同样的点集两次运行结果不一致原因点集里有重复点或者浮点误差导致剖分出现平局Delaunay 实现里的排序不稳定两次跑出来共享面的顶点顺序可能不同。另外叠加了jitter之后结果当然也会变。解决固定随机种子如果用得上或者在剖分前对点集按坐标字典序排一次序。这个操作很便宜但能让结果变成可复现的做回归测试的时候是后悔药级别的存在。function stableSort(points) { return points .map((p, i) ({ p, i })) .sort((a, b) { for (let k 0; k a.p.length; k) { if (a.p[k] ! b.p[k]) return a.p[k] - b.p[k] } return a.i - b.i }) .map(o o.p) }注意排序会改变下标如果下游还依赖原始下标得同步维护一张映射表。6. 二次开发把 alpha-shape 接进自己的几何管线直接拿原始输出用不了多久就会遇到瓶颈我的做法是在外面包一层适配层把「单形列表」转成业务要的结构。这一层值得写因为它能吃掉后面 80% 的改动需求。改造点我按收益排了序改造点做法收益自适应 alpha用中位最近邻距离乘系数反推 alpha换数据集不用重调参归一化包装进入前归一化出来时反变换回去alpha 变成无量纲量可缓存输出适配转 GeoJSON / BufferGeometry / 自研网格结构下游直接消费不再散落转换代码剖分缓存点集不变时缓存 Delaunay 结果反复调 alpha 时省掉最大开销我一般会封一个这样的函数把归一化和反变换包进去function adaptiveAlphaShape(points, k 2.5) { const { normalized, restore } makeNormalizer(points) const d medianNearestDist(normalized) // alpha 1 / 半径半径取 k 倍中位最近邻距离 const alpha 1 / (k * d) const cells alphaShape(alpha, normalized) return { cells, alpha, restore } }k是唯一要调的参数取 2~3 之间基本能覆盖大多数点云。restore是个闭包把归一化平移和缩放反着做一遍用于把结果顶点还原到原始坐标系。验证这套改造有没有跑偏最省事的办法是构造已知形状做回归取一个理想圆环上的密集采样点跑出来的边界面积应该收敛到解析值附近。误差超过 5% 就说明 alpha 选得不合适或者点采样不够密。// 圆环采样 面积校验当单元测试用 function ringAreaTest(n 500, R 1, r 0.4) { const pts [] for (let i 0; i n; i) { const a Math.random() * Math.PI * 2 const b Math.random() * Math.PI * 2 pts.push([ (R r * Math.cos(b)) * Math.cos(a), (R r * Math.cos(b)) * Math.sin(a) ]) } // 对输出边做鞋带公式累加和 π(R² - r²) 比对 // 具体累加逻辑依赖 chainEdges 的环输出此处省略 }最后说个血泪经验这套东西的坑几乎全在参数和数据预处理上算法本身反而是最省心的部分。从那以后我每次接入一个新的点云数据集都强制先跑一遍归一化、去重、算中位最近邻距离这三步再看 alpha 该取多少——跳过这三步直接调参基本上就是在浪费一下午。希望帮到你。本文还有配套的精品资源点击获取