diff --git a/eval/gen-pillow-goldens.py b/eval/gen-pillow-goldens.py index c235c1d..20f43f4 100644 --- a/eval/gen-pillow-goldens.py +++ b/eval/gen-pillow-goldens.py @@ -21,10 +21,25 @@ def add(name, arr, outw, outh): ch = arr.shape[2] mode = {1: 'L', 3: 'RGB', 4: 'RGBA'}[ch] img = Image.fromarray(arr.squeeze() if ch == 1 else arr, mode=mode) - out = img.resize((outw, outh), Image.BICUBIC) + # Pillow.Image.resize(RGBA) converts to premultiplied RGBa first. The + # extension resizes each channel independently on getImageData pixels + # (src/pixel-resize.js). Goldens follow that contract so the regen path + # can validate 4-channel pillowResize. + if mode == 'RGBA': + out = Image.merge( + 'RGBA', + [band.resize((outw, outh), Image.BICUBIC) for band in img.split()], + ) + else: + out = img.resize((outw, outh), Image.BICUBIC) ob = np.asarray(out) if ch == 1: ob = ob.reshape(outh, outw, 1) + if ch != {'L': 1, 'RGB': 3, 'RGBA': 4}[mode]: + raise SystemExit(f'channels/mode mismatch: {ch} {mode}') + # `channels` is the packed-pixel width. Tests must not infer 3 from every + # non-L mode: RGBA is 4, and the browser CF path always resizes + # getImageData pixels as 4-channel. cases.append({ 'name': name, 'mode': mode, 'inW': w, 'inH': h, 'outW': outw, 'outH': outh, @@ -38,6 +53,14 @@ def add(name, arr, outw, outh): add('prod_down_rgb', noise(1600, 1200, 3), 587, 440) add('prod_down_512_rgb', noise(1600, 1200, 3), 683, 512) add('prod_down_rgba', noise(1024, 768, 4), 587, 440) +# Compact RGBA for tests/fixtures/goldens.json.gz. Independent RNG so this +# does not shift subsequent noise() cases. +add( + 'compact_rgba', + np.random.default_rng(4242).integers(0, 256, size=(120, 160, 4), dtype=np.uint8), + 147, + 110, +) # Upscales add('upscale_rgb', noise(200, 150, 3), 587, 440) add('upscale_big_rgb', grad(64, 48, 3), 512, 700) diff --git a/tests/fixtures/goldens.json.gz b/tests/fixtures/goldens.json.gz index f5d6065..a654f38 100644 Binary files a/tests/fixtures/goldens.json.gz and b/tests/fixtures/goldens.json.gz differ diff --git a/tests/pixel-resize.test.mjs b/tests/pixel-resize.test.mjs index 8a5e197..8693f30 100644 --- a/tests/pixel-resize.test.mjs +++ b/tests/pixel-resize.test.mjs @@ -4,14 +4,47 @@ import { existsSync, readFileSync } from 'node:fs'; import { gunzipSync } from 'node:zlib'; import { pillowResize } from '../src/pixel-resize.js'; -// The committed fixture is a 4-case subset (downscale RGB, tall 512 target, -// upscale, grayscale) of the full 10-case set generated against Pillow -// 12.3.0. Point PIXEL_RESIZE_GOLDENS at a full goldens.json to run all 10. +// The committed fixture is a small subset (downscale RGB, tall 512 target, +// upscale, grayscale, compact RGBA) of the full set from +// eval/gen-pillow-goldens.py against Pillow 12.3.0. Point +// PIXEL_RESIZE_GOLDENS at a full goldens.json to run every case, including +// the production-shaped RGBA downscale the browser CF path uses. const GOLDEN_CANDIDATES = [ process.env.PIXEL_RESIZE_GOLDENS, new URL('./fixtures/goldens.json.gz', import.meta.url).pathname, ].filter(Boolean); +const GOLDEN_MODE_CHANNELS = Object.freeze({ + L: 1, + RGB: 3, + RGBA: 4, +}); + +/** + * Packed-pixel width for a Pillow golden entry. + * Prefer the stored `channels` field that gen-pillow-goldens.py writes. + * Fall back to mode so the older RGB/L subset entries still load. + * RGBA must stay 4: getImageData pixels are what the browser CF path resizes. + */ +function goldenChannels(entry) { + const fromMode = GOLDEN_MODE_CHANNELS[entry.mode]; + if (Number.isInteger(entry.channels)) { + if (entry.channels < 1 || entry.channels > 4) { + throw new Error(`Invalid golden channels: ${entry.channels}`); + } + if (fromMode && fromMode !== entry.channels) { + throw new Error( + `Golden ${entry.name}: mode ${entry.mode} disagrees with channels ${entry.channels}` + ); + } + return entry.channels; + } + if (!fromMode) { + throw new Error(`Unsupported golden mode: ${String(entry.mode)}`); + } + return fromMode; +} + function loadGoldens() { for (const path of GOLDEN_CANDIDATES) { if (existsSync(path)) { @@ -47,16 +80,24 @@ describe('pillowResize goldens', () => { console.warn( 'WARNING: goldens.json not found in any of:\n ' + GOLDEN_CANDIDATES.join('\n ') + - '\nSkipping Pillow byte-exact golden tests. Regenerate with the ' + - 'gen_goldens.py script against Pillow 12.3.0.' + '\nSkipping Pillow byte-exact golden tests. Regenerate with ' + + 'eval/gen-pillow-goldens.py against Pillow 12.3.0.' ); it.skip('goldens.json missing, byte-exact checks skipped', () => {}); return; } + const rgbaGoldens = goldens.filter((entry) => goldenChannels(entry) === 4); + it('includes an RGBA golden so 4-channel pillowResize is byte-checked', () => { + assert.ok( + rgbaGoldens.length > 0, + 'goldens must include at least one RGBA / 4-channel case; the browser CF path always resizes getImageData pixels' + ); + }); + for (const entry of goldens) { it(`matches Pillow byte-exact: ${entry.name} (${entry.mode} ${entry.inW}x${entry.inH} -> ${entry.outW}x${entry.outH})`, () => { - const channels = entry.mode === 'L' ? 1 : 3; + const channels = goldenChannels(entry); const input = Buffer.from(entry.input_b64, 'base64'); const expected = Buffer.from(entry.output_b64, 'base64'); assert.equal(input.length, entry.inW * entry.inH * channels); @@ -80,6 +121,46 @@ describe('pillowResize goldens', () => { } }); +describe('goldenChannels', () => { + it('uses stored channels when present', () => { + assert.equal(goldenChannels({ mode: 'RGBA', channels: 4, name: 'a' }), 4); + assert.equal(goldenChannels({ mode: 'RGB', channels: 3, name: 'b' }), 3); + assert.equal(goldenChannels({ mode: 'L', channels: 1, name: 'c' }), 1); + }); + + it('maps mode when channels is absent (committed RGB/L subset)', () => { + assert.equal(goldenChannels({ mode: 'L' }), 1); + assert.equal(goldenChannels({ mode: 'RGB' }), 3); + assert.equal(goldenChannels({ mode: 'RGBA' }), 4); + }); + + it('does not map generator RGBA entries to 3 channels', () => { + const entry = { + name: 'prod_down_rgba', + mode: 'RGBA', + channels: 4, + inW: 1024, + inH: 768, + outW: 587, + outH: 440, + }; + const channels = goldenChannels(entry); + assert.equal(channels, 4); + const legacy = entry.mode === 'L' ? 1 : 3; + assert.equal(entry.inW * entry.inH * channels, 1024 * 768 * 4); + assert.notEqual(entry.inW * entry.inH * channels, entry.inW * entry.inH * legacy); + }); + + it('rejects unknown modes and channel/mode mismatches', () => { + assert.throws(() => goldenChannels({ mode: 'CMYK' }), /Unsupported golden mode/); + assert.throws( + () => goldenChannels({ name: 'bad', mode: 'RGB', channels: 4 }), + /disagrees with channels/ + ); + assert.throws(() => goldenChannels({ mode: 'RGB', channels: 0 }), /Invalid golden channels/); + }); +}); + describe('pillowResize identity', () => { it('same-dims resize returns identical bytes', () => { const width = 123;