[Bug] PILLOW_PRESERVE_INDEXED_MODE drops transparency on GIF output from a palette source
Thumbor Request URL
http://example.com/unsafe/100x100/transparent.gif
Expected Behaviour
The transparent pixels of a palette source should still be transparent after a resize. Requesting a GIF through thumbor should give back a GIF with its transparency index intact, the way the same request does for PNG or WebP output.
Actual Behaviour
The GIF comes back fully opaque and the formerly transparent pixels render black.
None of the fixtures in tests/fixtures/images carries a transparency index, so here is a generated source. It is a 200x200 GIF, top half opaque red, bottom half fully transparent:
from PIL import Image
im = Image.new("P", (200, 200))
im.putpalette([255, 0, 0] + [0, 0, 0] * 255)
im.paste(1, (0, 100, 200, 200))
im.save("transparent.gif", transparency=1)Serve that and ask for a plain /unsafe/100x100/ resize, no filters:
PILLOW_PRESERVE_INDEXED_MODE = True (the default)
out: GIF info['transparency']=None fully transparent px = 0 / 10000
PILLOW_PRESERVE_INDEXED_MODE = False
out: GIF info['transparency']=1 fully transparent px = 4800 / 10000Comparing the two renders pixel by pixel, the pixels that are transparent in the second are all (0, 0, 0, 255) in the first.
I mapped which combinations are affected, since it is narrower than I first assumed. Five sources, each 200x200 with the same half-transparent layout, through four output formats:
PILLOW_PRESERVE_INDEXED_MODE = True (the default)
source native gif png webp
GIF (P + index) LOST LOST kept kept
PNG-8 (P + index) kept LOST kept kept
PNG-8 (P + tRNS palette) kept LOST kept kept
PNG-32 (RGBA) kept kept kept kept
WebP (RGBA) kept kept kept kept
PILLOW_PRESERVE_INDEXED_MODE = False
every cell above is keptSo it takes all three of a palette source, GIF output, and the setting at its default. A truecolour source is fine even into GIF, and PNG output is fine even from a palette source.
I think it might be caused by the quantize() call in the PILLOW_PRESERVE_INDEXED_MODE branch of PILEngine.read(), since that is what the affected cells have in common and turning the setting off avoids it. But I do not know the codebase well enough to say whether that is where it should be handled, and the PNG column suggests the writer matters too, since the same branch runs there without losing anything.
I have no fix to propose, I work at the PHP integration layer rather than in Pillow. Our workaround is PILLOW_PRESERVE_INDEXED_MODE = False, which costs some file size (Pillow's own quantization instead of libimagequant) but keeps the alpha.
One note if you add a regression fixture: paletted-transparent.png is not a good candidate. It carries 16083 partially transparent pixels, and GIF has only 1 bit of alpha, so it comes back opaque under both settings and cannot show the difference. A source with binary alpha, like the generated one above, separates them cleanly.
Operating System
Official Docker image ghcr.io/thumbor/thumbor:latest (Debian 11 bullseye, thumbor 7.7.7, Pillow 10.4.0), host macOS 15.7.3.
Your thumbor.conf
SECURITY_KEY = "dummy-key"
ALLOW_UNSAFE_URL = True
LOADER = "thumbor.loaders.http_loader"
# PILLOW_PRESERVE_INDEXED_MODE left at its True default for the failing case,
# set to False for the working one. Nothing else is set.Source: thumbor/thumbor