JPEG 截圖中的文字,4:2:0 與 4:4:4 色度抽樣效果比較
Chroma subsampling 4:2:0 vs 4:4:4 for screenshots with text
Sahadat Husain 測試 JPEG 截圖後指出,彩色背景文字使用 4:4:4,比單純提高 4:2:0 品質更能保留邊緣。其 1x 截圖測試中,quality 80、4:4:4 的彩色按鈕區域 PSNR 為 35.83 dB,高於 quality 90、4:2:0 的 33.29 dB,整個 JPEG 檔案亦小 19,871 bytes。
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")))
75 2
80 2
90 2
95 2
4:4:4 forced: 0
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`);
}
}
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
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);
}
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
| 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.
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?
- 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).
- 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');
im.save("out.jpg", quality=80, subsampling="4:4:4")
- 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.pngin 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.
來源:DEV Community · dev.to

