@sylphx/image-reader-mcp 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/dist/doctor-cli.js +3 -4
- package/package.json +1 -1
- package/src/handlers/readImage.ts +5 -1
- package/src/schemas/readImage.ts +72 -0
- package/src/utils/agentMap.ts +94 -0
- package/src/utils/applyImageIntelligence.ts +84 -0
- package/src/utils/layout.ts +161 -0
- package/src/utils/ocr.ts +114 -34
- package/src/utils/optionalLlm.ts +228 -0
- package/src/utils/palette.ts +36 -0
package/README.md
CHANGED
|
@@ -36,6 +36,15 @@ Each instrument is an independent repository (marketplace + stars).
|
|
|
36
36
|
---
|
|
37
37
|
|
|
38
38
|
|
|
39
|
+
|
|
40
|
+
## Read images (not vague vision)
|
|
41
|
+
|
|
42
|
+
Iris is **local-first**: geometry + OCR + **layout blocks** + **agent_map** so a text-only agent can understand picture architecture without a vision model.
|
|
43
|
+
|
|
44
|
+
Spec: [docs/specs/agent-image-read-contract.md](docs/specs/agent-image-read-contract.md)
|
|
45
|
+
|
|
46
|
+
**Local-first frontier:** Rust decode, Tesseract native layout (no npm ML), optional Ollama VLM; cloud URL optional. Zero API key.
|
|
47
|
+
|
|
39
48
|
## Product docs
|
|
40
49
|
|
|
41
50
|
| Doc | Purpose |
|
package/dist/doctor-cli.js
CHANGED
|
@@ -6501,9 +6501,11 @@ var listTesseractLanguages = () => {
|
|
|
6501
6501
|
}
|
|
6502
6502
|
const result = spawnSync("tesseract", ["--list-langs"], {
|
|
6503
6503
|
encoding: "utf8",
|
|
6504
|
-
timeout:
|
|
6504
|
+
timeout: OCR_HEALTHCHECK_TIMEOUT_MS,
|
|
6505
6505
|
windowsHide: true
|
|
6506
6506
|
});
|
|
6507
|
+
const stdout = typeof result.stdout === "string" ? result.stdout : "";
|
|
6508
|
+
const languages = stdout.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.toLowerCase().includes("list of available"));
|
|
6507
6509
|
if (result.status !== 0) {
|
|
6508
6510
|
const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
|
|
6509
6511
|
return {
|
|
@@ -6512,9 +6514,6 @@ var listTesseractLanguages = () => {
|
|
|
6512
6514
|
warning: stderr || "tesseract --list-langs failed"
|
|
6513
6515
|
};
|
|
6514
6516
|
}
|
|
6515
|
-
const out = `${result.stdout ?? ""}
|
|
6516
|
-
${result.stderr ?? ""}`;
|
|
6517
|
-
const languages = out.split(/\r?\n/).map((l) => l.trim()).filter((l) => l && !l.toLowerCase().includes("list of available languages") && !l.startsWith("Error"));
|
|
6518
6517
|
return { available: true, languages };
|
|
6519
6518
|
};
|
|
6520
6519
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sylphx/image-reader-mcp",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"mcpName": "io.github.SylphxAI/image-reader-mcp",
|
|
5
5
|
"description": "Iris \u2014 Evidence-first image reading for AI agents \u2014 metadata, OCR text, regions, and citeable evidence without generative LLM.",
|
|
6
6
|
"type": "module",
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from '../engine/rust-decode.js';
|
|
10
10
|
import { text, tool, toolError } from '../mcp.js';
|
|
11
11
|
import { type AgentMediaTwin, readImageArgsSchema } from '../schemas/readImage.js';
|
|
12
|
+
import { applyImageIntelligence } from '../utils/applyImageIntelligence.js';
|
|
12
13
|
import { ErrorCode, ImageError } from '../utils/errors.js';
|
|
13
14
|
import { collectTrustWarnings, redactGpsFields } from '../utils/metadata.js';
|
|
14
15
|
import { runTesseractOcr } from '../utils/ocr.js';
|
|
@@ -74,7 +75,7 @@ const readMetadata = async (
|
|
|
74
75
|
|
|
75
76
|
export const readImage = tool()
|
|
76
77
|
.description(
|
|
77
|
-
'Evidence-first image reader. Returns
|
|
78
|
+
'Evidence-first image reader for agents (read, not vague vision). Returns Agent Media Twin: geometry, metadata, OCR lines/words, layout blocks, text agent_map, optional palette, optional non-authority LLM caption. Local-first; generative path off by default.'
|
|
78
79
|
)
|
|
79
80
|
.input(readImageArgsSchema)
|
|
80
81
|
.handler(async ({ input }) => {
|
|
@@ -201,9 +202,12 @@ export const readImage = tool()
|
|
|
201
202
|
? { languages_warning: ocr.languages_warning }
|
|
202
203
|
: {}),
|
|
203
204
|
...(ocr.words !== undefined ? { words: ocr.words } : {}),
|
|
205
|
+
...(ocr.native_blocks !== undefined ? { native_blocks: ocr.native_blocks } : {}),
|
|
204
206
|
};
|
|
205
207
|
}
|
|
206
208
|
|
|
209
|
+
twin = await applyImageIntelligence(twin, resolvedPath, input, includeOcr);
|
|
210
|
+
|
|
207
211
|
if (input.region !== undefined) {
|
|
208
212
|
if (!useRustDecode) {
|
|
209
213
|
throw new ImageError(
|
package/src/schemas/readImage.ts
CHANGED
|
@@ -59,6 +59,28 @@ export const readImageArgsSchema = z.object({
|
|
|
59
59
|
.positive()
|
|
60
60
|
.optional()
|
|
61
61
|
.describe('Maximum width or height when resizing the cropped region for evidence.'),
|
|
62
|
+
include_layout: z
|
|
63
|
+
.boolean()
|
|
64
|
+
.optional()
|
|
65
|
+
.describe(
|
|
66
|
+
'When OCR lines exist, cluster them into reading-order text blocks (image architecture). Defaults to true when include_ocr is true.'
|
|
67
|
+
),
|
|
68
|
+
include_agent_map: z
|
|
69
|
+
.boolean()
|
|
70
|
+
.optional()
|
|
71
|
+
.describe(
|
|
72
|
+
'Return a text-only agent_map so non-vision models can read image structure. Defaults to true.'
|
|
73
|
+
),
|
|
74
|
+
include_palette: z
|
|
75
|
+
.boolean()
|
|
76
|
+
.optional()
|
|
77
|
+
.describe('Sample an approximate local color palette via sharp (not ML). Defaults to false.'),
|
|
78
|
+
include_optional_llm: z
|
|
79
|
+
.boolean()
|
|
80
|
+
.optional()
|
|
81
|
+
.describe(
|
|
82
|
+
'Optional local frontier caption via Ollama vision models or IRIS_OPTIONAL_LLM_URL. Off by default; never authority over OCR/layout evidence.'
|
|
83
|
+
),
|
|
62
84
|
});
|
|
63
85
|
|
|
64
86
|
export const agentMediaTwinSchema = z.object({
|
|
@@ -88,6 +110,17 @@ export const agentMediaTwinSchema = z.object({
|
|
|
88
110
|
})
|
|
89
111
|
)
|
|
90
112
|
.optional(),
|
|
113
|
+
native_blocks: z
|
|
114
|
+
.array(
|
|
115
|
+
z.object({
|
|
116
|
+
id: z.string(),
|
|
117
|
+
kind: z.enum(['block', 'paragraph']),
|
|
118
|
+
text: z.string(),
|
|
119
|
+
bbox: boundingBoxSchema,
|
|
120
|
+
confidence: z.number().optional(),
|
|
121
|
+
})
|
|
122
|
+
)
|
|
123
|
+
.optional(),
|
|
91
124
|
})
|
|
92
125
|
.optional(),
|
|
93
126
|
region_evidence: z
|
|
@@ -101,6 +134,45 @@ export const agentMediaTwinSchema = z.object({
|
|
|
101
134
|
image_base64: z.string().optional(),
|
|
102
135
|
})
|
|
103
136
|
.optional(),
|
|
137
|
+
layout: z
|
|
138
|
+
.object({
|
|
139
|
+
policy: z.string(),
|
|
140
|
+
block_count: z.number().int().nonnegative(),
|
|
141
|
+
blocks: z.array(
|
|
142
|
+
z.object({
|
|
143
|
+
id: z.string(),
|
|
144
|
+
kind: z.literal('text_block'),
|
|
145
|
+
text: z.string(),
|
|
146
|
+
bbox: boundingBoxSchema,
|
|
147
|
+
line_count: z.number().int().nonnegative(),
|
|
148
|
+
reading_order: z.number().int().positive(),
|
|
149
|
+
})
|
|
150
|
+
),
|
|
151
|
+
full_text: z.string(),
|
|
152
|
+
warnings: z.array(z.string()),
|
|
153
|
+
})
|
|
154
|
+
.optional(),
|
|
155
|
+
agent_map: z
|
|
156
|
+
.object({
|
|
157
|
+
policy: z.string(),
|
|
158
|
+
filename: z.string(),
|
|
159
|
+
mime: z.string(),
|
|
160
|
+
dimensions: imageDimensionsSchema,
|
|
161
|
+
outline: z.string(),
|
|
162
|
+
text_present: z.boolean(),
|
|
163
|
+
block_count: z.number().int().nonnegative(),
|
|
164
|
+
palette: z.array(z.object({ hex: z.string(), approx_share: z.number() })).optional(),
|
|
165
|
+
optional_llm: z
|
|
166
|
+
.object({
|
|
167
|
+
available: z.boolean(),
|
|
168
|
+
skipped_reason: z.string().optional(),
|
|
169
|
+
route: z.string().optional(),
|
|
170
|
+
caption: z.string().optional(),
|
|
171
|
+
model: z.string().optional(),
|
|
172
|
+
})
|
|
173
|
+
.optional(),
|
|
174
|
+
})
|
|
175
|
+
.optional(),
|
|
104
176
|
trust_warnings: z.array(z.string()),
|
|
105
177
|
});
|
|
106
178
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text-only Agent Map: lets a non-vision model "read" the image architecture.
|
|
3
|
+
* Deterministic, local-first. Optional LLM captions are layered elsewhere.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ImageLayout } from './layout.js';
|
|
7
|
+
|
|
8
|
+
export type AgentImageMap = {
|
|
9
|
+
policy: 'agent_image_map_v1';
|
|
10
|
+
filename: string;
|
|
11
|
+
mime: string;
|
|
12
|
+
dimensions: { width: number; height: number };
|
|
13
|
+
/** Human/agent readable outline (markdown-ish, not prose hallucination). */
|
|
14
|
+
outline: string;
|
|
15
|
+
text_present: boolean;
|
|
16
|
+
block_count: number;
|
|
17
|
+
palette?: Array<{ hex: string; approx_share: number }>;
|
|
18
|
+
optional_llm?: {
|
|
19
|
+
available: boolean;
|
|
20
|
+
skipped_reason?: string;
|
|
21
|
+
route?: string;
|
|
22
|
+
caption?: string;
|
|
23
|
+
model?: string;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function buildAgentImageMap(input: {
|
|
28
|
+
filename: string;
|
|
29
|
+
mime: string;
|
|
30
|
+
dimensions: { width: number; height: number };
|
|
31
|
+
layout?: ImageLayout;
|
|
32
|
+
ocrLineCount?: number;
|
|
33
|
+
palette?: Array<{ hex: string; approx_share: number }>;
|
|
34
|
+
optionalLlm?: AgentImageMap['optional_llm'];
|
|
35
|
+
}): AgentImageMap {
|
|
36
|
+
const { width, height } = input.dimensions;
|
|
37
|
+
const blocks = input.layout?.blocks ?? [];
|
|
38
|
+
const textPresent = (input.ocrLineCount ?? 0) > 0 || blocks.length > 0;
|
|
39
|
+
|
|
40
|
+
const lines: string[] = [
|
|
41
|
+
`# Image map: ${input.filename}`,
|
|
42
|
+
`- mime: ${input.mime}`,
|
|
43
|
+
`- size: ${width}×${height}px`,
|
|
44
|
+
`- text_present: ${textPresent}`,
|
|
45
|
+
`- layout_blocks: ${blocks.length}`,
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
if (input.palette && input.palette.length > 0) {
|
|
49
|
+
lines.push(
|
|
50
|
+
`- palette: ${input.palette.map((p) => `${p.hex}~${Math.round(p.approx_share * 100)}%`).join(', ')}`
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (blocks.length > 0) {
|
|
55
|
+
lines.push('', '## Reading-order text blocks');
|
|
56
|
+
for (const b of blocks) {
|
|
57
|
+
const preview = b.text.replace(/\s+/g, ' ').slice(0, 160);
|
|
58
|
+
lines.push(
|
|
59
|
+
`### ${b.id} (order ${b.reading_order}, ${b.line_count} lines)`,
|
|
60
|
+
`- bbox: x=${b.bbox.x} y=${b.bbox.y} w=${b.bbox.width} h=${b.bbox.height}`,
|
|
61
|
+
`- text: ${preview}${b.text.length > 160 ? '…' : ''}`,
|
|
62
|
+
''
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
} else if (!textPresent) {
|
|
66
|
+
lines.push(
|
|
67
|
+
'',
|
|
68
|
+
'## Notes',
|
|
69
|
+
'- No OCR text recovered. Enable include_ocr for text architecture.',
|
|
70
|
+
'- Use crop_region / image_probe for geometry; optional LLM caption only if configured.'
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (input.optionalLlm?.available && input.optionalLlm.caption) {
|
|
75
|
+
lines.push('', '## Optional LLM caption (non-authority)', input.optionalLlm.caption);
|
|
76
|
+
} else if (input.optionalLlm && !input.optionalLlm.available) {
|
|
77
|
+
lines.push(
|
|
78
|
+
'',
|
|
79
|
+
`## Optional LLM: skipped (${input.optionalLlm.skipped_reason ?? 'not configured'})`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
policy: 'agent_image_map_v1',
|
|
85
|
+
filename: input.filename,
|
|
86
|
+
mime: input.mime,
|
|
87
|
+
dimensions: input.dimensions,
|
|
88
|
+
outline: lines.join('\n'),
|
|
89
|
+
text_present: textPresent,
|
|
90
|
+
block_count: blocks.length,
|
|
91
|
+
...(input.palette ? { palette: input.palette } : {}),
|
|
92
|
+
...(input.optionalLlm ? { optional_llm: input.optionalLlm } : {}),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { AgentMediaTwin, ReadImageArgs } from '../schemas/readImage.js';
|
|
2
|
+
import { buildAgentImageMap } from './agentMap.js';
|
|
3
|
+
import { buildBestEffortLayout } from './layout.js';
|
|
4
|
+
import { maybeOptionalImageCaption } from './optionalLlm.js';
|
|
5
|
+
import { samplePalette } from './palette.js';
|
|
6
|
+
|
|
7
|
+
/** Attach layout, palette, optional LLM, and agent_map to an Agent Media Twin. */
|
|
8
|
+
export async function applyImageIntelligence(
|
|
9
|
+
twin: AgentMediaTwin,
|
|
10
|
+
resolvedPath: string,
|
|
11
|
+
input: ReadImageArgs,
|
|
12
|
+
includeOcr: boolean
|
|
13
|
+
): Promise<AgentMediaTwin> {
|
|
14
|
+
const next: AgentMediaTwin = {
|
|
15
|
+
...twin,
|
|
16
|
+
trust_warnings: [...twin.trust_warnings],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const includeLayout = input.include_layout ?? includeOcr;
|
|
20
|
+
if (includeLayout && next.ocr?.lines && next.ocr.lines.length > 0) {
|
|
21
|
+
const ocrAny = next.ocr as {
|
|
22
|
+
lines: Array<{
|
|
23
|
+
text: string;
|
|
24
|
+
bbox: { x: number; y: number; width: number; height: number };
|
|
25
|
+
confidence?: number;
|
|
26
|
+
}>;
|
|
27
|
+
native_blocks?: Array<{
|
|
28
|
+
id: string;
|
|
29
|
+
kind: 'block' | 'paragraph';
|
|
30
|
+
text: string;
|
|
31
|
+
bbox: { x: number; y: number; width: number; height: number };
|
|
32
|
+
confidence?: number;
|
|
33
|
+
}>;
|
|
34
|
+
};
|
|
35
|
+
const layout = buildBestEffortLayout({
|
|
36
|
+
lines: ocrAny.lines.map((line) => ({
|
|
37
|
+
text: line.text,
|
|
38
|
+
bbox: line.bbox,
|
|
39
|
+
...(line.confidence !== undefined ? { confidence: line.confidence } : {}),
|
|
40
|
+
})),
|
|
41
|
+
...(ocrAny.native_blocks ? { nativeBlocks: ocrAny.native_blocks } : {}),
|
|
42
|
+
});
|
|
43
|
+
next.layout = layout;
|
|
44
|
+
next.trust_warnings.push(`layout_policy: ${layout.policy}`);
|
|
45
|
+
for (const warning of layout.warnings) {
|
|
46
|
+
next.trust_warnings.push(`layout: ${warning}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let palette: Array<{ hex: string; approx_share: number }> | undefined;
|
|
51
|
+
if (input.include_palette) {
|
|
52
|
+
try {
|
|
53
|
+
palette = await samplePalette(resolvedPath);
|
|
54
|
+
} catch {
|
|
55
|
+
next.trust_warnings.push('palette: sampling failed; continuing without palette.');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const optionalLlm = await maybeOptionalImageCaption({
|
|
60
|
+
path: resolvedPath,
|
|
61
|
+
mime: next.mime,
|
|
62
|
+
enabled: input.include_optional_llm ?? false,
|
|
63
|
+
});
|
|
64
|
+
if (optionalLlm.available) {
|
|
65
|
+
next.trust_warnings.push(
|
|
66
|
+
'optional_llm caption is non-authority; prefer OCR/layout locators for claims.'
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const includeAgentMap = input.include_agent_map ?? true;
|
|
71
|
+
if (includeAgentMap) {
|
|
72
|
+
next.agent_map = buildAgentImageMap({
|
|
73
|
+
filename: next.filename,
|
|
74
|
+
mime: next.mime,
|
|
75
|
+
dimensions: next.dimensions,
|
|
76
|
+
...(next.layout ? { layout: next.layout } : {}),
|
|
77
|
+
ocrLineCount: next.ocr?.line_count ?? next.ocr?.lines?.length ?? 0,
|
|
78
|
+
...(palette ? { palette } : {}),
|
|
79
|
+
optionalLlm,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return next;
|
|
84
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-first layout: prefer Tesseract native block/paragraph units; fall back to line clustering.
|
|
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
|
+
source?: 'tesseract_native' | 'line_cluster';
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type ImageLayout = {
|
|
24
|
+
policy: string;
|
|
25
|
+
block_count: number;
|
|
26
|
+
blocks: LayoutBlock[];
|
|
27
|
+
full_text: string;
|
|
28
|
+
warnings: string[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type NativeBlockInput = {
|
|
32
|
+
id: string;
|
|
33
|
+
kind: 'block' | 'paragraph';
|
|
34
|
+
text: string;
|
|
35
|
+
bbox: BBox;
|
|
36
|
+
confidence?: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const unionBBox = (boxes: BBox[]): BBox => {
|
|
40
|
+
const minX = Math.min(...boxes.map((b) => b.x));
|
|
41
|
+
const minY = Math.min(...boxes.map((b) => b.y));
|
|
42
|
+
const maxX = Math.max(...boxes.map((b) => b.x + b.width));
|
|
43
|
+
const maxY = Math.max(...boxes.map((b) => b.y + b.height));
|
|
44
|
+
return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const median = (values: number[], fallback: number): number => {
|
|
48
|
+
if (values.length === 0) return fallback;
|
|
49
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
50
|
+
return sorted[Math.floor(sorted.length / 2)] ?? fallback;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const shouldMerge = (prev: LayoutLine, next: LayoutLine, yGap: number, xSlop: number): boolean => {
|
|
54
|
+
const verticalClose = next.bbox.y - (prev.bbox.y + prev.bbox.height) <= yGap;
|
|
55
|
+
const prevRight = prev.bbox.x + prev.bbox.width;
|
|
56
|
+
const nextRight = next.bbox.x + next.bbox.width;
|
|
57
|
+
const horizontalOverlap =
|
|
58
|
+
Math.min(prevRight, nextRight) - Math.max(prev.bbox.x, next.bbox.x) > -xSlop;
|
|
59
|
+
return verticalClose && horizontalOverlap;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const toClusterBlock = (lines: LayoutLine[], index: number): LayoutBlock => {
|
|
63
|
+
const ordered = [...lines].sort((a, b) => a.bbox.y - b.bbox.y || a.bbox.x - b.bbox.x);
|
|
64
|
+
const text = ordered
|
|
65
|
+
.map((l) => l.text.trim())
|
|
66
|
+
.filter(Boolean)
|
|
67
|
+
.join('\n');
|
|
68
|
+
return {
|
|
69
|
+
id: `block-${index + 1}`,
|
|
70
|
+
kind: 'text_block',
|
|
71
|
+
text,
|
|
72
|
+
bbox: unionBBox(ordered.map((l) => l.bbox)),
|
|
73
|
+
line_count: ordered.length,
|
|
74
|
+
reading_order: index + 1,
|
|
75
|
+
source: 'line_cluster',
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
export function buildLayoutFromNativeBlocks(native: NativeBlockInput[]): ImageLayout {
|
|
80
|
+
const usable = native
|
|
81
|
+
.filter((b) => b.text.trim().length > 0 && b.bbox.width > 0 && b.bbox.height > 0)
|
|
82
|
+
.sort((a, b) => a.bbox.y - b.bbox.y || a.bbox.x - b.bbox.x);
|
|
83
|
+
const blocks: LayoutBlock[] = usable.map((b, index) => ({
|
|
84
|
+
id: b.id || `native-${index + 1}`,
|
|
85
|
+
kind: 'text_block',
|
|
86
|
+
text: b.text.trim(),
|
|
87
|
+
bbox: b.bbox,
|
|
88
|
+
line_count: Math.max(1, b.text.split(/\n/).length),
|
|
89
|
+
reading_order: index + 1,
|
|
90
|
+
source: 'tesseract_native',
|
|
91
|
+
}));
|
|
92
|
+
return {
|
|
93
|
+
policy: 'tesseract_native_layout_v1',
|
|
94
|
+
block_count: blocks.length,
|
|
95
|
+
blocks,
|
|
96
|
+
full_text: blocks.map((b) => b.text).join('\n\n'),
|
|
97
|
+
warnings:
|
|
98
|
+
blocks.length === 0
|
|
99
|
+
? ['Native Tesseract blocks present but empty text; falling back recommended.']
|
|
100
|
+
: [],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function buildLayoutFromOcrLines(lines: LayoutLine[]): ImageLayout {
|
|
105
|
+
if (lines.length === 0) {
|
|
106
|
+
return {
|
|
107
|
+
policy: 'ocr_line_cluster_v1',
|
|
108
|
+
block_count: 0,
|
|
109
|
+
blocks: [],
|
|
110
|
+
full_text: '',
|
|
111
|
+
warnings: ['No OCR lines available for layout clustering.'],
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const sorted = [...lines].sort((a, b) => a.bbox.y - b.bbox.y || a.bbox.x - b.bbox.x);
|
|
116
|
+
const medianHeight = median(
|
|
117
|
+
sorted.map((l) => l.bbox.height).filter((h) => h > 0),
|
|
118
|
+
16
|
|
119
|
+
);
|
|
120
|
+
const yGap = Math.max(8, medianHeight * 1.4);
|
|
121
|
+
const xSlop = medianHeight * 2;
|
|
122
|
+
|
|
123
|
+
const clusters: LayoutLine[][] = [];
|
|
124
|
+
for (const line of sorted) {
|
|
125
|
+
const last = clusters[clusters.length - 1];
|
|
126
|
+
const prev = last?.[last.length - 1];
|
|
127
|
+
if (last && prev && shouldMerge(prev, line, yGap, xSlop)) {
|
|
128
|
+
last.push(line);
|
|
129
|
+
} else {
|
|
130
|
+
clusters.push([line]);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const blocks = clusters.map((cluster, index) => toClusterBlock(cluster, index));
|
|
135
|
+
const warnings: string[] = [];
|
|
136
|
+
if (blocks.length === 1 && lines.length > 8) {
|
|
137
|
+
warnings.push(
|
|
138
|
+
'Layout collapsed to a single block; image may be dense continuous text or OCR line geometry is coarse.'
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
policy: 'ocr_line_cluster_v1',
|
|
144
|
+
block_count: blocks.length,
|
|
145
|
+
blocks,
|
|
146
|
+
full_text: blocks.map((b) => b.text).join('\n\n'),
|
|
147
|
+
warnings,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Local-first frontier layout: native Tesseract structure first, heuristic second. */
|
|
152
|
+
export function buildBestEffortLayout(input: {
|
|
153
|
+
lines: LayoutLine[];
|
|
154
|
+
nativeBlocks?: NativeBlockInput[];
|
|
155
|
+
}): ImageLayout {
|
|
156
|
+
if (input.nativeBlocks?.some((b) => b.text.trim().length > 0)) {
|
|
157
|
+
const native = buildLayoutFromNativeBlocks(input.nativeBlocks);
|
|
158
|
+
if (native.block_count > 0) return native;
|
|
159
|
+
}
|
|
160
|
+
return buildLayoutFromOcrLines(input.lines);
|
|
161
|
+
}
|
package/src/utils/ocr.ts
CHANGED
|
@@ -10,6 +10,17 @@ export interface OcrWord {
|
|
|
10
10
|
confidence?: number;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/** Native Tesseract layout unit (block or paragraph) — frontier-local structure free with classic OCR. */
|
|
14
|
+
export interface OcrNativeBlock {
|
|
15
|
+
id: string;
|
|
16
|
+
kind: 'block' | 'paragraph';
|
|
17
|
+
text: string;
|
|
18
|
+
bbox: { x: number; y: number; width: number; height: number };
|
|
19
|
+
block_num?: number;
|
|
20
|
+
par_num?: number;
|
|
21
|
+
confidence?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
13
24
|
export interface OcrResult {
|
|
14
25
|
available: boolean;
|
|
15
26
|
skipped_reason?: string;
|
|
@@ -21,6 +32,8 @@ export interface OcrResult {
|
|
|
21
32
|
dropped_low_confidence?: number;
|
|
22
33
|
lines: OcrLine[];
|
|
23
34
|
words?: OcrWord[];
|
|
35
|
+
/** Prefer for layout when present (Tesseract levels 2/3). */
|
|
36
|
+
native_blocks?: OcrNativeBlock[];
|
|
24
37
|
}
|
|
25
38
|
|
|
26
39
|
export interface ParseTesseractOptions {
|
|
@@ -28,21 +41,31 @@ export interface ParseTesseractOptions {
|
|
|
28
41
|
includeWords?: boolean;
|
|
29
42
|
}
|
|
30
43
|
|
|
31
|
-
|
|
44
|
+
const num = (v: string | undefined): number => Number.parseFloat(v ?? '');
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Pure TSV parser — lines (level 4 aggregated from words), words (level 5),
|
|
48
|
+
* and native blocks/paragraphs (levels 2/3) for local-first layout frontier.
|
|
49
|
+
*/
|
|
32
50
|
export const parseTesseractTsv = (
|
|
33
51
|
raw: string,
|
|
34
52
|
options: ParseTesseractOptions = {}
|
|
35
|
-
): {
|
|
53
|
+
): {
|
|
54
|
+
lines: OcrLine[];
|
|
55
|
+
words: OcrWord[];
|
|
56
|
+
native_blocks: OcrNativeBlock[];
|
|
57
|
+
dropped_low_confidence: number;
|
|
58
|
+
} => {
|
|
36
59
|
const minConfidence = options.minConfidence ?? 0;
|
|
37
60
|
const includeWords = options.includeWords ?? false;
|
|
38
61
|
const linesRaw = raw.split(/\r?\n/).filter((line) => line.length > 0);
|
|
39
62
|
if (linesRaw.length <= 1) {
|
|
40
|
-
return { lines: [], words: [], dropped_low_confidence: 0 };
|
|
63
|
+
return { lines: [], words: [], native_blocks: [], dropped_low_confidence: 0 };
|
|
41
64
|
}
|
|
42
65
|
|
|
43
66
|
const rows = linesRaw.slice(1).map((line) => line.split('\t'));
|
|
44
67
|
const lineMap = new Map<
|
|
45
|
-
|
|
68
|
+
string,
|
|
46
69
|
{
|
|
47
70
|
words: Array<{
|
|
48
71
|
text: string;
|
|
@@ -57,22 +80,47 @@ export const parseTesseractTsv = (
|
|
|
57
80
|
|
|
58
81
|
let dropped = 0;
|
|
59
82
|
const flatWords: OcrWord[] = [];
|
|
83
|
+
const nativeBlocks: OcrNativeBlock[] = [];
|
|
60
84
|
|
|
61
85
|
for (const columns of rows) {
|
|
62
86
|
if (columns.length < 12) continue;
|
|
63
87
|
const level = Number.parseInt(columns[0] ?? '', 10);
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
const text = columns[11]?.trim() ?? '';
|
|
67
|
-
if (text.length === 0) continue;
|
|
68
|
-
|
|
88
|
+
const blockNum = Number.parseInt(columns[2] ?? '', 10);
|
|
89
|
+
const parNum = Number.parseInt(columns[3] ?? '', 10);
|
|
69
90
|
const lineNum = Number.parseInt(columns[4] ?? '', 10);
|
|
70
91
|
const left = Number.parseInt(columns[6] ?? '', 10);
|
|
71
92
|
const top = Number.parseInt(columns[7] ?? '', 10);
|
|
72
93
|
const width = Number.parseInt(columns[8] ?? '', 10);
|
|
73
94
|
const height = Number.parseInt(columns[9] ?? '', 10);
|
|
74
|
-
const conf =
|
|
95
|
+
const conf = num(columns[10]);
|
|
96
|
+
const text = columns[11]?.trim() ?? '';
|
|
97
|
+
|
|
98
|
+
// Native layout units from Tesseract itself (better than pure heuristic when present)
|
|
99
|
+
if ((level === 2 || level === 3) && Number.isFinite(left) && Number.isFinite(top)) {
|
|
100
|
+
if (text.length > 0 || level === 2) {
|
|
101
|
+
// level 2 blocks often have empty text; still keep geometry if non-zero
|
|
102
|
+
if (width > 0 && height > 0) {
|
|
103
|
+
const nb: OcrNativeBlock = {
|
|
104
|
+
id: level === 2 ? `tess-block-${blockNum}` : `tess-par-${blockNum}-${parNum}`,
|
|
105
|
+
kind: level === 2 ? 'block' : 'paragraph',
|
|
106
|
+
text,
|
|
107
|
+
bbox: {
|
|
108
|
+
x: left,
|
|
109
|
+
y: top,
|
|
110
|
+
width: Number.isFinite(width) ? width : 0,
|
|
111
|
+
height: Number.isFinite(height) ? height : 0,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
if (Number.isFinite(blockNum)) nb.block_num = blockNum;
|
|
115
|
+
if (Number.isFinite(parNum)) nb.par_num = parNum;
|
|
116
|
+
if (Number.isFinite(conf) && conf >= 0) nb.confidence = conf;
|
|
117
|
+
nativeBlocks.push(nb);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
75
121
|
|
|
122
|
+
if (level !== 5) continue;
|
|
123
|
+
if (text.length === 0) continue;
|
|
76
124
|
if (!Number.isFinite(lineNum) || !Number.isFinite(left) || !Number.isFinite(top)) continue;
|
|
77
125
|
|
|
78
126
|
const confVal = Number.isFinite(conf) ? conf : 0;
|
|
@@ -81,7 +129,8 @@ export const parseTesseractTsv = (
|
|
|
81
129
|
continue;
|
|
82
130
|
}
|
|
83
131
|
|
|
84
|
-
const
|
|
132
|
+
const lineKey = `${blockNum}:${parNum}:${lineNum}`;
|
|
133
|
+
const bucket = lineMap.get(lineKey) ?? { words: [] };
|
|
85
134
|
bucket.words.push({
|
|
86
135
|
text,
|
|
87
136
|
left,
|
|
@@ -90,7 +139,7 @@ export const parseTesseractTsv = (
|
|
|
90
139
|
height: Number.isFinite(height) ? height : 0,
|
|
91
140
|
conf: confVal,
|
|
92
141
|
});
|
|
93
|
-
lineMap.set(
|
|
142
|
+
lineMap.set(lineKey, bucket);
|
|
94
143
|
|
|
95
144
|
if (includeWords) {
|
|
96
145
|
flatWords.push({
|
|
@@ -106,18 +155,34 @@ export const parseTesseractTsv = (
|
|
|
106
155
|
}
|
|
107
156
|
}
|
|
108
157
|
|
|
109
|
-
|
|
158
|
+
// Fill empty native block text from aggregated words in that block
|
|
159
|
+
const wordsByBlock = new Map<number, string[]>();
|
|
160
|
+
for (const [key, bucket] of lineMap) {
|
|
161
|
+
const blockNum = Number.parseInt(key.split(':')[0] ?? '', 10);
|
|
162
|
+
if (!Number.isFinite(blockNum)) continue;
|
|
163
|
+
const list = wordsByBlock.get(blockNum) ?? [];
|
|
164
|
+
const sorted = [...bucket.words].sort((a, b) => a.top - b.top || a.left - b.left);
|
|
165
|
+
list.push(sorted.map((w) => w.text).join(' '));
|
|
166
|
+
wordsByBlock.set(blockNum, list);
|
|
167
|
+
}
|
|
168
|
+
for (const block of nativeBlocks) {
|
|
169
|
+
if (block.text.length > 0) continue;
|
|
170
|
+
if (block.block_num === undefined) continue;
|
|
171
|
+
const parts = wordsByBlock.get(block.block_num);
|
|
172
|
+
if (parts && parts.length > 0) {
|
|
173
|
+
block.text = parts.join('\n').trim();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
110
176
|
|
|
177
|
+
const ocrLines: OcrLine[] = [];
|
|
111
178
|
for (const bucket of lineMap.values()) {
|
|
112
179
|
if (bucket.words.length === 0) continue;
|
|
113
|
-
|
|
114
180
|
const sorted = [...bucket.words].sort((a, b) => a.left - b.left);
|
|
115
181
|
const text = sorted
|
|
116
182
|
.map((word) => word.text)
|
|
117
183
|
.join(' ')
|
|
118
184
|
.trim();
|
|
119
185
|
if (text.length === 0) continue;
|
|
120
|
-
|
|
121
186
|
const left = Math.min(...sorted.map((word) => word.left));
|
|
122
187
|
const top = Math.min(...sorted.map((word) => word.top));
|
|
123
188
|
const right = Math.max(...sorted.map((word) => word.left + word.width));
|
|
@@ -127,7 +192,6 @@ export const parseTesseractTsv = (
|
|
|
127
192
|
confidenceValues.length > 0
|
|
128
193
|
? confidenceValues.reduce((sum, value) => sum + value, 0) / confidenceValues.length
|
|
129
194
|
: undefined;
|
|
130
|
-
|
|
131
195
|
ocrLines.push({
|
|
132
196
|
text,
|
|
133
197
|
bbox: {
|
|
@@ -140,9 +204,20 @@ export const parseTesseractTsv = (
|
|
|
140
204
|
});
|
|
141
205
|
}
|
|
142
206
|
|
|
207
|
+
// Prefer paragraph native blocks with text; else blocks
|
|
208
|
+
const paragraphs = nativeBlocks.filter((b) => b.kind === 'paragraph' && b.text.length > 0);
|
|
209
|
+
const blocksOnly = nativeBlocks.filter((b) => b.kind === 'block');
|
|
210
|
+
const preferredNative =
|
|
211
|
+
paragraphs.length > 0
|
|
212
|
+
? paragraphs
|
|
213
|
+
: blocksOnly.filter((b) => b.text.length > 0).length > 0
|
|
214
|
+
? blocksOnly.filter((b) => b.text.length > 0)
|
|
215
|
+
: blocksOnly;
|
|
216
|
+
|
|
143
217
|
return {
|
|
144
218
|
lines: ocrLines.sort((a, b) => a.bbox.y - b.bbox.y || a.bbox.x - b.bbox.x),
|
|
145
219
|
words: includeWords ? flatWords.sort((a, b) => a.bbox.y - b.bbox.y || a.bbox.x - b.bbox.x) : [],
|
|
220
|
+
native_blocks: preferredNative.sort((a, b) => a.bbox.y - b.bbox.y || a.bbox.x - b.bbox.x),
|
|
146
221
|
dropped_low_confidence: dropped,
|
|
147
222
|
};
|
|
148
223
|
};
|
|
@@ -156,7 +231,6 @@ export const isTesseractAvailable = (): boolean => {
|
|
|
156
231
|
return result.status === 0;
|
|
157
232
|
};
|
|
158
233
|
|
|
159
|
-
/** List installed Tesseract traineddata languages (honest empty when unavailable). */
|
|
160
234
|
export const listTesseractLanguages = (): {
|
|
161
235
|
available: boolean;
|
|
162
236
|
languages: string[];
|
|
@@ -171,9 +245,14 @@ export const listTesseractLanguages = (): {
|
|
|
171
245
|
}
|
|
172
246
|
const result = spawnSync('tesseract', ['--list-langs'], {
|
|
173
247
|
encoding: 'utf8',
|
|
174
|
-
timeout:
|
|
248
|
+
timeout: OCR_HEALTHCHECK_TIMEOUT_MS,
|
|
175
249
|
windowsHide: true,
|
|
176
250
|
});
|
|
251
|
+
const stdout = typeof result.stdout === 'string' ? result.stdout : '';
|
|
252
|
+
const languages = stdout
|
|
253
|
+
.split(/\r?\n/)
|
|
254
|
+
.map((l) => l.trim())
|
|
255
|
+
.filter((l) => l && !l.toLowerCase().includes('list of available'));
|
|
177
256
|
if (result.status !== 0) {
|
|
178
257
|
const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : '';
|
|
179
258
|
return {
|
|
@@ -182,13 +261,6 @@ export const listTesseractLanguages = (): {
|
|
|
182
261
|
warning: stderr || 'tesseract --list-langs failed',
|
|
183
262
|
};
|
|
184
263
|
}
|
|
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
264
|
return { available: true, languages };
|
|
193
265
|
};
|
|
194
266
|
|
|
@@ -205,7 +277,7 @@ export const runTesseractOcr = (
|
|
|
205
277
|
const options: RunOcrOptions = Array.isArray(languagesOrOptions)
|
|
206
278
|
? { languages: languagesOrOptions }
|
|
207
279
|
: languagesOrOptions;
|
|
208
|
-
const languages = options.languages
|
|
280
|
+
const languages = options.languages ?? ['eng'];
|
|
209
281
|
const minConfidence = options.minConfidence ?? 0;
|
|
210
282
|
const includeWords = options.includeWords ?? false;
|
|
211
283
|
|
|
@@ -218,6 +290,7 @@ export const runTesseractOcr = (
|
|
|
218
290
|
lines: [],
|
|
219
291
|
line_count: 0,
|
|
220
292
|
dropped_low_confidence: 0,
|
|
293
|
+
native_blocks: [],
|
|
221
294
|
};
|
|
222
295
|
}
|
|
223
296
|
|
|
@@ -227,22 +300,27 @@ export const runTesseractOcr = (
|
|
|
227
300
|
);
|
|
228
301
|
|
|
229
302
|
const languageArg = languages.join('+');
|
|
230
|
-
const result = spawnSync(
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
303
|
+
const result = spawnSync(
|
|
304
|
+
'tesseract',
|
|
305
|
+
[imagePath, 'stdout', '-l', languageArg, 'tsv', '--psm', '3'],
|
|
306
|
+
{
|
|
307
|
+
encoding: 'utf8',
|
|
308
|
+
timeout: OCR_TIMEOUT_MS,
|
|
309
|
+
windowsHide: true,
|
|
310
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
311
|
+
}
|
|
312
|
+
);
|
|
236
313
|
|
|
237
314
|
if (result.error) {
|
|
238
315
|
return {
|
|
239
316
|
available: false,
|
|
240
|
-
skipped_reason:
|
|
317
|
+
skipped_reason: result.error.message,
|
|
241
318
|
route: 'tesseract_tsv',
|
|
242
319
|
languages,
|
|
243
320
|
lines: [],
|
|
244
321
|
line_count: 0,
|
|
245
322
|
dropped_low_confidence: 0,
|
|
323
|
+
native_blocks: [],
|
|
246
324
|
};
|
|
247
325
|
}
|
|
248
326
|
|
|
@@ -257,6 +335,7 @@ export const runTesseractOcr = (
|
|
|
257
335
|
lines: [],
|
|
258
336
|
line_count: 0,
|
|
259
337
|
dropped_low_confidence: 0,
|
|
338
|
+
native_blocks: [],
|
|
260
339
|
};
|
|
261
340
|
}
|
|
262
341
|
|
|
@@ -264,11 +343,12 @@ export const runTesseractOcr = (
|
|
|
264
343
|
const parsed = parseTesseractTsv(stdout, { minConfidence, includeWords });
|
|
265
344
|
return {
|
|
266
345
|
available: true,
|
|
267
|
-
route: '
|
|
346
|
+
route: 'tesseract_tsv_psm3_native_layout',
|
|
268
347
|
languages,
|
|
269
348
|
lines: parsed.lines,
|
|
270
349
|
line_count: parsed.lines.length,
|
|
271
350
|
dropped_low_confidence: parsed.dropped_low_confidence,
|
|
351
|
+
native_blocks: parsed.native_blocks,
|
|
272
352
|
...(missingLangs.length
|
|
273
353
|
? {
|
|
274
354
|
languages_warning: `Requested OCR language(s) not listed by tesseract --list-langs: ${missingLangs.join(', ')}. Installed: ${installed.languages.join(', ') || '(none)'}.`,
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local-first frontier optional vision caption.
|
|
3
|
+
*
|
|
4
|
+
* Priority:
|
|
5
|
+
* 1) IRIS_OPTIONAL_LLM_URL — custom POST { path, mime, purpose } → { caption }
|
|
6
|
+
* 2) Local Ollama at IRIS_OLLAMA_URL (default http://127.0.0.1:11434) if a vision model is installed
|
|
7
|
+
*
|
|
8
|
+
* Never authority over OCR/layout locators. OFF unless include_optional_llm=true.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
|
|
13
|
+
export type OptionalLlmResult = {
|
|
14
|
+
available: boolean;
|
|
15
|
+
skipped_reason?: string;
|
|
16
|
+
route?: string;
|
|
17
|
+
caption?: string;
|
|
18
|
+
model?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const DEFAULT_OLLAMA = 'http://127.0.0.1:11434';
|
|
22
|
+
|
|
23
|
+
const VISION_MODEL_HINTS = [
|
|
24
|
+
'llava',
|
|
25
|
+
'minicpm-v',
|
|
26
|
+
'minicpm_v',
|
|
27
|
+
'qwen2-vl',
|
|
28
|
+
'qwen2.5-vl',
|
|
29
|
+
'qwen3-vl',
|
|
30
|
+
'bakllava',
|
|
31
|
+
'moondream',
|
|
32
|
+
'gemma3',
|
|
33
|
+
'llama3.2-vision',
|
|
34
|
+
'pixtral',
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
function isVisionModel(name: string): boolean {
|
|
38
|
+
const n = name.toLowerCase();
|
|
39
|
+
return VISION_MODEL_HINTS.some((h) => n.includes(h));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function tryCustomUrl(input: {
|
|
43
|
+
path: string;
|
|
44
|
+
mime: string;
|
|
45
|
+
url: string;
|
|
46
|
+
}): Promise<OptionalLlmResult> {
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetch(input.url, {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: { 'content-type': 'application/json' },
|
|
51
|
+
body: JSON.stringify({
|
|
52
|
+
path: input.path,
|
|
53
|
+
mime: input.mime,
|
|
54
|
+
purpose: 'image_caption',
|
|
55
|
+
}),
|
|
56
|
+
signal: AbortSignal.timeout(20_000),
|
|
57
|
+
});
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
return {
|
|
60
|
+
available: false,
|
|
61
|
+
skipped_reason: `optional LLM HTTP ${res.status}`,
|
|
62
|
+
route: input.url,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const body = (await res.json()) as { caption?: string };
|
|
66
|
+
if (!body.caption || typeof body.caption !== 'string') {
|
|
67
|
+
return {
|
|
68
|
+
available: false,
|
|
69
|
+
skipped_reason: 'optional LLM response missing caption string',
|
|
70
|
+
route: input.url,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
available: true,
|
|
75
|
+
route: `optional-llm:${input.url}`,
|
|
76
|
+
caption: body.caption.slice(0, 4000),
|
|
77
|
+
};
|
|
78
|
+
} catch (error: unknown) {
|
|
79
|
+
const message = error instanceof Error ? error.message : 'optional LLM failed';
|
|
80
|
+
return { available: false, skipped_reason: message, route: input.url };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function listOllamaModels(base: string): Promise<string[]> {
|
|
85
|
+
const res = await fetch(`${base.replace(/\/$/, '')}/api/tags`, {
|
|
86
|
+
signal: AbortSignal.timeout(3_000),
|
|
87
|
+
});
|
|
88
|
+
if (!res.ok) return [];
|
|
89
|
+
const body = (await res.json()) as { models?: Array<{ name?: string }> };
|
|
90
|
+
return (body.models ?? [])
|
|
91
|
+
.map((m) => m.name)
|
|
92
|
+
.filter((n): n is string => typeof n === 'string' && n.length > 0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function tryOllamaVision(input: {
|
|
96
|
+
path: string;
|
|
97
|
+
baseUrl: string;
|
|
98
|
+
modelHint?: string;
|
|
99
|
+
}): Promise<OptionalLlmResult> {
|
|
100
|
+
const base = input.baseUrl.replace(/\/$/, '');
|
|
101
|
+
let models: string[] = [];
|
|
102
|
+
try {
|
|
103
|
+
models = await listOllamaModels(base);
|
|
104
|
+
} catch {
|
|
105
|
+
return {
|
|
106
|
+
available: false,
|
|
107
|
+
skipped_reason: `Ollama not reachable at ${base}`,
|
|
108
|
+
route: base,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (models.length === 0) {
|
|
112
|
+
return {
|
|
113
|
+
available: false,
|
|
114
|
+
skipped_reason: 'Ollama has no models installed',
|
|
115
|
+
route: base,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const preferred =
|
|
120
|
+
(input.modelHint &&
|
|
121
|
+
models.find((m) => m === input.modelHint || m.startsWith(`${input.modelHint}:`))) ||
|
|
122
|
+
models.find((m) => isVisionModel(m));
|
|
123
|
+
if (!preferred) {
|
|
124
|
+
return {
|
|
125
|
+
available: false,
|
|
126
|
+
skipped_reason: `No local vision model found in Ollama (have: ${models.slice(0, 6).join(', ')}). Install e.g. llava or minicpm-v.`,
|
|
127
|
+
route: base,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let b64: string;
|
|
132
|
+
try {
|
|
133
|
+
b64 = readFileSync(input.path).toString('base64');
|
|
134
|
+
} catch (error: unknown) {
|
|
135
|
+
const message = error instanceof Error ? error.message : 'read failed';
|
|
136
|
+
return { available: false, skipped_reason: message, route: base };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Cap image payload ~4MB base64
|
|
140
|
+
if (b64.length > 5_500_000) {
|
|
141
|
+
return {
|
|
142
|
+
available: false,
|
|
143
|
+
skipped_reason: 'image too large for default Ollama caption path; crop_region first',
|
|
144
|
+
route: `ollama:${preferred}`,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
const res = await fetch(`${base}/api/chat`, {
|
|
150
|
+
method: 'POST',
|
|
151
|
+
headers: { 'content-type': 'application/json' },
|
|
152
|
+
body: JSON.stringify({
|
|
153
|
+
model: preferred,
|
|
154
|
+
stream: false,
|
|
155
|
+
messages: [
|
|
156
|
+
{
|
|
157
|
+
role: 'user',
|
|
158
|
+
content:
|
|
159
|
+
'Describe this image for a software agent. Focus on structure, UI/text regions, objects, and layout. Be concise and factual. Do not invent unreadable text.',
|
|
160
|
+
images: [b64],
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
}),
|
|
164
|
+
signal: AbortSignal.timeout(60_000),
|
|
165
|
+
});
|
|
166
|
+
if (!res.ok) {
|
|
167
|
+
return {
|
|
168
|
+
available: false,
|
|
169
|
+
skipped_reason: `Ollama chat HTTP ${res.status}`,
|
|
170
|
+
route: `ollama:${preferred}`,
|
|
171
|
+
model: preferred,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
const body = (await res.json()) as {
|
|
175
|
+
message?: { content?: string };
|
|
176
|
+
response?: string;
|
|
177
|
+
};
|
|
178
|
+
const caption = body.message?.content ?? body.response;
|
|
179
|
+
if (!caption || typeof caption !== 'string') {
|
|
180
|
+
return {
|
|
181
|
+
available: false,
|
|
182
|
+
skipped_reason: 'Ollama response missing message content',
|
|
183
|
+
route: `ollama:${preferred}`,
|
|
184
|
+
model: preferred,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
available: true,
|
|
189
|
+
route: `ollama-local-vlm:${preferred}`,
|
|
190
|
+
model: preferred,
|
|
191
|
+
caption: caption.slice(0, 4000),
|
|
192
|
+
};
|
|
193
|
+
} catch (error: unknown) {
|
|
194
|
+
const message = error instanceof Error ? error.message : 'Ollama caption failed';
|
|
195
|
+
return {
|
|
196
|
+
available: false,
|
|
197
|
+
skipped_reason: message,
|
|
198
|
+
route: `ollama:${preferred}`,
|
|
199
|
+
model: preferred,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function maybeOptionalImageCaption(input: {
|
|
205
|
+
path: string;
|
|
206
|
+
mime: string;
|
|
207
|
+
enabled: boolean;
|
|
208
|
+
}): Promise<OptionalLlmResult> {
|
|
209
|
+
if (!input.enabled) {
|
|
210
|
+
return { available: false, skipped_reason: 'include_optional_llm is false (default).' };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const custom = process.env['IRIS_OPTIONAL_LLM_URL'];
|
|
214
|
+
if (custom) {
|
|
215
|
+
return tryCustomUrl({ path: input.path, mime: input.mime, url: custom });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const ollamaBase = process.env['IRIS_OLLAMA_URL'] ?? process.env['OLLAMA_HOST'] ?? DEFAULT_OLLAMA;
|
|
219
|
+
// OLLAMA_HOST may be host:port without scheme
|
|
220
|
+
const baseUrl = ollamaBase.startsWith('http') ? ollamaBase : `http://${ollamaBase}`;
|
|
221
|
+
const modelHint = process.env['IRIS_OLLAMA_VISION_MODEL'] ?? process.env['OLLAMA_VISION_MODEL'];
|
|
222
|
+
|
|
223
|
+
return tryOllamaVision({
|
|
224
|
+
path: input.path,
|
|
225
|
+
baseUrl,
|
|
226
|
+
...(modelHint ? { modelHint } : {}),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
@@ -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
|
+
}
|