Skip to content

Commit 548df31

Browse files
committed
perf(scripts): add a device-free PNG crop benchmark
`pnpm bench:png-crop` runs the whole-image pipeline and the shipped region crop over the same bytes in one process, so the comparison holds the capture content, the deflate stream, and the machine fixed. The corpus is generated, which keeps a run at seconds with no device; real captures join the same table via `--file`, and each corpus entry prints its compressed size so an unrealistic corpus is visible. The README records what the measurements said, including the case the encoder policy loses: `None` on every scanline is faster everywhere but writes about 1.7x more bytes than a filtered encoding on smooth low-frequency content.
1 parent d3a0d39 commit 548df31

11 files changed

Lines changed: 580 additions & 0 deletions

File tree

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@
124124
"bench:ios-snapshot": "node --experimental-strip-types scripts/ios-snapshot-benchmark/run.ts",
125125
"bench:ios-snapshot:deep-button": "node --experimental-strip-types scripts/ios-snapshot-benchmark/deep-button.ts",
126126
"bench:ios-snapshot:evidence": "node --experimental-strip-types scripts/ios-snapshot-benchmark/evidence.ts",
127+
"bench:png-crop": "node --experimental-strip-types scripts/png-crop-benchmark/run.ts",
127128
"bench:ios-ax-bridge:targeted": "node --experimental-strip-types scripts/ios-ax-bridge-spike/targeted-run.ts",
128129
"mutation:run": "node --experimental-strip-types scripts/mutation/run.ts",
129130
"mutation:check": "node --experimental-strip-types scripts/mutation/run.ts --no-run",
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# PNG crop benchmark
2+
3+
```sh
4+
pnpm bench:png-crop -- --rounds 5
5+
pnpm bench:png-crop -- --rounds 5 --file /path/to/real-capture.png
6+
```
7+
8+
Compares the two ways this repository can crop a screenshot. Both run over the same bytes as PNG
9+
worker jobs in one process, so what differs is the algorithm and not the thread it happens to land
10+
on, or a file write on one side only:
11+
12+
- `whole-image` — the previous crop: one job decodes the whole capture to RGBA, the box rows are
13+
copied out of that bitmap, and a second job encodes the box.
14+
- `region` — the shipped crop: one job reads the box's rows and encodes them, as RGB rather than
15+
RGBA whenever the cropped pixels are all opaque.
16+
17+
The corpus is generated, so a full run costs seconds and needs no device. Generated captures are
18+
written to `.tmp/png-crop-benchmark/` and each one's compressed size is printed under the table:
19+
a corpus that stops resembling a real capture becomes visible there instead of flattering the
20+
result. Pass real captures with `--file` (repeatable) to put their numbers in the same table; the
21+
real captures decide the verdict, since generated content cannot match a device's deflate stream.
22+
23+
## What is actually saved
24+
25+
Neither pipeline reads less of the file: a deflate stream has to be inflated to its end, so both
26+
inflate the whole compressed image and reconstruct the rows above the box. What the region path
27+
saves is the pixel work — reconstructing only down to the box's last row, allocating and copying
28+
only the box's pixels instead of a full RGBA bitmap — plus one worker round trip, and the RGBA
29+
re-encode of the answer.
30+
31+
## What the measurements have said
32+
33+
Measured at that matched boundary over 7 rounds, on captures taken from an iOS Simulator and an
34+
Android Emulator: the iOS captures go 2.5x to 5.6x faster and their crops come out 1.04x to 2.16x
35+
smaller, mostly because an opaque crop is written as RGB instead of RGBA. A flat UI capture gains
36+
the most, because the previous pipeline still expands the whole capture to an RGBA bitmap whatever
37+
the filters look like, while the region path skips the pixels above the box.
38+
39+
The noisiest capture in that set — a full-screen Android `screencap`, 1.4 MB compressed — is a wash
40+
on time (1.0x to 1.6x) and its crop comes out up to 1.13x *larger*. Inflating and reconstructing
41+
that much entropy dominates both pipelines, and the region writer's `None` filter cannot beat the
42+
general writer's filter search on content that noisy. Check your own captures with `--file` before
43+
reading a win or a loss into any number here.
44+
45+
The encoder keeps the `None` filter on every scanline. Scoring the five PNG filters per row is
46+
1.4x to 2.6x slower and produces a *larger* file on UI captures, where the smallest-sum-of-absolute-
47+
differences heuristic prefers Sub or Up on text rows that deflate smaller unfiltered. On synthetic
48+
low-frequency content — the `photo` captures here, which are smooth 8px blocks — `None` is still
49+
faster but writes about 1.7x more bytes than a filtered encoding would. Real photo-filled captures
50+
still come out smaller with `None` than with the general writer's own filter search, so the corpus
51+
here overstates that case; check your own captures with `--file` before treating it as a limit.
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { test } from 'vitest';
2+
import assert from 'node:assert/strict';
3+
import { parseBenchmarkArgs } from './args.ts';
4+
5+
test('an empty command line keeps the default round count', () => {
6+
assert.deepEqual(parseBenchmarkArgs([]), {
7+
rounds: 5,
8+
jsonPath: undefined,
9+
captureFiles: [],
10+
});
11+
});
12+
13+
test('rounds, json path, and every capture file are read from their flags', () => {
14+
assert.deepEqual(
15+
parseBenchmarkArgs([
16+
'--rounds',
17+
'9',
18+
'--json',
19+
'out/bench.json',
20+
'--file',
21+
'a.png',
22+
'--file',
23+
'b.png',
24+
]),
25+
{ rounds: 9, jsonPath: 'out/bench.json', captureFiles: ['a.png', 'b.png'] },
26+
);
27+
});
28+
29+
test('a round count that is not a positive number falls back to the default', () => {
30+
assert.equal(parseBenchmarkArgs(['--rounds', '0']).rounds, 5);
31+
assert.equal(parseBenchmarkArgs(['--rounds', 'many']).rounds, 5);
32+
assert.equal(parseBenchmarkArgs(['--rounds', '2.4']).rounds, 2);
33+
});
34+
35+
test('a flag with no value after it is ignored', () => {
36+
assert.deepEqual(parseBenchmarkArgs(['--json']), {
37+
rounds: 5,
38+
jsonPath: undefined,
39+
captureFiles: [],
40+
});
41+
});

