图片编辑与合成开发总结

avatar
FE @字节跳动

介绍

创意魔方是 ImageX 提供的一个图片编辑与合成组件,在需要生成大量类似海报、图片的情况下,只需要创建好通用的样式,就可以通过使用不同的参数调用 OpenAPI 生成需要的大量图片。

创意魔方采用「云+端结合」的方式生产图片。用户在前端 Web 编辑器进行样式的设计,在完成样式设计之后,后端通过 OpenAPI 的调用对应样式、替换相应要素内容来生产图片,生产所得的图片存放在 ImageX 的存储中,可以直接通过服务绑定的域名访问。此外,通过接口调用批量生产图片可以轻易达到较高的 QPS,这也是在前端界面操作无论如何也无法达到的。

创意魔方与众不同的图片生产模式,就会给开发带来一个困难,即「前后端效果对齐」问题。在前端设计前端生产的模式下,所有的过程都在前端完成,效果对齐是一件很容易的事情;而当图片生产的步骤移到了云端,「所见即所得」变得更加困难。

架构

整体架构

image.png

用户首先通过 OpenAPI 创建样式,调用渲染接口时,服务会对参数进行摘要,查找是否命中缓存;没有命中缓存时,才会实际进行渲染任务,渲染完成后将其持久化到服务绑定的 TOS Bucket;

消费图片时,则复用 ImageX 原有的图片分发链路进行分发,此时还可以对图片应用模板进行二次处理。

前端架构

前端 Web 编辑器其实可以划分为两个部分,一部分就是用户可以直接操作要素的画布;另一部分是画布以外的区域,包括资源面板、属性设置面板、快捷操作栏等等。为了使得画布部分能够和操作部分更好地结合,我们将 fabric.js 包装成了一个 Redux State 数据驱动的渲染器。前端编辑器架构如下图所示:

image.png

从功能与架构上来看,画布数据的更新有两种来源:

  1. 当通过画布操作直接修改画布上的要素时,我们通过监听 fabric.js 暴露的一系列事件来更新 Redux 中的状态,如当前选中的要素变了,或者某个要素被拉长了30px(前者属于编辑器状态数据,后者属于样式数据);
  2. 当用户通过编辑器的UI,如要素的属性面板,来设置要素属性的时候,则是直接修改了 Redux 状态。在这里,我们通过自定义 Redux 中间件,在状态变化时,对其进行 diff ,将变化应用到对应的 fabric 对象(即下图中的更新流程)。

image.png

实现

创意魔方 Web 编辑器以 fabric.js 为基础进行了二次开发与封装,服务端使用 fogleman/gg 绘制图形、golang/freetype 绘制文本。为了能够更好地进行前后端渲染效果的对齐,我们定义了各个要素的数据字段,前后端都将以这份数据作为渲染的唯一依据。

样式中描述了画布的大小、背景及包含的各种要素,每个要素都有自己的大小、位置等属性,而针对不同类型的要素,它还会有不同的特有属性。这些属性一起确定了该要素该被如何绘制。

一份数据示例

{
    "id": "******",
    "name": "hks-test",
    "width": 800,
    "height": 800,
    "unit": "px",
    "service": "******",
    "background": {
        "fill": "#00000000",
        "fillSrc": "",
        "viewpoint": { "x": 0, "y": 0, "width": 1, "height": 1 }
    },
    "elements": [
        {
            "id": "el18792eeaed82",
            "name": "图片1",
            "type": "image", // 取值还有 qrcode / text / shape
            // 各种类型要素的通用属性
            "left": 273,
            "top": 239,
            "width": 240,
            "height": 240,
            "content": "tos-cn-i-3won6xyz0u/mofang/2.gif",
            "opacity": 100,
            "angle": 0,
            "flipX": false,
            "flipY": false,
            "attr": {
                // ...图片要素的特有属性
            }
        },
        // ... 省略更多要素
    ],
    "output": {
        "format": "WEBP",
        "quality": 0
    }
}

在整体的架构设计完成之后,需要我们去解决前后端「效果对齐」问题。对于要素的大部分属性,其预期效果都是很明确的,如宽高、位置、透明度等。对于这部分属性,只需要分别进行开发,最终确认效果一致即可。而对于另一些属性,如文字删除线应该在文字的什么位置、图片蒙版透明度如何混合,这些问题会由前后端同学在实现前进行讨论,case by case 进行对齐、解决并记录到文档。由于前后端渲染引擎与依赖库的不同,这也是难以避免的问题。

下面会重点介绍一下在各种类型的要素在效果对齐中遇到的关键问题及其解决方案。

二维码

最初前端使用 node-qrcode,后端使用 skip2/go-qrcode 进行二维码的生成,然而在结果图的对比中,我们发现,二者生成的二维码并不一致,而两个二维码扫码得到的内容却是一样的。如下图,二者编码的信息都是 www.volcengine.com ,同样采用 Medium 的纠错等级,但很明显二者长得不一样(扫码结果是一样的,可以试试看 XD)。前者为node-qrcode 生成结果,后者为go-qrcode 生成结果。

通过了解二维码的生成过程,并对 node-qrcode 和 skip2/go-qrcode 的源码进行对比之后,发现两者在掩码的选择上并不一致。在二维码的生成过程中,在完成一系列构造、编码的过程之后,最后会需要让编码完成之后的结果与掩码进行一次 XOR 操作,目的是使用掩码图形让图像中的黑白色块比例接近 1:1 并均匀分布,尽可能避免用于定位的探测图形出现在别的区域,影响识别。

下面是可以选择的 8 种掩码:

image.png

在生成二维码时,会将所有掩码分别应用于编码得到的数据,再根据四个评估规则对这些图像进行惩罚评分,最终选取惩罚评分最低的结果作为二维码的编码结果。

四条评估规则分别是:

规则一:

  • 逐行检查,如果有 5 个连续的像素,增加 3 点惩罚。
  • 如果在前 5 个之后还有连续的像素,则后续每个相同颜色的像素再增加 1 点惩罚。
  • 按相同的条件检查每一行、每一列,然后把惩罚分数加起来。

规则二:

  • 查找 >= 2x2 的相同颜色的模块。QR 码规范规定,对于大小为 m×n 的实色块,惩罚分数为 3×(m-1)×(n-1)

规则三:

  • 寻找符合**黑白黑黑黑白黑**且任意一边存在 4 个白色块的模式,如下图,每出现一个,惩罚分 + 40

image.png

规则四:

根据黑白色块的比例赋惩罚分,计算的规则如下:

  1. 计算黑色像素个数占总像素个数的百分比

  2. 计算上面的百分比中的前和后5的整数倍的百分比,举个栗子,对于 43%,前向 5 的整数倍是 40,后向 5 的整数倍是 45.

  3. 步骤2得到的前向/后向百分比,和 50 做差,求绝对值。例如:|40-50|=10,|45-50|=5。

  4. 步骤3得到的数除以 5,例如:10 / 5=2,5 / 5=1.

  5. 步骤4中较小的数乘以 10,得到最后的分数。例如:上例中 1 更小,所以惩罚分数是 1*10=10

在排查过程中发现 go-qrcode 在计算惩罚评分时对于规则 1 和 4 的算法与标准有出入,导致选择的掩码不符合预期。在根据规则1计算惩罚分数时,原逻辑为在出现 6 个连续像素时赋 4 分,后续每多一个加 1 分(应为出现 5 个连续像素时赋 3 分,后续每多一个加 1 分);在根据规则 4 计算惩罚分数时,原逻辑没有取「前向/后向百分比」分别计算取较小值。

后续通过分别 fork 进行修复后完成了效果对齐

图片

对于图片,大多数属性的设置与结果都很明确。开发 Web 编辑器时主要在滤镜上遇到了困难。目前创意魔方中的图片主要支持亮度、对比度、饱和度、高斯模糊和锐化这几个滤镜。其中亮度、对比度、饱和度滤镜,只需要进行参数映射就可以与后端效果达成对齐,但高斯模糊和锐化滤镜则需要我们对照 后端用的图片滤镜处理库 实现。

