Fabric.js 实现数据标注

6,315 阅读3分钟

阅读本文需要你有一定的 Fabric.js 基础,如果还不太了解 Fabric.js 是什么,可以阅读 《Fabric.js 从入门到膨胀》

创建基础项目

为了方便演示,我在初始化画布的时:

  1. 添加一个背景图,该背景图的尺寸和初始化的画布一样大。
  2. 默认渲染一个矩形。
  3. 右侧可以放大缩小画布以及删除选中矩形。
  4. 点击左侧可以修改标签内容。

标注案列


相关API

  1. 删除元素的2种方法:
  • canvas.remove(...object)
  • new fabric.Control 绑定控制器删除
  1. 更新标签内容:
  • 获取选中的矩形框点击标签更新。
  • 选中标签后画布上绘制。
  1. 搜索框支持本地模糊匹配标签。

  2. isOff字段在代码中起到判断是否绘制的作用,fabric.Object.prototype.selectable = false 在绘制时关闭选中功能前提是未选中状态。

封装myFabric类

/* eslint-disable import/no-extraneous-dependencies */
import { fabric } from 'fabric';

// 删除img
const deleteIcon =
  "data:image/svg+xml,%3C%3Fxml version='1.0' encoding='utf-8'%3F%3E%3C!DOCTYPE svg PUBLIC '-//W3C//DTD SVG 1.1//EN' 'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3E%3Csvg version='1.1' id='Ebene_1' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' x='0px' y='0px' width='595.275px' height='595.275px' viewBox='200 215 230 470' xml:space='preserve'%3E%3Ccircle style='fill:%23F44336;' cx='299.76' cy='439.067' r='218.516'/%3E%3Cg%3E%3Crect x='267.162' y='307.978' transform='matrix(0.7071 -0.7071 0.7071 0.7071 -222.6202 340.6915)' style='fill:white;' width='65.545' height='262.18'/%3E%3Crect x='266.988' y='308.153' transform='matrix(0.7071 0.7071 -0.7071 0.7071 398.3889 -83.3116)' style='fill:white;' width='65.544' height='262.179'/%3E%3C/g%3E%3C/svg%3E";
const deleteIconImg = document.createElement('img');
deleteIconImg.src = deleteIcon;

class myFabric {
  /**
   * 构造函数
   * @param {object} params 构造函数参数
   */
  constructor(params) {
    this.canvas = null; // 画布对象
    this.isOff = true; // 是否开启画画模式
    this.downPoint = null; // 按下鼠标时的坐标
    this.upPoint = null; // 松开鼠标时的坐标
    this.label = '';
    this.change = null;
    this.initCanvas(params);
  }
  /**
   * 初始化画布
   * @param {object}   params  { imgUrl, data } 图片路径,标注数据
   */
  initCanvas(params) {
    const { imgUrl, data, change } = params;
    this.change = change;
    const img = new Image();
    img.src = imgUrl;
    img.onload = () => {
      this.canvas = new fabric.Canvas('canvas');

      fabric.Object.prototype.transparentCorners = false;
      fabric.Object.prototype.cornerColor = 'white';
      fabric.Object.prototype.cornerStyle = 'circle';
      // console.log(this.canvas, fabric);

      // 创建删除按钮
      fabric.Object.prototype.controls.deleteControl = new fabric.Control({
        x: 0.5,
        y: -0.5,
        offsetY: -16,
        offsetX: 16,
        cursorStyle: 'pointer',
        mouseUpHandler: () => {
          this.delete();
        },
        render: (ctx, left, top, styleOverride, fabricObject) => {
          const size = 24;
          ctx.save();
          ctx.translate(left, top);
          ctx.rotate(fabric.util.degreesToRadians(fabricObject.angle));
          ctx.drawImage(deleteIconImg, -size / 2, -size / 2, size, size);
          ctx.restore();
        },
        cornerSize: 24,
      });
      fabric.Image.fromURL(imgUrl, (imgs) => {
        this.canvas.setBackgroundImage(imgs, this.canvas.renderAll.bind(this.canvas));
      });
      this.canvas.setWidth(img.width);
      this.canvas.setHeight(img.height);
      // 选中
      this.canvas.on('selection:created', (e) => {
        this.isOff = false;
        // console.log('选中');
      });

      // 取消选中
      this.canvas.on('selection:cleared', () => {
        this.isOff = true;
        // console.log('取消选中');
      });
      this.canvas.on('selection:updated', () => {
        // console.log('选中updated');
      });

      // mouse:move
      // 鼠标在画布上按下
      this.canvas.on('mouse:down', (e) => {
        if (this.isOff) {
          fabric.Object.prototype.selectable = false;
        }
        // 鼠标左键按下时,将当前坐标 赋值给 downPoint。{x: xxx, y: xxx} 的格式
        this.downPoint = e.absolutePointer;
        // console.log('鼠标左键按下时');
      });
      // 松开鼠标左键时
      this.canvas.on('mouse:up', (e) => {
        if (this.isOff) {
          fabric.Object.prototype.selectable = true;
        }
        // console.log('松开鼠标左键时');
        // 同步外部数据
        // change(this.getAll());

        // 绘制矩形的模式下,才执行下面的代码
        // 松开鼠标左键时,将当前坐标 赋值给 upPoint
        this.upPoint = e.absolutePointer;
        // 调用 创建矩形 的方法
        this.createRect();
        change(this.getAll());
      }); // 鼠标在画布上松开

      data.forEach((item) => {
        this.canvas.add(this.creatGroup(item));
        this.canvas.renderAll();
      });
      // 将矩形添加到画布上
    };
  }

