Jpeg
-
JPEG Quality 80 Is Not a Setting
MP4’s boxes are exact about everything: every byte accounted for, every offset written down. JPEG is exact about its structure too, and then hands you one control that has no defined meaning at all.
It destroys things. That is the entire point, and it will not tell you how much.
Here is one 512x512 PNG, encoded to JPEG four times, on the same machine, with every encoder set to quality 80.
q80-cjpeg.jpg 42595 bytes (libjpeg-turbo 3.2.0) q80-pillow.jpg 42595 bytes (Pillow 12.3.0) q80-sips.jpg 67865 bytes (macOS sips) q80-ffmpeg.jpg 13847 bytes (ffmpeg -q:v 80)Same input. Same number typed into the same-named parameter. A 5x spread in output size.
The Number Is an Index, Not a Measurement
There is no quality field in a JPEG file. Nothing in ISO/IEC 10918-1 defines a scale from 0 to 100. What the file actually carries is a quantization table: 64 integers that every DCT coefficient in an 8x8 block gets divided by before rounding. Bigger divisors, more coefficients rounded to zero, smaller file, more damage.
“Quality 80” is just a name your encoder gives to one particular table. Here is the first row of the luminance table each of those four files chose:
cjpeg 6 4 4 6 10 16 20 24 pillow 6 4 4 6 10 16 20 24 sips 2 2 2 3 4 5 7 8 ffmpeg 8 62 73 85 100 104 112 131cjpeg and Pillow are identical because Pillow links libjpeg and inherits its table. Both are the standard Annex K example table scaled by a linear formula: at quality 80 the scale factor is 40%, and 16 x 0.40 rounds to 6, 11 x 0.40 rounds to 4, and so on down the row.
macOS
sipsdivides by 2 where libjpeg divides by 6. Its quality 80 lands somewhere around libjpeg’s 93, and it is not the standard table scaled differently, it is a different table. Apple picked their own numbers.ffmpeg is the funny one. Its
-q:vfor MJPEG runs 2 to 31, where lower is better. So-q:v 80gets clamped to 31, the worst setting it has:$ ffmpeg -i source.png -q:v 31 f31.jpg $ cmp f31.jpg q80-ffmpeg.jpg $Byte-identical. Asking ffmpeg for 80 asks it for the ugliest image it knows how to make, and it does not warn you, because 80 is a perfectly valid thing to say to a parameter that happens to top out at 31.
None of these encoders is wrong. The specification never told them what 80 means.
The Damage Is Mostly Not Where You Think
The DCT-and-quantize step gets all the attention. It is not usually the thing wrecking your image.
Before any of that happens, the encoder converts RGB to YCbCr and then, by default, throws away three quarters of the color information. 4:2:0 subsampling averages the two chroma channels over 2x2 pixel blocks. Human vision is much less sensitive to color detail than to brightness detail, so most of the time you cannot see it.
Most of the time. Here is a 400x120 image, pure red on pure blue, encoded at quality 95 both ways:
size max channel error mean error 4:4:4 (no subsampling) 19688 bytes 20 0.59 4:2:0 (default) 10782 bytes 232 14.70Quality 95 is a setting people reach for when they want the image to be basically untouched. At 4:2:0 a single channel is off by 232 out of 255. The red and blue have the same luminance, so the entire edge between them lives in chroma, and chroma is the part that got averaged away.
This is why red text on a colored background looks like it was scanned by a fax machine, why UI screenshots with colored syntax highlighting come out muddy, and why a logo saved as JPEG at “high quality” still has a smeared halo. Turning subsampling off costs about 80% more bytes here and takes the error from 232 to 20.
cjpeg -sample 1x1does it. In Pillow it issubsampling=0. Almost no tool exposes it in a GUI, and almost every default is 4:2:0.
Generation Loss Converges
The folk wisdom is that re-saving a JPEG degrades it a little more each time, forever, until it turns to soup. I re-encoded the same image 50 times at quality 85, decoding and re-encoding each round, and measured the drift from the original:
gen size(bytes) mean err max err 1 48911 3.51 194 2 49532 4.11 188 5 49351 5.21 197 10 49299 6.17 199 25 49249 7.20 190 50 49189 7.54 190Most of the loss happens on save one. Generation two adds about half a point. Generations 25 through 50 add a third of a point between them, and the maximum error never moves at all.
It converges because quantization is idempotent once you land on the grid. Decode a coefficient that was rounded to 6 times its divisor, transform it back, and it quantizes to the same bucket. The image reaches a fixed point that survives re-encoding. What breaks this is changing anything: a different quality, a different subsampling mode, a crop that shifts the 8x8 block boundaries, or a rotation that resamples. Then you land on a new grid and pay the first-generation cost again.
Which is the actual reason
jpegtranexists:$ jpegtran -rotate 180 -outfile r1.jpg original.jpg $ jpegtran -rotate 180 -outfile r2.jpg r1.jpg $ cmp original.jpg r2.jpg $Two 180-degree rotations, byte-identical to the input.
jpegtranpermutes the already-quantized coefficient blocks without ever decoding to pixels, so there is nothing to re-quantize. Rotating, flipping, and cropping on 8-pixel boundaries are all lossless if you use the right tool. Almost nobody does.
What the File Looks Like
Worth thirty seconds, because the structure is unusually clean. Every marker is
0xFFfollowed by a type byte, and every marker except the two bare ones carries a 2-byte big-endian length:$ xxd -g 1 -l 32 q80-cjpeg.jpg 00000000: ff d8 ff e0 00 10 4a 46 49 46 00 01 01 00 00 01 ......JFIF...... 00000010: 00 01 00 00 ff db 00 43 00 06 04 05 06 05 04 06 .......C........ffd8is Start of Image.ffe0is APP0, length0x0010, containing the literal stringJFIF\0. Then at offset 20,ffdbis Define Quantization Table, length 67, table 0, and the bytes after it are the 64 divisors in zigzag order.06 04 05 06 05 04is that first row I printed above, read diagonally.Walking the whole file:
0 FFD8 SOI 2 FFE0 APP0 length 16 20 FFDB DQT length 67 89 FFDB DQT length 67 158 FFC0 SOF0 length 17 177 FFC4 DHT length 31 210 FFC4 DHT length 181 393 FFC4 DHT length 31 426 FFC4 DHT length 181 609 FFDA SOS length 12 42593 FFD9 EOIEverything structural fits in the first 623 bytes. The remaining 42,000 are entropy-coded scan data with no framing at all, which creates one last problem: if a Huffman code happens to emit the byte
0xFF, a decoder scanning for markers would misread it. So the encoder stuffs a0x00after every literal0xFF, and the decoder throws it away. This file contains 504 of those.
What To Do About It
- Never move a quality number between tools. Quality 80 in Photoshop,
cjpeg,sips, and ffmpeg are four unrelated things. If you are porting a pipeline, re-tune by measuring output size or error, not by copying the integer. - Turn off chroma subsampling for anything with saturated color edges. Logos, screenshots, charts, text.
-sample 1x1in cjpeg,subsampling=0in Pillow. Leave 4:2:0 on for photographs, where it is nearly free. - Use
jpegtranfor rotations and crops.-rotate,-flip,-crop, and-perfectoperate on coefficients and cost nothing. - Stop worrying about generation loss and start worrying about generation one. The first save is where the damage is. Keep the original.
- Don’t put a JPEG in the middle of a pipeline. Every intermediate step should be PNG or the raw source. JPEG is an output format.
- Strip the metadata deliberately. APP1 holds Exif, which holds GPS coordinates, camera serial numbers, and an embedded thumbnail. Some editors update the pixels and leave the old thumbnail in place, so the crop you made survives only in the big version.
The interesting thing about JPEG is not that it is lossy. Everyone signed up for lossy. It is that the one control the format exposes to users, the quality number, is not part of the format, has no defined meaning, and is quietly reinterpreted by every tool that offers it. You are not setting the quality. You are picking a preset out of a list you cannot see.
Sources
- ITU-T T.81 / ISO/IEC 10918-1 — the core JPEG specification; Annex K holds the example quantization tables everybody scales
- ITU-T T.871 — JFIF, standardized in 2011, nineteen years after the industry started shipping it
- W3C copy of the JFIF 1.02 specification — the original C-Cube document from September 1992
- Independent JPEG Group — libjpeg, whose quality-to-table formula became the de facto meaning of the number
- libjpeg-turbo — what almost everything actually links against today
I’d appreciate a follow. You can subscribe with your email below. The emails go out once a week, or you can find me on Mastodon at @[email protected].
- Never move a quality number between tools. Quality 80 in Photoshop,