A common question from people who post a Malfoid portrait: the full picture looks fine, so why does the little avatar version look muddier, with the platinum hair gone flat and grey? Most of the time the platform isn't doing anything sinister. The image was shrunk the way almost every library shrinks images by default: by averaging gamma-encoded sRGB numbers as if they were amounts of light. This post shows the effect on test images, gives you a tested script that resizes in linear light instead, and is honest about when it matters (fine bright detail) and when it doesn't (most of a portrait).
Context: I build malfoidai.com, which makes one still 3:4 Malfoid (Female Malfoy / Fem Draco) portrait from one photo. Long, fine platinum hair on a dark candlelit background is close to a worst case for this bug, so I measured it.
The one-paragraph version of the problem
An 8-bit sRGB value of 128 is not half as bright as 255. It's about 21% of the light. The sRGB curve spends more codes on dark tones because our eyes are more sensitive there. When a resampler averages a white pixel (255) and a black pixel (0), it returns 128, which is too dark. The physically right answer is the average of the light, 0.5 linear, which encodes back to about 188. On a smooth gradient the two answers barely differ. On one-pixel strands of bright hair against a dark robe, they differ a lot.
Measuring it
The test: shrink an image two ways with the same Lanczos filter, once on the stored sRGB values (what Image.resize does) and once on linear light, then compare both to a reference: a plain box average of the original in linear light over each output pixel's footprint. That's the true mean light. Errors are in CIELAB L* (0–100). A difference of about 1 is roughly one just-visible step, and 10+ is obvious.
First, a stress pattern: platinum one-pixel strands (232, 228, 214) on a dark robe colour (25, 22, 30), 300 × 400 px:
1px strands, period 2 -> 150px wide: sRGB-space dL* -16.31 (RGB [128, 125, 122]), linear dL* -0.04 (RGB [171, 168, 158]) 1px strands, period 2 -> 60px wide: sRGB-space dL* -15.90 (RGB [128, 125, 122]), linear dL* +0.33 (RGB [171, 168, 158]) 1px strands, period 2 -> 30px wide: sRGB-space dL* -16.23 (RGB [129, 125, 122]), linear dL* -0.02 (RGB [171, 168, 158]) 1px strands, period 3 -> 150px wide: sRGB-space dL* -9.81 (RGB [94, 91, 91]), linear dL* +9.68 (RGB [143, 140, 132]) 1px strands, period 3 -> 60px wide: sRGB-space dL* -18.80 (RGB [94, 91, 91]), linear dL* +0.60 (RGB [143, 140, 133]) 1px strands, period 3 -> 30px wide: sRGB-space dL* -19.38 (RGB [94, 91, 91]), linear dL* +0.04 (RGB [143, 140, 133]) 1px strands, period 6 -> 150px wide: sRGB-space dL* -4.32 (RGB [60, 57, 61]), linear dL* +7.57 (RGB [89, 87, 82]) 1px strands, period 6 -> 60px wide: sRGB-space dL* -16.64 (RGB [60, 56, 61]), linear dL* +2.95 (RGB [106, 103, 98]) 1px strands, period 6 -> 30px wide: sRGB-space dL* -19.17 (RGB [59, 56, 61]), linear dL* +0.43 (RGB [105, 103, 98]) When the output is small enough that the strands blend into one tone (the 60 and 30 px rows), sRGB-space shrinking comes out 16–19 L* too dark, and linear-light shrinking is within 3 (mostly within 0.6). The 150 px rows are a 2× shrink of a period-3 pattern, which still aliases. Per-pixel error is noisy there for both methods, so don't read much into those two rows.
The reverse case shows that linear light isn't "brighter". It's just correct. Dark one-pixel lines on platinum:
dark 1px lines every 3 px on platinum -> 30px wide: srgb RGB [163, 159, 153] dL* -11.53 dark 1px lines every 3 px on platinum -> 30px wide: linear RGB [194, 191, 179] dL* +0.07 sRGB-space shrinking makes the lines too heavy (−11.5 L*). Linear-light shrinking lets them fade the way they physically should. That has a visible side effect I come back to below: thin dark features like lashes and pupil rims get lighter in a linear-light thumbnail.
Then the realistic check: 30 synthetic 3:4 test cards (shape-only portraits with fine hair-strand texture, no real face, no AI output; the same cards are published in my dataset). Frame mean and the hair box, median over 30 cards:
256px wide, 30 cards: frame mean dL* sRGB -0.72 (worst -1.15) vs linear +0.06; hair box sRGB -0.42 (worst -3.53) vs linear +0.04 128px wide, 30 cards: frame mean dL* sRGB -1.05 (worst -1.48) vs linear -0.06; hair box sRGB -0.73 (worst -4.34) vs linear +0.02 48px wide, 30 cards: frame mean dL* sRGB -1.45 (worst -1.76) vs linear -0.13; hair box sRGB -0.65 (worst -5.89) vs linear +0.22 On a whole portrait the effect is small: about 1–1.5 L* darker across the frame for sRGB-space shrinking, growing as the thumbnail gets smaller. In the hair box it's usually under 1, but the worst card hits −4 to −6 L*, which is the "flat grey hair" people notice. Linear light stays within ±0.25 on every size.
So the honest answer: for most of a portrait it's a slight overall darkening. For fine bright detail (hair, candle highlights, a silver stripe on a tie) it can be the difference between "platinum" and "dishwater".
The script
Pillow can resample 32-bit float images (mode F) with the same filters as 8-bit ones, so the fix is: decode sRGB to linear, resize each channel as float, re-encode. No extra dependencies beyond NumPy.
#!/usr/bin/env python3 """Shrink a portrait in linear light instead of sRGB, and measure the difference. pip install pillow numpy python thumb_gamma.py portrait.png --sizes 256 128 64 48 --out thumbs/ python thumb_gamma.py portrait.png --sizes 128 --region 0.24 0.30 0.32 0.55 Pillow (like most image libraries) resamples the stored 8-bit sRGB values directly. Those values are gamma-encoded, so averaging them is not averaging light: a block of fine bright strands on a darker background comes out darker than it should. This script decodes to linear light, resamples each channel as 32-bit float with the same filter, and re-encodes. For each size it writes <stem>-<w>-srgb.png and <stem>-<w>-linear.png and prints the error of each against a reference: a plain box average of the original in linear light over the same footprint (the physically correct mean). Errors are in CIELAB L* (0-100), so 1.0 is about one just-visible step. """ import argparse import os import numpy as np from PIL import Image def srgb_to_linear(u8): c = np.asarray(u8, dtype=np.float64) / 255.0 return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4) def linear_to_srgb(lin): c = np.clip(lin, 0.0, 1.0) s = np.where(c <= 0.0031308, c * 12.92, 1.055 * np.power(c, 1 / 2.4) - 0.055) return np.round(s * 255.0).astype(np.uint8) def lum_lstar(lin_rgb): """CIELAB L* from linear sRGB (D65 Y).""" y = lin_rgb[..., 0] * 0.2126 + lin_rgb[..., 1] * 0.7152 + lin_rgb[..., 2] * 0.0722 f = np.where(y > (6 / 29) ** 3, np.cbrt(y), y / (3 * (6 / 29) ** 2) + 4 / 29) return 116 * f - 16 def size_for(img, width): """Keep the source aspect (3:4 for a Malfoid portrait).""" w, h = img.size return width, max(1, round(width * h / w)) def resize_srgb(img, size, filt=Image.Resampling.LANCZOS): return img.convert("RGB").resize(size, filt, reducing_gap=None) def resize_linear(img, size, filt=Image.Resampling.LANCZOS): lin = srgb_to_linear(img.convert("RGB")).astype(np.float32) chans = [np.asarray(Image.fromarray(lin[..., i], "F").resize(size, filt, reducing_gap=None)) for i in range(3)] return Image.fromarray(linear_to_srgb(np.stack(chans, axis=-1)), "RGB") def reference(img, size): """Box average in linear light over each output pixel's exact footprint.""" lin = srgb_to_linear(img.convert("RGB")) H, W, _ = lin.shape w, h = size xs = np.linspace(0, W, w + 1).round().astype(int) ys = np.linspace(0, H, h + 1).round().astype(int) rows = np.add.reduceat(lin, ys[:-1], axis=0) / np.diff(ys)[:, None, None] out = np.add.reduceat(rows, xs[:-1], axis=1) / np.diff(xs)[None, :, None] return out def compare(img, width, region=None): """Mean |dL*| vs the linear box reference, whole frame and (optionally) a region.""" size = size_for(img, width) ref = lum_lstar(reference(img, size)) res = {} for name, fn in (("srgb", resize_srgb), ("linear", resize_linear)): out = fn(img, size) L = lum_lstar(srgb_to_linear(out)) d = L - ref row = {"mean_dL": float(d.mean()), "mean_abs_dL": float(np.abs(d).mean())} if region: x0, y0, x1, y1 = region w, h = size sl = (slice(int(y0 * h), max(int(y0 * h) + 1, int(y1 * h))), slice(int(x0 * w), max(int(x0 * w) + 1, int(x1 * w)))) row["region_mean_dL"] = float(d[sl].mean()) res[name] = (out, row) return size, res def main(): ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) ap.add_argument("image") ap.add_argument("--sizes", type=int, nargs="+", default=[256, 128, 64, 48]) ap.add_argument("--out", help="folder for the thumbnails (optional)") ap.add_argument("--region", type=float, nargs=4, metavar=("X0", "Y0", "X1", "Y1"), help="fractions of the frame to report separately, e.g. the hair") a = ap.parse_args() img = Image.open(a.image).convert("RGB") stem = os.path.splitext(os.path.basename(a.image))[0] if a.out: os.makedirs(a.out, exist_ok=True) print(f"{a.image}: {img.size[0]}x{img.size[1]}") print(f"{'size':>9} {'method':<7} {'mean dL*':>9} {'mean |dL*|':>10}" + (" region dL*" if a.region else "")) for wd in a.sizes: size, res = compare(img, wd, a.region) for name, (out, row) in res.items(): line = f"{size[0]:>4}x{size[1]:<4} {name:<7} {row['mean_dL']:>+9.2f} {row['mean_abs_dL']:>10.2f}" if a.region: line += f" {row['region_mean_dL']:>+10.2f}" print(line) if a.out: out.save(os.path.join(a.out, f"{stem}-{size[0]}-{name}.png")) if __name__ == "__main__": main() Run it on a portrait:
pip install pillow numpy python thumb_gamma.py my-portrait.png --sizes 256 128 64 48 --out thumbs/ --region 0.24 0.30 0.32 0.55 --region takes fractions of the frame (x0, y0, x1, y1). The example is roughly where the hair falls beside the face in a centred 3:4 portrait. Adjust it for yours.
Tests
Six tests, including the textbook one (one-pixel black/white stripes must average to sRGB ≈ 188 in linear light and ≈ 128 the naive way), a flat colour that both methods must leave alone, and the exact-box reference:
$ python -m pytest -q test_thumb_gamma.py ...... [100%] 6 passed in 0.12s The test file is short enough to read in a minute. The key assertion:
def test_one_pixel_stripes_average_to_half_light(): img = stripes() # 1 px white, 1 px black lin = np.asarray(tg.resize_linear(img, (50, 50)))[5:-5, 5:-5] srgb = np.asarray(tg.resize_srgb(img, (50, 50)))[5:-5, 5:-5] assert abs(lin.mean() - 188) <= 2 # half the light assert abs(srgb.mean() - 128) <= 2 # the classic too-dark result When not to bother, and the trade-offs
- Upload sizes. If you're giving a platform a large image, the platform makes its own thumbnails, in whatever space it uses. You only control the thumbnails you make yourself: an avatar you upload at exactly 400 px, a site icon, an
og:image, a gallery grid. - Dark details fade. Linear light treats a thin dark line as the small amount of darkness it really is. Eyes, lashes and the dark rim of an iris get lighter in small thumbnails. If a 48 px avatar looks "blank-eyed" after you switch, a small unsharp mask after resizing (in sRGB) brings the features back. That's a taste call, not a correctness one.
- Ringing. Lanczos overshoots at hard edges. In linear light the overshoot shows up as slightly brighter halos around the hair against dark backgrounds. The script clips to [0, 1] so you won't get wrap-around, but if halos bother you, try
Image.Resampling.BICUBICorBOXas the filter. - Colour-managed sources. The script assumes the file is sRGB, which is right for web images. If your picture carries a wide-gamut profile (Display P3 from a phone, for example), convert it to sRGB first. Otherwise "linear" is computed on the wrong primaries.
- Speed. Float resampling is a few times slower than 8-bit. For a 1K portrait it's still well under a second.
Where the portrait comes from
Any portrait works. If you don't have one, malfoidai.com makes one still 3:4 picture at 1K from one photo of an adult, or without a photo, for $4.99 paid once (no account), or 2 credits from a one-time pack (30 credits $13.99, 100 for $35.99, 200 for $59.99; packs need sign-in, and the pricing page says credits never expire). I re-checked the pricing page on 2026-11-12 around 18:00 CST (UTC+8). It produces stills only, no video. Before posting a thumbnail anywhere, the pre-posting checklist is worth thirty seconds, and the FAQ covers what the picture is and isn't (unofficial fan art, not an official character image).
Tested on Python 3.13, Pillow 13.0, NumPy 2.5, on synthetic shape-only images. No real face was used.
