@sylphx/iris 0.1.8 → 0.2.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 +23 -11
- package/bin/{image-reader-mcp → iris} +12 -12
- package/package.json +13 -14
- package/src/handlers/readImage.ts +1 -1
- package/src/schemas/readImage.ts +35 -0
- package/src/utils/agentMap.ts +26 -27
- package/src/utils/applyImageIntelligence.ts +25 -1
- package/src/utils/optionalSemantics.ts +402 -0
package/README.md
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
# Iris
|
|
4
4
|
|
|
5
|
-
### Image evidence for agents
|
|
5
|
+
### Image evidence for agents
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Canonical package: **`@sylphx/iris`** · bin **`iris`**
|
|
8
|
+
|
|
9
|
+
**Iris** — **Rust-first** local image facts (`@sylphx/iris`) local image facts, not default VLM guesses. `sharp`/`exifr` are optional, not required for MCP.
|
|
8
10
|
|
|
9
11
|
<p align="center">
|
|
10
12
|
<img src="https://mark.sylphx.com/api/v1/banner?type=holo&theme=tokyonight&text=image+reader+mcp&desc=Evidence-first+image+reading+for+AI+agents+%E2%80%94+metadata%2C+OCR+text%2C+regions%2C+and+ci&height=200&animation=rise&credit=0" alt="image-reader-mcp — Sylphx Mark banner" width="100%" />
|
|
@@ -16,7 +18,7 @@ Evidence-first image reading for AI agents. One call turns any local image into
|
|
|
16
18
|
**Agent Media Twin** — dimensions, metadata, optional OCR with bounding boxes, and
|
|
17
19
|
trust warnings you can cite without asking a vision LLM to guess.
|
|
18
20
|
|
|
19
|
-
[](https://www.npmjs.com/package/@sylphx/iris)
|
|
20
22
|
[](https://opensource.org/licenses/MIT)
|
|
21
23
|
[](https://github.com/SylphxAI/image-reader-mcp/actions/workflows/ci.yml)
|
|
22
24
|
[](https://www.typescriptlang.org/)
|
|
@@ -37,13 +39,23 @@ Each instrument is an independent repository (marketplace + stars).
|
|
|
37
39
|
|
|
38
40
|
|
|
39
41
|
|
|
42
|
+
## See objects (L2, optional)
|
|
43
|
+
|
|
44
|
+
With a local Florence-class sidecar or Ollama, the same `read_image` can return **open-vocab objects** with pixel bboxes and scores:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{ "path": "/abs/photo.jpg", "include_semantics": true, "semantics_prompt": "people and animals" }
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Objects are `scored_non_locator` evidence — deterministic L0/L1 facts (geometry/OCR/layout) stay authoritative and always on.
|
|
51
|
+
|
|
40
52
|
## Read images (not vague vision)
|
|
41
53
|
|
|
42
54
|
Iris is **local-first**: geometry + OCR + **layout blocks** + **agent_map** so a text-only agent can understand picture architecture without a vision model.
|
|
43
55
|
|
|
44
56
|
Spec: [docs/specs/agent-image-read-contract.md](docs/specs/agent-image-read-contract.md)
|
|
45
57
|
|
|
46
|
-
**Local-first frontier:** Rust decode, Tesseract native layout (no npm ML), optional Ollama VLM; cloud URL optional. Zero API key.
|
|
58
|
+
**Local-first frontier:** Rust decode, Tesseract native layout (no npm ML), optional Ollama VLM; cloud URL optional. Zero API key. Optional **L2 local semantics** (include_semantics) detects open-vocab objects (people/animals/things) with pixel bboxes via an official Florence-class sidecar (examples/florence-sidecar/) or Ollama -- never authority over OCR/layout locators.
|
|
47
59
|
|
|
48
60
|
## Product docs
|
|
49
61
|
|
|
@@ -86,15 +98,15 @@ an image, not a creative caption.**
|
|
|
86
98
|
### Install (30 seconds)
|
|
87
99
|
|
|
88
100
|
```bash
|
|
89
|
-
npm install -g @sylphx/
|
|
101
|
+
npm install -g @sylphx/iris
|
|
90
102
|
iris doctor
|
|
91
|
-
claude mcp add iris -- npx @sylphx/
|
|
103
|
+
claude mcp add iris -- npx @sylphx/iris
|
|
92
104
|
```
|
|
93
105
|
|
|
94
106
|
**Install once. Call once.**
|
|
95
107
|
|
|
96
108
|
```bash
|
|
97
|
-
claude mcp add image-reader -- npx @sylphx/
|
|
109
|
+
claude mcp add image-reader -- npx @sylphx/iris
|
|
98
110
|
```
|
|
99
111
|
|
|
100
112
|
```json
|
|
@@ -148,7 +160,7 @@ Supported formats: PNG, JPEG, GIF, WebP, TIFF, and other formats the **Rust deco
|
|
|
148
160
|
### Claude Code
|
|
149
161
|
|
|
150
162
|
```bash
|
|
151
|
-
claude mcp add image-reader -- npx @sylphx/
|
|
163
|
+
claude mcp add image-reader -- npx @sylphx/iris
|
|
152
164
|
```
|
|
153
165
|
|
|
154
166
|
### Claude Desktop
|
|
@@ -160,7 +172,7 @@ Add this to `claude_desktop_config.json`:
|
|
|
160
172
|
"mcpServers": {
|
|
161
173
|
"image-reader": {
|
|
162
174
|
"command": "npx",
|
|
163
|
-
"args": ["@sylphx/
|
|
175
|
+
"args": ["@sylphx/iris"]
|
|
164
176
|
}
|
|
165
177
|
}
|
|
166
178
|
}
|
|
@@ -169,7 +181,7 @@ Add this to `claude_desktop_config.json`:
|
|
|
169
181
|
### Any MCP Client
|
|
170
182
|
|
|
171
183
|
```bash
|
|
172
|
-
npx @sylphx/
|
|
184
|
+
npx @sylphx/iris
|
|
173
185
|
```
|
|
174
186
|
|
|
175
187
|
Node.js `>=22.13` is required. Optional OCR uses a local Tesseract adapter when
|
|
@@ -219,7 +231,7 @@ Example `read_image` requests live in [`examples/`](examples/).
|
|
|
219
231
|
## Support
|
|
220
232
|
|
|
221
233
|
- [Issues](https://github.com/SylphxAI/image-reader-mcp/issues)
|
|
222
|
-
- [npm package](https://www.npmjs.com/package/@sylphx/
|
|
234
|
+
- [npm package](https://www.npmjs.com/package/@sylphx/iris)
|
|
223
235
|
- Portfolio orchestration: [smart-reader-mcp](https://github.com/SylphxAI/smart-reader-mcp)
|
|
224
236
|
|
|
225
237
|
## Help this reach more builders
|
|
@@ -23,10 +23,10 @@ host_platform_key() {
|
|
|
23
23
|
|
|
24
24
|
platform_package_name() {
|
|
25
25
|
case "$(host_platform_key)" in
|
|
26
|
-
darwin-arm64) printf '%s\n' '@sylphx/
|
|
27
|
-
darwin-x64) printf '%s\n' '@sylphx/
|
|
28
|
-
linux-x64) printf '%s\n' '@sylphx/
|
|
29
|
-
linux-arm64) printf '%s\n' '@sylphx/
|
|
26
|
+
darwin-arm64) printf '%s\n' '@sylphx/iris-darwin-arm64' ;;
|
|
27
|
+
darwin-x64) printf '%s\n' '@sylphx/iris-darwin-x64' ;;
|
|
28
|
+
linux-x64) printf '%s\n' '@sylphx/iris-linux-x64-gnu' ;;
|
|
29
|
+
linux-arm64) printf '%s\n' '@sylphx/iris-linux-arm64-gnu' ;;
|
|
30
30
|
*) return 1 ;;
|
|
31
31
|
esac
|
|
32
32
|
}
|
|
@@ -47,7 +47,7 @@ resolve_from_optional_dep() {
|
|
|
47
47
|
"$PACKAGE_ROOT/../../node_modules"
|
|
48
48
|
)
|
|
49
49
|
for root in "${search_roots[@]}"; do
|
|
50
|
-
candidate="$root/$pkg_name/
|
|
50
|
+
candidate="$root/$pkg_name/iris-mcp-server"
|
|
51
51
|
if is_runnable_native "$candidate"; then
|
|
52
52
|
printf '%s\n' "$candidate"
|
|
53
53
|
return 0
|
|
@@ -61,7 +61,7 @@ resolve_from_optional_dep() {
|
|
|
61
61
|
const { createRequire } = require('node:module');
|
|
62
62
|
const r = createRequire(p.join(process.argv[1], 'package.json'));
|
|
63
63
|
const pkg = r.resolve(process.argv[2] + '/package.json');
|
|
64
|
-
process.stdout.write(p.join(p.dirname(pkg), '
|
|
64
|
+
process.stdout.write(p.join(p.dirname(pkg), 'iris-mcp-server'));
|
|
65
65
|
} catch { process.exit(1); }
|
|
66
66
|
" "$PACKAGE_ROOT" "$pkg_name" 2>/dev/null || true
|
|
67
67
|
)"
|
|
@@ -74,8 +74,8 @@ resolve_from_optional_dep() {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
resolve_rust_bin() {
|
|
77
|
-
if [[ -n "${
|
|
78
|
-
printf '%s\n' "${
|
|
77
|
+
if [[ -n "${IRIS_RUST_BIN:-}" && -x "${IRIS_RUST_BIN}" ]]; then
|
|
78
|
+
printf '%s\n' "${IRIS_RUST_BIN}"
|
|
79
79
|
return 0
|
|
80
80
|
fi
|
|
81
81
|
if bin="$(resolve_from_optional_dep)"; then
|
|
@@ -83,9 +83,9 @@ resolve_rust_bin() {
|
|
|
83
83
|
return 0
|
|
84
84
|
fi
|
|
85
85
|
for candidate in \
|
|
86
|
-
"$PACKAGE_ROOT/bin/native/
|
|
87
|
-
"$PACKAGE_ROOT/target/release/
|
|
88
|
-
"$PACKAGE_ROOT/target/debug/
|
|
86
|
+
"$PACKAGE_ROOT/bin/native/iris-mcp-server" \
|
|
87
|
+
"$PACKAGE_ROOT/target/release/iris-mcp-server" \
|
|
88
|
+
"$PACKAGE_ROOT/target/debug/iris-mcp-server"; do
|
|
89
89
|
if is_runnable_native "$candidate"; then
|
|
90
90
|
printf '%s\n' "$candidate"
|
|
91
91
|
return 0
|
|
@@ -115,5 +115,5 @@ if bin="$(resolve_rust_bin)"; then
|
|
|
115
115
|
exec "$bin" "$@"
|
|
116
116
|
fi
|
|
117
117
|
|
|
118
|
-
echo "[iris] Rust MCP server not found. Install matching optional native package (@sylphx/
|
|
118
|
+
echo "[iris] Rust MCP server not found. Install matching optional native package (@sylphx/iris-<platform>) or run: bun run build:rust" >&2
|
|
119
119
|
exit 1
|
package/package.json
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sylphx/iris",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"mcpName": "io.github.SylphxAI/
|
|
5
|
-
"description": "Iris
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"mcpName": "io.github.SylphxAI/iris",
|
|
5
|
+
"description": "Iris \u2014 evidence-first image reading for AI agents. Metadata, OCR text, regions, citeable evidence. Local-first. No generative LLM authority.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
|
-
"
|
|
9
|
-
"iris": "./bin/image-reader-mcp"
|
|
8
|
+
"iris": "./bin/iris"
|
|
10
9
|
},
|
|
11
10
|
"files": [
|
|
12
|
-
"bin/
|
|
11
|
+
"bin/iris",
|
|
13
12
|
"dist/",
|
|
14
13
|
"README.md",
|
|
15
14
|
"LICENSE",
|
|
@@ -17,8 +16,7 @@
|
|
|
17
16
|
],
|
|
18
17
|
"exports": {
|
|
19
18
|
".": "./dist/doctor-cli.js",
|
|
20
|
-
"./sdk": "./src/sdk.ts"
|
|
21
|
-
"./iris": "./src/sdk.ts"
|
|
19
|
+
"./sdk": "./src/sdk.ts"
|
|
22
20
|
},
|
|
23
21
|
"publishConfig": {
|
|
24
22
|
"access": "public"
|
|
@@ -60,8 +58,9 @@
|
|
|
60
58
|
"benchmark:release-gate": "bun scripts/release-gate.ts",
|
|
61
59
|
"sync:server-json": "bun scripts/sync-server-json.ts",
|
|
62
60
|
"version-packages": "changeset version && bun run sync:server-json",
|
|
63
|
-
"start": "./bin/
|
|
61
|
+
"start": "./bin/iris",
|
|
64
62
|
"clean": "rm -rf dist coverage",
|
|
63
|
+
"prepublishOnly": "bun run clean && bun run build && bun run build:rust",
|
|
65
64
|
"release": "bun run typecheck && bun run check && bun run build && bun test && changeset publish",
|
|
66
65
|
"benchmark:public-proof": "bun scripts/public-proof.ts",
|
|
67
66
|
"brand:pack-plan": "bun scripts/brand-pack-plan.ts"
|
|
@@ -83,9 +82,9 @@
|
|
|
83
82
|
"optionalDependencies": {
|
|
84
83
|
"sharp": "^0.35.0",
|
|
85
84
|
"exifr": "^7.1.3",
|
|
86
|
-
"@sylphx/
|
|
87
|
-
"@sylphx/
|
|
88
|
-
"@sylphx/
|
|
89
|
-
"@sylphx/
|
|
85
|
+
"@sylphx/iris-darwin-arm64": "0.2.0",
|
|
86
|
+
"@sylphx/iris-darwin-x64": "0.2.0",
|
|
87
|
+
"@sylphx/iris-linux-x64-gnu": "0.2.0",
|
|
88
|
+
"@sylphx/iris-linux-arm64-gnu": "0.2.0"
|
|
90
89
|
}
|
|
91
|
-
}
|
|
90
|
+
}
|
|
@@ -90,7 +90,7 @@ const readMetadata = async (
|
|
|
90
90
|
|
|
91
91
|
export const readImage = tool()
|
|
92
92
|
.description(
|
|
93
|
-
'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.'
|
|
93
|
+
'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, optional L2 semantics (objects). Local-first; generative path off by default.'
|
|
94
94
|
)
|
|
95
95
|
.input(readImageArgsSchema)
|
|
96
96
|
.handler(async ({ input }) => {
|
package/src/schemas/readImage.ts
CHANGED
|
@@ -83,6 +83,17 @@ export const readImageArgsSchema = z.object({
|
|
|
83
83
|
.describe(
|
|
84
84
|
'Optional local frontier caption via Ollama vision models or IRIS_OPTIONAL_LLM_URL. Off by default; never authority over OCR/layout evidence.'
|
|
85
85
|
),
|
|
86
|
+
include_semantics: z
|
|
87
|
+
.union([z.boolean(), z.literal('auto')])
|
|
88
|
+
.optional()
|
|
89
|
+
.describe(
|
|
90
|
+
'L2 local semantics: open-vocab objects + optional caption via IRIS_SEMANTICS_URL (Florence/DINO/SAM adapter) or Ollama structured vision. Default false (zero-config). Never authority over OCR/layout locators.'
|
|
91
|
+
),
|
|
92
|
+
semantics_prompt: z
|
|
93
|
+
.string()
|
|
94
|
+
.max(500)
|
|
95
|
+
.optional()
|
|
96
|
+
.describe('Optional focus prompt for L2 semantics (e.g. "animals", "UI widgets").'),
|
|
86
97
|
});
|
|
87
98
|
|
|
88
99
|
export const agentMediaTwinSchema = z.object({
|
|
@@ -175,6 +186,30 @@ export const agentMediaTwinSchema = z.object({
|
|
|
175
186
|
.optional(),
|
|
176
187
|
})
|
|
177
188
|
.optional(),
|
|
189
|
+
semantics: z
|
|
190
|
+
.object({
|
|
191
|
+
available: z.boolean(),
|
|
192
|
+
authority: z.literal('scored_non_locator'),
|
|
193
|
+
skipped_reason: z.string().optional(),
|
|
194
|
+
route: z.string().optional(),
|
|
195
|
+
model: z.string().optional(),
|
|
196
|
+
caption: z.string().optional(),
|
|
197
|
+
object_count: z.number().int().nonnegative().optional(),
|
|
198
|
+
objects: z
|
|
199
|
+
.array(
|
|
200
|
+
z.object({
|
|
201
|
+
id: z.string(),
|
|
202
|
+
label: z.string(),
|
|
203
|
+
category: z.string().optional(),
|
|
204
|
+
bbox: boundingBoxSchema.optional(),
|
|
205
|
+
score: z.number().min(0).max(1).optional(),
|
|
206
|
+
mask_ref: z.string().nullable().optional(),
|
|
207
|
+
})
|
|
208
|
+
)
|
|
209
|
+
.optional(),
|
|
210
|
+
warnings: z.array(z.string()).optional(),
|
|
211
|
+
})
|
|
212
|
+
.optional(),
|
|
178
213
|
trust_warnings: z.array(z.string()),
|
|
179
214
|
});
|
|
180
215
|
|
package/src/utils/agentMap.ts
CHANGED
|
@@ -1,41 +1,22 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
* Deterministic, local-first. Optional LLM captions are layered elsewhere.
|
|
4
|
-
*/
|
|
1
|
+
import type { AgentMediaTwin } from '../schemas/readImage.js';
|
|
2
|
+
import type { SemanticsResult } from './optionalSemantics.js';
|
|
5
3
|
|
|
6
|
-
|
|
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
|
-
};
|
|
4
|
+
export type AgentImageMap = NonNullable<AgentMediaTwin['agent_map']>;
|
|
26
5
|
|
|
27
6
|
export function buildAgentImageMap(input: {
|
|
28
7
|
filename: string;
|
|
29
8
|
mime: string;
|
|
30
9
|
dimensions: { width: number; height: number };
|
|
31
|
-
layout?:
|
|
10
|
+
layout?: AgentMediaTwin['layout'];
|
|
32
11
|
ocrLineCount?: number;
|
|
33
12
|
palette?: Array<{ hex: string; approx_share: number }>;
|
|
34
13
|
optionalLlm?: AgentImageMap['optional_llm'];
|
|
14
|
+
semantics?: SemanticsResult | undefined;
|
|
35
15
|
}): AgentImageMap {
|
|
36
16
|
const { width, height } = input.dimensions;
|
|
37
17
|
const blocks = input.layout?.blocks ?? [];
|
|
38
18
|
const textPresent = (input.ocrLineCount ?? 0) > 0 || blocks.length > 0;
|
|
19
|
+
const objects = input.semantics?.available ? (input.semantics.objects ?? []) : [];
|
|
39
20
|
|
|
40
21
|
const lines: string[] = [
|
|
41
22
|
`# Image map: ${input.filename}`,
|
|
@@ -43,6 +24,7 @@ export function buildAgentImageMap(input: {
|
|
|
43
24
|
`- size: ${width}×${height}px`,
|
|
44
25
|
`- text_present: ${textPresent}`,
|
|
45
26
|
`- layout_blocks: ${blocks.length}`,
|
|
27
|
+
`- semantics_objects: ${objects.length}`,
|
|
46
28
|
];
|
|
47
29
|
|
|
48
30
|
if (input.palette && input.palette.length > 0) {
|
|
@@ -62,15 +44,32 @@ export function buildAgentImageMap(input: {
|
|
|
62
44
|
''
|
|
63
45
|
);
|
|
64
46
|
}
|
|
65
|
-
} else if (!textPresent) {
|
|
47
|
+
} else if (!textPresent && objects.length === 0) {
|
|
66
48
|
lines.push(
|
|
67
49
|
'',
|
|
68
50
|
'## Notes',
|
|
69
51
|
'- No OCR text recovered. Enable include_ocr for text architecture.',
|
|
70
|
-
'-
|
|
52
|
+
'- Enable include_semantics for local open-vocab objects (IRIS_SEMANTICS_URL or Ollama).',
|
|
53
|
+
'- Use crop_region / image_probe for geometry.'
|
|
71
54
|
);
|
|
72
55
|
}
|
|
73
56
|
|
|
57
|
+
if (objects.length > 0) {
|
|
58
|
+
lines.push('', '## L2 semantics objects (scored, non-locator authority)');
|
|
59
|
+
for (const obj of objects.slice(0, 24)) {
|
|
60
|
+
const box = obj.bbox
|
|
61
|
+
? `bbox x=${obj.bbox.x} y=${obj.bbox.y} w=${obj.bbox.width} h=${obj.bbox.height}`
|
|
62
|
+
: 'bbox: n/a';
|
|
63
|
+
const score = obj.score !== undefined ? ` score=${obj.score.toFixed(2)}` : '';
|
|
64
|
+
lines.push(`- ${obj.id}: ${obj.label}${score}; ${box}`);
|
|
65
|
+
}
|
|
66
|
+
if (input.semantics?.caption) {
|
|
67
|
+
lines.push('', '## L2 caption (scored_non_locator)', input.semantics.caption);
|
|
68
|
+
}
|
|
69
|
+
} else if (input.semantics && !input.semantics.available) {
|
|
70
|
+
lines.push('', `## L2 semantics: skipped (${input.semantics.skipped_reason ?? 'unavailable'})`);
|
|
71
|
+
}
|
|
72
|
+
|
|
74
73
|
if (input.optionalLlm?.available && input.optionalLlm.caption) {
|
|
75
74
|
lines.push('', '## Optional LLM caption (non-authority)', input.optionalLlm.caption);
|
|
76
75
|
} else if (input.optionalLlm && !input.optionalLlm.available) {
|
|
@@ -2,9 +2,10 @@ import type { AgentMediaTwin, ReadImageArgs } from '../schemas/readImage.js';
|
|
|
2
2
|
import { buildAgentImageMap } from './agentMap.js';
|
|
3
3
|
import { buildBestEffortLayout } from './layout.js';
|
|
4
4
|
import { maybeOptionalImageCaption } from './optionalLlm.js';
|
|
5
|
+
import { maybeImageSemantics } from './optionalSemantics.js';
|
|
5
6
|
import { samplePalette } from './palette.js';
|
|
6
7
|
|
|
7
|
-
/** Attach layout, palette, optional LLM, and agent_map to an Agent Media Twin. */
|
|
8
|
+
/** Attach layout, palette, optional LLM/semantics, and agent_map to an Agent Media Twin. */
|
|
8
9
|
export async function applyImageIntelligence(
|
|
9
10
|
twin: AgentMediaTwin,
|
|
10
11
|
resolvedPath: string,
|
|
@@ -67,6 +68,28 @@ export async function applyImageIntelligence(
|
|
|
67
68
|
);
|
|
68
69
|
}
|
|
69
70
|
|
|
71
|
+
const semantics = await maybeImageSemantics({
|
|
72
|
+
path: resolvedPath,
|
|
73
|
+
mime: next.mime,
|
|
74
|
+
width: next.dimensions.width,
|
|
75
|
+
height: next.dimensions.height,
|
|
76
|
+
include: input.include_semantics,
|
|
77
|
+
prompt: input.semantics_prompt,
|
|
78
|
+
});
|
|
79
|
+
if (semantics) {
|
|
80
|
+
next.semantics = semantics;
|
|
81
|
+
if (semantics.available) {
|
|
82
|
+
next.trust_warnings.push(
|
|
83
|
+
`semantics L2 via ${semantics.route ?? 'unknown'} is scored_non_locator; never overrides OCR/layout.`
|
|
84
|
+
);
|
|
85
|
+
if (semantics.object_count) {
|
|
86
|
+
next.trust_warnings.push(`semantics_objects: ${semantics.object_count}`);
|
|
87
|
+
}
|
|
88
|
+
} else if (semantics.skipped_reason) {
|
|
89
|
+
next.trust_warnings.push(`semantics_skipped: ${semantics.skipped_reason}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
70
93
|
const includeAgentMap = input.include_agent_map ?? true;
|
|
71
94
|
if (includeAgentMap) {
|
|
72
95
|
next.agent_map = buildAgentImageMap({
|
|
@@ -77,6 +100,7 @@ export async function applyImageIntelligence(
|
|
|
77
100
|
ocrLineCount: next.ocr?.line_count ?? next.ocr?.lines?.length ?? 0,
|
|
78
101
|
...(palette ? { palette } : {}),
|
|
79
102
|
optionalLlm,
|
|
103
|
+
...(semantics ? { semantics } : {}),
|
|
80
104
|
});
|
|
81
105
|
}
|
|
82
106
|
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Iris L2 local semantics — open-vocab objects + optional caption.
|
|
3
|
+
*
|
|
4
|
+
* Priority:
|
|
5
|
+
* 1) IRIS_SEMANTICS_URL — Florence/Grounding-DINO/SAM-class adapter
|
|
6
|
+
* 2) Local Ollama vision with structured JSON objects
|
|
7
|
+
*
|
|
8
|
+
* Authority: scored_non_locator — never overrides OCR/layout locators.
|
|
9
|
+
* Default OFF (include_semantics false) for zero-config surprise control.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFileSync } from 'node:fs';
|
|
13
|
+
|
|
14
|
+
export type SemanticsBBox = {
|
|
15
|
+
x: number;
|
|
16
|
+
y: number;
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type SemanticsObject = {
|
|
22
|
+
id: string;
|
|
23
|
+
label: string;
|
|
24
|
+
category?: string | undefined;
|
|
25
|
+
bbox?: SemanticsBBox | undefined;
|
|
26
|
+
score?: number | undefined;
|
|
27
|
+
mask_ref?: string | null | undefined;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type SemanticsResult = {
|
|
31
|
+
available: boolean;
|
|
32
|
+
authority: 'scored_non_locator';
|
|
33
|
+
skipped_reason?: string | undefined;
|
|
34
|
+
route?: string | undefined;
|
|
35
|
+
model?: string | undefined;
|
|
36
|
+
caption?: string | undefined;
|
|
37
|
+
object_count?: number | undefined;
|
|
38
|
+
objects?: SemanticsObject[] | undefined;
|
|
39
|
+
warnings?: string[] | undefined;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type IncludeSemantics = boolean | 'auto';
|
|
43
|
+
|
|
44
|
+
const DEFAULT_OLLAMA = 'http://127.0.0.1:11434';
|
|
45
|
+
|
|
46
|
+
const VISION_MODEL_HINTS = [
|
|
47
|
+
'llava',
|
|
48
|
+
'minicpm-v',
|
|
49
|
+
'minicpm_v',
|
|
50
|
+
'qwen2-vl',
|
|
51
|
+
'qwen2.5-vl',
|
|
52
|
+
'qwen3-vl',
|
|
53
|
+
'bakllava',
|
|
54
|
+
'moondream',
|
|
55
|
+
'gemma3',
|
|
56
|
+
'llama3.2-vision',
|
|
57
|
+
'pixtral',
|
|
58
|
+
'florence',
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
function isVisionModel(name: string): boolean {
|
|
62
|
+
const n = name.toLowerCase();
|
|
63
|
+
return VISION_MODEL_HINTS.some((h) => n.includes(h));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function clampBBox(
|
|
67
|
+
raw: Partial<SemanticsBBox> | undefined,
|
|
68
|
+
maxW: number,
|
|
69
|
+
maxH: number
|
|
70
|
+
): SemanticsBBox | undefined {
|
|
71
|
+
if (!raw || typeof raw !== 'object') return undefined;
|
|
72
|
+
const x = Number(raw.x);
|
|
73
|
+
const y = Number(raw.y);
|
|
74
|
+
const width = Number(raw.width);
|
|
75
|
+
const height = Number(raw.height);
|
|
76
|
+
if (![x, y, width, height].every((n) => Number.isFinite(n))) return undefined;
|
|
77
|
+
if (width <= 0 || height <= 0) return undefined;
|
|
78
|
+
const cx = Math.max(0, Math.min(maxW, Math.round(x)));
|
|
79
|
+
const cy = Math.max(0, Math.min(maxH, Math.round(y)));
|
|
80
|
+
const cw = Math.max(1, Math.min(maxW - cx, Math.round(width)));
|
|
81
|
+
const ch = Math.max(1, Math.min(maxH - cy, Math.round(height)));
|
|
82
|
+
return { x: cx, y: cy, width: cw, height: ch };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function parseScore(scoreRaw: unknown): number | undefined {
|
|
86
|
+
if (typeof scoreRaw !== 'number' || !Number.isFinite(scoreRaw)) return undefined;
|
|
87
|
+
return Math.max(0, Math.min(1, scoreRaw > 1 ? scoreRaw / 100 : scoreRaw));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseObjectItem(
|
|
91
|
+
item: unknown,
|
|
92
|
+
index: number,
|
|
93
|
+
maxW: number,
|
|
94
|
+
maxH: number,
|
|
95
|
+
warnings: string[]
|
|
96
|
+
): SemanticsObject | undefined {
|
|
97
|
+
if (!item || typeof item !== 'object') return undefined;
|
|
98
|
+
const rec = item as Record<string, unknown>;
|
|
99
|
+
const label = typeof rec['label'] === 'string' ? rec['label'].trim() : '';
|
|
100
|
+
if (!label) return undefined;
|
|
101
|
+
const score = parseScore(rec['score'] ?? rec['confidence']);
|
|
102
|
+
const bbox = clampBBox(rec['bbox'] as Partial<SemanticsBBox> | undefined, maxW, maxH);
|
|
103
|
+
if (rec['bbox'] && !bbox) warnings.push(`dropped invalid bbox for ${label}`);
|
|
104
|
+
const id = typeof rec['id'] === 'string' && rec['id'] ? rec['id'] : `obj_${index}`;
|
|
105
|
+
const category = typeof rec['category'] === 'string' ? rec['category'].slice(0, 64) : undefined;
|
|
106
|
+
const maskRaw = rec['mask_ref'];
|
|
107
|
+
const mask_ref = typeof maskRaw === 'string' ? maskRaw : maskRaw === null ? null : undefined;
|
|
108
|
+
return {
|
|
109
|
+
id,
|
|
110
|
+
label: label.slice(0, 128),
|
|
111
|
+
...(category !== undefined ? { category } : {}),
|
|
112
|
+
...(bbox ? { bbox } : {}),
|
|
113
|
+
...(score !== undefined ? { score } : {}),
|
|
114
|
+
...(mask_ref !== undefined ? { mask_ref } : {}),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function normalizeObjects(
|
|
119
|
+
objects: unknown,
|
|
120
|
+
maxW: number,
|
|
121
|
+
maxH: number
|
|
122
|
+
): { objects: SemanticsObject[]; warnings: string[] } {
|
|
123
|
+
const warnings: string[] = [];
|
|
124
|
+
if (!Array.isArray(objects)) {
|
|
125
|
+
return { objects: [], warnings: ['semantics objects missing or not an array'] };
|
|
126
|
+
}
|
|
127
|
+
const out: SemanticsObject[] = [];
|
|
128
|
+
let i = 0;
|
|
129
|
+
for (const item of objects.slice(0, 64)) {
|
|
130
|
+
i += 1;
|
|
131
|
+
const obj = parseObjectItem(item, i, maxW, maxH, warnings);
|
|
132
|
+
if (obj) out.push(obj);
|
|
133
|
+
}
|
|
134
|
+
return { objects: out, warnings };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function extractJsonObject(text: string): unknown {
|
|
138
|
+
const trimmed = text.trim();
|
|
139
|
+
try {
|
|
140
|
+
return JSON.parse(trimmed);
|
|
141
|
+
} catch {
|
|
142
|
+
const start = trimmed.indexOf('{');
|
|
143
|
+
const end = trimmed.lastIndexOf('}');
|
|
144
|
+
if (start >= 0 && end > start) {
|
|
145
|
+
return JSON.parse(trimmed.slice(start, end + 1));
|
|
146
|
+
}
|
|
147
|
+
throw new Error('no JSON object in model response');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function tryHttpSemantics(input: {
|
|
152
|
+
path: string;
|
|
153
|
+
mime: string;
|
|
154
|
+
url: string;
|
|
155
|
+
prompt?: string | undefined;
|
|
156
|
+
width: number;
|
|
157
|
+
height: number;
|
|
158
|
+
}): Promise<SemanticsResult> {
|
|
159
|
+
try {
|
|
160
|
+
const res = await fetch(input.url, {
|
|
161
|
+
method: 'POST',
|
|
162
|
+
headers: { 'content-type': 'application/json' },
|
|
163
|
+
body: JSON.stringify({
|
|
164
|
+
path: input.path,
|
|
165
|
+
mime: input.mime,
|
|
166
|
+
purpose: 'image_semantics',
|
|
167
|
+
prompt: input.prompt,
|
|
168
|
+
dimensions: { width: input.width, height: input.height },
|
|
169
|
+
}),
|
|
170
|
+
signal: AbortSignal.timeout(45_000),
|
|
171
|
+
});
|
|
172
|
+
if (!res.ok) {
|
|
173
|
+
return {
|
|
174
|
+
available: false,
|
|
175
|
+
authority: 'scored_non_locator',
|
|
176
|
+
skipped_reason: `semantics HTTP ${res.status}`,
|
|
177
|
+
route: input.url,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
const body = (await res.json()) as {
|
|
181
|
+
caption?: string;
|
|
182
|
+
model?: string;
|
|
183
|
+
objects?: unknown;
|
|
184
|
+
warnings?: string[];
|
|
185
|
+
};
|
|
186
|
+
const { objects, warnings } = normalizeObjects(body.objects ?? [], input.width, input.height);
|
|
187
|
+
const caption = typeof body.caption === 'string' ? body.caption.slice(0, 4000) : undefined;
|
|
188
|
+
if (objects.length === 0 && !caption) {
|
|
189
|
+
return {
|
|
190
|
+
available: false,
|
|
191
|
+
authority: 'scored_non_locator',
|
|
192
|
+
skipped_reason: 'semantics HTTP response had no objects or caption',
|
|
193
|
+
route: input.url,
|
|
194
|
+
model: body.model,
|
|
195
|
+
warnings,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
available: true,
|
|
200
|
+
authority: 'scored_non_locator',
|
|
201
|
+
route: `iris-semantics-http:${input.url}`,
|
|
202
|
+
model: body.model,
|
|
203
|
+
caption,
|
|
204
|
+
object_count: objects.length,
|
|
205
|
+
objects,
|
|
206
|
+
warnings: [...warnings, ...(Array.isArray(body.warnings) ? body.warnings.map(String) : [])],
|
|
207
|
+
};
|
|
208
|
+
} catch (error: unknown) {
|
|
209
|
+
const message = error instanceof Error ? error.message : 'semantics HTTP failed';
|
|
210
|
+
return {
|
|
211
|
+
available: false,
|
|
212
|
+
authority: 'scored_non_locator',
|
|
213
|
+
skipped_reason: message,
|
|
214
|
+
route: input.url,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function listOllamaModels(base: string): Promise<string[]> {
|
|
220
|
+
const res = await fetch(`${base.replace(/\/$/, '')}/api/tags`, {
|
|
221
|
+
signal: AbortSignal.timeout(3_000),
|
|
222
|
+
});
|
|
223
|
+
if (!res.ok) return [];
|
|
224
|
+
const body = (await res.json()) as { models?: Array<{ name?: string }> };
|
|
225
|
+
return (body.models ?? [])
|
|
226
|
+
.map((m) => m.name)
|
|
227
|
+
.filter((n): n is string => typeof n === 'string' && n.length > 0);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function tryOllamaStructured(input: {
|
|
231
|
+
path: string;
|
|
232
|
+
baseUrl: string;
|
|
233
|
+
width: number;
|
|
234
|
+
height: number;
|
|
235
|
+
prompt?: string | undefined;
|
|
236
|
+
modelHint?: string | undefined;
|
|
237
|
+
}): Promise<SemanticsResult> {
|
|
238
|
+
const base = input.baseUrl.replace(/\/$/, '');
|
|
239
|
+
try {
|
|
240
|
+
const models = await listOllamaModels(base);
|
|
241
|
+
const preferred =
|
|
242
|
+
input.modelHint && models.includes(input.modelHint)
|
|
243
|
+
? input.modelHint
|
|
244
|
+
: models.find(isVisionModel);
|
|
245
|
+
if (!preferred) {
|
|
246
|
+
return {
|
|
247
|
+
available: false,
|
|
248
|
+
authority: 'scored_non_locator',
|
|
249
|
+
skipped_reason: 'no local Ollama vision model for structured semantics',
|
|
250
|
+
route: `ollama:${base}`,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const bytes = readFileSync(input.path);
|
|
255
|
+
// Keep payloads modest for local VLM
|
|
256
|
+
if (bytes.byteLength > 8_000_000) {
|
|
257
|
+
return {
|
|
258
|
+
available: false,
|
|
259
|
+
authority: 'scored_non_locator',
|
|
260
|
+
skipped_reason: 'image too large for default Ollama semantics path; crop_region first',
|
|
261
|
+
route: `ollama:${preferred}`,
|
|
262
|
+
model: preferred,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
const b64 = bytes.toString('base64');
|
|
266
|
+
const focus = input.prompt?.trim() ? ` Focus: ${input.prompt.trim().slice(0, 200)}.` : '';
|
|
267
|
+
const system = `You extract structured visual evidence for software agents.
|
|
268
|
+
Image size is ${input.width}x${input.height} pixels (x right, y down).
|
|
269
|
+
Return ONLY a JSON object (no markdown) with shape:
|
|
270
|
+
{"caption":"short factual caption","objects":[{"label":"person|dog|car|…","category":"optional","bbox":{"x":0,"y":0,"width":10,"height":10},"score":0.0}]}
|
|
271
|
+
Rules: bbox in pixels for the full image; invent nothing unreadable; empty objects array if unsure; max 24 objects; scores 0..1.${focus}`;
|
|
272
|
+
|
|
273
|
+
const res = await fetch(`${base}/api/chat`, {
|
|
274
|
+
method: 'POST',
|
|
275
|
+
headers: { 'content-type': 'application/json' },
|
|
276
|
+
body: JSON.stringify({
|
|
277
|
+
model: preferred,
|
|
278
|
+
stream: false,
|
|
279
|
+
format: 'json',
|
|
280
|
+
messages: [
|
|
281
|
+
{ role: 'system', content: system },
|
|
282
|
+
{
|
|
283
|
+
role: 'user',
|
|
284
|
+
content: 'Extract objects and caption from this image as JSON.',
|
|
285
|
+
images: [b64],
|
|
286
|
+
},
|
|
287
|
+
],
|
|
288
|
+
options: { temperature: 0.1 },
|
|
289
|
+
}),
|
|
290
|
+
signal: AbortSignal.timeout(90_000),
|
|
291
|
+
});
|
|
292
|
+
if (!res.ok) {
|
|
293
|
+
return {
|
|
294
|
+
available: false,
|
|
295
|
+
authority: 'scored_non_locator',
|
|
296
|
+
skipped_reason: `ollama chat HTTP ${res.status}`,
|
|
297
|
+
route: `ollama:${preferred}`,
|
|
298
|
+
model: preferred,
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
const body = (await res.json()) as {
|
|
302
|
+
message?: { content?: string };
|
|
303
|
+
response?: string;
|
|
304
|
+
};
|
|
305
|
+
const text = body.message?.content ?? body.response;
|
|
306
|
+
if (!text || typeof text !== 'string') {
|
|
307
|
+
return {
|
|
308
|
+
available: false,
|
|
309
|
+
authority: 'scored_non_locator',
|
|
310
|
+
skipped_reason: 'ollama response missing content',
|
|
311
|
+
route: `ollama-structured:${preferred}`,
|
|
312
|
+
model: preferred,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
const parsed = extractJsonObject(text) as {
|
|
316
|
+
caption?: string;
|
|
317
|
+
objects?: unknown;
|
|
318
|
+
};
|
|
319
|
+
const { objects, warnings } = normalizeObjects(parsed.objects ?? [], input.width, input.height);
|
|
320
|
+
const caption = typeof parsed.caption === 'string' ? parsed.caption.slice(0, 4000) : undefined;
|
|
321
|
+
if (objects.length === 0 && !caption) {
|
|
322
|
+
return {
|
|
323
|
+
available: false,
|
|
324
|
+
authority: 'scored_non_locator',
|
|
325
|
+
skipped_reason: 'structured VLM returned no objects or caption',
|
|
326
|
+
route: `ollama-structured:${preferred}`,
|
|
327
|
+
model: preferred,
|
|
328
|
+
warnings,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
return {
|
|
332
|
+
available: true,
|
|
333
|
+
authority: 'scored_non_locator',
|
|
334
|
+
route: `ollama-structured:${preferred}`,
|
|
335
|
+
model: preferred,
|
|
336
|
+
caption,
|
|
337
|
+
object_count: objects.length,
|
|
338
|
+
objects,
|
|
339
|
+
warnings,
|
|
340
|
+
};
|
|
341
|
+
} catch (error: unknown) {
|
|
342
|
+
const message = error instanceof Error ? error.message : 'ollama semantics failed';
|
|
343
|
+
return {
|
|
344
|
+
available: false,
|
|
345
|
+
authority: 'scored_non_locator',
|
|
346
|
+
skipped_reason: message,
|
|
347
|
+
route: `ollama:${base}`,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export async function maybeImageSemantics(input: {
|
|
353
|
+
path: string;
|
|
354
|
+
mime: string;
|
|
355
|
+
width: number;
|
|
356
|
+
height: number;
|
|
357
|
+
include: IncludeSemantics | undefined;
|
|
358
|
+
prompt?: string | undefined;
|
|
359
|
+
}): Promise<SemanticsResult | undefined> {
|
|
360
|
+
const flag = input.include ?? false;
|
|
361
|
+
if (flag === false) return undefined;
|
|
362
|
+
|
|
363
|
+
const httpUrl = process.env['IRIS_SEMANTICS_URL']?.trim();
|
|
364
|
+
if (httpUrl) {
|
|
365
|
+
const http = await tryHttpSemantics({
|
|
366
|
+
path: input.path,
|
|
367
|
+
mime: input.mime,
|
|
368
|
+
url: httpUrl,
|
|
369
|
+
prompt: input.prompt,
|
|
370
|
+
width: input.width,
|
|
371
|
+
height: input.height,
|
|
372
|
+
});
|
|
373
|
+
if (http.available) return http;
|
|
374
|
+
// Prefer explicit HTTP error when URL is set and mode is true
|
|
375
|
+
if (flag === true) return http;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const ollamaBase = process.env['IRIS_OLLAMA_URL'] ?? process.env['OLLAMA_HOST'] ?? DEFAULT_OLLAMA;
|
|
379
|
+
const baseUrl = ollamaBase.startsWith('http') ? ollamaBase : `http://${ollamaBase}`;
|
|
380
|
+
const modelHint = process.env['IRIS_OLLAMA_VISION_MODEL']?.trim();
|
|
381
|
+
const ollama = await tryOllamaStructured({
|
|
382
|
+
path: input.path,
|
|
383
|
+
baseUrl,
|
|
384
|
+
width: input.width,
|
|
385
|
+
height: input.height,
|
|
386
|
+
prompt: input.prompt,
|
|
387
|
+
modelHint,
|
|
388
|
+
});
|
|
389
|
+
if (ollama.available) return ollama;
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
available: false,
|
|
393
|
+
authority: 'scored_non_locator',
|
|
394
|
+
skipped_reason:
|
|
395
|
+
ollama.skipped_reason ??
|
|
396
|
+
(httpUrl
|
|
397
|
+
? 'semantics backends unavailable'
|
|
398
|
+
: 'no IRIS_SEMANTICS_URL and no local Ollama vision model'),
|
|
399
|
+
route: ollama.route,
|
|
400
|
+
model: ollama.model,
|
|
401
|
+
};
|
|
402
|
+
}
|