手把手,带你用canvas做一个图像标注工具(二)

4,027 阅读3分钟

前言

本文将开始详细介绍图像标注工具的开发过程。主要包括布局、事件类型、图形类设计等内容。

布局

整个工具的布局是比较简洁的。分为上中下三个部分:

  • 顶部工具栏:主要包括放大、缩小、旋转、平移、全屏、清除、配置等各种操作。
  • 中间绘制区:为主要的图片展示区域和数据图形绘制区域。
  • 底部自定义:本质是一个插槽,可以方便用户添加自己的逻辑。如:加载图片、标注数据处理等。 显然,中间的绘制区是最为重要的部分,它内部又划分为两个部分:
  • 左侧图形选择区:可以选择不同的图形,从而再绘制区进行绘制。
  • 图形展示和绘制区:用来展示图形,并在图像上进行图形绘制。 最终形成的布局如下图所示:

这里有一点需要特别注意: 如果我们把图片显示在canvas上,然后直接在这个canvas上绘制图形,那么当需要撤销编辑操作时,会对原图像展示影响。因此,在工具设计时,设置了两个canvas,一个用来呈现图像,另一个用来绘制图形,两者通过与父元素的绝对定位来实现重合布局。

图形类

图标标注工具的核心在于不同图形的绘制。我采用的是设计一个父类---图形类,然后其他具体的图形(如矩形、多边形等)都是继承于该类。图形类的设计如下:

const config = {
  PATH_LINEWIDTH:1,
  PATH_STROKESTYLE:"#f00",
  POINT_LINEWIDTH:2,
  POINT_STROKESTYLE:"#999",
  POINT_RADIS:5
}

class Graph {
  // 构造函数:主要包含图形的基本信息 
  constructor(point,options={}){
    this.x = Math.round(point.x)
    this.y = Math.round(point.y)
    this.points = []
    this.points.push(point)
    this.options = options
    this.path_lineWidth = options.path_lineWidth || config.PATH_LINEWIDTH
    this.path_strokeStyle = options.path_strokeStyle || config.PATH_STROKESTYLE
    this.point_radis = options.point_radis|| config.POINT_RADIS
    this.point_lineWidth = options.point_lineWidth || config.POINT_LINEWIDTH
    this.point_strokeStyle = options.point_strokeStyle || config.POINT_STROKESTYLE
  }
  // 计算图形的中心点:在进行图形平移时会使用到
  computedCenter() {
    let x_sum = 0,y_sum = 0;
    this.points.forEach(p => {
      x_sum += p.x;
      y_sum += p.y;
    });
    this.x = Math.round(x_sum / this.points.length);
    this.y = Math.round(y_sum / this.points.length);
  }
  // 移动方法
  move(startPoint,endPoint) {
    let x1 = endPoint.x - startPoint.x;
    let y1 = endPoint.y - startPoint.y;
    this.points = this.points.map(item => {
      return {
        x: item.x + x1,
        y: item.y + y1,
      }
    })
    this.computedCenter()
  }
  // 点更新方法
  update(i,point){
    this.points[i] = point
    this.computedCenter()
  }
  // 根据图形的基本要素创建图形
  createPath(ctx) {
    ctx.beginPath();
    ctx.lineWidth = this.path_lineWidth;
    ctx.strokeStyle = this.path_strokeStyle;
    this.points.forEach((p, i) => {
      ctx[i == 0 ? "moveTo" : "lineTo"](p.x, p.y);
    });
    ctx.closePath();
  }
  // 是否在路径中:用于更新和平移图形
  isInPath(ctx, point) {
    // in the point
    for (let i = 0; i < this.points.length; i++) {
      ctx.beginPath();
      ctx.arc(this.points[i].x, this.points[i].y, this.point_radis, 0, Math.PI * 2, false);
      if (ctx.isPointInPath(point.x, point.y)) {
        return i;
      }
    }
    // in the figure
    this.createPath(ctx);
    if (ctx.isPointInPath(point.x, point.y)) {
      return 999;
    }
    return -1;
  }
  // 绘制控制点,当图形被选中时显示
  drawPoints(ctx) {
    ctx.save();
    ctx.lineWidth = this.point_lineWidth;
    ctx.strokeStyle = this.point_strokeStyle;
    ctx.fillStyle = this.point_strokeStyle;
    this.points.forEach(p => {
      ctx.beginPath();
      ctx.moveTo(p.x - this.point_radis, p.y - this.point_radis);
      ctx.lineTo(p.x - this.point_radis, p.y + this.point_radis);
      ctx.lineTo(p.x + this.point_radis, p.y + this.point_radis);
      ctx.lineTo(p.x + this.point_radis, p.y - this.point_radis);
      ctx.closePath();
      ctx.fill();
    });
    ctx.restore();
  }
  // 绘制图形
  draw(ctx) {
    ctx.save();
    this.createPath(ctx);
    ctx.stroke();
    ctx.restore();
  }
}

其他的图形,如果有不同于父类的方法可以在自身进行重写,具体重写方法可以看源代码。

事件与状态

图像标注工具主要存在三种事件:

  • mousedown
  • mousemove
  • mouseup 图像标注工具主要存在四种状态:
  • 绘制(DRAWING)
  • 更新(UPDATING)
  • 平移(MOVING)
  • 默认(DEFAULT)

根据以上的三种事件和四种状态,在不同的事件和状态下执行不同的操作。这里对应源代码中的 canvasMousedown、canvasMousemove和canvasMouseup,这是图像标注工具的核心代码。

坐标系转换

占坑