但是,此时另一个问题又摆在眼前,服务端处理图片是一次性的,而页面上用户却可能多次修改滤镜参数,而由于每个滤镜的处理都依托于前一个滤镜的输出,一旦修改参数就意味着每个滤镜都需要重新进行计算;服务端的图片处理库通过 goroutine 进行多线程的处理,而众所周知 JavaScript 是单线程的。如果把图片滤镜的处理放在主线程中,不加以特殊处理,一旦处理的图片过大,就会导致页面假死无法响应用户操作。

我们从 3 个角度来优化了整个使用的体验:

  1. 针对高斯模糊滤镜与锐化滤镜这样代价比较高的操作,我们将其转移到 WebWorker 进行计算,首先保证滤镜计算不会导致页面假死。相关 filter 及 worker 实现如下。
// fabric-extend/gaussianblur-filter.class.js

/**
 * GaussianBlur filter class
 */
filters.GaussianBlur = createClass(
  filters.BaseFilter,
  /** @lends fabric.Image.filters.GaussianBlur.prototype */ {
    // 省略其他处理代码..
    gaussianBlur: async function (options) {
      if (this.sigma === 0) {
        return;
      }
      if (this.worker) {
        // terminal previous worker
        Thread.terminate(this.worker);
        console.log(`terminate blur worker ${options.cachedKey}`);
      }
      // 唤起 WebWorker
      this.worker = await spawn(BlobWorker.fromText(gaussianBlurText));

      const opt = { imageData: options.imageData };
      const imageData = await this.worker.gaussianBlur(opt, this.sigma);

      console.log(`${this.sigma} gaussianBlur worker calculate =>`, imageData);

      // 回收资源,释放 WebWorker
      this.worker && Thread.terminate(this.worker);
      this.worker = null;

      // eslint-disable-next-line require-atomic-updates
      options.imageData = imageData;
    },
  },
);
// worker/gaussian-blur.js
import { expose } from 'threads/worker';

expose({
  gaussianBlur(options, sigma) {
    function gaussianBlurKernel(x, sigma) {
      return Math.pow(Math.E, -(x * x) / (2 * sigma * sigma)) / (sigma * Math.sqrt(2 * Math.PI));
    }

    function clamp(x) {
      const v = Math.round(x);
      if (v > 255) return 255;
      if (v > 0) return v;
      return 0;
    }

    const radius = Math.round(Math.ceil(sigma) * 3);
    const kernel = new Array(radius + 1);

    for (let i = 0; i <= radius; i++) {
      kernel[i] = gaussianBlurKernel(i, sigma);
    }
    // 省略相关矩阵运算... 

    return options.imageData;
  },
});
  1. 在解决了计算占用 JS 线程时间过长的问题之后,我们发现,将计算结果渲染到画布这一步本身也是一次同步操作,对于大图来说,这一步本身也会占用不少时间。因此对于不同的图片大小,我们设置了一个阈值,采取了不同的应用滤镜变更的策略。

image.png

const ImageSetting: React.FC<ImageSettingProps> = (props) => {
  // ...省略
  
  const [fn, setFn] = useState(() => debounce);
  
  useEffect(() => {
    getImageSize(elem.content).then(res => {
      const { width, height } = res;
      console.log('current image size=>', width, height, width * height > 1800 * 1800);
      if (width * height > 1800 * 1800) {
        setFn(() => debounce);
      } else {
        setFn(() => throttle);
      }
    });
  }, [elem.id]);

  /**
   * 根据图片大小选择throttle或者debounce
   */
  const handleFilterChangeInfrequently = useCallback(fn(handleFilterChange, 500, { leading: false, trailing: true }), [
    elem.id,
    fn,
  ]);
  
  // ...省略
}
  1. 在进行了以上两点优化之后,我们发现,对于大图来说,当高斯模糊参数较高时,由于卷积核过大,复杂度仍然很高,耗时仍然相当久,对于一个 Web 程序而言仍然是不能接受的。因此我们最终选择了限制卷积核的大小,在性能和质量的 trade-off 中选择了更好的性能,以此保证良好的用户体验。
