解码图片到像素,做图片处理的话是必不可少的过程。大家都知道浏览器就能看图,所以自带解码功能,而 Node 也有许多库可以用,这通常不是什么难题。
我最初也是这么想的,在我的一个网页项目 ICAnalyzer 里是这样写的:
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 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 位整形存储,这就需要舍入,然后转回来的时候同样如此,最终导致了误差的产生。
你可以测试下这段代码:
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
不相等,具体与浏览器的实现有关:
[12, 189, 148, 62]
,推测采用了偏向上的舍入,以第二个像素为例: 187*62/255 = 45.46 -> 46
,46*255/62 = 189.2 -> 189
[12, 185, 144, 62]
,用得是四舍五入,即45.46 -> 45
,45*255/62 = 185.08 -> 185
。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 equivalentgetImageData()
as different values.
既然画到 canvas 上有预乘,那不用它行不行?很遗憾,原生的 API 里不用 canvas 还就拿不到像素数据,不过在一个 StackOverflow 回答中给出了一种方案:
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、主流格式全支持!
// 支持格式: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 |