My first job was three years of codec work at an audio/video SDK company, and the rule I took from it is blunt: any stage that makes a still pixel come out differently in the next frame is a bug for inter-frame coding. Dithering a GIF can be that kind of stage. I'd also read that frame differencing halves a GIF. So I measured both claims on one real clip, and the answer depended on which dither I used.
Frame differencing saved nothing until I allowed loss
The clip is a cat flicking its tail, 320×240, 208 frames, a low-light phone video turned into a 3,728,810-byte GIF (Mateus2019 / Wikimedia Commons, CC BY-SA 4.0). I re-encoded it in Python 3.9 with Pillow 11.3 on an M4 Mac, using one shared 255-color palette and three layouts: full frames, change rectangles, and rectangles with unchanged pixels made transparent, keeping the smaller per frame. Sensor noise kills the lossless case. Only 13.7% of pixels keep their palette index from one frame to the next, and the best layout came out at 100% of full frames. Everything below uses a tolerance of 16: any pixel within 16 levels per channel of the previous frame copies it. That is lossy, and it dropped PSNR against the source, sampled on every fourth frame, from 44.53 dB to 33.00 dB. In exchange, 86.3% of pixels became identical and the diffed file fell to 25.2% of full frames at the same tolerance (about 43% of the lossless full-frame file, since tolerance also inflates the full-frame baseline). My LZW encoder is worse than the one that made the original file, so I only compare ratios within my own runs.
Floyd–Steinberg nearly doubled the diffed file
Dither is a switch that browser tools expose too. Before running my own encoder I opened the clip in ImgIng's animation workshop (https://imging.ai/) in desktop Chromium 149. With GIF output selected, its dither setting offers Floyd–Steinberg or off, off by default, next to a frame-diff versus full-frame option. How it orders dithering against the diff I can't tell from the outside. The workshop runs only in desktop browsers, and Safari can't use it to compress an existing animated GIF. In my own runs I kept the palette and tolerance fixed and changed only the dither, quantizing with Pillow's quantize(palette=...). Without dither the frame-diffed GIF was 3,138,114 bytes. Floyd–Steinberg made it 6,004,213, which is 91% bigger. An 8×8 ordered (Bayer) dither made it 3,597,688, only 15% bigger. At tolerance 8 the Floyd–Steinberg penalty was 44%.
Read the left panel first. Ordered dither keeps exactly as many identical pixels as no dither (86.3%), while Floyd–Steinberg drops to 73.8%, and the right panel's blue bars follow that loss. Sizes are in MB of 1024×1024 bytes. Chart from my runs on the cat GIF by Mateus2019 / Wikimedia Commons, CC BY-SA 4.0; the chart is shared under the same license.
Error diffusion moves pixels that did not change
Ordered dither adds a threshold that depends only on pixel position, so the same color at the same spot gets the same index every frame. Floyd–Steinberg pushes each pixel's rounding error right and down, so a pixel's index also depends on everything scanned before it, as long as there is rounding error to push. I measured that directly. In the loop below, stable holds the frames after the tolerance-16 freeze and to_index maps one frame onto the shared palette with the given dither.
for mode in ("none", "ordered", "fs"):
idx = [to_index(f, mode) for f in stable]
flipped = []
for prev, cur, a, b in zip(stable, stable[1:], idx, idx[1:]):
still = (prev == cur).all(axis=2) # RGB identical to the previous frame
flipped.append((a[still] != b[still]).mean())
print(f"{mode:8s} still pixels whose index changed: {np.mean(flipped):5.1%}")
Averaged per frame, it printed 0.0% for no dither, 0.0% for ordered and 14.6% for Floyd–Steinberg. Then I nudged one pixel of frame 60 by 6 levels. Without dither or with ordered dither, one index changed. With Floyd–Steinberg, 65 changed, spread from x 34 to 54 and from that row down to y 92, never above it.
The large white shape is the part of the cat that actually moved, and it looks the same in all three panels. Compare the background: left and middle are mostly black, while the right panel is covered in white specks where nothing moved. Derived from the cat GIF by Mateus2019 / Wikimedia Commons, CC BY-SA 4.0, shared under the same license.
Ordered dither pays in image quality
At tolerance 16, PSNR was 33.00 dB without dither, 33.11 dB with Floyd–Steinberg and 27.78 dB with ordered. Its full-frame file was also the largest of the three, so the Bayer pattern costs bytes in every frame and frame diffing only hides that. I set its amplitude to 32 levels and never tuned it; a weaker pattern would likely win some PSNR back, but I haven't run it. I also can't explain why, on the unfrozen frame, the one-pixel nudge spread to only 4 pixels under Floyd–Steinberg.
Dither has to stay stable before diffing
One condition sits under all of the Floyd–Steinberg numbers. This clip turns out to use only 254 distinct colors, so my 255-color palette covers every one of them. When I mapped colors with an exact nearest-color search, Floyd–Steinberg had no error to spread and produced the same bytes as no dither. The 73.8% and +91% above come from Pillow's quantizer with this palette, which I assume picks colors approximately; I haven't read its source. So the penalty shows up when the palette can't hold the source colors or the color lookup itself is inexact, which I'd expect to be common with real footage in 256 colors, but it isn't automatic.
In my pipeline I now treat dithering as part of frame-stability design, decided before the diff stage. Noisy footage gets no dither or ordered dither, and Floyd–Steinberg stays on only for static images where nothing gets reused. Off by default, as in the workshop above, is the setting I would pick for noisy clips too. For your own encoder, run the still-pixel check above on two frozen frames; anything above 0% means some stage is not frame-stable.

Top comments (0)