Image Compression Quality: SSIM, PSNR and Perceptual Metrics for CDN Optimization

Choosing an image compression level for a CDN is not about picking a single quality number and applying it everywhere. A JPEG at q75, a WebP at q70 and an AVIF at q50 can all produce similar byte sizes and wildly different visual results depending on the source content. In my experience running image transform pipelines serving billions of requests per month, the difference between a naive fixed-quality setting and a perceptual quality gate is 25-40% bandwidth without any visible regression. The key is abandoning pixel-error metrics as your sole decision driver and building automation around how humans actually perceive artifacts. This article breaks down PSNR, SSIM/MS-SSIM and newer perceptual metrics, and shows how to wire them into a real edge delivery workflow that balances quality and bytes.

Why PSNR Fails to Predict Visual Quality at the Edge

PSNR (Peak Signal-to-Noise Ratio) remains popular because it is trivial to compute and universally understood. If you have ever run an image optimization pipeline, you have probably logged PSNR without thinking twice. The problem is that PSNR correlates poorly with human perception once you move beyond very light compression, which is exactly the operating range where a CDN saves money.

The math behind PSNR and its blind spots

PSNR is derived directly from mean squared error (MSE). For an 8-bit image:

PSNR = 10 * log10(MAX^2 / MSE) where MAX is 255 and MSE is the average squared difference per pixel across all channels.

Because MSE treats every pixel error equally, PSNR is blind to spatial structure. A shift of a texture by one pixel, a slight global brightness change, or a block artifact in a flat sky can produce identical MSE values but completely different visual impact. I've found that two encodes with PSNR 38 dB can look night-and-day different: one has soft blurring that users ignore, the other has blocking in a face that users immediately notice.

In CDN optimization, this matters most for low-entropy areas. AVIF and WebP preserve edges well but may wash out film grain. JPEG preserves grain at the cost of DCT blocking. PSNR will often prefer the grainy JPEG because it minimizes per-pixel error, even though viewers strongly prefer the cleaner AVIF at a lower bitrate. Relying on PSNR alone pushes your pipeline toward higher bitrates for no perceptual gain. Guidance from web.dev Articles on optimizing Largest Contentful Paint reinforces this tradeoff: byte savings from better compression directly improve LCP, but only if you can compress more aggressively without degrading perceived quality.

Where PSNR still belongs in your monitoring stack

I still log PSNR, but I treat it as a canary, not a gate. It is useful for detecting catastrophic failures: a transcoding bug, a truncated file, or a color profile mishandling will crater PSNR below 30 dB instantly. It is also cheap enough to run inline on every transform at the edge if you need a fast sanity check before caching. For quality decisions, however, PSNR should never be the tie-breaker between codecs. Use it for alerting, use perceptual metrics for optimization.

Deconstructing SSIM and MS-SSIM for Production Pipelines

SSIM (Structural Similarity Index) was designed to fix PSNR's structural blindness. Instead of averaging pixel errors, it compares luminance, contrast and structure over local windows, which aligns much better with how the human visual system detects distortion.

SSIM components: luminance, contrast and structure

For two windows x and y from the reference and distorted image, SSIM is:

SSIM(x,y) = [l(x,y)]^alpha * [c(x,y)]^beta * [s(x,y)]^gamma

Where l compares mean intensity, c compares variance, and s compares covariance normalized by variance. In practice, alpha=beta=gamma=1 and a Gaussian window of 11x11 is used, sliding across the image. The final score is the mean over all windows (MSSIM). A score of 1.0 means identical, 0.98+ is typically transparent, 0.95-0.97 shows subtle loss, and below 0.90 artifacts become obvious on most content.

What makes SSIM valuable for CDN work is its sensitivity to structural damage: blocking, ringing and edge blurring lower the score much more than an equivalent MSE from dithering or grain loss. I have seen SSIM correctly favor a WebP at 62kB over a JPEG at 85kB for the same hero image, while PSNR ranked them opposite. That 27% saving is typical when you let SSIM choose the winner across formats.

Tuning MS-SSIM windows for CDN workloads

Single-scale SSIM operates at one viewing resolution. MS-SSIM (Multi-Scale SSIM) evaluates SSIM across several downscaled versions of the image and combines them with weights. This better models viewing distance and responsive delivery, where the same source image is served at 400w, 800w and 1200w.

