Skip to content

Commit 60d768d

Browse files
matmenclaude
andcommitted
WebP: color transform after the predictor in lossless encoding
Like libwebp, the predictor's residuals now get the color transform: per 32x32 block the multipliers of green for red and of green and red for blue whose residuals take the fewest bits together with the blocks before (VP8LColorSpaceTransform's search and costs). It's skipped where no block changes or red and blue wouldn't get cheaper overall. Coarser than libwebp's: the costs are of every fourth pixel and blue's search stops at steps of 4, 0.1% larger than the full search at a third of its time. v log v comes from a table, beyond it from log(1 + t)'s series. 5-6% smaller for emoji and rendered graphics (effects.png, colrv1), 0.61% on average over 39 images, about 9% slower single-threaded; vs cwebp -m 4 -q 75 1.047x -> 1.041x. All lossless, threads give the same bytes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BsU2GAqbnFSNaYDBGD1Km2
1 parent 268ab9a commit 60d768d

3 files changed

Lines changed: 181 additions & 2 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
predictor. The 1434x1434 benchmark graphic takes 277 kB instead of 557 kB (cwebp: 278 kB), screenshots and images
1010
with transparency 15-30% less; photos stay the same. Over 39 test images the bitstreams went from 1.11 to 1.05
1111
times cwebp's (`-m 4 -q 75`, like sharp's default).
12+
- Lossless `encodeWebP()` uses the color transform after the predictor, choosing per 32x32 block how much of green to
13+
subtract from red and of green and red from blue, like libwebp: 5-6% smaller for images like emoji and rendered
14+
graphics, about 0.6% on average, and about 9% slower. Over the same 39 images: 1.04 times cwebp's.
1215

1316
## 3.12.0
1417

