基于 fabric 实现图片的橡皮擦、魔术橡皮擦

1,093 阅读12分钟

前言

我在国内和国外的网站上搜了很久都没找到相关的文章 这可能是全文唯一一篇教你怎么基于fabric上实现橡皮擦和魔术橡皮擦功能比较全的文章了

什么是图片的橡皮擦、魔术橡皮功能?

橡皮擦

关于橡皮擦,我们可以直接在官网找到示例,大招的实现效果如下

橡皮擦示例.gif

魔术橡皮

而魔术橡皮相对于橡皮擦来说就复杂了点,简单来说是通过分析图像中的颜色相似度来选择并删除某个区域
一般这个功能是用于快速抠图,具体我们可以参考 ps 中的魔术橡皮擦功能
类似于下面这样

如何实现橡皮擦

如何实现画线消除的效果

橡皮擦的实现其实很简单,毕竟官方都有例子给出了
但是这里麻烦的是,默认版本的 fabric 是没有集成 EraserBrush 功能的,但是如果你的项目已经进行到了很久,为了这一个功能修改版本或者升级版本,也有点比较得不偿失

而在 fabric 的定制页面勾选了橡皮擦之后下载下来的文件似乎是我的打开方式不对,也是会存在报错,出现不可用的情况

所以我这边是找了一个单独的抽离的 fabric erasing功能组合进去的

import { fabric } from 'fabric'
import Shape from '../tool/shape'
import './fabric.erasing.min.js'

class Eraser extends Shape {
  private eraserWidth: number = 20

  override initialize() {
    this.canvas.isDrawingMode = true 
    this.canvas.freeDrawingBrush = new (fabric as any).EraserBrush(this.owlCanvas)
    this.canvas.freeDrawingBrush.width = this.eraserWidth // 设置画笔宽度
  }

  
  changeEraserWidth(value) {
    this.canvas.freeDrawingBrush.width = value // 设置画笔宽度
  }
}
export default Eraser

fabric.erasing.min.js 文件

fabric.erasing.min.js

如何实现真实修改到图片上

但是,我们发现官方例子中的橡皮本质上只是一个透明的遮罩层,并没有修改到图片本身,这是不符合我们需求的
我们需要的是修改到图片的像素上去,而不是只是一个简单的遮罩

那我们该怎么做呢?
其实很简单,我们根据现在实现出来的遮罩画线效果,可以从以下几步简单得实现这个效果:

获取到当前有橡皮擦效果的图片及其数据

private getErasedImageObject() {
    // 假设 canvas 上只有一个图片对象的情况
    const objects = this.canvas.getObjects()
    const imageObject = objects.find((obj) => obj.type === 'image')
    return imageObject
}

// 获取原图片对象
const imageObject = this.getErasedImageObject()
if (!imageObject) {
  console.error('未找到图片对象')
  return
}

创建一个与图片大小、位置一致的新的canvas标签

const cloneWidth = (imageObject!.width || 0) / (1 / (originScaleX || 1))
const cloneHeight = (imageObject!.height || 0) / (1 / (originScaleY || 1))

// 创建新的 Canvas 元素
const newCanvas = document.createElement('canvas')
newCanvas.width = cloneWidth
newCanvas.height = cloneHeight

// 创建 Fabric.Canvas 实例,并设置大小
const newFabricCanvas = new fabric.Canvas(newCanvas, {
width: cloneWidth,
height: cloneHeight,
backgroundColor: 'transparent' // 设置背景为白色
})

将原图片绘制到新的canvas上去

// 绘制原图片到新 Canvas 上
fabric.Image.fromURL(imageObject.toDataURL({}), (img) => {
  img.set({
    left: 0, // 设置为新 Canvas 的左上角,根据需要调整
    top: 0,
    scaleX: 1, // 根据需要设置缩放比例
    scaleY: 1,
    angle: 0, // 根据需要设置旋转角度
    opacity: 1, // 根据需要设置透明度
    flipX: false, // 根据需要设置翻转
    flipY: false,
    originX: 'left', // 设置原点为左上角
    originY: 'top',
    crossOrigin: 'anonymous', // 根据需要设置跨域属性
    strokeWidth: 0
  })
  newFabricCanvas.add(img)
 })

绘制存在的橡皮擦痕迹

// 绘制橡皮擦痕迹(如果有)
const objects = canvas.getObjects()
objects.forEach((obj) => {
  if (obj !== imageObject) {
    const clonedObj = fabric.util.object.clone(obj)
    newFabricCanvas.add(clonedObj)
  }
})

导出这个新建的canvas作为图片保存

// 导出新 Canvas 为图片
const dataURL = newFabricCanvas.toDataURL({
  format: 'png',
  quality: 1,
  multiplier: 1,
  enableRetinaScaling: false
})

根据导出的图片新建一个新的 Fabric.Image 添加到原来的画布上

 // 创建新的 Fabric.Image 对象
fabric.Image.fromURL(dataURL, (newImgObj) => {
  // 设置新图片对象的属性(位置、大小等)
  newImgObj.set({
    left: imageObject.left, // 设置为原始图片对象的位置
    top: imageObject.top,
    scaleX: 1, // 设置为原始图片对象的缩放比例
    scaleY: 1,
    angle: imageObject.angle,
    opacity: imageObject.opacity,
    flipX: imageObject.flipX,
    flipY: imageObject.flipY,
    originX: imageObject.originX,
    originY: imageObject.originY,
    skewX: imageObject.skewX,
    skewY: imageObject.skewY,
    clipPath: imageObject.clipPath,
    strokeWidth: 0
  })
})

清除原来的图片对象

  // 移除原来的图片对象,并添加新图片对象
  canvas.clear()
  canvas.add(newImgObj)

实现效果

橡皮擦示例.gif

如何实现魔术橡皮擦

魔术橡皮擦,本质上就是将选中点的周围近似颜色给进行擦除,所以我们可以基于这点进行方案预演
步骤大致有以下几点

  1. 获取鼠标落点的颜色值
  2. 获取鼠标落点颜色值周围近似颜色的像素
  3. 擦除获取到的结果
  4. 重绘图片

× 方案一 洪水算法

什么是洪水算法

洪水覆盖是怎么覆盖的?想一下如果对一片土地进行灌水,那么这些洪水是如何进行覆盖的。水是从一个点向周围四周开始覆盖的。如下图从一个点开始一层一层向外面扩散。
而 BFS 搜索的过程也是这个样子,从一个点开始,一层一层向外面搜索。
而 Flood Fiil 是基于 BFS 的因此其操作的流程是有一些相似的。
颜色的处理刚刚好和这个类似,我们可以获取到落点的颜色后使用BFS来遍历周围是否有近似点,然后对其进行改变

四邻域填充法

而我们常见的填充方式通常是有四邻域填充法

image.png

这种方式不需要考虑对角线方向的节点

八邻域填充法

除此之外我们还有八邻域填充法

image.png

这种方法是基于扫描线填充方法的,可以充分考虑对角线的问题

而具体实现又可以分为递归和非递归(基于栈)。最简单的办法当然是使用深度优先遍历,但是也可以使用广度优先遍历,不过基于递归有个缺点就是对于过大区域填充可能会导致栈溢出。

常用场景

洪水算法最常用的场景就是颜色填充,也就是我们当前需要做的事情
除此之外还有扫雷之类的游戏也是利用了洪水填充来计算计算的,具体来说可以有以下几点:

  1. 图像编辑:洪水填充算法在图像编辑软件中得到了广泛应用,如填充图像中的瑕疵、消除红眼现象、平滑皮肤质感等。通过选择需要填充的区域和填充颜色,用户可以轻松地实现对图像的编辑和美化。
  2. 计算机游戏:在计算机游戏中,洪水填充算法常用于生成动态环境,如植被、建筑物等。通过指定种子点和填充颜色,游戏引擎可以自动填充整个连通区域,从而快速生成具有真实感的游戏场景。
  3. 计算机视觉:在计算机视觉领域,洪水填充算法可用于物体检测和分割、提取图像中的特征等任务。通过填充与背景或前景相连通的区域,算法可以帮助识别图像中的关键信息,并为后续处理提供基础。
  4. 地理信息系统(GIS):在GIS中,洪水填充算法可用于地形建模和分析。通过将图像中的每个像素点视为一个地形高度点,并计算每个点的水流方向和速度,算法可以模拟洪水填充过程并生成清晰的地形分割图像。这有助于地理信息的可视化和查询。
  5. 实时渲染和模拟:在游戏开发和虚拟现实等领域中,洪水填充算法可用于实时地形渲染和模拟。通过动态更新填充区域和颜色信息,算法可以实时生成具有真实感和动态变化的地形效果。