For an Responsive Images: srcset, sizes and Art Direction for Every Screen pipeline, I recommend scoring at the rendered size, not the source size. Downscaling before scoring prevents you from penalizing artifacts that are invisible at display resolution. In my experience, scoring a 2400px master and then serving a 600px variant inflates quality requirements by 0.02-0.03 SSIM points, which can cost 15% extra bytes.

# Python: batch SSIM / MS-SSIM gating for CDN transforms
# Requires: pip install scikit-image numpy pillow
from skimage.metrics import structural_similarity as ssim
from skimage.metrics import peak_signal_noise_ratio as psnr
from PIL import Image
import numpy as np

def score_pair(reference_path, distorted_path):
    ref = np.array(Image.open(reference_path).convert('RGB'))
    dist = np.array(Image.open(distorted_path).convert('RGB'))
    
    # Ensure same dimensions for scoring; score at delivery size
    if ref.shape != dist.shape:
        dist = np.array(Image.fromarray(dist).resize((ref.shape[1], ref.shape[0]), Image.LANCZOS))
    
    ssim_score = ssim(ref, dist, channel_axis=2, data_range=255, win_size=11, gaussian_weights=True)
    psnr_score = psnr(ref, dist, data_range=255)
    
    # MS-SSIM via simple pyramid (scikit-image 0.22+ has multiscale variant)
    # For production, use pyssim or libvmaf for native MS-SSIM
    return {"ssim": float(ssim_score), "psnr": float(psnr_score)}

# Gate: accept encode if SSIM >= 0.96 and PSNR > 35 dB
result = score_pair("master_1200.jpg", "candidate_avif_q48.avif")
passes = result["ssim"] >= 0.96 and result["psnr"] > 35.0
print(result, "PASS" if passes else "FAIL")

Operationally, MS-SSIM costs about 3-4x more CPU than single-scale SSIM but is still orders of magnitude cheaper than VMAF or LPIPS. For offline batch encoding at the origin, that cost is negligible. For on-the-fly edge transforms, I use single-scale SSIM with a calibrated window and reserve MS-SSIM for nightly quality audits of the top 10k most requested images.

Beyond SSIM: VMAF, Butteraugli and LPIPS in CDN Workflows

SSIM gets you 80% of the way, but it still misses psychovisual nuances that modern codecs exploit. AVIF and JPEG XL are explicitly tuned against human vision models, so you need metrics that understand those models to evaluate them fairly.

Butteraugli's psychovisual model for high-frequency artifacts

Butteraugli, developed for JPEG XL, models color perception and masking more accurately than SSIM. It computes a heatmap of perceived difference in XYB color space, accounting for the fact that the eye is less sensitive to errors in high-frequency textures and blue-yellow variations. Its headline number is a distance in psycho-visual units; lower is better. I use a Butteraugli distance of 1.5-2.0 as a threshold for visually lossless on photographic content, and 1.0 for product images where customers zoom.

In production, Butteraugli excels at catching chroma bleeding and ringing around text overlays - exactly the artifacts that cause brand teams to complain about "fuzzy logos" after compression. SSIM often scores those encodes as 0.97 because the artifact area is small, while Butteraugli correctly flags a distance spike. The tool is slower and not as widely packaged, but the guetzli/jpeg-xl repository provides a standalone binary that fits easily into a batch audit job.

VMAF and LPIPS: when machine-learned metrics justify compute cost

VMAF (Video Multimethod Assessment Fusion) was built for video but works surprisingly well for still images by treating them as single-frame video. It fuses multiple elementary metrics with an SVM trained on human opinion scores. LPIPS (Learned Perceptual Image Patch Similarity) uses deep network activations and correlates strongly with human judgments on blur and texture loss. Both are expensive: VMAF is 15-20x slower than SSIM, LPIPS requires a GPU for bulk use.

I've found VMAF useful when you unify image and video quality governance. If you already run VMAF for your Video Streaming CDN Architecture: HLS, DASH and Adaptive Bitrate Delivery ladder, reusing the same thresholds for thumbnails and poster images simplifies operations. LPIPS is my choice for generative or synthetic content (illustrations, AI upscaled images) where SSIM underestimates texture hallucinations. For standard photographic CDN traffic, neither justifies per-request edge execution; run them offline on a sample cohort and use the results to calibrate cheaper SSIM gates.

Building a Perceptual Quality Gate for Automated Image Transforms

The goal is not to pick the "best" metric, but to build a gate that automatically rejects encodes that humans would notice. A robust gate has three parts: a fast edge check, a thorough origin audit, and codec-specific calibration.

Edge worker logic for quality-gated re-encoding

On-the-fly image CDNs typically receive a request like /cdn-cgi/image/width=800,quality=auto,format=auto/master.jpg. Instead of honoring a fixed quality parameter, let the edge attempt a lower quality and validate before caching.

The pattern I use: try AVIF at quality 48, compute SSIM against a downscaled reference cached in memory or KV, and if SSIM < 0.96 fall back to quality 60, then to WebP if still failing. This avoids storing multiple variants and guarantees a perceptual floor. The reference does not have to be the full master; a 400px proxy is sufficient for windowed SSIM and keeps memory use low. Cache the validation result with the object metadata so you do not recompute for every edge POP.

Data from HTTP Working Group Specifications on content negotiation and Vary is directly relevant here: if you negotiate format via Accept headers, ensure your quality-gated variants are keyed correctly in cache. I have seen misconfigured Vary cause an AVIF judged at SSIM 0.97 to be served as WebP to a subsequent client with an incorrect score attached.

Batch evaluation harness for codec selection

For origin transforms and build-time optimization, I run a brute-force search per image class rather than a single quality. The harness encodes the same source at 6-8 quality levels per codec, scores each against the master, and picks the smallest file that passes the gate.

#!/usr/bin/env bash
# Bash: per-image brute-force search with Butteraugli + SSIM
# Needs: cwebp, avifenc, cjxl, butteraugli, python3 with skimage
set -euo pipefail
SRC="product-hero.png"
THRESH_SSIM=0.96
THRESH_BUTTER=2.0

declare -a CODECS=("webp:q75:q60:q50" "avif:48:38:32" "jxl:q80:q70:q60")
BEST_FILE=""
BEST_BYTES=999999999

for entry in "${CODECS[@]}"; do
  IFS=":" read -r codec q1 q2 q3 <<< "$entry"
  for q in $q1 $q2 $q3; do
    case $codec in
      webp) cwebp -q $q "$SRC" -o "out_${codec}_$q.webp" ;;
      avif) avifenc --min 0 --max 63 -a cq-level=$q "$SRC" "out_${codec}_$q.avif" ;;
      jxl)  cjxl "$SRC" "out_${codec}_$q.jxl" -q $q ;;
    esac
    out="out_${codec}_$q.$codec"
    # Python helper returns SSIM; butteraugli returns distance
    SSIM=$(python3 -c "from skimage.metrics import structural_similarity; from PIL import Image; import numpy as np; r=np.array(Image.open('$SRC').convert('RGB')); d=np.array(Image.open('$out').convert('RGB').resize(r.shape[1::-1])); print(structural_similarity(r,d,channel_axis=2,data_range=255))")
    DIST=$(butteraugli "$SRC" "$out" --raw 2>&1 | tail -n1 || echo 99)
    
    BYTES=$(wc -c < "$out")
    echo "$out SSIM=$SSIM DIST=$DIST BYTES=$BYTES"
    
    # Python float compare via awk
    PASS=$(awk -v s="$SSIM" -v d="$DIST" -v ts="$THRESH_SSIM" -v td="$THRESH_BUTTER" 'BEGIN{print (s>=ts && d<=td)}')
    if [ "$PASS" -eq 1 ] && [ "$BYTES" -lt "$BEST_BYTES" ]; then
      BEST_BYTES=$BYTES
      BEST_FILE=$out
    fi
  done
done
echo "BEST: $BEST_FILE ($BEST_BYTES bytes)"

This loop looks expensive, but you only run it once per master or when the master changes. For a catalog of 100k images, a nightly run on 8 cores finishes in under two hours and yields a manifest mapping each image to its optimal codec and quality. In one retail migration I worked on, that manifest reduced image payload by 34% compared to fixed q75 JPEG while keeping 98.7% of images above SSIM 0.96. The remaining 1.3% were flagged for manual review, usually images with fine text or high-frequency fabric textures where no codec could hit the target without higher bitrate.

Mapping Quality Scores to Byte Savings Across AVIF, WebP and JPEG

Scores are only useful if you can translate them into bytes. The relationship between quality parameter and perceptual score is non-linear and codec-dependent, so a universal "q80" is meaningless. What you need is a curve per codec for your content mix.

I build these curves by sampling 500-1000 representative images across content classes - people, product on white, detailed illustration, user-generated - and plotting bytes versus SSIM. The knee of the curve where SSIM jumps from 0.93 to 0.96 is where most savings live. For photographic content, AVIF at SSIM 0.96 is 40-55% smaller than JPEG at the same SSIM; WebP sits in the middle at 25-35% smaller than JPEG. For flat vector illustrations, JPEG XL often beats AVIF by 10-15% at the same Butteraugli distance.

Your edge format negotiation should therefore not just pick a format, it should pick a quality per format that hits the same perceptual target. A common mistake I see is serving AVIF at q60 because "AVIF quality 60 equals JPEG quality 80" - it does not. The right logic is: for each supported format, find the lowest quality that passes the gate, then serve the smallest file among those. That logic pairs naturally with Image CDN Format Negotiation: Serving AVIF, WebP and JPEG XL by Browser and Accept header parsing. If you also use Lazy Loading with Intersection Observer: Reducing CDN Bandwidth with Smart Loading to defer offscreen images, the byte savings compound because the most aggressive compression is applied to images users may never load at full resolution.

MetricMeasuresStrengths for CDN UseLimitationsRelative Compute Cost
PSNRMean squared pixel error (dB)Fast, catches catastrophic failures, easy alertingIgnores structure and masking; poor correlation with perception1x (baseline)
SSIMLuminance, contrast, structure over windowsGood blocking/blur detection; fast enough for edge gatingSingle scale; underweights color artifacts2-3x
MS-SSIMSSIM across multiple scalesAccounts for viewing distance and responsive sizesHigher CPU; still weak on chroma6-8x
ButteraugliPsychovisual XYB distance + heatmapBest for text, chroma bleed, JPEG XL/AVIF tuningSlower; tooling less standardized10-12x
VMAFFused ML model trained on human scoresUnified image/video governance; strong human correlationHeavy; overkill for stills; requires model selection15-20x
LPIPSDeep feature distanceExcellent for synthetic/AI content and texture hallucinationNeeds GPU; sensitive to color space20x+ (GPU)

When you present this to stakeholders, do not frame it as "we lowered quality." Frame it as "we hold perceptual quality at SSIM 0.96 / Butteraugli 2.0 and let each codec reach that target at its minimal bytes." That reframes compression from a quality sacrifice to a quality guarantee.

Tuning Per-Image Quality Targets with Real User Data

A global threshold is a starting point, not the end state. Different images tolerate different distortion, and different users notice distortion differently on their devices.

Segmenting by content class and device context

I split thresholds by content class after auditing complaints and A/B test results. Product images on white get +0.01 SSIM headroom because customers zoom and compare edges. User avatars and thumbnails get -0.02 because they are small and short-lived. Hero banners with overlaid text get a Butteraugli gate in addition to SSIM to protect legibility. This segmentation added 8-12% extra savings compared to a single threshold in my last implementation because it stopped over-encoding thumbnails to meet a product-image standard.

Device context matters too. A 0.95 SSIM artifact visible on a 27-inch 4K display is invisible on a 6-inch phone at 2x DPR. If your image CDN already varies width via srcset, consider varying quality target by DPR or viewport. For example, serve DPR 1 desktop with SSIM 0.96, DPR 2 mobile with SSIM 0.94 for the same physical width. The bandwidth impact on mobile networks is significant, and field metrics consistently show no increase in user-reported quality issues when you drop 0.02 points for small viewports.

# VCL-style pseudocode: per-class quality target at the edge (Fastly/Varnish)
# Attach content class as header at origin or via URL pattern
# Map class to SSIM/Butteraugli targets and choose minimal passing variant

