背景
在做一些图文分离的卡片时,文字、文字颜色和背景图往往是开放给运营同学去自定义配置。运营同学在配置卡片时,如果图片是深色的,那文字颜色就需要配置成白色,如果图片的浅色的,文字颜色就需要配置成黑色。一旦配错,就会出现这种情况:
我们开放文字颜色的配置给运营,依赖运营人工去判断图片的明亮,看似是给他们提供了选择的权利,实则却增加了他们的工作量,尤其是当卡片数量众多时,无疑给运营的配置工作增加了难度。
那么,技术上有没有办法,来根据图片的明亮动态改变文字的颜色呢?
方案
现在的核心问题是,拿到一张图,如何判断它是亮色 or 暗色。
在计算机视觉领域,一般使用图片的灰度来表示图片的亮度,灰度值越高则图像越亮。
彩色图像在某个点的像素值是由三个分量值叠加而成,也就是(R,G,B),灰度的计算公式(浮点法):
Gray = 0.299 * R + 0.587 * G + 0.114 *B
参考黑白电视对亮灰色的定义(192,192,192),灰度值为192,我们可制定一个简单有效的规则:
Gray > 192 -> 亮色
Gray <= 192 -> 暗色
- 图像的像素值
有了上述公式作为基础,接下来只需要拿到图片的RGB值,套入公式,就能判断它的亮暗。
考虑到我们的设计原则,一般一张图内不会大规模同时出现亮色和暗色,我们姑且将图片中的像素点按rgb值分组,选出占比最多的用于代表图片的rgb值,本文称该值为图片主色。(实践证明非常有效)
- 利用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, 0, 0, width, height)
const { data } = context.getImageData(0, 0, 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 = (data: Uint8ClampedArray, ignore: string[]): [] => {
const countMap = {}
for (let i = 0; i < data.length; i += 4 /* 4 gives us r, g, b, and a*/) {
let alpha: number = data[i + 3]
// skip FULLY transparent pixels
if (alpha === 0) continue
let rgbComponents: number[] = Array.from(data.subarray(i, i + 3))
// skip undefined data
if (rgbComponents.indexOf(undefined) !== -1) continue
let color: string = alpha && alpha !== 255
? `rgba(${[...rgbComponents, alpha].join(',')})`
: `rgb(${rgbComponents.join(',')})`
// skip colors in the ignore list
if (ignore.indexOf(color) !== -1) continue
if (countMap[color]) {
countMap[color].count++
} else {
countMap[color] = { color, count: 1 }
}
}
const counts = Object.values(countMap) as []
const dominantOne: any = counts.sort(
(a: any, b: any) => b.count - a.count,
)[0];
return dominantOne.color;
}
到这里,我们就能拿到图片主色,并结合(1)的公式,从而判断一个图片是亮或暗。
但这个方案有个隐患:
绘制图片到 canvas 和遍历像素点都是 CPU 密集型计算,会阻塞JS主线程,如果一个页面中这样的卡片过多(如瀑布流无限加载),页面性能必然下降,造成卡顿。
遇到 CPU 密集型计算,前端一般将这部分工作交由 WebWorkers 处理,于是很自然想到了一个优化方案
简单总结下二者的作用:
- 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将计算结果写入缓存,同一张图无须重复计算;创建工作线程的开销较大,可以自行维护一个工作线程池,充分复用它们。
能把需要人做的重复性劳动交给机器,就是技术人的小确幸。