Skip to content

Image compare decodes JPEGs differently from every other viewer, causing false differences #37

Description

@Ikkoru

TL;DR

WinMerge's image compare decodes JPEG files to slightly different pixel values than other image software does, so it reports differences between images that are actually identical.
Open the two attached files in WinMerge's image compare — repro.jpg, and repro-libjpeg-decode.png which is a lossless copy of that JPEG's own correct decode. They should have zero differences. Instead there's 5000+, with some faintly visible at 100% zoom, and many visible at 200%.
The same JPEG renders identically in 7 image viewers and 2 image editors (listed below), and differently only in WinMerge.

Image Image Image

Proposed fix

Presumed and untested — I did not build WinIMerge to verify it. If it is wrong or incomplete, the sections below give the measurements, the reproduction, and the reasoning.

Decode for accuracy on the image-compare path. Either pass JPEG_ACCURATE to FreeImage_Load, or set dct_method = JDCT_ISLOW and do_fancy_upsampling = TRUE in the bundled FreeImage JPEG plugin.

Or add as an option: Compare ▸ Image ▸ accurate JPEG decoding.

Where to look: FreeImage's JPEG loader offers JPEG_ACCURATE ("best quality, sacrificing some speed") as an opt-in flag, so its default path is not the most accurate available. Check whether WinIMerge passes it, and what dct_method and do_fancy_upsampling end up as in the forked PluginJPEG.cpp.


Environment

WinMerge 2.16.58.0 · Windows 11 Pro 26200 · x64

Reproduction

Two attached files:

File What it is
repro.jpg a 949×949 4:2:0 JPEG, quality 95
repro-libjpeg-decode.png that same JPEG decoded with libjpeg at its defaults, saved losslessly

Two independent decodes of repro.jpg are bit-identical (0 pixels differ), so the reference is deterministic.

  1. Open both files in WinMerge's image compare.
  2. Make sure no unpacker plugin is active.

Expected: no differences. The PNG holds exactly the pixels a conforming decoder produces from the JPEG.

Actual: differences reported across the image.

What it looks like

At 1:1 the difference is faint. At 2× zoom it is clear on saturated colour edges — a red hair strand in this image — and faint in several other areas.

Where the difference is

Not uniform. It concentrates on edges, line art, hatching and screentone, and is exactly zero across flat areas.

repro-difference-map.png is attached: the difference between a default decode and a fast-IDCT decode of repro.jpg, amplified ×14 with a 2.2 gamma lift. The error follows the drawing; flat regions are black.

Measured on the same pair, 64-pixel tiles:

mean abs. difference (0–255 levels)
whole frame 1.096
red hair strand region (200×200 at 170,195) 1.411
quietest tile of 225 (flat background) 0.000
median tile 1.160
worst tile 2.263

The worst 10% of tiles hold 17.3% of the summed error, where a uniform distribution would hold 10%. Five of the eight worst tiles fall inside the red-strand region, which is where the difference is visible by eye.

A flat 8×8 block carries only a DC coefficient, so an approximate inverse transform has nothing to get wrong — hence the true zeros. A detailed block carries many AC coefficients and the error accumulates across them.

Presumed Cause

FreeImage bundles and configures its own libjpeg, so its output need not match libjpeg's defaults.

Decoding the same file under each combination of IDCT method and chroma upsampling, against a libjpeg-default decode as baseline:

Decode variant mean abs. difference
islow + fancy upsampling — the default; control 0.000
float + fancy upsampling — control 0.026
islow + simple upsampling 0.182
ifast + fancy upsampling 1.432
ifast + simple upsampling 1.476
WinMerge, measured against paint.NET's render 1.537

Both controls return ~0. A fast IDCT reproduces the observed difference within 4%; chroma upsampling alone is roughly eight times too small to account for it.

The error grows with file quality

Source est. quality chroma ifast + fancy
quality-50 original 50 4:2:0 0.554
near-lossless re-encode of it 100 4:2:0 1.432
same, 4:4:4 100 4:4:4 1.317

Same reason as the spatial pattern: a quality-50 file has most high-frequency coefficients quantized away, a near-lossless file keeps them. The error is 2.6× larger on the high-quality file.

The 4:4:4 row is a control on the mechanism: there, islow + simple scores 0.000 and ifast + fancy equals ifast + simple exactly (both 1.317). With no chroma to upsample that setting has nothing to do, while the IDCT term is unchanged.

Effects

A fast IDCT is conforming: JPEG defines the transform mathematically and permits approximation. In image comparison the decoded pixels are also the input to the difference count.

  • A JPEG and a lossless copy of its own decode are reported as different.
  • Difference counts carry a floor of decode noise, so they do not measure how much two images actually differ.
  • Tested on the same file: IrfanView, FastStone Image Viewer, FSPViewer, ImageGlass, Honeyview, NeeView, qView (7 viewers) and Paint, paint.NET (2 editors) agree with each other and with the reference PNG. WinMerge is the only outlier.

Nothing is corrupted and no data is at risk.

Residual uncertainty

On a 4:4:4 near-lossless variant, WinMerge measured 2.061 against ifast's 1.317 — a wider gap than the 4:2:0 case, and unexplained. Candidates: FreeImage linking classic IJG libjpeg, whose jpeg_idct_ifast is less accurate than the libjpeg-turbo used for the figures above; rounding in the YCbCr→RGB conversion; an 8-bit intermediate where libjpeg keeps more precision. All checkable in the build, none from outside it.

Workaround for users

Convert both sides to a PNG before comparing:

magick in.jpg PNG:out.png

A script can make the temporary PNGs, open them in WinMerge, and delete them afterwards.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions