图片处理在前端中后台管理系统中的实践

1,657 阅读5分钟

image.png

背景

在后台管理系统中,业务需要对部分用户端上传的证件、车辆车牌照片等进行打码处理。

痛点:业务方需要在平台外使用图片处理工具进行打码处理,再到平台进行对应数据项的补充上传等操作,当要处理的数据较多时,流程耗时增加,且依赖人工对应处理前后图片,容易出错。

期望:在管理平台查看图片,如果需要打码处理的,点击即可直接在平台页面进行打码操作,完成后直接保存,无需在多个平台工具间来回切换,无需人工查找维护上传,处理前后图片归属一致。

方案

避免重复造轮子,首选现成的开源工具。大致有两种类型:

  • 集涂抹、裁切、滤镜、文字编辑等功能于一体的多合一工具库,如 tui.image-editor
  • 单一打码功能小工具 image-mosaic

综合考虑功能需求、界面交互风格、库代码量、接入复杂度,选择单一打码功能的小工具,源码不到 200 行,非常轻量,无需查阅复杂文档,有问题也方便排查处理。

原理简介

由于后续整体功能的实现需要对库源码稍作调整,此处先梳理下所用库的实现原理,简单描述就是将待处理的图片绘制到 canvas 上,然后将整个画布分为若干行、列,每个行列交叉形成一个小方块,当选中指定区域的小方块时,读取小方块内所有像素点的 rgba 信息进行均值计算,得到新的 rgba 值用于绘制小方块区域,从而形成马赛克图样。

接着看看库源码中几个比较核心的方法实现:

前置知识:canvas 上绘制的图像,可以通过其 contextgetImageData 方法获取到像素点信息,得到的是一组数组形式的数据,每个像素点的 rgba 属性在数组中连续列出, 所以对像素点进行处理时,遍历像素数组每四个遍历项即得到一个像素点的 rgba 值。

1. 初始化数据

在 class Mosaic 的 constructor 中,通过实例化时传入 canvas 的 context 和马赛克方块( tile )宽高尺寸,对整个画布按 tile 的像素宽高为基本单位划分行、列,每一个行列的交叉点就是一个 tile, 对于每一个 tile,使用对象对其进行描述,属性包括 tile 所在行、列、像素宽高以及对应 tile 在画布上的像素集合。最终得到实例属性 tiles,一个包含了所有的 tile 对象的数组。

class Mosaic {
  constructor(
    context,
    { tileWidth = 10, tileHeight = 10, brushSize = 3 } = {},
  ) {
    const { canvas } = context;
    this.context = context;
    this.brushSize = brushSize;
    this.width = canvas.width;
    this.height = canvas.height;
    this.tileWidth = tileWidth;
    this.tileHeight = tileHeight;

    const { width, height } = this;
    this.imageData = context.getImageData(0, 0, width, height).data;
    this.tileRowSize = Math.ceil(height / this.tileHeight);
    this.tileColumnSize = Math.ceil(width / this.tileWidth);

    this.tiles = []; // All image tiles.
    // Set tiles.
    for (let i = 0; i < this.tileRowSize; i++) {
      for (let j = 0; j < this.tileColumnSize; j++) {
        const tile = {
          row: i,
          column: j,
          pixelWidth: tileWidth,
          pixelHeight: tileHeight,
        };
        if (j === this.column - 1) {
          // Last column
          tile.pixelWidth = width - j * tileWidth;
        }
        if (i === this.row - 1) {
          // Last row
          tile.pixelHeight = height - i * tileHeight;
        }

        // Set tile data;
        const data = [];
        const pixelPosition =
          this.width * 4 * this.tileHeight * tile.row +
          tile.column * this.tileWidth * 4;
        for (let i = 0, j = tile.pixelHeight; i < j; i++) {
          const position = pixelPosition + this.width * 4 * i;
          data.push.apply(
            data,
            this.imageData.slice(position, position + tile.pixelWidth * 4),
          );
        }

        tile.data = data;
        this.tiles.push(tile);
      }
    }
  }
  // ...
}

2. 根据 tile 绘制改变像素

根据 tiles 进行绘制,每一个 tile 的 data 中存储着图片在对应区块的像素点信息,绘制操作时,使用均值处理的方式,把每个 tile 中的像素点的 rgba 值分别加总,然后除以 tile 的像素点数,得到平均的 rgba 值用于填充对应 tile 的像素区域,这样的计算方式简单,新覆盖的颜色值跟原始色块的差距不会很大,修改后画面看起来不会很突兀。