scripts/png-crop-benchmark/args.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/** The command line `pnpm bench:png-crop` accepts. */
2+
3+
export type BenchmarkOptions = Readonly<{
4+
rounds: number;
5+
jsonPath: string | undefined;
6+
captureFiles: readonly string[];
7+
}>;
8+
9+
const DEFAULT_ROUNDS = 5;
10+
11+
export function parseBenchmarkArgs(argv: readonly string[]): BenchmarkOptions {
12+
return {
13+
rounds: readNumber(argv, '--rounds') ?? DEFAULT_ROUNDS,
14+
jsonPath: readString(argv, '--json'),
15+
captureFiles: readAll(argv, '--file'),
16+
};
17+
}
18+
19+
function readNumber(argv: readonly string[], flag: string): number | undefined {
20+
const value = readString(argv, flag);
21+
const parsed = value === undefined ? Number.NaN : Number(value);
22+
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : undefined;
23+
}
24+
25+
function readString(argv: readonly string[], flag: string): string | undefined {
26+
const index = argv.indexOf(flag);
27+
return index >= 0 ? argv[index + 1] : undefined;
28+
}
29+
30+
function readAll(argv: readonly string[], flag: string): string[] {
31+
const values: string[] = [];
32+
argv.forEach((entry, index) => {
33+
if (entry === flag && argv[index + 1] !== undefined) values.push(argv[index + 1]!);
34+
});
35+
return values;
36+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { test } from 'vitest';
2+
import assert from 'node:assert/strict';
3+
import { mkdtempSync, rmSync } from 'node:fs';
4+
import os from 'node:os';
5+
import path from 'node:path';
6+
import { PNG } from '@agent-device/capture-kit/png';
7+
import { buildCorpus, cropBoxOf, CROP_SCENARIOS } from './corpus.ts';
8+
9+
const SCENARIO_BY_NAME = new Map(CROP_SCENARIOS.map((scenario) => [scenario.name, scenario]));
10+
const CAPTURE = {
11+
name: 'phone',
12+
label: 'phone',
13+
width: 1200,
14+
height: 2400,
15+
bytes: Buffer.alloc(0),
16+
};
17+
const SMALL = [{ name: 'tiny', label: 'tiny', width: 24, height: 40 }];
18+
19+
test('a card crop keeps the framed fraction of the capture', () => {
20+
const box = cropBoxOf(CAPTURE, SCENARIO_BY_NAME.get('card')!);
21+
22+
assert.deepEqual(box, { x: 96, y: 600, width: 960, height: 480 });
23+
});
24+
25+
test('a full-bleed header is clamped to the image, never one pixel past it', () => {
26+
const box = cropBoxOf(CAPTURE, SCENARIO_BY_NAME.get('header')!);
27+
28+
assert.equal(box.x, 0);
29+
assert.equal(box.width, CAPTURE.width);
30+
assert.equal(box.y + box.height, 288);
31+
});
32+
33+
test('the same generated capture comes out byte for byte every run', () => {
34+
const directory = mkdtempSync(path.join(os.tmpdir(), 'png-crop-corpus-'));
35+
try {
36+
const first = buildCorpus(path.join(directory, 'first'), SMALL);
37+
const second = buildCorpus(path.join(directory, 'second'), SMALL);
38+
39+
assert.equal(first.length, 2);
40+
assert.deepEqual(
41+
first.map((capture) => capture.bytes),
42+
second.map((capture) => capture.bytes),
43+
);
44+
} finally {
45+
rmSync(directory, { recursive: true, force: true });
46+
}
47+
});
48+
49+
test('interface and photo captures differ, and both decode at their declared size', () => {
50+
const directory = mkdtempSync(path.join(os.tmpdir(), 'png-crop-corpus-'));
51+
try {
52+
const [interfaceCapture, photoCapture] = buildCorpus(directory, SMALL);
53+
54+
const decoded = [interfaceCapture, photoCapture].map((capture) =>
55+
PNG.sync.read(capture!.bytes),
56+
);
57+
assert.deepEqual(
58+
decoded.map((png) => [png.width, png.height]),
59+
[
60+
[24, 40],
61+
[24, 40],
62+
],
63+
);
64+
assert.notDeepEqual(decoded[0]!.data, decoded[1]!.data);
65+
} finally {
66+
rmSync(directory, { recursive: true, force: true });
67+
}
68+
});
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2+
import path from 'node:path';
3+
import { PNG } from '@agent-device/capture-kit/png';
4+
5+
/**
6+
* The captures and crop boxes this benchmark measures. They are generated rather than recorded,
7+
* so a run costs seconds and needs no device; each one reports its own compressed size, which is
8+
* how a corpus that stopped resembling a real capture becomes visible instead of flattering.
9+
* Pass real captures with `--file` to put their numbers in the same table.
10+
*/
11+
12+
export type CaptureResolution = Readonly<{
13+
name: string;
14+
label: string;
15+
width: number;
16+
height: number;
17+
}>;
18+
19+
const RESOLUTIONS: readonly CaptureResolution[] = [
20+
{ name: 'ios-phone', label: 'iPhone-class 1206x2622', width: 1206, height: 2622 },
21+
{ name: 'android-phone', label: 'Android-class 1080x2400', width: 1080, height: 2400 },
22+
{ name: 'ios-pad', label: 'iPad-class 2048x2732', width: 2048, height: 2732 },
23+
];
24+
25+
export type Capture = Readonly<{
26+
name: string;
27+
label: string;
28+
width: number;
29+
height: number;
30+
bytes: Buffer;
31+
}>;
32+
33+
export type CropScenario = Readonly<{
34+
name: string;
35+
/** Fractions of the capture, so one scenario reads well at every resolution. */
36+
fraction: Readonly<{ x: number; y: number; width: number; height: number }>;
37+
}>;
38+
39+
/** The box shapes `--crop-on` resolves: a card, a full-bleed header, and a small control. */
40+
export const CROP_SCENARIOS: readonly CropScenario[] = [
41+
{ name: 'card', fraction: { x: 0.08, y: 0.25, width: 0.8, height: 0.2 } },
42+
{ name: 'header', fraction: { x: 0, y: 0, width: 1, height: 0.12 } },
43+
{ name: 'control', fraction: { x: 0.3, y: 0.6, width: 0.3, height: 0.04 } },
44+
];
45+
46+
export function buildCorpus(
47+
outDir: string,
48+
resolutions: readonly CaptureResolution[] = RESOLUTIONS,
49+
): Capture[] {
50+
mkdirSync(outDir, { recursive: true });
51+
return resolutions.flatMap(({ name, label, width, height }) =>
52+
(['interface', 'photo'] as const).map((kind) => {
53+
const bytes = PNG.sync.write(encodeCapture(width, height, kind));
54+
writeFileSync(path.join(outDir, `${name}-${kind}.png`), bytes);
55+
return { name: `${name}-${kind}`, label: `${label} ${kind}`, width, height, bytes };
56+
}),
57+
);
58+
}
59+
60+
export function readCaptureFile(filePath: string, index: number): Capture {
61+
const bytes = readFileSync(filePath);
62+
const png = PNG.sync.read(bytes);
63+
return {
64+
name: `capture-${index}`,
65+
label: path.basename(filePath),
66+
width: png.width,
67+
height: png.height,
68+
bytes,
69+
};
70+
}
71+
72+
type Content = 'interface' | 'photo';
73+
74+
/**
75+
* A capture a device could have produced. `interface` is flat panels with high-frequency text
76+
* where the copy is drawn, which is what a settings or list screen looks like. `photo` is
77+
* low-frequency detail in 8px blocks plus a gradient, which is what a photo, artwork, or a blurred
78+
* background does to the deflate stream.
79+
*/
80+
function encodeCapture(width: number, height: number, content: Content): PNG {
81+
const png = new PNG({ width, height });
82+
const tones = content === 'photo' ? photoToneField(width, height) : null;
83+
for (let y = 0; y < height; y += 1) {
84+
for (let x = 0; x < width; x += 1) {
85+
const offset = (y * width + x) * 4;
86+
const [red, green, blue] = tones ? photoPixel(tones, x, y) : interfacePixel(x, y);
87+
png.data[offset] = red;
88+
png.data[offset + 1] = green;
89+
png.data[offset + 2] = blue;
90+
png.data[offset + 3] = 255;
91+
}
92+
}
93+
return png;
94+
}
95+
96+
/** Flat panels with high-frequency text where the copy is drawn. */
97+
function interfacePixel(x: number, y: number): readonly [number, number, number] {
98+
const ink = x % 320 < 2 || y % 96 < 2 || (y % 24 > 6 && y % 24 < 18 && x % 7 < 3);
99+
if (ink) return [24, 26, 30];
100+
const band = Math.floor(y / 96);
101+
return [(band * 11) % 240, (band * 17) % 244, (band * 23) % 248];
102+
}
103+
104+
/** The 8px-block detail and soft gradient of a photo, scaled by the pixel position. */
105+
function photoPixel(
106+
tones: readonly (readonly number[])[],
107+
x: number,
108+
y: number,
109+
): readonly [number, number, number] {
110+
const tone = tones[y >> 3]?.[x >> 3] ?? 0;
111+
return [
112+
clampByte(tone + y / 6),
113+
clampByte(tone * 0.8 + x / 9),
114+
clampByte(tone * 0.6 + (x + y) / 24),
115+
];
116+
}
117+
118+
/** Smooth 8px-block luminance in [40, 215], so a photo corpus compresses like a real photo. */
119+
function photoToneField(width: number, height: number): number[][] {
120+
let state = 0x9e3779b9;
121+
const next = () => {
122+
state = (state * 1664525 + 1013904223) >>> 0;
123+
return state / 0x100000000;
124+
};
125+
return Array.from({ length: Math.ceil(height / 8) }, (_row, blockRow) =>
126+
Array.from({ length: Math.ceil(width / 8) }, (_column, blockColumn) => {
127+
const drift = Math.sin(blockRow / 26) * 45 + Math.cos(blockColumn / 19) * 45;
128+
return clampByte(128 + drift + (next() - 0.5) * 70);
129+
}),
130+
);
131+
}
132+
133+
function clampByte(value: number): number {
134+
return Math.max(0, Math.min(255, Math.round(value)));
135+
}
136+
137+
export function cropBoxOf(
138+
capture: Capture,
139+
scenario: CropScenario,
140+
): {
141+
x: number;
142+
y: number;
143+
width: number;
144+
height: number;
145+
} {
146+
const { fraction } = scenario;
147+
const x = Math.round(capture.width * fraction.x);
148+
const y = Math.round(capture.height * fraction.y);
149+
return {
150+
x,
151+
y,
152+
width: Math.max(1, Math.min(capture.width - x, Math.round(capture.width * fraction.width))),
153+
height: Math.max(1, Math.min(capture.height - y, Math.round(capture.height * fraction.height))),
154+
};
155+
}

0 commit comments

Comments
 (0)