// 卷积核的边长为radius * 2 - 1,
// 对于每一个像素,都需要遍历其周围的 (radius * 2 - 1)^2 个像素才能得到该像素的高斯模糊结果。
// 因此当 sigma 增加时,复杂度会以 n^2 的速度增加。
// 最终我们选择限制卷积核的最大大小
-    const radius = Math.round(Math.ceil(sigma) * 3);
+    const radius = Math.max(Math.ceil(sigma) * 3, 5);

文本框

文本框支持的属性很多,既有与文字样式有关的字体、颜色、粗体、斜体、删除线、下划线、文字边框(并非文字描边)等功能,也有与排版相关的左、中、右和双端对齐的功能,以及为了能在后端生产实际图片时,即使替换文字也不会让排版效果变得奇怪的两个自适应功能:字号自适应与宽度自适应。

接下来我们逐一来介绍我们在这些功能上分别做出了什么样的调整优化。

排版

首先是与排版相关的功能。前端的基本排版能力由 fabric.js 提供,而后端则是根据创意魔方的需求实现了一套文本渲染的排版规则。

说回前端,fabric.js 本身的排版能力几乎都建立在英文环境的基础上,也正因此,它对于中文排版的支持相当有限。在英文环境中,我们知道语句由一个个单词组成,单词之间通常会由空格间隔分割,然而在中文环境下,我们很少或者是几乎不使用空格,文本基本只由标点和方块字组成。这就导致了在启用双端对齐排版时,输入中文语句会被认为是一整个单词,双端对齐不会真正生效。如下图所示,在英文环境下,空白被正确填充到了单词间的空隙中,而在中文环境下,仍然是左对齐的效果。

image.png 上图为fabric.js默认文本框,中文文本没有双端对齐

image.png 重写双端对齐后的文本框,图中文本框宽度 479 px,文本字号 48px,第一行有 9 个字宽度432 px,剩余的 47 px 被平均填充到了每个字的间隙中。

