fabric.js-在线抠图的图片编辑工具

5,392 阅读7分钟

前言

Fabric.js 作为一个功能强大、操作简单的 HTML5 画布库。尝试学习如何借助它更好地进行 canvas 开发;

image.png

以实现创客贴易企秀稿定设计这些设计平台的智能抠图功能为例,开发一个在线抠图的图片编辑工具。

在线示例 demo:fabric.js-在线抠图工具

项目地址:fabric-remove-bg | Github

开始搭建

页面结构

image.png

确定根页面 index.vue 的页面布局,包括侧边栏(Sidebar),头部(Header),以及主内容区;

其中分别左右两个面板(ImagePanel,FabricPanel)就是我们的页面主内容;

<div class="flex h-full">
  <Sidebar />
  <div class="flex flex-col flex-1">
    <Header />
    <div class="wrapper">
      <div class="panel-container">
        <BasePanel ref="formImageBasePanel">
          <!-- 左侧图片 -->
          <ImagePanel></ImagePanel>
        </BasePanel>
      </div>
      <div class="panel-container left-border">
        <BasePanel ref="fabricBasePanel">
          <!-- 右侧画布 -->
          <FabricPanel></FabricPanel>
        </BasePanel>
      </div>
    </div>
  </div>
</div>

基础面板

image.png

BasePanel 是一个基础面板,提供了对插入内容进行设置大小、拖拽、缩放的能力;我们将其封装为 BasePanel.vue 组件:

<!-- BasePanel.vue -->
<div ref="basePanelRef" class="base-panel">
  <slot></slot>
</div>

接下来为该组件实现基础能力:

拖拽位置

编写一个 useDraggable 组合函数,内部使用 @use-gesture 实现对传入 DOM 拖拽修改 CSS 位置;

<!-- BasePanel.vue -->
<script setup>
  import { useDraggable } from "../../composables/useDraggable.js";
  // 拖动位置
  const { x, y, draggable, setDraggable, setPosition } = useDraggable(
    basePanelRef,
    {
      enabled: false,
      onDrag: (position) => emit("onDrag", position),
    }
  );
</script>

<template>
  <div
    ref="basePanelRef"
    :style="{
      left: `${x.toFixed()}px`,
      top: `${y.toFixed()}px`,
    }"
    :class="{ draggable }"
    class="base-panel"
  >
    <slot></slot>
  </div>
</template>

设置大小

BasePanel 作为容器,支持外部设置容器大小,这也是后续缩放的基础能力

<!-- BasePanel.vue -->
<script setup>
  // 操作面板大小
  const width = ref(0);
  const height = ref(0);
  const getWidthHeight = () => ({ width: width.value, height: height.value });
  const setWidthHeight = (options) => {
    width.value = options.width;
    height.value = options.height;
  };
</script>

<template>
  <div
    ref="basePanelRef"
    :style="{
      width: `${width}px`,
      height: `${height}px`,
      left: `${x.toFixed()}px`,
      top: `${y.toFixed()}px`,
    }"
    :class="{ draggable }"
    class="base-panel"
  >
    <slot></slot>
  </div>
</template>

面板缩放

编写一个 useWheel 组合函数,内部使用 @use-gesture 实现对 DOM 监听滚轮操作

<!-- BasePanel.vue -->
<script setup>
  // 滚轮缩放
  const scaleStep = 0.2; // 缩放比增减步长
  const onWheel = (dy, isTrusted = true) => {
    const diff = dy > 0 ? -scaleStep : scaleStep;
    const oldWidthHeight = { width: width.value, height: height.value };
    // 放大/缩小宽高
    setWidthHeight({
      width: width.value * (1 + diff),
      height: height.value * (1 + diff),
    });
    // 保证基于中心缩放
    const leftOffset = (width.value - oldWidthHeight.width) >> 1;
    const topOffset = (height.value - oldWidthHeight.height) >> 1;
    setPosition({ x: x.value - leftOffset, y: y.value - topOffset });
    isTrusted && emit("onWheel", dy);
  };
  const triggerWheel = (dy) => onWheel(dy, false); // 支持手动触发缩放
  useWheel(basePanelRef, { onWheel });
</script>

BasePanel 外部方法

最终,BasePanel 通过 defineExpose 暴露给外部对面板容器的操作方法:

defineExpose({
  setDraggable, // 开启/禁用拖拽
  setXY: setPosition, // 设置位置坐标
  getWidthHeight, // 获取宽高
  setWidthHeight, // 设置宽高
  triggerWheel, // 手动触发缩放
});

画布面板

image.png

主内容区由左侧原图片面板和右侧fabric画布面板组成,左侧图片面板独立为 ImagePanel,将原图片回显即可:

<!-- ImagePanel.vue -->
<script setup>
const props = defineProps({
  fromImageURL: { type: String, required: true }, // 原图片地址
});
</script>

<template>
  <img class="w-hull h-full" :src="fromImageURL" draggable="false" />
</template>

右侧画布面板独立为 FabricPanel,初始化一个fabric画布对象,并对外提供fabric画布基础操作方法

