前端小tips—利用离屏 canvas 计算图片的明亮度

791 阅读4分钟

背景

在做一些图文分离的卡片时,文字、文字颜色和背景图往往是开放给运营同学去自定义配置。运营同学在配置卡片时,如果图片是深色的,那文字颜色就需要配置成白色,如果图片的浅色的,文字颜色就需要配置成黑色。一旦配错,就会出现这种情况:

我们开放文字颜色的配置给运营,依赖运营人工去判断图片的明亮,看似是给他们提供了选择的权利,实则却增加了他们的工作量,尤其是当卡片数量众多时,无疑给运营的配置工作增加了难度。

那么,技术上有没有办法,来根据图片的明亮动态改变文字的颜色呢?

方案

现在的核心问题是,拿到一张图,如何判断它是亮色 or 暗色。

在计算机视觉领域,一般使用图片的灰度来表示图片的亮度,灰度值越高则图像越亮。

  1. 灰度值

彩色图像在某个点的像素值是由三个分量值叠加而成,也就是(R,G,B),灰度的计算公式(浮点法):

Gray = 0.299 * R + 0.587 * G + 0.114 *B

参考黑白电视对亮灰色的定义(192,192,192),灰度值为192,我们可制定一个简单有效的规则:

Gray > 192    -> 亮色

Gray <= 192    -> 暗色

  1. 图像的像素值

有了上述公式作为基础,接下来只需要拿到图片的RGB值,套入公式,就能判断它的亮暗。

考虑到我们的设计原则,一般一张图内不会大规模同时出现亮色和暗色,我们姑且将图片中的像素点按rgb值分组,选出占比最多的用于代表图片的rgb值,本文称该值为图片主色。(实践证明非常有效)

  1. 利用canvas获取图片主色

利用 canvas 可以将图片绘制,并获取每个像素点的 rgba 值。

示例代码:

// 通过图片地址加载图片并通过canvas绘制,得到图片的像素点信息 (Uint8ClampedArray)
export const getImageData = (src: string, scale: number = 1): Promise<Uint8ClampedArray> => {
  const img = new Image()

  // Can't set cross origin to be anonymous for data url's
  // https://github.com/mrdoob/three.js/issues/1305
  if (!src.startsWith('data')) img.crossOrigin = 'Anonymous'

  return new Promise((resolve, reject) => {
    img.onload = function () {
      const width = img.width * scale
      const height = img.height * scale
      const context = getContext(width, height)
      context.drawImage(img, 00, width, height)

      const { data } = context.getImageData(00, width, height)
      resolve(data)
    }

    const errorHandler = () => reject(new Error('An error occurred attempting to load image'))

    img.onerror = errorHandler
    img.onabort = errorHandler //图片加载中止
    img.src = src
  })
}

// 绘制canvas
export const getContext = (width, height) => {
  const canvas = document.createElement('canvas')
  canvas.setAttribute('width', width)
  canvas.setAttribute('height', height)
  return canvas.getContext('2d')
}

再遍历每个像素点,找出占比最多的色值作为图片主色:

async function getDominantColor(src: string) {
  const data = await getImageData(src);
  return getColor(data);
}

// 遍历图片像素点,选出占比最多的颜色
export const getColor = (dataUint8ClampedArrayignorestring[]): [] => {
  const countMap = {}

  for (let i = 0; i < data.length; i += 4 /* 4 gives us r, g, b, and a*/) {
    let alphanumber = data[i + 3]
    // skip FULLY transparent pixels
    if (alpha === 0continue

    let rgbComponentsnumber[] = Array.from(data.subarray(i, i + 3))

    // skip undefined data
    if (rgbComponents.indexOf(undefined) !== -1continue

    let colorstring = alpha && alpha !== 255
      ? `rgba(${[...rgbComponents, alpha].join(',')})`
      : `rgb(${rgbComponents.join(',')})`

    // skip colors in the ignore list
    if (ignore.indexOf(color) !== -1continue

    if (countMap[color]) {
      countMap[color].count++
    } else {
      countMap[color] = { color, count1 }
    }
  }

  const counts = Object.values(countMap) as []
  const dominantOneany = counts.sort(
    (a: any, b: any) => b.count - a.count,
  )[0];
  return dominantOne.color;
}

到这里,我们就能拿到图片主色,并结合(1)的公式,从而判断一个图片是亮或暗。

但这个方案有个隐患:

绘制图片到 canvas 和遍历像素点都是 CPU 密集型计算,会阻塞JS主线程,如果一个页面中这样的卡片过多(如瀑布流无限加载),页面性能必然下降,造成卡顿。

遇到 CPU 密集型计算,前端一般将这部分工作交由 WebWorkers 处理,于是很自然想到了一个优化方案

  1. Web WorkersOffscreenCanvas

简单总结下二者的作用:

  • WebWorkers

允许你独立于JS主线程之外,创建自己的工作线程,二者互不干扰,可以通信。除了dom操作和某些特定API在工作线程中不可用外,其余与允许在JS主线程的的脚本无异。

  • OffscreenCanvas

除了不能把画面绘制到屏幕上,基本和 canvas 没有任何区别,它在窗口环境和web worker环境均有效。

对于二者的详细概念不做赘述。

实现也非常简单:

  • 主线程:“这活儿我不干了,交给你吧”
function getDominatColorFromWorker(src) {
  return new Promise((resolve, reject) => {
    const worker = new Worker('worker.js');
    worker.onmessage = function (e) {
      const color = e.data;
      console.log('getDominatColor from worker', color);
      resolve(color);
      // 关闭线程
      worker.terminate();
    };
    // 省略错误处理 。。。
    worker.postMessage(src);
  });
}
  • 工作线程:“***”

上文第3步提到的方法都可以直接复用,只需要改造 getContext 方法,创建canvas时使用 OffscreenCanvas API即可。

// 绘制离屏 canvas
const getContext = (width, height) => {
  const canvas = new OffscreenCanvas(width, height)
  return canvas.getContext('2d')
};
// 省略 3 中的其它方法定义
onmessage = async (e) => {
  const src = e.data;
  const dominantColor = await getDominantColor(src);
  postMessage(dominantColor);
};

思考

本文提到的方案其实还有很多优化空间,比如说可以利用service worker将计算结果写入缓存,同一张图无须重复计算;创建工作线程的开销较大,可以自行维护一个工作线程池,充分复用它们。

能把需要人做的重复性劳动交给机器,就是技术人的小确幸。