DEV Community

yue xing
yue xing

Posted on

Making unchanged pixels transparent made my GIF 25% bigger

For a multimedia course assignment I'm writing GIF frames byte by byte, and tutorials keep repeating one trick: store only the rectangle that changed since the last frame, then mark the unchanged pixels inside it as transparent so the previous frame shows through. Some posts say this halves the file. I tried it on a running-horse GIF and the file came out 25% bigger than just storing the rectangles. That second step is the part I had misunderstood.

The rectangle was mostly pixels that had not changed

My main test file is a cartoon horse from Wikimedia Commons: 307×230, 8 frames, 16 colors. I first opened it and three other samples in ImgIng (https://imging.ai/), the browser tool I use for GIFs, in Chromium 149 on an M4 Mac, to check the frame timings it read from the files. The horse's original file already stores 7 of its 8 frames as partial rectangles.

I re-encoded it with Python 3.9 and Pillow 11.3.0: one shared 255-color palette with the last slot as the transparent color, my own headers, Pillow's LZW for the pixels. Every output decoded back pixel-identical. My encoder is worse than whatever made the original file, so I only compare sizes within my own runs.

The change rectangle covered 71.9% to 77.3% of the frame, yet only 11.2% to 14.2% of the pixels actually changed. Inside the box, 81% to 85% of the pixels were identical to the previous frame. On paper that's a lot of pixels I'd never have to store again.

Frames 2 to 4 of the horse GIF: blue box is the change rectangle, red marks the pixels that really changed

Compare the white background and brown body inside each blue box with the thin red outlines. Tail and nose both moved, so the box drags in everything between them. Horse: Janke / Wikimedia Commons, CC BY-SA 2.5; this annotated version is shared under CC BY-SA 2.5.

Turning those pixels transparent cost 25 percent

Three versions, same palette, identical decoded frames:

How each frame is stored Bytes vs full frames
Full frame every time 27,733 100%
Change rectangle only 27,051 97.5%
Rectangle, unchanged pixels transparent 33,935 122.4%

Against rectangle-only that's +25%, and all 7 frames after the first got bigger. Here is the per-frame check, where idx is the list of palette-index frames and encode_lzw wraps Pillow's encoder:

cur, prev = idx[n], idx[n - 1]
diff = cur != prev
rr, cc = np.nonzero(diff)
box = (slice(rr.min(), rr.max() + 1), slice(cc.min(), cc.max() + 1))
kept = cur[box].copy()
holed = kept.copy()
holed[~diff[box]] = 255                  # 255 = transparent index
for label, block in (("rect      ", kept), ("rect+holes", holed)):
    flat = block.ravel()
    switches = int((flat[1:] != flat[:-1]).sum())
    print(f"frame {n} {label} {switches:5d} switches {len(encode_lzw(block)):5d} bytes")
Enter fullscreen mode Exit fullscreen mode
frame 1 rect        3227 switches  3109 bytes
frame 1 rect+holes  4149 switches  4013 bytes
frame 2 rect        3294 switches  3173 bytes
frame 2 rect+holes  4151 switches  4061 bytes
frame 3 rect        3736 switches  3432 bytes
frame 3 rect+holes  4617 switches  4346 bytes
Enter fullscreen mode Exit fullscreen mode

Frame numbers start at 0, so frame 1 is the second image.

My guess is that the holes chop up long runs

LZW learns sequences of palette indexes it has already seen and gives repeats a short code, so long stretches of one color are cheap. The horse has plenty of those: flat white background and a flat brown body. The white mostly just turns into equally long runs of index 255. The trouble is at the edges. Take a pixel that moved from white to brown, sitting next to a pixel that was brown in both frames. In the plain rectangle they're one brown run. With holes, the unchanged neighbour becomes 255, and a new boundary appears right there. The moving legs and mane are covered in such boundaries.

So I counted how often the index changes when you read each rectangle row by row. Over the 7 frames after the first, switches went from 24,192 to 30,859 (+27.6%), and the average run shrank from 15.03 to 11.79 pixels. That lines up with the size jump, but it's a correlation. I didn't open up the LZW dictionary to prove where the extra bytes go, and I'm not sure how much of it is the extra boundaries and how much is the new 255 symbol itself.

Panning and noisy footage saved nothing

I made a 240×180, 40-frame clip that pans a window across the first frame of a cat GIF, 2 px right and 1 px down per frame. The rectangle was the whole frame every time, the rectangle-only file came out the same size as full frames, and marking unchanged pixels transparent added 23% (985,912 vs 800,084 bytes). All 39 frames after the first got bigger. The real cat GIF (320×240, 208 frames, phone footage in dim light) saved 0% too: with sensor noise only 13.7% of pixels match between neighbouring frames.

Changed-pixel maps between neighbouring frames, white means changed

Left is the pan clip, middle is the cat compared exactly, right is the cat when a pixel within 16 levels of the previous frame counts as unchanged. Only the right panel has big dark areas worth reusing. Cat: Mateus2019 / Wikimedia Commons, CC BY-SA 4.0; the pan clip is cut from it, and both maps are shared under CC BY-SA 4.0.

That right panel is where a saving bigger than half finally showed up: with that tolerance the transparent version was 25.2% of full frames. Those full frames had grown too, so against the lossless full-frame file it was about 43%. But it isn't lossless anymore. Quality against the original dropped from 44.53 dB to 33.00 dB PSNR.

What I changed in my encoder

Now I encode each frame both ways and keep the smaller one. On the horse that picked plain rectangles every time: 27,051 bytes. Without letting pixels drift, the best saving I got from frame differencing on these samples was 2.5%. I haven't tried a screen recording where most of the window stays perfectly still. My guess is that's where the holes pay off, since the unchanged parts would be big solid blocks instead of slivers next to moving edges.

Top comments (0)