Skip to content

Commit d09456f

Browse files
matmenclaude
andcommitted
PNG encoder: leave rows unfiltered where that compresses better
The paeth filter shrinks photos by about a third, but graphics often compress better unfiltered, since repeated shapes stay the same bytes: the benchmark's graphic was 20% larger paeth filtered (25% at level 6), potato.png 36-42%. 32 rows of every 512 are compressed both ways at the target level and the smaller wins (images of at most 32 rows: the smaller whole). Graphics get 17-29% smaller and faster, photos are unchanged and take about 8% longer. encode() and the APNG frames share compressRows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lfv41PqHEGbU7SLg5onpG1
1 parent 343825d commit d09456f

2 files changed

Lines changed: 44 additions & 31 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@
1414
block and its quarters cost about the same: files about 2% smaller at the default effort, encoding about 20% slower.
1515
- AVIF encoding with `effort: 2` also chooses smaller transforms within blocks (transform depth): files about 1% smaller
1616
(up to 4% for graphics), encoding about 10% slower.
17+
- PNG (and APNG) encoding leaves the rows unfiltered when that compresses better than the paeth filter, as it often
18+
does for graphics and screenshots: their files are up to 30% smaller (and encoded faster), decided by compressing
19+
samples of the rows both ways. Photos stay as they were, encoding them takes about 8% longer.
1720

1821
### Performance
1922

‎src/codecs/png.ts‎

