Skip to content

Commit b5440fa

Browse files
committed
feat: add compact screenshot diff hints
1 parent 7490a6c commit b5440fa

8 files changed

Lines changed: 436 additions & 12 deletions

File tree

skills/agent-device/references/verification.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ agent-device diff screenshot --baseline ./baseline.png --out /tmp/diff.png --ove
5959

6060
- Text output includes ranked changed regions with screen-space rectangles, shape, size, density, average color, and luminance. JSON also includes normalized bounds.
6161
- The diff PNG uses a light grayscale current-screen context with changed pixels tinted red and changed regions outlined.
62-
- Install `tesseract` when you want `diff screenshot` to add best-effort OCR text deltas such as moved labels and possible text metric mismatches. OCR improves the text/JSON descriptions only; it does not change the pixel comparison or the diff PNG.
62+
- Install `tesseract` when you want `diff screenshot` to add best-effort OCR text deltas, movement clusters, added/removed text candidates, and bbox size-change hints. OCR improves the text/JSON descriptions only; it does not change the pixel comparison or the diff PNG.
6363
- When OCR is available, `diff screenshot` also reports best-effort non-text visual deltas by masking OCR text boxes out of the pixel diff and clustering the remaining residuals. Treat these as hints for icons, controls, and separators, not semantic icon recognition.
6464
- Add `--overlay-refs` to `diff screenshot` when you also want a separate current-screen overlay guide. The raw screenshot is still used for pixel comparison; the overlay guide is only context for non-text controls, icons, and tappable regions. When overlay refs intersect changed regions, the output lists the best current-screen ref matches under the affected region.
6565

