picturereader-zcode 1.0.3

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.
package/src/core.js ADDED
@@ -0,0 +1,1490 @@
1
+ /**
2
+ * picturereader core — the entire business logic in ONE self-contained module.
3
+ *
4
+ * The plugin loads this module dynamically with a cache-busting query on every
5
+ * tool execution (see `importCore` in tool.js), so edits to this file take
6
+ * effect on the next `image_scan` call WITHOUT a process restart. That only
7
+ * works because this module has no relative imports of its own source files:
8
+ * the only static dependencies are stable npm packages (pngjs / jpeg-js /
9
+ * omggif), which Node keeps cached.
10
+ *
11
+ * Everything is pure JS, no native dependencies:
12
+ * - decoding: PNG (pngjs), JPEG (jpeg-js), GIF first frame (omggif), BMP (built-in)
13
+ * - palette: configurable depth (full 14 / basic 8 / gray 3) with an
14
+ * achromatic gate so dark grays never misclassify as brown
15
+ * - pipeline: region crop, aspect-fit downscale, per-cell average + saturated
16
+ * "accent" color (keeps thin colored lines visible), luminance/color grids
17
+ * - OCR: Windows.Media.Ocr via a spawned PowerShell (built into Windows 10+,
18
+ * local, no install; Chinese needs the language pack)
19
+ * @module picturereader/core
20
+ */
21
+
22
+ import { PNG } from 'pngjs';
23
+ import * as jpeg from 'jpeg-js';
24
+ import { GifReader } from 'omggif';
25
+ import { spawn } from 'node:child_process';
26
+ import { writeFile, rm, stat } from 'node:fs/promises';
27
+ import { tmpdir, release } from 'node:os';
28
+ import { join } from 'node:path';
29
+ import { randomBytes } from 'node:crypto';
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // palette
33
+ // ---------------------------------------------------------------------------
34
+
35
+ export const PALETTE = [
36
+ { name: 'black', hex: '#101010', code: 'K' },
37
+ { name: 'white', hex: '#f5f5f5', code: 'W' },
38
+ { name: 'gray', hex: '#8a8a8a', code: 'G' },
39
+ { name: 'red', hex: '#d81b1b', code: 'R' },
40
+ { name: 'darkred', hex: '#8f1d1d', code: 'E' },
41
+ { name: 'orange', hex: '#f07a1b', code: 'O' },
42
+ { name: 'yellow', hex: '#f2d024', code: 'Y' },
43
+ { name: 'green', hex: '#2e9e44', code: 'N' },
44
+ { name: 'cyan', hex: '#1bb8c4', code: 'C' },
45
+ { name: 'blue', hex: '#1b5fd8', code: 'B' },
46
+ { name: 'darkblue', hex: '#1d2f6e', code: 'V' },
47
+ { name: 'purple', hex: '#7b2fc0', code: 'P' },
48
+ { name: 'pink', hex: '#e060a8', code: 'I' },
49
+ { name: 'brown', hex: '#7a4a21', code: 'T' }
50
+ ];
51
+
52
+ /** Valid palette keys; `auto` resolves at analysis time. */
53
+ export const PALETTE_KEYS = ['auto', 'full', 'basic', 'gray'];
54
+
55
+ const BASIC_NAMES = ['black', 'white', 'gray', 'red', 'green', 'blue', 'yellow', 'cyan'];
56
+ const GRAY_NAMES = ['black', 'gray', 'white'];
57
+
58
+ /** Palette lookup by depth key. */
59
+ export const PALETTES = {
60
+ full: PALETTE,
61
+ basic: BASIC_NAMES.map((name) => PALETTE.find((entry) => entry.name === name)),
62
+ gray: GRAY_NAMES.map((name) => PALETTE.find((entry) => entry.name === name))
63
+ };
64
+
65
+ /** Names treated as achromatic in every palette. */
66
+ export const GRAY_FAMILY = new Set(['black', 'white', 'gray']);
67
+
68
+ /** Saturation (max-min) below which a color is treated as achromatic and classified by luminance only. */
69
+ export const ACHROMATIC_SATURATION = 40;
70
+
71
+ /** Luminance stops for achromatic classification (names exist in every palette). */
72
+ const ACHROMATIC_STOPS = [
73
+ { upTo: 64, name: 'black' },
74
+ { upTo: 192, name: 'gray' },
75
+ { name: 'white' }
76
+ ];
77
+
78
+ const RGB_CACHE = new Map();
79
+
80
+ function hexToRgb(hex) {
81
+ return [
82
+ parseInt(hex.slice(1, 3), 16),
83
+ parseInt(hex.slice(3, 5), 16),
84
+ parseInt(hex.slice(5, 7), 16)
85
+ ];
86
+ }
87
+
88
+ function rgbFor(paletteKey) {
89
+ let rgb = RGB_CACHE.get(paletteKey);
90
+ if (rgb === undefined) {
91
+ rgb = PALETTES[paletteKey].map((entry) => hexToRgb(entry.hex));
92
+ RGB_CACHE.set(paletteKey, rgb);
93
+ }
94
+ return rgb;
95
+ }
96
+
97
+ /**
98
+ * Map an RGB triple to the nearest named palette entry.
99
+ *
100
+ * Near-achromatic colors (low saturation) are classified by luminance into
101
+ * black / gray / white first: pure Euclidean distance would misclassify dark
102
+ * grays as brown or dark blue because the gray stop sits mid-brightness. In
103
+ * the `gray` palette every color is classified by luminance only.
104
+ * @param r - red channel 0..255.
105
+ * @param g - green channel 0..255.
106
+ * @param b - blue channel 0..255.
107
+ * @param paletteKey - `'full'` (default), `'basic'` or `'gray'`.
108
+ * @returns the nearest entry's index, name, and whether it is achromatic.
109
+ */
110
+ export function classify(r, g, b, paletteKey = 'full') {
111
+ const palette = PALETTES[paletteKey];
112
+ const max = Math.max(r, g, b);
113
+ const min = Math.min(r, g, b);
114
+ if (paletteKey === 'gray' || max - min < ACHROMATIC_SATURATION) {
115
+ const luma = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
116
+ for (const stop of ACHROMATIC_STOPS) {
117
+ if (stop.upTo === undefined || luma < stop.upTo) {
118
+ const index = palette.findIndex((entry) => entry.name === stop.name);
119
+ return {
120
+ index,
121
+ name: stop.name,
122
+ gray: true
123
+ };
124
+ }
125
+ }
126
+ }
127
+ const rgb = rgbFor(paletteKey);
128
+ let best = 0;
129
+ let bestDistance = Infinity;
130
+ for (let i = 0; i < rgb.length; i += 1) {
131
+ const [pr, pg, pb] = rgb[i];
132
+ const dr = r - pr;
133
+ const dg = g - pg;
134
+ const db = b - pb;
135
+ const distance = dr * dr + dg * dg + db * db;
136
+ if (distance < bestDistance) {
137
+ bestDistance = distance;
138
+ best = i;
139
+ }
140
+ }
141
+ return {
142
+ index: best,
143
+ name: palette[best].name,
144
+ gray: GRAY_FAMILY.has(palette[best].name)
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Validate a raw palette argument.
150
+ * @param raw - the model-supplied value.
151
+ * @returns the validated key ('auto' allowed).
152
+ */
153
+ export function resolvePaletteArgument(raw) {
154
+ const key = String(raw ?? 'auto');
155
+ if (!PALETTE_KEYS.includes(key)) {
156
+ throw new Error("image_scan: palette must be one of 'auto', 'full', 'basic', 'gray'");
157
+ }
158
+ return key;
159
+ }
160
+
161
+ /** Build the one-line legend for a palette, e.g. "K=black, W=white, ...". */
162
+ export function colorLegendFor(paletteKey) {
163
+ return PALETTES[paletteKey].map((entry) => `${entry.code}=${entry.name}`).join(', ');
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // bmp decoding
168
+ // ---------------------------------------------------------------------------
169
+
170
+ /**
171
+ * Decode a BMP buffer into RGBA. BI_RGB / BI_BITFIELDS, 8/24/32 bpp,
172
+ * BITMAPINFOHEADER (40), V4 (108), V5 (124) and OS/2 BITMAPCOREHEADER (12).
173
+ * 16 bpp and RLE-compressed variants are rejected with a clear message.
174
+ * @param buffer - the raw file bytes.
175
+ * @returns `{ width, height, data }` with data as a `Buffer` of RGBA rows
176
+ * top-to-bottom, left-to-right.
177
+ */
178
+ export function decodeBmp(buffer) {
179
+ if (buffer.length < 26) throw new Error('not a valid BMP: file too small');
180
+ const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
181
+ if (view.getUint16(0, true) !== 0x4d42) throw new Error('not a valid BMP: bad magic bytes');
182
+ const dataOffset = view.getUint32(10, true);
183
+ const dibSize = view.getUint32(14, true);
184
+ try {
185
+ if (dibSize === 12) {
186
+ const width = view.getUint16(18, true);
187
+ const height = view.getUint16(20, true);
188
+ const bpp = view.getUint16(24, true);
189
+ return renderBmpRows(view, buffer, {
190
+ width,
191
+ height: -height, // OS/2 core is bottom-up
192
+ bpp,
193
+ compression: 0,
194
+ dataOffset,
195
+ paletteOffset: 26,
196
+ paletteEntrySize: 3
197
+ });
198
+ }
199
+ if (dibSize !== 40 && dibSize !== 108 && dibSize !== 124) {
200
+ throw new Error(`unsupported BMP DIB header (${dibSize} bytes)`);
201
+ }
202
+ const width = view.getInt32(18, true);
203
+ const height = view.getInt32(22, true);
204
+ const bpp = view.getUint16(28, true);
205
+ const compression = view.getUint32(30, true);
206
+ if (width <= 0 || height === 0) throw new Error('invalid BMP dimensions');
207
+ if (bpp === 16) throw new Error('16-bit BMP is not supported');
208
+ if (compression === 1 || compression === 2) throw new Error('RLE-compressed BMP is not supported');
209
+ if (compression !== 0 && compression !== 3) throw new Error(`unsupported BMP compression (${compression})`);
210
+ return renderBmpRows(view, buffer, {
211
+ width,
212
+ height,
213
+ bpp,
214
+ compression,
215
+ dataOffset,
216
+ paletteOffset: 14 + dibSize,
217
+ paletteEntrySize: 4
218
+ });
219
+ } catch (error) {
220
+ if (error instanceof RangeError) throw new Error('not a valid BMP: truncated pixel data');
221
+ throw error;
222
+ }
223
+ }
224
+
225
+ function renderBmpRows(view, buffer, { width, height, bpp, compression, dataOffset, paletteOffset, paletteEntrySize }) {
226
+ const topDown = height < 0;
227
+ const h = Math.abs(height);
228
+ const w = width;
229
+ let palette = null;
230
+ if (bpp <= 8) {
231
+ const count = bpp === 8 ? 256 : 1 << bpp;
232
+ palette = new Array(count);
233
+ for (let i = 0; i < count; i += 1) {
234
+ const off = paletteOffset + i * paletteEntrySize;
235
+ palette[i] = { r: view.getUint8(off + 2), g: view.getUint8(off + 1), b: view.getUint8(off) };
236
+ }
237
+ }
238
+ let masks = null;
239
+ if (compression === 3) {
240
+ masks = {
241
+ r: view.getUint32(paletteOffset, true),
242
+ g: view.getUint32(paletteOffset + 4, true),
243
+ b: view.getUint32(paletteOffset + 8, true)
244
+ };
245
+ }
246
+ const rowBytes = Math.ceil((w * bpp) / 32) * 4;
247
+ if (dataOffset + h * rowBytes > buffer.length) throw new Error('not a valid BMP: truncated pixel data');
248
+ const out = Buffer.alloc(w * h * 4);
249
+ for (let y = 0; y < h; y += 1) {
250
+ const srcRow = topDown ? y : h - 1 - y;
251
+ const rowStart = dataOffset + srcRow * rowBytes;
252
+ for (let x = 0; x < w; x += 1) {
253
+ const o = (y * w + x) * 4;
254
+ let r;
255
+ let g;
256
+ let b;
257
+ if (bpp === 24) {
258
+ const p = rowStart + x * 3;
259
+ b = view.getUint8(p);
260
+ g = view.getUint8(p + 1);
261
+ r = view.getUint8(p + 2);
262
+ } else if (bpp === 32) {
263
+ const p = rowStart + x * 4;
264
+ if (masks) {
265
+ const pixel = view.getUint32(p, true);
266
+ r = maskChannel(pixel, masks.r);
267
+ g = maskChannel(pixel, masks.g);
268
+ b = maskChannel(pixel, masks.b);
269
+ } else {
270
+ b = view.getUint8(p);
271
+ g = view.getUint8(p + 1);
272
+ r = view.getUint8(p + 2);
273
+ }
274
+ } else if (bpp === 8) {
275
+ const color = palette[view.getUint8(rowStart + x)] ?? { r: 0, g: 0, b: 0 };
276
+ r = color.r;
277
+ g = color.g;
278
+ b = color.b;
279
+ } else {
280
+ throw new Error(`unsupported BMP bit depth (${bpp})`);
281
+ }
282
+ out[o] = r;
283
+ out[o + 1] = g;
284
+ out[o + 2] = b;
285
+ out[o + 3] = 255;
286
+ }
287
+ }
288
+ return { width: w, height: h, data: out };
289
+ }
290
+
291
+ function maskChannel(pixel, mask) {
292
+ if (mask === 0) return 0;
293
+ const shift = ctz(mask);
294
+ const max = mask >>> shift;
295
+ const value = (pixel & mask) >>> shift;
296
+ return max === 255 ? value : Math.round((value * 255) / max);
297
+ }
298
+
299
+ function ctz(value) {
300
+ let n = 0;
301
+ let v = value >>> 0;
302
+ while ((v & 1) === 0 && n < 32) {
303
+ v >>>= 1;
304
+ n += 1;
305
+ }
306
+ return n;
307
+ }
308
+
309
+ // ---------------------------------------------------------------------------
310
+ // image decoding
311
+ // ---------------------------------------------------------------------------
312
+
313
+ /** Extensions this plugin can decode, keyed by lowercase extension. */
314
+ export const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.bmp']);
315
+ /** Recognized-but-unsupported extensions (friendly error rather than silent fail). */
316
+ export const UNSUPPORTED_EXTENSIONS = new Set(['.webp']);
317
+
318
+ /**
319
+ * Decode an image buffer into RGBA pixels.
320
+ * @param buffer - raw file bytes.
321
+ * @param ext - lowercase file extension including the dot (e.g. ".png").
322
+ * @returns `{ width, height, data }` where data is a `Buffer` of RGBA rows,
323
+ * top-to-bottom, left-to-right.
324
+ */
325
+ export function decodeImage(buffer, ext) {
326
+ switch (ext) {
327
+ case '.png': return decodePng(buffer);
328
+ case '.jpg':
329
+ case '.jpeg': return decodeJpeg(buffer);
330
+ case '.gif': return decodeGif(buffer);
331
+ case '.bmp': return decodeBmp(buffer);
332
+ default: throw new Error(`image_scan: unsupported image type "${ext}"`);
333
+ }
334
+ }
335
+
336
+ function decodePng(buffer) {
337
+ let png;
338
+ try {
339
+ png = PNG.sync.read(buffer);
340
+ } catch (error) {
341
+ throw new Error(`image_scan: not a valid PNG (${error.message})`, { cause: error });
342
+ }
343
+ return { width: png.width, height: png.height, data: Buffer.from(png.data) };
344
+ }
345
+
346
+ function decodeJpeg(buffer) {
347
+ let raw;
348
+ try {
349
+ raw = jpeg.decode(buffer, { formatAsRGBA: true, useTArray: true, maxMemoryUsageInMB: 1024 });
350
+ } catch (error) {
351
+ throw new Error(`image_scan: not a valid JPEG (${error.message})`, { cause: error });
352
+ }
353
+ return {
354
+ width: raw.width,
355
+ height: raw.height,
356
+ data: Buffer.from(raw.data.buffer, raw.data.byteOffset, raw.data.byteLength)
357
+ };
358
+ }
359
+
360
+ function decodeGif(buffer) {
361
+ let reader;
362
+ try {
363
+ reader = new GifReader(buffer);
364
+ } catch (error) {
365
+ throw new Error(`image_scan: not a valid GIF (${error.message})`, { cause: error });
366
+ }
367
+ if (reader.numFrames() < 1) throw new Error('image_scan: GIF contains no frames');
368
+ const { width, height } = reader;
369
+ const pixels = new Uint8Array(width * height * 4);
370
+ try {
371
+ reader.decodeAndBlitFrameRGBA(0, pixels);
372
+ } catch (error) {
373
+ throw new Error(`image_scan: cannot decode GIF frame (${error.message})`, { cause: error });
374
+ }
375
+ return { width, height, data: Buffer.from(pixels.buffer, pixels.byteOffset, pixels.byteLength) };
376
+ }
377
+
378
+ // ---------------------------------------------------------------------------
379
+ // pipeline: downscale, classify, render
380
+ // ---------------------------------------------------------------------------
381
+
382
+ /** Luminance ramp, darkest first; index = luminance fraction over 9 levels. */
383
+ export const RAMP = '.:-=+*#%@';
384
+
385
+ /** Valid grid sizes (target cells on the longer side). */
386
+ export const SIZE_RANGE = { min: 8, max: 64 };
387
+
388
+ /** Threshold for the auto mode: fraction of non-gray cells that switches to the color grid. */
389
+ export const COLOR_MODE_FRACTION = 0.1;
390
+
391
+ /** A cell's saturation above which its accent color is trusted over the average. */
392
+ export const ACCENT_SATURATION = 60;
393
+
394
+ /** Auto palette thresholds: colored-cell fraction that selects full / basic / gray. */
395
+ export const AUTO_PALETTE_FRACTIONS = { full: 0.2, basic: 0.03 };
396
+
397
+ /**
398
+ * Convert a grid-coordinate focus `[row0, col0, row1, col1]` into a fraction
399
+ * region. Rows/cols are INCLUSIVE bounds of the full-image grid that a
400
+ * previous `image_scan` output used (gridWidth = size, gridHeight follows the
401
+ * image aspect — see the "grid coords" line in the render). This lets the
402
+ * model zoom by saying "rows 8-14, cols 15-30" instead of hand-computing
403
+ * 0..1 fractions.
404
+ * @param focus - `[row0, col0, row1, col1]` inclusive grid coordinates.
405
+ * @param gridWidth - the full-image grid width (the size argument).
406
+ * @param gridHeight - the full-image grid height for that size.
407
+ * @returns the equivalent `[x0, y0, x1, y1]` fraction region.
408
+ */
409
+ export function resolveFocus(focus, gridWidth, gridHeight) {
410
+ if (!Array.isArray(focus) || focus.length !== 4) {
411
+ throw new Error('image_scan: focus must be [row0, col0, row1, col1] grid coordinates (inclusive)');
412
+ }
413
+ const [row0, col0, row1, col1] = focus.map((v) => Number(v));
414
+ for (const v of [row0, col0, row1, col1]) {
415
+ if (!Number.isInteger(v) || v < 0) {
416
+ throw new Error('image_scan: focus values must be non-negative integers');
417
+ }
418
+ }
419
+ if (row1 < row0 + 1 || col1 < col0 + 1) {
420
+ throw new Error('image_scan: focus must span at least 2 rows and 2 columns (row1 > row0, col1 > col0)');
421
+ }
422
+ if (row1 >= gridHeight || col1 >= gridWidth) {
423
+ throw new Error(`image_scan: focus out of range — the grid is ${gridWidth}x${gridHeight} (rows 0..${gridHeight - 1}, cols 0..${gridWidth - 1})`);
424
+ }
425
+ return [
426
+ col0 / gridWidth,
427
+ row0 / gridHeight,
428
+ (col1 + 1) / gridWidth,
429
+ (row1 + 1) / gridHeight
430
+ ];
431
+ }
432
+
433
+ /**
434
+ * Validate a 0..1 region and normalize it to `[x0, y0, x1, y1]`.
435
+ * @param region - `[x0, y0, x1, y1]` fractions of the image, or undefined for full image.
436
+ * @returns the normalized region.
437
+ */
438
+ export function normalizeRegion(region) {
439
+ if (region === undefined) return [0, 0, 1, 1];
440
+ if (!Array.isArray(region) || region.length !== 4) {
441
+ throw new Error('image_scan: region must be [x0, y0, x1, y1] fractions in 0..1');
442
+ }
443
+ const [x0, y0, x1, y1] = region.map((v) => Number(v));
444
+ for (const v of [x0, y0, x1, y1]) {
445
+ if (!Number.isFinite(v) || v < 0 || v > 1) {
446
+ throw new Error('image_scan: region values must be numbers in 0..1');
447
+ }
448
+ }
449
+ if (x1 <= x0 || y1 <= y0) {
450
+ throw new Error('image_scan: region must have x1 > x0 and y1 > y0');
451
+ }
452
+ return [x0, y0, x1, y1];
453
+ }
454
+
455
+ /**
456
+ * Analyze an image into a coarse cell grid plus color statistics.
457
+ * @param rgba - RGBA `Buffer` (length `imgWidth * imgHeight * 4`).
458
+ * @param imgWidth - source pixel width.
459
+ * @param imgHeight - source pixel height.
460
+ * @param options - `{ size, mode, region, palette, pxPerCell }`. `size` = target
461
+ * cells on the longer side; `pxPerCell` (mutually exclusive) = requested
462
+ * source pixels per cell, clamped to the 64-cell-per-side limit (the result
463
+ * reports the actual density via `regionWidth`/`regionHeight`).
464
+ * @returns the analysis result consumed by {@link renderImageScan}.
465
+ */
466
+ export function analyzeImage(rgba, imgWidth, imgHeight, { size, mode, region, palette, pxPerCell }) {
467
+ const requestedPalette = resolvePaletteArgument(palette);
468
+ const [rx0, ry0, rx1, ry1] = normalizeRegion(region);
469
+ const rw = Math.max(1e-6, rx1 - rx0);
470
+ const rh = Math.max(1e-6, ry1 - ry0);
471
+
472
+ const px0 = Math.max(0, Math.floor(rx0 * imgWidth));
473
+ const px1 = Math.min(imgWidth, Math.ceil(rx1 * imgWidth));
474
+ const py0 = Math.max(0, Math.floor(ry0 * imgHeight));
475
+ const py1 = Math.min(imgHeight, Math.ceil(ry1 * imgHeight));
476
+ const regionWidth = px1 - px0;
477
+ const regionHeight = py1 - py0;
478
+
479
+ // Grid resolution: either size cells on the longer side, or a requested
480
+ // pixel-per-cell density (clamped to 64 cells per side).
481
+ let gridWidth;
482
+ let gridHeight;
483
+ if (pxPerCell !== undefined && pxPerCell > 0) {
484
+ gridWidth = Math.min(64, Math.max(1, Math.round(regionWidth / pxPerCell)));
485
+ gridHeight = Math.min(64, Math.max(1, Math.round(gridWidth * (regionHeight / Math.max(1, regionWidth)))));
486
+ } else {
487
+ gridWidth = size;
488
+ gridHeight = Math.max(1, Math.round(size * ((rh * imgHeight) / (rw * imgWidth))));
489
+ }
490
+
491
+ const cells = new Array(gridWidth * gridHeight); // holes = fully transparent
492
+ let contentCells = 0;
493
+
494
+ for (let cy = 0; cy < gridHeight; cy += 1) {
495
+ const y0 = py0 + Math.floor((cy * (py1 - py0)) / gridHeight);
496
+ const y1 = py0 + Math.floor(((cy + 1) * (py1 - py0)) / gridHeight);
497
+ for (let cx = 0; cx < gridWidth; cx += 1) {
498
+ const x0 = px0 + Math.floor((cx * (px1 - px0)) / gridWidth);
499
+ const x1 = px0 + Math.floor(((cx + 1) * (px1 - px0)) / gridWidth);
500
+ let sumR = 0;
501
+ let sumG = 0;
502
+ let sumB = 0;
503
+ let n = 0;
504
+ let accentSat = -1;
505
+ let accentR = 0;
506
+ let accentG = 0;
507
+ let accentB = 0;
508
+ let minLuma = Infinity;
509
+ let maxLuma = -Infinity;
510
+ for (let y = y0; y < y1; y += 1) {
511
+ const row = y * imgWidth * 4;
512
+ for (let x = x0; x < x1; x += 1) {
513
+ const p = row + x * 4;
514
+ const a = rgba[p + 3];
515
+ if (a < 128) continue;
516
+ const r = rgba[p];
517
+ const g = rgba[p + 1];
518
+ const b = rgba[p + 2];
519
+ sumR += r;
520
+ sumG += g;
521
+ sumB += b;
522
+ n += 1;
523
+ const luma = 0.299 * r + 0.587 * g + 0.114 * b;
524
+ if (luma < minLuma) minLuma = luma;
525
+ if (luma > maxLuma) maxLuma = luma;
526
+ const sat = Math.max(r, g, b) - Math.min(r, g, b);
527
+ if (sat > accentSat) {
528
+ accentSat = sat;
529
+ accentR = r;
530
+ accentG = g;
531
+ accentB = b;
532
+ }
533
+ }
534
+ }
535
+ if (n === 0) continue;
536
+ const avgR = Math.round(sumR / n);
537
+ const avgG = Math.round(sumG / n);
538
+ const avgB = Math.round(sumB / n);
539
+ cells[cy * gridWidth + cx] = {
540
+ luminance: luminance(avgR, avgG, avgB),
541
+ detail: minLuma === Infinity ? 0 : Math.min(1, (maxLuma - minLuma) / 255),
542
+ shade: shadeFor(avgR, avgG, avgB),
543
+ avgR,
544
+ avgG,
545
+ avgB,
546
+ accentR,
547
+ accentG,
548
+ accentB,
549
+ accentSat
550
+ };
551
+ contentCells += 1;
552
+ }
553
+ }
554
+
555
+ // Pixel-level color statistics: sample the region's actual pixels (not the
556
+ // downsampled cells) so small colored details (pink blossoms, red banners,
557
+ // cyan water) are reported at their TRUE area share instead of being diluted
558
+ // into a gray cell average. Always uses the full 14-color palette, plus a
559
+ // hue-family breakdown (BY HUE ONLY — survives dark/desaturated colors).
560
+ const { colors: pixelColors, hues: pixelHues } = pixelColorStats(rgba, imgWidth, imgHeight, [rx0, ry0, rx1, ry1]);
561
+ // Colored fraction judged by hue, not by the palette gate: a misty scene
562
+ // whose colors are all dark/desaturated still counts as colorful.
563
+ const coloredFractionPixel = pixelHues.filter((h) => h.name !== 'achromatic').reduce((sum, h) => sum + h.pct, 0) / 100;
564
+
565
+ // Resolve the palette first (auto needs the colored fraction; pixel-level
566
+ // is far more reliable than the coarse grid's for small color regions).
567
+ const coloredFractionFull = coloredFractionPixel;
568
+ const paletteKey =
569
+ requestedPalette === 'auto'
570
+ ? coloredFractionFull >= AUTO_PALETTE_FRACTIONS.full
571
+ ? 'full'
572
+ : coloredFractionFull >= AUTO_PALETTE_FRACTIONS.basic
573
+ ? 'basic'
574
+ : 'gray'
575
+ : requestedPalette;
576
+
577
+ const colors = pixelColors;
578
+ const coloredFraction = coloredFractionPixel;
579
+ const resolvedMode = mode === 'auto' ? (coloredFraction >= COLOR_MODE_FRACTION ? 'color' : 'ascii') : mode;
580
+
581
+ // When the caller explicitly wants the color grid (mode="color"), auto must
582
+ // not fall to the achromatic gray palette — the color grid needs colors.
583
+ const effectivePaletteKey = requestedPalette === 'auto' && resolvedMode === 'color' && paletteKey === 'gray'
584
+ ? 'basic'
585
+ : paletteKey;
586
+
587
+ // Global shade diversity + texture mix: how many distinct hue+brightness
588
+ // buckets the image uses and how much fine detail it has. Many shades and
589
+ // high rough share = photo-like; few shades + mostly smooth = flat artwork.
590
+ const distinctShades = new Set();
591
+ let smoothCells = 0;
592
+ let mediumCells = 0;
593
+ let roughCells = 0;
594
+ for (const cell of cells) {
595
+ if (!cell) continue;
596
+ distinctShades.add(cell.shade);
597
+ if (cell.detail < 0.15) smoothCells += 1;
598
+ else if (cell.detail < 0.35) mediumCells += 1;
599
+ else roughCells += 1;
600
+ }
601
+ const texture = {
602
+ smooth: Math.round((smoothCells / Math.max(1, contentCells)) * 1000) / 10,
603
+ medium: Math.round((mediumCells / Math.max(1, contentCells)) * 1000) / 10,
604
+ rough: Math.round((roughCells / Math.max(1, contentCells)) * 1000) / 10
605
+ };
606
+
607
+ const result = {
608
+ gridWidth,
609
+ gridHeight,
610
+ palette: effectivePaletteKey,
611
+ colors,
612
+ hues: pixelHues,
613
+ mode: resolvedMode,
614
+ distinctShades: distinctShades.size,
615
+ texture,
616
+ regionWidth,
617
+ regionHeight,
618
+ regions: buildBlobs(cells, gridWidth, gridHeight, effectivePaletteKey, contentCells),
619
+ structure: structuralHints(cells, gridWidth, gridHeight, effectivePaletteKey),
620
+ ascii: buildAsciiGrid(cells, gridWidth, gridHeight)
621
+ };
622
+ if (resolvedMode === 'color') {
623
+ result.colorGrid = buildColorGrid(cells, gridWidth, gridHeight, effectivePaletteKey);
624
+ result.colorLegend = colorLegendFor(effectivePaletteKey);
625
+ }
626
+ return result;
627
+ }
628
+
629
+ /**
630
+ * Classify a color into a shade bucket: hue family + brightness level, or
631
+ * black/gray/white when nearly achromatic. Used to measure color diversity
632
+ * (flat artwork has 1-2 shades per region; photos have many).
633
+ * @param r - red 0..255.
634
+ * @param g - green 0..255.
635
+ * @param b - blue 0..255.
636
+ * @returns e.g. 'green-dark', 'blue-mid', 'gray', 'white'.
637
+ */
638
+ export function shadeFor(r, g, b) {
639
+ const max = Math.max(r, g, b);
640
+ const min = Math.min(r, g, b);
641
+ const luma = 0.299 * r + 0.587 * g + 0.114 * b;
642
+ if (max - min < 40) {
643
+ if (luma < 40) return 'black';
644
+ if (luma < 100) return 'darkgray';
645
+ if (luma < 170) return 'gray';
646
+ if (luma < 225) return 'lightgray';
647
+ return 'white';
648
+ }
649
+ let hue;
650
+ const d = max - min;
651
+ if (max === r) hue = ((g - b) / d) % 6;
652
+ else if (max === g) hue = (b - r) / d + 2;
653
+ else hue = (r - g) / d + 4;
654
+ hue = ((hue * 60) % 360 + 360) % 360;
655
+ let family;
656
+ if (hue < 15 || hue >= 345) family = 'red';
657
+ else if (hue < 45) family = 'orange';
658
+ else if (hue < 70) family = 'yellow';
659
+ else if (hue < 160) family = 'green';
660
+ else if (hue < 200) family = 'cyan';
661
+ else if (hue < 260) family = 'blue';
662
+ else if (hue < 310) family = 'purple';
663
+ else family = 'pink';
664
+ const level = luma < 85 ? 'dark' : luma < 170 ? 'mid' : 'light';
665
+ return `${family}-${level}`;
666
+ }
667
+
668
+ /** How many region rows are rendered before collapsing into "+N more". */
669
+ export const MAX_RENDERED_REGIONS = 8;
670
+
671
+ /**
672
+ * Connected-color-region analysis (blob detection) on the classified grid:
673
+ * 8-connected flood fill over same-color cells. The output is pure,
674
+ * deterministic image structure — the MODEL does the semantic interpretation
675
+ * ("a large round green blob with rough texture on a thin brown stem" -> tree).
676
+ * @param cells - the sparse cell array from `analyzeImage` (holes = transparent).
677
+ * @param gridWidth - grid width in cells.
678
+ * @param gridHeight - grid height in cells.
679
+ * @param paletteKey - the resolved palette.
680
+ * @param contentCells - opaque cell count (for percentages).
681
+ * @returns regions sorted by area: `{ color, code, cells, pct, rows, cols, w, h, aspect, density }`.
682
+ */
683
+ export function buildBlobs(cells, gridWidth, gridHeight, paletteKey, contentCells) {
684
+ const visited = new Uint8Array(cells.length);
685
+ const blobs = [];
686
+ const palette = PALETTES[paletteKey];
687
+ for (let start = 0; start < cells.length; start += 1) {
688
+ if (visited[start] || !cells[start]) continue;
689
+ const colorIndex = cellColorIndex(cells[start], paletteKey);
690
+ const stack = [start];
691
+ visited[start] = 1;
692
+ let count = 0;
693
+ let detailSum = 0;
694
+ let r0 = Infinity;
695
+ let r1 = -1;
696
+ let c0 = Infinity;
697
+ let c1 = -1;
698
+ const shades = new Map();
699
+ while (stack.length > 0) {
700
+ const index = stack.pop();
701
+ const row = Math.floor(index / gridWidth);
702
+ const col = index % gridWidth;
703
+ count += 1;
704
+ detailSum += cells[index].detail;
705
+ shades.set(cells[index].shade, (shades.get(cells[index].shade) ?? 0) + 1);
706
+ if (row < r0) r0 = row;
707
+ if (row > r1) r1 = row;
708
+ if (col < c0) c0 = col;
709
+ if (col > c1) c1 = col;
710
+ for (let dr = -1; dr <= 1; dr += 1) {
711
+ for (let dc = -1; dc <= 1; dc += 1) {
712
+ if (dr === 0 && dc === 0) continue;
713
+ const nr = row + dr;
714
+ const nc = col + dc;
715
+ if (nr < 0 || nr >= gridHeight || nc < 0 || nc >= gridWidth) continue;
716
+ const ni = nr * gridWidth + nc;
717
+ if (visited[ni] || !cells[ni]) continue;
718
+ if (cellColorIndex(cells[ni], paletteKey) === colorIndex) {
719
+ visited[ni] = 1;
720
+ stack.push(ni);
721
+ }
722
+ }
723
+ }
724
+ }
725
+ const shadeList = [...shades.entries()]
726
+ .map(([name, cellsCount]) => ({ name, pct: Math.round((cellsCount / count) * 1000) / 10 }))
727
+ .sort((a, b) => b.pct - a.pct);
728
+ const blobWidth = c1 - c0 + 1;
729
+ const blobHeight = r1 - r0 + 1;
730
+ blobs.push({
731
+ color: palette[colorIndex].name,
732
+ code: palette[colorIndex].code,
733
+ cells: count,
734
+ pct: Math.round((count / Math.max(1, contentCells)) * 1000) / 10,
735
+ rows: [r0, r1],
736
+ cols: [c0, c1],
737
+ w: blobWidth,
738
+ h: blobHeight,
739
+ aspect: Math.round((blobWidth / blobHeight) * 10) / 10,
740
+ density: detailSum / count < 0.15 ? 'smooth' : detailSum / count < 0.35 ? 'medium' : 'rough',
741
+ shades: shadeList
742
+ });
743
+ }
744
+ return blobs.sort((a, b) => b.cells - a.cells);
745
+ }
746
+
747
+ /** The color-grid color index for one cell under a palette (null for transparent). */
748
+ function cellColorIndex(cell, paletteKey) {
749
+ if (!cell) return null;
750
+ const avgClass = classify(cell.avgR, cell.avgG, cell.avgB, paletteKey);
751
+ if (paletteKey === 'gray' || cell.accentSat <= ACCENT_SATURATION) return avgClass.index;
752
+ const accentClass = classify(cell.accentR, cell.accentG, cell.accentB, paletteKey);
753
+ return accentClass.gray ? avgClass.index : accentClass.index;
754
+ }
755
+
756
+ function countColored(cells, paletteKey) {
757
+ let count = 0;
758
+ for (const cell of cells) {
759
+ const index = cellColorIndex(cell, paletteKey);
760
+ if (index === null) continue;
761
+ if (!GRAY_FAMILY.has(PALETTES[paletteKey][index].name)) count += 1;
762
+ }
763
+ return count;
764
+ }
765
+
766
+ function colorStats(cells, paletteKey, contentCells) {
767
+ const palette = PALETTES[paletteKey];
768
+ const counts = new Map();
769
+ for (const cell of cells) {
770
+ const index = cellColorIndex(cell, paletteKey);
771
+ if (index === null) continue;
772
+ counts.set(index, (counts.get(index) ?? 0) + 1);
773
+ }
774
+ return [...counts.entries()]
775
+ .map(([index, count]) => ({
776
+ name: palette[index].name,
777
+ hex: palette[index].hex,
778
+ count,
779
+ pct: Math.round((count / contentCells) * 1000) / 10
780
+ }))
781
+ .sort((a, b) => b.count - a.count);
782
+ }
783
+
784
+ /**
785
+ * TRUE pixel-level color statistics for a region: sample the actual pixels
786
+ * (every `sampleStep`-th in both axes) and classify each against the full
787
+ * 14-color palette. Unlike cell-average statistics, small colored regions
788
+ * keep their real area share, so a few percent of pink blossoms or red
789
+ * banners are reported instead of being diluted into gray.
790
+ *
791
+ * Also returns `hues`: hue-FAMILY shares (red/orange/yellow/green/cyan/blue/
792
+ * purple/pink/achromatic), which are insensitive to darkness — a dark olive
793
+ * hillside that the 14-color palette would fold into "black" is still
794
+ * reported as green-family 12%.
795
+ * @param rgba - RGBA `Buffer`.
796
+ * @param imgWidth - source width.
797
+ * @param imgHeight - source height.
798
+ * @param region - `[x0, y0, x1, y1]` fractions.
799
+ * @param sampleStep - sampling stride in pixels (auto-grown for huge regions).
800
+ * @returns `{ colors, hues }` where both are `[{ name, pct }]` arrays.
801
+ */
802
+ export function pixelColorStats(rgba, imgWidth, imgHeight, region, sampleStep) {
803
+ const [rx0, ry0, rx1, ry1] = normalizeRegion(region);
804
+ const x0 = Math.max(0, Math.floor(rx0 * imgWidth));
805
+ const x1 = Math.min(imgWidth, Math.ceil(rx1 * imgWidth));
806
+ const y0 = Math.max(0, Math.floor(ry0 * imgHeight));
807
+ const y1 = Math.min(imgHeight, Math.ceil(ry1 * imgHeight));
808
+ const regionW = x1 - x0;
809
+ const regionH = y1 - y0;
810
+ // keep ~2M samples max; stride 3 default
811
+ const step = sampleStep ?? Math.max(3, Math.ceil(Math.sqrt((regionW * regionH) / 2_000_000)));
812
+ const colorCounts = new Map();
813
+ const hueCounts = new Map();
814
+ let total = 0;
815
+ for (let y = y0; y < y1; y += step) {
816
+ for (let x = x0; x < x1; x += step) {
817
+ const p = (y * imgWidth + x) * 4;
818
+ if (rgba[p + 3] < 128) continue;
819
+ const r = rgba[p];
820
+ const g = rgba[p + 1];
821
+ const b = rgba[p + 2];
822
+ const index = classify(r, g, b, 'full').index;
823
+ colorCounts.set(index, (colorCounts.get(index) ?? 0) + 1);
824
+ const family = hueFamilyFor(r, g, b);
825
+ hueCounts.set(family, (hueCounts.get(family) ?? 0) + 1);
826
+ total += 1;
827
+ }
828
+ }
829
+ const toList = (counts) => [...counts.entries()]
830
+ .map(([key, count]) => ({ name: key, count, pct: total === 0 ? 0 : Math.round((count / total) * 1000) / 10 }))
831
+ .sort((a, b) => b.count - a.count);
832
+ return {
833
+ colors: toList(colorCounts).map(({ name, count, pct }) => ({ name: PALETTE[name].name, hex: PALETTE[name].hex, count, pct })),
834
+ hues: toList(hueCounts).map(({ name, count, pct }) => ({ name, count, pct }))
835
+ };
836
+ }
837
+
838
+ /** Hue family names that are considered chromatic (everything else is achromatic). */
839
+ const COLOR_FAMILIES = new Set(['red', 'orange', 'yellow', 'green', 'cyan', 'blue', 'purple', 'pink']);
840
+
841
+ /**
842
+ * Hue family of a color BY HUE ONLY (no saturation gate): dark/desaturated
843
+ * colors like an olive hillside or misty pink blossoms keep their family, so
844
+ * hue statistics survive scenes the 14-color palette would flatten to gray.
845
+ * Truly colorless pixels (channel spread < 8) return 'achromatic'.
846
+ * @param r - red 0..255.
847
+ * @param g - green 0..255.
848
+ * @param b - blue 0..255.
849
+ * @returns 'red' | 'orange' | 'yellow' | 'green' | 'cyan' | 'blue' | 'purple' | 'pink' | 'achromatic'.
850
+ */
851
+ export function hueFamilyFor(r, g, b) {
852
+ const max = Math.max(r, g, b);
853
+ const min = Math.min(r, g, b);
854
+ const d = max - min;
855
+ if (d < 8) return 'achromatic';
856
+ let hue;
857
+ if (max === r) hue = ((g - b) / d) % 6;
858
+ else if (max === g) hue = (b - r) / d + 2;
859
+ else hue = (r - g) / d + 4;
860
+ hue = ((hue * 60) % 360 + 360) % 360;
861
+ if (hue < 15 || hue >= 345) return 'red';
862
+ if (hue < 45) return 'orange';
863
+ if (hue < 70) return 'yellow';
864
+ if (hue < 160) return 'green';
865
+ if (hue < 200) return 'cyan';
866
+ if (hue < 260) return 'blue';
867
+ if (hue < 310) return 'purple';
868
+ return 'pink';
869
+ }
870
+
871
+ /** Rec.601 luma for an RGB triple. */
872
+ export function luminance(r, g, b) {
873
+ return Math.round(0.299 * r + 0.587 * g + 0.114 * b);
874
+ }
875
+
876
+ function rampChar(luma) {
877
+ return RAMP[Math.min(RAMP.length - 1, Math.floor((luma / 255) * RAMP.length))];
878
+ }
879
+
880
+ function buildAsciiGrid(cells, gridWidth, gridHeight) {
881
+ const rows = [];
882
+ for (let cy = 0; cy < gridHeight; cy += 1) {
883
+ let row = '';
884
+ for (let cx = 0; cx < gridWidth; cx += 1) {
885
+ const cell = cells[cy * gridWidth + cx];
886
+ row += !cell ? ' ' : rampChar(cell.luminance);
887
+ }
888
+ rows.push(row);
889
+ }
890
+ return rows.join('\n');
891
+ }
892
+
893
+ function buildColorGrid(cells, gridWidth, gridHeight, paletteKey) {
894
+ const palette = PALETTES[paletteKey];
895
+ const rows = [];
896
+ for (let cy = 0; cy < gridHeight; cy += 1) {
897
+ let row = '';
898
+ for (let cx = 0; cx < gridWidth; cx += 1) {
899
+ const index = cellColorIndex(cells[cy * gridWidth + cx], paletteKey);
900
+ row += index === null ? ' ' : palette[index].code;
901
+ }
902
+ rows.push(row);
903
+ }
904
+ return rows.join('\n');
905
+ }
906
+
907
+ /**
908
+ * Render an analysis result as model-facing text.
909
+ * @param value - the analysis result (plus `path`/`width`/`height`/`region`).
910
+ * @returns the multi-line text fed back to the model.
911
+ */
912
+ export function renderImageScan(value) {
913
+ const lines = [];
914
+ const regionW = value.regionWidth ?? value.width;
915
+ const regionH = value.regionHeight ?? value.height;
916
+ const cellW = Math.round((regionW / value.gridWidth) * 10) / 10;
917
+ const cellH = Math.round((regionH / value.gridHeight) * 10) / 10;
918
+ lines.push(`image: ${value.path} (${value.width}x${value.height} -> ${value.gridWidth}x${value.gridHeight} cells, ~${cellW}x${cellH}px per cell, region=${value.region}, palette=${value.palette}, mode=${value.mode})`);
919
+ lines.push(`grid coords: rows 0..${value.gridHeight - 1}, cols 0..${value.gridWidth - 1}; zoom with focus: [row0,col0,row1,col1] (keep size unchanged, see below) or region: [x0,y0,x1,y1] (0..1 fractions)`);
920
+ if (value.distinctShades !== undefined) {
921
+ lines.push(`shade diversity: ${value.distinctShades} distinct shades | texture: smooth ${value.texture?.smooth ?? 0}%, medium ${value.texture?.medium ?? 0}%, rough ${value.texture?.rough ?? 0}% (many shades + rough = photo-like; few shades + smooth = flat artwork)`);
922
+ }
923
+ if (value.structure !== undefined && value.structure.length > 0) {
924
+ lines.push(`structure: ${value.structure.join('; ')}`);
925
+ }
926
+ if (value.regions !== undefined && value.regions.length > 0) {
927
+ lines.push('regions (connected color blobs, by area; rows/cols in grid coords, w/h aspect, texture density, shade mix):');
928
+ for (const region of value.regions.slice(0, MAX_RENDERED_REGIONS)) {
929
+ const shadeMix = region.shades.slice(0, 3).map((s) => `${s.name} ${s.pct}%`).join(', ');
930
+ const shadeCount = region.shades.length;
931
+ lines.push(`- ${region.color} ${region.pct}% @ rows ${region.rows[0]}..${region.rows[1]}, cols ${region.cols[0]}..${region.cols[1]}, ${region.w}x${region.h}, w/h=${region.aspect}, ${region.density}, ${shadeCount} shade(s): ${shadeMix}`);
932
+ }
933
+ if (value.regions.length > MAX_RENDERED_REGIONS) {
934
+ lines.push(`(+ ${value.regions.length - MAX_RENDERED_REGIONS} smaller blobs)`);
935
+ }
936
+ }
937
+ if (value.colors.length > 0) {
938
+ lines.push(`colors by area: ${value.colors.map((c) => `${c.name} ${c.pct}% (${c.hex})`).join(', ')}`);
939
+ } else {
940
+ lines.push('colors by area: (fully transparent)');
941
+ }
942
+ if (value.hues !== undefined && value.hues.length > 0) {
943
+ const coloredHues = value.hues.filter((h) => h.name !== 'achromatic');
944
+ const achromatic = value.hues.find((h) => h.name === 'achromatic');
945
+ if (coloredHues.length > 0) {
946
+ lines.push(`hue families: ${coloredHues.map((h) => `${h.name} ${h.pct}%`).join(', ')}${achromatic ? `, achromatic ${achromatic.pct}%` : ''}`);
947
+ }
948
+ }
949
+ lines.push('');
950
+ lines.push("luminance grid (rows top->bottom, cols left->right; ' '=transparent; '.' darkest -> '@' brightest):");
951
+ lines.push(value.ascii);
952
+ if (value.colorGrid !== undefined) {
953
+ lines.push('');
954
+ lines.push(`color grid (one letter per cell; legend: ${value.colorLegend}):`);
955
+ lines.push(value.colorGrid);
956
+ }
957
+ return lines.join('\n');
958
+ }
959
+
960
+ // ---------------------------------------------------------------------------
961
+ // crop + PNG encode + OCR (Windows.Media.Ocr via PowerShell)
962
+ // ---------------------------------------------------------------------------
963
+
964
+ /**
965
+ * Crop an RGBA buffer to a fraction region.
966
+ * @param rgba - RGBA `Buffer`.
967
+ * @param imgWidth - source width.
968
+ * @param imgHeight - source height.
969
+ * @param region - `[x0, y0, x1, y1]` fractions, or undefined for the full image.
970
+ * @returns `{ data, width, height }` with the cropped RGBA.
971
+ */
972
+ export function cropRgba(rgba, imgWidth, imgHeight, region) {
973
+ const [rx0, ry0, rx1, ry1] = normalizeRegion(region);
974
+ const x0 = Math.max(0, Math.floor(rx0 * imgWidth));
975
+ const x1 = Math.min(imgWidth, Math.ceil(rx1 * imgWidth));
976
+ const y0 = Math.max(0, Math.floor(ry0 * imgHeight));
977
+ const y1 = Math.min(imgHeight, Math.ceil(ry1 * imgHeight));
978
+ const w = Math.max(1, x1 - x0);
979
+ const h = Math.max(1, y1 - y0);
980
+ const data = Buffer.alloc(w * h * 4);
981
+ for (let y = 0; y < h; y += 1) {
982
+ rgba.copy(data, y * w * 4, (y0 + y) * imgWidth * 4, (y0 + y) * imgWidth * 4 + w * 4);
983
+ }
984
+ return { data, width: w, height: h };
985
+ }
986
+
987
+ /** Encode an RGBA buffer as PNG bytes (lossless, for feeding OCR). */
988
+ export function encodePng(rgba, width, height) {
989
+ const png = new PNG({ width, height });
990
+ rgba.copy(png.data);
991
+ return PNG.sync.write(png);
992
+ }
993
+
994
+ /**
995
+ * Build the PowerShell command that runs Windows.Media.Ocr on a PNG file and
996
+ * emits a UTF-8 JSON payload as base64 on stdout.
997
+ * @param pngPath - absolute path to the PNG to recognize.
998
+ * @param language - optional BCP-47 tag (e.g. 'zh-Hans'); defaults to the user's languages.
999
+ * @returns the PowerShell command string (joined statements).
1000
+ */
1001
+ export function buildOcrCommand(pngPath, language) {
1002
+ const esc = (s) => String(s).replaceAll("'", "''");
1003
+ const engineLine =
1004
+ language === undefined
1005
+ ? '$engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromUserProfileLanguages()'
1006
+ : `$engine = [Windows.Media.Ocr.OcrEngine]::TryCreateFromLanguage([Windows.Globalization.Language]::new('${esc(language)}'))`;
1007
+ return [
1008
+ 'Add-Type -AssemblyName System.Runtime.WindowsRuntime',
1009
+ '$null = [Windows.Storage.StorageFile, Windows.Storage, ContentType = WindowsRuntime]',
1010
+ '$null = [Windows.Media.Ocr.OcrEngine, Windows.Foundation, ContentType = WindowsRuntime]',
1011
+ '$null = [Windows.Graphics.Imaging.BitmapDecoder, Windows.Graphics, ContentType = WindowsRuntime]',
1012
+ "$asTaskGeneric = ([System.WindowsRuntimeSystemExtensions].GetMethods() | Where-Object { $_.Name -eq 'AsTask' -and $_.GetParameters().Count -eq 1 -and $_.GetParameters()[0].ParameterType.Name -eq 'IAsyncOperation`1' })[0]",
1013
+ 'Function Await($WinRtTask, $ResultType) {',
1014
+ ' $asTask = $asTaskGeneric.MakeGenericMethod($ResultType)',
1015
+ ' $netTask = $asTask.Invoke($null, @($WinRtTask))',
1016
+ ' $netTask.Wait(-1) | Out-Null',
1017
+ ' $netTask.Result',
1018
+ '}',
1019
+ `$path = '${esc(pngPath)}'`,
1020
+ '$file = Await ([Windows.Storage.StorageFile]::GetFileFromPathAsync($path)) ([Windows.Storage.StorageFile])',
1021
+ '$stream = Await ($file.OpenAsync([Windows.Storage.FileAccessMode]::Read)) ([Windows.Storage.Streams.IRandomAccessStream])',
1022
+ '$decoder = Await ([Windows.Graphics.Imaging.BitmapDecoder]::CreateAsync($stream)) ([Windows.Graphics.Imaging.BitmapDecoder])',
1023
+ '$bitmap = Await ($decoder.GetSoftwareBitmapAsync()) ([Windows.Graphics.Imaging.SoftwareBitmap])',
1024
+ engineLine,
1025
+ "if ($engine -eq $null) { Write-Error 'no OCR engine for the requested language'; exit 2 }",
1026
+ '$result = Await ($engine.RecognizeAsync($bitmap)) ([Windows.Media.Ocr.OcrResult])',
1027
+ '$lines = @()',
1028
+ 'foreach ($line in $result.Lines) {',
1029
+ ' $words = @()',
1030
+ ' foreach ($w in $line.Words) {',
1031
+ ' $words += [PSCustomObject]@{ Text = $w.Text; X = [int]$w.BoundingRect.X; Y = [int]$w.BoundingRect.Y; W = [int]$w.BoundingRect.Width; H = [int]$w.BoundingRect.Height }',
1032
+ ' }',
1033
+ ' $lines += [PSCustomObject]@{ Text = $line.Text; X = [int]$line.BoundingRect.X; Y = [int]$line.BoundingRect.Y; W = [int]$line.BoundingRect.Width; H = [int]$line.BoundingRect.Height; Words = $words }',
1034
+ '}',
1035
+ '$json = [PSCustomObject]@{ Width = $decoder.PixelWidth; Height = $decoder.PixelHeight; Lines = $lines } | ConvertTo-Json -Depth 5',
1036
+ '[Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($json))'
1037
+ ].join('; ');
1038
+ }
1039
+
1040
+ /**
1041
+ * Run Windows OCR on a PNG file and parse the result.
1042
+ * @param pngPath - absolute path to the PNG.
1043
+ * @param options - `{ language }`.
1044
+ * @returns `{ width, height, lines }` where each line is
1045
+ * `{ text, x, y, width, height }` (pixel box aggregated from its words).
1046
+ */
1047
+ export function runOcr(pngPath, { language } = {}) {
1048
+ const command = buildOcrCommand(pngPath, language);
1049
+ return new Promise((resolve, reject) => {
1050
+ const child = spawn('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', command], { windowsHide: true });
1051
+ let stdout = '';
1052
+ let stderr = '';
1053
+ const timer = setTimeout(() => {
1054
+ child.kill();
1055
+ reject(new Error('image_ocr: OCR timed out after 30s'));
1056
+ }, 30_000);
1057
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
1058
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
1059
+ child.on('error', (error) => {
1060
+ clearTimeout(timer);
1061
+ reject(new Error(`image_ocr: cannot start OCR engine: ${error.message}`));
1062
+ });
1063
+ child.on('close', (code) => {
1064
+ clearTimeout(timer);
1065
+ if (code !== 0) {
1066
+ reject(new Error(`image_ocr: OCR engine failed (exit ${code}): ${stderr.trim().slice(0, 300)}`));
1067
+ return;
1068
+ }
1069
+ try {
1070
+ const json = Buffer.from(stdout.trim(), 'base64').toString('utf8');
1071
+ const parsed = JSON.parse(json);
1072
+ const lines = (parsed.Lines ?? []).map((line) => {
1073
+ let minX = Infinity;
1074
+ let minY = Infinity;
1075
+ let maxRight = -Infinity;
1076
+ let maxBottom = -Infinity;
1077
+ for (const word of line.Words ?? []) {
1078
+ minX = Math.min(minX, word.X);
1079
+ minY = Math.min(minY, word.Y);
1080
+ maxRight = Math.max(maxRight, word.X + word.W);
1081
+ maxBottom = Math.max(maxBottom, word.Y + word.H);
1082
+ }
1083
+ return {
1084
+ text: line.Text,
1085
+ x: minX === Infinity ? 0 : minX,
1086
+ y: minY === Infinity ? 0 : minY,
1087
+ width: maxRight === -Infinity ? 0 : maxRight - minX,
1088
+ height: maxBottom === -Infinity ? 0 : maxBottom - minY
1089
+ };
1090
+ });
1091
+ resolve({ width: parsed.Width, height: parsed.Height, lines });
1092
+ } catch (error) {
1093
+ reject(new Error(`image_ocr: cannot parse OCR result: ${error.message}`));
1094
+ }
1095
+ });
1096
+ });
1097
+ }
1098
+
1099
+ /**
1100
+ * Full OCR pipeline: decode -> optional region crop -> PNG temp file ->
1101
+ * OCR engine -> cleanup. Returns recognized text lines with pixel boxes.
1102
+ * @param buffer - raw image bytes.
1103
+ * @param ext - lowercase extension ('.png' etc.).
1104
+ * @param options - `{ region, language, engine }`. engine: 'windows'
1105
+ * (Windows.Media.Ocr, default) or 'paddle' (PaddleOCR via the local
1106
+ * paddle_venv — far better at glowing/curved/game-rendered text).
1107
+ * @returns `{ width, height, lines }`.
1108
+ */
1109
+ export async function ocrImage(buffer, ext, { region, language, engine = 'windows' } = {}) {
1110
+ const image = decodeImage(buffer, ext);
1111
+ let work = image;
1112
+ if (region !== undefined) {
1113
+ const cropped = cropRgba(image.data, image.width, image.height, normalizeRegion(region));
1114
+ work = cropped;
1115
+ }
1116
+ const pngBytes = encodePng(work.data, work.width, work.height);
1117
+ // WSL compat: powershell.exe (Windows OCR) cannot reach a WSL /tmp path and
1118
+ // GetFileFromPathAsync rejects forward slashes — write the temp PNG under
1119
+ // /mnt/c/Windows/Temp and hand Windows a backslash path.
1120
+ const isWsl = process.platform === 'linux' && /microsoft/i.test(release());
1121
+ const tmpBase = isWsl ? '/mnt/c/Windows/Temp' : tmpdir();
1122
+ const tmpName = `picturereader-ocr-${randomBytes(6).toString('hex')}.png`;
1123
+ const tmpPath = join(tmpBase, tmpName);
1124
+ const winPath = isWsl ? `C:\\Windows\\Temp\\${tmpName}` : tmpPath;
1125
+ await writeFile(tmpPath, pngBytes);
1126
+ try {
1127
+ if (engine === 'paddle') {
1128
+ const result = await runPaddleOcr(tmpPath);
1129
+ return { width: work.width, height: work.height, lines: result.lines };
1130
+ }
1131
+ return await runOcr(winPath, { language });
1132
+ } finally {
1133
+ await rm(tmpPath, { force: true }).catch(() => {});
1134
+ }
1135
+ }
1136
+
1137
+ /** Absolute path to the local PaddleOCR environment (paddle_venv); overridable via DSH_PADDLE_PYTHON. */
1138
+ export function paddlePython() {
1139
+ return process.env.DSH_PADDLE_PYTHON ?? 'C:/Users/Administrator/paddle_venv/Scripts/python.exe';
1140
+ }
1141
+ /** PaddleX model cache (the default ~/.paddlex is broken on this machine); overridable via DSH_PADDLE_CACHE. */
1142
+ export function paddleCacheHome() {
1143
+ return process.env.DSH_PADDLE_CACHE ?? 'D:/coding/picturereader/.paddlex-cache';
1144
+ }
1145
+
1146
+ /**
1147
+ * PaddleOCR input cap: images whose longer side exceeds this are downscaled
1148
+ * before recognition. Paddle's predict time scales roughly linearly with
1149
+ * pixel count (a 2560x1440 frame takes ~15-18s vs ~3.5s for 900x220), which
1150
+ * can exceed the client's tool-call timeout on full screenshots. Downscaling
1151
+ * to this cap keeps calls within limits while preserving text recognition
1152
+ * (verified: 12/12 lines still read at 1600px).
1153
+ */
1154
+ export const PADDLE_MAX_LONG_SIDE = 1600;
1155
+
1156
+ /**
1157
+ * Box-average downscale so the longer side fits `maxLongSide`. Returns the
1158
+ * same buffer untouched when no downscale is needed.
1159
+ * @param rgba - RGBA `Buffer`.
1160
+ * @param width - source width.
1161
+ * @param height - source height.
1162
+ * @param maxLongSide - cap on the longer side in pixels.
1163
+ * @returns `{ data, width, height, downscaled }`.
1164
+ */
1165
+ export function downscaleRgba(rgba, width, height, maxLongSide = PADDLE_MAX_LONG_SIDE) {
1166
+ const scale = Math.min(1, maxLongSide / Math.max(width, height));
1167
+ if (scale >= 1) return { data: rgba, width, height, downscaled: false };
1168
+ const nw = Math.max(1, Math.round(width * scale));
1169
+ const nh = Math.max(1, Math.round(height * scale));
1170
+ const out = Buffer.alloc(nw * nh * 4);
1171
+ for (let cy = 0; cy < nh; cy += 1) {
1172
+ const y0 = Math.floor((cy * height) / nh);
1173
+ const y1 = Math.max(y0 + 1, Math.floor(((cy + 1) * height) / nh));
1174
+ for (let cx = 0; cx < nw; cx += 1) {
1175
+ const x0 = Math.floor((cx * width) / nw);
1176
+ const x1 = Math.max(x0 + 1, Math.floor(((cx + 1) * width) / nw));
1177
+ let sumR = 0;
1178
+ let sumG = 0;
1179
+ let sumB = 0;
1180
+ let sumA = 0;
1181
+ const n = (y1 - y0) * (x1 - x0);
1182
+ for (let y = y0; y < y1; y += 1) {
1183
+ const row = y * width * 4;
1184
+ for (let x = x0; x < x1; x += 1) {
1185
+ const p = row + x * 4;
1186
+ sumR += rgba[p];
1187
+ sumG += rgba[p + 1];
1188
+ sumB += rgba[p + 2];
1189
+ sumA += rgba[p + 3];
1190
+ }
1191
+ }
1192
+ const o = (cy * nw + cx) * 4;
1193
+ out[o] = Math.round(sumR / n);
1194
+ out[o + 1] = Math.round(sumG / n);
1195
+ out[o + 2] = Math.round(sumB / n);
1196
+ out[o + 3] = Math.round(sumA / n);
1197
+ }
1198
+ }
1199
+ return { data: out, width: nw, height: nh, downscaled: true };
1200
+ }
1201
+
1202
+ /**
1203
+ * Whether the optional PaddleOCR environment is available. PaddleOCR is an
1204
+ * OPTIONAL engine: when it is missing, callers must degrade gracefully to the
1205
+ * Windows engine instead of failing.
1206
+ * @param python - python executable to probe (defaults to the configured path).
1207
+ * @returns true when the interpreter exists.
1208
+ */
1209
+ export async function paddleAvailable(python = paddlePython()) {
1210
+ try {
1211
+ await stat(python);
1212
+ return true;
1213
+ } catch {
1214
+ return false;
1215
+ }
1216
+ }
1217
+
1218
+ /**
1219
+ * Run PaddleOCR on a PNG file via the local paddle_venv. Strongly better than
1220
+ * Windows OCR for glowing, curved, or game-rendered text (verified on the
1221
+ * ENDFIELD "勇于探索叩问苍穹" banner). Model load takes ~2s per call.
1222
+ * @param pngPath - absolute path to the PNG.
1223
+ * @returns `{ lines: [{ text, score, x, y, width, height }] }` (box aggregated).
1224
+ */
1225
+ export function runPaddleOcr(pngPath) {
1226
+ const escaped = String(pngPath).replaceAll("'", "''");
1227
+ const script = [
1228
+ 'import base64, json, sys',
1229
+ 'from paddleocr import PaddleOCR',
1230
+ "ocr = PaddleOCR(lang='ch', use_doc_orientation_classify=False, use_doc_unwarping=False, use_textline_orientation=False, enable_mkldnn=False)",
1231
+ `result = ocr.predict(r'${escaped}')`,
1232
+ 'lines = []',
1233
+ 'for res in result:',
1234
+ " texts = res.get('rec_texts') or []",
1235
+ " scores = res.get('rec_scores') or []",
1236
+ " polys = res.get('rec_polys') or []",
1237
+ ' for i, t in enumerate(texts):',
1238
+ ' if i < len(polys):',
1239
+ ' pts = [[int(float(v)) for v in pt] for pt in polys[i]]',
1240
+ ' xs = [pt[0] for pt in pts]; ys = [pt[1] for pt in pts]',
1241
+ ' box = {"x": min(xs), "y": min(ys), "w": max(xs)-min(xs), "h": max(ys)-min(ys)}',
1242
+ ' else:',
1243
+ ' box = {"x": 0, "y": 0, "w": 0, "h": 0}',
1244
+ " score = round(float(scores[i]), 3) if i < len(scores) else 0.0",
1245
+ " lines.append({'text': t, 'score': score, **box})",
1246
+ "out = json.dumps({'lines': lines}, ensure_ascii=False)",
1247
+ "sys.stdout.write(base64.b64encode(out.encode('utf-8')).decode('ascii'))"
1248
+ ].join('\n');
1249
+ return new Promise((resolve, reject) => {
1250
+ const child = spawn(paddlePython(), ['-c', script], {
1251
+ env: { ...process.env, PADDLE_PDX_CACHE_HOME: paddleCacheHome(), PYTHONIOENCODING: 'utf-8' },
1252
+ windowsHide: true
1253
+ });
1254
+ let stdout = '';
1255
+ let stderr = '';
1256
+ const timer = setTimeout(() => {
1257
+ child.kill();
1258
+ reject(new Error('image_ocr: PaddleOCR timed out after 60s'));
1259
+ }, 60_000);
1260
+ child.stdout.on('data', (chunk) => { stdout += chunk; });
1261
+ child.stderr.on('data', (chunk) => { stderr += chunk; });
1262
+ child.on('error', (error) => {
1263
+ clearTimeout(timer);
1264
+ reject(new Error(`image_ocr: cannot start PaddleOCR: ${error.message}`));
1265
+ });
1266
+ child.on('close', (code) => {
1267
+ clearTimeout(timer);
1268
+ if (code !== 0) {
1269
+ const tail = stderr.trim().split('\n').filter((l) => l.includes('Error') || l.includes('error') || l.includes('Traceback')).slice(-3).join(' | ') || stderr.trim().slice(-200);
1270
+ reject(new Error(`image_ocr: PaddleOCR failed (exit ${code}): ${tail}`));
1271
+ return;
1272
+ }
1273
+ try {
1274
+ const json = Buffer.from(stdout.trim(), 'base64').toString('utf8');
1275
+ const parsed = JSON.parse(json);
1276
+ resolve({ lines: parsed.lines ?? [] });
1277
+ } catch (error) {
1278
+ reject(new Error(`image_ocr: cannot parse PaddleOCR result: ${error.message}`));
1279
+ }
1280
+ });
1281
+ });
1282
+ }
1283
+
1284
+ /**
1285
+ * Render OCR results as model-facing text.
1286
+ * @param value - `{ path, width, height, region, lines }`.
1287
+ * @returns the multi-line text.
1288
+ */
1289
+ export function renderOcr(value) {
1290
+ const lines = [];
1291
+ lines.push(`ocr: ${value.path} (${value.width}x${value.height}, region=${value.region}, engine=${value.engine ?? 'windows'})`);
1292
+ if (value.note !== undefined) {
1293
+ lines.push(`note: ${value.note}`);
1294
+ }
1295
+ if (value.lines.length === 0) {
1296
+ lines.push('no text recognized in this region');
1297
+ } else {
1298
+ lines.push(`recognized ${value.lines.length} line(s):`);
1299
+ value.lines.forEach((line, index) => {
1300
+ const score = line.score !== undefined ? ` score=${line.score}` : '';
1301
+ lines.push(`${index + 1}. "${line.text}" @ (${line.x},${line.y}) ${line.width}x${line.height}${score}`);
1302
+ });
1303
+ }
1304
+ return lines.join('\n');
1305
+ }
1306
+
1307
+ // ---------------------------------------------------------------------------
1308
+ // pixel-level texture sampling (material hints for the model)
1309
+ // ---------------------------------------------------------------------------
1310
+
1311
+ /**
1312
+ * Sample a small region as an NxN grid of EXACT pixels (one sample per cell,
1313
+ * taken at the cell center — not an average). Together with the contrast
1314
+ * statistic this lets the model judge local material: smooth gradients (skin,
1315
+ * sky), high-contrast stripes (metal, wood grain), periodic repeats (fabric),
1316
+ * high-frequency noise (foliage).
1317
+ * @param rgba - RGBA `Buffer`.
1318
+ * @param imgWidth - source width.
1319
+ * @param imgHeight - source height.
1320
+ * @param region - `[x0, y0, x1, y1]` fractions; must cover at least `size` px
1321
+ * in each direction so samples are distinct.
1322
+ * @param size - grid side length (2..16, default 8).
1323
+ * @returns `{ width, height, points, contrast, distinct, stepX, stepY }`.
1324
+ */
1325
+ export function samplePixels(rgba, imgWidth, imgHeight, region, size = 8) {
1326
+ const grid = Math.min(16, Math.max(2, Math.round(size)));
1327
+ const [rx0, ry0, rx1, ry1] = normalizeRegion(region);
1328
+ const x0 = Math.floor(rx0 * imgWidth);
1329
+ const x1 = Math.ceil(rx1 * imgWidth);
1330
+ const y0 = Math.floor(ry0 * imgHeight);
1331
+ const y1 = Math.ceil(ry1 * imgHeight);
1332
+ const regionW = x1 - x0;
1333
+ const regionH = y1 - y0;
1334
+ if (regionW < grid || regionH < grid) {
1335
+ throw new Error(
1336
+ `image_sample: the region is only ${regionW}x${regionH}px — too small for a ${grid}x${grid} sample; enlarge the region or use a smaller size`
1337
+ );
1338
+ }
1339
+ const points = [];
1340
+ const colors = new Set();
1341
+ for (let gy = 0; gy < grid; gy += 1) {
1342
+ const row = [];
1343
+ const py = y0 + Math.floor(((gy + 0.5) * regionH) / grid);
1344
+ for (let gx = 0; gx < grid; gx += 1) {
1345
+ const px = x0 + Math.floor(((gx + 0.5) * regionW) / grid);
1346
+ const p = (Math.min(imgHeight - 1, py) * imgWidth + Math.min(imgWidth - 1, px)) * 4;
1347
+ const color = [rgba[p], rgba[p + 1], rgba[p + 2]];
1348
+ row.push(color);
1349
+ colors.add((color[0] << 16) | (color[1] << 8) | color[2]);
1350
+ }
1351
+ points.push(row);
1352
+ }
1353
+ // adjacent-sample contrast: mean RGB distance between horizontal neighbours
1354
+ let diffSum = 0;
1355
+ let diffCount = 0;
1356
+ for (let gy = 0; gy < grid; gy += 1) {
1357
+ for (let gx = 0; gx < grid - 1; gx += 1) {
1358
+ const a = points[gy][gx];
1359
+ const b = points[gy][gx + 1];
1360
+ diffSum += Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]);
1361
+ diffCount += 1;
1362
+ }
1363
+ }
1364
+ return {
1365
+ width: regionW,
1366
+ height: regionH,
1367
+ stepX: regionW / grid,
1368
+ stepY: regionH / grid,
1369
+ points,
1370
+ contrast: diffCount === 0 ? 0 : Math.round((diffSum / diffCount / 3 / 255) * 1000) / 1000,
1371
+ distinct: colors.size
1372
+ };
1373
+ }
1374
+
1375
+ /**
1376
+ * Render a texture sample as model-facing text: an NxN grid of exact RGB
1377
+ * triples plus interpretation hints.
1378
+ * @param value - `{ path, width, height, region, points, contrast, distinct, stepX, stepY }`.
1379
+ * @returns the multi-line text.
1380
+ */
1381
+ export function renderSample(value) {
1382
+ const lines = [];
1383
+ const grid = value.points.length;
1384
+ lines.push(`texture sample: ${value.path} region ${value.region} (${value.width}x${value.height} px, ${grid}x${grid} exact pixels, ~${Math.round(value.stepX * 10) / 10}px apart)`);
1385
+ for (const row of value.points) {
1386
+ lines.push(row.map(([r, g, b]) => `(${r},${g},${b})`).join(' '));
1387
+ }
1388
+ const contrastLabel = value.contrast < 0.04 ? 'smooth (gradient, uniform)' : value.contrast < 0.12 ? 'subtle texture' : 'high contrast (rough/material)';
1389
+ lines.push(`stats: local contrast ${value.contrast} -> ${contrastLabel}; ${value.distinct} distinct colors`);
1390
+ return lines.join('\n');
1391
+ }
1392
+
1393
+ // ---------------------------------------------------------------------------
1394
+ // structural hints: stripes, symmetry (shape evidence for the model)
1395
+ // ---------------------------------------------------------------------------
1396
+
1397
+ /** The dominant color index of a row or column (null if fully transparent). */
1398
+ function dominantIndex(seq) {
1399
+ const counts = new Map();
1400
+ for (const index of seq) {
1401
+ if (index === null) continue;
1402
+ counts.set(index, (counts.get(index) ?? 0) + 1);
1403
+ }
1404
+ if (counts.size === 0) return null;
1405
+ return [...counts.entries()].sort((a, b) => b[1] - a[1])[0][0];
1406
+ }
1407
+
1408
+ /** Longest run of adjacent elements that are all pairwise different (and non-null). */
1409
+ function longestAlternating(seq) {
1410
+ let bestStart = 0;
1411
+ let bestLength = 0;
1412
+ let start = 0;
1413
+ for (let i = 1; i <= seq.length; i += 1) {
1414
+ const alternates = i < seq.length && seq[i] !== null && seq[i - 1] !== null && seq[i] !== seq[i - 1];
1415
+ if (alternates) continue;
1416
+ const length = i - start;
1417
+ if (length > bestLength) {
1418
+ bestLength = length;
1419
+ bestStart = start;
1420
+ }
1421
+ start = i;
1422
+ }
1423
+ return { start: bestStart, length: bestLength };
1424
+ }
1425
+
1426
+ /**
1427
+ * Detect parallel stripes (alternating color bands) in a row/column of
1428
+ * dominant colors. Panels, grilles and blades produce 2-4 colors alternating
1429
+ * across several adjacent columns/rows (e.g. K/Y/K/Y for solar panels).
1430
+ * @param seq - dominant color index per column (or row), null for empty.
1431
+ * @returns `{ start, length, colors }` or null.
1432
+ */
1433
+ function detectStripe(seq) {
1434
+ const { start, length } = longestAlternating(seq);
1435
+ if (length < 4) return null;
1436
+ const colors = new Set(seq.slice(start, start + length).filter((v) => v !== null));
1437
+ if (colors.size < 2 || colors.size > 4) return null;
1438
+ return { start, length, colors: [...colors] };
1439
+ }
1440
+
1441
+ /**
1442
+ * Compute structural shape evidence from the classified grid: parallel
1443
+ * stripes (vertical/horizontal alternating bands -> panels/grilles/blades)
1444
+ * and left-right symmetry (manufactured/constructed objects).
1445
+ * @param cells - the sparse cell array.
1446
+ * @param gridWidth - grid width.
1447
+ * @param gridHeight - grid height.
1448
+ * @param paletteKey - the resolved palette.
1449
+ * @returns an array of human-readable hints (empty when none found).
1450
+ */
1451
+ export function structuralHints(cells, gridWidth, gridHeight, paletteKey) {
1452
+ const hints = [];
1453
+ // per-column dominant colors
1454
+ const colMajors = [];
1455
+ for (let c = 0; c < gridWidth; c += 1) {
1456
+ const column = [];
1457
+ for (let r = 0; r < gridHeight; r += 1) column.push(cellColorIndex(cells[r * gridWidth + c], paletteKey));
1458
+ colMajors.push(dominantIndex(column));
1459
+ }
1460
+ const vertical = detectStripe(colMajors);
1461
+ if (vertical !== null) {
1462
+ hints.push(`${vertical.length} vertical stripes (${vertical.colors.length} alternating colors) at cols ${vertical.start}..${vertical.start + vertical.length - 1}`);
1463
+ }
1464
+ // per-row dominant colors
1465
+ const rowMajors = [];
1466
+ for (let r = 0; r < gridHeight; r += 1) {
1467
+ const row = [];
1468
+ for (let c = 0; c < gridWidth; c += 1) row.push(cellColorIndex(cells[r * gridWidth + c], paletteKey));
1469
+ rowMajors.push(dominantIndex(row));
1470
+ }
1471
+ const horizontal = detectStripe(rowMajors);
1472
+ if (horizontal !== null) {
1473
+ hints.push(`${horizontal.length} horizontal stripes (${horizontal.colors.length} alternating colors) at rows ${horizontal.start}..${horizontal.start + horizontal.length - 1}`);
1474
+ }
1475
+ // left-right symmetry (always reported as a number; >= 0.5 flagged as notable)
1476
+ let same = 0;
1477
+ let total = 0;
1478
+ for (let r = 0; r < gridHeight; r += 1) {
1479
+ for (let c = 0; c < Math.floor(gridWidth / 2); c += 1) {
1480
+ const a = cellColorIndex(cells[r * gridWidth + c], paletteKey);
1481
+ const b = cellColorIndex(cells[r * gridWidth + (gridWidth - 1 - c)], paletteKey);
1482
+ if (a === null && b === null) continue;
1483
+ total += 1;
1484
+ if (a === b) same += 1;
1485
+ }
1486
+ }
1487
+ const symmetry = total === 0 ? 0 : same / total;
1488
+ hints.push(`left-right symmetry ${Math.round(symmetry * 100)}%${symmetry >= 0.5 ? ' (suggestive of manufactured/constructed shapes)' : ''}`);
1489
+ return hints;
1490
+ }