q-floodfill白边锯齿?一招搞定抗锯齿边缘问题

q-floodfill 填充白边锯齿问题记录

最近在 Canvas 里用 q-floodfill 做油漆桶填充,基础用法很简单:

js 复制代码
const imageData = ctx.getImageData(
  0,
  0,
  canvas.width,
  canvas.height
);

const flood = new FloodFill(imageData);

flood.fill('#ff0000', x, y, 10);

ctx.putImageData(flood.imageData, 0, 0);

功能能正常用,但实际画起来会发现一个问题:

填充区域和黑色描边之间容易出现一圈白边 / 锯齿。

原因

一开始我以为是 tolerance 太小,后来发现主要还是 Canvas 抗锯齿导致的。

Canvas 画线时,边缘不是纯黑像素,而是存在很多半透明像素:

text 复制代码
透明
10% 黑
40% 黑
80% 黑
100% 黑

q-floodfill 主要负责判断:

哪些像素属于同一个连续区域。

它不会专门帮我们处理描边边缘这些半透明抗锯齿像素。

结果就是主体区域填上颜色了,但描边边缘还残留一圈浅色像素。

单纯调大:

js 复制代码
tolerance

效果也不稳定,调太大甚至可能直接穿过边界。


解决思路

我的处理方式是:

text 复制代码
q-floodfill 正常填充
↓
记录实际填充过的像素
↓
找到填充区域周围的抗锯齿像素
↓
把填充颜色混合到这些边缘像素下面

1. 用 mask 记录填充区域

js 复制代码
const mask = new Uint8Array(width * height);

const setPixel = flood.setColorAtPixel.bind(flood);

flood.setColorAtPixel = (data, value, x, y) => {
  mask[y * width + x] = 1;
  setPixel(data, value, x, y);
};

执行完:

js 复制代码
flood.fill(color, x, y, tolerance);

之后:

text 复制代码
mask = 1  已填充
mask = 0  未填充

2. 找边缘像素

遍历没有填充的像素,如果它周围 8 个方向存在已经填充的像素,就把它认为是边缘区域。

js 复制代码
let adjacent = false;

for (let dy = -1; dy <= 1; dy++) {
  for (let dx = -1; dx <= 1; dx++) {
    const nx = px + dx;
    const ny = py + dy;

    if (
      nx >= 0 &&
      nx < width &&
      ny >= 0 &&
      ny < height &&
      mask[ny * width + nx]
    ) {
      adjacent = true;
      break;
    }
  }
}

3. 处理半透明抗锯齿像素

如果边缘像素满足:

js 复制代码
0 < alpha && alpha < 1

说明它是半透明描边。

这里不能直接替换成填充色,否则黑色描边也会被破坏。

正确做法是:

保留原来的描边,把填充色垫到描边下面。

js 复制代码
const under = (1 - alpha) * fill.a / 255;
const resultAlpha = alpha + under;

image.data[i] =
  (source[i] * alpha + fill.r * under) / resultAlpha;

image.data[i + 1] =
  (source[i + 1] * alpha + fill.g * under) / resultAlpha;

image.data[i + 2] =
  (source[i + 2] * alpha + fill.b * under) / resultAlpha;

image.data[i + 3] = resultAlpha * 255;

这样原来的:

text 复制代码
黑线
白边
填充色

就会变成:

text 复制代码
黑线
抗锯齿黑线 + 填充色
填充色

白边基本就没了。


还有一个透明像素的小坑

Canvas 里两个完全透明的像素,RGB 可能不一样:

js 复制代码
rgba(255, 255, 255, 0)
rgba(0, 0, 0, 0)

视觉上都是透明的,但颜色比较时可能被认为不一样。

所以我额外改了 isSameColor

js 复制代码
const same = flood.isSameColor.bind(flood);

flood.isSameColor = (a, b, limit) => {
  return (
    (a.a === 0 && b.a === 0) ||
    same(a, b, limit)
  );
};

只要两个像素:

js 复制代码
alpha === 0

就直接认为是同色。


最后

这个问题本质上不是 q-floodfill 填错了,而是:

text 复制代码
Flood Fill
解决区域判断

Canvas 抗锯齿
解决视觉平滑

两者结合时,边缘半透明像素需要额外处理。

最后我的方案就是:

text 复制代码
q-floodfill
+
mask
+
8 邻域检测
+
Alpha 混合

实测比单纯调 tolerance 稳定很多。

