Marginal Musings

PIL Takes (width, height) but NumPy Returns (height, width, channels)

Author
Shlomo Stept
Published
Updated

I got burned by this during my metrics research , and understanding it deeply is what eventually let me spot a dimension swap in clean-fid . It might be the single most common source of silent failures in image processing code.

from PIL import Image
import numpy as np

img = Image.open("photo.png")

print(img.size)              # (1920, 1080) — width, height
print(np.array(img).shape)   # (1080, 1920, 3) — height, width, channels

PIL gives you (width, height) — the way you’d describe a monitor resolution. NumPy gives you (height, width, channels) — rows first, because arrays are row-major. Same image, flipped convention.

The classic failure:

target_shape = some_array.shape[:2]  # (height, width) = (256, 512)

resized = img.resize(target_shape)   # PIL expects (width, height), so this silently produces (512, 256)

# correct:
resized = img.resize((target_shape[1], target_shape[0]))

No error. No warning. Wrong dimensions flowing through your pipeline. And here’s the part that makes this bug persistent: for square images (which are everywhere in ML — 224x224, 256x256, 299x299), the swap is completely invisible.

That last detail is what matters. This exact pattern caused the issue I found in clean-fid, where a reshape had swapped dimensions that went undetected because FID computation defaults to 299x299 images. Square inputs masked the bug. It was only visible with non-square inputs, which almost nobody tested with.

The fix is to be annoyingly explicit at every boundary:

h, w, c = arr.shape          # NumPy convention
resized = img.resize((w, h))  # PIL convention

If you see .resize() anywhere near .shape, check the order. And test with non-square images. Always.