picturereader 0.1.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.
- package/LICENSE +21 -0
- package/README.md +122 -0
- package/cordis.patch.yml +4 -0
- package/package.json +50 -0
- package/skills/image-reading.md +96 -0
- package/src/core.js +1427 -0
- package/src/index.js +30 -0
- package/src/tool.js +548 -0
package/src/core.js
ADDED
|
@@ -0,0 +1,1427 @@
|
|
|
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 } 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
|
+
const tmpPath = join(tmpdir(), `picturereader-ocr-${randomBytes(6).toString('hex')}.png`);
|
|
1118
|
+
await writeFile(tmpPath, pngBytes);
|
|
1119
|
+
try {
|
|
1120
|
+
if (engine === 'paddle') {
|
|
1121
|
+
const result = await runPaddleOcr(tmpPath);
|
|
1122
|
+
return { width: work.width, height: work.height, lines: result.lines };
|
|
1123
|
+
}
|
|
1124
|
+
return await runOcr(tmpPath, { language });
|
|
1125
|
+
} finally {
|
|
1126
|
+
await rm(tmpPath, { force: true }).catch(() => {});
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
/** Absolute path to the local PaddleOCR environment (paddle_venv); overridable via DSH_PADDLE_PYTHON. */
|
|
1131
|
+
export function paddlePython() {
|
|
1132
|
+
return process.env.DSH_PADDLE_PYTHON ?? 'C:/Users/Administrator/paddle_venv/Scripts/python.exe';
|
|
1133
|
+
}
|
|
1134
|
+
/** PaddleX model cache (the default ~/.paddlex is broken on this machine); overridable via DSH_PADDLE_CACHE. */
|
|
1135
|
+
export function paddleCacheHome() {
|
|
1136
|
+
return process.env.DSH_PADDLE_CACHE ?? 'D:/coding/picturereader/.paddlex-cache';
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* Whether the optional PaddleOCR environment is available. PaddleOCR is an
|
|
1141
|
+
* OPTIONAL engine: when it is missing, callers must degrade gracefully to the
|
|
1142
|
+
* Windows engine instead of failing.
|
|
1143
|
+
* @param python - python executable to probe (defaults to the configured path).
|
|
1144
|
+
* @returns true when the interpreter exists.
|
|
1145
|
+
*/
|
|
1146
|
+
export async function paddleAvailable(python = paddlePython()) {
|
|
1147
|
+
try {
|
|
1148
|
+
await stat(python);
|
|
1149
|
+
return true;
|
|
1150
|
+
} catch {
|
|
1151
|
+
return false;
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* Run PaddleOCR on a PNG file via the local paddle_venv. Strongly better than
|
|
1157
|
+
* Windows OCR for glowing, curved, or game-rendered text (verified on the
|
|
1158
|
+
* ENDFIELD "勇于探索叩问苍穹" banner). Model load takes ~2s per call.
|
|
1159
|
+
* @param pngPath - absolute path to the PNG.
|
|
1160
|
+
* @returns `{ lines: [{ text, score, x, y, width, height }] }` (box aggregated).
|
|
1161
|
+
*/
|
|
1162
|
+
export function runPaddleOcr(pngPath) {
|
|
1163
|
+
const escaped = String(pngPath).replaceAll("'", "''");
|
|
1164
|
+
const script = [
|
|
1165
|
+
'import base64, json, sys',
|
|
1166
|
+
'from paddleocr import PaddleOCR',
|
|
1167
|
+
"ocr = PaddleOCR(lang='ch', use_doc_orientation_classify=False, use_doc_unwarping=False, use_textline_orientation=False, enable_mkldnn=False)",
|
|
1168
|
+
`result = ocr.predict(r'${escaped}')`,
|
|
1169
|
+
'lines = []',
|
|
1170
|
+
'for res in result:',
|
|
1171
|
+
" texts = res.get('rec_texts') or []",
|
|
1172
|
+
" scores = res.get('rec_scores') or []",
|
|
1173
|
+
" polys = res.get('rec_polys') or []",
|
|
1174
|
+
' for i, t in enumerate(texts):',
|
|
1175
|
+
' if i < len(polys):',
|
|
1176
|
+
' pts = [[int(float(v)) for v in pt] for pt in polys[i]]',
|
|
1177
|
+
' xs = [pt[0] for pt in pts]; ys = [pt[1] for pt in pts]',
|
|
1178
|
+
' box = {"x": min(xs), "y": min(ys), "w": max(xs)-min(xs), "h": max(ys)-min(ys)}',
|
|
1179
|
+
' else:',
|
|
1180
|
+
' box = {"x": 0, "y": 0, "w": 0, "h": 0}',
|
|
1181
|
+
" score = round(float(scores[i]), 3) if i < len(scores) else 0.0",
|
|
1182
|
+
" lines.append({'text': t, 'score': score, **box})",
|
|
1183
|
+
"out = json.dumps({'lines': lines}, ensure_ascii=False)",
|
|
1184
|
+
"sys.stdout.write(base64.b64encode(out.encode('utf-8')).decode('ascii'))"
|
|
1185
|
+
].join('\n');
|
|
1186
|
+
return new Promise((resolve, reject) => {
|
|
1187
|
+
const child = spawn(paddlePython(), ['-c', script], {
|
|
1188
|
+
env: { ...process.env, PADDLE_PDX_CACHE_HOME: paddleCacheHome(), PYTHONIOENCODING: 'utf-8' },
|
|
1189
|
+
windowsHide: true
|
|
1190
|
+
});
|
|
1191
|
+
let stdout = '';
|
|
1192
|
+
let stderr = '';
|
|
1193
|
+
const timer = setTimeout(() => {
|
|
1194
|
+
child.kill();
|
|
1195
|
+
reject(new Error('image_ocr: PaddleOCR timed out after 60s'));
|
|
1196
|
+
}, 60_000);
|
|
1197
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
1198
|
+
child.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
1199
|
+
child.on('error', (error) => {
|
|
1200
|
+
clearTimeout(timer);
|
|
1201
|
+
reject(new Error(`image_ocr: cannot start PaddleOCR: ${error.message}`));
|
|
1202
|
+
});
|
|
1203
|
+
child.on('close', (code) => {
|
|
1204
|
+
clearTimeout(timer);
|
|
1205
|
+
if (code !== 0) {
|
|
1206
|
+
const tail = stderr.trim().split('\n').filter((l) => l.includes('Error') || l.includes('error') || l.includes('Traceback')).slice(-3).join(' | ') || stderr.trim().slice(-200);
|
|
1207
|
+
reject(new Error(`image_ocr: PaddleOCR failed (exit ${code}): ${tail}`));
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
try {
|
|
1211
|
+
const json = Buffer.from(stdout.trim(), 'base64').toString('utf8');
|
|
1212
|
+
const parsed = JSON.parse(json);
|
|
1213
|
+
resolve({ lines: parsed.lines ?? [] });
|
|
1214
|
+
} catch (error) {
|
|
1215
|
+
reject(new Error(`image_ocr: cannot parse PaddleOCR result: ${error.message}`));
|
|
1216
|
+
}
|
|
1217
|
+
});
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* Render OCR results as model-facing text.
|
|
1223
|
+
* @param value - `{ path, width, height, region, lines }`.
|
|
1224
|
+
* @returns the multi-line text.
|
|
1225
|
+
*/
|
|
1226
|
+
export function renderOcr(value) {
|
|
1227
|
+
const lines = [];
|
|
1228
|
+
lines.push(`ocr: ${value.path} (${value.width}x${value.height}, region=${value.region}, engine=${value.engine ?? 'windows'})`);
|
|
1229
|
+
if (value.note !== undefined) {
|
|
1230
|
+
lines.push(`note: ${value.note}`);
|
|
1231
|
+
}
|
|
1232
|
+
if (value.lines.length === 0) {
|
|
1233
|
+
lines.push('no text recognized in this region');
|
|
1234
|
+
} else {
|
|
1235
|
+
lines.push(`recognized ${value.lines.length} line(s):`);
|
|
1236
|
+
value.lines.forEach((line, index) => {
|
|
1237
|
+
const score = line.score !== undefined ? ` score=${line.score}` : '';
|
|
1238
|
+
lines.push(`${index + 1}. "${line.text}" @ (${line.x},${line.y}) ${line.width}x${line.height}${score}`);
|
|
1239
|
+
});
|
|
1240
|
+
}
|
|
1241
|
+
return lines.join('\n');
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// ---------------------------------------------------------------------------
|
|
1245
|
+
// pixel-level texture sampling (material hints for the model)
|
|
1246
|
+
// ---------------------------------------------------------------------------
|
|
1247
|
+
|
|
1248
|
+
/**
|
|
1249
|
+
* Sample a small region as an NxN grid of EXACT pixels (one sample per cell,
|
|
1250
|
+
* taken at the cell center — not an average). Together with the contrast
|
|
1251
|
+
* statistic this lets the model judge local material: smooth gradients (skin,
|
|
1252
|
+
* sky), high-contrast stripes (metal, wood grain), periodic repeats (fabric),
|
|
1253
|
+
* high-frequency noise (foliage).
|
|
1254
|
+
* @param rgba - RGBA `Buffer`.
|
|
1255
|
+
* @param imgWidth - source width.
|
|
1256
|
+
* @param imgHeight - source height.
|
|
1257
|
+
* @param region - `[x0, y0, x1, y1]` fractions; must cover at least `size` px
|
|
1258
|
+
* in each direction so samples are distinct.
|
|
1259
|
+
* @param size - grid side length (2..16, default 8).
|
|
1260
|
+
* @returns `{ width, height, points, contrast, distinct, stepX, stepY }`.
|
|
1261
|
+
*/
|
|
1262
|
+
export function samplePixels(rgba, imgWidth, imgHeight, region, size = 8) {
|
|
1263
|
+
const grid = Math.min(16, Math.max(2, Math.round(size)));
|
|
1264
|
+
const [rx0, ry0, rx1, ry1] = normalizeRegion(region);
|
|
1265
|
+
const x0 = Math.floor(rx0 * imgWidth);
|
|
1266
|
+
const x1 = Math.ceil(rx1 * imgWidth);
|
|
1267
|
+
const y0 = Math.floor(ry0 * imgHeight);
|
|
1268
|
+
const y1 = Math.ceil(ry1 * imgHeight);
|
|
1269
|
+
const regionW = x1 - x0;
|
|
1270
|
+
const regionH = y1 - y0;
|
|
1271
|
+
if (regionW < grid || regionH < grid) {
|
|
1272
|
+
throw new Error(
|
|
1273
|
+
`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`
|
|
1274
|
+
);
|
|
1275
|
+
}
|
|
1276
|
+
const points = [];
|
|
1277
|
+
const colors = new Set();
|
|
1278
|
+
for (let gy = 0; gy < grid; gy += 1) {
|
|
1279
|
+
const row = [];
|
|
1280
|
+
const py = y0 + Math.floor(((gy + 0.5) * regionH) / grid);
|
|
1281
|
+
for (let gx = 0; gx < grid; gx += 1) {
|
|
1282
|
+
const px = x0 + Math.floor(((gx + 0.5) * regionW) / grid);
|
|
1283
|
+
const p = (Math.min(imgHeight - 1, py) * imgWidth + Math.min(imgWidth - 1, px)) * 4;
|
|
1284
|
+
const color = [rgba[p], rgba[p + 1], rgba[p + 2]];
|
|
1285
|
+
row.push(color);
|
|
1286
|
+
colors.add((color[0] << 16) | (color[1] << 8) | color[2]);
|
|
1287
|
+
}
|
|
1288
|
+
points.push(row);
|
|
1289
|
+
}
|
|
1290
|
+
// adjacent-sample contrast: mean RGB distance between horizontal neighbours
|
|
1291
|
+
let diffSum = 0;
|
|
1292
|
+
let diffCount = 0;
|
|
1293
|
+
for (let gy = 0; gy < grid; gy += 1) {
|
|
1294
|
+
for (let gx = 0; gx < grid - 1; gx += 1) {
|
|
1295
|
+
const a = points[gy][gx];
|
|
1296
|
+
const b = points[gy][gx + 1];
|
|
1297
|
+
diffSum += Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2]);
|
|
1298
|
+
diffCount += 1;
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
return {
|
|
1302
|
+
width: regionW,
|
|
1303
|
+
height: regionH,
|
|
1304
|
+
stepX: regionW / grid,
|
|
1305
|
+
stepY: regionH / grid,
|
|
1306
|
+
points,
|
|
1307
|
+
contrast: diffCount === 0 ? 0 : Math.round((diffSum / diffCount / 3 / 255) * 1000) / 1000,
|
|
1308
|
+
distinct: colors.size
|
|
1309
|
+
};
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/**
|
|
1313
|
+
* Render a texture sample as model-facing text: an NxN grid of exact RGB
|
|
1314
|
+
* triples plus interpretation hints.
|
|
1315
|
+
* @param value - `{ path, width, height, region, points, contrast, distinct, stepX, stepY }`.
|
|
1316
|
+
* @returns the multi-line text.
|
|
1317
|
+
*/
|
|
1318
|
+
export function renderSample(value) {
|
|
1319
|
+
const lines = [];
|
|
1320
|
+
const grid = value.points.length;
|
|
1321
|
+
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)`);
|
|
1322
|
+
for (const row of value.points) {
|
|
1323
|
+
lines.push(row.map(([r, g, b]) => `(${r},${g},${b})`).join(' '));
|
|
1324
|
+
}
|
|
1325
|
+
const contrastLabel = value.contrast < 0.04 ? 'smooth (gradient, uniform)' : value.contrast < 0.12 ? 'subtle texture' : 'high contrast (rough/material)';
|
|
1326
|
+
lines.push(`stats: local contrast ${value.contrast} -> ${contrastLabel}; ${value.distinct} distinct colors`);
|
|
1327
|
+
return lines.join('\n');
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// ---------------------------------------------------------------------------
|
|
1331
|
+
// structural hints: stripes, symmetry (shape evidence for the model)
|
|
1332
|
+
// ---------------------------------------------------------------------------
|
|
1333
|
+
|
|
1334
|
+
/** The dominant color index of a row or column (null if fully transparent). */
|
|
1335
|
+
function dominantIndex(seq) {
|
|
1336
|
+
const counts = new Map();
|
|
1337
|
+
for (const index of seq) {
|
|
1338
|
+
if (index === null) continue;
|
|
1339
|
+
counts.set(index, (counts.get(index) ?? 0) + 1);
|
|
1340
|
+
}
|
|
1341
|
+
if (counts.size === 0) return null;
|
|
1342
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1])[0][0];
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
/** Longest run of adjacent elements that are all pairwise different (and non-null). */
|
|
1346
|
+
function longestAlternating(seq) {
|
|
1347
|
+
let bestStart = 0;
|
|
1348
|
+
let bestLength = 0;
|
|
1349
|
+
let start = 0;
|
|
1350
|
+
for (let i = 1; i <= seq.length; i += 1) {
|
|
1351
|
+
const alternates = i < seq.length && seq[i] !== null && seq[i - 1] !== null && seq[i] !== seq[i - 1];
|
|
1352
|
+
if (alternates) continue;
|
|
1353
|
+
const length = i - start;
|
|
1354
|
+
if (length > bestLength) {
|
|
1355
|
+
bestLength = length;
|
|
1356
|
+
bestStart = start;
|
|
1357
|
+
}
|
|
1358
|
+
start = i;
|
|
1359
|
+
}
|
|
1360
|
+
return { start: bestStart, length: bestLength };
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
/**
|
|
1364
|
+
* Detect parallel stripes (alternating color bands) in a row/column of
|
|
1365
|
+
* dominant colors. Panels, grilles and blades produce 2-4 colors alternating
|
|
1366
|
+
* across several adjacent columns/rows (e.g. K/Y/K/Y for solar panels).
|
|
1367
|
+
* @param seq - dominant color index per column (or row), null for empty.
|
|
1368
|
+
* @returns `{ start, length, colors }` or null.
|
|
1369
|
+
*/
|
|
1370
|
+
function detectStripe(seq) {
|
|
1371
|
+
const { start, length } = longestAlternating(seq);
|
|
1372
|
+
if (length < 4) return null;
|
|
1373
|
+
const colors = new Set(seq.slice(start, start + length).filter((v) => v !== null));
|
|
1374
|
+
if (colors.size < 2 || colors.size > 4) return null;
|
|
1375
|
+
return { start, length, colors: [...colors] };
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
/**
|
|
1379
|
+
* Compute structural shape evidence from the classified grid: parallel
|
|
1380
|
+
* stripes (vertical/horizontal alternating bands -> panels/grilles/blades)
|
|
1381
|
+
* and left-right symmetry (manufactured/constructed objects).
|
|
1382
|
+
* @param cells - the sparse cell array.
|
|
1383
|
+
* @param gridWidth - grid width.
|
|
1384
|
+
* @param gridHeight - grid height.
|
|
1385
|
+
* @param paletteKey - the resolved palette.
|
|
1386
|
+
* @returns an array of human-readable hints (empty when none found).
|
|
1387
|
+
*/
|
|
1388
|
+
export function structuralHints(cells, gridWidth, gridHeight, paletteKey) {
|
|
1389
|
+
const hints = [];
|
|
1390
|
+
// per-column dominant colors
|
|
1391
|
+
const colMajors = [];
|
|
1392
|
+
for (let c = 0; c < gridWidth; c += 1) {
|
|
1393
|
+
const column = [];
|
|
1394
|
+
for (let r = 0; r < gridHeight; r += 1) column.push(cellColorIndex(cells[r * gridWidth + c], paletteKey));
|
|
1395
|
+
colMajors.push(dominantIndex(column));
|
|
1396
|
+
}
|
|
1397
|
+
const vertical = detectStripe(colMajors);
|
|
1398
|
+
if (vertical !== null) {
|
|
1399
|
+
hints.push(`${vertical.length} vertical stripes (${vertical.colors.length} alternating colors) at cols ${vertical.start}..${vertical.start + vertical.length - 1}`);
|
|
1400
|
+
}
|
|
1401
|
+
// per-row dominant colors
|
|
1402
|
+
const rowMajors = [];
|
|
1403
|
+
for (let r = 0; r < gridHeight; r += 1) {
|
|
1404
|
+
const row = [];
|
|
1405
|
+
for (let c = 0; c < gridWidth; c += 1) row.push(cellColorIndex(cells[r * gridWidth + c], paletteKey));
|
|
1406
|
+
rowMajors.push(dominantIndex(row));
|
|
1407
|
+
}
|
|
1408
|
+
const horizontal = detectStripe(rowMajors);
|
|
1409
|
+
if (horizontal !== null) {
|
|
1410
|
+
hints.push(`${horizontal.length} horizontal stripes (${horizontal.colors.length} alternating colors) at rows ${horizontal.start}..${horizontal.start + horizontal.length - 1}`);
|
|
1411
|
+
}
|
|
1412
|
+
// left-right symmetry (always reported as a number; >= 0.5 flagged as notable)
|
|
1413
|
+
let same = 0;
|
|
1414
|
+
let total = 0;
|
|
1415
|
+
for (let r = 0; r < gridHeight; r += 1) {
|
|
1416
|
+
for (let c = 0; c < Math.floor(gridWidth / 2); c += 1) {
|
|
1417
|
+
const a = cellColorIndex(cells[r * gridWidth + c], paletteKey);
|
|
1418
|
+
const b = cellColorIndex(cells[r * gridWidth + (gridWidth - 1 - c)], paletteKey);
|
|
1419
|
+
if (a === null && b === null) continue;
|
|
1420
|
+
total += 1;
|
|
1421
|
+
if (a === b) same += 1;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
const symmetry = total === 0 ? 0 : same / total;
|
|
1425
|
+
hints.push(`left-right symmetry ${Math.round(symmetry * 100)}%${symmetry >= 0.5 ? ' (suggestive of manufactured/constructed shapes)' : ''}`);
|
|
1426
|
+
return hints;
|
|
1427
|
+
}
|