参考文档

zhuanlan.zhihu.com/p/419692331

如何使用

在下面的实现中我们主要是计算了鼠标落点颜色以及附近的近似颜色,然后手动一个个像素点修改
但是这种方式肯定是不合适的,所以下面代码仅供参考,没有现实意义
(7月16日更新 入职某公司后作者发现该方案是正确可用的,只是可能作者系的方法哪里有问题导致大图的情况下会有卡顿的情况)
具体代码实现如下

// 获取鼠标点击位置的颜色
export const getColorAtMouse = (
  event: fabric.IEvent,
  canvas: fabric.Canvas
): { r: number; g: number; b: number; a: number } => {
  const pointer = canvas.getPointer(event.e)
  const ctx = canvas.getElement().getContext('2d') // 获取底层 HTML Canvas 的上下文
  if (!ctx) {
    throw new Error('Unable to get 2d context from canvas')
  }
  const pixelColor = ctx.getImageData(pointer.x, pointer.y, 1, 1).data
  return { r: pixelColor[0], g: pixelColor[1], b: pixelColor[2], a: pixelColor[3] }
}

export const magicEraser = (
  ctx: CanvasRenderingContext2D,
  x: number,
  y: number,
  eraseColor: { r: number; g: number; b: number; a: number },
  tolerance: number
) => {
  const imageData = ctx.getImageData(0, 0, ctx.canvas.width, ctx.canvas.height)
  const data = imageData.data
  const width = ctx.canvas.width
  const height = ctx.canvas.height
  const pixelStack: [number, number][] = [[x, y]]

  const targetColor = getColorAtPixel(data, width, x, y)

  if (colorsMatch(targetColor, eraseColor, tolerance)) {
    return
  }

  while (pixelStack.length > 0) {
    const newPos = pixelStack.pop()
    if (!newPos) continue

    // eslint-disable-next-line prefer-const
    let [nx, ny] = newPos

    let pixelPos = (ny * width + nx) * 4

    while (ny >= 0 && colorsMatch(getColorAtPixel(data, width, nx, ny), targetColor, tolerance)) {
      pixelPos -= width * 4
      ny--
    }
    pixelPos += width * 4
    ny++

    let reachLeft = false
    let reachRight = false

    while (
      ny < height &&
      colorsMatch(getColorAtPixel(data, width, nx, ny), targetColor, tolerance)
    ) {
      if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
        if (!colorsMatch(getColorAtPixel(data, width, nx, ny), eraseColor, tolerance)) {
          colorPixel(data, pixelPos, eraseColor)
        }
      }

      if (nx > 0) {
        if (colorsMatch(getColorAtPixel(data, width, nx - 1, ny), targetColor, tolerance)) {
          if (!reachLeft) {
            pixelStack.push([nx - 1, ny])
            reachLeft = true
          }
        } else if (reachLeft) {
          reachLeft = false
        }
      }

      if (nx < width - 1) {
        if (colorsMatch(getColorAtPixel(data, width, nx + 1, ny), targetColor, tolerance)) {
          if (!reachRight) {
            pixelStack.push([nx + 1, ny])
            reachRight = true
          }
        } else if (reachRight) {
          reachRight = false
        }
      }

      pixelPos += width * 4
      ny++
    }
  }

  ctx.putImageData(imageData, 0, 0)
}

const colorsMatch = (
  color1: { r: number; g: number; b: number; a: number },
  color2: { r: number; g: number; b: number; a: number },
  tolerance: number
) => {
  return (
    Math.abs(color1.r - color2.r) <= tolerance &&
    Math.abs(color1.g - color2.g) <= tolerance &&
    Math.abs(color1.b - color2.b) <= tolerance &&
    Math.abs(color1.a - color2.a) <= tolerance
  )
}

const getColorAtPixel = (data: Uint8ClampedArray, width: number, x: number, y: number) => {
  const pixelPos = (y * width + x) * 4
  return {
    r: data[pixelPos],
    g: data[pixelPos + 1],
    b: data[pixelPos + 2],
    a: data[pixelPos + 3]
  }
}

const colorPixel = (
  data: Uint8ClampedArray,
  pixelPos: number,
  color: { r: number; g: number; b: number; a: number }
) => {
  data[pixelPos] = color.r
  data[pixelPos + 1] = color.g
  data[pixelPos + 2] = color.b
  data[pixelPos + 3] = color.a
}

而在fabric中是这么调用的

import { fabric } from 'fabric'
import Shape from '../tool/shape'
import { magicEraser } from './flood-fill'

class MagicEraser extends Shape {
  private chromatism: number = 20

  override initialize() {}

  override onMousedown(event: fabric.IEvent) {
    const fillColor = { r: 0, g: 0, b: 0, a: 0 } // 替换为透明

    const pointer = this.canvas.getPointer(event.e)
    const x = Math.floor(pointer.x)
    const y = Math.floor(pointer.y)

    const ctx = this.owlCanvas.getElement().getContext('2d') // 获取底层 HTML Canvas 的上下文
    if (!ctx) {
      throw new Error('Unable to get 2d context from canvas')
    }
    // 执行洪水填充
    magicEraser(ctx, x, y, fillColor, this.chromatism)

    this.canvas.renderAll() // 重新渲染 canvas
  }
}
export default MagicEraser

方案二 opencv.js

因为我们上面的洪水算法的方案并不能实现我们想要的效果,所以我们就得考虑其他方案了,我们可以尝试用opencv 的颜色滤镜效果来实现这个功能

什么是opencv

opencv.js 是 OpenCV(Open Source Computer Vision Library)的 JavaScript 版本。OpenCV 是一个开源的计算机视觉和机器学习库,包含大量的计算机视觉算法。
而 opencv.js 允许开发者在浏览器中直接使用这些算法,无需后端服务器的支持。

1. 特点

  • 跨平台:由于 JavaScript 可以在任何支持现代浏览器的设备上运行,因此 opencv.js 也具有跨平台性。
  • 实时性:由于处理在客户端进行,因此可以实现实时的图像处理和分析。
  • 丰富的功能:继承自 OpenCV,opencv.js 提供了大量的计算机视觉算法,如边缘检测、特征点提取、图像分割等。
  • 易于集成:只需将 opencv.js 文件添加到项目中,并在 HTML 中引用即可。

2. 应用场景

  • 实时图像处理:如实时美颜、滤镜效果等。
  • 目标检测:如人脸识别、物体识别等。
  • 增强现实(AR):结合 WebGL 或其他技术实现增强现实效果。
  • 机器学习应用:使用 OpenCV 的机器学习模块在浏览器中进行简单的分类或回归任务。

3. 使用方式

  • 引入库:将 opencv.js 文件添加到项目的静态资源文件夹中,并在 HTML 文件中通过
  • API 调用:使用 OpenCV 的 JavaScript API 进行图像处理和分析。这些 API 的用法与原生 OpenCV C++ API 非常相似。
  • 性能优化:由于 JavaScript 的性能通常不如 C++,因此在使用 opencv.js 时需要注意性能优化,如减少不必要的计算、使用 WebGL 加速等。

使用示例

以下是一个简单的例子,展示如何使用 OpenCV.js 加载图像并显示在页面上:

let cvReady = false;
let cv = null;

function onOpenCvReady() {
  cvReady = true;
  cv = window.cv;
  console.log('OpenCV.js 加载完成');
  startApp();
}

// 在页面加载时异步加载 OpenCV.js
let script = document.createElement('script');
script.onload = onOpenCvReady;
script.src = 'https://docs.opencv.org/4.5.5/opencv.js';
document.head.appendChild(script);

