在 Canvas 图形编辑器中,如何为任意形状的透明 PNG 图片添加「沿轮廓走」的描边效果?本文从零开始,用通俗的语言带你理解整个实现原理。
想象一下这样的场景:你在做一个海报编辑器,用户拖入一张透明背景的卡通角色 PNG,想给它加一圈白色描边——就像贴纸上那种经典的白色轮廓。
┌────────────────────────────────────┐
│ │
│ ╭──╮ ╭─────╮ │
│ ╭╯🐱╰╮ → ╭╯ ░🐱░ ╰╮ ← 描边 │
│ ╰────╯ ╰───────╯ │
│ │
│ 原始透明图片 添加轮廓描边后 │
└────────────────────────────────────┘
如果用 Canvas 2D / fabric.js 的原生 stroke 属性,只能画出一个矩形边框——它根本不认识你图片的轮廓。我们需要自己动手,「教」计算机看懂图片的形状。
fabric.js(底层是 Canvas 2D)对图片的描边,本质上就是在图片的矩形边界上画一圈:
┌────────────────┐
│ ┌──────────┐ │ ← fabric 原生 stroke
│ │ 🐱 │ │ 只能画矩形框
│ │ │ │
│ └──────────┘ │
└────────────────┘
但我们想要的是这种效果——紧贴图片内容轮廓的描边:
╭───╮
╭╯ ╰╮
╭╯ 🐱 ╰╮ ← 我们想要的:沿轮廓描边
╰╮ ╭╯
╰╮ ╭╯
╰───╯
Canvas 2D API 没有提供这样的能力。所以我们得深入像素层面,自己算出描边区域。
整个算法可以概括为 5 步(别急,后面会一步步展开):
原始图片
│
▼
① 读取像素 alpha 通道 → 建立「二值化蒙版」
│
▼
② 用「膨胀算法」将蒙版向外扩展 N 像素
│
▼
③ 膨胀蒙版 - 原始蒙版 = 描边区域
│
▼
④ 将描边颜色填充到描边区域 → 生成「描边图层」
│
▼
⑤ 重写 fabric 的渲染方法:先画描边图层,再画原图
接下来我们一步步拆解。
每个像素有 4 个通道:R(红)、G(绿)、B(蓝)、A(Alpha/透明度)。
A = 255 → 完全不透明A = 0 → 完全透明A = 128 → 半透明对于透明 PNG,背景区域的 A 值是 0,有内容的区域 A 值 > 0。
我们设一个阈值(比如 10),把所有像素分成两类:
如果 alpha > 10 → 标记为 1(不透明,属于图片内容)
如果 alpha ≤ 10 → 标记为 0(透明,属于背景)
这样就得到一张「蒙版」——一个只有 0 和 1 的二维数组:
原始图片的 alpha: 二值化蒙版:
┌───────────────┐ ┌───────────────┐
│ 0 0 0 0 0 │ │ 0 0 0 0 0 │
│ 0 0 200 0 0 │ → │ 0 0 1 0 0 │
│ 0 180 255 190 0│ │ 0 1 1 1 0 │
│ 0 0 210 0 0 │ │ 0 0 1 0 0 │
│ 0 0 0 0 0 │ │ 0 0 0 0 0 │
└───────────────┘ └───────────────┘
代码实现:
// 读取图片的原始像素数据
function getImagePixelData(image: FabricImage): ImageData | null {
const element = image.getElement();
const width = element.naturalWidth || element.width;
const height = element.naturalHeight || element.height;
// 创建临时 canvas,把图片画上去,再读取像素
const tempCanvas = document.createElement("canvas");
tempCanvas.width = width;
tempCanvas.height = height;
const ctx = tempCanvas.getContext("2d")!;
ctx.drawImage(element, 0, 0, width, height);
return ctx.getImageData(0, 0, width, height);
}
💡 为什么要临时 Canvas? 因为浏览器不允许直接读取
<img>标签的像素数据,必须先画到 Canvas 上,再用getImageData提取。
然后提取 alpha 通道,建立蒙版:
const expandedMask = new Uint8Array(expandedWidth * expandedHeight);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const srcIdx = y * width + x;
// 每个像素 4 个字节 [R, G, B, A],我们只看第 4 个(index + 3)
expandedMask[dstIdx] = data[srcIdx * 4 + 3] > alphaThreshold ? 1 : 0;
}
}
🎯 注意:这里我们在一个比原图大一圈的画布上建蒙版(四周各留
strokeWidth的 padding),给描边预留空间。后面会解释为什么。
这是整个算法的核心中的核心。
想象蒙版上每个值为 1 的像素都变成一个小圆饼,半径就是描边宽度。所有小圆饼覆盖到的区域,就是膨胀后的蒙版。
膨胀前(原始蒙版): 膨胀后(半径=1):
┌───────────┐ ┌───────────┐
│ 0 0 0 0 0 │ │ 0 0 1 0 0 │
│ 0 0 1 0 0 │ │ 0 1 1 1 0 │
│ 0 1 1 1 0 │ → │ 1 1 1 1 1 │
│ 0 0 1 0 0 │ │ 0 1 1 1 0 │
│ 0 0 0 0 0 │ │ 0 0 1 0 0 │
└───────────┘ └───────────┘
↑ 比原来大了一圈!
用更直观的比喻:就像往面团上滴了一滴墨水,墨水会往四周渗透 N 毫米。
对于每个透明像素(值为 0),我们检查它周围一个圆形范围内,有没有任何一个不透明像素。如果有,这个点就被「膨胀」了:
function dilateMask(mask, width, height, radius) {
// 1. 预计算圆形范围内的所有偏移量
const offsets = [];
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
if (dx * dx + dy * dy <= radius * radius) {
// 圆形判断
offsets.push({ dx, dy });
}
}
}
// 2. 对每个透明像素,检查圆形邻域
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (mask[y * width + x] === 1) {
dilated[y * width + x] = 1; // 本来就不透明,保持
continue;
}
// 看看周围有没有不透明的邻居
for (const { dx, dy } of offsets) {
if (mask[(y + dy) * width + (x + dx)] === 1) {
dilated[y * width + x] = 1; // 有!膨胀它
break;
}
}
}
}
}
这个方案直观易懂,但时间复杂度是 O(W × H × R²)。当 R = 10 时,圆形范围内约有 314 个像素点,还可以接受。但 R = 50 时就变成 7,854 个点——太慢了。
这是一个巧妙的优化:与其「每个点检查一个圆形邻域」,不如先算出每个透明像素到最近的不透明像素的距离。然后距离 ≤ radius 的,就是膨胀区域。
原始蒙版: 距离场: 膨胀(radius=2):
0 0 0 0 0 3 2 1 2 3 0 1 1 1 0
0 0 1 0 0 → 2 1 0 1 2 → 1 1 1 1 1
0 1 1 1 0 1 0 0 0 1 1 1 1 1 1
0 0 1 0 0 2 1 0 1 2 1 1 1 1 1
0 0 0 0 0 3 2 1 2 3 0 1 1 1 0
距离变换只需要4 次全图扫描(上 → 下、下 → 上 + 两次对角线扫描),复杂度是 O(W × H),跟图片大小成正比,与描边宽度无关!
function dilateMaskFast(mask, width, height, radius) {
const dist = new Float32Array(width * height);
// 初始化:不透明=0,透明=∞
for (let i = 0; i < width * height; i++) {
dist[i] = mask[i] === 1 ? 0 : Infinity;
}
// 前向扫描:上→下、左→右
for (let y = 0; y < height; y++)
for (let x = 0; x < width; x++) {
if (x > 0) dist[idx] = Math.min(dist[idx], dist[idx - 1] + 1);
if (y > 0) dist[idx] = Math.min(dist[idx], dist[上方] + 1);
}
// 后向扫描:下→上、右→左
// ...(类似,只是方向反过来)
// 对角线扫描(提高精度,1.414 ≈ √2)
// ...
// 距离 ≤ radius 的像素就是膨胀区域
for (let i = 0; i < width * height; i++) {
dilated[i] = dist[i] <= radius ? 1 : 0;
}
}
🏎️ 性能对比:对于 1000×1000 的图片,
radius = 20:
- 暴力方案:需要检查 ~12 亿次(1000² × 1,256)
- 距离变换:只需要扫描 ~400 万次(1000² × 4 遍)
快了 300 倍。
我们的实现中,根据描边宽度自动选择算法:
const dilatedMask =
strokeWidth <= 10
? dilateMask(...) // 小半径用精确圆形
: dilateMaskFast(...) // 大半径用距离变换
这步最简单——做一次「减法」:
膨胀蒙版: 原始蒙版: 描边区域:
0 0 1 0 0 0 0 0 0 0 0 0 1 0 0
0 1 1 1 0 0 0 1 0 0 0 1 0 1 0
1 1 1 1 1 — 0 1 1 1 0 = 1 0 0 0 1
0 1 1 1 0 0 0 1 0 0 0 1 0 1 0
0 0 1 0 0 0 0 0 0 0 0 0 1 0 0
膨胀了的 原来的图片 只有描边的
整个范围 内容区域 环形区域 ✨
代码就一行:
strokeMask[i] = dilatedMask[i] === 1 && expandedMask[i] === 0 ? 1 : 0;
翻译成人话:膨胀后有、原来没有的像素 = 描边像素。
现在我们知道了哪些像素需要画描边,直接把描边颜色填进去:
const { r, g, b, a } = parseColor(strokeColor); // 比如 "red" → {r:255, g:0, b:0, a:255}
for (let i = 0; i < totalPixels; i++) {
if (strokeMask[i] === 1) {
outputData.data[i * 4] = r; // 红
outputData.data[i * 4 + 1] = g; // 绿
outputData.data[i * 4 + 2] = b; // 蓝
outputData.data[i * 4 + 3] = a; // 透明度
}
// 非描边区域保持 [0,0,0,0] = 完全透明
}
这样我们就得到了一张只有描边、其余透明的图片:
┌───────────────┐
│ │
│ ░ ░ ░ ░ │ ░ = 描边像素(红色)
│ ░ ░ │ · = 透明
│ ░ 🐱 ░ │
│ ░ ░ │
│ ░ ░ ░ ░ │
│ │
└───────────────┘
💡 为什么描边 Canvas 比原图大? 因为描边是向外扩展的!如果不加 padding,描边会超出画布被截断。所以我们在 Step 1 就预留了
strokeWidth的边距。
最后一步是「组装」。fabric.js 渲染图片时会调用 image._render(ctx) 方法。我们把这个方法「劫持」一下:
function patchImageRender(image: FabricImage, options: ImageStrokeOptions) {
// 保存原始 _render(只保存一次)
const originalRender = image._render.bind(image);
// 重写 _render
image._render = function (ctx: CanvasRenderingContext2D) {
// ① 先画描边图层(在原图下面)
ctx.drawImage(
strokeCanvas, // 我们生成的描边图
-width / 2 - padding, // 居中偏移 + 描边扩展
-height / 2 - padding,
width + padding * 2,
height + padding * 2
);
// ② 再画原图(覆盖在描边上面)
originalRender.call(this, ctx);
};
}
渲染顺序很重要——先描边、再原图:
第 1 层(先画) 第 2 层(后画) 最终效果
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ │ │ │ │ │
│ ░░░░░░░░░ │ │ │ │ ░░░░░░░░░ │
│ ░░░░░░░░░░░ │ + │ 🐱🐱🐱 │ = │ ░░🐱🐱🐱░░ │
│ ░░░░░░░░░░░ │ │ 🐱🐱🐱 │ │ ░░🐱🐱🐱░░ │
│ ░░░░░░░░░ │ │ │ │ ░░░░░░░░░ │
│ │ │ │ │ │
└──────────────┘ └──────────────┘ └──────────────┘
描边图层 原图 叠加效果 ✨
像素级操作很耗时(对一张 1000×1000 的图片,要处理 100 万个像素)。如果每帧都重新计算,滚动和缩放画布时会卡成 PPT。
解决方案:用 WeakMap 缓存描边 Canvas。
const strokeCacheMap = new WeakMap<
FabricImage,
{
canvas: HTMLCanvasElement;
optionsKey: string; // "red_6_10" 这样的格式
}
>();
只有当描边参数(颜色、宽度、阈值)发生变化时,才重新生成:
const cacheKey = `${options.color}_${options.width}_${options.alphaThreshold}`;
const existing = strokeCacheMap.get(image);
// 参数没变 → 直接复用缓存
if (existing && existing.optionsKey === cacheKey) return;
// 参数变了 → 重新生成
const strokeCanvas = generateStrokeCanvas(...);
strokeCacheMap.set(image, { canvas: strokeCanvas, optionsKey: cacheKey });
🗑️ 为什么用 WeakMap? 当图片对象被删除(没有其他引用)时,WeakMap 会自动释放缓存内存,不需要手动清理。
一个很重要的设计决策:我们不设置 fabric 的原生 stroke / strokeWidth 属性。
为什么?三个原因:
fabric 会把 strokeWidth 计入 bounding box 计算。如果设置了 strokeWidth: 10,选中框会向外扩 5px——但我们的轮廓描边是不规则形状,和矩形扩展完全对不上。
设了 stroke 之后,fabric 会画一个矩形边框。我们需要额外 hack _renderPaintInOrder 方法来阻止它——增加了复杂度和不稳定性。
fabric 的 stroke 是给矩形/圆形/多边形设计的,我们的轮廓描边跟它完全是两个概念。混在一起会让 JSON 保存/恢复变得混乱。
所以我们的做法是:
// 描边配置存在自定义属性里
(img as any).__strokeOptions = {
enabled: true,
color: "red",
width: 6,
alphaThreshold: 10,
};
// 清掉 fabric 原生描边
img.set({ stroke: undefined, strokeWidth: 0 });
__strokeOptions 会被注册到 PROPERTIES_TO_INCLUDE 中,fabric 的 toJSON() 和 loadFromJSON() 会自动处理它的序列化与恢复。
把整个系统串起来看:
用户操作 系统内部
│
├── 拖入一张 PNG ──────→ object:added 事件触发
│ │
│ ├── autoMode 开启?
│ │ ├── 有 __strokeOptions → 恢复已保存的配置
│ │ ├── 有原生 stroke → 转换为轮廓描边
│ │ └── 都没有 → 跳过,保持原样
│ │
│ └── 执行描边流程:
│ ① getImagePixelData → 读取像素
│ ② 二值化 → 膨胀 → 做差 → 生成描边 Canvas
│ ③ patchImageRender → 重写 _render
│ ④ strokeCacheMap 缓存结果
│
├── 调整描边颜色 ────→ 清除缓存 → 重新生成描边 Canvas → re-render
│
├── 调整描边宽度 ────→ 同上
│
├── 关闭描边 ────────→ __strokeOptions.enabled = false
│ 恢复原始 _render
│
├── 保存/导出 ────→ __strokeOptions 随 JSON 序列化
│ 导出时:新建 StaticCanvas → loadFromJSON → 重建描边 → toBlob
│
└── 重新加载 ────→ loadFromJSON 恢复 __strokeOptions
restoreStrokesFromCanvas() → 重建所有描边
interface ImageStrokeOptions {
enabled: boolean; // 是否启用
color: string; // 描边颜色,如 'red', 'rgba(0,0,0,1)'
width: number; // 描边宽度(像素)
alphaThreshold?: number; // alpha 阈值,默认 10
}
// 给选中图片添加轮廓描边
editor.imageStrokeHandler.applyStroke({
color: "white",
width: 6,
});
// 更新颜色
editor.imageStrokeHandler.updateStrokeColor("#FF0000");
// 更新宽度
editor.imageStrokeHandler.updateStrokeWidth(10);
// 移除描边
editor.imageStrokeHandler.removeStroke();
// 读取当前配置
const opts = editor.imageStrokeHandler.getStrokeOptions();
// → { enabled: true, color: 'white', width: 6, alphaThreshold: 10 }
// 启用自动模式:所有新加入的图片自动描边
editor.imageStrokeHandler.enableAutoMode({ color: "black", width: 4 });
| 步骤 | 做什么 | 关键技术 |
|---|---|---|
| 1 | 读取 alpha 通道 → 二值化蒙版 | getImageData + 阈值判断 |
| 2 | 蒙版膨胀 N 像素 | 圆形膨胀(暴力)/ 距离变换(EDT) |
| 3 | 膨胀 - 原始 = 描边区域 | 集合差运算 |
| 4 | 描边区域填色 → 描边 Canvas | putImageData |
| 5 | 重写 _render:先描边后原图 |
Monkey Patch + WeakMap 缓存 |
核心思想其实很简单:先找到图片的「边界」在哪里(alpha 通道),然后把边界向外「膨胀」一圈,膨胀出来的那个环就是描边。
整个方案的优势:
希望这篇文章对你有帮助!如果你也在做 Canvas 图形编辑器,不妨试试这个方案 🎨