Skip to content

Commit 1305d3f

Browse files
matmenclaude
andcommitted
WebP: choose lossless transforms by libwebp's entropy analysis
The lossless encoder always applied the predictor (and subtracted green unless red and blue were zero). Graphics with flat areas and repeated parts compress much better on the pixels themselves: backward references find more of them there. Now the transforms are chosen like libwebp's AnalyzeEntropy: the bits of histograms of the pixels and of their differences to the left one, each with green subtracted or not (pixels repeating the left or upper one left out), the predictor with the cost of its sub-image. Bench graphic 557 -> 277 kB (cwebp -m 4 -q 75: 278 kB), screen.png, potato.png, overlay.png 21-31% smaller, photos unchanged; over 39 images the VP8L bitstreams went from 1.109x to 1.047x cwebp's (geometric mean). All lossless. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BsU2GAqbnFSNaYDBGD1Km2
1 parent 6dc81c7 commit 1305d3f

3 files changed

Lines changed: 68 additions & 8 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
## 3.13.0
4+
5+
### Changes
6+
7+
- Lossless `encodeWebP()` chooses whether to use the predictor and to subtract green by libwebp's entropy analysis,
8+
instead of always predicting: graphics with flat areas and repeated parts compress much better without the
9+
predictor. The 1434x1434 benchmark graphic takes 277 kB instead of 557 kB (cwebp: 278 kB), screenshots and images
10+
with transparency 15-30% less; photos stay the same. Over 39 test images the bitstreams went from 1.11 to 1.05
11+
times cwebp's (`-m 4 -q 75`, like sharp's default).
12+
313
## 3.12.0
414

515
### New

