DEV Community

Cover image for Chroma subsampling 4:2:0 vs 4:4:4 for screenshots with text
Sahadat Husain
Sahadat Husain

Posted on

Chroma subsampling 4:2:0 vs 4:4:4 for screenshots with text

If you must save a screenshot as JPEG, use 4:4:4 chroma. In my test, quality 80 at 4:4:4 rendered white text on a green button closer to the source than quality 90 at 4:2:0, and the file was 19,871 bytes smaller. Raising quality does not fix what subsampling breaks. Turning subsampling off does.

What does 4:2:0 throw away?

A JPEG stores brightness (luma) and colour (chroma) separately. At 4:2:0, the encoder keeps colour at half the width and half the height: one colour sample per 2 × 2 block of pixels. Photos hide this well, because colour in a photo changes slowly. Text does not. A thin white stroke on a green button gets its colour averaged with the green next to it, and the edge goes soft and tinted.

Both common libraries default to 4:2:0. The sharp output docs list chromaSubsampling with a default of '4:2:0', and say to set '4:4:4' to prevent it. Pillow's JPEG docs say that, if you leave subsampling out, libjpeg decides. I checked what it decided:

from PIL import Image, JpegImagePlugin

im = Image.open("screen-1x.png").convert("RGB")
for q in (75, 80, 90, 95):
    im.save("pil.jpg", quality=q)
    print(q, JpegImagePlugin.get_sampling(Image.open("pil.jpg")))  # 2 = 4:2:0, 0 = 4:4:4
im.save("pil444.jpg", quality=80, subsampling="4:4:4")
print("4:4:4 forced:", JpegImagePlugin.get_sampling(Image.open("pil444.jpg")))
Enter fullscreen mode Exit fullscreen mode
75 2
80 2
90 2
95 2
4:4:4 forced: 0
Enter fullscreen mode Exit fullscreen mode

Even at quality 95, Pillow 11.3.0 wrote 4:2:0.

How big is the difference on a real screenshot?

My test image is a Retina screenshot of Blinkimg's own pricing page: 2880 × 1800 pixels, halved to 1440 × 900 for a 1x copy. It has a green "Get started" button with white text, green badges and dark headings on a pale background. I encoded both sizes with sharp at quality 80 and 90, at each subsampling, with mozjpeg on. PSNR compares every pixel to the PNG; higher is closer.

import sharp from 'sharp';

const crop2x = await sharp('screenshot.png').png().toBuffer();              // 2880 x 1800
const crop1x = await sharp(crop2x).resize({ width: 1440 }).png().toBuffer(); // 1440 x 900

async function error(png, jpg) {
  const a = await sharp(png).removeAlpha().raw().toBuffer();
  const b = await sharp(jpg).removeAlpha().raw().toBuffer();
  let abs = 0, sq = 0;
  for (let i = 0; i < a.length; i++) { const d = a[i] - b[i]; abs += Math.abs(d); sq += d * d; }
  return { mae: abs / a.length, psnr: 10 * Math.log10(255 ** 2 / (sq / a.length)) };
}

for (const [name, png] of [['1x', crop1x], ['2x', crop2x]]) {
  console.log(`${name}: PNG ${(await sharp(png).png({ compressionLevel: 9 }).toBuffer()).length} bytes`);
  for (const quality of [80, 90]) for (const chromaSubsampling of ['4:2:0', '4:4:4']) {
    const jpg = await sharp(png).jpeg({ quality, chromaSubsampling, mozjpeg: true }).toBuffer();
    const e = await error(png, jpg);
    console.log(`  q${quality} ${chromaSubsampling}  ${String(jpg.length).padStart(7)} bytes  MAE ${e.mae.toFixed(2)}  PSNR ${e.psnr.toFixed(2)} dB`);
  }
}
Enter fullscreen mode Exit fullscreen mode
1x: PNG 392945 bytes
  q80 4:2:0    71849 bytes  MAE 1.14  PSNR 37.88 dB
  q80 4:4:4    80562 bytes  MAE 1.08  PSNR 38.60 dB
  q90 4:2:0   100433 bytes  MAE 0.87  PSNR 40.70 dB
  q90 4:4:4   114737 bytes  MAE 0.79  PSNR 42.03 dB
2x: PNG 809844 bytes
  q80 4:2:0   169526 bytes  MAE 0.99  PSNR 39.77 dB
  q80 4:4:4   198477 bytes  MAE 0.95  PSNR 40.22 dB
  q90 4:2:0   243490 bytes  MAE 0.82  PSNR 42.38 dB
  q90 4:4:4   285982 bytes  MAE 0.78  PSNR 43.13 dB
Enter fullscreen mode Exit fullscreen mode

On the whole frame, 4:4:4 costs 12% more bytes at 1x (71,849 to 80,562) and 17% more at 2x. Whole-frame PSNR also favours "just raise the quality". That number is misleading, because most of this screenshot is flat pale background that any setting gets right.

What happens on the coloured text itself?

So I scored only the 260 × 64 green button, where white text sits on green:

const box = { left: 740, top: 624, width: 260, height: 64 }; // the button at 1x
async function psnr(a, b) {
  const x = await sharp(a).extract(box).removeAlpha().raw().toBuffer();
  const y = await sharp(b).extract(box).removeAlpha().raw().toBuffer();
  let sq = 0;
  for (let i = 0; i < x.length; i++) sq += (x[i] - y[i]) ** 2;
  return (10 * Math.log10(255 ** 2 / (sq / x.length))).toFixed(2);
}
Enter fullscreen mode Exit fullscreen mode
q80-420 button PSNR 31.73 dB
q80-444 button PSNR 35.83 dB
q90-420 button PSNR 33.29 dB
q90-444 button PSNR 39.46 dB
Enter fullscreen mode Exit fullscreen mode
1440 × 900 screenshot Bytes Whole frame PSNR Button PSNR
q80, 4:2:0 (default) 71,849 37.88 dB 31.73 dB
q90, 4:2:0 100,433 40.70 dB 33.29 dB
q80, 4:4:4 80,562 38.60 dB 35.83 dB
q90, 4:4:4 114,737 42.03 dB 39.46 dB

Going from q80 to q90 at 4:2:0 added 28,584 bytes and 1.56 dB on the text. Switching q80 to 4:4:4 added 8,713 bytes and 4.10 dB. The subsampling, not the quality, limits the coloured edges.

Three 4x zooms of a green Get started button: the PNG source, the 4:2:0 JPEG with soft tinted letter edges, and the 4:4:4 JPEG close to the source

Bar chart of button PSNR: q80 4:2:0 31.73 dB, q90 4:2:0 33.29 dB, q80 4:4:4 35.83 dB, q90 4:4:4 39.46 dB

Note: Black text on white barely suffers from 4:2:0, because it has almost no colour. The damage shows on text over colour, coloured UI chips, red error states and syntax-highlighted code. Score those regions, not the whole frame.

So what should a pipeline do?

  1. Prefer a lossless format for screenshots. The PNG above is 392,945 bytes; lossless WebP of the same pixels is 323,152 bytes (that comparison is the next article).
  2. If the output must be JPEG, set 4:4:4 and keep a moderate quality:
await sharp(input).jpeg({ quality: 80, chromaSubsampling: '4:4:4', mozjpeg: true }).toFile('out.jpg');
Enter fullscreen mode Exit fullscreen mode
im.save("out.jpg", quality=80, subsampling="4:4:4")
Enter fullscreen mode Exit fullscreen mode
  1. Keep 4:2:0 for photos. Colour in a photo is soft already, and the saving is real.

If your pipeline cannot tell a screenshot from a photo, a rough test works: count unique colours, or check how much of the frame is flat. To try a single file without code, use the Blinkimg JPEG compressor, and the PNG vs JPG guide covers when a screenshot should stay PNG.

How I measured

  • Machine: Apple M1 Pro, macOS, 2026-10-17.
  • Node v24.22.0, sharp 0.35.4, libvips 8.18.6, mozjpeg as bundled with sharp.
  • Python 3.9.6, Pillow 11.3.0 in a fresh venv (for the default-subsampling check only).
  • Source: apps/web/assets/reference/screenshot.png in the Blinkimg repo, a 2880 × 1800 Retina screenshot of Blinkimg's own pricing page (2x), and that image halved to 1440 × 900 (1x) with sharp's default resize.
  • PSNR is computed over RGB against the PNG. The button box is 260 × 64 at (740, 624) in the 1x image, and the same box doubled in the 2x image.

What I did not test

Other encoders (libjpeg-turbo without mozjpeg, Guetzli, jpegli), 4:2:2, and dark-mode screenshots. I did not run a perceptual metric such as SSIMULACRA 2.


I build Blinkimg, an image compressor and converter with a developer API. I wrote this article with help from an AI assistant (Claude), which ran the code on my machine; every number in it comes from those runs.

Top comments (0)