I wanted a one-line answer to a small question: when I shrink a PNG that has a cut-out edge, which of the browser and Node resize calls quietly mix the colour stored under the transparent pixels into the edge? I answered it with a table built from one image. I am a tool person, not an imaging person, so everything below comes from what the pixels did, not from reading library source.
The test image is a portrait from Wikimedia Commons (CC BY-SA 4.0, by Dmitry Makeev), scaled to 1330×2000 and cut out with ImgIng (图映), a browser tool at https://imging.ai/ that I used on a desktop. Every number is this one image, one Apple M4, one run, Chromium 149 and sharp 0.35.5.
The short table
| Call | Behaves like | What I saw |
|---|---|---|
canvas drawImage, quality high |
premultiplied | blue stored under transparent pixels never reached the edge |
createImageBitmap, premultiplyAlpha: 'none'
|
straight (not premultiplied) | the hidden blue showed up in the edge pixel |
createImageBitmap, premultiplyAlpha: 'premultiply'
|
premultiplied | nearer the premultiplied reference (2.689) than the straight one (4.85) |
| sharp, default resize | premultiplied | semi-transparent pixels averaged 109.63 in brightness |
| sharp, RGB and alpha split by hand | straight | the same pixels averaged 125.1 |
For comparison, my own reference code gave 112.0 for premultiplied and 129.48 for straight on those semi-transparent pixels (alpha 10 to 245). That is why I put sharp's default next to the first and the hand-split version next to the second.
A tiny test you can run yourself
The cleanest check is not the portrait at all. Make an 8-pixel-wide image: four opaque red pixels (255, 0, 0, 255) on the left, four fully transparent pixels on the right that still carry the colour blue (0, 0, 255, 0). Shrink it to 5 pixels wide and look at the one pixel in the middle.
In canvas, drawImage gave (255, 0, 0, 128): half transparent red, no blue anywhere. createImageBitmap with premultiplyAlpha: 'none' gave (128, 0, 128, 128), which is red with blue mixed in. That purple is the kind of fringe a shrunk cutout shows. It is the invisible colour under alpha 0 leaking into the edge.
Do not use this test for sharp. In my run its 8 to 3, 8 to 5 and 8 to 7 shrinks all came out as hard cuts with no transition pixel, so the two-pixel example tells you nothing about it. For sharp I only trust the real portrait.
Why the portrait numbers are messier
On the portrait, a 4x shrink and enlarge, I compared each call with a straight reference and a premultiplied reference. Canvas was 7.456 away from straight and 6.636 from premultiplied. createImageBitmap with none was 6.108 and 5.184. Both lean slightly towards premultiplied, which does not match the tiny example for the none case.
I would not read much into that. Each call uses its own resampling kernel, so absolute distances do not compare across calls, and the gaps are only about 0.8 to 0.9. The tiny example has no kernel noise, so I lean on it for these two.
sharp flips when you split the channels
Inside sharp the comparison is clean. With the default call, versus resizing RGB and alpha as two separate raw images, the alpha channel matched exactly with lanczos3 (largest difference 0). The RGB did not: over the edge band the average difference was 4.218 and the largest 51.7 with lanczos3, and 4.356 and 50.3 with linear.
Here is one edge row of the 1330×2000 portrait (row 1300, x = 838, 840, 842, 844, 846, shown as RGBA), taken from my run:
sharp_default_lanczos3 (49, 43, 49, 41) (0, 0, 0, 0) (255, 255, 255, 11) (79, 82, 89, 74) (33, 34, 38, 191)
sharp_split_lanczos3 (220, 218, 219, 41) (255, 255, 255, 0) (240, 241, 243, 11) (180, 181, 184, 74) (72, 74, 78, 191)
canvas_high (45, 41, 45, 163) (60, 55, 60, 98) (85, 76, 85, 30) (81, 81, 81, 22) (47, 47, 47, 65)
bitmap_none (38, 34, 38, 53) (0, 0, 0, 0) (0, 0, 0, 0) (89, 93, 98, 60) (80, 83, 88, 162)
bitmap_premultiply (60, 57, 57, 76) (184, 170, 170, 18) (219, 219, 219, 7) (81, 81, 87, 47) (66, 68, 74, 128)
The two sharp rows have the same alpha (41, 0, 11, 74, 191) and very different colour: (49, 43, 49) against (220, 218, 219) at the first pixel. The clothing at that spot is dark, so 220 is the pale fringe. Only compare rows within the same library here; the canvas and bitmap rows come from different kernels and their alpha values differ.
The mistake that made me think sharp was broken
My first attempt at the straight version was .removeAlpha().resize(). It looked reasonable and it was wrong: the RGB came out darker. As far as I can tell, libvips premultiplies by alpha before dropping it, so you get the premultiplied colour with no alpha to put it back. To get a real straight resize I had to export RGB and alpha as two separate raw images, resize each, and join them again.
What I would do with this
If you want edges without a pale or dark ring, the default sharp resize, canvas drawImage and premultiplyAlpha: 'premultiply' all behaved as premultiplied in my runs. If you have to use none, or you split channels in sharp, the colour under the transparent pixels matters. On this ImgIng export the pixels at alpha 0 were (0, 0, 0), but the ones at alpha 1 to 5 averaged (221, 218, 219), pale like the old background. Cleaning the colour under low alpha before the resize would probably help; I did not test that.
One more thing: canvas does not keep the colour under transparent pixels at all. I wrote (200, 100, 50) with alpha 2 using putImageData and getImageData returned (255, 128, 0). At alpha 4 it was (191, 128, 64), and at alpha 0 it was (0, 0, 0). So anything that goes through a canvas on its way to a PNG may already have lost its hidden colour. Whether ImgIng's own export goes through a canvas I did not check; I can only say the zeros at alpha 0 look the same.
Safari and Firefox are untested.
Top comments (0)