既然找到了问题的原因所在,那解决起来也就并不复杂。我们通过修改分词的正则与重写双端对齐方法解决了这一本土化问题。(根据产品需求,双端对齐时,最后一行保持左对齐,因此实际表现与 fabric.js 默认行为不一致)

  
+ _reChars: /[\S\s]/g,
+ _reChar: /[\S\s]/,
  
  /**
   * 自定义双端对齐
   */
  enlargeSpaces: function () {
    let diffSpace, currentLineWidth, numberOfSpaces, accumulatedSpace, line, charBound, spaces;
    for (let i = 0, len = this._textLines.length; i < len; i++) {
-     if (this.textAlign !== 'justify' && (i === len - 1 || this.isEndOfWrapping(i))) {
+     if (this.textAlign !== 'justify' || i === len - 1 || this.isEndOfWrapping(i)) {
        continue;
      }
      accumulatedSpace = 0;
      line = this._textLines[i];
      currentLineWidth = this.getLineWidth(i);
-     if (currentLineWidth < this.width && (spaces = this.textLines[i].match(this._reSpacesAndTabs))) {
+     if (currentLineWidth < this.width && (spaces = this.textLines[i].match(this._reChars))) {
        numberOfSpaces = spaces.length;
        diffSpace = (this.width - currentLineWidth) / numberOfSpaces;
        for (let j = 0, jlen = line.length; j <= jlen; j++) {
          charBound = this.__charBounds[i][j];
-         if (this._reSpaceAndTab.test(line[j])) {
+         if (this._reChar.test(line[j])) {
            charBound.width += diffSpace;
            charBound.kernedWidth += diffSpace;
            charBound.left += accumulatedSpace;
            accumulatedSpace += diffSpace;
          } else {
            charBound.left += accumulatedSpace;
          }
        }
      }
    }
  },

宽度自适应&字号自适应

这两个自适应功能同样是由于创意魔方可以替换要素内容的特殊性才存在。我们先来简单介绍一下它们分别有什么作用:

能力介绍使用场景
宽度自适应在默认情况下,文字框有一个固定的宽度,当用户输入的文本超出了文本框的宽度,就会自动换行;而开启宽度自适应之后,文本框的宽度将会由用户所输入的内容决定,当且仅当用户手动输入换行符时进行换行。该功能通常用于避免替换文本后,由于文本过长而出现出乎意料的折行
字号自适应在默认情况下,当用户因为文本较长/输入换行符换行时,如果文本框的高度不够,就会自动撑高;而开启字号自适应之后,在文本框高度不够时,会优先选择缩小字号以容纳文本。反之同理,当用户删除文本,导致文本框有多余空间可以容纳更多内容时,字号会自动放大。该功能通常用于避免替换文本后,由于文本过长而出现文本内容覆盖其它要素或被其它要素覆盖的情况。

从功能的描述上来看,这两个自适应功能需要是互斥的,不能同时开启。

我们通过重写 fabric.js 的文字分行的函数实现了宽度自适应;

  // 宽度自适应相关
  _splitTextIntoLines: function (text) {
    // 这里会根据用户输入的换行符进行分行
    const newText = fabric.Text.prototype._splitTextIntoLines.call(this, text);
    if (this.adapt) {
      // 当启用宽度自适应时,文本框只会因为用户手动输入的换行符换行
      return newText;
    } else {
      // 当未启用宽度自适应时,根据换行符划分的每一行文本,还需要根据文本框宽度二次分行
      const graphemeLines = this._wrapText(newText.lines, this.width);
      const lines = new Array(graphemeLines.length);
      for (let i = 0; i < graphemeLines.length; i++) {
        lines[i] = graphemeLines[i].join('');
      }
      newText.lines = lines;
      newText.graphemeLines = graphemeLines;
      return newText;
    }
  },
  
  /**
   * @param {boolean} duringBiSearchAppropriateFontSize 是否是在开启字号自适应情况下,二分查找合适的字号时调用的
   */
  initDimensions: function (duringBiSearchAppropriateFontSize = false) {
    
    // 省略原有处理逻辑
    
    // 1. 启用宽度自适应时
    // 2. 启用字号自适应二分查找合适字号时
    // 将当前文本的实际宽度赋值给文本框
    if (this.adapt || duringBiSearchAppropriateFontSize) {
      this.width = this.calcTextWidth();
    }
    // clear cache and re-calculate height
    this.height = this.calcTextHeight();

    this.saveState({ propertySet: '_dimensionAffectingProps' });
  },

对于文字字号自适应,我们需要根据文本区域的大小和实际的文本内容动态计算文字的字号,以确保渲染文本能充满文本区域。最终采取了二分查找的方式,流程如下图所示。

image.png

形状

对于形状而言,主要是 lineCaplineJoin 属性的前后端对齐。此前,由于后端所使用的图形库不支持 lineJoin 取值为 miter,并且 lineCap=butt 时遇到了绘制结果有问题的情况,我们折中选用了 lineCap=lineJoin=round。此时前后端效果能够达成对齐。然而在绘制矩形时,即使没有设置圆角,也会因为 lineCap=round 而绘制出圆滑的角部,不符合预期。目前也已经通过 fork 依赖改写源码的方式在创意魔方的场景下支持了对应的属性设置。

image.png
上图中从左往右 lineCap 分别为 butt / round / square

image.png
上图中从上往下 lineJoin 分别为 round / bevel / miter

展望

  1. 正如「实现」中所提到的,目前创意魔方的功能迭代中,前后端效果对齐问题大多只能 case by case 解决。是否可以通过某种前后端同构的方式,用一份代码来解决样式渲染不一致的问题?此时前端交互问题如何解决?
  2. 是否可以将创意魔方与框架解耦,将编辑器状态、样式数据与数据驱动的 fabric.js 封装在一起。将已有的能力(如快捷键、右键菜单等)插件化,可插拔、可拓展。