Files
software-mansion__argent/packages/tool-server/test/screenshot-diff-ocr-coordinate-space.test.ts
Ignacy Łątka b8d729bb1f fix(screenshot-diff): rescale OCR text bounds into the normalized diff space (#442)
## Problem

When two same-aspect screenshots of different resolution are compared
(the explicitly supported flow, e.g. a 0.3x saved baseline vs a full-res
live capture), `diffPngFiles` calls `normalizeToCommonSize` to downscale
both framebuffers to a common size for the pixel diff and sets
`imageSize` to those normalized dimensions. The OCR/text pass, however,
ran OCR on the original decoded images: baseline OCR bounds came back in
baseline-original coordinates and current OCR bounds in current-original
coordinates - two different coordinate spaces.

`analyzeTextRegions` then compared those bounds directly (boundsDelta /
centerDistance / IoU), so identical text at the same logical position
was flagged as a half-image `moved` change. The summary compounded it by
normalizing the baseline bounds against the smaller normalized
`imageSize`, clamping the reported "from" width to 1. A uniform rescale
of one framebuffer is not a content change.

### Behavior repro

The same "Settings" text rendered at 480x240 vs 240x120 (2:1, same
aspect) produced one `kind:"moved"` change with a delta of roughly
negative half-image and a "from" normalized width of 1.

## Fix

Rescale each image's OCR text bounds (and per-word bounds) into the
normalized common coordinate space before the cross-image geometric
comparison and the normalized-coordinate reporting. Baseline bounds are
multiplied by `(normalizedW / baselineOrigW, normalizedH /
baselineOrigH)` and current bounds by `(normalizedW / currentOrigW,
normalizedH / currentOrigH)`, so both sets live in the same space the
pixel diff already uses. The font-geometry pass is handed the normalized
images to match the rescaled bounds, and the top cutoff is derived from
the normalized height. The scale factors are 1/1 when the inputs already
share dimensions, so same-size comparisons are unchanged.

A small pure helper `rescaleTextRegions(regions, scale)` performs the
mapping; `analyzeScreenshotTextChanges` accepts optional
`baselineRegionScale` / `currentRegionScale` and applies it to the OCR
output, and `diffPngFiles` computes the factors from the original vs
normalized dimensions.

## Test


`packages/tool-server/test/screenshot-diff-ocr-coordinate-space.test.ts`
is a pure unit test with no OCR engine dependency - it never shells out
to tesseract, python, or PIL. It supplies synthetic OCR regions for
"Settings" at the same logical location in the 480x240 baseline space
and the 240x120 current space, documents the spurious half-image `moved`
change from mismatched spaces, and asserts that after rescaling into the
common space the geometry produces no `moved` change and sane normalized
coordinates. A pipeline-level case mocks the OCR module and verifies the
same through `analyzeScreenshotTextChanges` with per-image scales.
2026-07-02 12:40:19 +02:00

150 lines
5.7 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OcrTextBlock } from "../src/tools/screenshot-diff/screenshot-diff-ocr";
import {
analyzeScreenshotTextChanges,
analyzeTextRegions,
rescaleTextRegions,
type TextRegion,
} from "../src/tools/screenshot-diff/text-diff";
// This suite guards the OCR coordinate-space fix WITHOUT any OCR engine. It
// never shells out to tesseract/python/PIL: OCR output is supplied directly as
// synthetic regions (and, for the pipeline test, via a mocked OCR module), so
// it runs as a pure unit test in CI where tesseract is not installed.
const ocrMock = vi.hoisted(() => ({
extractOcrTextBlocks: vi.fn(),
}));
vi.mock("../src/tools/screenshot-diff/screenshot-diff-ocr", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../src/tools/screenshot-diff/screenshot-diff-ocr")>();
return {
...actual,
extractOcrTextBlocks: ocrMock.extractOcrTextBlocks,
};
});
// "Settings" rendered at the same logical location, captured at two resolutions
// with the same 2:1 aspect ratio. The baseline file is twice the size of the
// current file, so its OCR bounds are exactly double the current bounds.
const SETTINGS_IN_BASELINE_480x240: TextRegion = {
text: "Settings",
confidence: 0.94,
bounds: { x: 200, y: 100, width: 80, height: 18 },
words: [
{ text: "Settings", confidence: 0.94, bounds: { x: 200, y: 100, width: 80, height: 18 } },
],
};
const SETTINGS_IN_CURRENT_240x120: TextRegion = {
text: "Settings",
confidence: 0.94,
bounds: { x: 100, y: 50, width: 40, height: 9 },
words: [{ text: "Settings", confidence: 0.94, bounds: { x: 100, y: 50, width: 40, height: 9 } }],
};
// Normalized common space (the smaller image's dimensions), which is what the
// pixel diff and the summary report against.
const NORMALIZED = { width: 240, height: 120 };
const BASELINE_TO_NORMALIZED = { x: 240 / 480, y: 120 / 240 };
const CURRENT_TO_NORMALIZED = { x: 1, y: 1 };
describe("screenshot-diff OCR coordinate space", () => {
beforeEach(() => {
ocrMock.extractOcrTextBlocks.mockReset();
});
it("rescales region and word bounds while leaving an identity scale untouched", () => {
const regions: TextRegion[] = [structuredClone(SETTINGS_IN_BASELINE_480x240)];
expect(rescaleTextRegions(regions, { x: 1, y: 1 })).toBe(regions);
expect(rescaleTextRegions(regions)).toBe(regions);
const scaled = rescaleTextRegions(regions, BASELINE_TO_NORMALIZED);
expect(scaled[0].bounds).toEqual({ x: 100, y: 50, width: 40, height: 9 });
expect(scaled[0].words?.[0].bounds).toEqual({ x: 100, y: 50, width: 40, height: 9 });
// The source regions are not mutated.
expect(regions[0].bounds).toEqual({ x: 200, y: 100, width: 80, height: 18 });
});
it("flags identical text in mismatched coordinate spaces as a spurious move (the bug)", () => {
const result = analyzeTextRegions({
baselineRegions: [SETTINGS_IN_BASELINE_480x240],
currentRegions: [SETTINGS_IN_CURRENT_240x120],
});
const moved = result.changes.find((change) => change.kind === "moved");
expect(moved).toBeDefined();
// A half-image "move" of text that never actually moved.
expect(Math.abs(moved?.delta?.x ?? 0)).toBeGreaterThanOrEqual(80);
expect(Math.abs(moved?.delta?.y ?? 0)).toBeGreaterThanOrEqual(40);
});
it("reports no move once both OCR bound sets are rescaled into the common space", () => {
const baseline = rescaleTextRegions([SETTINGS_IN_BASELINE_480x240], BASELINE_TO_NORMALIZED);
const current = rescaleTextRegions([SETTINGS_IN_CURRENT_240x120], CURRENT_TO_NORMALIZED);
const result = analyzeTextRegions({ baselineRegions: baseline, currentRegions: current });
expect(result.status).toBe("ok");
expect(result.changes.some((change) => change.kind === "moved")).toBe(false);
expect(result.changes).toEqual([]);
// The rescaled baseline bounds now coincide with the current bounds and sit
// inside the normalized frame, so the summary cannot clamp a normalized
// width to 1 (the compounding symptom of the bug).
expect(baseline[0].bounds).toEqual(current[0].bounds);
expect(baseline[0].bounds.x / NORMALIZED.width).toBeLessThan(1);
expect(baseline[0].bounds.width / NORMALIZED.width).toBeLessThan(1);
expect(baseline[0].bounds.y / NORMALIZED.height).toBeLessThan(1);
expect(baseline[0].bounds.height / NORMALIZED.height).toBeLessThan(1);
});
it("does not invent a move when analyzeScreenshotTextChanges receives per-image scales", async () => {
ocrMock.extractOcrTextBlocks.mockImplementation(async (imagePath: string) =>
imagePath === "baseline.png"
? okOcr(SETTINGS_IN_BASELINE_480x240)
: okOcr(SETTINGS_IN_CURRENT_240x120)
);
const result = await analyzeScreenshotTextChanges({
baselinePath: "baseline.png",
currentPath: "current.png",
hasPixelDiff: true,
baselineRegionScale: BASELINE_TO_NORMALIZED,
currentRegionScale: CURRENT_TO_NORMALIZED,
});
expect(result.status).toBe("ok");
expect(result.changes.some((change) => change.kind === "moved")).toBe(false);
expect(result.changes).toEqual([]);
});
});
function okOcr(region: TextRegion): {
status: "ok";
provider: "tesseract";
blocks: OcrTextBlock[];
} {
return {
status: "ok",
provider: "tesseract",
blocks: [
{
text: region.text,
confidence: region.confidence ?? 1,
bounds: region.bounds,
words: (region.words ?? []).map((word) => ({
text: word.text,
confidence: word.confidence,
bounds: word.bounds,
blockNum: 1,
parNum: 1,
lineNum: 1,
wordNum: 1,
})),
},
],
};
}