Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion eval/gen-pillow-goldens.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down
Binary file modified tests/fixtures/goldens.json.gz
Binary file not shown.
93 changes: 87 additions & 6 deletions tests/pixel-resize.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand Down