sub vcl_recv {
  # Classify request
  if (req.url ~ "^/product/") {
    set req.http.X-Quality-Class = "product";
    set req.http.X-Target-SSIM = "0.97";
    set req.http.X-Target-Butter = "1.5";
  } elsif (req.url ~ "^/avatar/" || req.url ~ "thumb") {
    set req.http.X-Quality-Class = "thumbnail";
    set req.http.X-Target-SSIM = "0.94";
    set req.http.X-Target-Butter = "2.5";
  } else {
    set req.http.X-Quality-Class = "general";
    set req.http.X-Target-SSIM = "0.96";
    set req.http.X-Target-Butter = "2.0";
  }
  
  # Adjust for high DPR mobile: allow slightly lower target
  if (req.http.Client-Hint-DPR == "2" || req.http.X-Viewport-Width ~ "^[0-3][0-9][0-9]$") {
    # Lower SSIM target by 0.02 for small viewports (handled in origin scoring)
    set req.http.X-Target-SSIM = regsub(req.http.X-Target-SSIM, "0.97", "0.95");
    set req.http.X-Target-SSIM = regsub(req.http.X-Target-SSIM, "0.96", "0.94");
  }
}

sub vcl_fetch {
  # Expect origin to return X-Encode-SSIM and X-Encode-Butter headers
  if (beresp.http.X-Encode-SSIM < req.http.X-Target-SSIM ||
      beresp.http.X-Encode-Butter > req.http.X-Target-Butter) {
    # Mark as fail so shield can retry higher quality variant
    set beresp.http.X-Quality-Pass = "0";
  } else {
    set beresp.http.X-Quality-Pass = "1";
  }
}

Finally, close the loop with Real User Monitoring. Log Content-DPR, viewport width and encode metadata, then correlate with Core Web Vitals and conversion. I've found that moving from fixed quality 75 to perceptual gating at SSIM 0.96 improved LCP by 400-600 ms on image-heavy pages and lifted conversion 1-2% simply by shipping fewer bytes without changing layout. If you see no regression in zoom interactions or return rates, you have headroom to push thresholds further. If complaints spike for a specific class, tighten that class alone. The metrics give you a dial; RUM tells you where to set it.

Frequently Asked Questions

What SSIM or Butteraugli threshold should I start with for a general image CDN?

Start with SSIM >= 0.96 and Butteraugli <= 2.0 for photographic content at delivery size. That is visually near-transparent for most users and typically 30-40% smaller than fixed JPEG q80. Run a calibration on 500 of your own images: find the SSIM where your team stops noticing differences in a blind A/B at 100% zoom, then add 0.01 as safety margin. For product/zoom images use 0.97 / 1.5; for thumbnails and avatars you can relax to 0.94 / 2.5 and reclaim extra bytes.

Can I compute SSIM or VMAF directly on the edge for every request?

Single-scale SSIM is lightweight enough for edge workers if you score against a small proxy reference and cache the result. MS-SSIM is borderline and VMAF/LPIPS are not suitable per-request due to CPU/GPU cost and latency. In my experience the best pattern is fast SSIM at the edge for gating plus thorough MS-SSIM/Butteraugli audits offline for the top-N most requested images. Use the offline results to tune the edge thresholds.

Why do my AVIF files score worse on PSNR but look better than JPEG?

AVIF and JPEG XL optimize for perceptual quality, not pixel error. They may smooth grain or shift chroma in ways that increase MSE and lower PSNR, but preserve edges and structure that SSIM and Butteraugli reward and humans prefer. Always compare codecs at equal perceptual scores, not equal PSNR or equal quality numbers. If you judge AVIF by PSNR you will over-encode it and erase most of its bandwidth advantage.

How do I handle responsive images when scoring quality?

Score at the rendered size, not the source size. Generate the distorted variant at its delivery width (e.g., 800w) and compare to a reference resized to the same dimensions with a high-quality filter. This prevents penalizing artifacts that disappear after downscaling and avoids wasting bytes to meet a threshold that does not reflect what the user sees. Store quality metadata per width variant if you serve multiple srcset widths.

Related Articles

References & Standards: HTTP Working Group Specifications · web.dev Articles · IETF Datatracker