‎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), `src/codecs/vp8l.ts` (the lossless encoder's entropy analysis) | 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 and color transform search) | 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: 177 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
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,
44
* 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
5+
* analysis (src/enc/vp8l_enc.c, AnalyzeEntropy), its color transform search libwebp's (src/enc/predictor_enc.c).
6+
* Copyright (c) 2012 Google Inc., BSD-3-Clause License
67
*/
78

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

1232+
// the bits of the red and blue values of pixels (their histograms' entropy)
1233+
function redBlueBits(p: Uint32Array): number {
1234+
const red = new Uint32Array(256), blue = new Uint32Array(256);
1235+
for (let i = 0; i < p.length; i++) {
1236+
red[p[i] >> 16 & 255]++;
1237+
blue[p[i] & 255]++;
1238+
}
1239+
let bits = 0;
1240+
for (const h of [red, blue]) for (const n of h) if (n) bits += n * Math.log2(p.length / n);
1241+
return bits;
1242+
}
1243+
1244+
const slog = (v: number): number => v ? v * Math.log2(v) : 0;
1245+
// (v log v up to 65536, built when first needed)
1246+
let SLOG: Float64Array | undefined;
1247+
1248+
/**
1249+
* The bits a tile's histogram takes when added to the accumulated one (libwebp's CombinedShannonEntropy, in bits),
1250+
* with a bias favoring values near 0 (PredictionCostBias): over the n symbols the tile has (touched), with the
1251+
* accumulated histogram's sum and the sum of its entries' v log v
1252+
*/
1253+
function crossColorCost(accumulated: Uint32Array, accumulatedSlogs: Float64Array, accumulatedLogs: Float64Array, counts: Uint32Array, touched: Int32Array, n: number, accumulatedSum: number, accumulatedSlog: number): number {
1254+
const table = SLOG!;
1255+
let sum = accumulatedSlog, sumX = 0;
1256+
for (let k = 0; k < n; k++) {
1257+
const i = touched[k], x = counts[i], y = accumulated[i];
1258+
sumX += x;
1259+
// ((x + y) log (x + y) - y log y = x log y + (x + y) log(1 + x / y), the latter's series exact enough for y
1260+
// beyond the table, where x / y is at most 1/255)
1261+
if (x + y < table.length) sum += table[x] + table[x + y] - accumulatedSlogs[i];
1262+
else {
1263+
const t = x / y;
1264+
sum += table[x] + x * accumulatedLogs[i] + (x + y) * (t - t * t / 2 + t * t * t / 3) * Math.LOG2E;
1265+
}
1266+
}
1267+
let bias = 3 * counts[0], weight = 240;
1268+
for (let i = 1; i < 16; i++) {
1269+
bias += weight * (counts[i] + counts[256 - i]) / 100;
1270+
weight *= 0.6;
1271+
}
1272+
return slog(sumX) + slog(sumX + accumulatedSum) - sum - bias / 10;
1273+
}
1274+
1275+
/**
1276+
* libwebp's color transform search (src/enc/predictor_enc.c, VP8LColorSpaceTransform): per block of 2^bits pixels the
1277+
* multipliers (3.5 bit fixed point) of green for red, and of green and red for blue, whose residuals take the fewest
1278+
* bits with the blocks' before (blocks keeping their neighbors' multipliers or 0 favored), applied to the pixels;
1279+
* returns the sub-image of the multipliers. Coarser than libwebp's: the costs are of every fourth pixel and the search
1280+
* for blue's multipliers stops at steps of 4, which compresses about as well (0.1% larger) at a third of the time
1281+
*/
1282+
function colorTransform(p: Uint32Array, width: number, height: number, bits: number): Uint32Array {
1283+
const size = 1 << bits, blocksX = subSize(width, bits), blocksY = subSize(height, bits);
1284+
const image = new Uint32Array(blocksX * blocksY);
1285+
const accumulatedRed = new Uint32Array(256), accumulatedBlue = new Uint32Array(256), histo = new Uint32Array(256), touched = new Int32Array(256);
1286+
// (a tile's green and red as signed bytes, red and blue as they are)
1287+
const green = new Int32Array(size * size), red = new Int32Array(size * size), redByte = new Int32Array(size * size), blue = new Int32Array(size * size);
1288+
const s8 = (v: number): number => v << 24 >> 24;
1289+
// (an accumulated histogram's sum, its entries' v log v and logs, and the sum of the former)
1290+
SLOG ??= Float64Array.from({ length: 65536 }, (_, v) => slog(v));
1291+
const redSlogs = new Float64Array(256), blueSlogs = new Float64Array(256), redLogs = new Float64Array(256), blueLogs = new Float64Array(256);
1292+
const sums = (h: Uint32Array, slogs: Float64Array, logs: Float64Array): [number, number] => {
1293+
let total = 0, entropy = 0;
1294+
for (let i = 0; i < 256; i++) {
1295+
total += h[i];
1296+
entropy += slogs[i] = slog(h[i]);
1297+
logs[i] = h[i] ? Math.log2(h[i]) : 0;
1298+
}
1299+
return [total, entropy];
1300+
};
1301+
let prevX = [0, 0, 0], prevY = [0, 0, 0];
1302+
for (let by = 0; by < blocksY; by++) {
1303+
for (let bx = 0; bx < blocksX; bx++) {
1304+
const x0 = bx * size, y0 = by * size, x1 = Math.min(width, x0 + size), y1 = Math.min(height, y0 + size);
1305+
if (by) {
1306+
const above = image[(by - 1) * blocksX + bx];
1307+
prevY = [above & 255, above >> 8 & 255, above >> 16 & 255];
1308+
}
1309+
// (the pixels the costs are of: every fourth, shifted by one each row)
1310+
let count = 0;
1311+
for (let y = y0; y < y1; y++)
1312+
for (let i = y * width + x0 + (y & 3), end = y * width + x1; i < end; i += 4, count++) {
1313+
const v = p[i];
1314+
green[count] = s8(v >> 8);
1315+
red[count] = s8(v >> 16);
1316+
redByte[count] = v >> 16 & 255;
1317+
blue[count] = v & 255;
1318+
}
1319+
// (green to red: by halving steps from 32)
1320+
const [redSum, redSlog] = sums(accumulatedRed, redSlogs, redLogs);
1321+
const redCost = (gtr: number): number => {
1322+
const m = s8(gtr);
1323+
let n = 0;
1324+
for (let k = 0; k < count; k++) {
1325+
const v = redByte[k] - (m * green[k] >> 5) & 255;
1326+
if (!histo[v]++) touched[n++] = v;
1327+
}
1328+
let cost = crossColorCost(accumulatedRed, redSlogs, redLogs, histo, touched, n, redSum, redSlog);
1329+
for (let k = 0; k < n; k++) histo[touched[k]] = 0;
1330+
if ((gtr & 255) === prevX[0]) cost -= 3;
1331+
if ((gtr & 255) === prevY[0]) cost -= 3;
1332+
if (gtr === 0) cost -= 3;
1333+
return cost;
1334+
};
1335+
let gtr = 0, best = redCost(0);
1336+
for (let iter = 0; iter < 6; iter++) {
1337+
const step = 32 >> iter;
1338+
for (const offset of [-step, step]) {
1339+
const cost = redCost(gtr + offset);
1340+
if (cost < best) [best, gtr] = [cost, gtr + offset];
1341+
}
1342+
}
1343+
// (green and red to blue: along the axes and diagonals)
1344+
const [blueSum, blueSlog] = sums(accumulatedBlue, blueSlogs, blueLogs);
1345+
const blueCost = (gtb: number, rtb: number): number => {
1346+
const mg = s8(gtb), mr = s8(rtb);
1347+
let n = 0;
1348+
for (let k = 0; k < count; k++) {
1349+
const v = blue[k] - (mg * green[k] >> 5) - (mr * red[k] >> 5) & 255;
1350+
if (!histo[v]++) touched[n++] = v;
1351+
}
1352+
let cost = crossColorCost(accumulatedBlue, blueSlogs, blueLogs, histo, touched, n, blueSum, blueSlog);
1353+
for (let k = 0; k < n; k++) histo[touched[k]] = 0;
1354+
if ((gtb & 255) === prevX[1]) cost -= 3;
1355+
if ((gtb & 255) === prevY[1]) cost -= 3;
1356+
if ((rtb & 255) === prevX[2]) cost -= 3;
1357+
if ((rtb & 255) === prevY[2]) cost -= 3;
1358+
if (gtb === 0) cost -= 3;
1359+
if (rtb === 0) cost -= 3;
1360+
return cost;
1361+
};
1362+
let gtb = 0, rtb = 0;
1363+
best = blueCost(0, 0);
1364+
for (const step of [16, 8, 4]) {
1365+
for (const [dg, dr] of [[0, -1], [0, 1], [-1, 0], [1, 0], [-1, -1], [-1, 1], [1, -1], [1, 1]]) {
1366+
const cost = blueCost(gtb + dg * step, rtb + dr * step);
1367+
if (cost < best) [best, gtb, rtb] = [cost, gtb + dg * step, rtb + dr * step];
1368+
}
1369+
}
1370+
prevX = [gtr & 255, gtb & 255, rtb & 255];
1371+
image[by * blocksX + bx] = (0xff000000 | prevX[2] << 16 | prevX[1] << 8 | prevX[0]) >>> 0;
1372+
// the block transformed, its pixels accumulated (but repeated ones: backward references take them)
1373+
const mgr = s8(gtr), mgb = s8(gtb), mrb = s8(rtb);
1374+
for (let y = y0; y < y1; y++)
1375+
for (let i = y * width + x0, end = y * width + x1; i < end; i++) {
1376+
const v = p[i], g = s8(v >> 8), rs = s8(v >> 16);
1377+
const r = (v >> 16) - (mgr * g >> 5) & 255, b = v - (mgb * g >> 5) - (mrb * rs >> 5) & 255;
1378+
p[i] = (v & 0xff00ff00 | r << 16 | b) >>> 0;
1379+
}
1380+
for (let y = y0; y < y1; y++)
1381+
for (let i = y * width + x0, end = y * width + x1; i < end; i++) {
1382+
const v = p[i];
1383+
if (i >= 2 && v === p[i - 2] && v === p[i - 1]) continue;
1384+
if (i >= width + 2 && p[i - 2] === p[i - width - 2] && p[i - 1] === p[i - width - 1] && v === p[i - width]) continue;
1385+
accumulatedRed[v >> 16 & 255]++;
1386+
accumulatedBlue[v & 255]++;
1387+
}
1388+
}
1389+
}
1390+
return image;
1391+
}
1392+
12311393
/**
12321394
* libwebp's entropy analysis (AnalyzeEntropy): which of the pixels and their differences to the pixel left of them,
12331395
* each with green subtracted from red and blue or not, take the fewest bits (the predictor with the cost of its
@@ -1414,6 +1576,20 @@ async function encodeWith(argb: Uint32Array, width: number, height: number, colo
14141576
w.write(bits - 2, 3);
14151577
const modes = await applyPredictor(pixels, width, height, bits, threads);
14161578
await writeImage(w, modes, subSize(width, bits), false);
1579+
// (the residuals' red and blue predicted from their green, like libwebp does with the predictor; not if no block
1580+
// gains from it, all multipliers 0 leave the pixels as they are)
1581+
if (redBlue) {
1582+
const colorBits = 5, before = pixels.slice(), multipliers = colorTransform(pixels, width, height, colorBits);
1583+
// (nor if red and blue don't take fewer bits in all, with about 12 bits per block that changes them)
1584+
const changed = multipliers.reduce((n, m) => n + (m !== 0xff000000 ? 1 : 0), 0);
1585+
if (changed && redBlueBits(pixels) + 12 * changed >= redBlueBits(before)) pixels.set(before);
1586+
else if (changed) {
1587+
w.write(1, 1);
1588+
w.write(Transform.COLOR, 2);
1589+
w.write(colorBits - 2, 3);
1590+
await writeImage(w, multipliers, subSize(width, colorBits), false);
1591+
}
1592+
}
14171593
}
14181594
}
14191595

0 commit comments

Comments
 (0)