src/utils/__tests__/output.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,29 @@ test('formatScreenshotDiffText renders mismatch with pixel counts without color'
706706
possibleTextMetricMismatch: true,
707707
},
708708
],
709+
addedText: [
710+
{
711+
text: 'Accessibility',
712+
rect: { x: 220, y: 2520, width: 220, height: 40 },
713+
confidence: 93,
714+
},
715+
],
716+
removedText: [
717+
{
718+
text: 'VPN',
719+
rect: { x: 120, y: 2100, width: 90, height: 36 },
720+
confidence: 96,
721+
},
722+
],
723+
movementClusters: [
724+
{
725+
texts: ['Wi-Fi', 'Bluetooth'],
726+
averageDelta: { x: 10, y: 12 },
727+
xRange: { min: 10, max: 12 },
728+
yRange: { min: 10, max: 14 },
729+
confidence: 90,
730+
},
731+
],
709732
},
710733
nonTextDeltas: [
711734
{
@@ -724,6 +747,15 @@ test('formatScreenshotDiffText renders mismatch with pixel counts without color'
724747
assert.match(text, /Current overlay:/);
725748
assert.match(text, /diff\.current-overlay\.png \(1 refs\)/);
726749
assert.match(text, /500 different \/ 10000 total pixels/);
750+
assert.match(text, /Hints:/);
751+
assert.match(
752+
text,
753+
/text movement cluster: "Wi-Fi", "Bluetooth" dx=\+10\.\.\+12px dy=\+10\.\.\+14px/,
754+
);
755+
assert.match(text, /added text candidates: "Accessibility" at x=220,y=2520/);
756+
assert.match(text, /removed text candidates: "VPN" at x=120,y=2100/);
757+
assert.match(text, /non-text controls\/boundaries: icon near "Wi-Fi" r1/);
758+
assert.match(text, /largest changed region: r1 top-left 70% of diff, brighter/);
727759
assert.match(text, /Changed regions:/);
728760
assert.match(text, /1\. top-left x=10 y=20 100x40, 70% of diff, change=brighter/);
729761
assert.match(
@@ -741,7 +773,7 @@ test('formatScreenshotDiffText renders mismatch with pixel counts without color'
741773
);
742774
assert.match(
743775
text,
744-
/1 \| "Wi-Fi" \| \+10,\+12 \| \+10,0 \| x=120,y=320,w=60,h=22 \| x=130,y=332,w=70,h=22 \| 94 \| possible-text-metric-mismatch/,
776+
/1 \| "Wi-Fi" \| \+10,\+12 \| \+10,0 \| x=120,y=320,w=60,h=22 \| x=130,y=332,w=70,h=22 \| 94 \| ocr-bbox-size-change/,
745777
);
746778
assert.match(text, /Non-text visual deltas \(showing 1\/1; px\):/);
747779
assert.match(text, /item \| region \| slot \| kind \| bboxCurrent \| nearestText/);

src/utils/__tests__/screenshot-diff-ocr.test.ts

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
matchOcrBlocks,
88
parseTesseractTsv,
99
summarizeScreenshotOcr,
10+
summarizeOcrMovementClusters,
11+
summarizeOcrTextChanges,
1012
} from '../screenshot-diff-ocr.ts';
1113

1214
test('parseTesseractTsv groups word rows into text line blocks', () => {
@@ -44,7 +46,7 @@ test('parseTesseractTsv groups word rows into text line blocks', () => {
4446
});
4547
});
4648

47-
test('matchOcrBlocks reports movement and possible text metric mismatch', () => {
49+
test('matchOcrBlocks reports movement and OCR bbox size change', () => {
4850
const matches = matchOcrBlocks(
4951
[
5052
{
@@ -69,6 +71,101 @@ test('matchOcrBlocks reports movement and possible text metric mismatch', () =>
6971
assert.equal(matches[0]?.possibleTextMetricMismatch, true);
7072
});
7173

74+
test('summarizeOcrTextChanges normalizes noisy OCR labels before reporting candidates', () => {
75+
const changes = summarizeOcrTextChanges(
76+
[
77+
{
78+
text: 'Airplane Mode',
79+
confidence: 95,
80+
rect: { x: 100, y: 200, width: 80, height: 20 },
81+
normalizedRect: { x: 25, y: 25, width: 20, height: 2.5 },
82+
},
83+
{
84+
text: '2) Personal Hotspot',
85+
confidence: 90,
86+
rect: { x: 100, y: 260, width: 160, height: 20 },
87+
normalizedRect: { x: 25, y: 32.5, width: 40, height: 2.5 },
88+
},
89+
{
90+
text: 'Removed Row',
91+
confidence: 91,
92+
rect: { x: 100, y: 320, width: 140, height: 20 },
93+
normalizedRect: { x: 25, y: 40, width: 35, height: 2.5 },
94+
},
95+
{
96+
text: '4:44',
97+
confidence: 96,
98+
rect: { x: 10, y: 20, width: 60, height: 20 },
99+
normalizedRect: { x: 2.5, y: 2.5, width: 15, height: 2.5 },
100+
},
101+
],
102+
[
103+
{
104+
text: 'Airplane Mode @e~',
105+
confidence: 91,
106+
rect: { x: 120, y: 210, width: 120, height: 20 },
107+
normalizedRect: { x: 30, y: 26.25, width: 30, height: 2.5 },
108+
},
109+
{
110+
text: 'Personal Hotspot',
111+
confidence: 92,
112+
rect: { x: 120, y: 270, width: 140, height: 20 },
113+
normalizedRect: { x: 30, y: 33.75, width: 35, height: 2.5 },
114+
},
115+
{
116+
text: 'Added Row',
117+
confidence: 93,
118+
rect: { x: 120, y: 340, width: 110, height: 20 },
119+
normalizedRect: { x: 30, y: 42.5, width: 27.5, height: 2.5 },
120+
},
121+
],
122+
800,
123+
);
124+
125+
assert.deepEqual(
126+
changes.addedText.map((change) => change.text),
127+
['Added Row'],
128+
);
129+
assert.deepEqual(
130+
changes.removedText.map((change) => change.text),
131+
['Removed Row'],
132+
);
133+
});
134+
135+
test('summarizeOcrMovementClusters groups repeated x-axis text movement', () => {
136+
const clusters = summarizeOcrMovementClusters([
137+
{
138+
text: 'Wi-Fi',
139+
baselineRect: { x: 100, y: 200, width: 50, height: 20 },
140+
currentRect: { x: 286, y: 120, width: 50, height: 20 },
141+
delta: { x: 186, y: -80, width: 0, height: 0 },
142+
confidence: 96,
143+
possibleTextMetricMismatch: false,
144+
},
145+
{
146+
text: 'Bluetooth',
147+
baselineRect: { x: 100, y: 260, width: 90, height: 20 },
148+
currentRect: { x: 284, y: 190, width: 90, height: 20 },
149+
delta: { x: 184, y: -70, width: 0, height: 0 },
150+
confidence: 90,
151+
possibleTextMetricMismatch: false,
152+
},
153+
{
154+
text: 'Search',
155+
baselineRect: { x: 100, y: 500, width: 90, height: 20 },
156+
currentRect: { x: 52, y: 560, width: 90, height: 20 },
157+
delta: { x: -48, y: 60, width: 0, height: 0 },
158+
confidence: 94,
159+
possibleTextMetricMismatch: false,
160+
},
161+
]);
162+
163+
assert.equal(clusters.length, 1);
164+
assert.deepEqual(clusters[0]?.texts, ['Wi-Fi', 'Bluetooth']);
165+
assert.deepEqual(clusters[0]?.xRange, { min: 184, max: 186 });
166+
assert.deepEqual(clusters[0]?.yRange, { min: -80, max: -70 });
167+
});
168+
72169
test('summarizeScreenshotOcr returns undefined when tesseract exits non-zero', async () => {
73170
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ocr-test-'));
74171
const binDir = path.join(dir, 'bin');

src/utils/output.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,12 @@ export function formatScreenshotDiffText(data: ScreenshotDiffResult): string {
242242
lines.push(` ${diffCount} different / ${totalPixels} total pixels`);
243243
}
244244

245+
const hints = !match && !dimensionMismatch ? formatScreenshotDiffHints(data) : [];
246+
if (hints.length > 0) {
247+
lines.push(' Hints:');
248+
for (const hint of hints) lines.push(` - ${hint}`);
249+
}
250+
245251
const regions = Array.isArray(data.regions) ? data.regions : [];
246252
if (!match && !dimensionMismatch && regions.length > 0) {
247253
lines.push(' Changed regions:');
@@ -288,7 +294,7 @@ export function formatScreenshotDiffText(data: ScreenshotDiffResult): string {
288294
`${formatSignedPixels(delta.width)},${formatSignedPixels(delta.height)} | ` +
289295
`${formatRect(ocrMatch.baselineRect)} | ${formatRect(ocrMatch.currentRect)} | ` +
290296
`${ocrMatch.confidence} | ` +
291-
`${ocrMatch.possibleTextMetricMismatch ? 'possible-text-metric-mismatch' : '-'}`,
297+
`${ocrMatch.possibleTextMetricMismatch ? 'ocr-bbox-size-change' : '-'}`,
292298
);
293299
}
294300
}
@@ -320,6 +326,75 @@ function formatSignedPixels(value: number): string {
320326
return value > 0 ? `+${value}` : String(value);
321327
}
322328

329+
function formatScreenshotDiffHints(data: ScreenshotDiffResult): string[] {
330+
const hints: string[] = [];
331+
const clusters = data.ocr?.movementClusters ?? [];
332+
for (const cluster of clusters.slice(0, 2)) {
333+
hints.push(
334+
`text movement cluster: ${formatQuotedList(cluster.texts)} dx=${formatRange(cluster.xRange)}px ` +
335+
`dy=${formatRange(cluster.yRange)}px`,
336+
);
337+
}
338+
339+
const addedText = data.ocr?.addedText ?? [];
340+
if (addedText.length > 0) {
341+
hints.push(`added text candidates: ${formatTextChanges(addedText)}`);
342+
}
343+
344+
const removedText = data.ocr?.removedText ?? [];
345+
if (removedText.length > 0) {
346+
hints.push(`removed text candidates: ${formatTextChanges(removedText)}`);
347+
}
348+
349+
const controlDeltas = (data.nonTextDeltas ?? [])
350+
.filter((delta) => ['icon', 'toggle', 'chevron', 'separator'].includes(delta.likelyKind))
351+
.slice(0, 3);
352+
if (controlDeltas.length > 0) {
353+
hints.push(`non-text controls/boundaries: ${controlDeltas.map(formatNonTextHint).join('; ')}`);
354+
}
355+
356+
const largestRegion = data.regions?.[0];
357+
if (largestRegion) {
358+
hints.push(
359+
`largest changed region: r${largestRegion.index} ${largestRegion.location} ` +
360+
`${largestRegion.shareOfDiffPercentage}% of diff, ${largestRegion.dominantChange}`,
361+
);
362+
}
363+
364+
return hints.slice(0, 6);
365+
}
366+
367+
function formatTextChanges(
368+
changes: Array<{ text: string; rect: { x: number; y: number; width: number; height: number } }>,
369+
): string {
370+
return changes
371+
.slice(0, 3)
372+
.map((change) => `${JSON.stringify(change.text)} at x=${change.rect.x},y=${change.rect.y}`)
373+
.join(', ');
374+
}
375+
376+
function formatNonTextHint(delta: {
377+
likelyKind: string;
378+
nearestText?: string;
379+
regionIndex?: number;
380+
}): string {
381+
const anchor = delta.nearestText ? ` near ${JSON.stringify(delta.nearestText)}` : '';
382+
const region = delta.regionIndex ? ` r${delta.regionIndex}` : '';
383+
return `${delta.likelyKind}${anchor}${region}`;
384+
}
385+
386+
function formatRange(range: { min: number; max: number }): string {
387+
return range.min === range.max
388+
? formatSignedPixels(range.min)
389+
: `${formatSignedPixels(range.min)}..${formatSignedPixels(range.max)}`;
390+
}
391+
392+
function formatQuotedList(values: string[]): string {
393+
const shown = values.slice(0, 4).map((value) => JSON.stringify(value));
394+
const suffix = values.length > shown.length ? ` +${values.length - shown.length} more` : '';
395+
return `${shown.join(', ')}${suffix}`;
396+
}
397+
323398
function formatScreenshotRegionDetails(region: ScreenshotDiffRegion): string | null {
324399
const details = [
325400
region.size ? `size=${region.size}` : null,

0 commit comments

Comments
 (0)