@sylphx/image-reader-mcp 0.1.0 → 0.1.2
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 +229 -16
- package/bin/image-reader-mcp +48 -0
- package/bin/native/image-reader-mcp-server +0 -0
- package/dist/doctor-cli.js +6705 -0
- package/package.json +22 -10
- package/src/doctor-cli.ts +18 -0
- package/src/doctor.ts +216 -0
- package/src/engine/rust-decode.ts +145 -0
- package/src/handlers/readImage.ts +255 -0
- package/src/mcp.ts +302 -0
- package/src/schemas/readImage.ts +170 -0
- package/src/sdk.ts +27 -0
- package/src/utils/agentMap.ts +93 -0
- package/src/utils/applyImageIntelligence.ts +68 -0
- package/src/utils/errors.ts +18 -0
- package/src/utils/layout.ts +114 -0
- package/src/utils/metadata.ts +108 -0
- package/src/utils/ocr.ts +279 -0
- package/src/utils/optionalLlm.ts +67 -0
- package/src/utils/palette.ts +36 -0
- package/src/utils/pathUtils.ts +40 -0
- package/src/utils/safety.ts +29 -0
- package/dist/index.js +0 -624
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic layout structure from OCR line boxes (no generative model).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type BBox = { x: number; y: number; width: number; height: number };
|
|
6
|
+
|
|
7
|
+
export type LayoutLine = {
|
|
8
|
+
text: string;
|
|
9
|
+
bbox: BBox;
|
|
10
|
+
confidence?: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type LayoutBlock = {
|
|
14
|
+
id: string;
|
|
15
|
+
kind: 'text_block';
|
|
16
|
+
text: string;
|
|
17
|
+
bbox: BBox;
|
|
18
|
+
line_count: number;
|
|
19
|
+
reading_order: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type ImageLayout = {
|
|
23
|
+
policy: string;
|
|
24
|
+
block_count: number;
|
|
25
|
+
blocks: LayoutBlock[];
|
|
26
|
+
full_text: string;
|
|
27
|
+
warnings: string[];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const unionBBox = (boxes: BBox[]): BBox => {
|
|
31
|
+
const minX = Math.min(...boxes.map((b) => b.x));
|
|
32
|
+
const minY = Math.min(...boxes.map((b) => b.y));
|
|
33
|
+
const maxX = Math.max(...boxes.map((b) => b.x + b.width));
|
|
34
|
+
const maxY = Math.max(...boxes.map((b) => b.y + b.height));
|
|
35
|
+
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const median = (values: number[], fallback: number): number => {
|
|
39
|
+
if (values.length === 0) return fallback;
|
|
40
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
41
|
+
return sorted[Math.floor(sorted.length / 2)] ?? fallback;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const shouldMerge = (prev: LayoutLine, next: LayoutLine, yGap: number, xSlop: number): boolean => {
|
|
45
|
+
const verticalClose = next.bbox.y - (prev.bbox.y + prev.bbox.height) <= yGap;
|
|
46
|
+
const prevRight = prev.bbox.x + prev.bbox.width;
|
|
47
|
+
const nextRight = next.bbox.x + next.bbox.width;
|
|
48
|
+
const horizontalOverlap =
|
|
49
|
+
Math.min(prevRight, nextRight) - Math.max(prev.bbox.x, next.bbox.x) > -xSlop;
|
|
50
|
+
return verticalClose && horizontalOverlap;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const toBlock = (lines: LayoutLine[], index: number): LayoutBlock => {
|
|
54
|
+
const ordered = [...lines].sort((a, b) => a.bbox.y - b.bbox.y || a.bbox.x - b.bbox.x);
|
|
55
|
+
const text = ordered
|
|
56
|
+
.map((l) => l.text.trim())
|
|
57
|
+
.filter(Boolean)
|
|
58
|
+
.join('\n');
|
|
59
|
+
return {
|
|
60
|
+
id: `block-${index + 1}`,
|
|
61
|
+
kind: 'text_block',
|
|
62
|
+
text,
|
|
63
|
+
bbox: unionBBox(ordered.map((l) => l.bbox)),
|
|
64
|
+
line_count: ordered.length,
|
|
65
|
+
reading_order: index + 1,
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export function buildLayoutFromOcrLines(lines: LayoutLine[]): ImageLayout {
|
|
70
|
+
if (lines.length === 0) {
|
|
71
|
+
return {
|
|
72
|
+
policy: 'ocr_line_cluster_v1',
|
|
73
|
+
block_count: 0,
|
|
74
|
+
blocks: [],
|
|
75
|
+
full_text: '',
|
|
76
|
+
warnings: ['No OCR lines available for layout clustering.'],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const sorted = [...lines].sort((a, b) => a.bbox.y - b.bbox.y || a.bbox.x - b.bbox.x);
|
|
81
|
+
const medianHeight = median(
|
|
82
|
+
sorted.map((l) => l.bbox.height).filter((h) => h > 0),
|
|
83
|
+
16
|
|
84
|
+
);
|
|
85
|
+
const yGap = Math.max(8, medianHeight * 1.4);
|
|
86
|
+
const xSlop = medianHeight * 2;
|
|
87
|
+
|
|
88
|
+
const clusters: LayoutLine[][] = [];
|
|
89
|
+
for (const line of sorted) {
|
|
90
|
+
const last = clusters[clusters.length - 1];
|
|
91
|
+
const prev = last?.[last.length - 1];
|
|
92
|
+
if (last && prev && shouldMerge(prev, line, yGap, xSlop)) {
|
|
93
|
+
last.push(line);
|
|
94
|
+
} else {
|
|
95
|
+
clusters.push([line]);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const blocks = clusters.map((cluster, index) => toBlock(cluster, index));
|
|
100
|
+
const warnings: string[] = [];
|
|
101
|
+
if (blocks.length === 1 && lines.length > 8) {
|
|
102
|
+
warnings.push(
|
|
103
|
+
'Layout collapsed to a single block; image may be dense continuous text or OCR line geometry is coarse.'
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
policy: 'ocr_line_cluster_v1',
|
|
109
|
+
block_count: blocks.length,
|
|
110
|
+
blocks,
|
|
111
|
+
full_text: blocks.map((b) => b.text).join('\n\n'),
|
|
112
|
+
warnings,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
const GPS_FIELD_PATTERN = /^(gps|geo|location|latitude|longitude|altitude|coordinates)/i;
|
|
2
|
+
|
|
3
|
+
const GPS_NESTED_KEYS = new Set([
|
|
4
|
+
'latitude',
|
|
5
|
+
'longitude',
|
|
6
|
+
'altitude',
|
|
7
|
+
'lat',
|
|
8
|
+
'lon',
|
|
9
|
+
'lng',
|
|
10
|
+
'GPSLatitude',
|
|
11
|
+
'GPSLongitude',
|
|
12
|
+
'GPSAltitude',
|
|
13
|
+
'GPSLatitudeRef',
|
|
14
|
+
'GPSLongitudeRef',
|
|
15
|
+
'GPSAltitudeRef',
|
|
16
|
+
'GPSDateStamp',
|
|
17
|
+
'GPSTimeStamp',
|
|
18
|
+
'GPSProcessingMethod',
|
|
19
|
+
'GPSAreaInformation',
|
|
20
|
+
'GPSDOP',
|
|
21
|
+
'GPSMapDatum',
|
|
22
|
+
'GPSDestLatitude',
|
|
23
|
+
'GPSDestLongitude',
|
|
24
|
+
'GPSDestBearing',
|
|
25
|
+
'GPSDestDistance',
|
|
26
|
+
'GPSHPositioningError',
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
const isGpsKey = (key: string): boolean => GPS_FIELD_PATTERN.test(key) || GPS_NESTED_KEYS.has(key);
|
|
30
|
+
|
|
31
|
+
const redactValue = (value: unknown): unknown => {
|
|
32
|
+
if (Array.isArray(value)) {
|
|
33
|
+
return value.map((item) => redactValue(item));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (value !== null && typeof value === 'object') {
|
|
37
|
+
return redactGpsFields(value as Record<string, unknown>).metadata;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return '[redacted]';
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const redactGpsFields = (
|
|
44
|
+
metadata: Record<string, unknown>
|
|
45
|
+
): { metadata: Record<string, unknown>; hadGps: boolean } => {
|
|
46
|
+
let hadGps = false;
|
|
47
|
+
const redacted: Record<string, unknown> = {};
|
|
48
|
+
|
|
49
|
+
for (const [key, value] of Object.entries(metadata)) {
|
|
50
|
+
if (key.toLowerCase() === 'gps' && value !== null && typeof value === 'object') {
|
|
51
|
+
hadGps = true;
|
|
52
|
+
redacted[key] = '[redacted]';
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (isGpsKey(key)) {
|
|
57
|
+
hadGps = true;
|
|
58
|
+
redacted[key] = redactValue(value);
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
63
|
+
const nested = redactGpsFields(value as Record<string, unknown>);
|
|
64
|
+
if (nested.hadGps) hadGps = true;
|
|
65
|
+
redacted[key] = nested.metadata;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
redacted[key] = value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return { metadata: redacted, hadGps };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const collectTrustWarnings = (
|
|
76
|
+
metadata: Record<string, unknown>,
|
|
77
|
+
hadGps: boolean
|
|
78
|
+
): string[] => {
|
|
79
|
+
const warnings: string[] = [];
|
|
80
|
+
|
|
81
|
+
if (hadGps) {
|
|
82
|
+
warnings.push(
|
|
83
|
+
'GPS coordinates were present in metadata and have been redacted from the response.'
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const software = metadata['Software'] ?? metadata['software'];
|
|
88
|
+
if (
|
|
89
|
+
typeof software === 'string' &&
|
|
90
|
+
/photoshop|gimp|ai|generative|midjourney|stable diffusion/i.test(software)
|
|
91
|
+
) {
|
|
92
|
+
warnings.push(
|
|
93
|
+
`EXIF Software field suggests possible editing or synthetic origin: "${software}".`
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const make = metadata['Make'] ?? metadata['make'];
|
|
98
|
+
const model = metadata['Model'] ?? metadata['model'];
|
|
99
|
+
if (
|
|
100
|
+
typeof make === 'string' &&
|
|
101
|
+
typeof model === 'string' &&
|
|
102
|
+
/unknown|fake|synthetic/i.test(`${make} ${model}`)
|
|
103
|
+
) {
|
|
104
|
+
warnings.push('Camera make/model metadata looks inconsistent or synthetic.');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return warnings;
|
|
108
|
+
};
|
package/src/utils/ocr.ts
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import type { OcrLine } from '../schemas/readImage.js';
|
|
3
|
+
|
|
4
|
+
const OCR_HEALTHCHECK_TIMEOUT_MS = 2_500;
|
|
5
|
+
const OCR_TIMEOUT_MS = 60_000;
|
|
6
|
+
|
|
7
|
+
export interface OcrWord {
|
|
8
|
+
text: string;
|
|
9
|
+
bbox: { x: number; y: number; width: number; height: number };
|
|
10
|
+
confidence?: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface OcrResult {
|
|
14
|
+
available: boolean;
|
|
15
|
+
skipped_reason?: string;
|
|
16
|
+
/** Adapter route for evidence contract honesty */
|
|
17
|
+
route?: string;
|
|
18
|
+
languages?: string[];
|
|
19
|
+
languages_warning?: string;
|
|
20
|
+
line_count?: number;
|
|
21
|
+
dropped_low_confidence?: number;
|
|
22
|
+
lines: OcrLine[];
|
|
23
|
+
words?: OcrWord[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ParseTesseractOptions {
|
|
27
|
+
minConfidence?: number;
|
|
28
|
+
includeWords?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Pure TSV parser — offline-testable evidence path for OCR bbox lines. */
|
|
32
|
+
export const parseTesseractTsv = (
|
|
33
|
+
raw: string,
|
|
34
|
+
options: ParseTesseractOptions = {}
|
|
35
|
+
): { lines: OcrLine[]; words: OcrWord[]; dropped_low_confidence: number } => {
|
|
36
|
+
const minConfidence = options.minConfidence ?? 0;
|
|
37
|
+
const includeWords = options.includeWords ?? false;
|
|
38
|
+
const linesRaw = raw.split(/\r?\n/).filter((line) => line.length > 0);
|
|
39
|
+
if (linesRaw.length <= 1) {
|
|
40
|
+
return { lines: [], words: [], dropped_low_confidence: 0 };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const rows = linesRaw.slice(1).map((line) => line.split('\t'));
|
|
44
|
+
const lineMap = new Map<
|
|
45
|
+
number,
|
|
46
|
+
{
|
|
47
|
+
words: Array<{
|
|
48
|
+
text: string;
|
|
49
|
+
left: number;
|
|
50
|
+
top: number;
|
|
51
|
+
width: number;
|
|
52
|
+
height: number;
|
|
53
|
+
conf: number;
|
|
54
|
+
}>;
|
|
55
|
+
}
|
|
56
|
+
>();
|
|
57
|
+
|
|
58
|
+
let dropped = 0;
|
|
59
|
+
const flatWords: OcrWord[] = [];
|
|
60
|
+
|
|
61
|
+
for (const columns of rows) {
|
|
62
|
+
if (columns.length < 12) continue;
|
|
63
|
+
const level = Number.parseInt(columns[0] ?? '', 10);
|
|
64
|
+
if (level !== 5) continue;
|
|
65
|
+
|
|
66
|
+
const text = columns[11]?.trim() ?? '';
|
|
67
|
+
if (text.length === 0) continue;
|
|
68
|
+
|
|
69
|
+
const lineNum = Number.parseInt(columns[4] ?? '', 10);
|
|
70
|
+
const left = Number.parseInt(columns[6] ?? '', 10);
|
|
71
|
+
const top = Number.parseInt(columns[7] ?? '', 10);
|
|
72
|
+
const width = Number.parseInt(columns[8] ?? '', 10);
|
|
73
|
+
const height = Number.parseInt(columns[9] ?? '', 10);
|
|
74
|
+
const conf = Number.parseFloat(columns[10] ?? '');
|
|
75
|
+
|
|
76
|
+
if (!Number.isFinite(lineNum) || !Number.isFinite(left) || !Number.isFinite(top)) continue;
|
|
77
|
+
|
|
78
|
+
const confVal = Number.isFinite(conf) ? conf : 0;
|
|
79
|
+
if (confVal < minConfidence) {
|
|
80
|
+
dropped += 1;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const bucket = lineMap.get(lineNum) ?? { words: [] };
|
|
85
|
+
bucket.words.push({
|
|
86
|
+
text,
|
|
87
|
+
left,
|
|
88
|
+
top,
|
|
89
|
+
width: Number.isFinite(width) ? width : 0,
|
|
90
|
+
height: Number.isFinite(height) ? height : 0,
|
|
91
|
+
conf: confVal,
|
|
92
|
+
});
|
|
93
|
+
lineMap.set(lineNum, bucket);
|
|
94
|
+
|
|
95
|
+
if (includeWords) {
|
|
96
|
+
flatWords.push({
|
|
97
|
+
text,
|
|
98
|
+
bbox: {
|
|
99
|
+
x: left,
|
|
100
|
+
y: top,
|
|
101
|
+
width: Number.isFinite(width) ? width : 0,
|
|
102
|
+
height: Number.isFinite(height) ? height : 0,
|
|
103
|
+
},
|
|
104
|
+
confidence: confVal,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const ocrLines: OcrLine[] = [];
|
|
110
|
+
|
|
111
|
+
for (const bucket of lineMap.values()) {
|
|
112
|
+
if (bucket.words.length === 0) continue;
|
|
113
|
+
|
|
114
|
+
const sorted = [...bucket.words].sort((a, b) => a.left - b.left);
|
|
115
|
+
const text = sorted
|
|
116
|
+
.map((word) => word.text)
|
|
117
|
+
.join(' ')
|
|
118
|
+
.trim();
|
|
119
|
+
if (text.length === 0) continue;
|
|
120
|
+
|
|
121
|
+
const left = Math.min(...sorted.map((word) => word.left));
|
|
122
|
+
const top = Math.min(...sorted.map((word) => word.top));
|
|
123
|
+
const right = Math.max(...sorted.map((word) => word.left + word.width));
|
|
124
|
+
const bottom = Math.max(...sorted.map((word) => word.top + word.height));
|
|
125
|
+
const confidenceValues = sorted.map((word) => word.conf).filter((value) => value >= 0);
|
|
126
|
+
const confidence =
|
|
127
|
+
confidenceValues.length > 0
|
|
128
|
+
? confidenceValues.reduce((sum, value) => sum + value, 0) / confidenceValues.length
|
|
129
|
+
: undefined;
|
|
130
|
+
|
|
131
|
+
ocrLines.push({
|
|
132
|
+
text,
|
|
133
|
+
bbox: {
|
|
134
|
+
x: left,
|
|
135
|
+
y: top,
|
|
136
|
+
width: Math.max(0, right - left),
|
|
137
|
+
height: Math.max(0, bottom - top),
|
|
138
|
+
},
|
|
139
|
+
...(confidence !== undefined ? { confidence } : {}),
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return {
|
|
144
|
+
lines: ocrLines.sort((a, b) => a.bbox.y - b.bbox.y || a.bbox.x - b.bbox.x),
|
|
145
|
+
words: includeWords ? flatWords.sort((a, b) => a.bbox.y - b.bbox.y || a.bbox.x - b.bbox.x) : [],
|
|
146
|
+
dropped_low_confidence: dropped,
|
|
147
|
+
};
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
export const isTesseractAvailable = (): boolean => {
|
|
151
|
+
const result = spawnSync('tesseract', ['--version'], {
|
|
152
|
+
timeout: OCR_HEALTHCHECK_TIMEOUT_MS,
|
|
153
|
+
windowsHide: true,
|
|
154
|
+
stdio: 'ignore',
|
|
155
|
+
});
|
|
156
|
+
return result.status === 0;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/** List installed Tesseract traineddata languages (honest empty when unavailable). */
|
|
160
|
+
export const listTesseractLanguages = (): {
|
|
161
|
+
available: boolean;
|
|
162
|
+
languages: string[];
|
|
163
|
+
warning?: string;
|
|
164
|
+
} => {
|
|
165
|
+
if (!isTesseractAvailable()) {
|
|
166
|
+
return {
|
|
167
|
+
available: false,
|
|
168
|
+
languages: [],
|
|
169
|
+
warning: 'Tesseract is not installed or not available on PATH.',
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
const result = spawnSync('tesseract', ['--list-langs'], {
|
|
173
|
+
encoding: 'utf8',
|
|
174
|
+
timeout: 5_000,
|
|
175
|
+
windowsHide: true,
|
|
176
|
+
});
|
|
177
|
+
if (result.status !== 0) {
|
|
178
|
+
const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : '';
|
|
179
|
+
return {
|
|
180
|
+
available: false,
|
|
181
|
+
languages: [],
|
|
182
|
+
warning: stderr || 'tesseract --list-langs failed',
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
const out = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
|
186
|
+
const languages = out
|
|
187
|
+
.split(/\r?\n/)
|
|
188
|
+
.map((l) => l.trim())
|
|
189
|
+
.filter(
|
|
190
|
+
(l) => l && !l.toLowerCase().includes('list of available languages') && !l.startsWith('Error')
|
|
191
|
+
);
|
|
192
|
+
return { available: true, languages };
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export type RunOcrOptions = {
|
|
196
|
+
languages?: string[];
|
|
197
|
+
minConfidence?: number;
|
|
198
|
+
includeWords?: boolean;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
export const runTesseractOcr = (
|
|
202
|
+
imagePath: string,
|
|
203
|
+
languagesOrOptions: string[] | RunOcrOptions = ['eng']
|
|
204
|
+
): OcrResult => {
|
|
205
|
+
const options: RunOcrOptions = Array.isArray(languagesOrOptions)
|
|
206
|
+
? { languages: languagesOrOptions }
|
|
207
|
+
: languagesOrOptions;
|
|
208
|
+
const languages = options.languages?.length ? options.languages : ['eng'];
|
|
209
|
+
const minConfidence = options.minConfidence ?? 0;
|
|
210
|
+
const includeWords = options.includeWords ?? false;
|
|
211
|
+
|
|
212
|
+
if (!isTesseractAvailable()) {
|
|
213
|
+
return {
|
|
214
|
+
available: false,
|
|
215
|
+
skipped_reason: 'Tesseract is not installed or not available on PATH.',
|
|
216
|
+
route: 'tesseract_tsv',
|
|
217
|
+
languages,
|
|
218
|
+
lines: [],
|
|
219
|
+
line_count: 0,
|
|
220
|
+
dropped_low_confidence: 0,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const installed = listTesseractLanguages();
|
|
225
|
+
const missingLangs = languages.filter(
|
|
226
|
+
(lang) => installed.available && !installed.languages.includes(lang)
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const languageArg = languages.join('+');
|
|
230
|
+
const result = spawnSync('tesseract', [imagePath, 'stdout', '-l', languageArg, 'tsv'], {
|
|
231
|
+
encoding: 'utf8',
|
|
232
|
+
timeout: OCR_TIMEOUT_MS,
|
|
233
|
+
windowsHide: true,
|
|
234
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
if (result.error) {
|
|
238
|
+
return {
|
|
239
|
+
available: false,
|
|
240
|
+
skipped_reason: `Tesseract failed to start: ${result.error.message}`,
|
|
241
|
+
route: 'tesseract_tsv',
|
|
242
|
+
languages,
|
|
243
|
+
lines: [],
|
|
244
|
+
line_count: 0,
|
|
245
|
+
dropped_low_confidence: 0,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (result.status !== 0) {
|
|
250
|
+
const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : '';
|
|
251
|
+
return {
|
|
252
|
+
available: false,
|
|
253
|
+
skipped_reason:
|
|
254
|
+
stderr.length > 0 ? stderr : `Tesseract exited with status ${String(result.status)}.`,
|
|
255
|
+
route: 'tesseract_tsv',
|
|
256
|
+
languages,
|
|
257
|
+
lines: [],
|
|
258
|
+
line_count: 0,
|
|
259
|
+
dropped_low_confidence: 0,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const stdout = typeof result.stdout === 'string' ? result.stdout : '';
|
|
264
|
+
const parsed = parseTesseractTsv(stdout, { minConfidence, includeWords });
|
|
265
|
+
return {
|
|
266
|
+
available: true,
|
|
267
|
+
route: 'tesseract_tsv',
|
|
268
|
+
languages,
|
|
269
|
+
lines: parsed.lines,
|
|
270
|
+
line_count: parsed.lines.length,
|
|
271
|
+
dropped_low_confidence: parsed.dropped_low_confidence,
|
|
272
|
+
...(missingLangs.length
|
|
273
|
+
? {
|
|
274
|
+
languages_warning: `Requested OCR language(s) not listed by tesseract --list-langs: ${missingLangs.join(', ')}. Installed: ${installed.languages.join(', ') || '(none)'}.`,
|
|
275
|
+
}
|
|
276
|
+
: {}),
|
|
277
|
+
...(includeWords ? { words: parsed.words } : {}),
|
|
278
|
+
};
|
|
279
|
+
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional local/remote vision caption — OFF by default (local-first).
|
|
3
|
+
* Set IRIS_OPTIONAL_LLM_URL to a POST endpoint that accepts JSON
|
|
4
|
+
* { path, mime, purpose: "image_caption" } and returns { caption: string }.
|
|
5
|
+
* Never treated as authority over OCR/layout evidence.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type OptionalLlmResult = {
|
|
9
|
+
available: boolean;
|
|
10
|
+
skipped_reason?: string;
|
|
11
|
+
route?: string;
|
|
12
|
+
caption?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export async function maybeOptionalImageCaption(input: {
|
|
16
|
+
path: string;
|
|
17
|
+
mime: string;
|
|
18
|
+
enabled: boolean;
|
|
19
|
+
}): Promise<OptionalLlmResult> {
|
|
20
|
+
if (!input.enabled) {
|
|
21
|
+
return { available: false, skipped_reason: 'include_optional_llm is false (default).' };
|
|
22
|
+
}
|
|
23
|
+
const url = process.env['IRIS_OPTIONAL_LLM_URL'];
|
|
24
|
+
if (!url) {
|
|
25
|
+
return {
|
|
26
|
+
available: false,
|
|
27
|
+
skipped_reason:
|
|
28
|
+
'IRIS_OPTIONAL_LLM_URL is not set. Local OCR/layout remain the evidence authority.',
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch(url, {
|
|
34
|
+
method: 'POST',
|
|
35
|
+
headers: { 'content-type': 'application/json' },
|
|
36
|
+
body: JSON.stringify({
|
|
37
|
+
path: input.path,
|
|
38
|
+
mime: input.mime,
|
|
39
|
+
purpose: 'image_caption',
|
|
40
|
+
}),
|
|
41
|
+
signal: AbortSignal.timeout(15_000),
|
|
42
|
+
});
|
|
43
|
+
if (!res.ok) {
|
|
44
|
+
return {
|
|
45
|
+
available: false,
|
|
46
|
+
skipped_reason: `optional LLM HTTP ${res.status}`,
|
|
47
|
+
route: url,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const body = (await res.json()) as { caption?: string };
|
|
51
|
+
if (!body.caption || typeof body.caption !== 'string') {
|
|
52
|
+
return {
|
|
53
|
+
available: false,
|
|
54
|
+
skipped_reason: 'optional LLM response missing caption string',
|
|
55
|
+
route: url,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
return {
|
|
59
|
+
available: true,
|
|
60
|
+
route: `optional-llm:${url}`,
|
|
61
|
+
caption: body.caption.slice(0, 4000),
|
|
62
|
+
};
|
|
63
|
+
} catch (error: unknown) {
|
|
64
|
+
const message = error instanceof Error ? error.message : 'optional LLM failed';
|
|
65
|
+
return { available: false, skipped_reason: message, route: url };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import sharp from 'sharp';
|
|
2
|
+
|
|
3
|
+
/** Cheap local palette sample via sharp stats (not ML segmentation). */
|
|
4
|
+
export async function samplePalette(
|
|
5
|
+
filePath: string,
|
|
6
|
+
maxColors = 5
|
|
7
|
+
): Promise<Array<{ hex: string; approx_share: number }>> {
|
|
8
|
+
const image = sharp(filePath, { failOn: 'none' }).resize(64, 64, { fit: 'inside' });
|
|
9
|
+
const stats = await image.stats();
|
|
10
|
+
const dominant = stats.dominant;
|
|
11
|
+
if (!dominant) return [];
|
|
12
|
+
|
|
13
|
+
const hex = (r: number, g: number, b: number) =>
|
|
14
|
+
`#${[r, g, b]
|
|
15
|
+
.map((v) =>
|
|
16
|
+
Math.max(0, Math.min(255, Math.round(v)))
|
|
17
|
+
.toString(16)
|
|
18
|
+
.padStart(2, '0')
|
|
19
|
+
)
|
|
20
|
+
.join('')}`;
|
|
21
|
+
|
|
22
|
+
const colors: Array<{ hex: string; approx_share: number }> = [
|
|
23
|
+
{ hex: hex(dominant.r, dominant.g, dominant.b), approx_share: 0.55 },
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
// Channel means as a second honest swatch (not true multi-color clustering).
|
|
27
|
+
const ch = stats.channels;
|
|
28
|
+
if (ch && ch.length >= 3) {
|
|
29
|
+
colors.push({
|
|
30
|
+
hex: hex(ch[0]?.mean ?? dominant.r, ch[1]?.mean ?? dominant.g, ch[2]?.mean ?? dominant.b),
|
|
31
|
+
approx_share: 0.45,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return colors.slice(0, maxColors);
|
|
36
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { ErrorCode, ImageError } from './errors.js';
|
|
4
|
+
|
|
5
|
+
export const PROJECT_ROOT = process.cwd();
|
|
6
|
+
|
|
7
|
+
const canonicalize = (p: string): string => {
|
|
8
|
+
try {
|
|
9
|
+
return fs.realpathSync(p);
|
|
10
|
+
} catch (err: unknown) {
|
|
11
|
+
if (
|
|
12
|
+
typeof err === 'object' &&
|
|
13
|
+
err !== null &&
|
|
14
|
+
'code' in err &&
|
|
15
|
+
(err.code === 'ENOENT' || err.code === 'ENOTDIR')
|
|
16
|
+
) {
|
|
17
|
+
const parent = path.dirname(p);
|
|
18
|
+
if (parent === p) return p;
|
|
19
|
+
return path.join(canonicalize(parent), path.basename(p));
|
|
20
|
+
}
|
|
21
|
+
throw err;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolves a user-provided path, accepting both absolute and relative paths.
|
|
27
|
+
* Relative paths are resolved against the current working directory.
|
|
28
|
+
*/
|
|
29
|
+
export const resolvePath = (userPath: string): string => {
|
|
30
|
+
if (typeof userPath !== 'string') {
|
|
31
|
+
throw new ImageError(ErrorCode.InvalidParams, 'Path must be a string.');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const normalizedUserPath = path.normalize(userPath);
|
|
35
|
+
const resolved = path.isAbsolute(normalizedUserPath)
|
|
36
|
+
? normalizedUserPath
|
|
37
|
+
: path.resolve(PROJECT_ROOT, normalizedUserPath);
|
|
38
|
+
|
|
39
|
+
return canonicalize(resolved);
|
|
40
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { ErrorCode, ImageError } from './errors.js';
|
|
2
|
+
|
|
3
|
+
export const IMAGE_SAFETY_LIMITS = {
|
|
4
|
+
maxFileBytes: 32 * 1024 * 1024,
|
|
5
|
+
maxPixels: 64 * 1024 * 1024,
|
|
6
|
+
} as const;
|
|
7
|
+
|
|
8
|
+
export function validateImageSafety(input: {
|
|
9
|
+
fileSizeBytes: number;
|
|
10
|
+
width?: number | undefined;
|
|
11
|
+
height?: number | undefined;
|
|
12
|
+
}): void {
|
|
13
|
+
if (input.fileSizeBytes > IMAGE_SAFETY_LIMITS.maxFileBytes) {
|
|
14
|
+
throw new ImageError(
|
|
15
|
+
ErrorCode.InvalidRequest,
|
|
16
|
+
`Image exceeds the ${IMAGE_SAFETY_LIMITS.maxFileBytes} byte safety limit.`
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
if (input.width !== undefined && input.height !== undefined) {
|
|
21
|
+
const pixels = input.width * input.height;
|
|
22
|
+
if (pixels > IMAGE_SAFETY_LIMITS.maxPixels) {
|
|
23
|
+
throw new ImageError(
|
|
24
|
+
ErrorCode.InvalidRequest,
|
|
25
|
+
`Image exceeds the ${IMAGE_SAFETY_LIMITS.maxPixels} pixel safety budget (${input.width}x${input.height}).`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|