JS 解码图片到 RGBA 像素的正确方法

发布时间:

最后更新:

解码图片到像素,做图片处理的话是必不可少的过程。大家都知道浏览器就能看图,所以自带解码功能,而 Node 也有许多库可以用,这通常不是什么难题。

我最初也是这么想的,在我的项目 ICAnalyzer 里是这样写的:

javascript
async function decodeImageNative(blob) {
	const bitmap = await createImageBitmap(blob);

	const canvas = document.createElement("canvas");
	const { width, height } = bitmap;
	canvas.width = width;
	canvas.height = height;

	const ctx = canvas.getContext("2d");
	ctx.drawImage(bitmap, 0, 0);
	return ctx.getImageData(0, 0, width, height);
}

不必多讲,这段代码非常简单,去 Google 搜的话靠前的回答都是这么做的,而且找了几张图试下也没有问题……直到某次找了张半透明的图做无损压缩测试,发现这无损的输出竟然跟原图不同。

经多方对比之后,确定了压缩的结果没有问题,那误差只可能出现在解码上,而且只有半透明的图才有这问题。经一番搜索终于确定了原因:canvas 2d API 会对像素做 alpha 预乘,存在舍入误差。

什么是 alpha 预乘? #

Alpha Premultiply 是一种图片合成的优化手段,通过提前将颜色通道乘以透明度,简化后续合成时的计算量。

举个例子,当一个 32 位 RGBA 像素 [255, 0, 100, 128] 绘制在背景 RGB = [0, 255, 50] 上时,最终呈现的颜色将是每通道乘以透明度的比例然后相加,即:

A = 128 / 255
[
    255 * A +  0  * (1 - A),
     0  * A + 255 * (1 - A),
    100 * A +  50 * (1 - A),
]

这其中有大量的乘法运算会降低性能,试想一下如果背景变了,那重新渲染的时候又得挨个乘一遍。于是一种优化方案出现了,就是提前把颜色通道乘以透明度,这样混合时即可省一半的乘法。

提前计算:
A = 128 / 255
R = 255 * A = 128
G =  0  * A = 0
B = 100 * A = 50

后续合成时
[
    128 +  0  * (1 - A),
     0  + 255 * (1 - A),
    50  +  50 * (1 - A),
]

对做游戏和渲染的人来说,Alpha 预乘是经常接触到的知识,但搞网页的一般还碰不到。

浏览器的坑 #

理论上讲,Alpha 预乘并不丢失信息,想要还原为非预乘(直通)的表示只需要除回来即可。但可惜浏览器内部对预乘后的通道仍然使用 8 位整形存储,这就需要舍入,然后转回来的时候同样如此,最终导致了误差的产生。

你可以测试下这段代码:

javascript
const input = [12, 187, 146, 62];
const image = new ImageData(new Uint8ClampedArray(input), 1, 1);
const ctx = document.createElement("canvas").getContext("2d");
ctx.putImageData(image, 0, 0);
const gotBack = ctx.getImageData(0, 0, 1, 1);
console.log(gotBack.data);

其打印出来的数组跟input不相等,具体与浏览器的实现有关:

这个问题其实在 HTML 规范MDN 上都有提及:

Due to the lossy nature of converting between color spaces and converting to and from premultiplied alpha color values, pixels that have just been set using putImageData(), and are not completely opaque, might be returned to an equivalent getImageData() as different values.

解决方案 #

WebGL #

既然画到 canvas 上有预乘,那不用它行不行?很遗憾,原生的 API 里不用 canvas 还就拿不到像素数据,不过在一个 StackOverflow 回答中给出了一种方案:

typescript
function drawableToImageData(bitmap: ImageBitmap | HTMLImageElement) {
	const canvas = document.createElement("canvas");
	const gl = canvas.getContext("webgl2")!;
	const { width, height } = bitmap;

	gl.activeTexture(gl.TEXTURE0);
	const texture = gl.createTexture();
	gl.bindTexture(gl.TEXTURE_2D, texture);
	const framebuffer = gl.createFramebuffer();
	gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
	gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, texture, 0);
	gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap);
	gl.drawBuffers([gl.NONE]);

	const data = new Uint8ClampedArray(width * height * 4);
	gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, data);
	return new ImageData(data, width, height);
}

// 用法:
drawableToImageData(await createImageBitmap(blob, {
	premultiplyAlpha: "none",
}))

同样是 canvas 但用了更复杂的 WebGL2 API,我对 WebGL 不熟就不分析了,总之用它是可以避免预乘误差的。

三方解码器 #

另一种搞法是不走浏览器的那一套,改调解码库,这里推荐我自己的 icodec,编译了最新版本的编码器到 WebAssembly、主流格式全支持!

javascript
// 支持格式:png, jpeg, webp, heic, avif, jxl, qoi, webp2
import { avif } from "icodec";

await avif.loadDecoder();
avif.decode(new Uint8Array(await blob.arrayBuffer()));

在小图片上测试(和 Edge 对比),性能跟有误差的 2d canvas 的差不多,领先于 WebGL,当然下载解码器的开销没有算上。

No. Name codec time time.SD
0 icodec avif 3.22 ms 8.24 us
1 2d avif 1.50 ms 3.13 us
2 WebGL avif 3.08 ms 26.33 us
3 icodec heic 3.06 ms 16.84 us
4 icodec jpeg 727.85 us 1.65 us
5 2d jpeg 601.21 us 3.51 us
6 WebGL jpeg 1,876.96 us 8.85 us
7 icodec jxl 3.57 ms 17.73 us
8 icodec png 419.48 us 2,901.49 ns
9 2d png 573.07 us 801.34 ns
10 WebGL png 1,835.78 us 16,278.04 ns
11 icodec qoi 444.00 us 1.08 us
12 icodec webp 792.57 us 1.58 us
13 2d webp 805.07 us 4.04 us
14 WebGL webp 2,156.43 us 36.42 us
15 icodec wp2 2.59 ms 12.10 us
评论加载中