<rss version="2.0">
  <channel>
    <title>Jpeg on LLBBL Blog</title>
    <link>https://llbbl.blog/categories/jpeg/</link>
    <description></description>
    
    <language>en</language>
    
    <lastBuildDate>Mon, 24 Aug 2026 10:00:00 -0500</lastBuildDate>
    
    <item>
      <title>JPEG Quality 80 Is Not a Setting</title>
      <link>https://llbbl.blog/2026/08/24/jpeg-quality-is-not-a.html</link>
      <pubDate>Mon, 24 Aug 2026 10:00:00 -0500</pubDate>
      
      <guid>http://llbbl.micro.blog/2026/08/24/jpeg-quality-is-not-a.html</guid>
      <description>&lt;p&gt;MP4&amp;rsquo;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.&lt;/p&gt;
&lt;p&gt;It destroys things. That is the entire point, and it will not tell you how much.&lt;/p&gt;
&lt;p&gt;Here is one 512x512 PNG, encoded to JPEG four times, on the same machine, with every encoder set to quality 80.&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;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)
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Same input. Same number typed into the same-named parameter. A 5x spread in output size.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;the-number-is-an-index-not-a-measurement&#34;&gt;The Number Is an Index, Not a Measurement&lt;/h2&gt;
&lt;p&gt;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 &lt;strong&gt;quantization table&lt;/strong&gt;: 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.&lt;/p&gt;
&lt;p&gt;&amp;ldquo;Quality 80&amp;rdquo; 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:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;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 131
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;cjpeg 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.&lt;/p&gt;
&lt;p&gt;macOS &lt;code&gt;sips&lt;/code&gt; divides by 2 where libjpeg divides by 6. Its quality 80 lands somewhere around libjpeg&amp;rsquo;s 93, and it is not the standard table scaled differently, it is a different table. Apple picked their own numbers.&lt;/p&gt;
&lt;p&gt;ffmpeg is the funny one. Its &lt;code&gt;-q:v&lt;/code&gt; for MJPEG runs 2 to 31, where &lt;strong&gt;lower&lt;/strong&gt; is better. So &lt;code&gt;-q:v 80&lt;/code&gt; gets clamped to 31, the worst setting it has:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;$ ffmpeg -i source.png -q:v 31 f31.jpg
$ cmp f31.jpg q80-ffmpeg.jpg
$
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;None of these encoders is wrong. The specification never told them what 80 means.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;the-damage-is-mostly-not-where-you-think&#34;&gt;The Damage Is Mostly Not Where You Think&lt;/h2&gt;
&lt;p&gt;The DCT-and-quantize step gets all the attention. It is not usually the thing wrecking your image.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;Most of the time. Here is a 400x120 image, pure red on pure blue, encoded at &lt;strong&gt;quality 95&lt;/strong&gt; both ways:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;                    size      max channel error   mean error
4:4:4 (no subsampling)   19688 bytes          20         0.59
4:2:0 (default)          10782 bytes         232        14.70
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Quality 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.&lt;/p&gt;
&lt;p&gt;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 &amp;ldquo;high quality&amp;rdquo; still has a smeared halo. Turning subsampling off costs about 80% more bytes here and takes the error from 232 to 20.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;cjpeg -sample 1x1&lt;/code&gt; does it. In Pillow it is &lt;code&gt;subsampling=0&lt;/code&gt;. Almost no tool exposes it in a GUI, and almost every default is 4:2:0.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;generation-loss-converges&#34;&gt;Generation Loss Converges&lt;/h2&gt;
&lt;p&gt;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:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;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        190
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Most 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.&lt;/p&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;p&gt;Which is the actual reason &lt;code&gt;jpegtran&lt;/code&gt; exists:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;$ jpegtran -rotate 180 -outfile r1.jpg original.jpg
$ jpegtran -rotate 180 -outfile r2.jpg r1.jpg
$ cmp original.jpg r2.jpg
$
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Two 180-degree rotations, byte-identical to the input. &lt;code&gt;jpegtran&lt;/code&gt; permutes 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.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;what-the-file-looks-like&#34;&gt;What the File Looks Like&lt;/h2&gt;
&lt;p&gt;Worth thirty seconds, because the structure is unusually clean. Every marker is &lt;code&gt;0xFF&lt;/code&gt; followed by a type byte, and every marker except the two bare ones carries a 2-byte big-endian length:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;$ 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........
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;ffd8&lt;/code&gt; is Start of Image. &lt;code&gt;ffe0&lt;/code&gt; is APP0, length &lt;code&gt;0x0010&lt;/code&gt;, containing the literal string &lt;code&gt;JFIF\0&lt;/code&gt;. Then at offset 20, &lt;code&gt;ffdb&lt;/code&gt; is Define Quantization Table, length 67, table 0, and the bytes after it are the 64 divisors in zigzag order. &lt;code&gt;06 04 05 06 05 04&lt;/code&gt; is that first row I printed above, read diagonally.&lt;/p&gt;
&lt;p&gt;Walking the whole file:&lt;/p&gt;
&lt;pre tabindex=&#34;0&#34;&gt;&lt;code&gt;     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  EOI
&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Everything 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 &lt;code&gt;0xFF&lt;/code&gt;, a decoder scanning for markers would misread it. So the encoder stuffs a &lt;code&gt;0x00&lt;/code&gt; after every literal &lt;code&gt;0xFF&lt;/code&gt;, and the decoder throws it away. This file contains 504 of those.&lt;/p&gt;
&lt;hr&gt;
&lt;h2 id=&#34;what-to-do-about-it&#34;&gt;What To Do About It&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Never move a quality number between tools.&lt;/strong&gt; Quality 80 in Photoshop, &lt;code&gt;cjpeg&lt;/code&gt;, &lt;code&gt;sips&lt;/code&gt;, 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.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Turn off chroma subsampling for anything with saturated color edges.&lt;/strong&gt; Logos, screenshots, charts, text. &lt;code&gt;-sample 1x1&lt;/code&gt; in cjpeg, &lt;code&gt;subsampling=0&lt;/code&gt; in Pillow. Leave 4:2:0 on for photographs, where it is nearly free.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Use &lt;code&gt;jpegtran&lt;/code&gt; for rotations and crops.&lt;/strong&gt; &lt;code&gt;-rotate&lt;/code&gt;, &lt;code&gt;-flip&lt;/code&gt;, &lt;code&gt;-crop&lt;/code&gt;, and &lt;code&gt;-perfect&lt;/code&gt; operate on coefficients and cost nothing.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stop worrying about generation loss and start worrying about generation one.&lt;/strong&gt; The first save is where the damage is. Keep the original.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Don&amp;rsquo;t put a JPEG in the middle of a pipeline.&lt;/strong&gt; Every intermediate step should be PNG or the raw source. JPEG is an output format.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Strip the metadata deliberately.&lt;/strong&gt; 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.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;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.&lt;/p&gt;
&lt;h2 id=&#34;sources&#34;&gt;Sources&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href=&#34;https://www.itu.int/rec/T-REC-T.81-199209-I&#34;&gt;ITU-T T.81 / ISO/IEC 10918-1&lt;/a&gt; — the core JPEG specification; Annex K holds the example quantization tables everybody scales&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://www.itu.int/rec/T-REC-T.871&#34;&gt;ITU-T T.871&lt;/a&gt; — JFIF, standardized in 2011, nineteen years after the industry started shipping it&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://www.w3.org/Graphics/JPEG/jfif3.pdf&#34;&gt;W3C copy of the JFIF 1.02 specification&lt;/a&gt; — the original C-Cube document from September 1992&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://ijg.org/&#34;&gt;Independent JPEG Group&lt;/a&gt; — libjpeg, whose quality-to-table formula became the de facto meaning of the number&lt;/li&gt;
&lt;li&gt;&lt;a href=&#34;https://libjpeg-turbo.org/&#34;&gt;libjpeg-turbo&lt;/a&gt; — what almost everything actually links against today&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote&gt;
&lt;p&gt;I&amp;rsquo;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 &lt;a href=&#34;https://micro.blog/llbbl?remote_follow=1&#34;&gt;@logan@llbbl.blog&lt;/a&gt;.&lt;/p&gt;
&lt;/blockquote&gt;
</description>
    </item>
    
  </channel>
</rss>