picturereader 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,695 @@
1
+ /**
2
+ * picturereader extra tools — a second set of local image tools that sit
3
+ * alongside the frame-free scanners in tool.js:
4
+ *
5
+ * image_crop — crop an image to a 0..1 fraction region and write the
6
+ * result to a PNG file (temp dir by default, or an
7
+ * explicit out_path).
8
+ * image_palette — extract the dominant colors of an image (or a region)
9
+ * via 3-bit/channel quantization, plus a hue-family
10
+ * breakdown so a text-only model can reason about tone.
11
+ * image_compare — pixel-wise comparison of two images (optionally within
12
+ * the same fraction region), reporting mean/ratio/max diff
13
+ * and a normalized difference bounding box, with an
14
+ * optional red-marked difference preview PNG.
15
+ *
16
+ * All business logic is self-contained in this module and reuses the shared
17
+ * primitives exported by `core.js` (decodeImage, normalizeRegion, cropRgba,
18
+ * encodePng, classify, hueFamilyFor, luminance). It imports `importCore` and
19
+ * the BYTE_CAP / MAX_PIXELS guards from `tool.js` so decode limits are
20
+ * identical to the existing tools.
21
+ *
22
+ * HOT RELOAD: like tool.js, the core module is fetched through `importCore`
23
+ * so edits to `core.js` take effect on the next tool call. The tool
24
+ * definitions here (schema/description) are fixed at boot.
25
+ * @module picturereader/more-tools
26
+ */
27
+
28
+ import { extname, resolve as pathResolve, join, dirname } from 'node:path';
29
+ import { writeFile, mkdir } from 'node:fs/promises';
30
+ import { tmpdir } from 'node:os';
31
+ import { randomBytes } from 'node:crypto';
32
+ import { importCore, BYTE_CAP, MAX_PIXELS } from './tool.js';
33
+
34
+ /** Amount of area that a norm channel's leading bits dedicate to one 3-bit bucket. */
35
+ const BUCKET_SHIFT = 5; // 256 >> 5 = 8 buckets per channel (3 bits/channel)
36
+
37
+ /** A pixel whose mean RGB channel delta exceeds this fraction counts as "differing". */
38
+ const DIFF_PIXEL_THRESHOLD = 0.1;
39
+
40
+ const toHex = (v) => v.toString(16).padStart(2, '0');
41
+ const hexOf = (r, g, b) => `#${toHex(r)}${toHex(g)}${toHex(b)}`;
42
+ const round3 = (v) => Math.round(v * 1000) / 1000;
43
+
44
+ /** Validate an integer in [min, max], throwing a tool-prefixed error. */
45
+ function parseBoundedInt(raw, fallback, min, max, label) {
46
+ const n = raw === undefined ? fallback : Number(raw);
47
+ if (!Number.isInteger(n) || n < min || n > max) {
48
+ throw new Error(`image: ${label} must be an integer between ${min} and ${max}`);
49
+ }
50
+ return n;
51
+ }
52
+
53
+ /** Validate a 0..1 threshold. */
54
+ function parseThreshold(raw, fallback, label) {
55
+ const n = raw === undefined ? fallback : Number(raw);
56
+ if (!Number.isFinite(n) || n < 0 || n > 1) {
57
+ throw new Error(`image: ${label} must be a number between 0 and 1`);
58
+ }
59
+ return n;
60
+ }
61
+
62
+ /** Resolve a write target against a cwd; empty input resolves to null. */
63
+ function resolveWritePath(outPath, cwd) {
64
+ if (outPath === undefined || outPath === null) return null;
65
+ const text = String(outPath).trim();
66
+ if (text.length === 0) return null;
67
+ return pathResolve(cwd ?? process.cwd(), text);
68
+ }
69
+
70
+ /** Ensure the parent directory of a path exists (recursive). */
71
+ async function ensureDirFor(p) {
72
+ await mkdir(dirname(p), { recursive: true });
73
+ }
74
+
75
+ /** Build a default crop temp path under the OS temp dir. */
76
+ function defaultCropPath() {
77
+ const dir = join(tmpdir(), 'picturereader');
78
+ return { dir, file: join(dir, `crop-${Date.now()}-${randomBytes(4).toString('hex')}.png`) };
79
+ }
80
+
81
+ /**
82
+ * Pixel bounds (integer [x0,y0,w,h]) of a fraction region on an image.
83
+ * @param normalize - the core `normalizeRegion` function (validates + defaults).
84
+ * @param imgWidth - source pixel width.
85
+ * @param imgHeight - source pixel height.
86
+ * @param region - `[x0, y0, x1, y1]` fractions (or undefined = full image).
87
+ * @returns an integer box `{ x0, y0, w, h }`.
88
+ */
89
+ function regionBounds(normalize, imgWidth, imgHeight, region) {
90
+ const [rx0, ry0, rx1, ry1] = normalize(region);
91
+ const px0 = Math.max(0, Math.floor(rx0 * imgWidth));
92
+ const px1 = Math.min(imgWidth, Math.ceil(rx1 * imgWidth));
93
+ const py0 = Math.max(0, Math.floor(ry0 * imgHeight));
94
+ const py1 = Math.min(imgHeight, Math.ceil(ry1 * imgHeight));
95
+ return { x0: px0, y0: py0, w: px1 - px0, h: py1 - py0 };
96
+ }
97
+
98
+ /**
99
+ * Load the core module (cache-busted by tool.js) once per process and return
100
+ * its namespace. The lookup is memoized so repeated tool calls reuse it.
101
+ * @returns the core module namespace.
102
+ */
103
+ let corePromise = null;
104
+ async function loadCore() {
105
+ if (corePromise === null) corePromise = importCore();
106
+ return corePromise;
107
+ }
108
+
109
+ /**
110
+ * Decode an image file bytes into `{ data, width, height }` after validating
111
+ * the extension and pixel-count guard, matching the existing tools' behavior.
112
+ */
113
+ async function decodeChecked(core, ext, bytes, tool, filePath) {
114
+ if (core.UNSUPPORTED_EXTENSIONS.has(ext)) {
115
+ throw new Error(`${tool}: WebP is not supported yet — convert the file to PNG or JPEG first`);
116
+ }
117
+ if (!core.IMAGE_EXTENSIONS.has(ext)) {
118
+ throw new Error(`${tool}: unsupported image type "${ext}" (supported: PNG, JPEG, GIF, BMP)`);
119
+ }
120
+ const image = core.decodeImage(bytes, ext);
121
+ if (image.width * image.height > MAX_PIXELS) {
122
+ throw new Error(
123
+ `${tool}: ${image.width}x${image.height} exceeds the ${MAX_PIXELS}-pixel decode limit for "${filePath}" — downscale or crop the file first`
124
+ );
125
+ }
126
+ return image;
127
+ }
128
+
129
+ // ---------------------------------------------------------------------------
130
+ // image_crop
131
+ // ---------------------------------------------------------------------------
132
+
133
+ /**
134
+ * Build the `image_crop` tool: crop an image to a 0..1 fraction region and
135
+ * write the result as a lossless PNG, either to an explicit out_path or a
136
+ * unique file in the OS temp dir. The returned path is ready to feed back to
137
+ * image_scan / image_ocr for continued analysis.
138
+ * @param ctx - the Cordis context providing `ctx.fs`.
139
+ */
140
+ export function createImageCropTool(ctx) {
141
+ return {
142
+ name: 'image_crop',
143
+ description: [
144
+ 'Crop a local image to a rectangular fraction region and write the result as a lossless PNG file.',
145
+ 'Parameters: file_path (required, PNG/JPEG/GIF/BMP), region (required, [x0, y0, x1, y1] fractions in 0..1, with x1 > x0 and y1 > y0) selects the rectangle to keep, and out_path (optional — where to write the PNG; when empty a unique file is created under the system temp directory picturereader/).',
146
+ 'The valid region comes from a prior image_scan: pass the same region fractions that located the subject you now want isolated at full resolution.',
147
+ 'Returns the written output path plus the cropped pixel dimensions. Use image_scan / image_ocr on the returned path to continue analyzing the cropped result, or image_sample for fine texture detail.'
148
+ ].join(' '),
149
+ parameters: {
150
+ type: 'object',
151
+ additionalProperties: true,
152
+ properties: {
153
+ file_path: {
154
+ type: 'string',
155
+ description: 'Path to the source image file (PNG/JPEG/GIF/BMP), resolved by the filesystem backend.'
156
+ },
157
+ region: {
158
+ type: 'array',
159
+ description: 'Required [x0, y0, x1, y1] fractions in 0..1 to crop to. Must obey x1 > x0 and y1 > y0.',
160
+ items: { type: 'number' }
161
+ },
162
+ out_path: {
163
+ type: 'string',
164
+ description: 'Optional output path for the cropped PNG. When empty, a unique file is written under the system temp directory (picturereader/).'
165
+ }
166
+ },
167
+ required: ['file_path', 'region']
168
+ },
169
+ output: {
170
+ schema: {
171
+ type: 'object',
172
+ additionalProperties: true,
173
+ properties: {
174
+ path: { type: 'string' },
175
+ width: { type: 'integer' },
176
+ height: { type: 'integer' },
177
+ generated: { type: 'boolean' },
178
+ tempDir: { type: 'string' },
179
+ outPath: { type: 'string' },
180
+ note: { type: 'string' }
181
+ },
182
+ required: ['path', 'width', 'height', 'generated', 'outPath']
183
+ },
184
+ render: (_args, value) => {
185
+ const lines = [`crop: ${value.path} (${value.width}x${value.height}) -> ${value.outPath}`];
186
+ if (value.generated) lines.push(`written to generated temp file under ${value.tempDir}`);
187
+ if (value.note !== undefined) lines.push(value.note);
188
+ return [{ type: 'text', text: lines.join('\n') }];
189
+ }
190
+ },
191
+ isConcurrencySafe: () => true,
192
+ async execute(args, exec) {
193
+ if (exec.signal?.aborted) throw new Error('image_crop: cancelled');
194
+ const tool = 'image_crop';
195
+ const filePath = String(args.file_path ?? '').trim();
196
+ if (filePath.length === 0) throw new Error('image_crop: file_path must be a non-empty string');
197
+ if (args.region === undefined) {
198
+ throw new Error('image_crop: region is required ([x0, y0, x1, y1] fractions)');
199
+ }
200
+
201
+ const ext = extname(filePath).toLowerCase();
202
+ const core = await loadCore();
203
+ const cwd = exec.agent?.session?.header?.cwd;
204
+ const target = await ctx.fs.resolve(filePath, {
205
+ ...(cwd !== undefined ? { cwd } : {}),
206
+ signal: exec.signal
207
+ });
208
+ const info = await ctx.fs.stat(target, exec.signal);
209
+ if (!info) throw new Error(`image_crop: cannot read "${target.displayPath}": file not found`);
210
+ if (info.type !== 'file') throw new Error(`image_crop: cannot read "${target.displayPath}": not a regular file`);
211
+ const bytes = await ctx.fs.readBytes(target, exec.signal, BYTE_CAP);
212
+
213
+ const image = await decodeChecked(core, ext, bytes, tool, target.displayPath);
214
+ const region = core.normalizeRegion(args.region); // throws clear error on invalid
215
+ const cropped = core.cropRgba(image.data, image.width, image.height, region);
216
+ const pngBytes = core.encodePng(cropped.data, cropped.width, cropped.height);
217
+
218
+ let outPath;
219
+ let generated = false;
220
+ let tempDir;
221
+ const explicitOut = resolveWritePath(args.out_path, cwd);
222
+ if (explicitOut !== null) {
223
+ outPath = explicitOut;
224
+ await ensureDirFor(outPath);
225
+ } else {
226
+ const def = defaultCropPath();
227
+ tempDir = def.dir;
228
+ outPath = def.file;
229
+ generated = true;
230
+ await mkdir(tempDir, { recursive: true });
231
+ }
232
+ await writeFile(outPath, pngBytes);
233
+
234
+ ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, exec);
235
+ const result = {
236
+ path: target.displayPath,
237
+ width: cropped.width,
238
+ height: cropped.height,
239
+ generated,
240
+ outPath,
241
+ ...(tempDir !== undefined ? { tempDir } : {}),
242
+ note: '可用 image_scan / image_ocr 对裁剪结果做进一步分析'
243
+ };
244
+ return result;
245
+ }
246
+ };
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // image_palette
251
+ // ---------------------------------------------------------------------------
252
+
253
+ /**
254
+ * Build the `image_palette` tool: extract the dominant colors (3-bit/channel
255
+ * quantization) and hue-family tone of an image or a region, so a text-only
256
+ * model can reason about color composition without a vision model.
257
+ * @param ctx - the Cordis context providing `ctx.fs`.
258
+ */
259
+ export function createImagePaletteTool(ctx) {
260
+ return {
261
+ name: 'image_palette',
262
+ description: [
263
+ 'Extract the dominant colors of a local image (or a region of it) using 3-bit/channel quantization, plus a hue-family breakdown for an overall tone read.',
264
+ 'Parameters: file_path (required, PNG/JPEG/GIF/BMP), region (optional [x0, y0, x1, y1] fractions — restrict to a sub-area), top (number of dominant colors to return, 1..32, default 12), sample_step (optional sampling stride in pixels, default 1).',
265
+ 'Each dominant color gives its hex (#rrggbb, the bucket mean color), a classified palette name (black/white/gray/red/green/blue/yellow/cyan/orange/pink/purple/brown/...), its percent share of sampled pixels, and its RGB tuple.',
266
+ 'hue_families groups colors by hue family (red/orange/yellow/green/cyan/blue/purple/pink/achromatic) regardless of darkness, which is the most robust signal for overall image tone — a photo whose many colors all classify as gray still reports its true hue mix here.',
267
+ 'distinct reports how many distinct quantization buckets were found (coarse color diversity). Use it together with image_scan to understand palette vs layout.'
268
+ ].join(' '),
269
+ parameters: {
270
+ type: 'object',
271
+ additionalProperties: true,
272
+ properties: {
273
+ file_path: {
274
+ type: 'string',
275
+ description: 'Path to the source image file (PNG/JPEG/GIF/BMP), resolved by the filesystem backend.'
276
+ },
277
+ region: {
278
+ type: 'array',
279
+ description: 'Optional [x0, y0, x1, y1] fractions in 0..1 to restrict the analysis to part of the image.',
280
+ items: { type: 'number' }
281
+ },
282
+ top: {
283
+ type: 'integer',
284
+ description: 'Number of dominant colors to return (1..32, default 12).'
285
+ },
286
+ sample_step: {
287
+ type: 'integer',
288
+ description: 'Optional sampling stride in pixels (default 1 = every pixel). Use a larger stride on huge images to bound cost.'
289
+ }
290
+ },
291
+ required: ['file_path']
292
+ },
293
+ output: {
294
+ schema: {
295
+ type: 'object',
296
+ additionalProperties: true,
297
+ properties: {
298
+ path: { type: 'string' },
299
+ width: { type: 'integer' },
300
+ height: { type: 'integer' },
301
+ region: { type: 'string' },
302
+ top: {
303
+ type: 'array',
304
+ items: {
305
+ type: 'object',
306
+ additionalProperties: true,
307
+ properties: {
308
+ hex: { type: 'string' },
309
+ name: { type: 'string' },
310
+ pct: { type: 'number' },
311
+ rgb: {
312
+ type: 'object',
313
+ properties: { r: { type: 'integer' }, g: { type: 'integer' }, b: { type: 'integer' } }
314
+ }
315
+ },
316
+ required: ['hex', 'name', 'pct', 'rgb']
317
+ }
318
+ },
319
+ hue_families: {
320
+ type: 'array',
321
+ items: {
322
+ type: 'object',
323
+ additionalProperties: true,
324
+ properties: { family: { type: 'string' }, pct: { type: 'number' } },
325
+ required: ['family', 'pct']
326
+ }
327
+ },
328
+ distinct: { type: 'integer' }
329
+ },
330
+ required: ['path', 'width', 'height', 'top', 'hue_families', 'distinct']
331
+ },
332
+ render: (_args, value) => {
333
+ const lines = [`palette: ${value.path} (${value.width}x${value.height}, region=${value.region})`];
334
+ if (value.top.length > 0) {
335
+ lines.push(`dominant colors: ${value.top.map((c) => `${c.name} ${c.pct}% (${c.hex} rgb(${c.rgb.r},${c.rgb.g},${c.rgb.b}))`).join(', ')}`);
336
+ } else {
337
+ lines.push('dominant colors: (none found)');
338
+ }
339
+ if (value.hue_families.length > 0) {
340
+ const colored = value.hue_families.filter((h) => h.family !== 'achromatic');
341
+ const achromatic = value.hue_families.find((h) => h.family === 'achromatic');
342
+ lines.push(`hue families: ${colored.map((h) => `${h.family} ${h.pct}%`).join(', ')}${achromatic ? `, achromatic ${achromatic.pct}%` : ''}`);
343
+ }
344
+ lines.push(`distinct quantization buckets: ${value.distinct}`);
345
+ return [{ type: 'text', text: lines.join('\n') }];
346
+ }
347
+ },
348
+ isConcurrencySafe: () => true,
349
+ async execute(args, exec) {
350
+ if (exec.signal?.aborted) throw new Error('image_palette: cancelled');
351
+ const tool = 'image_palette';
352
+ const filePath = String(args.file_path ?? '').trim();
353
+ if (filePath.length === 0) throw new Error('image_palette: file_path must be a non-empty string');
354
+
355
+ const top = parseBoundedInt(args.top, 12, 1, 32, 'top');
356
+ const sampleStep = parseBoundedInt(args.sample_step, 1, 1, 100_000, 'sample_step');
357
+ const ext = extname(filePath).toLowerCase();
358
+ const core = await loadCore();
359
+ const cwd = exec.agent?.session?.header?.cwd;
360
+ const target = await ctx.fs.resolve(filePath, {
361
+ ...(cwd !== undefined ? { cwd } : {}),
362
+ signal: exec.signal
363
+ });
364
+ const info = await ctx.fs.stat(target, exec.signal);
365
+ if (!info) throw new Error(`image_palette: cannot read "${target.displayPath}": file not found`);
366
+ if (info.type !== 'file') throw new Error(`image_palette: cannot read "${target.displayPath}": not a regular file`);
367
+ const bytes = await ctx.fs.readBytes(target, exec.signal, BYTE_CAP);
368
+
369
+ const image = await decodeChecked(core, ext, bytes, tool, target.displayPath);
370
+ const region = args.region === undefined ? [0, 0, 1, 1] : core.normalizeRegion(args.region);
371
+
372
+ const topList = [];
373
+ const hueCounts = new Map();
374
+ const buckets = new Map();
375
+ const box = regionBounds(core.normalizeRegion, image.width, image.height, region);
376
+ let total = 0;
377
+ for (let y = box.y0; y < box.y0 + box.h; y += sampleStep) {
378
+ for (let x = box.x0; x < box.x0 + box.w; x += sampleStep) {
379
+ const p = (y * image.width + x) * 4;
380
+ if (image.data[p + 3] < 128) continue;
381
+ const r = image.data[p];
382
+ const g = image.data[p + 1];
383
+ const b = image.data[p + 2];
384
+ const key = (r >> BUCKET_SHIFT) << 6 | (g >> BUCKET_SHIFT) << 3 | (b >> BUCKET_SHIFT);
385
+ let bucket = buckets.get(key);
386
+ if (bucket === undefined) {
387
+ bucket = { r: 0, g: 0, b: 0, count: 0 };
388
+ buckets.set(key, bucket);
389
+ }
390
+ bucket.r += r;
391
+ bucket.g += g;
392
+ bucket.b += b;
393
+ bucket.count += 1;
394
+ const fam = core.hueFamilyFor(r, g, b);
395
+ hueCounts.set(fam, (hueCounts.get(fam) ?? 0) + 1);
396
+ total += 1;
397
+ }
398
+ }
399
+ if (total > 0) {
400
+ for (const bucket of buckets.values()) {
401
+ const ar = Math.round(bucket.r / bucket.count);
402
+ const ag = Math.round(bucket.g / bucket.count);
403
+ const ab = Math.round(bucket.b / bucket.count);
404
+ topList.push({
405
+ hex: hexOf(ar, ag, ab),
406
+ name: core.classify(ar, ag, ab, 'full').name,
407
+ pct: Math.round((bucket.count / total) * 1000) / 10,
408
+ rgb: { r: ar, g: ag, b: ab },
409
+ count: bucket.count
410
+ });
411
+ }
412
+ topList.sort((a, b) => b.count - a.count);
413
+ for (const item of topList) delete item.count;
414
+ }
415
+ const hueFamilies = [...hueCounts.entries()]
416
+ .map(([family, count]) => ({ family, pct: Math.round((count / total) * 1000) / 10 }))
417
+ .sort((a, b) => b.pct - a.pct);
418
+
419
+ ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, exec);
420
+ return {
421
+ path: target.displayPath,
422
+ width: image.width,
423
+ height: image.height,
424
+ region: region.map((v) => Math.round(v * 1000) / 1000).join(','),
425
+ top: topList.slice(0, top),
426
+ hue_families: hueFamilies,
427
+ distinct: buckets.size
428
+ };
429
+ }
430
+ };
431
+ }
432
+
433
+ // ---------------------------------------------------------------------------
434
+ // image_compare
435
+ // ---------------------------------------------------------------------------
436
+
437
+ /**
438
+ * Compare two RGBA images at a common sample grid. Both images use the same
439
+ * normalized region; when regions have different pixel sizes (different image
440
+ * dimensions) the grid is aligned on the minimum size, so only the overlapping
441
+ * portion is compared.
442
+ * @returns `{ meanDiff, diffRatio, maxDiff, diffBox, commonWidth, commonHeight, diffPixels, samples, cells }`.
443
+ */
444
+ function compareRgba(dataA, imgWA, imgHA, dataB, imgWB, imgHB, normalize, region, downsample) {
445
+ const boxA = regionBounds(normalize, imgWA, imgHA, region);
446
+ const boxB = regionBounds(normalize, imgWB, imgHB, region);
447
+ const gw = Math.min(boxA.w, boxB.w);
448
+ const gh = Math.min(boxA.h, boxB.h);
449
+ if (gw <= 0 || gh <= 0) throw new Error('image_compare: the comparison region has zero area');
450
+
451
+ let samples = 0;
452
+ let diffPixels = 0;
453
+ let meanSum = 0;
454
+ let maxDiff = 0;
455
+ let dMinX = Infinity;
456
+ let dMinY = Infinity;
457
+ let dMaxX = -1;
458
+ let dMaxY = -1;
459
+ const cols = Math.max(1, Math.ceil(gw / downsample));
460
+ const rows = Math.max(1, Math.ceil(gh / downsample));
461
+ const cells = new Array(rows * cols);
462
+
463
+ for (let gy = 0; gy < gh; gy += downsample) {
464
+ const row = Math.floor(gy / downsample);
465
+ for (let gx = 0; gx < gw; gx += downsample) {
466
+ const col = Math.floor(gx / downsample);
467
+ const ux = gw === 1 ? 0.5 : (gx + 0.5) / gw;
468
+ const uy = gh === 1 ? 0.5 : (gy + 0.5) / gh;
469
+ const ax = boxA.x0 + Math.floor(ux * boxA.w);
470
+ const ay = boxA.y0 + Math.floor(uy * boxA.h);
471
+ const bx = boxB.x0 + Math.floor(ux * boxB.w);
472
+ const by = boxB.y0 + Math.floor(uy * boxB.h);
473
+ const pa = (ay * imgWA + ax) * 4;
474
+ const pb = (by * imgWB + bx) * 4;
475
+ const dr = Math.abs(dataA[pa] - dataB[pb]) / 255;
476
+ const dg = Math.abs(dataA[pa + 1] - dataB[pb + 1]) / 255;
477
+ const db = Math.abs(dataA[pa + 2] - dataB[pb + 2]) / 255;
478
+ const diff = (dr + dg + db) / 3;
479
+ meanSum += diff;
480
+ samples += 1;
481
+ if (diff > maxDiff) maxDiff = diff;
482
+ const differing = diff > DIFF_PIXEL_THRESHOLD;
483
+ if (differing) {
484
+ diffPixels += 1;
485
+ if (ux < dMinX) dMinX = ux;
486
+ if (uy < dMinY) dMinY = uy;
487
+ if (ux > dMaxX) dMaxX = ux;
488
+ if (uy > dMaxY) dMaxY = uy;
489
+ }
490
+ // base preview cell = image A color, differencing cells are red
491
+ const cellIdx = row * cols + col;
492
+ cells[cellIdx] = differing ? [255, 0, 0] : [dataA[pa], dataA[pa + 1], dataA[pa + 2]];
493
+ }
494
+ }
495
+ const meanDiff = samples === 0 ? 0 : meanSum / samples;
496
+ const diffRatio = samples === 0 ? 0 : diffPixels / samples;
497
+ const diffBox = dMinX === Infinity
498
+ ? null
499
+ : [round3(Math.min(dMinX, dMaxX)), round3(Math.min(dMinY, dMaxY)), round3(Math.max(dMinX, dMaxX)), round3(Math.max(dMinY, dMaxY))];
500
+ return { meanDiff, diffRatio, maxDiff, diffBox, commonWidth: gw, commonHeight: gh, diffPixels, samples, cells, cols, rows };
501
+ }
502
+
503
+ /**
504
+ * Build the `image_compare` tool: pixel-wise comparison of two images (or the
505
+ * same fraction region of both). Reports the mean/ratio/max diff, a normalized
506
+ * difference bounding box, and a verdict, and optionally writes a red-marked
507
+ * difference preview PNG.
508
+ * @param ctx - the Cordis context providing `ctx.fs`.
509
+ */
510
+ export function createImageCompareTool(ctx) {
511
+ return {
512
+ name: 'image_compare',
513
+ description: [
514
+ 'Compare two local images pixel-by-pixel, optionally within the same 0..1 fraction region of both, and report how different they are.',
515
+ 'Parameters: file_path_a / file_path_b (required, PNG/JPEG/GIF/BMP), region (optional [x0,y0,x1,y1] fractions applied to both images — when omitted the full images are compared, aligned to the smaller size if dimensions differ), max_diff_threshold (optional 0..1, default 0.05; the pixel-difference share above which the verdict flips to "different"), downsample (optional 1..32 sampling stride, default 4 — controls how many pixels are sampled to bound cost), preview_path (optional — write a PNG that marks differing pixels red on top of image A).',
516
+ 'Returns mean_diff (average per-pixel RGB channel delta 0..1), diff_ratio (fraction of sampled pixels differing by more than 0.1), max_diff (the single largest pixel difference), size_diff (pixel dimension delta, or null when identical), and diff_box (the normalized [x0,y0,x1,y1] bounding box of differing pixels within the compared region, or null when identical).',
517
+ 'verdict is "size-diff" when the images have different dimensions, otherwise "different" when diff_ratio or mean_diff exceeds max_diff_threshold, otherwise "same". Use it to verify whether a re-export, a crop with text overlay, or a reprocessed image is effectively unchanged.'
518
+ ].join(' '),
519
+ parameters: {
520
+ type: 'object',
521
+ additionalProperties: true,
522
+ properties: {
523
+ file_path_a: {
524
+ type: 'string',
525
+ description: 'Path to the first image file, resolved by the filesystem backend.'
526
+ },
527
+ file_path_b: {
528
+ type: 'string',
529
+ description: 'Path to the second image file, resolved by the filesystem backend.'
530
+ },
531
+ region: {
532
+ type: 'array',
533
+ description: 'Optional [x0, y0, x1, y1] fractions in 0..1 applied to both images.',
534
+ items: { type: 'number' }
535
+ },
536
+ max_diff_threshold: {
537
+ type: 'number',
538
+ description: 'Optional 0..1 threshold (default 0.05) controlling the "same" vs "different" verdict.'
539
+ },
540
+ downsample: {
541
+ type: 'integer',
542
+ description: 'Optional sampling stride in pixels (1..32, default 4) controlling comparison cost.'
543
+ },
544
+ preview_path: {
545
+ type: 'string',
546
+ description: 'Optional output path for a difference preview PNG (differing pixels marked red on image A). When omitted no preview is written.'
547
+ }
548
+ },
549
+ required: ['file_path_a', 'file_path_b']
550
+ },
551
+ output: {
552
+ schema: {
553
+ type: 'object',
554
+ additionalProperties: true,
555
+ properties: {
556
+ path_a: { type: 'string' },
557
+ path_b: { type: 'string' },
558
+ width_a: { type: 'integer' },
559
+ height_a: { type: 'integer' },
560
+ width_b: { type: 'integer' },
561
+ height_b: { type: 'integer' },
562
+ size_diff: {
563
+ type: 'object',
564
+ properties: { w: { type: 'integer' }, h: { type: 'integer' } },
565
+ },
566
+ mean_diff: { type: 'number' },
567
+ diff_ratio: { type: 'number' },
568
+ max_diff: { type: 'number' },
569
+ region_a: { type: 'string' },
570
+ region_b: { type: 'string' },
571
+ diff_box: {
572
+ type: 'array',
573
+ items: { type: 'number' },
574
+ },
575
+ verdict: { type: 'string', enum: ['same', 'different', 'size-diff'] },
576
+ preview_path: { type: 'string' },
577
+ note: { type: 'string' }
578
+ },
579
+ required: ['path_a', 'path_b', 'width_a', 'height_a', 'width_b', 'height_b', 'region_a', 'region_b', 'verdict']
580
+ },
581
+ render: (_args, value) => {
582
+ const lines = [
583
+ `compare: ${value.path_a} (${value.width_a}x${value.height_a}) vs ${value.path_b} (${value.width_b}x${value.height_b})`
584
+ ];
585
+ lines.push(`verdict: ${value.verdict} | mean_diff=${round3(value.mean_diff)} diff_ratio=${round3(value.diff_ratio)} max_diff=${round3(value.max_diff)}`);
586
+ if (value.size_diff) lines.push(`size_diff: w ${value.size_diff.w}, h ${value.size_diff.h}`);
587
+ if (value.diff_box) lines.push(`difference region: ${value.diff_box.join(',')} (normalized within compared region)`);
588
+ else lines.push('no differing pixels found (diff_box: null)');
589
+ if (value.preview_path) lines.push(`preview: ${value.preview_path}`);
590
+ if (value.note) lines.push(`note: ${value.note}`);
591
+ return [{ type: 'text', text: lines.join('\n') }];
592
+ }
593
+ },
594
+ isConcurrencySafe: () => true,
595
+ async execute(args, exec) {
596
+ if (exec.signal?.aborted) throw new Error('image_compare: cancelled');
597
+ const tool = 'image_compare';
598
+ const filePathA = String(args.file_path_a ?? '').trim();
599
+ const filePathB = String(args.file_path_b ?? '').trim();
600
+ if (filePathA.length === 0) throw new Error('image_compare: file_path_a must be a non-empty string');
601
+ if (filePathB.length === 0) throw new Error('image_compare: file_path_b must be a non-empty string');
602
+
603
+ const maxDiffThreshold = parseThreshold(args.max_diff_threshold, 0.05, 'max_diff_threshold');
604
+ const downsample = parseBoundedInt(args.downsample, 4, 1, 32, 'downsample');
605
+ const extA = extname(filePathA).toLowerCase();
606
+ const extB = extname(filePathB).toLowerCase();
607
+ const core = await loadCore();
608
+ const cwd = exec.agent?.session?.header?.cwd;
609
+
610
+ const targetA = await ctx.fs.resolve(filePathA, { ...(cwd !== undefined ? { cwd } : {}), signal: exec.signal });
611
+ const targetB = await ctx.fs.resolve(filePathB, { ...(cwd !== undefined ? { cwd } : {}), signal: exec.signal });
612
+ const infoA = await ctx.fs.stat(targetA, exec.signal);
613
+ const infoB = await ctx.fs.stat(targetB, exec.signal);
614
+ if (!infoA) throw new Error(`image_compare: cannot read "${targetA.displayPath}": file not found`);
615
+ if (!infoB) throw new Error(`image_compare: cannot read "${targetB.displayPath}": file not found`);
616
+ if (infoA.type !== 'file') throw new Error(`image_compare: cannot read "${targetA.displayPath}": not a regular file`);
617
+ if (infoB.type !== 'file') throw new Error(`image_compare: cannot read "${targetB.displayPath}": not a regular file`);
618
+ const bytesA = await ctx.fs.readBytes(targetA, exec.signal, BYTE_CAP);
619
+ const bytesB = await ctx.fs.readBytes(targetB, exec.signal, BYTE_CAP);
620
+
621
+ const imageA = await decodeChecked(core, extA, bytesA, tool, targetA.displayPath);
622
+ const imageB = await decodeChecked(core, extB, bytesB, tool, targetB.displayPath);
623
+
624
+ const sameSize = imageA.width === imageB.width && imageA.height === imageB.height;
625
+ const sizeDiff = sameSize ? null : { w: Math.abs(imageA.width - imageB.width), h: Math.abs(imageA.height - imageB.height) };
626
+
627
+ const region = args.region === undefined ? [0, 0, 1, 1] : core.normalizeRegion(args.region);
628
+ const regionDisplay = region.map((v) => Math.round(v * 1000) / 1000).join(',');
629
+ let note;
630
+ if (!sameSize && args.region === undefined) {
631
+ note = 'images differ in size and no region was given — compared aligned whole images at the smaller dimensions';
632
+ }
633
+
634
+ const cmp = compareRgba(imageA.data, imageA.width, imageA.height, imageB.data, imageB.width, imageB.height, core.normalizeRegion, region, downsample);
635
+
636
+ let verdict;
637
+ if (sizeDiff !== null) {
638
+ verdict = 'size-diff';
639
+ } else if (cmp.diffRatio > maxDiffThreshold || cmp.meanDiff > maxDiffThreshold) {
640
+ verdict = 'different';
641
+ } else {
642
+ verdict = 'same';
643
+ }
644
+
645
+ let preview;
646
+ const explicitPrev = resolveWritePath(args.preview_path, cwd);
647
+ if (explicitPrev !== null) {
648
+ const rgba = Buffer.alloc(cmp.rows * cmp.cols * 4);
649
+ for (let i = 0; i < cmp.cells.length; i += 1) {
650
+ const [pr, pg, pb] = cmp.cells[i] ?? [0, 0, 0];
651
+ rgba[i * 4] = pr;
652
+ rgba[i * 4 + 1] = pg;
653
+ rgba[i * 4 + 2] = pb;
654
+ rgba[i * 4 + 3] = 255;
655
+ }
656
+ const pngBytes = core.encodePng(rgba, cmp.cols, cmp.rows);
657
+ await ensureDirFor(explicitPrev);
658
+ await writeFile(explicitPrev, pngBytes);
659
+ preview = explicitPrev;
660
+ }
661
+
662
+ ctx.emit('fs/observed', targetA, { kind: 'present', version: infoA.version }, exec);
663
+ ctx.emit('fs/observed', targetB, { kind: 'present', version: infoB.version }, exec);
664
+ return {
665
+ path_a: targetA.displayPath,
666
+ path_b: targetB.displayPath,
667
+ width_a: imageA.width,
668
+ height_a: imageA.height,
669
+ width_b: imageB.width,
670
+ height_b: imageB.height,
671
+ ...(sizeDiff !== null && sizeDiff !== undefined ? { size_diff: sizeDiff } : {}),
672
+ mean_diff: round3(cmp.meanDiff),
673
+ diff_ratio: round3(cmp.diffRatio),
674
+ max_diff: round3(cmp.maxDiff),
675
+ region_a: regionDisplay,
676
+ region_b: regionDisplay,
677
+ ...(cmp.diffBox !== null && cmp.diffBox !== undefined ? { diff_box: cmp.diffBox } : {}),
678
+ verdict,
679
+ ...(preview !== undefined ? { preview_path: preview } : {}),
680
+ ...(note !== undefined ? { note } : {})
681
+ };
682
+ }
683
+ };
684
+ }
685
+
686
+ export const tools = [
687
+ createImageCropTool,
688
+ createImagePaletteTool,
689
+ createImageCompareTool
690
+ ];
691
+
692
+ // Register factories bound to a ctx when the host mounts this module.
693
+ export function registerMoreTools(ctx) {
694
+ tools.forEach((factory) => ctx.tools.register(factory(ctx)));
695
+ }