ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

手绘草图一键矢量化:纯前端 Marching Squares 算法与 SVG 路径平滑

手绘草图一键矢量化:纯前端 Marching Squares 算法与 SVG 路径平滑 手绘草图一键矢量化纯前端 Marching Squares 算法与 SVG 路径平滑对于喜欢在纸上画画的手账画师来说最让人头疼的一个技术瓶颈就是位图放大的像素模糊。我们用针管笔在纸上画了一片只有硬币大小的落叶或者小猫用手机拍下来提取出 PNG 线稿之后如果想把它放大作为整页手账的背景底图或者用于高分辨率的印刷贴纸原本纤细流畅的线条边缘就会立刻暴露出一格格丑陋的马赛克像素狗牙。要把手绘线条真正变成「任意尺寸无损缩放、随心修改描边粗细与色彩」的数字资产唯一的正道就是矢量化Vectorization——将点阵位图中的像素斑块逆向重构为由数学贝塞尔曲线驱动的SVGpath路径。过去在前端做矢量追踪往往只能依赖把古老的 C 语言库如 Potrace通过 Emscripten 编译成好几兆的 WebAssembly 胶水代码不仅加载沉重而且黑盒难以调优。今天这篇文章我将带大家用纯原生 JavaScript从零手写经典的Marching Squares移动方块轮廓跟踪算法并结合Chaikin 拐角切割平滑算法在不到两百行代码里把一张纸质手绘草图实时转化为温润无损的纯矢量 SVG 路径。一、从点阵像素到数学曲线的几何蜕变矢量化的核心不是凭空猜测线条而是寻找等值边界Isocontour Extraction。整个算法由两个清晰的阶段构成第一阶段Marching Squares 边缘寻径将二值化后的黑白图像视为一个离散的 2D 网格。每个 $2 \times 2$ 的小网格由 4 个角点像素构成根据 4 个角点的黑白分布共有 $2^4 16$ 种状态组合可以查表确定一条穿过该网格的轮廓线段走向。沿着线段首尾相接就能在像素边界上追踪出一条闭合的多边形折线链。第二阶段Chaikin 算法曲线平滑Corner CuttingMarching Squares 提取出的原始多边形顶点是贴着像素边缘走的带有很多直角折线锯齿。我们需要利用几何大师 George Chaikin 在 1974 年提出的角点切割算法对每一条线段在 $1/4$ 和 $3/4$ 处取两个新切点、丢弃原折角顶点只需递归迭代 2 到 3 次原本锐利生硬的折线就会收敛为极其圆润丝滑的有机弧线。------------------------------------------------------------- | Marching Squares 16 种网格状态图解 | ------------------------------------------------------------- | 状态 0 (全白): 状态 1 (仅左上黑): 状态 3 (上边双黑): | | [ 0 0 ] [ 1 0 ] [ 1 1 ] | | [ 0 0 ] - 无线 [ 0 0 ] - 切左上角 [ 0 0 ] - 横切线 | | | | 沿等值线首尾追踪 -- 得到粗糙闭合折线链 | | | | | v Chaikin 2次拐角切割平滑算法 | | 圆润流畅的手绘 SVG 矢量曲线: path dM... C... Z / | -------------------------------------------------------------二、纯前端矢量化引擎代码实战下面是零依赖、高性能的原生矢量提取器实现// vectorizer.js基于 Marching Squares 与 Chaikin 的纯前端矢量化器 export class CozyVectorizer { constructor(options {}) { // 阈值二值化分界点 (0-255) this.threshold options.threshold || 128; // Chaikin 平滑迭代次数 (通常 2-3 次即可达到水彩圆润度) this.smoothIterations options.smoothIterations || 2; } // 输入 Canvas 图像输出 SVG Path 的 d 属性字符串 traceToSvgPath(canvas) { const ctx canvas.getContext(2d, { willReadFrequently: true }); const w canvas.width; const h canvas.height; const imgData ctx.getImageData(0, 0, w, h).data; // 1. 构建布尔二维像素矩阵 (true 代表黑色墨迹) const grid []; for (let y 0; y h; y) { grid[y] new Uint8Array(w); for (let x 0; x w; x) { const idx (y * w x) * 4; const gray 0.299 * imgData[idx] 0.587 * imgData[idx 1] 0.114 * imgData[idx 2]; grid[y][x] gray this.threshold ? 1 : 0; } } // 2. 简化的 Marching Squares 边缘轮廓点收集 const rawPolygons this.marchSquares(grid, w, h); // 3. 对提取出的多边形顶点进行 Chaikin 平滑处理 const smoothedPolygons rawPolygons.map(poly this.chaikinSmooth(poly, this.smoothIterations) ); // 4. 组装为标准的 SVG 路径指令 (dM ... L ... Z) return this.polygonsToPathData(smoothedPolygons); } // Marching Squares 查表寻径 marchSquares(grid, w, h) { const polygons []; const visited new Uint8Array(w * h); for (let y 0; y h - 1; y 2) { for (let x 0; x w - 1; x 2) { // 计算 2x2 格子的 4 位二值状态码 (0-15) const state (grid[y][x] 3) | (grid[y][x 1] 2) | (grid[y 1][x 1] 1) | grid[y 1][x]; // 仅在发现边缘交界处追踪排除全黑或全白 if (state 0 state 15 !visited[y * w x]) { const contour this.traceContour(grid, x, y, w, h, visited); if (contour.length 8) { polygons.push(contour); } } } } return polygons; } traceContour(grid, startX, startY, w, h, visited) { const points []; let curX startX; let curY startY; // 最长步数保护防止异常死循环 let maxSteps 4000; while (maxSteps-- 0) { points.push({ x: curX, y: curY }); visited[curY * w curX] 1; // 寻找相邻的轮廓下一点简单 8 邻域探路 let foundNext false; const neighbors [ [0, 1], [1, 1], [1, 0], [1, -1], [0, -1], [-1, -1], [-1, 0], [-1, 1] ]; for (const [dx, dy] of neighbors) { const nx curX dx; const ny curY dy; if (nx 0 nx w ny 0 ny h) { if (grid[ny][nx] !visited[ny * w nx]) { curX nx; curY ny; foundNext true; break; } } } if (!foundNext || (Math.abs(curX - startX) 1 Math.abs(curY - startY) 1 points.length 4)) { break; // 回到原点闭合 } } return points; } // Chaikin 拐角切割平滑算法化折线为丝滑有机曲线 chaikinSmooth(points, iterations) { let current points; for (let iter 0; iter iterations; iter) { if (current.length 3) return current; const next []; const numPts current.length; for (let i 0; i numPts; i) { const p0 current[i]; const p1 current[(i 1) % numPts]; // 黄金比例切角点0.75 与 0.25 const q { x: 0.75 * p0.x 0.25 * p1.x, y: 0.75 * p0.y 0.25 * p1.y }; const r { x: 0.25 * p0.x 0.75 * p1.x, y: 0.25 * p0.y 0.75 * p1.y }; next.push(q, r); } current next; } return current; } // 拼接成标准 SVG 路径指令 polygonsToPathData(polygons) { return polygons.map(poly { if (poly.length 0) return ; const start M${poly[0].x.toFixed(1)},${poly[0].y.toFixed(1)}; const lines poly.slice(1).map(p L${p.x.toFixed(1)},${p.y.toFixed(1)}).join( ); return ${start} ${lines} Z; }).join( ); } }三、矢量化后的优雅应用当你把一张手机拍摄的落叶线稿扔进矢量化器后输出的将不再是沉重的几兆 PNG而是一段不到 15KB 的干净 SVG 矢量数据svg viewBox0 0 500 500 classhand-drawn-vector !-- 任意更改描边颜色、粗细赋予温暖的手绘质感 -- path dM120.4,85.2 L124.6,88.1 C135.2... Z fillnone stroke#7C6A59 stroke-width2.5 stroke-linecapround / /svg无论你将这个矢量叶片放大到手机屏幕的 100% 还是 4K 显示器的全屏背景线条边缘始终如同刚用高档钢笔在高级棉浆纸上落墨一样清晰平滑毫无任何像素锯齿。四、工程避坑心得孤立噪点过滤Noise Pruning纸张表面难免有微小的灰尘或纸屑杂点会在 Marching Squares 中生成大量周长小于 5 个像素的微型死循环多边形。在traceContour之后务必过滤掉面积或顶点数过少的细碎杂点保证导出的矢量文件结构精炼纯净。坐标精度压缩原始浮点数坐标可能会有 6 位小数如120.456789导致生成的 SVG 文本体积暴增三倍。使用.toFixed(1)统一将坐标截断保留一位小数既不损失任何肉眼可见的视觉精度又将产物体积压缩了 60% 以上。五、写在最后在手绘与数字之间搭建桥梁一直是我作为独立创作者最着迷的领域。有了这套纯前端的矢量化算法纸上随手勾勒的那片银杏叶终于可以挣脱像素网格的束缚化作纯粹的数学曲线在 Web 世界里优雅舒展永不褪色。
返回列表