Last week I was checking 256-colour exports of a transparent sticker, and on the dark canvas I started with, the file looked fine. On a white page the soft shadow had turned into a solid black band with an outer edge cut clean like paper. That evening, after my daughter was asleep, I turned the near miss into a check I can run on any transparent export. The samples are a pink ribbon sticker from Wikimedia Commons (by K B, CC BY 2.0), cropped to its top loop, and the melting face from Noto Color Emoji (Apache 2.0). Neither is my artwork. I used Pillow and NumPy on an Apple M4.
Count the alpha levels first
I look at alpha before colour. The ribbon uses all 256 alpha levels, and 225,652 of its pixels are semi-transparent, 17.9% of the image, nearly all in the shadow. The emoji has 17 levels and 1,548 soft pixels, 0.59%. The export I was checking had two alpha values left. Everything at 128 or above became opaque and everything below vanished, a plain threshold at 128 on every pixel. Of the old soft pixels, 63,769 turned solid and 161,883 disappeared. The shadow's dark core sits above that line and its faint fade below it. If the count drops to two on a file with a real soft share, I stop there.
A black background hides the damage
Top row is the original, bottom row a two-level copy, on white, black and #1976D2 blue. On black the rows look almost identical. The copy is my own threshold at 128, within 0.3 of the real export's error on every background. To score it, I composite source and candidate onto each background with straight alpha and compare only the pixels that were semi-transparent in the source.
BACKDROPS = {"white": (255, 255, 255), "black": (0, 0, 0), "blue": (25, 118, 210)}
def read_px(path):
return np.asarray(Image.open(path).convert("RGBA"), dtype=np.float64)
def over(px, bg):
alpha = px[..., 3:] / 255.0
return px[..., :3] * alpha + np.asarray(bg) * (1 - alpha)
def edge_report(src_path, out_path):
src, out = read_px(src_path), read_px(out_path)
edge = (src[..., 3] > 0) & (src[..., 3] < 255)
print(f"{out_path.split('/')[-1]}: alpha values {np.unique(src[..., 3]).size} -> "
f"{np.unique(out[..., 3]).size}, edge pixels {edge.mean():.1%}")
for name, bg in BACKDROPS.items():
diff = np.abs(over(out, bg) - over(src, bg)).mean(axis=-1)[edge]
print(f" on {name:5} mean {diff.mean():5.2f} over 24: {(diff > 24).mean():5.1%}")
ribbon-2-levels.png: alpha values 256 -> 2, edge pixels 17.9%
on white mean 44.17 over 24: 56.6%
on black mean 2.33 over 24: 3.4%
on blue mean 20.79 over 24: 41.0%
ribbon-q84.webp: alpha values 256 -> 256, edge pixels 17.9%
on white mean 0.61 over 24: 0.0%
on black mean 0.61 over 24: 0.0%
on blue mean 0.61 over 24: 0.0%
The mean is the average RGB difference on a 0 to 255 scale, and "over 24" is the share of edge pixels off by more than 24, a visibility line picked by hand. The two-level ribbon scores 44.17 on white and 2.33 on black, and that gap is why I nearly signed it off. A near-black shadow disappears into a black page, so a dark preview reports a clean file. I read the worst of the three backgrounds. The emoji has no dark shadow, so its two-level copy is bad everywhere: 29.51 on white, 37.80 on black, 39.06 on blue.
Indexed PNGs can store many alpha levels
I had half assumed two levels was a limit of the format. It isn't. An indexed PNG's tRNS chunk gives each palette entry its own alpha. I wrote a rough reducer of my own to test it, not pngquant or any product's code, so its sizes only compare with each other. The 34-level ribbon is still colour type 3 with 33 tRNS entries below 255, and a Chromium 149 open-source build, Firefox 151 and WebKit 26.5 (not a Safari release) read back the same levels and alpha sum as Pillow.
One patch of shadow at 4x: 10 levels show contour rings, 34 make the steps finer, 98 are close to the source. In my encoder the 34-level ribbon is 9.1% larger than its two-level twin, and the 20-level emoji 5.1% larger.
On white the error falls from 44.17 at two levels to 8.23 at 10 and 1.75 at 98. Dithering doesn't substitute for levels. On the two-level export it never touched alpha, white error stayed at 44.07 against 44.06, and files grew 1.6 to 2.1 times. On my 34-level copy it turned steps into grain, raised the white error from 3.48 to 4.29 and made the file 65% bigger.
Shadowed assets go to WebP
The export I was checking came from ImgIng, where my part is the on-device codecs and model loading. The 8-bit reduction isn't my code, so I judge it by its files. On 2026-09-30, version stamp 09.03, the default PNG-8 ribbon came out at 144.8 KB with two alpha levels, and the emoji also had two. Dithering off or 256 colours left alpha unchanged. The png-compress help page says its transparent PNG-8 supports multi-level alpha, and those exports did not match it. Shadowed assets skip PNG-8 for me now. For the WebP conversion I used ImgIng (https://imging.ai/) at its default quality 84 in the same Chromium build, and the ribbon went from 430.7 KB to 98.8 KB with alpha matching the source pixel for pixel, the 0.61 above. It ran in the tab, with zero non-GET requests. The emoji as WebP kept all 17 alpha levels, and its 4.02 error came from lossy colour. PNG-8 stays for screenshots and flat icons.
What the check cannot tell you
The mean is not a perceptual colour difference, and 24 is not a standard. Two samples cover only a thin antialiased rim and a large black shadow. Coloured shadows, glows, glassy art, low colour counts, pngquant and chat-app recompression are untested, and I only verified decoding in the three engines. In review I now count alpha levels against the source first, then run the report and read the worst background. Files with a large soft share go to WebP.


Top comments (0)