The problem with comparing two scans

Print a page, scan it, edit one word, print and scan it again. Now compare the two images.

Nothing lines up. The second scan sits two millimetres lower, the margins are off by a few pixels, the line spacing drifted. A pixel comparison dutifully reports what it sees: almost every line differs. Somewhere in that output is the one word that changed, and no reviewer is going to find it.

This is not a flaw in pixel comparison - it is answering the question it was asked. The trouble is that the question was wrong. Nobody wants to know which pixels differ between two scans. They want to know which words differ.

GroupDocs.Comparison for .NET 26.10 adds OCR-based image comparison, which asks the right question. It reads the text out of both images and compares that instead.

What is OCR-based image comparison?

OCR-based image comparison compares two images by the text recognized in them instead of by their pixels. Each image is read by a recognition engine, and the resulting word sequences are compared. Because the unit of comparison is a word rather than a pixel, text that merely shifted position between two scans counts as unchanged, and only genuinely different wording is reported as a change.

Enabling it

One property:

using var comparer = new Comparer("documents/source.png");
comparer.Add("documents/target.png");

comparer.Compare("Result/compared.png", new CompareOptions
{
    Ocr = new OcrOptions { Enabled = true },
    GenerateSummaryPage = false,
});

Console.WriteLine($"Changes: {comparer.GetChanges().Length}");

That is the entire integration. Enabled defaults to false, so every existing comparison in your codebase behaves exactly as it did - even if it happens to pass an OcrOptions instance without turning it on.

Two things are worth knowing before you run it. The package requirement is 26.10.0 or later, because the feature does not exist before that. And it needs a .NET Core-era target: the recognition engine supplies its native runtime for .NET Core and later only, so asking for OCR on .NET Framework throws NotSupportedException rather than failing obscurely inside the engine.

What it does not need is a second package. The OCR engine arrives as a transitive dependency of GroupDocs.Comparison, so dotnet add package and nothing else.

What comes back

Running the snippet above against two deliberately misaligned renderings of the same page - one with a word swapped, a line rewritten, a line deleted, a line inserted and a heading changed in the table of contents - returns seven changes. Not seven hundred. Seven, at recognition confidence between 0.97 and 1.00:

Modified: 'alpha' -> 'gamma'                        (confidence 0.98)
Modified: 'is short.' -> 'has been rewritten ...'   (confidence 0.99)
Modified: 'five' -> 'six'                           (confidence 0.99)
Modified: 'heading' -> 'rewritten'                  (confidence 1.00)

The whole text block sits at a different offset on each page, and none of that offset appears in the output. A word that only moved is still the same word.

Choosing how the result is drawn

OcrDisplayMode has two values, and the choice is about the audience rather than the comparison.

SideBySide is the default. It reproduces both pages in their original layout and marks each where it differs from the other - fonts, tables, columns and images intact. This is what a human approver wants: the changed clause sitting in the contract it belongs to.

Ocr = new OcrOptions
{
    Enabled = true,
    DisplayMode = OcrDisplayMode.SideBySide,
},

Text renders the recognized words of both documents as a single stream with deletions and insertions highlighted, reproducing none of the original layout. The result file came out at roughly a quarter the size of the side-by-side one in the sample run. It suits an audit log, a digest email, or anywhere the wording matters and the page design does not.

Ocr = new OcrOptions
{
    Enabled = true,
    DisplayMode = OcrDisplayMode.Text,
},

Both modes find the same edits. The mode decides how the result is drawn, not what the comparison finds - a distinction worth holding on to, because it means you can switch output format without re-validating your change detection.

Languages

OcrLanguage carries 154 values. Single alphabets (Deu, Rus, Jpn, Spa), script-wide models (Cyrillic, Chinese, Indic, PersoArabic), and Auto for detection when the documents are mixed or unknown.

Ocr = new OcrOptions
{
    Enabled = true,
    Language = OcrLanguage.Cyrillic,
},

It defaults to Latin, and that default matters operationally: Latin is the only model inside the package. Every other value needs a model fetched from the Aspose.OCR repository on first use and cached locally. Naming the script your documents actually use reads their characters more accurately than a general-purpose model does, so it is worth setting when you know it.

Reading where the changes are