  /**
   * 创建矩形和text组
   * @param {object}   item   {x,y,w,h,r,label}
   */
  creatGroup(item) {
    const { top, left, width, height, angle } = item;
    const rect = new fabric.Rect({
      top,
      left,
      width,
      height,
      angle,
      padding: 0,
      strokeUniform: true,
      noScaleCache: false,
      stroke: this.label ? 'lightgreen' : 'red',
      strokeWidth: 1,
      fill: 'rgba(0,0,255,0.2)', // 填充色:透明
    });
    // 矩形对象
    const text = new fabric.Textbox(this.label, {
      top: top + height / 2.3,
      left,
      width,
      height,
      fontFamily: 'Helvetica',
      fill: 'white', // 设置字体颜色
      fontSize: 14,
      textAlign: 'center',
    });
    const group = new fabric.Group([rect, text]);
    return group;
  }
  // 创建矩形
  createRect() {
    if (!this.isOff) return;
    // 如果点击和松开鼠标,都是在同一个坐标点,不会生成矩形
    if (JSON.stringify(this.downPoint) === JSON.stringify(this.upPoint)) {
      return;
    }
    // 创建矩形
    // 矩形参数计算
    const top = Math.min(this.downPoint.y, this.upPoint.y);
    const left = Math.min(this.downPoint.x, this.upPoint.x);
    const width = Math.abs(this.downPoint.x - this.upPoint.x);
    const height = Math.abs(this.downPoint.y - this.upPoint.y);
    if (width < 2 || height < 2) return;
    // 将矩形添加到画布上
    this.canvas.add(
      this.creatGroup({
        top,
        left,
        width,
        height,
        angle: 0,
        label: this.label,
      })
    );
    this.canvas.renderAll();
    // 创建完矩形,清空 downPoint 和 upPoint
    this.downPoint = null;
    this.upPoint = null;
  }
  /**
   * 画布缩放
   * @param {Boolean}   type   true放大 false缩小
   */

  setZoom(type) {
    let scale = this.canvas.getZoom();
    if (type) {
      scale += 0.1;
    } else {
      scale -= 0.1;
    }
    this.canvas.setZoom(scale);
  }

  /**
   * 画布上的元素数据
   */
  getAll() {
    const allTarget = this.canvas.getObjects().map((item) => ({
      label: item._objects[1].text,
      width: item.width,
      height: item.height,
      left: item.left,
      top: item.top,
      angle: item.angle || 0,
    }));
    return allTarget;
  }

