DEV Community

Cover image for Pick JPEG quality by an SSIM target instead of a fixed number
Sahadat Husain
Sahadat Husain

Posted on

Pick JPEG quality by an SSIM target instead of a fixed number

A fixed JPEG quality gives a different visual result on every image. Pick the quality per image instead: encode, measure SSIM against the original, and binary-search for the lowest quality that reaches your target. In my run, an SSIM target of 0.97 chose quality 21 for a screenshot, 52 for one photo and 74 for another, and the screenshot came out 37.9 KB instead of 90.6 KB at quality 75.

Why does one quality number not fit every image?

The quality setting scales the quantization tables. It does not promise a level of fidelity. A noisy camera photo loses more detail at quality 75 than a flat screenshot does. Here is what quality 75 scored on three files at 1920 px wide:

File Bytes at q75 SSIM at q75
photo.jpg (iguana, 1920 × 1280) 253.3 KB 0.9828
screenshot.png (1920 × 1200) 90.6 KB 0.9918
camera.jpg (boat at sunset, 1920 × 1076) 125.8 KB 0.9716

So the same setting overshoots on the screenshot and undershoots on the camera photo. An SSIM target fixes the result and lets the bytes vary.

What is SSIM, in one paragraph?

SSIM (structural similarity) compares two images window by window on brightness, contrast and structure, and averages the result. 1.0 means identical. It was introduced by Wang, Bovik, Sheikh and Simoncelli in 2004 (the authors' SSIM page). It tracks what people notice better than PSNR does, because it looks at local structure and not raw pixel error.

The search, in Node with sharp

The code below computes SSIM on luma over 8 × 8 windows at a stride of 4, then binary-searches quality from 1 to 100. It assumes SSIM rises with quality, which held on every file I tried.

import sharp from 'sharp';

// Luma (BT.601) of an image as a Float64Array.
async function luma(input) {
  const { data, info } = await sharp(input).removeAlpha().raw()
    .toBuffer({ resolveWithObject: true });
  const y = new Float64Array(info.width * info.height);
  for (let i = 0, p = 0; i < y.length; i++, p += info.channels)
    y[i] = 0.299 * data[p] + 0.587 * data[p + 1] + 0.114 * data[p + 2];
  return { y, w: info.width, h: info.height };
}

// Mean SSIM over 8 x 8 windows, stride 4.
function ssim(a, b, w, h) {
  const C1 = (0.01 * 255) ** 2, C2 = (0.03 * 255) ** 2;
  let total = 0, n = 0;
  for (let y0 = 0; y0 + 8 <= h; y0 += 4) {
    for (let x0 = 0; x0 + 8 <= w; x0 += 4) {
      let sa = 0, sb = 0, saa = 0, sbb = 0, sab = 0;
      for (let y = y0; y < y0 + 8; y++) {
        for (let x = x0; x < x0 + 8; x++) {
          const i = y * w + x, va = a[i], vb = b[i];
          sa += va; sb += vb; saa += va * va; sbb += vb * vb; sab += va * vb;
        }
      }
      const ma = sa / 64, mb = sb / 64;
      const va = saa / 64 - ma * ma, vb = sbb / 64 - mb * mb, cov = sab / 64 - ma * mb;
      total += ((2 * ma * mb + C1) * (2 * cov + C2)) /
               ((ma * ma + mb * mb + C1) * (va + vb + C2));
      n++;
    }
  }
  return total / n;
}

// Lowest JPEG quality whose SSIM reaches `target`: binary search over 1 to 100.
export async function qualityFor(input, target) {
  const ref = await luma(input);
  const tried = new Map();
  async function score(q) {
    if (!tried.has(q)) {
      const buf = await sharp(input).jpeg({ quality: q, mozjpeg: true }).toBuffer();
      const out = await luma(buf);
      tried.set(q, { q, bytes: buf.length, ssim: ssim(ref.y, out.y, ref.w, ref.h) });
    }
    return tried.get(q);
  }
  let lo = 1, hi = 100;
  while (lo < hi) {
    const mid = (lo + hi) >> 1;
    if ((await score(mid)).ssim >= target) hi = mid; else lo = mid + 1;
  }
  return { ...(await score(lo)), encodes: tried.size };
}
Enter fullscreen mode Exit fullscreen mode

The driver decodes each file once to PNG at 1920 px wide, so every candidate starts from the same pixels, then tries three targets.

What did it pick?

photo.jpg 1920x1280  fixed q75: 253349 B  ssim 0.9828
  target 0.95: q31  113979 B  ssim 0.9509  7 encodes  1014 ms
  target 0.97: q52  166433 B  ssim 0.9701  7 encodes  1259 ms
  target 0.99: q88  387217 B  ssim 0.9902  6 encodes  1407 ms
screenshot.png 1920x1200  fixed q75: 90566 B  ssim 0.9918
  target 0.95: q10  23612 B  ssim 0.9507  6 encodes  548 ms
  target 0.97: q21  37910 B  ssim 0.9716  7 encodes  666 ms
  target 0.99: q69  81470 B  ssim 0.9900  6 encodes  708 ms
camera.jpg 1920x1076  fixed q75: 125834 B  ssim 0.9716
  target 0.95: q45  67354 B  ssim 0.9502  7 encodes  837 ms
  target 0.97: q74  125403 B  ssim 0.9715  7 encodes  978 ms
  target 0.99: q95  364076 B  ssim 0.9905  7 encodes  1532 ms
Enter fullscreen mode Exit fullscreen mode
File Target 0.95 Target 0.97 Target 0.99
photo.jpg q31, 114.0 KB q52, 166.4 KB q88, 387.2 KB
screenshot.png q10, 23.6 KB q21, 37.9 KB q69, 81.5 KB
camera.jpg q45, 67.4 KB q74, 125.4 KB q95, 364.1 KB

Bars: fixed q75 against an SSIM 0.97 target; photo.jpg 253.3 KB to 166.4 KB, screenshot.png 90.6 KB to 37.9 KB, camera.jpg 125.8 KB to 125.4 KB

At target 0.97 the quality ranged from 21 to 74. The camera photo landed almost exactly where q75 already was, so the search saved it nothing. The other two files dropped by 34% and 58%. Each search took 6 or 7 encodes, as a binary search over 100 values should.

Where does an SSIM target go wrong?

Note: this SSIM reads luma only. JPEG's chroma subsampling smears coloured text and thin coloured lines, and a luma score never sees it. The screenshot at q10 scored 0.9507, yet its green label text turned grey and blocky. For screenshots, keep a floor on quality or compare chroma too.

The green 2 MONTHS FREE label from the screenshot, 3 times zoomed: crisp green text in the source, grey blocky text at JPEG quality 10

Two more limits. Mean SSIM averages the whole frame, so a damaged face in a large sky can still score well. And the search costs 6 to 7 encodes per image; my timings above ran on a busy machine, so treat the milliseconds as rough. Run it in a background worker, not in a request.

Which target should you pick?

Start at 0.97 for photos, look at the results at 100% zoom, then move by 0.01. Above 0.99 the bytes climb fast: the camera photo needed 364.1 KB for 0.99, nearly 3 times its 0.97 size. If a hard byte budget matters more than a fidelity target, search on size instead; that is what the Max file size field of the JPEG compressor does, and targetBytes in its API.

How I measured

  • Machine: Apple M1 Pro (8 cores), macOS, 2026-10-17. Other jobs ran on the machine at the same time, so the millisecond figures are rough; the bytes and SSIM are deterministic.
  • Node v24.22.0, sharp 0.35.4, libvips 8.18.6, mozjpeg as bundled with sharp.
  • Files: photo.jpg (1920 × 1280) and screenshot.png (2880 × 1800, resized to 1920 × 1200) from the site's reference set; camera.jpg is the site's 4964 × 2782 camera photo, resized to 1920 × 1076.
  • SSIM: my implementation above, luma only, 8 × 8 windows, stride 4, mean over all windows.

What I did not test

Gaussian-window SSIM, MS-SSIM, SSIMULACRA 2 or Butteraugli. WebP and AVIF, where the same search works but the quality-to-SSIM curve differs.


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)