Lines changed: 41 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -65,15 +65,6 @@ const ADAM7 = [[0, 0, 8, 8], [4, 0, 8, 8], [0, 4, 4, 8], [2, 0, 4, 4], [0, 2, 2,
6565
* @param data Raw pixel data, rows packed MSB first for depths below 8
6666
*/
6767
export async function encode(data: Uint8Array, { text, width, height, channels, depth = 8, level = 0, palette, transparency, filter: filterRows = false }: EncodeOptions): Promise<Uint8Array> {
68-
const rowBytes = Math.ceil(width * channels * depth / 8);
69-
let filtered: Uint8Array;
70-
if (filterRows) filtered = filter(data, rowBytes, height, Math.max(1, channels * depth / 8));
71-
else {
72-
// prefix every row with filter type 0 (none)
73-
filtered = new Uint8Array(height * (rowBytes + 1));
74-
for (let y = 0; y < height; y++) filtered.set(data.subarray(y * rowBytes, (y + 1) * rowBytes), y * (rowBytes + 1) + 1);
75-
}
76-
7768
const header = new Uint8Array(13);
7869
const view = new DataView(header.buffer);
7970
view.setUint32(0, width);
@@ -85,7 +76,7 @@ export async function encode(data: Uint8Array, { text, width, height, channels,
8576
if (palette) chunks.push(chunk(PLTE, palette));
8677
if (transparency) chunks.push(chunk(tRNS, transparency));
8778
chunks.push(...textChunks(text));
88-
chunks.push(chunk(IDAT, await compressAsync(filtered, level)));
79+
chunks.push(chunk(IDAT, await compressRows(data, width, height, channels, depth, level, filterRows)));
8980

9081
chunks.push(chunk(IEND, new Uint8Array(0)));
9182
return concat([HEAD, ...chunks]);
@@ -106,15 +97,12 @@ function textChunks(text: EncodeOptions['text']): Uint8Array[] {
10697
return chunks;
10798
}
10899

109-
/**
110-
* Filters every row with the paeth filter. Picking a filter per row (libpng's heuristic) costs twice as much and
111-
* compresses within 0.1% of it for photos, graphics and gradients alike
112-
*/
113-
function filter(data: Uint8Array, rowBytes: number, height: number, bpp: number): Uint8Array {
114-
const out = new Uint8Array(height * (rowBytes + 1));
100+
/** Filters rows y0 to y1 with the paeth filter */
101+
function filter(data: Uint8Array, rowBytes: number, y0: number, y1: number, bpp: number): Uint8Array {
102+
const out = new Uint8Array((y1 - y0) * (rowBytes + 1));
115103

116-
for (let y = 0; y < height; y++) {
117-
const row = y * rowBytes, o = y * (rowBytes + 1) + 1;
104+
for (let y = y0; y < y1; y++) {
105+
const row = y * rowBytes, o = (y - y0) * (rowBytes + 1) + 1;
118106
out[o - 1] = 4;
119107

120108
// the first row has no row above, the first pixel of every row no left neighbour
@@ -136,6 +124,39 @@ function filter(data: Uint8Array, rowBytes: number, height: number, bpp: number)
136124
return out;
137125
}
138126

127+
/** Prefixes rows y0 to y1 with filter type 0 (none) */
128+
function prefix(data: Uint8Array, rowBytes: number, y0: number, y1: number): Uint8Array {
129+
const out = new Uint8Array((y1 - y0) * (rowBytes + 1));
130+
for (let y = y0; y < y1; y++) out.set(data.subarray(y * rowBytes, (y + 1) * rowBytes), (y - y0) * (rowBytes + 1) + 1);
131+
return out;
132+
}
133+
134+
const SAMPLE_ROWS = 32, SAMPLE_STEP = 512;
135+
136+
/**
137+
* Filters (or just prefixes) the rows and compresses them. Filtered rows are paeth filtered, or not at all if that
138+
* compresses better: photos shrink by about a third filtered, but graphics often compress better unfiltered (their
139+
* repeated shapes stay the same bytes), by up to 40%. Decided by compressing samples both ways, 32 rows of every 512
140+
* (about 8% more time); picking a filter per row (libpng's heuristic) costs twice as much and does worse for graphics
141+
*/
142+
async function compressRows(data: Uint8Array, width: number, height: number, channels: number, depth: number, level: number, filterRows: boolean): Promise<Uint8Array> {
143+
const rowBytes = Math.ceil(width * channels * depth / 8), bpp = Math.max(1, channels * depth / 8);
144+
if (!filterRows) return compressAsync(prefix(data, rowBytes, 0, height), level);
145+
// (small images: the smaller of both)
146+
if (height <= SAMPLE_ROWS) {
147+
const [unfiltered, filtered] = await Promise.all([compressAsync(prefix(data, rowBytes, 0, height), level), compressAsync(filter(data, rowBytes, 0, height, bpp), level)]);
148+
return unfiltered.length < filtered.length ? unfiltered : filtered;
149+
}
150+
const unfilteredSamples: Uint8Array[] = [], filteredSamples: Uint8Array[] = [];
151+
for (let y = Math.min((SAMPLE_STEP - SAMPLE_ROWS) >> 1, (height - SAMPLE_ROWS) >> 1); y < height; y += SAMPLE_STEP) {
152+
const end = Math.min(height, y + SAMPLE_ROWS);
153+
unfilteredSamples.push(prefix(data, rowBytes, y, end));
154+
filteredSamples.push(filter(data, rowBytes, y, end, bpp));
155+
}
156+
const [unfiltered, filtered] = await Promise.all([compressAsync(concat(unfilteredSamples), level), compressAsync(concat(filteredSamples), level)]);
157+
return compressAsync(unfiltered.length < filtered.length ? prefix(data, rowBytes, 0, height) : filter(data, rowBytes, 0, height, bpp), level);
158+
}
159+
139160
function chunk(type: number, body: Uint8Array): Uint8Array {
140161
const out = new Uint8Array(body.length + 12);
141162
const view = new DataView(out.buffer);
@@ -281,8 +302,8 @@ const aligned = (rgba: Uint8Array): Uint8Array => rgba.byteOffset % 4 ? rgba.sli
281302

282303
/**
283304
* Encodes 8-bit RGBA pixels into a PNG with the smallest lossless color type (see {@link pixelFormat}).
284-
* Rows of non-palette images are paeth filtered (unless the data isn't compressed), palette indices aren't
285-
* filtered (like libpng recommends)
305+
* Rows of non-palette images are paeth filtered where that compresses better (unless the data isn't compressed),
306+
* palette indices aren't filtered (like libpng recommends)
286307
*/
287308
export async function encodeRGBA(rgba: Uint8Array, width: number, height: number, level: number, text?: EncodeOptions['text']): Promise<Uint8Array> {
288309
rgba = aligned(rgba.subarray(0, width * height * 4));
@@ -291,17 +312,6 @@ export async function encodeRGBA(rgba: Uint8Array, width: number, height: number
291312
return encode(format.convert(rgba, width, height), { width, height, level, text, channels, depth, palette, transparency, filter: level > 0 && !palette });
292313
}
293314

294-
// filters (or just prefixes) the rows and compresses them
295-
async function compressRows(data: Uint8Array, width: number, height: number, channels: number, depth: number, level: number, filterRows: boolean): Promise<Uint8Array> {
296-
const rowBytes = Math.ceil(width * channels * depth / 8);
297-
let filtered: Uint8Array;
298-
if (filterRows) filtered = filter(data, rowBytes, height, Math.max(1, channels * depth / 8));
299-
else {
300-
filtered = new Uint8Array(height * (rowBytes + 1));
301-
for (let y = 0; y < height; y++) filtered.set(data.subarray(y * rowBytes, (y + 1) * rowBytes), y * (rowBytes + 1) + 1);
302-
}
303-
return compressAsync(filtered, level);
304-
}
305315

306316
export interface APNGEncodeFrame {
307317
x: number;

0 commit comments

Comments
 (0)