class Mosaic {
  // ...
  drawTile(tiles) {
    tiles = [].concat(tiles);
    tiles.forEach((tile) => {
      if (tile.isFilled) {
        return false; // Already filled.
      }

      if (!tile.color) {
        let dataLen = tile.data.length;
        let r = 0,
          g = 0,
          b = 0,
          a = 0;
        for (let i = 0; i < dataLen; i += 4) {
          r += tile.data[i];
          g += tile.data[i + 1];
          b += tile.data[i + 2];
          a += tile.data[i + 3];
        }
        // Set tile color.
        let pixelLen = dataLen / 4;
        tile.color = {
          r: parseInt(r / pixelLen, 10),
          g: parseInt(g / pixelLen, 10),
          b: parseInt(b / pixelLen, 10),
          a: parseInt(a / pixelLen, 10),
        };
      }

      const color = tile.color;
      this.context.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, ${
        color.a / 255
      })`;

      const x = tile.column * this.tileWidth;
      const y = tile.row * this.tileHeight;
      const w = tile.pixelWidth;
      const h = tile.pixelHeight;

      this.context.clearRect(x, y, w, h); // Clear.
      this.context.fillRect(x, y, w, h); // Draw.
      tile.isFilled = true;
    });
  }
  // ...
}

3. 通过坐标点得到区域 tile

在实际操作中,用户通常通过鼠标点触或连续滑动来进行指定区域的涂抹,那么就需要能够根据鼠标在画布上操作的坐标点得到对应区域的 tile,然后通过前面介绍的 drawTile 方法来绘制。结合初始化实例传入的画笔尺寸 brushSize,即可得到不同起始行、列所围区域的一组 tiles。

class Mosaic {
  // ...
  getTilesByPoint(x, y, isBrushSize = true) {
    const tiles = [];
    if (isBrushSize) {
      /** 纵坐标值除以tileHeight得到截至该坐标的行数
       *再减去brushSize的一半得到起始行数
       *与0比较是边缘情况的处理,防止计算结果小于最小起始行
       **/
      let brushSize = this.brushSize;
      let startRow = Math.max(
        0,
        Math.floor(y / this.tileHeight) - Math.floor(brushSize / 2),
      );
      let startColumn = Math.max(
        0,
        Math.floor(x / this.tileWidth) - Math.floor(brushSize / 2),
      );
      let endRow = Math.min(this.tileRowSize, startRow + brushSize);
      let endColumn = Math.min(this.tileColumnSize, startColumn + brushSize);

      // Get tiles.
      while (startRow < endRow) {
        let column = startColumn;
        while (column < endColumn) {
          tiles.push(this.tiles[startRow * this.tileColumnSize + column]);
          column += 1;
        }
        startRow += 1;
      }
    }
    return tiles;
  }
  // ...
}

4. 擦除还原

从前面的介绍可以知道,每个 tile 的 data 中存有对应区块的原始像素信息,擦除时取出原始信息,使用 context 的 createImageData 方法创建新的像素对象,然后将原始像素信息赋值其中,再使用putImageData 方法绘制回画布中,从而达到橡皮擦还原的效果,将所有被操作过的 tile 传入其中处理,即可还原整个画布上的马赛克。

class Mosaic {
  // ...
  eraseTile(tiles) {
    [].concat(tiles).forEach((tile) => {
      const x = tile.column * this.tileWidth;
      const y = tile.row * this.tileHeight;
      const w = tile.pixelWidth;
      const h = tile.pixelHeight;

      var imgData = this.context.createImageData(w, h);
      tile.data.forEach((val, i) => {
        imgData.data[i] = val;
      });

      this.context.clearRect(x, y, w, h); // Clear.
      this.context.putImageData(imgData, x, y); // Draw.
      tile.isFilled = false;
    });
  }
  // ...
}

扩展问题

从上面的原理介绍中可以看出,所用小工具已经能满足我们所需要的核心打码功能,但是距离完成完整的目标功能还需进一步扩展,比如实际场景中用户编辑一张图片需要对不同地方使用不同尺寸的马赛克方块,比如汽车尾部喷漆式的大号车牌字体,用小马赛克方块涂抹会导致整体看起来还是可辨认的轮廓,而对于证件照片上的小字体,又需要使用比较小的笔触来涂抹,而笔触尺寸是一开始初始化实例时传入的参数,那么要如何在编辑同一张图片时动态切换不同尺寸笔触呢?

尝试以新尺寸生成实例

function changeSize(value) {
  const canvas = this.$refs.canvas;
  const ctx = canvas.getContext('2d');
  const newInstance = new Mosaic(ctx, {
    tileWidth: value,
    tileHeight: value,
  });
  this.mosaic = newInstance;
}

结果是可行的,但存在两个擦除还原问题:

  • 由于擦除功能需要依赖对应的 tile 中存储的像素信息做还原,新 new 的实例的 tiles 中没有原来的像素信息,会导致切换笔触后无法还原切换笔触前产生的马赛克,所以继续修改,重新 new 实例时将新旧实例的 tiles 合并
newInstance.tiles = newInstance.tiles.concat(this.mosaic.tiles);
  • 原始的还原方法实现中,是以实例的 tileWidth、tileHeight 属性进行坐标计算,那么当新实例执行还原计算时,以新实例的 tileWidth 等属性计算坐标,遍历使用的却是旧实例的 tile,会产生错乱,只需修改原始实现的两处取值为 tile 自身存储的属性即可解决:
eraseTile(tiles) {
  [].concat(tiles).forEach((tile) => {
    const x = tile.column * tile.pixelWidth;
    const y = tile.column * tile.pixelHeight;
    // ...
  });
}

仅需修改 2 个源码中的变量,即可解决动态切换笔触的问题。

逻辑填坑结束,继续来完善交互界面,我们可以使用 range bar 等控件来实现交互界面调整笔触,然后基于鼠标事件来控制画笔行为,在鼠标 mousedown 时激活绘制,在 mousemove 时不断传入坐标给实例方法进行绘制处理,在 mouseup 时停止绘制,最终仅需少量代码 + 自己实现简单的交互界面即可低成本实现功能需求,且易于维护,后续如果需要新的处理算法比如亮度、对比度调整等,也只需集成相应的处理类即可,在控制面板通过切换功能模式来激活不同的类进行使用。

示例效果如下: