Skip to content

Commit 1a56e22

Browse files
matmenclaude
andcommitted
faster lossless WebP: predictors per mode, cost table, hashes once
- predictRun predicts (encoding) or restores (decoding) a run of pixels with one loop per mode, instead of predict() with a switch per pixel. The encoder's mode search and the decoder's inverse predictor use it. - residual costs come from a 256-entry table instead of branches. - tokenize hashes every pixel pair once instead of on search and insert. Output is identical (VP8L data of 1298 corpus encodes, pixels of 153 WebP decodes). 1920x1080 photo: encoding 1220 -> 735 ms, decoding 95 -> 82 ms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YJgRK5qpwJEbHPuFv6kgAQ
1 parent ecb3317 commit 1a56e22

2 files changed

Lines changed: 63 additions & 44 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,16 @@
99
`fast: false` keeps the more precise results of 3.2.1.
1010
- Opaque images: halving a 1920×1080 photo takes 12 instead of 15 ms, doubling it 61 instead of 80 ms. About 10-25%
1111
of the channel values differ from 3.2.1, by at most 1.
12-
- Transparent images are premultiplied to 8 bits, like browsers do: halving takes 24 instead of 43 ms, doubling 119
12+
- Transparent images are premultiplied to 8 bits, like browsers do: halving takes 24 instead of 43 ms, doubling 103
1313
instead of 260 ms. What shows once they're composited differs from 3.2.1 by up to about 2 steps (alpha by at most 2),
1414
the color of pixels that are almost transparent can differ more.
1515

16+
### Performance
17+
18+
- Lossless WebP encoding is about 40% faster and decoding about 15% faster (1920×1080 photo: 735 instead of 1220 ms,
19+
82 instead of 95 ms), with identical output: the predictors run as one loop per mode instead of a switch per pixel,
20+
residual costs come from a table, and the LZ77 search hashes every pixel pair once.
21+
1622
## 3.2.1
1723

1824
### Performance

‎src/codecs/vp8l.ts‎