‎THIRD_PARTY_NOTICES.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ following projects, under their own licenses. Each derived file says so in its h
1212
| [font-rs](https://github.com/raphlinus/font-rs) by Raph Levien | `src/render/text.ts` (the `Rasterizer` class, also used for SVGs) | Apache-2.0 |
1313
| [RFC 9649](https://www.rfc-editor.org/rfc/rfc9649) (WebP Image Format) | `src/codecs/vp8l.ts` (lossless bitstream, short distance code table), `src/codecs/webp.ts` (container) | BSD-3-Clause (Revised BSD) |
1414
| [RFC 6386](https://www.rfc-editor.org/rfc/rfc6386) (VP8 Data Format and Decoding Guide, with the libvpx reference decoder) | `src/codecs/vp8-tables.ts` (probability and quantizer tables), `src/codecs/vp8.ts` (decoding procedures) | BSD-3-Clause |
15-
| [libwebp](https://chromium.googlesource.com/webm/libwebp) | `src/codecs/vp8.ts` (YUV to RGB conversion, "fancy" chroma upsampling), `src/codecs/webp.ts` (animation frame blending), `src/codecs/vp8-enc.ts`, `src/codecs/vp8-enc-dsp.ts` (the lossy encoder: RGB to YUV conversion, analysis, segments, rate-distortion optimized mode decisions, quantization, token probabilities, bitstream, alpha filtering), `src/codecs/vp8-enc-tables.ts` (its tables, generated from libwebp by `scripts/vp8-enc-tables.py`), `src/codecs/sharpyuv.ts` (the sharp RGB to YUV conversion, libsharpyuv) | BSD-3-Clause |
15+
| [libwebp](https://chromium.googlesource.com/webm/libwebp) | `src/codecs/vp8.ts` (YUV to RGB conversion, "fancy" chroma upsampling), `src/codecs/webp.ts` (animation frame blending), `src/codecs/vp8-enc.ts`, `src/codecs/vp8-enc-dsp.ts` (the lossy encoder: RGB to YUV conversion, analysis, segments, rate-distortion optimized mode decisions, quantization, token probabilities, bitstream, alpha filtering), `src/codecs/vp8-enc-tables.ts` (its tables, generated from libwebp by `scripts/vp8-enc-tables.py`), `src/codecs/sharpyuv.ts` (the sharp RGB to YUV conversion, libsharpyuv), `src/codecs/vp8l.ts` (the lossless encoder's entropy analysis) | BSD-3-Clause |
1616
| [dav1d](https://code.videolan.org/videolan/dav1d) by VideoLAN, the dav1d authors and Two Orioles | `src/codecs/av1/*` (the AV1 decoder, and the encoder's syntax, contexts and reconstruction mirrored from it: bitstream parsing, symbol decoding, prediction, inverse transforms, loop filters, film grain; the tables in `src/codecs/av1/data.ts`, generated from dav1d by `scripts/av1-tables.c`) | BSD-2-Clause |
1717
| [libaom](https://aomedia.googlesource.com/aom/) by the Alliance for Open Media | `src/codecs/av1/msac-enc.ts` (the symbol encoder), `src/codecs/av1/fdct.ts` (the forward Walsh-Hadamard transform), `src/codecs/av1/encode.ts` (the loop filter level from the quantizer), `src/codecs/avif-enc.ts` (the quantizer to qindex table) | BSD-2-Clause |
1818
| [libavif](https://github.com/AOMediaCodec/libavif) by Joe Drago | `src/codecs/avif.ts` (YUV to RGB conversion with bilinear chroma upsampling, alpha range expansion and unpremultiplication) | BSD-2-Clause |

‎src/codecs/vp8l.ts‎

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
/*!
22
* WebP lossless (VP8L) bitstream, as specified in RFC 9649 ("WebP Image Format") section 3 onwards, including its
33
* table of short distance codes. Copyright (c) 2024 IETF Trust and the persons identified as the document authors,
4-
* Revised BSD License (see THIRD_PARTY_NOTICES.md)
4+
* Revised BSD License (see THIRD_PARTY_NOTICES.md). The encoder's choice of transforms follows libwebp's entropy
5+
* analysis (src/enc/vp8l_enc.c, AnalyzeEntropy). Copyright (c) 2012 Google Inc., BSD-3-Clause License
56
*/
67

78
import { ByteWriter } from '../util/bytes.ts';
@@ -1227,6 +1228,50 @@ const BYTE_COST = Uint8Array.from({ length: 256 }, (_, c) => c < 128 ? c : 256 -
12271228
// cost estimate of a residual: sum of the channels' distances to zero
12281229
const residualCost = (r: number): number => BYTE_COST[r & 255] + BYTE_COST[r >>> 8 & 255] + BYTE_COST[r >>> 16 & 255] + BYTE_COST[r >>> 24];
12291230

1231+
/**
1232+
* libwebp's entropy analysis (AnalyzeEntropy): which of the pixels and their differences to the pixel left of them,
1233+
* each with green subtracted from red and blue or not, take the fewest bits (the predictor with the cost of its
1234+
* sub-image of 2^bits blocks); pixels repeating the left or upper one are left out, backward references take them
1235+
*/
1236+
function analyze(argb: Uint32Array, width: number, height: number, bits: number): { predict: boolean, subtractGreen: boolean } {
1237+
// the histograms: alpha, red, green, blue of the pixels (0-3) and of the differences (4-7), red and blue less green
1238+
// of the pixels (8, 9) and of the differences (10, 11)
1239+
const h = Array.from({ length: 12 }, () => new Uint32Array(256));
1240+
let prev = argb[0];
1241+
for (let i = 0; i < argb.length; i++) {
1242+
const p = argb[i], d = subPixels(p, prev);
1243+
prev = p;
1244+
if (d === 0 || (i >= width && p === argb[i - width])) continue;
1245+
h[0][p >>> 24]++;
1246+
h[1][p >> 16 & 255]++;
1247+
h[2][p >> 8 & 255]++;
1248+
h[3][p & 255]++;
1249+
h[4][d >>> 24]++;
1250+
h[5][d >> 16 & 255]++;
1251+
h[6][d >> 8 & 255]++;
1252+
h[7][d & 255]++;
1253+
h[8][(p >> 16) - (p >> 8) & 255]++;
1254+
h[9][p - (p >> 8) & 255]++;
1255+
h[10][(d >> 16) - (d >> 8) & 255]++;
1256+
h[11][d - (d >> 8) & 255]++;
1257+
}
1258+
// (one zero for the differences: comparing with 0 removes them too well)
1259+
for (const k of [4, 5, 6, 7, 10, 11]) h[k][0]++;
1260+
const bitsOf = (counts: Uint32Array): number => {
1261+
let total = 0, sum = 0;
1262+
for (const n of counts) total += n;
1263+
for (const n of counts) if (n) sum += n * Math.log2(total / n);
1264+
return sum;
1265+
};
1266+
const e = h.map(bitsOf), blocks = subSize(width, bits) * subSize(height, bits);
1267+
const entropy = [
1268+
e[0] + e[1] + e[2] + e[3], e[4] + e[5] + e[6] + e[7] + blocks * Math.log2(14),
1269+
e[0] + e[8] + e[2] + e[9], e[4] + e[10] + e[6] + e[11] + blocks * Math.log2(24),
1270+
];
1271+
const best = entropy.indexOf(Math.min(...entropy));
1272+
return { predict: best === 1 || best === 3, subtractGreen: best >= 2 };
1273+
}
1274+
12301275
/**
12311276
* Picks a predictor per block and replaces the pixels with their residuals
12321277
* @return the sub-image of modes
@@ -1349,7 +1394,10 @@ async function encodeWith(argb: Uint32Array, width: number, height: number, colo
13491394
// (subtracting green: not if red and blue are zero, like alpha planes', it would copy green into them)
13501395
let redBlue = false;
13511396
for (let i = 0; i < pixels.length && !redBlue; i++) if (pixels[i] & 0x00ff00ff) redBlue = true;
1352-
if (redBlue) {
1397+
// (and the predictor and subtracting green only where they're estimated to pay off: graphics with flat areas
1398+
// and repeated parts compress better without the predictor)
1399+
const { predict, subtractGreen } = analyze(argb, width, height, 4);
1400+
if (redBlue && subtractGreen) {
13531401
w.write(1, 1);
13541402
w.write(Transform.SUBTRACT_GREEN, 2);
13551403
for (let i = 0; i < pixels.length; i++) {
@@ -1360,11 +1408,13 @@ async function encodeWith(argb: Uint32Array, width: number, height: number, colo
13601408
}
13611409

13621410
const bits = 4;
1363-
w.write(1, 1);
1364-
w.write(Transform.PREDICTOR, 2);
1365-
w.write(bits - 2, 3);
1366-
const modes = await applyPredictor(pixels, width, height, bits, threads);
1367-
await writeImage(w, modes, subSize(width, bits), false);
1411+
if (predict) {
1412+
w.write(1, 1);
1413+
w.write(Transform.PREDICTOR, 2);
1414+
w.write(bits - 2, 3);
1415+
const modes = await applyPredictor(pixels, width, height, bits, threads);
1416+
await writeImage(w, modes, subSize(width, bits), false);
1417+
}
13681418
}
13691419

13701420
w.write(0, 1); // no more transforms

0 commit comments

Comments
 (0)