When I shrink a cutout PNG and blow it back up, two things go wrong at the edge, and it is easy to file them under one name: "resizing makes the edge blurry". After comparing the channels separately, I think they are two effects with two causes. The soft edge comes from interpolation. The bright or dark fringe comes from how the colour channels are averaged when alpha is not taken into account. Only the second one depends on the premultiplied-alpha convention.
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 the AI background removal in ImgIng, which I ran in a desktop browser at https://imging.ai/ . In the run where I watched the network tab, I saw only model file downloads. Every number below is one image per type, one Apple M4, one run, Chromium 149 and sharp 0.35.5.
Alpha does not care which convention you use
I shrank the exported PNG 4× with an area average and enlarged it back with bilinear, twice. One run scaled R, G, B and A as four independent channels (straight). The other multiplied RGB by alpha first, scaled, then divided back (premultiplied). The two alpha channels came out identical, maximum difference 0.
That explains the soft edge. Averaging neighbours widened the transition band in both runs, and premultiplying does not sharpen anything. If your only complaint is a soft edge, no alpha setting will fix it.
The fringe comes from colour under transparent pixels
A fully transparent pixel still stores an RGB value, and nobody looks at it. A straight resize averages that hidden colour into the visible neighbours, weighted by area, not by opacity.
The smallest case I could build is eight pixels wide: four opaque red on the left, four fully transparent pixels on the right that happen to store blue. Shrunk to five pixels, the single transition pixel in the browser canvas came out as (255,0,0,128), pure red at half opacity. With createImageBitmap set to premultiplyAlpha: 'none' it came out as (128,0,128,128). Same alpha, but the blue leaked in.
On the real portrait the hidden colour was not blue. Pixels with alpha 1 to 5 stored almost the original wall colour, averaging (221,218,219). Along row 1300 of the 4× run, at x=838 to 847, the source had alpha 1 to 5 and RGB 255 in those pixels. After the straight resize, the pixels at alpha 15 to 69 had RGB 195 to 244, a pale ring around dark clothing. The premultiplied run gave 76 to 113 at the same spot, which keeps the clothing dark.
Over the whole edge band (original alpha 1 to 254, grown outward by 4 pixels), the straight run was off from the premultiplied baseline by 2.991 levels on average, 46.9 at worst, and 13.83% of the pixels differed by more than 8 levels. The premultiplied run scored near zero partly because the baseline is itself premultiplied, so read this as how much dirty colour the straight run adds, not as proof of which is closer to the truth.
The same test on a dog shows much less
Same test on a white curly dog on a dark car seat. At 4× the straight run was off by 0.505 on average and only 0.12% of the edge pixels were more than 8 levels away. At 8× it was 1.560 and 4.98%, still far milder than the portrait at 3.879 and 19.27%. Here the hidden colour was dark, (30,31,32) on average at alpha 1 to 5. My guess is that the stored dark colour sits close to the fur's shadowed edge, so averaging it in costs little. One image per type, so not a rule.
Stored colour at low alpha is not reliable
The canvas seems to keep colour premultiplied. I wrote (200,100,50) with putImageData and read it back: at alpha 4 it returned (191,128,64), at alpha 2 it returned (255,128,0), and at alpha 0 it returned (0,0,0). The export I checked also had (0,0,0) under alpha 0. I have not looked at how the exporter writes pixels, so I only know the behaviour matches.
A way to tell which one you have
Compare alpha first. If an enlarged result has a wide soft edge but no colour fringe, that is interpolation and you are done. If there is a fringe, check whether your library resizes with alpha taken into account. In sharp 0.35.5, with Lanczos3, the default run and a run with RGB and alpha exported and resized separately gave identical alpha and a mean RGB difference of 4.218 in the edge band. The mean brightness of the semi-transparent pixels was 109.63 for the default and 125.1 for the split version, so the default behaves like premultiplied and the split like straight.
One trap from my own attempt: calling .removeAlpha().resize() is not a straight resize. libvips premultiplies before dropping alpha, so my first version came out darker and I had to redo it.
The sharp numbers above are Lanczos3 only, and I did not test Safari or Firefox.
Top comments (0)