如果画布比较大,还可以继续优化,只处理填充区域附近的范围,避免每次遍历整个 Canvas。 最后展示效果 源码附上,如果觉得有用点个赞再走呗

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no" />
  <title>平滑画笔 + q-floodfill 填充</title>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    html, body {
      height: 100%;
      overflow: hidden;
      background: #f0f2f5;
      font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
    }
    #app {
      position: fixed;
      inset: 0;
      display: flex;
      flex-direction: column;
    }
    #toolbar {
      min-height: 56px;
      flex-shrink: 0;
      background: #fff;
      border-bottom: 1px solid #e5e7eb;
      display: flex;
      align-items: center;
      flex-wrap: wrap;
      gap: 12px;
      padding: 10px 16px;
      box-shadow: 0 1px 2px rgba(0, 0, 0, .04);
      z-index: 2;
    }
    #toolbar .group {
      display: flex;
      align-items: center;
      gap: 8px;
    }
    #toolbar label {
      font-size: 13px;
      color: #374151;
    }
    #toolbar input[type="range"] { width: 110px; }
    #toolbar input[type="color"] {
      width: 32px;
      height: 32px;
      border: none;
      padding: 0;
      background: none;
      cursor: pointer;
    }
    #toolbar button {
      height: 32px;
      padding: 0 14px;
      border: 1px solid #d1d5db;
      border-radius: 6px;
      background: #fff;
      color: #111827;
      font-size: 13px;
      cursor: pointer;
    }
    #toolbar button:hover { background: #f3f4f6; }
    #toolbar button.active {
      background: #111827;
      color: #fff;
      border-color: #111827;
    }
    #toolbar button:disabled {
      opacity: .4;
      cursor: not-allowed;
    }
    #toolbar .sep {
      width: 1px;
      height: 24px;
      background: #e5e7eb;
    }
    .value {
      font-size: 12px;
      color: #6b7280;
      min-width: 36px;
    }
    #canvas {
      flex: 1;
      display: block;
      width: 100%;
      height: 100%;
      background: #fff;
      touch-action: none;
      cursor: crosshair;
    }
  </style>
</head>
<body>
<div id="app">
  <div id="toolbar">
    <div class="group">
      <button id="brushBtn" class="active">画笔</button>
      <button id="fillBtn">填充</button>
    </div>

    <div class="sep"></div>

    <div class="group">
      <label>粗细</label>
      <input id="size" type="range" min="1" max="60" value="6" />
      <span id="sizeVal" class="value">6px</span>
    </div>

    <div class="group">
      <label>颜色</label>
      <input id="color" type="color" value="#222222" />
    </div>

    <div class="group">
      <label>填充容差</label>
      <input id="tolerance" type="range" min="0" max="100" value="10" />
      <span id="toleranceVal" class="value">10</span>
    </div>

    <div class="sep"></div>

    <button id="undoBtn" disabled>撤销</button>
    <button id="clearBtn">清空</button>
  </div>

  <canvas id="canvas"></canvas>
</div>

<script type="module">
  // q-floodfill 1.3.1:ESM CDN
  import FloodFill from 'https://cdn.jsdelivr.net/npm/q-floodfill@1.3.1/dist/index.bundle.esm.js';

  /* =========================================================
     1. 平滑函数:等距重采样 → 加权平均 → Chaikin 细分 ×2
     ========================================================= */
  function smoothCutPoints(input) {
    if (input.length < 3) return input.map(p => ({ ...p }));

    const spacing = 4;
    const sampled = [{ ...input[0] }];
    let remaining = spacing;

    for (let i = 1; i < input.length; i++) {
      let start = input[i - 1];
      const end = input[i];
      let distance = Math.hypot(end.x - start.x, end.y - start.y);

      while (distance >= remaining) {
        const ratio = remaining / distance;
        start = {
          x: start.x + (end.x - start.x) * ratio,
          y: start.y + (end.y - start.y) * ratio,
        };
        sampled.push(start);
        distance = Math.hypot(end.x - start.x, end.y - start.y);
        remaining = spacing;
      }
      remaining -= distance;
    }

    const last = input[input.length - 1];
    const tail = sampled[sampled.length - 1];
    if (Math.hypot(last.x - tail.x, last.y - tail.y) > 0.01) {
      sampled.push({ ...last });
    }

    let points = sampled.map((point, index) => {
      if (index === 0 || index === sampled.length - 1) return { ...point };

      let x = 0, y = 0, total = 0;
      for (let offset = -3; offset <= 3; offset++) {
        const neighbor = sampled[index + offset];
        if (!neighbor) continue;
        const weight = 4 - Math.abs(offset);
        x += neighbor.x * weight;
        y += neighbor.y * weight;
        total += weight;
      }
      return { x: x / total, y: y / total };
    });

    for (let pass = 0; pass < 2; pass++) {
      if (points.length < 3) break;
      const refined = [points[0]];

      for (let index = 0; index < points.length - 1; index++) {
        const point = points[index];
        const next = points[index + 1];
        refined.push(
          { x: point.x * 0.75 + next.x * 0.25, y: point.y * 0.75 + next.y * 0.25 },
          { x: point.x * 0.25 + next.x * 0.75, y: point.y * 0.25 + next.y * 0.75 }
        );
      }

      refined.push(points[points.length - 1]);
      points = refined;
    }

    return points;
  }

  /* =========================================================
     2. 画笔 + 填充应用
     ========================================================= */
  (() => {
    const canvas = document.getElementById('canvas');
    const ctx = canvas.getContext('2d', { willReadFrequently: true });

    const brushBtn = document.getElementById('brushBtn');
    const fillBtn = document.getElementById('fillBtn');
    const sizeInput = document.getElementById('size');
    const sizeVal = document.getElementById('sizeVal');
    const colorInput = document.getElementById('color');
    const toleranceInput = document.getElementById('tolerance');
    const toleranceVal = document.getElementById('toleranceVal');
    const undoBtn = document.getElementById('undoBtn');
    const clearBtn = document.getElementById('clearBtn');

    let dpr = window.devicePixelRatio || 1;
    let tool = 'brush';

    // 不能只保存 strokes,因为 flood fill 是像素操作。
    // 统一保存"操作",redraw 时按发生顺序重放:
    // { type:'stroke', points, size, color }
    // { type:'fill', x, y, color, tolerance }
    let actions = [];

    let isDrawing = false;
    let rawPoints = [];
    let smoothedPoints = [];

    function resize() {
      dpr = window.devicePixelRatio || 1;
      const rect = canvas.getBoundingClientRect();
      canvas.width = Math.max(1, Math.floor(rect.width * dpr));
      canvas.height = Math.max(1, Math.floor(rect.height * dpr));
      redraw();
    }

    window.addEventListener('resize', resize);

    function getPoint(e) {
      const rect = canvas.getBoundingClientRect();
      return {
        x: (e.clientX - rect.left) * dpr,
        y: (e.clientY - rect.top) * dpr,
      };
    }

    /* ---------- 绘制原语 ---------- */
    function drawStroke(points, size, color) {
      if (!points || points.length < 2) return;

      ctx.save();
      ctx.strokeStyle = color;
      ctx.fillStyle = color;
      ctx.lineWidth = size;
      ctx.lineCap = 'round';
      ctx.lineJoin = 'round';

      if (points.length === 2) {
        ctx.beginPath();
        ctx.moveTo(points[0].x, points[0].y);
        ctx.lineTo(points[1].x, points[1].y);
        ctx.stroke();
        ctx.restore();
        return;
      }

      ctx.beginPath();
      ctx.moveTo(points[0].x, points[0].y);
      for (let i = 1; i < points.length; i++) {
        ctx.lineTo(points[i].x, points[i].y);
      }
      ctx.stroke();
      ctx.restore();
    }

    /**
     * 使用 q-floodfill 做区域填充,并额外修复抗锯齿边缘产生的白边/透明缝。
     *
     * 思路:
     * 1. q-floodfill 负责找出主体连通区域;
     * 2. mask 记录真正被 flood fill 命中的像素;
     * 3. 再扫描 mask 外围一圈 8 邻域像素;
     * 4. 对半透明抗锯齿像素进行 alpha 混合,让填充色延伸到描边下方。
     */
    function fillRegion(image, color, x, y, tolerance = 10, overlay = false) {
      const { width, height } = image;

      // 备份原始像素数据,后续用于边缘像素的混合计算
      const source = new Uint8ClampedArray(image.data);

      // 掩码:1 表示该像素被 flood fill 覆盖
      const mask = new Uint8Array(width * height);

      const flood = new FloodFill(image);

      // 完全透明像素的 RGB 没有视觉意义,统一视为同色。
      // 使用 bind 保证第三方库方法内部如果依赖 this 也不会出问题。
      const same = flood.isSameColor.bind(flood);
      flood.isSameColor = (a, b, limit) =>
        (a.a === 0 && b.a === 0) || same(a, b, limit);

      // 在 q-floodfill 写像素时同步记录 mask。
      const setPixel = flood.setColorAtPixel.bind(flood);
      flood.setColorAtPixel = (data, value, px, py) => {
        mask[py * width + px] = 1;
        setPixel(data, value, px, py);
      };

      flood.fill(color, x, y, tolerance);

      const fill = flood.colorToRGBA(color);
      const seed = (y * width + x) * 4;
      const transparentSeed = source[seed + 3] === 0;
      const seedMax = Math.max(
        source[seed],
        source[seed + 1],
        source[seed + 2]
      );

      // 处理 flood fill 区域外围一圈抗锯齿边缘。
      for (let p = 0; p < mask.length; p++) {
        const i = p * 4;

        // 主体填充区域已经由 q-floodfill 填好。
        if (mask[p]) continue;

        if (overlay) image.data.fill(0, i, i + 4);

        const px = p % width;
        const py = Math.floor(p / width);

        // 当前像素必须和已填充区域的 8 邻域相邻,才视为候选边缘像素。
        let adjacent = false;
        for (let dy = -1; dy <= 1 && !adjacent; dy++) {
          for (let dx = -1; dx <= 1; dx++) {
            const nx = px + dx;
            const ny = py + dy;

            if (
              nx >= 0 && nx < width &&
              ny >= 0 && ny < height &&
              mask[ny * width + nx]
            ) {
              adjacent = true;
              break;
            }
          }
        }

        if (!adjacent) continue;

        const alpha = source[i + 3] / 255;

        if (transparentSeed && alpha > 0 && alpha < 1) {
          // 透明背景区域:当前像素通常是描边的半透明抗锯齿边缘。
          if (overlay) {
            image.data[i] = source[i] * alpha + fill.r * (1 - alpha);
            image.data[i + 1] = source[i + 1] * alpha + fill.g * (1 - alpha);
            image.data[i + 2] = source[i + 2] * alpha + fill.b * (1 - alpha);
            image.data[i + 3] = fill.a;
            continue;
          }

          // 把填充颜色合成在原描边下方。
          const under = (1 - alpha) * fill.a / 255;
          const resultAlpha = alpha + under;

          // resultAlpha 理论上 > 0;额外保护避免极端输入出现除 0。
          if (resultAlpha > 0) {
            image.data[i] = (source[i] * alpha + fill.r * under) / resultAlpha;
            image.data[i + 1] = (source[i + 1] * alpha + fill.g * under) / resultAlpha;
            image.data[i + 2] = (source[i + 2] * alpha + fill.b * under) / resultAlpha;
            image.data[i + 3] = resultAlpha * 255;
          }
        } else if (source[seed + 3] === 255 && seedMax > 0 && alpha === 1) {
          // 不透明背景区域:尝试修正"种子底色 + 黑色描边"的预混合边缘。
          const coverage = Math.max(
            source[i],
            source[i + 1],
            source[i + 2]
          ) / seedMax;

          if (
            coverage <= 0 ||
            coverage >= 1 ||
            [0, 1, 2].some(channel =>
              Math.abs(
                source[i + channel] - source[seed + channel] * coverage
              ) > 1
            )
          ) {
            continue;
          }

          const amount = fill.a / 255;
          image.data[i] = source[i] * (1 - amount) + fill.r * coverage * amount;
          image.data[i + 1] = source[i + 1] * (1 - amount) + fill.g * coverage * amount;
          image.data[i + 2] = source[i + 2] * (1 - amount) + fill.b * coverage * amount;
          image.data[i + 3] = 255;
        }
      }

      return image;
    }

    /**
     * 从 Canvas 读取 ImageData,使用 fillRegion 填充后写回。
     */
    function applyFloodFill(x, y, color, tolerance = 10) {
      const px = Math.max(0, Math.min(canvas.width - 1, Math.floor(x)));
      const py = Math.max(0, Math.min(canvas.height - 1, Math.floor(y)));

      const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
      const result = fillRegion(
        imageData,
        color,
        px,
        py,
        Number(tolerance),
        false
      );

      ctx.putImageData(result, 0, 0);
    }

    function redraw() {
      ctx.clearRect(0, 0, canvas.width, canvas.height);

      for (const action of actions) {
        if (action.type === 'stroke') {
          drawStroke(action.points, action.size, action.color);
        } else if (action.type === 'fill') {
          applyFloodFill(
            action.x,
            action.y,
            action.color,
            action.tolerance
          );
        }
      }

      // 当前正在绘制的笔触预览
      if (tool === 'brush' && smoothedPoints.length >= 2) {
        drawStroke(
          smoothedPoints,
          Number(sizeInput.value) * dpr,
          colorInput.value
        );
      }
    }

    function pushAction(action) {
      actions.push(action);
      if (actions.length > 50) actions.shift();
      updateButtons();
    }

    /* ---------- 工具切换 ---------- */
    function setTool(nextTool) {
      tool = nextTool;
      isDrawing = false;
      rawPoints = [];
      smoothedPoints = [];

      brushBtn.classList.toggle('active', tool === 'brush');
      fillBtn.classList.toggle('active', tool === 'fill');
      canvas.style.cursor = tool === 'fill' ? 'cell' : 'crosshair';
      redraw();
    }

    brushBtn.addEventListener('click', () => setTool('brush'));
    fillBtn.addEventListener('click', () => setTool('fill'));

    /* ---------- 指针事件 ---------- */
    function onDown(e) {
      e.preventDefault();
      const point = getPoint(e);

      // 填充模式:点击一次就是一个完整操作
      if (tool === 'fill') {
        const action = {
          type: 'fill',
          x: point.x,
          y: point.y,
          color: colorInput.value,
          tolerance: Number(toleranceInput.value),
        };

        // 先直接执行,再保存操作,避免为了一次点击重放全部历史
        applyFloodFill(
          action.x,
          action.y,
          action.color,
          action.tolerance
        );

        pushAction(action);
        return;
      }

      canvas.setPointerCapture(e.pointerId);
      isDrawing = true;
      rawPoints = [point];
      smoothedPoints = [];
    }

    function onMove(e) {
      if (tool !== 'brush' || !isDrawing) return;
      e.preventDefault();

      rawPoints.push(getPoint(e));
      if (rawPoints.length >= 3) {
        smoothedPoints = smoothCutPoints(rawPoints);
      }
      redraw();
    }

    function onUp(e) {
      if (tool !== 'brush' || !isDrawing) return;

      isDrawing = false;
      try { canvas.releasePointerCapture(e.pointerId); } catch (_) {}

      const finalPoints = smoothedPoints.length >= 3
        ? smoothedPoints
        : rawPoints.map(p => ({ ...p }));

      if (finalPoints.length >= 2) {
        pushAction({
          type: 'stroke',
          points: finalPoints,
          size: Number(sizeInput.value) * dpr,
          color: colorInput.value,
        });
      }

      rawPoints = [];
      smoothedPoints = [];
      redraw();
    }

    canvas.addEventListener('pointerdown', onDown);
    canvas.addEventListener('pointermove', onMove);
    canvas.addEventListener('pointerup', onUp);
    canvas.addEventListener('pointercancel', onUp);
    canvas.addEventListener('pointerleave', onUp);

    /* ---------- 工具栏 ---------- */
    sizeInput.addEventListener('input', () => {
      sizeVal.textContent = sizeInput.value + 'px';
    });

    toleranceInput.addEventListener('input', () => {
      toleranceVal.textContent = toleranceInput.value;
    });

    clearBtn.addEventListener('click', () => {
      actions = [];
      rawPoints = [];
      smoothedPoints = [];
      redraw();
      updateButtons();
    });

    undoBtn.addEventListener('click', () => {
      actions.pop();
      redraw();
      updateButtons();
    });

    function updateButtons() {
      undoBtn.disabled = actions.length === 0;
    }

    // Ctrl/Cmd + Z
    window.addEventListener('keydown', (e) => {
      if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') {
        e.preventDefault();
        if (actions.length) {
          actions.pop();
          redraw();
          updateButtons();
        }
      }
    });

    resize();
  })();
</script>
</body>
</html>```
相关推荐
阿虎儿1 小时前
我的 HTML 样板(HTML Boilerplate)
前端·html
Amos_Web1 小时前
Rspack 源码解析(八):Module、Chunk 与 Runtime ID
前端·rust·源码阅读
彭于晏分晏1 小时前
深入理解 Vue 3 响应式原理:从 Proxy 到依赖收集
前端
晚安日记wanna1 小时前
Vue3 script setup 的四层追问答到第三层才算过关
前端·vue.js·面试
前端柱子1 小时前
Chaikin‘s Corner Cutting 算法 应用canvas绘制平滑的曲线
前端
gnip1 小时前
uts 插件示例:获取设备电量信息
前端·javascript
码上成长1 小时前
Mapbox 上用 Turf 裁多边形:屏幕贴边了,接口却说越界
前端·前端框架
计算机魔术师1 小时前
纽约时报告了OpenAI和微软:820万条聊天记录背后,是AI时代最大的版权保卫战
前端
码事漫谈2 小时前
在 Kubernetes 上管好数据库:金仓 KES-Operator 正式落地
前端·后端