初始化画布

通过 initFabric 方法,当 FabricPanel 组件渲染时,初始化执行以下操作:

  1. 创建fabric实例
  2. 加载底图到画布
  3. 加载抠图到画布

创建fabric实例

编写一个 useFabric,它以传入的canvas元素创建强大的fabric画布对象并返回;

<script setup>
  const fabricCanvasRef = ref(null);
  let fabricInstance; // 画布对象
  const initFabric = async () => {
    // 原图片  移除背景后图片 额外fabric画布参数
    const { fromImage, removeBgImage, fabricOptions } = props;
    // 创建fabric实例
    const defaultOptions = {
      selection: false, // 画布不可框选
      hoverCursor: "default", // 画布hover时鼠标cursor
      freeDrawingCursor: "default", // 画布画笔绘画时鼠标cursor
      isDrawingMode: false, // 初始是否为可绘画模式
    };
    fabricInstance = useFabric(fabricCanvasRef.value, { ...defaultOptions, ...fabricOptions });
    // ......
  }
</script>

<template>
  <canvas ref="fabricCanvasRef"></canvas>
</template>

加载底图到画布

后续我们是可以通过【修补】画笔在画布上绘画,将图片上不应该被移除的部分绘制出来,其实是因为我们画布上默认放入了【被整个擦除】的底图内容,当使用【修补】画笔在画布涂抹时,只不过将被擦除的底图进行【反向擦除】,是底部部分内容显现出来,即实现【修补】效果

<script setup>
  const initFabric = async () => {
    // ......
    fabricInstance = useFabric(fabricCanvasRef.value, { ...... });
    // 加载底图
    fabric.Image.fromURL(
      fromImage,
      function (img) {
        img.scaleToWidth(fabricInstance.width);
        img.scaleToHeight(fabricInstance.height);
        img.set("selectable", false);
        // 底图默认被擦除
        let path = new fabric.Path(
          `M 0 0 L ${img.width} 0 L ${img.width} ${img.height} L 0 ${img.height} z`,
        );
        path.set("globalCompositeOperation", "destination-out");
        fabric.EraserBrush.prototype._addPathToObjectEraser(img, path);
        fabricInstance.add(img);
      },
      { crossOrigin: "Anonymous" },
    );
    // ......
  }
</script>

加载抠图到画布

即将基于原图片的被移除背景图片绘制到画布顶层

<script setup>
  const initFabric = async () => {
    // ......
    // 加载抠图
    fabric.Image.fromURL(
      removeBgImage,
      function (img) {
        img.scaleToWidth(fabricInstance.width);
        img.scaleToHeight(fabricInstance.height);
        img.set("selectable", false);
        fabricInstance.add(img);
        emit("initialized", fabricInstance); // 初始化完成
      },
      { crossOrigin: "Anonymous" },
    );
  }
</script>

画笔绘制

有了fabric画布后,支持对已去除背景图片进行过编辑。

【擦除】画笔下,通过在画布上涂抹实现对画布内容擦除

image.png 【修补】画笔下,通过在画布上涂抹实现对画布内容反向擦除,即将去除部分补回来;

image.png

<script setup>
  // ......
  // 是否可绘画模式
  const getIsDrawingMode = () => fabricInstance.isDrawingMode;
  // 开启/关闭画布可绘画
  const setIsDrawingMode = (isDrawingMode) => {
    fabricInstance.set("isDrawingMode", isDrawingMode);
  };
  // 设置擦除/修补画笔
  const setDrawingBrush = ({ inverted, width }) => {
    const eraserBrush = new fabric.EraserBrush(fabricInstance);
    eraserBrush.inverted = inverted; // 是否反向擦除
    eraserBrush.width = width;
    fabricInstance.freeDrawingBrush = eraserBrush;
  };
  // 设置画笔粗细
  const setDrawingBrushWidth = (width) => {
    fabricInstance.freeDrawingBrush.width = width;
  };
</script>

设置背景

image.png

fabric提供了对画布设置背景图/背景色的方法,基于它们编写操作画布方法:

背景色

const setBackgroundColor = (color) => {
  fabricInstance.setBackgroundImage(null);
  // 设置背景色
  fabricInstance.setBackgroundColor(color, fabricInstance.renderAll.bind(fabricInstance));
};

背景图

// 设置画布背景图
const setBackgroundImage = (url) => {
  fabric.Image.fromURL(
    url,
    function (img) {
      // 背景图类似 object-fit: cover 的规则填满画布
      let scaleX = fabricInstance.width / img.width;
      let scaleY = fabricInstance.height / img.height;
      const scale = Math.ceil(Math.max(scaleX, scaleY) * 100) / 100;
      img.set({
        scaleX: scale,
        scaleY: scale,
        left: fabricInstance.width >> 1,
        top: fabricInstance.height >> 1,
        originX: "center",
        originY: "center",
      });
      img.set("erasable", false); // 背景图不可擦除
      fabricInstance.setBackgroundColor("");
      // 设置背景图
      fabricInstance.setBackgroundImage(img);
      fabricInstance.renderAll();
    },
    { crossOrigin: "Anonymous" },
  );
};