Lines changed: 56 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -370,24 +370,28 @@ function select(l: number, t: number, tl: number): number {
370370
// adds two ARGB pixels per channel, modulo 256
371371
const addPixels = (a: number, b: number): number => ((a & 0xff00ff00) + (b & 0xff00ff00) & 0xff00ff00 | (a & 0x00ff00ff) + (b & 0x00ff00ff) & 0x00ff00ff) >>> 0;
372372

373-
function predict(mode: number, p: Uint32Array, i: number, width: number): number {
374-
const l = p[i - 1], t = p[i - width], tl = p[i - width - 1], tr = p[i - width + 1];
373+
// predictions of `n` pixels from index i on (not in the first row or column) into out, from the pixels in p; with
374+
// out === p, every pixel is the residual plus its prediction from the pixels already restored (decoding)
375+
function predictRun(mode: number, p: Uint32Array, i: number, n: number, width: number, out: Uint32Array, o: number): void {
376+
const restore = out === p;
377+
const end = i + n;
378+
// the switch runs once per run instead of once per pixel
375379
switch (mode) {
376-
case 0: return 0xff000000;
377-
case 1: return l;
378-
case 2: return t;
379-
case 3: return tr;
380-
case 4: return tl;
381-
case 5: return average2(average2(l, tr), t);
382-
case 6: return average2(l, tl);
383-
case 7: return average2(l, t);
384-
case 8: return average2(tl, t);
385-
case 9: return average2(t, tr);
386-
case 10: return average2(average2(l, tl), average2(t, tr));
387-
case 11: return select(l, t, tl);
388-
case 12: return clampAddSubtractFull(l, t, tl);
389-
case 13: return clampAddSubtractHalf(average2(l, t), tl);
390-
default: return 0xff000000; // modes 14 and 15 aren't defined, libwebp predicts black like mode 0
380+
case 1: for (; i < end; i++, o++) out[o] = restore ? addPixels(p[i], p[i - 1]) : p[i - 1]; return;
381+
case 2: for (; i < end; i++, o++) out[o] = restore ? addPixels(p[i], p[i - width]) : p[i - width]; return;
382+
case 3: for (; i < end; i++, o++) out[o] = restore ? addPixels(p[i], p[i - width + 1]) : p[i - width + 1]; return;
383+
case 4: for (; i < end; i++, o++) out[o] = restore ? addPixels(p[i], p[i - width - 1]) : p[i - width - 1]; return;
384+
case 5: for (; i < end; i++, o++) { const v = average2(average2(p[i - 1], p[i - width + 1]), p[i - width]); out[o] = restore ? addPixels(p[i], v) : v; } return;
385+
case 6: for (; i < end; i++, o++) { const v = average2(p[i - 1], p[i - width - 1]); out[o] = restore ? addPixels(p[i], v) : v; } return;
386+
case 7: for (; i < end; i++, o++) { const v = average2(p[i - 1], p[i - width]); out[o] = restore ? addPixels(p[i], v) : v; } return;
387+
case 8: for (; i < end; i++, o++) { const v = average2(p[i - width - 1], p[i - width]); out[o] = restore ? addPixels(p[i], v) : v; } return;
388+
case 9: for (; i < end; i++, o++) { const v = average2(p[i - width], p[i - width + 1]); out[o] = restore ? addPixels(p[i], v) : v; } return;
389+
case 10: for (; i < end; i++, o++) { const v = average2(average2(p[i - 1], p[i - width - 1]), average2(p[i - width], p[i - width + 1])); out[o] = restore ? addPixels(p[i], v) : v; } return;
390+
case 11: for (; i < end; i++, o++) { const v = select(p[i - 1], p[i - width], p[i - width - 1]); out[o] = restore ? addPixels(p[i], v) : v; } return;
391+
case 12: for (; i < end; i++, o++) { const v = clampAddSubtractFull(p[i - 1], p[i - width], p[i - width - 1]); out[o] = restore ? addPixels(p[i], v) : v; } return;
392+
case 13: for (; i < end; i++, o++) { const v = clampAddSubtractHalf(average2(p[i - 1], p[i - width]), p[i - width - 1]); out[o] = restore ? addPixels(p[i], v) : v; } return;
393+
// mode 0, and modes 14 and 15 (not defined, libwebp predicts black like mode 0)
394+
default: for (; i < end; i++, o++) out[o] = restore ? addPixels(p[i], 0xff000000) : 0xff000000;
391395
}
392396
}
393397

@@ -403,10 +407,10 @@ function inversePredictor(p: Uint32Array, width: number, height: number, bits: n
403407
// first column: top
404408
p[row] = addPixels(p[row], p[row - width]);
405409
const modeRow = (y >> bits) * blocksPerRow;
406-
for (let x = 1; x < width; x++) {
407-
const mode = modes[modeRow + (x >> bits)] >> 8 & 15;
408-
const i = row + x;
409-
p[i] = addPixels(p[i], predict(mode, p, i, width));
410+
for (let x = 1; x < width;) {
411+
const end = Math.min(width, (x >> bits) + 1 << bits);
412+
predictRun(modes[modeRow + (x >> bits)] >> 8 & 15, p, row + x, end - x, width, p, row + x);
413+
x = end;
410414
}
411415
}
412416
}
@@ -780,10 +784,12 @@ function tokenize(pixels: Uint32Array, width: number, cacheBits: number): Stream
780784
const HASH_BITS = 15, CHAIN = 24, MAX_LENGTH = 4096, MIN_LENGTH = 3, WINDOW = (1 << 20) - 120;
781785
const head = new Int32Array(1 << HASH_BITS).fill(-1);
782786
const chain = new Int32Array(n);
783-
const hash = (i: number): number => Math.imul(Math.imul(pixels[i], 0x1e35a7bd) ^ pixels[i + 1], 0x9e3779b1) >>> 32 - HASH_BITS;
787+
// hashes of the pixel pairs starting at every index (but the last)
788+
const hashes = new Int32Array(n);
789+
for (let i = 0; i + 1 < n; i++) hashes[i] = Math.imul(Math.imul(pixels[i], 0x1e35a7bd) ^ pixels[i + 1], 0x9e3779b1) >>> 32 - HASH_BITS;
784790
const insert = (i: number): void => {
785791
if (i + 1 >= n) return;
786-
const h = hash(i);
792+
const h = hashes[i];
787793
chain[i] = head[h];
788794
head[h] = i;
789795
};
@@ -811,7 +817,7 @@ function tokenize(pixels: Uint32Array, width: number, cacheBits: number): Stream
811817
if (l > bestLength) { bestLength = l; bestDistance = width; }
812818
}
813819
if (i + 1 < n && bestLength < MAX_LENGTH) {
814-
for (let j = head[hash(i)], k = 0; j >= 0 && k < CHAIN && i - j <= WINDOW; j = chain[j], k++) {
820+
for (let j = head[hashes[i]], k = 0; j >= 0 && k < CHAIN && i - j <= WINDOW; j = chain[j], k++) {
815821
// the chain goes from near to far: a candidate no closer than the best one only wins by being
816822
// longer, which it can't be if it differs right after the best length
817823
if (i - j >= bestDistance && (i + bestLength >= n || pixels[j + bestLength] !== pixels[i + bestLength])) continue;
@@ -901,11 +907,11 @@ function writeImage(w: BitWriter, pixels: Uint32Array, width: number, isMain: bo
901907
// subtracts two ARGB pixels per channel, modulo 256
902908
const subPixels = (a: number, b: number): number => ((a | 0x00ff00ff) - (b & 0xff00ff00) & 0xff00ff00 | (a | 0xff00ff00) - (b & 0x00ff00ff) & 0x00ff00ff) >>> 0;
903909

904-
// cost estimate of a residual: sum of the channels' distances to zero (as signed bytes)
905-
function residualCost(r: number): number {
906-
const c0 = r & 255, c1 = r >>> 8 & 255, c2 = r >>> 16 & 255, c3 = r >>> 24;
907-
return (c0 < 128 ? c0 : 256 - c0) + (c1 < 128 ? c1 : 256 - c1) + (c2 < 128 ? c2 : 256 - c2) + (c3 < 128 ? c3 : 256 - c3);
908-
}
910+
// distance of a residual byte to zero (as a signed byte)
911+
const BYTE_COST = Uint8Array.from({ length: 256 }, (_, c) => c < 128 ? c : 256 - c);
912+
913+
// cost estimate of a residual: sum of the channels' distances to zero
914+
const residualCost = (r: number): number => BYTE_COST[r & 255] + BYTE_COST[r >>> 8 & 255] + BYTE_COST[r >>> 16 & 255] + BYTE_COST[r >>> 24];
909915

910916
/**
911917
* Picks a predictor per block and replaces the pixels with their residuals
@@ -916,30 +922,37 @@ function applyPredictor(p: Uint32Array, width: number, height: number, bits: num
916922
const modes = new Uint32Array(blocksX * blocksY);
917923
const size = 1 << bits;
918924

919-
// predictions use the original pixels, so modes are chosen and residuals computed on a copy
920-
const src = p.slice();
921-
const predictAt = (mode: number, i: number, x: number, y: number): number =>
922-
y === 0 ? (x === 0 ? 0xff000000 : src[i - 1]) : x === 0 ? src[i - width] : predict(mode, src, i, width);
925+
// predictions use the original pixels, so modes are chosen and residuals computed on a copy. A block's rows are
926+
// predicted into `predicted` (the first row from the left, the first column from the top, whatever the mode)
927+
const src = p.slice(), predicted = new Uint32Array(size);
928+
const predictRow = (mode: number, x0: number, x1: number, y: number): void => {
929+
const row = y * width;
930+
if (y === 0) {
931+
for (let x = x0; x < x1; x++) predicted[x - x0] = x === 0 ? 0xff000000 : src[row + x - 1];
932+
return;
933+
}
934+
let x = x0;
935+
if (x === 0) predicted[x++] = src[row - width];
936+
predictRun(mode, src, row + x, x1 - x, width, predicted, x - x0);
937+
};
923938

924939
for (let by = 0; by < blocksY; by++)
925940
for (let bx = 0; bx < blocksX; bx++) {
926941
let best = 0, bestCost = Infinity;
927942
const x0 = bx * size, y0 = by * size, x1 = Math.min(width, x0 + size), y1 = Math.min(height, y0 + size);
928943
for (let mode = 0; mode < 14; mode++) {
929944
let cost = 0;
930-
for (let y = y0; y < y1 && cost < bestCost; y++)
931-
for (let x = x0; x < x1; x++) {
932-
const i = y * width + x;
933-
cost += residualCost(subPixels(src[i], predictAt(mode, i, x, y)));
934-
}
945+
for (let y = y0; y < y1 && cost < bestCost; y++) {
946+
predictRow(mode, x0, x1, y);
947+
for (let x = x0, i = y * width + x0; x < x1; x++, i++) cost += residualCost(subPixels(src[i], predicted[x - x0]));
948+
}
935949
if (cost < bestCost) { bestCost = cost; best = mode; }
936950
}
937951
modes[by * blocksX + bx] = (0xff000000 | best << 8) >>> 0;
938-
for (let y = y0; y < y1; y++)
939-
for (let x = x0; x < x1; x++) {
940-
const i = y * width + x;
941-
p[i] = subPixels(src[i], predictAt(best, i, x, y));
942-
}
952+
for (let y = y0; y < y1; y++) {
953+
predictRow(best, x0, x1, y);
954+
for (let x = x0, i = y * width + x0; x < x1; x++, i++) p[i] = subPixels(src[i], predicted[x - x0]);
955+
}
943956
}
944957

945958
return modes;

0 commit comments

Comments
 (0)