picturereader 2.0.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +160 -139
- package/client.js +536 -0
- package/package.json +15 -3
- package/scripts/doc-to-image.py +194 -0
- package/scripts/setup-doc-venv.mjs +66 -0
- package/scripts/setup-rapid.mjs +85 -0
- package/src/bridge.js +162 -0
- package/src/config.js +70 -0
- package/src/core.js +94 -2
- package/src/doc-tools.js +326 -0
- package/src/image-batch.js +504 -0
- package/src/index.js +258 -32
- package/src/more-tools.js +695 -0
- package/src/picturereader-vision.mjs +164 -0
- package/src/routing.js +130 -0
- package/src/runtime.js +127 -0
- package/src/settings-expose.js +75 -0
- package/src/tool.js +19 -14
- package/src/vision-analyze.js +35 -13
- package/src/vlm.js +198 -15
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* image_batch — 批量规模/上下文验证工具 for the text-only DSH model.
|
|
3
|
+
*
|
|
4
|
+
* When a lot of images are handed over at once without per-file instructions,
|
|
5
|
+
* a text-only model must not blindly call image_scan / image_ocr / VLM on each
|
|
6
|
+
* one (that explodes the context and wastes calls). This tool gives the model a
|
|
7
|
+
* compact MANIFEST of the whole batch in one shot:
|
|
8
|
+
*
|
|
9
|
+
* 1. decode every image (same extension validation + BYTE/MAX_PIXELS caps,
|
|
10
|
+
* bad singles are recorded as errors and skipped, never a whole-batch fail)
|
|
11
|
+
* 2. 'auto' auto_ocr: probe the first few images with OCR; if they are
|
|
12
|
+
* text-dense ("the first few are all text"), treat the batch as a document
|
|
13
|
+
* / screenshot set and run full OCR on everything; otherwise only OCR
|
|
14
|
+
* the text-dense ones individually.
|
|
15
|
+
* 3. classify each image (text / table / photo / chart / blank / unknown)
|
|
16
|
+
* and attach an ocr_excerpt / scan_preview / recommendation.
|
|
17
|
+
* 4. summarise totals and tell the model which indices are worth deepening.
|
|
18
|
+
* 5. soft-cap the total rendered text so a 50-file batch stays ~6k chars.
|
|
19
|
+
*
|
|
20
|
+
* Registration happens in index.js via the shared ctx.tools.register flow, so
|
|
21
|
+
* this file only exports the factory.
|
|
22
|
+
*
|
|
23
|
+
* @module picturereader/image-batch
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { extname } from 'node:path';
|
|
27
|
+
import { BYTE_CAP, MAX_PIXELS } from './tool.js';
|
|
28
|
+
|
|
29
|
+
const CORE_URL = new URL('./core.js', import.meta.url).href;
|
|
30
|
+
let coreCache = { url: null, mtime: -1, module: null };
|
|
31
|
+
|
|
32
|
+
/** Load the newest core.js (cache-busted by mtime), same as tool.js / vision-analyze.js. */
|
|
33
|
+
async function importCore() {
|
|
34
|
+
const { stat } = await import('node:fs/promises');
|
|
35
|
+
const url = new URL(CORE_URL);
|
|
36
|
+
const info = await stat(url);
|
|
37
|
+
if (coreCache.module !== null && coreCache.url === CORE_URL && info.mtimeMs === coreCache.mtime) {
|
|
38
|
+
return coreCache.module;
|
|
39
|
+
}
|
|
40
|
+
const module = await import(`${url.href}?t=${info.mtimeMs}`);
|
|
41
|
+
coreCache = { url: CORE_URL, mtime: info.mtimeMs, module };
|
|
42
|
+
return module;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The most recent core module, used by the synchronous output.render. */
|
|
46
|
+
let latestCore = null;
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// defaults
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
const DEFAULT_MAX_FILES = 50;
|
|
53
|
+
const DEFAULT_PROBE_FIRST = 3;
|
|
54
|
+
const DEFAULT_OCR_LIMIT_CHARS = 800;
|
|
55
|
+
const DEFAULT_SCAN_SIZE = 16; // lighter than single-image 32 to keep batches compact
|
|
56
|
+
const SOFT_OUTPUT_LIMIT = 6000; // ~char budget for the whole rendered batch
|
|
57
|
+
|
|
58
|
+
/** A line counts as OCR-recognised text if it is non-empty. */
|
|
59
|
+
function nonEmptyLines(ocrResult) {
|
|
60
|
+
const lines = ocrResult && Array.isArray(ocrResult.lines) ? ocrResult.lines : [];
|
|
61
|
+
return lines.filter((l) => typeof l?.text === 'string' && l.text.trim().length > 0).length;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Classify an image into a coarse type using scan statistics + OCR line count.
|
|
66
|
+
* Pure heuristic — the batch manifest is a triage aid, not a vision model.
|
|
67
|
+
* @param analysis - result of core.analyzeImage.
|
|
68
|
+
* @param ocrLines - number of non-empty OCR lines (0 when OCR wasn't run / none).
|
|
69
|
+
* @param hasHorizontalStripes - whether the scan reported horizontal stripes.
|
|
70
|
+
* @returns one of 'text' | 'table' | 'chart' | 'photo' | 'blank' | 'unknown'.
|
|
71
|
+
*/
|
|
72
|
+
export function classifyType(analysis, ocrLines, hasHorizontalStripes = false) {
|
|
73
|
+
const rough = analysis?.texture?.rough ?? 0;
|
|
74
|
+
const shades = analysis?.distinctShades ?? 0;
|
|
75
|
+
const hueFraction =
|
|
76
|
+
(analysis?.hues ?? []).filter((h) => h.name !== 'achromatic').reduce((s, h) => s + h.pct, 0) / 100;
|
|
77
|
+
const regionCount = (analysis?.regions ?? []).length;
|
|
78
|
+
|
|
79
|
+
// text-dense takes precedence over everything else (a blank-looking cell
|
|
80
|
+
// grid can still be a screenshot of a document).
|
|
81
|
+
if (ocrLines >= 2) {
|
|
82
|
+
// text PLUS horizontal ruling lines reads like a table
|
|
83
|
+
return hasHorizontalStripes ? 'table' : 'text';
|
|
84
|
+
}
|
|
85
|
+
// a single OCR line + ruling lines also leans table
|
|
86
|
+
if (ocrLines === 1 && hasHorizontalStripes) return 'table';
|
|
87
|
+
|
|
88
|
+
// blank: no text and almost nothing there (very low shade/texture diversity)
|
|
89
|
+
if (shades <= 1 && rough < 8) return 'blank';
|
|
90
|
+
|
|
91
|
+
// chart: several distinct color blobs + a decent colour spread
|
|
92
|
+
if (regionCount >= 6 && hueFraction >= 0.05) return 'chart';
|
|
93
|
+
|
|
94
|
+
// photo: high-frequency detail (rough texture) or many shades + colour
|
|
95
|
+
if (rough >= 15 || (shades >= 6 && hueFraction >= 0.15)) return 'photo';
|
|
96
|
+
if (hueFraction >= 0.2) return 'photo';
|
|
97
|
+
|
|
98
|
+
return 'unknown';
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A default recommendation string per type, telling the model how to deepen.
|
|
103
|
+
* @param type - the classified type.
|
|
104
|
+
* @returns a short imperative recommendation.
|
|
105
|
+
*/
|
|
106
|
+
export function recommendFor(type) {
|
|
107
|
+
switch (type) {
|
|
108
|
+
case 'text': return 'read it with image_ocr (full text)';
|
|
109
|
+
case 'table': return 'image_ocr for the cell text, then image_scan+sample for layout';
|
|
110
|
+
case 'chart': return 'image_scan for axes/trends, then image_sample on points of interest';
|
|
111
|
+
case 'photo': return 'rich photo-like content — the one case worth considering an external VLM';
|
|
112
|
+
case 'blank': return 'skip — low information content';
|
|
113
|
+
default: return 'quick image_scan to confirm what it holds';
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** True if any structure hint mentions horizontal stripes. */
|
|
118
|
+
function hasHorizontalStripes(analysis) {
|
|
119
|
+
return (analysis?.structure ?? []).some((h) => /horizontal stripes/i.test(String(h)));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* Validate / normalise the auto_ocr argument. */
|
|
123
|
+
function parseAutoOcr(raw) {
|
|
124
|
+
const value = raw === undefined ? 'auto' : String(raw);
|
|
125
|
+
if (value !== 'auto' && value !== 'always' && value !== 'never') {
|
|
126
|
+
throw new Error("image_batch: auto_ocr must be one of 'auto', 'always', 'never'");
|
|
127
|
+
}
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Build the model-facing `image_batch` tool over one plugin context.
|
|
133
|
+
*
|
|
134
|
+
* Test seam: if `ctx.ocrImage` is provided it replaces core.ocrImage (lets the
|
|
135
|
+
* test suite inject deterministic OCR results without real OCR engines).
|
|
136
|
+
* @param ctx - the Cordis context providing `ctx.fs` and `ctx.emit`.
|
|
137
|
+
* @returns the tool definition.
|
|
138
|
+
*/
|
|
139
|
+
export function createImageBatchTool(ctx) {
|
|
140
|
+
return {
|
|
141
|
+
name: 'image_batch',
|
|
142
|
+
description: [
|
|
143
|
+
'Batch-scale / context-validation tool: given a LIST of image paths, return one compact manifest (per-file type guess + whether it has text + a short scan/OCR excerpt + a recommendation) plus a whole-batch summary, so you can decide which images are worth deepening WITHOUT blindly calling image_scan / image_ocr / VLM on each one.',
|
|
144
|
+
'Use it when many images arrive together and none have individual instructions — e.g. "these are all screenshots" or a folder dump. It first probes a few images with OCR: if the first few are text-dense it treats the whole batch as documents/screenshots and runs OCR on everything (auto_ocr=auto); otherwise it only OCRs the text-dense ones and triages the rest by pixel stats.',
|
|
145
|
+
'Each item reports: index, basename, width x height, type (text/table/photo/chart/blank/unknown), has_text, ocr_excerpt (truncated), scan_preview (truncated) and a recommendation. The summary tells you total counts and which indices are worth deepening.',
|
|
146
|
+
'scale control: previews and OCR excerpts are truncated and the whole manifest is soft-capped (~6k chars) so a large batch stays cheap; if truncated, the summary notes that DeepSeek can still call image_ocr / image_scan directly on specific indices. Invalid / missing files are recorded as errors, never a whole-batch failure.'
|
|
147
|
+
].join(' '),
|
|
148
|
+
parameters: {
|
|
149
|
+
type: 'object',
|
|
150
|
+
additionalProperties: true,
|
|
151
|
+
properties: {
|
|
152
|
+
file_paths: {
|
|
153
|
+
type: 'array',
|
|
154
|
+
description: 'List of image paths to batch (each may be relative to the working directory).',
|
|
155
|
+
items: { type: 'string' }
|
|
156
|
+
},
|
|
157
|
+
auto_ocr: {
|
|
158
|
+
type: 'string',
|
|
159
|
+
enum: ['auto', 'always', 'never'],
|
|
160
|
+
description: "'auto' (default) = probe the first few images; if text-dense, run full OCR on all, else only on the text-dense ones. 'always' = OCR every image. 'never' = no OCR at all."
|
|
161
|
+
},
|
|
162
|
+
preview: {
|
|
163
|
+
type: 'string',
|
|
164
|
+
enum: ['scan', 'none'],
|
|
165
|
+
description: "'scan' (default) = include a truncated image_scan overview per image; 'none' = skip scan previews (smaller output)."
|
|
166
|
+
},
|
|
167
|
+
max_files: {
|
|
168
|
+
type: 'integer',
|
|
169
|
+
description: 'Hard display cap on how many images to process in one call (default 50); pass more and the tool asks you to split into batches.'
|
|
170
|
+
},
|
|
171
|
+
probe_first: {
|
|
172
|
+
type: 'integer',
|
|
173
|
+
description: "For auto_ocr='auto': how many leading images to OCR just to decide whether the batch is text-dense (default 3)."
|
|
174
|
+
},
|
|
175
|
+
ocr_limit_chars: {
|
|
176
|
+
type: 'integer',
|
|
177
|
+
description: 'Max characters of OCR text to keep per image (default 800), to bound context growth.'
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
required: ['file_paths']
|
|
181
|
+
},
|
|
182
|
+
output: {
|
|
183
|
+
schema: {
|
|
184
|
+
type: 'object',
|
|
185
|
+
additionalProperties: true,
|
|
186
|
+
properties: {
|
|
187
|
+
summary: { type: 'string' },
|
|
188
|
+
items: {
|
|
189
|
+
type: 'array',
|
|
190
|
+
items: {
|
|
191
|
+
type: 'object',
|
|
192
|
+
additionalProperties: true,
|
|
193
|
+
properties: {
|
|
194
|
+
index: { type: 'integer' },
|
|
195
|
+
basename: { type: 'string' },
|
|
196
|
+
path: { type: 'string' },
|
|
197
|
+
width: { type: 'integer' },
|
|
198
|
+
height: { type: 'integer' },
|
|
199
|
+
type: { type: 'string' },
|
|
200
|
+
has_text: { type: 'boolean' },
|
|
201
|
+
ocr_excerpt: { type: 'string' },
|
|
202
|
+
scan_preview: { type: 'string' },
|
|
203
|
+
recommendation: { type: 'string' },
|
|
204
|
+
note: { type: 'string' },
|
|
205
|
+
error: { type: 'string' }
|
|
206
|
+
},
|
|
207
|
+
required: ['index', 'basename', 'type', 'recommendation']
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
processed: { type: 'integer' },
|
|
211
|
+
errors: { type: 'integer' }
|
|
212
|
+
},
|
|
213
|
+
required: ['summary', 'items', 'processed', 'errors']
|
|
214
|
+
},
|
|
215
|
+
render: (_args, value) => {
|
|
216
|
+
const lines = [];
|
|
217
|
+
lines.push(value.summary ?? `image_batch: processed=${value.processed}, errors=${value.errors}`);
|
|
218
|
+
for (const item of value.items ?? []) {
|
|
219
|
+
if (item.error !== undefined && item.error !== null) {
|
|
220
|
+
lines.push(` [!] ${item.index}. ${item.basename} — ERROR: ${item.error}`);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
let line = `[${item.index}] ${item.basename} ${item.width}x${item.height} type=${item.type}${item.has_text ? ' text=yes' : ''} | ${item.recommendation}`;
|
|
224
|
+
if (item.note !== undefined && item.note !== null) lines.push(` note: ${item.note}`);
|
|
225
|
+
if (item.ocr_excerpt !== undefined && item.ocr_excerpt !== null && item.ocr_excerpt.length > 0) {
|
|
226
|
+
line += `\n ocr: ${item.ocr_excerpt}`;
|
|
227
|
+
}
|
|
228
|
+
if (item.scan_preview !== undefined && item.scan_preview !== null && item.scan_preview.length > 0) {
|
|
229
|
+
line += `\n scan: ${item.scan_preview}`;
|
|
230
|
+
}
|
|
231
|
+
lines.push(line);
|
|
232
|
+
}
|
|
233
|
+
return [{ type: 'text', text: lines.join('\n') }];
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
isConcurrencySafe: () => true,
|
|
237
|
+
async execute(args, exec) {
|
|
238
|
+
if (exec.signal?.aborted) throw new Error('image_batch: cancelled');
|
|
239
|
+
const rawPaths = args.file_paths;
|
|
240
|
+
if (!Array.isArray(rawPaths) || rawPaths.length === 0) {
|
|
241
|
+
throw new Error('image_batch: file_paths must be a non-empty array of image paths');
|
|
242
|
+
}
|
|
243
|
+
const maxFiles = args.max_files === undefined ? DEFAULT_MAX_FILES : Number(args.max_files);
|
|
244
|
+
if (!Number.isInteger(maxFiles) || maxFiles < 1) {
|
|
245
|
+
throw new Error('image_batch: max_files must be a positive integer');
|
|
246
|
+
}
|
|
247
|
+
const paths = rawPaths.map((p) => String(p).trim()).filter((p) => p.length > 0);
|
|
248
|
+
if (paths.length === 0) throw new Error('image_batch: file_paths contains no usable paths');
|
|
249
|
+
if (paths.length > maxFiles) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
`image_batch: ${paths.length} files exceeds max_files=${maxFiles} for one call — split the batch and call again (or raise max_files)`
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const autoOcr = parseAutoOcr(args.auto_ocr);
|
|
256
|
+
const previewMode = args.preview === undefined ? 'scan' : String(args.preview);
|
|
257
|
+
if (previewMode !== 'scan' && previewMode !== 'none') {
|
|
258
|
+
throw new Error("image_batch: preview must be 'scan' or 'none'");
|
|
259
|
+
}
|
|
260
|
+
const probeFirst = args.probe_first === undefined ? DEFAULT_PROBE_FIRST : Number(args.probe_first);
|
|
261
|
+
if (!Number.isInteger(probeFirst) || probeFirst < 0) {
|
|
262
|
+
throw new Error('image_batch: probe_first must be a non-negative integer');
|
|
263
|
+
}
|
|
264
|
+
const ocrLimitChars = args.ocr_limit_chars === undefined ? DEFAULT_OCR_LIMIT_CHARS : Number(args.ocr_limit_chars);
|
|
265
|
+
if (!Number.isInteger(ocrLimitChars) || ocrLimitChars < 0) {
|
|
266
|
+
throw new Error('image_batch: ocr_limit_chars must be a non-negative integer');
|
|
267
|
+
}
|
|
268
|
+
const scanSize = DEFAULT_SCAN_SIZE;
|
|
269
|
+
|
|
270
|
+
const core = await importCore();
|
|
271
|
+
latestCore = core;
|
|
272
|
+
// Test seam: ctx.ocrImage replaces the real OCR pipeline.
|
|
273
|
+
const ocrFn = typeof ctx.ocrImage === 'function' ? ctx.ocrImage : core.ocrImage.bind(core);
|
|
274
|
+
|
|
275
|
+
const cwd = exec.agent?.session?.header?.cwd;
|
|
276
|
+
const items = [];
|
|
277
|
+
let processed = 0;
|
|
278
|
+
let errors = 0;
|
|
279
|
+
const decoded = []; // { index, path, basename, width, height, data, ext, target, info }
|
|
280
|
+
|
|
281
|
+
// ------------------------------------------------------------------
|
|
282
|
+
// pass 1: decode each image (bad singles are recorded, not fatal)
|
|
283
|
+
// ------------------------------------------------------------------
|
|
284
|
+
for (let i = 0; i < paths.length; i += 1) {
|
|
285
|
+
if (exec.signal?.aborted) throw new Error('image_batch: cancelled');
|
|
286
|
+
const filePath = paths[i];
|
|
287
|
+
const entry = {
|
|
288
|
+
index: i,
|
|
289
|
+
path: filePath,
|
|
290
|
+
basename: filePath.split(/[\\/]/).pop() || filePath,
|
|
291
|
+
type: 'unknown',
|
|
292
|
+
has_text: false,
|
|
293
|
+
recommendation: recommendFor('unknown')
|
|
294
|
+
};
|
|
295
|
+
try {
|
|
296
|
+
const ext = extname(filePath).toLowerCase();
|
|
297
|
+
if (core.UNSUPPORTED_EXTENSIONS.has(ext)) {
|
|
298
|
+
throw new Error('WebP is not supported yet — convert to PNG or JPEG first');
|
|
299
|
+
}
|
|
300
|
+
if (!core.IMAGE_EXTENSIONS.has(ext)) {
|
|
301
|
+
throw new Error(`unsupported image type "${ext}" (supported: PNG, JPEG, GIF, BMP)`);
|
|
302
|
+
}
|
|
303
|
+
const target = await ctx.fs.resolve(filePath, {
|
|
304
|
+
...(cwd !== undefined ? { cwd } : {}),
|
|
305
|
+
signal: exec.signal
|
|
306
|
+
});
|
|
307
|
+
const info = await ctx.fs.stat(target, exec.signal);
|
|
308
|
+
if (!info) throw new Error('file not found');
|
|
309
|
+
if (info.type !== 'file') throw new Error('not a regular file');
|
|
310
|
+
const data = await ctx.fs.readBytes(target, exec.signal, BYTE_CAP);
|
|
311
|
+
const image = core.decodeImage(data, ext);
|
|
312
|
+
if (image.width * image.height > MAX_PIXELS) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`${image.width}x${image.height} exceeds the ${MAX_PIXELS}-pixel decode limit — downscale or crop the file first`
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
entry.width = image.width;
|
|
318
|
+
entry.height = image.height;
|
|
319
|
+
entry.path = target.displayPath;
|
|
320
|
+
processed += 1;
|
|
321
|
+
// `image` carries the decoded RGBA (for analyze/scan); `raw` keeps the
|
|
322
|
+
// original file bytes (core.ocrImage decodes internally, so it wants bytes).
|
|
323
|
+
decoded.push({ ...entry, image, raw: data, ext, info, target });
|
|
324
|
+
} catch (error) {
|
|
325
|
+
entry.type = 'unknown';
|
|
326
|
+
entry.error = error.message;
|
|
327
|
+
errors += 1;
|
|
328
|
+
items.push(entry);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (decoded.length === 0) {
|
|
333
|
+
const summary =
|
|
334
|
+
`image_batch: processed=0, errors=${errors} — none of the ${paths.length} file(s) could be decoded. ` +
|
|
335
|
+
'Check the paths/extensions (PNG/JPEG/GIF/BMP) and file existence.';
|
|
336
|
+
return { summary, items, processed, errors };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ------------------------------------------------------------------
|
|
340
|
+
// pass 2: classify + (optionally) OCR each decoded image
|
|
341
|
+
// ------------------------------------------------------------------
|
|
342
|
+
// analysis cache keyed by index
|
|
343
|
+
const analyses = new Map();
|
|
344
|
+
const analyze = (item) => {
|
|
345
|
+
if (!analyses.has(item.index)) {
|
|
346
|
+
analyses.set(
|
|
347
|
+
item.index,
|
|
348
|
+
core.analyzeImage(item.image.data, item.image.width, item.image.height, {
|
|
349
|
+
size: scanSize,
|
|
350
|
+
mode: 'auto',
|
|
351
|
+
region: undefined,
|
|
352
|
+
palette: 'auto',
|
|
353
|
+
pxPerCell: undefined
|
|
354
|
+
})
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
return analyses.get(item.index);
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
// --- decide whether to run full OCR ---
|
|
361
|
+
let fullOcr = false;
|
|
362
|
+
let ocrReason = null;
|
|
363
|
+
const results = new Map(); // index -> { lines, note }
|
|
364
|
+
const runOcr = async (item) => {
|
|
365
|
+
try {
|
|
366
|
+
const res = await ocrFn(item.raw, item.ext, { engine: 'windows' });
|
|
367
|
+
results.set(item.index, { lines: res?.lines ?? [] });
|
|
368
|
+
return results.get(item.index);
|
|
369
|
+
} catch (error) {
|
|
370
|
+
results.set(item.index, { lines: [], note: `OCR failed (${error.message.slice(0, 120)})` });
|
|
371
|
+
return results.get(item.index);
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
if (autoOcr === 'always') {
|
|
376
|
+
fullOcr = true;
|
|
377
|
+
ocrReason = "auto_ocr='always'";
|
|
378
|
+
} else if (autoOcr === 'never') {
|
|
379
|
+
fullOcr = false;
|
|
380
|
+
ocrReason = "auto_ocr='never' — no OCR run";
|
|
381
|
+
} else {
|
|
382
|
+
// 'auto': probe the first `probeFirst` decoded images
|
|
383
|
+
const probeCount = Math.min(probeFirst, decoded.length);
|
|
384
|
+
let textDenseProbe = 0;
|
|
385
|
+
for (let p = 0; p < probeCount; p += 1) {
|
|
386
|
+
if (exec.signal?.aborted) throw new Error('image_batch: cancelled');
|
|
387
|
+
const probeItem = decoded[p];
|
|
388
|
+
const res = await runOcr(probeItem);
|
|
389
|
+
if (nonEmptyLines(res) >= 2) textDenseProbe += 1;
|
|
390
|
+
}
|
|
391
|
+
if (textDenseProbe > 0) {
|
|
392
|
+
// first few are text -> treat the whole batch as documents
|
|
393
|
+
fullOcr = true;
|
|
394
|
+
ocrReason = `probed first ${probeCount}; ${textDenseProbe} are text-dense -> treated the batch as documents and ran OCR on everything`;
|
|
395
|
+
} else {
|
|
396
|
+
fullOcr = false;
|
|
397
|
+
ocrReason = `probed first ${probeCount}; none text-dense -> no full OCR (only individual text-dense images)`;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// --- per-item type + excerpts ---
|
|
402
|
+
for (const src of decoded) {
|
|
403
|
+
if (exec.signal?.aborted) throw new Error('image_batch: cancelled');
|
|
404
|
+
const analysis = analyze(src);
|
|
405
|
+
const structureHasHStripes = hasHorizontalStripes(analysis);
|
|
406
|
+
|
|
407
|
+
// determine OCR for this item
|
|
408
|
+
let ocrLines = 0;
|
|
409
|
+
let ocrText = '';
|
|
410
|
+
let note = src.note;
|
|
411
|
+
if (autoOcr === 'never') {
|
|
412
|
+
ocrLines = 0;
|
|
413
|
+
} else if (fullOcr) {
|
|
414
|
+
let res = results.get(src.index);
|
|
415
|
+
if (res === undefined) res = await runOcr(src);
|
|
416
|
+
ocrLines = nonEmptyLines(res);
|
|
417
|
+
ocrText = (res?.lines ?? []).map((l) => l.text).filter(Boolean).join(' ');
|
|
418
|
+
if (res?.note) note = note ? `${note}; ${res.note}` : res.note;
|
|
419
|
+
} else {
|
|
420
|
+
// not full OCR: run OCR on this image to see if IT is text-dense
|
|
421
|
+
let res = results.get(src.index);
|
|
422
|
+
if (res === undefined) res = await runOcr(src);
|
|
423
|
+
ocrLines = nonEmptyLines(res);
|
|
424
|
+
if (ocrLines >= 2) {
|
|
425
|
+
ocrText = (res?.lines ?? []).map((l) => l.text).filter(Boolean).join(' ');
|
|
426
|
+
}
|
|
427
|
+
if (res?.note) note = note ? `${note}; ${res.note}` : res.note;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const type = classifyType(analysis, ocrLines, structureHasHStripes);
|
|
431
|
+
const hasText = ocrLines >= 2;
|
|
432
|
+
let item = {
|
|
433
|
+
index: src.index,
|
|
434
|
+
path: src.path,
|
|
435
|
+
basename: src.basename,
|
|
436
|
+
width: src.width,
|
|
437
|
+
height: src.height,
|
|
438
|
+
type,
|
|
439
|
+
has_text: hasText,
|
|
440
|
+
recommendation: recommendFor(type),
|
|
441
|
+
...(note !== undefined ? { note } : {})
|
|
442
|
+
};
|
|
443
|
+
if (ocrText.length > 0) {
|
|
444
|
+
item.ocr_excerpt = ocrText.length > ocrLimitChars ? `${ocrText.slice(0, ocrLimitChars)}…` : ocrText;
|
|
445
|
+
}
|
|
446
|
+
if (previewMode === 'scan') {
|
|
447
|
+
const rendered = core.renderImageScan({ path: src.path, width: src.width, height: src.height, region: 'full', ...analysis });
|
|
448
|
+
item.scan_preview = truncateScan(rendered);
|
|
449
|
+
}
|
|
450
|
+
ctx.emit('fs/observed', src.target, { kind: 'present', version: src.info.version }, exec);
|
|
451
|
+
items.push(item);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// ------------------------------------------------------------------
|
|
455
|
+
// summary
|
|
456
|
+
// ------------------------------------------------------------------
|
|
457
|
+
const okItems = items.filter((it) => it.error === undefined || it.error === null);
|
|
458
|
+
const textCount = okItems.filter((it) => it.type === 'text' || it.type === 'table').length;
|
|
459
|
+
const photoCount = okItems.filter((it) => it.type === 'photo').length;
|
|
460
|
+
const blankCount = okItems.filter((it) => it.type === 'blank').length;
|
|
461
|
+
const scanCount = okItems.filter((it) => it.has_text).length;
|
|
462
|
+
|
|
463
|
+
const bigText = okItems
|
|
464
|
+
.filter((it) => it.type === 'text' || it.type === 'table')
|
|
465
|
+
.map((it) => it.index);
|
|
466
|
+
const bigPhoto = okItems.filter((it) => it.type === 'photo').map((it) => it.index);
|
|
467
|
+
|
|
468
|
+
let summary =
|
|
469
|
+
`image_batch: ${processed} decoded / ${errors} error(s) out of ${paths.length} path(s). ` +
|
|
470
|
+
`Types: ${textCount} text/table, ${photoCount} photo, ${blankCount} blank (rest mixed/unknown). ` +
|
|
471
|
+
`${scanCount} image(s) contain text. ` +
|
|
472
|
+
(fullOcr
|
|
473
|
+
? `Full OCR was run on the whole batch (${ocrReason}).`
|
|
474
|
+
: `Full OCR was NOT run — ${ocrReason}.`) +
|
|
475
|
+
` Next step: ${bigPhoto.length > 0 ? `likely-photo indices worth a VLM look: ${bigPhoto.join(', ')}; ` : ''}` +
|
|
476
|
+
`read the text-dense ones (${bigText.length > 0 ? bigText.join(', ') : 'none'}) with image_ocr and scan the chart/table indices (image_scan+image_sample); skip the blank ones.`;
|
|
477
|
+
|
|
478
|
+
// soft output cap: if too big, tell the model to go one-by-one
|
|
479
|
+
const renderedTotal = JSON.stringify({ summary, items, processed, errors }).length;
|
|
480
|
+
if (renderedTotal > SOFT_OUTPUT_LIMIT) {
|
|
481
|
+
summary +=
|
|
482
|
+
' [TRUNCATED] The full manifest is large — instead of relying on these truncated excerpts, call image_ocr / image_scan directly on the specific indices above.';
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
return { summary, items, processed, errors };
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Truncate a scan render to a compact width/lines budget. */
|
|
491
|
+
function truncateScan(rendered) {
|
|
492
|
+
const cut = 900;
|
|
493
|
+
if (rendered.length <= cut) return rendered;
|
|
494
|
+
const lines = rendered.split('\n');
|
|
495
|
+
const kept = [];
|
|
496
|
+
let total = 0;
|
|
497
|
+
for (const line of lines) {
|
|
498
|
+
if (total + line.length > cut) break;
|
|
499
|
+
kept.push(line);
|
|
500
|
+
total += line.length + 1;
|
|
501
|
+
}
|
|
502
|
+
const text = kept.join('\n');
|
|
503
|
+
return text.length < rendered.length ? `${text}\n… (scan preview truncated)` : text;
|
|
504
|
+
}
|