历史记录

为编辑工具提供操作回退、重做和重置能力,这里基于我自己开发的 fabricjs-history 专为fabric画布对象实现历史记录功能;

为画布对象创建历史记录

在初始化 initFabric 方法的【加载抠图】步骤的初始化完成回调中,为画布创建历史记录:

// initFabric
  // 加载抠图
  fabric.Image.fromURL(
    removeBgImage,
    function (img) {
      // .....
      fabricInstance.add(img);
      // 基于当前画布状态作为历史记录起点,创建历史记录
      createHistory({ canvas: fabricInstance, historyEvent: ["erasing:end"] });
      emit("initialized", fabricInstance);
    },
    { crossOrigin: "Anonymous" },
  );

historyEvent: ["erasing:end"] 即每次擦除/反向擦除涂抹完成,记录快照到历史记录;

除此之外,我们手动在设置背景色/背景图时,手动调用方法 fabricInstance.record 记录到历史记录,实现设置背景动作时可回退的;

我们只需在index.vue中使用fabricjs-history提供的方法 fabricInstance.undofabricInstance.redofabricInstance.reset 对画布重做/回退/重置

导出图片

fabric画布对象上有 toDataURL 方法,将其转为base64格式数据的图片,基于此实现导出画布为图片方法:

// 导出画布为图片
import { saveAs } from "file-saver";
const saveAsImage = () => {
  // 由于画布此时可能是缩小/放大状态,我们先将其置为原图片大小,导出后再回到缩放大小
  const currentZoom = fabricInstance.getZoom();
  const currentWidth = fabricInstance.width;
  const currentHeight = fabricInstance.height;
  fabricInstance.setDimensions({
    width: props.fromImageSize.width,
    height: props.fromImageSize.height,
  });
  fabricInstance.setZoom(1);
  const dataURL = fabricInstance.toDataURL();
  saveAs(dataURL, "image.png");
  fabricInstance.setDimensions({
    width: currentWidth,
    height: currentHeight,
  });
  fabricInstance.setZoom(currentZoom);
};

暴露画布外部方法

至此,我们的 FabricPanel 已经能暴露一下基础操作方法给 index.vue 根页面

defineExpose({
  getIsDrawingMode, // 是否为绘画模式
  setIsDrawingMode, // 开启/关闭绘画模式
  setDrawingBrush,  // 设置绘制画笔[擦除/反向擦除]
  setDrawingBrushWidth, // 设置画笔粗细
  setWidthHeight, // 设置fabric画布大小
  setBackgroundColor, // 设置画布颜色
  setBackgroundImage, // 设置画布背景图
  saveAsImage,  // 导出为图片
});

接下来,我们在 Sidebar 侧边栏 和 Header 头部中,通过事件传递调用到以上基础方法,就能实现对画布(FabricPanel)执行各种操作;具体调用细节见 index.vue

抠图接口

FabricPanel 已具备基础操作画布能力,接下来就是进入根页面调用【去除图片背景】接口,得到去除原图片背景的图片后,传递给 FabricPanel 显示即可;

编写 removeBgService,包含去除图片背景HTTP请求方法:

import { request } from "@/utils/index.js";
const VITE_APP_REMOVE_BG_KEY = import.meta.env.VITE_APP_REMOVE_BG_KEY;
const VITE_APP_REMOVE_BG_KEY2 = import.meta.env.VITE_APP_REMOVE_BG_KEY2;

export function fetchRemoveBg(data) {
  return request.post("https://api.remove.bg/v1.0/removebg", data, {
    responseType: "blob",
    headers: {
      "Content-Type": "multipart/form-data",
      "x-api-key": VITE_APP_REMOVE_BG_KEY,
    },
  });
}

export function fetchRemoveBg2(data) {
  return request.post("https://clipdrop-api.co/remove-background/v1", data, {
    responseType: "blob",
    headers: {
      "Content-Type": "multipart/form-data",
      "x-api-key": VITE_APP_REMOVE_BG_KEY2,
    },
  });
}

fetchRemoveBg 方法使用的是remove.bg去除背景;fetchRemoveBg2 方法使用的是clipdrop API去除背景;他们都将在 useRemoveBg 中被调用;

API Key各自在 .env 环境变量中配置,更多 抠图接口 迁移细节见 fabric-remove-bg | Remove.bg API

更新:由于自己的remove.bg API接口次数耗尽,最新代码改用效果稍差的Koutu API;你也可以使用自己的remove.bg API key调用抠图;

图片裁剪

图片裁剪的实现,之前我是通过 vue-drag-resize 实现一个裁剪框到裁剪面板,支持用户拖动裁剪框大小位置自定义图片裁剪区域;

另外,fabric导出图片 toDataURL 方法支持裁剪部分导出:

const dataURL = ctx.toDataURL({
  width: width,
  height: height,
  left: left, // 裁剪起始x坐标
  top: top, // 裁剪起始y坐标
});