🌑

冰河末日的博客

fabricjs:透明图片轮廓描边:从像素操作到实时渲染

— 2026年4月1日
  1. 🎯 先看效果
  2. ❓ 为什么原生描边不行?
  3. 🧠 核心思路:5 步走
  4. Step 1:读取 Alpha 通道 → 二值化蒙版
    1. 什么是 Alpha 通道?
    2. 二值化:非黑即白
  5. Step 2:膨胀算法(Dilation)——让蒙版「胖一圈」
    1. 膨胀是什么意思?
    2. 方案一:暴力但精确(适合小半径 ≤ 10px)
    3. 方案二:距离变换(适合大半径 > 10px)
  6. Step 3:膨胀蒙版 - 原始蒙版 = 描边区域
  7. Step 4:生成描边图层
  8. Step 5:重写渲染方法——在原图下面垫一层描边
  9. ⚡ 性能优化:缓存机制
  10. 🏗️ 架构设计:不动 fabric 的奶酪
    1. 1. Bounding Box 错位
    2. 2. 原生矩形描边干扰
    3. 3. 序列化不兼容
  11. 📦 完整流程图
  12. 🔑 关键代码一览
    1. 配置接口
    2. 使用 API
  13. 📝 小结

在 Canvas 图形编辑器中,如何为任意形状的透明 PNG 图片添加「沿轮廓走」的描边效果?本文从零开始,用通俗的语言带你理解整个实现原理。

🎯 先看效果

想象一下这样的场景:你在做一个海报编辑器,用户拖入一张透明背景的卡通角色 PNG,想给它加一圈白色描边——就像贴纸上那种经典的白色轮廓。

┌────────────────────────────────────┐
│                                    │
│    ╭──╮       ╭─────╮             │
│   ╭╯🐱╰╮  →  ╭╯ ░🐱░ ╰╮  ← 描边  │
│   ╰────╯     ╰───────╯           │
│                                    │
│   原始透明图片    添加轮廓描边后     │
└────────────────────────────────────┘

如果用 Canvas 2D / fabric.js 的原生 stroke 属性,只能画出一个矩形边框——它根本不认识你图片的轮廓。我们需要自己动手,「教」计算机看懂图片的形状。

❓ 为什么原生描边不行?

fabric.js(底层是 Canvas 2D)对图片的描边,本质上就是在图片的矩形边界上画一圈:

┌────────────────┐
│  ┌──────────┐  │  ← fabric 原生 stroke
│  │ 🐱       │  │     只能画矩形框
│  │          │  │
│  └──────────┘  │
└────────────────┘

但我们想要的是这种效果——紧贴图片内容轮廓的描边:

  ╭───╮
 ╭╯   ╰╮
╭╯ 🐱  ╰╮   ← 我们想要的:沿轮廓描边
╰╮     ╭╯
 ╰╮   ╭╯
  ╰───╯

Canvas 2D API 没有提供这样的能力。所以我们得深入像素层面,自己算出描边区域。

🧠 核心思路:5 步走

整个算法可以概括为 5 步(别急,后面会一步步展开):

原始图片
  │
  ▼
① 读取像素 alpha 通道 → 建立「二值化蒙版」
  │
  ▼
② 用「膨胀算法」将蒙版向外扩展 N 像素
  │
  ▼
③ 膨胀蒙版 - 原始蒙版 = 描边区域
  │
  ▼
④ 将描边颜色填充到描边区域 → 生成「描边图层」
  │
  ▼
⑤ 重写 fabric 的渲染方法:先画描边图层,再画原图

接下来我们一步步拆解。


Step 1:读取 Alpha 通道 → 二值化蒙版

什么是 Alpha 通道?

每个像素有 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),给描边预留空间。后面会解释为什么。


Step 2:膨胀算法(Dilation)——让蒙版「胖一圈」

这是整个算法的核心中的核心。

膨胀是什么意思?

想象蒙版上每个值为 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 毫米。

方案一:暴力但精确(适合小半径 ≤ 10px)

对于每个透明像素(值为 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 个点——太慢了。

方案二:距离变换(适合大半径 > 10px)

这是一个巧妙的优化:与其「每个点检查一个圆形邻域」,不如先算出每个透明像素到最近的不透明像素的距离。然后距离 ≤ 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(...) // 大半径用距离变换

Step 3:膨胀蒙版 - 原始蒙版 = 描边区域

这步最简单——做一次「减法」:

膨胀蒙版:                原始蒙版:                描边区域:
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;

翻译成人话:膨胀后有、原来没有的像素 = 描边像素。


Step 4:生成描边图层

现在我们知道了哪些像素需要画描边,直接把描边颜色填进去:

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 的边距。


Step 5:重写渲染方法——在原图下面垫一层描边

最后一步是「组装」。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 的奶酪

一个很重要的设计决策:我们不设置 fabric 的原生 stroke / strokeWidth 属性。

为什么?三个原因:

1. Bounding Box 错位

fabric 会把 strokeWidth 计入 bounding box 计算。如果设置了 strokeWidth: 10,选中框会向外扩 5px——但我们的轮廓描边是不规则形状,和矩形扩展完全对不上。

2. 原生矩形描边干扰

设了 stroke 之后,fabric 会画一个矩形边框。我们需要额外 hack _renderPaintInOrder 方法来阻止它——增加了复杂度和不稳定性。

3. 序列化不兼容

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
}

使用 API

// 给选中图片添加轮廓描边
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 通道),然后把边界向外「膨胀」一圈,膨胀出来的那个环就是描边。

整个方案的优势:

  • ✅ 完全不依赖 fabric 原生 stroke,不影响 bounding box
  • ✅ 支持任意形状的透明图片
  • ✅ 自动选择最优算法(小半径精确、大半径快速)
  • ✅ WeakMap 缓存,参数不变不重复计算
  • ✅ 配置可序列化,JSON 导入导出无缝恢复
  • ✅ 描边在原图下方,不遮挡图片内容

希望这篇文章对你有帮助!如果你也在做 Canvas 图形编辑器,不妨试试这个方案 🎨

,

知行合一