Skip to Content
New: blazediff-png - a from-scratch Rust PNG codec, byte-exact to libspng and faster on every fixture. Read more β†’

What Is SSIM?

SSIM (Structural Similarity Index) scores how similar two images look, on a scale from 0 to 1. Instead of counting pixels that changed, it slides a window over both images and compares three things inside each window: brightness, contrast, and structure. 1 means identical. Pixel diffing answers β€œhow many pixels changed”. SSIM answers β€œwould a person see a difference”.

SSIM vs pixel-by-pixel comparison

Pixel-by-pixelSSIM
What it measuresHow many pixels changedHow different the images look
OutputPixel count and percentageOne score, 0 to 1, higher is better
Unit of workOne pixel at a timeA window of pixels (11x11 by default)
Compression artifactsCounted as real changesMostly ignored
Anti-aliasing on edgesCounted unless you filter itMostly ignored
A block of text moved 1pxEvery pixel in and around it countsScored as one local structure change
Shows you whereYes, a diff imageYes, an SSIM map
SpeedFastestSlower, more math per pixel

Pixel diffing is exact and cheap, so it stays the right default. Reach for SSIM when exact matching is too strict: JPEG artifacts, font and GPU rendering that differs between machines, or screenshots that get re-encoded somewhere in the pipeline.

How the score is computed

For each window, SSIM takes the mean, the variance, and the covariance of the two images:

mu_x = mean(x) mu_y = mean(y) var_x = var(x) var_y = var(y) cov = cov(x, y)

Those feed three terms - luminance, contrast, and structure - which are multiplied together:

SSIM(x,y) = l(x,y) * c(x,y) * s(x,y) l(x,y) = (2 * mu_x * mu_y + C1) / (mu_x^2 + mu_y^2 + C1) c(x,y) = (2 * sd_x * sd_y + C2) / (var_x + var_y + C2) s(x,y) = (cov + C2/2) / (sd_x * sd_y + C2/2)

Which collapses to the form usually quoted:

SSIM(x,y) = ((2*mu_x*mu_y + C1) * (2*cov + C2)) / ((mu_x^2 + mu_y^2 + C1) * (var_x + var_y + C2))

C1 and C2 only exist to stop the fractions blowing up when a window is flat (a solid background, where the means and variances are near zero):

C1 = (K1 * L)^2 K1 = 0.01 C2 = (K2 * L)^2 K2 = 0.03 L = 255 dynamic range for 8-bit images

The default window is 11x11 Gaussian with sigma 1.5, so pixels near the middle of the window count more than pixels at its edge. The final score is the mean of every window’s SSIM.

@blazediff/ssim matches the reference MATLAB implementation to within 0.01%.

Reading the score

ScoreMeaning
1.00Identical
0.95-1.00Excellent, safe to pass
0.85-0.95Good, small visible change
0.70-0.85Fair, clearly different
< 0.70Poor, large change

For UI screenshots the useful band is narrow. A real regression on a page that is mostly whitespace can still score above 0.98, so pick your threshold from your own baselines rather than from this table.

Three variants

VariantImportWhat changesUse it for
SSIM@blazediff/ssim/ssimGaussian windows, one scaleMatching published/MATLAB results
MS-SSIM@blazediff/ssim/msssim5 scales, downsampled 2x each step, weighted geometric meanImages seen at different sizes
Hitchhiker’s SSIM@blazediff/ssim/hitchhikers-ssimRectangular non-overlapping windows via integral images, ~4x fasterLarge batches in CI

MS-SSIM computes contrast and structure at every scale but luminance only at the coarsest one, then combines them with the default weights [0.0448, 0.2856, 0.3001, 0.2363, 0.1333]. It correlates better with human judgement when the image will be viewed at more than one size.

Hitchhiker’s SSIM swaps the Gaussian window for a rectangular one and uses integral images, which makes each window O(1) instead of O(window size). Windows do not overlap by default (windowStride defaults to windowSize). It pools with coefficient of variation rather than a plain mean.

Run it

npm install @blazediff/ssim
import ssim from "@blazediff/ssim/ssim"; const score = ssim(image1, image2, undefined, width, height); if (score < 0.98) throw new Error(`too different: ${score}`);

Pass an output buffer as the third argument to get the SSIM map back as a grayscale image - dark areas are where the two images disagree.

From the CLI:

blazediff-cli ssim baseline.png current.png blazediff-cli ssim baseline.png current.png --output ssim-map.png

In a test:

await expect(screenshot).toMatchImageSnapshot({ method: "ssim" });

Options: windowSize (default 11), k1 (0.01), k2 (0.03), L (255). Full signature in the @blazediff/ssim reference.

When SSIM is the wrong tool

  • You need to know exactly what changed. SSIM gives one number. Use pixel diffing or interpret mode for regions and change types.
  • The change is tiny but important. A single wrong character in a heading barely moves the score on a full-page screenshot.
  • Only the colors changed. SSIM works on luminance, so a red button turning green can score near 1.0. Pixel diffing catches that; SSIM does not.
  • You need speed above all. The native pixel core is faster by a wide margin.

Reference

Wang, Z., Bovik, A. C., Sheikh, H. R., & Simoncelli, E. P. (2004). β€œImage quality assessment: from error visibility to structural similarity.” IEEE Transactions on Image Processing, 13(4), 600-612.

Next: How GMSD works β†’ Β· Choosing a metric β†’

Last updated on