On palette PNGs, oxipng's filter re-choice saved 15% on one photo and cost 1.6% on another
Every PNG row starts with one byte that names a filter: None, Sub, Up, Average or Paeth. The filter turns pixel values into differences before deflate sees them. pngquant 3.0.3 writes None on every row of its output. Whe
Every PNG row starts with one byte that names a filter: None, Sub, Up, Average or Paeth. The filter turns pixel values into differences before deflate sees them. pngquant 3.0.3 writes None on every row of its output. When I ran oxipng 10.2.1 over those files and counted the bytes, the filters it re-chose made one photo 15.0% smaller and another 1.6% bigger, both compared with the same oxipng run held to None. The loss is easy to miss: that file still came out smaller than pngquant's output, so only splitting the pass shows it.
Everything below is one run on an Apple M4 Mac on 2026-11-05, in exact bytes, with oxipng at -o 4 --strip safe and pngquant at --quality=65-80 --speed 4. The main comparison uses three Wikimedia Commons photos saved as PNG, so read it as a description of three files. I did not score visual quality. oxipng is documented as lossless, but I did not diff pixels for these runs.
pngquant writes None on every row
To see filters per row I used an 18-line Python script with only the standard library. It joins the IDAT chunks, inflates them, steps through the rows using the width and bit depth from IHDR, and counts the first byte of each row. It appears as filters.py below.
Across the outputs I checked, pngquant used None everywhere: 512 rows of a 512Γ512 transparent icon, 901 rows of the Steve Selvin photo, 1,571 rows of a screenshot. The PNG spec says None usually works best for indexed-color images, because index 40 and index 41 need not be similar colors. On the icon and the screenshot, oxipng agreed and kept None on every row after its own pass. Only the photos ended up with anything other than None.
Two changes happen in one oxipng pass
By default oxipng is allowed to reduce the image, and for a palette PNG that includes reordering the palette. --nx turns those reductions off. -f 0 restricts the filter trials to None. Crossing the two flags gives four runs per image. Full output for the Steve Selvin photo:
$ oxipng -q -o 4 --strip safe --nx -f 0 --out photo-nx-f0.png photo-q.png
$ oxipng -q -o 4 --strip safe --nx --out photo-nx.png photo-q.png
$ oxipng -q -o 4 --strip safe -f 0 --out photo-sorted-f0.png photo-q.png
$ oxipng -q -o 4 --strip safe --out photo-sorted.png photo-q.png
$ python3 filters.py photo-q.png photo-nx-f0.png photo-nx.png photo-sorted-f0.png photo-sorted.png
photo-q.png: 417,199 bytes, {'None': 901}
photo-nx-f0.png: 380,655 bytes, {'None': 901}
photo-nx.png: 380,655 bytes, {'None': 901}
photo-sorted-f0.png: 380,551 bytes, {'None': 901}
photo-sorted.png: 386,812 bytes, {'Sub': 901}
The same four runs on all three photos:
| pngquant output, then oxipng | Alekszej (grey) | Directiva | Steve Selvin |
|---|---|---|---|
| pngquant alone, all None | 131,963 | 145,573 | 417,199 |
--nx -f 0: same palette, None |
118,264 | 134,882 | 380,655 |
--nx: same palette, filters tried |
118,264, all None | 134,882, all None | 380,655, all None |
-f 0: palette reordered, None |
117,941 | 134,491 | 380,551 |
| default: reordered, filters tried | 100,267, mixed | 125,889, mixed | 386,812, all Sub |
With the palette left in pngquant's order, oxipng tried its filters and still picked None on every row, so that gain is mostly deflate work. Reordering the palette with None kept saves very little: 323, 391 and 104 bytes. The large move is the last row, where the reordered palette and the filter trials act together. On Alekszej the rows went to Sub 15, Up 502, Average 229, Paeth 218 and the file dropped 17,674 bytes (15.0%). On Directiva it dropped 8,602 bytes (6.4%). On Steve Selvin oxipng put Sub on all 901 rows and the file grew 6,261 bytes (1.6%) compared with the reordered palette and plain None.
My guesses, kept apart from the numbers
None of this is verified. First guess: the reorder is what makes filters useful on a palette image. If the palette ends up roughly sorted by brightness, neighboring indices become neighboring shades and Up or Paeth start predicting something real. Alekszej is a grey photo with only 222 distinct colors in the source, which is close to the best case for this, and it gained the most. Steve Selvin is a color photo, and no single ordering of 232 colors can keep all similar shades next to each other, so the prediction stays noisy.
The second guess concerns the wrong pick on Steve Selvin. My understanding is that oxipng ranks filters with fast trial compressions and then compresses for real, and the two rankings can disagree. I have not checked its source. I also did not run the split on the six transparent images or the three screenshots, so I can't say whether a loss like this shows up there too.
What I would put in a pipeline
Since the pixels never change, nothing forces a pipeline to take one oxipng result on faith. A build step can write both the default run and the --nx run to temporary files, keep whichever is smaller, and drop the other. On these three photos that keeps the 15.0% and 6.4% wins and turns the Steve Selvin loss into a 6,157-byte saving. For a large product-image library I would first measure on a sample how often the two disagree. For a quick check in a desktop browser I use the image compression in ImgIng https://imging.ai/ , which also cuts colors and then recompresses on the local machine. None of its output appears in this post.
A row-filter counter like the one above shows whether a palette PNG in a repo still has None on every row, which makes it a candidate for the two-run check. Three photos cannot tell how often the trial loses, so I have not turned this into a rule.
Sample images from Wikimedia Commons: Alekszej Fedoricsev by Javelin Bayraktar (CC0); Directiva junto a presidente APPA by JaimeRuix (CC BY-SA 4.0); Steve Selvin by UC Berkeley Public Health (CC BY 4.0); Afro Mobile Icon by Ben plc (public domain); The Book screenshot by Stefan KΓΌhn (CC0).
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.