// 当 OpenCV.js 准备好后运行应用程序
function startApp() {
  const fileInput = document.getElementById('fileInput');
  const canvasOutput = document.getElementById('canvasOutput');
  const ctx = canvasOutput.getContext('2d');

  fileInput.addEventListener('change', function(event) {
    const file = event.target.files[0];
    const reader = new FileReader();

    reader.onload = function(e) {
      const img = new Image();
      img.onload = function() {
        canvasOutput.width = img.width;
        canvasOutput.height = img.height;
        ctx.drawImage(img, 0, 0);
      };
      img.src = e.target.result;
    };
    reader.readAsDataURL(file);
  });
}

如何使用 opencv.js 实现魔术橡皮效果

魔术橡皮本质上的操作就是当鼠标落下的时候,获取到鼠标落点的颜色,然后将图片上近似的颜色给清除掉
所以我们大致可以分为以下几步来实现

获取到鼠标落点位置的信息以及落点位置的颜色

 override onMousedown(event: fabric.IEvent) {
    const canvas = this.Canvas
    const pointer = canvas.getPointer(event.e)
    // 获取点击位置的像素颜色
    const context = canvas.getElement().getContext('2d')
    if (!context) {
      throw new Error('Unable to get 2d context from canvas')
    }
    const pixelColor = context.getImageData(pointer.x, pointer.y, 1, 1).data
 }}

获取获取图像对象的原始元素

// 获取图像对象的原始元素
const imgElement = obj.getElement()
const imgCanvas = fabric.util.createCanvasElement()
imgCanvas.width = imgElement.width
imgCanvas.height = imgElement.height
const imgContext = imgCanvas.getContext('2d')
if (!imgContext) return

将图像绘制到临时Canvas上

imgContext.drawImage(imgElement, 0, 0, imgCanvas.width, imgCanvas.height)

获取图像数据并转换为OpenCV Mat对象

const imgData = imgContext.getImageData(0, 0, imgCanvas.width, imgCanvas.height)
const src = cv.matFromImageData(imgData)
const mask = new cv.Mat()

创建用于定义颜色范围的矩阵

const lower = new cv.Mat(src.rows, src.cols, src.type(), [
  Math.max(0, pixelColor[0] - this.chromatism), // R
  Math.max(0, pixelColor[1] - this.chromatism), // G
  Math.max(0, pixelColor[2] - this.chromatism), // B
  0
])
const upper = new cv.Mat(src.rows, src.cols, src.type(), [
  Math.min(255, pixelColor[0] + this.chromatism), // R
  Math.min(255, pixelColor[1] + this.chromatism), // G
  Math.min(255, pixelColor[2] + this.chromatism), // B
  255
])

创建掩模(mask),找到符合颜色范围的像素,并将掩模应用到图像数据上

// 创建掩模(mask),找到符合颜色范围的像素
cv.inRange(src, lower, upper, mask)

// 将掩模应用到图像数据上
for (let i = 0; i < imgData.data.length; i += 4) {
  if (mask.data[i / 4] === 255) {
    imgData.data[i + 3] = 0 // 设置透明通道为0,即完全透明
  }
}

// 更新临时Canvas的像素数据
imgContext.putImageData(imgData, 0, 0)

创建新的图像对象,并将临时Canvas设置为其元素,替换原始图像对象,并且手动更新对象边界框

// 创建新的图像对象,并将临时Canvas设置为其元素
const newImgObj = new fabric.Image(imgCanvas, {
  left: obj.left,
  top: obj.top,
  scaleX: obj.scaleX,
  scaleY: obj.scaleY,
  angle: obj.angle,
  opacity: obj.opacity,
  flipX: obj.flipX,
  flipY: obj.flipY,
  originX: obj.originX,
  originY: obj.originY,
  skewX: obj.skewX,
  skewY: obj.skewY,
  clipPath: obj.clipPath,
  crossOrigin: obj.crossOrigin,
  hoverCursor: 'none',
  strokeWidth: 0
})
// 替换原始图像对象
canvas.remove(obj)
canvas.add(newImgObj)

newImgObj.setCoords() // 手动更新对象边界框
canvas.renderAll()

释放OpenCV.js相关资源

src.delete()
mask.delete()
lower.delete()
upper.delete()

实现效果

魔术橡皮示例.gif