A side-by-side image is enough when a person is looking at it. When the application draws its own overlay - a review UI, a redlining tool, a diff viewer

  • it needs coordinates.
foreach (ChangeInfo change in comparer.GetChanges())
{
    if (change is not OcrChangeInfo ocrChange)
    {
        continue;
    }

    Console.WriteLine($"{ocrChange.Type}: '{ocrChange.SourceText}'"
        + $" -> '{ocrChange.TargetText}'"
        + $" (confidence {ocrChange.Confidence:F2})");

    foreach (Rectangle box in ocrChange.TargetBoxes)
    {
        Highlight(box.X, box.Y, box.Width, box.Height);
    }
}

GetChanges() is typed as ChangeInfo[], so the OCR detail needs a cast to OcrChangeInfo. That subtype adds SourceBoxes and TargetBoxes - one rectangle per line of text, in pixels of that side’s own image, with float coordinates - and Confidence from 0 to 1.

Two details save debugging time. Rectangle here is GroupDocs.Comparison.Result.Rectangle; adding using System.Drawing; to the file makes the name ambiguous and the build fails with CS0104. And both box arrays are empty under DisplayMode.Text, which is consistent rather than surprising: that mode does not reproduce the pages, so there is nothing to report positions against.

The confidence value is more useful than it first appears. Recording it beside each change lets a pipeline route anything below a threshold to manual review instead of treating all recognized text as equally certain.

Running where there is no network

A build agent or an air-gapped host should not attempt an unannounced download of a recognition model. Two properties prevent it:

Ocr = new OcrOptions
{
    Enabled = true,
    Language = OcrLanguage.Latin,
    DisableResourceDownloads = true,
    ResourcesPath = "/opt/ocr-models",
},

DisableResourceDownloads turns a missing model into an error rather than a fetch that hangs or is refused outright. ResourcesPath names the folder models are read from and cached in.

Both apply to the entire process rather than to a single call, so set them once during startup rather than on each comparison - a detail easy to miss when the options object is constructed per request.

Latin text works offline with no preparation at all, since that model ships inside the package. For any other script, copy the cached model folder from a machine that has fetched it once and point ResourcesPath at it.

Handling the failures worth handling

Two failure modes deserve explicit treatment rather than a general catch.

The first is the platform one. If any code path can reach OCR comparison from a .NET Framework target, it will throw NotSupportedException, and catching it specifically lets you log something a maintainer can act on instead of a stack trace from inside the engine.

The second is a model that is not there. With DisableResourceDownloads set, a language whose model has not been cached fails rather than fetching - which is the point of the setting, but only helps if the error is recognisable. The engine reports it through the exception chain, so inspect InnerException as well as the outer message when deciding whether a failure is about the model or about the comparison itself.

try
{
    comparer.Compare(outputPath, options);
}
catch (NotSupportedException)
{
    logger.LogError("OCR comparison needs a .NET Core-era runtime.");
    throw;
}

Neither case is exotic. The first shows up when a library is shared between a modern service and a legacy host; the second, the first time a deployment runs somewhere the cache was not copied.

A note on image quality

Recognition accuracy sets the ceiling on comparison accuracy, and nothing in the comparison can recover text the engine misread. The sample pair used here is clean - rendered text, high contrast, no skew - and comes back at 0.97 to 1.00. Real scans of older paper will not always do that.

This is the practical argument for recording Confidence rather than discarding it. A change reported at 0.6 is not necessarily wrong, but it is a different kind of claim than one at 0.99, and a pipeline that treats them identically will eventually surface a recognition artefact as a document edit. Thresholding on confidence, and routing what falls below it to a person, costs little and keeps that out of the output.

When to reach for this

OCR comparison is the right tool when the documents arrived as images and the question is about their wording: scanned contracts, archived invoices, faxed amendments, screenshots of a report from two different builds. Anywhere two renderings of the same content will not align to the pixel.

It is not a replacement for comparing documents you already have as text. If both sides are DOCX or PDF with a real text layer, compare them directly - the text is already there, and recognition would only add a step and a source of error.

Start with Enabled = true and the defaults, run it against a pair of pages where you know what changed, and check the count matches. Then add language selection and coordinate handling as the application needs them.

Resources