Skip to content

Commit 7d17519

Browse files
matmenclaude
andcommitted
faster PNG decoding and encoding
- The paeth predictor is computed without branches (bit masks pick a, b or c), in unfilter and filter: photos make the branches unpredictable. - 8-bit RGB images are converted to RGBA without the generic per-sample functions, interlaced 8-bit RGBA without a subarray per pixel. - Encoding: the pixel format scan stops once nothing is left to find out (no palette, not grey, not opaque), the conversion to RGB, grey and grey with alpha has a loop for each. The results are bit-identical: decoding, every encoder and animations checked against master on 1593 images. A 1920×1080 photo decodes about 25% faster (PNG written by ImageScript) and encodes about 10% faster. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ
1 parent bbeab78 commit 7d17519

2 files changed

Lines changed: 49 additions & 15 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010
TGA, text chunks for PNG and APNG. Like PNGs, all files name ImageScript and its version as the software and
1111
include the creation time by default; `{software: ''}` leaves the software out.
1212

13+
### Performance
14+
15+
- PNG decoding is about 25% faster and encoding about 10% faster, with identical results: the paeth filter is
16+
computed without branches, 8-bit RGB images are converted without per-sample function calls.
17+
1318
### Deprecated
1419

1520
- **`PNGMetadata`**: renamed to `Metadata`, the old name stays an alias until 4.0.

‎src/codecs/png.ts‎

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,9 @@ function filter(data: Uint8Array, rowBytes: number, height: number, bpp: number)
125125
for (let i = bpp; i < rowBytes; i++) {
126126
const a = data[row + i - bpp], b = data[row + i - rowBytes], c = data[row + i - rowBytes - bpp];
127127
const pa = Math.abs(b - c), pb = Math.abs(a - c), pc = Math.abs(a + b - 2 * c);
128-
out[o + i] = data[row + i] - (pa <= pb && pa <= pc ? a : pb <= pc ? b : c);
128+
// the predictor without branches, see unfilter
129+
const notA = (pb - pa | pc - pa) >> 31, takeC = (pc - pb) >> 31;
130+
out[o + i] = data[row + i] - (a ^ (a ^ b ^ (b ^ c) & takeC) & notA);
129131
}
130132
}
131133

@@ -160,7 +162,7 @@ function pixelFormat(images: Uint8Array[]): PixelFormat {
160162

161163
// collect up to 257 distinct colors, runs of equal pixels are skipped cheaply
162164
let colors: Map<number, number> | null = new Map();
163-
for (const rgba of images) {
165+
scan: for (const rgba of images) {
164166
const pixels = new Uint32Array(rgba.buffer, rgba.byteOffset, rgba.length >> 2);
165167
let previous = -1;
166168
for (let i = 0; i < pixels.length; i++) {
@@ -175,6 +177,8 @@ function pixelFormat(images: Uint8Array[]): PixelFormat {
175177
if (colors.size === 256) colors = null;
176178
else colors.set(value, colors.size);
177179
}
180+
// nothing left to find out: RGBA it is
181+
if (!colors && !opaque && !grey) break scan;
178182
}
179183
}
180184

@@ -222,16 +226,18 @@ function pixelFormat(images: Uint8Array[]): PixelFormat {
222226
if (channels === 4) return rgba;
223227
const count = width * height;
224228
const data = new Uint8Array(count * channels);
225-
for (let i = 0, o = 0; i < count; i++) {
226-
const s = i * 4;
227-
if (grey) data[o++] = rgba[s];
228-
else {
229-
data[o++] = rgba[s];
230-
data[o++] = rgba[s + 1];
231-
data[o++] = rgba[s + 2];
229+
if (channels === 3) {
230+
for (let s = 0, o = 0; o < data.length; s += 4, o += 3) {
231+
data[o] = rgba[s];
232+
data[o + 1] = rgba[s + 1];
233+
data[o + 2] = rgba[s + 2];
232234
}
233-
if (!opaque) data[o++] = rgba[s + 3];
234-
}
235+
} else if (channels === 2) {
236+
for (let s = 0, o = 0; o < data.length; s += 4, o += 2) {
237+
data[o] = rgba[s];
238+
data[o + 1] = rgba[s + 3];
239+
}
240+
} else for (let s = 0, o = 0; o < data.length; s += 4, o++) data[o] = rgba[s];
235241
return data;
236242
}
237243
};
@@ -714,7 +720,9 @@ function unfilter(data: Uint8Array, offset: number, height: number, rowBytes: nu
714720
for (let i = row + bpp; i < row + rowBytes; i++) {
715721
const a = out[i - bpp], b = out[i - rowBytes], c = out[i - rowBytes - bpp];
716722
const pa = Math.abs(b - c), pb = Math.abs(a - c), pc = Math.abs(a + b - 2 * c);
717-
out[i] = data[src + i] + (pa <= pb && pa <= pc ? a : pb <= pc ? b : c);
723+
// without branches (photos make them unpredictable): all ones when a loses to b or c, when c beats b
724+
const notA = (pb - pa | pc - pa) >> 31, takeC = (pc - pb) >> 31;
725+
out[i] = data[src + i] + (a ^ (a ^ b ^ (b ^ c) & takeC) & notA);
718726
}
719727
}
720728
} else throw new Error(`PNG: invalid filter type ${filter}`);
@@ -791,6 +799,22 @@ function createConverter(colorType: number, depth: number, palette: Uint8Array |
791799
? [transparency[0] << 8 | transparency[1], transparency[2] << 8 | transparency[3], transparency[4] << 8 | transparency[5]].map(v => v & maxValue)
792800
: null;
793801

802+
// 8-bit samples are bytes that need no scaling, read directly (the most common kind of opaque PNG)
803+
if (depth === 8) {
804+
const [kr, kg, kb] = key || [-1, -1, -1];
805+
return (pixels, rowBytes, w, h, out, place) => {
806+
for (let y = 0; y < h; y++) {
807+
for (let x = 0, s = y * rowBytes, o = place.row(y), step = place.step; x < w; x++, s += 3, o += step) {
808+
const r = pixels[s], g = pixels[s + 1], b = pixels[s + 2];
809+
out[o] = r;
810+
out[o + 1] = g;
811+
out[o + 2] = b;
812+
out[o + 3] = r === kr && g === kg && b === kb ? 0 : 0xff;
813+
}
814+
}
815+
};
816+
}
817+
794818
return (pixels, rowBytes, w, h, out, place) => {
795819
for (let y = 0; y < h; y++) {
796820
const row = pixels.subarray(y * rowBytes, (y + 1) * rowBytes);
@@ -828,9 +852,14 @@ function createConverter(colorType: number, depth: number, palette: Uint8Array |
828852
// non-interlaced 8-bit RGBA maps 1:1 onto the output
829853
if (place.step === 4 && w * 4 === rowBytes && place.row(0) === 0 && place.row(1) === rowBytes) return out.set(pixels);
830854

831-
for (let y = 0; y < h; y++)
832-
for (let x = 0, o = place.row(y); x < w; x++, o += place.step)
833-
out.set(pixels.subarray(y * rowBytes + x * 4, y * rowBytes + x * 4 + 4), o);
855+
for (let y = 0; y < h; y++) {
856+
for (let x = 0, s = y * rowBytes, o = place.row(y), step = place.step; x < w; x++, s += 4, o += step) {
857+
out[o] = pixels[s];
858+
out[o + 1] = pixels[s + 1];
859+
out[o + 2] = pixels[s + 2];
860+
out[o + 3] = pixels[s + 3];
861+
}
862+
}
834863
};
835864
}
836865

0 commit comments

Comments
 (0)