  // 更新标注框文字
  updateLabel(label) {
    var active = this.canvas.getActiveObject();
    if (active) {
      active.item(0).set({
        stroke: label ? 'lightgreen' : 'red',
      });
      active.item(1).set({
        text: label,
      });
      this.canvas.renderAll();
    }
    // active && active.set(active._objects[1], 'text', '444');
    // active._objects.
    this.label = label;
  }

  // 删除标注框
  delete() {
    var active = this.canvas.getActiveObject();
    if (active) {
      this.canvas.remove(active);
      if (active.type == 'activeSelection') {
        active.getObjects().forEach((x) => this.canvas.remove(x));
        this.canvas.discardActiveObject().renderAll();
      }
    }
  }

  // 销毁
  dispose() {
    this.canvas.dispose();
  }
}
export default myFabric;

vue组件使用示列

<template>
  <div class="markbox">
    <!-- 操作区域 -->
    <div class="markbox-left">
      <div class="markbox-left-small">
        <a-button shape="circle" @click="cF.setZoom(true)">
          <Icon icon="ant-design:zoom-in-outlined" />
        </a-button>
        <a-button shape="circle" @click="cF.setZoom(false)">
          <Icon icon="ant-design:zoom-out-outlined" />
        </a-button>

        <a-button shape="circle" @click="cF.delete()">
          <Icon icon="ant-design:delete-outlined" />
        </a-button>
      </div>
    </div>
    <!-- 画布区域 -->
    <div class="markbox-center">
      <a-tabs v-model:activeKey="activeKey" @change="tabChange">
        <a-tab-pane key="1" tab="全部" />
        <a-tab-pane key="2" tab="有标注" force-render />
        <a-tab-pane key="3" tab="无标注" />
      </a-tabs>
      <a-button type="primary" @click="getCfData">保存当前标注(s)</a-button>

      <!-- 画布 -->
      <canvas id="canvas"></canvas>

      <!-- 切换区域 -->
      <div class="markbox-img">
        <img :src="item.url" v-for="(item, i) in imgList" :key="i" @click="beforeChange(item)" />
      </div>
    </div>
    <!-- 标签区域 -->
    <div class="markbox-right">
      <a-input v-model:value="searchValue" @change="onSearch" placeholder="请输入标签" style="width: 200px">
        <template #suffix>
          <Icon icon="ant-design:search-outlined" :style="{ color: 'rgba(0,0,0,.25)' }" />
        </template>
      </a-input>
      <a-divider class="markbox-right-border" />
      <div class="markbox-right-tag">
        <a-button :type="actionTag == item ? 'primary' : 'dashed'" v-for="item in targetList" @click="tagChange(item)" :key="item">
          <Icon icon="ant-design:tag-outlined" />
          {{ item }}</a-button
        >
      </div>
    </div>
  </div>
