If you write a pixel with a low alpha into a canvas and read it back, the color is not the one you wrote. In Chromium 149, putImageData with (200, 100, 50) at alpha 2 came back from getImageData as (255, 128, 0). Alpha stays 2, the RGB is simply different. At alpha 0 the color is gone entirely.
I hit this while checking why cutout PNGs go grey or bright at the edge after a resize. The samples were three Wikimedia Commons CC photos (a portrait, a product, a hair close-up) that I cut out with ImgIng, a browser tool at https://imging.ai/ , on a desktop. The readback test below is only a pixel I made up and one browser version, a single run.
The readback test is twelve lines
The loop writes one pixel with RGB fixed at (200, 100, 50) for each alpha from 0 to 22 in steps of 2, then reads it back with getImageData. This is the complete output:
in (200,100,50, 0) -> out (0,0,0,0)
in (200,100,50, 2) -> out (255,128,0,2)
in (200,100,50, 4) -> out (191,128,64,4)
in (200,100,50, 6) -> out (212,85,42,6)
in (200,100,50, 8) -> out (191,96,64,8)
in (200,100,50,10) -> out (204,102,51,10)
in (200,100,50,12) -> out (191,106,42,12)
in (200,100,50,14) -> out (200,91,55,14)
in (200,100,50,16) -> out (207,96,48,16)
in (200,100,50,18) -> out (198,99,57,18)
in (200,100,50,20) -> out (204,102,51,20)
in (200,100,50,22) -> out (197,104,46,22)
Alpha comes back exact every time. The RGB wanders, and it does not settle smoothly as alpha grows. Alpha 10 lands within 4 on every channel, but alpha 12 is already 9 off on red (191 against 200), and at alpha 18 blue is still 7 off (57 against 50).
The numbers match a premultiplied store
My explanation is an inference, not something I checked in the browser source. If the canvas keeps each pixel premultiplied (RGB multiplied by alpha, rounded to a byte) and getImageData divides alpha back out, the pattern falls out of the arithmetic. At alpha 2, red is 200 × 2 / 255 = 1.57, which rounds to 2, and 2 × 255 / 2 gives 255. Green is 0.78, rounds to 1, comes back as 127.5, shown as 128. Blue is 0.39, rounds to 0. That is (255, 128, 0), the exact row above.
Alpha 4 works the same way: 3.14 rounds to 3 and returns 191, 1.57 rounds to 2 and returns 128, 0.78 rounds to 1 and returns 64. I did not run the arithmetic for every row, only these two plus alpha 10 (7.84 rounds to 8, returns 204). At alpha 2 each channel has about three possible stored values, so there is nothing to recover. At alpha 0 every channel is zero, which is why the color vanishes completely.
Cutout PNGs hit this at the edge
In my three exports there was a ring of pixels at alpha 1 to a few dozen, and the color under the lowest of them was the old background: average RGB for alpha 1–5 was about (221, 218, 219) on the portrait, (230, 224, 218) on the product and (30, 31, 32) on the hair photo. Where alpha was 0, the portrait and the hair photo stored (0, 0, 0); I did not check the product one. A PNG file keeps those values as they are. A canvas does not.
So the color under low alpha is not reliable once an image has passed through a canvas. I would guess that is one source of the grey or bright fringe after resizing, though I did not isolate it. A second source I measured separately: scaling the four channels independently, without premultiplying, pulls that background color into the edge. On the portrait the edge band was off from a premultiplied baseline by 2.991 levels on average, and 13.83% of its pixels were off by more than 8 levels (this portrait, a 4× shrink and grow, one run).
Rules I would follow now
Do not use getImageData to learn what color a faint pixel "really" had. Keep the original file buffer if you need it, and treat the RGB under alpha below roughly 10 as noise. If a pipeline round-trips cutouts through a canvas, compare a few edge rows before and after, as in the table above, before trusting the output.
createImageBitmap with premultiplyAlpha: 'none' is not a clean fix either. On a tiny 8-pixel test it let the blue from a fully transparent area mix into the transition pixel, giving (128, 0, 128, 128). Safari and Firefox I did not test, so the whole table is Chromium 149 only.
Top comments (0)