</template>
<script lang="ts">
  import { defineComponent, nextTick, onMounted, reactive, toRefs } from 'vue';
  import { useMessage } from '/@/hooks/web/useMessage';
  import myFabric from '/@/utils/draw.js';
  const { createMessage } = useMessage();
  export default defineComponent({
    setup() {
      const state = reactive({
        // tab
        activeKey: '1',
        // 搜索标签
        searchValue: '',
        // 选中标签
        actionTag: '',
        imgList: [
          {
            id: '333',
            url: `https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fup.enterdesk.com%2Fphoto%2F2009-7-30%2Fenterdesk.com-13DD143D384A30C48832CDACBC54153B.jpg&refer=http%3A%2F%2Fup.enterdesk.com&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1666170017&t=b9f0ab6a04b1b2c33b55fbca22aa6def`,
            markList: [
              {
                top: 100,
                left: 300,
                width: 510,
                height: 100,
                angle: 0,
                label: '1',
              },
              {
                top: 200,
                left: 100,
                width: 50,
                height: 100,
                angle: 0,
                label: '2',
              },
            ],
          },
          {
            id: 1,
            url: `https://gimg2.baidu.com/image_search/src=http%3A%2F%2Fn.sinaimg.cn%2Fspider20220812%2F27%2Fw930h697%2F20220812%2F3525-535aaec9331cf54852b2c350eeb03394.jpg&refer=http%3A%2F%2Fn.sinaimg.cn&app=2002&size=f9999,10000&q=a80&n=0&g=0n&fmt=auto?sec=1665803609&t=4a6b257e41e2d2ae49485665b648d8aa`,
            markList: [
              {
                top: 300,
                left: 100,
                width: 510,
                height: 100,
                angle: 0,
                label: '1',
              },
              {
                top: 200,
                left: 100,
                width: 50,
                height: 100,
                angle: 0,
                label: '2',
              },
            ],
          },
        ],
        targetList: ['car', 'person'],
        originalTargetList: ['car', 'person'],
        cF: null,
      });
      const getImgUrl = (i: number) => {
        return state.imgList[i];
      };

      const beforeChange = (item) => {
        // 如果有标签为空不能切换
        if (state.cF.getAll().some((item) => !item.label)) {
          createMessage.warn('所有标注必须设有标签');
          return;
        }
        initMyFabric(item);
      };

      const initMyFabric = ({ url, markList }) => {
        state.cF && state.cF.dispose();
        state.actionTag = '';
        nextTick(() => {
          // 初始化画布
          state.cF = new myFabric({
            imgUrl: url,
            data: markList,
            // 监听画布操作
            change: (e) => moveChange(e),
          });
        });
      };

      const moveChange = (e) => {
        // console.log(e);
      };
      // 获取画布所有元素
      const getCfData = () => {
        console.log(state.cF.getAll());
      };

      const onSearch = () => {
        // value:要查询的字符串
        if (state.searchValue) {
          let arr = [];
          state.targetList.forEach((el) => {
            if (el.indexOf(state.searchValue) >= 0) {
              arr.push(el);
            }
          });
          state.targetList = arr;
        } else {
          state.targetList = state.originalTargetList;
        }
      };

      const tagChange = (val) => {
        state.actionTag = val;
        state.cF.updateLabel(val);
      };

      const tabChange = (val) => {
        // 初始化
        initMyFabric(state.imgList[0]);
      };

      onMounted(() => {
        initMyFabric(state.imgList[0]);
      });

      return {
        ...toRefs(state),
        tabChange,
        onSearch,
        moveChange,
        tagChange,
        getCfData,
        initMyFabric,
        getImgUrl,
        beforeChange,
      };
    },
  });
</script>
<style lang="scss" scoped>
  /* For demo */
  .markbox {
    position: relative;
    display: flex;
    justify-content: space-between;
    background-color: #fff;
  }

  #canvas {
  }
  .markbox-left {
    width: 50px;
    display: flex;
    align-items: center;
    .markbox-left-small {
      width: 100%;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      background-color: #fff;
      border: 1px solid #ddd;
      border-left: none;
      border-radius: 0 3px 3px 0;
      box-shadow: 0 0 4px 0 rgb(0 0 0 / 10%);
      color: #979797;
      font-size: 18px;
      padding: 15px;
      button {
        width: 40px;
        height: 40px;
        margin: 15px;
      }
    }
  }
  .markbox-right {
    position: relative;
    width: 250px;
    background-color: #fff;
    border: 1px solid #ddd;
    border-left: none;
    border-radius: 0 3px 3px 0;
    box-shadow: 0 0 4px 0 rgb(0 0 0 / 10%);
    color: #979797;
    font-size: 18px;
    padding: 15px;
    z-index: 100;
    .markbox-right-border {
      position: absolute;
      left: 0;
      width: 100%;
    }
    .markbox-right-tag {
      margin-top: 45px;
      button {
        width: 100%;
        margin-bottom: 10px;
      }
    }
  }
  .markbox-img {
    display: flex;
    width: 100%;
    padding: 20px 0;
    img {
      width: 100px;
      margin-right: 10px;
    }
  }
</style>