vision-electronic-indexing-pi 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.pi/extensions/vision-inventory-mcp/README.md +39 -0
- package/.pi/extensions/vision-inventory-mcp/index.ts +526 -0
- package/.pi/prompts/vision-inventory-agent-bom.md +13 -0
- package/.pi/skills/vision-inventory-workflow/SKILL.md +38 -0
- package/LICENSE +21 -0
- package/README.md +481 -0
- package/package.json +45 -0
- package/requirements.txt +6 -0
- package/scripts/inventory_folder_to_csv.py +477 -0
- package/vision_inventory_mcp.py +859 -0
|
@@ -0,0 +1,859 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
vision_inventory_mcp.py
|
|
4
|
+
|
|
5
|
+
Single-file local MCP server for processing electronics / PCB images into
|
|
6
|
+
structured visual inventory JSON using Cloudflare Workers AI.
|
|
7
|
+
|
|
8
|
+
Exposed MCP tools:
|
|
9
|
+
- process_image
|
|
10
|
+
- process_image_folder
|
|
11
|
+
- save_inventory
|
|
12
|
+
|
|
13
|
+
Version 1 constraints:
|
|
14
|
+
- local stdio MCP server
|
|
15
|
+
- one Python file
|
|
16
|
+
- no database
|
|
17
|
+
- no GUI
|
|
18
|
+
- no part-number web lookup
|
|
19
|
+
- only external request is to Cloudflare Workers AI
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import base64
|
|
25
|
+
import csv
|
|
26
|
+
import io
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
31
|
+
|
|
32
|
+
import requests
|
|
33
|
+
from PIL import Image, ImageOps
|
|
34
|
+
|
|
35
|
+
# Optional .env support. The app still works if python-dotenv is not installed.
|
|
36
|
+
try:
|
|
37
|
+
from dotenv import load_dotenv
|
|
38
|
+
|
|
39
|
+
load_dotenv()
|
|
40
|
+
except Exception:
|
|
41
|
+
pass
|
|
42
|
+
|
|
43
|
+
# Optional iPhone HEIC/HEIF support. The app still works if pillow-heif is not installed.
|
|
44
|
+
try:
|
|
45
|
+
import pillow_heif
|
|
46
|
+
|
|
47
|
+
pillow_heif.register_heif_opener()
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
# Support both the official MCP Python SDK import path and the standalone FastMCP package.
|
|
52
|
+
try:
|
|
53
|
+
from mcp.server.fastmcp import FastMCP
|
|
54
|
+
except ImportError: # pragma: no cover - compatibility fallback
|
|
55
|
+
from fastmcp import FastMCP # type: ignore
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
DEFAULT_MODEL = os.getenv("WORKERS_AI_MODEL", "@cf/meta/llama-4-scout-17b-16e-instruct")
|
|
59
|
+
DEFAULT_MAX_SIDE = 4000
|
|
60
|
+
DEFAULT_JPEG_QUALITY = 96
|
|
61
|
+
DEFAULT_MAX_TOKENS = 1600
|
|
62
|
+
DEFAULT_TEMPERATURE = 0.05
|
|
63
|
+
DEFAULT_TOP_P = 0.8
|
|
64
|
+
|
|
65
|
+
SUPPORTED_EXTENSIONS = {
|
|
66
|
+
".jpg",
|
|
67
|
+
".jpeg",
|
|
68
|
+
".png",
|
|
69
|
+
".webp",
|
|
70
|
+
".bmp",
|
|
71
|
+
".gif",
|
|
72
|
+
".heic",
|
|
73
|
+
".heif",
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
SYSTEM_PROMPT = """
|
|
77
|
+
You are a careful electronics image-analysis assistant.
|
|
78
|
+
|
|
79
|
+
Your job is to inspect electronics/PCB images and extract visible inventory information.
|
|
80
|
+
|
|
81
|
+
Do not perform web lookup.
|
|
82
|
+
Do not invent part numbers.
|
|
83
|
+
Do not infer missing letters or numbers.
|
|
84
|
+
For package markings, transcribe only what is visible.
|
|
85
|
+
Use [?] for unclear characters.
|
|
86
|
+
If text is blurry or partially hidden, set marking_confidence to "low" or "unreadable".
|
|
87
|
+
Prefer uncertainty over guessing.
|
|
88
|
+
Return only valid JSON.
|
|
89
|
+
""".strip()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build_default_user_prompt(image_name: str) -> str:
|
|
93
|
+
return f"""
|
|
94
|
+
Analyze this electronics image and return an inventory of visible components.
|
|
95
|
+
|
|
96
|
+
Image filename: {image_name}
|
|
97
|
+
|
|
98
|
+
Focus especially on IC packages and readable package markings.
|
|
99
|
+
|
|
100
|
+
Return only valid JSON using this schema:
|
|
101
|
+
|
|
102
|
+
{{
|
|
103
|
+
"image": "{image_name}",
|
|
104
|
+
"items": [
|
|
105
|
+
{{
|
|
106
|
+
"item_type": "IC | connector | passive | module | switch | sensor | display | mechanical | unknown",
|
|
107
|
+
"count_index": 1,
|
|
108
|
+
"package_marking": "exact visible marking, unclear, unreadable, or [?]-marked partial text",
|
|
109
|
+
"marking_confidence": "high | medium | low | unreadable",
|
|
110
|
+
"likely_part": "visible part marking only, or unknown",
|
|
111
|
+
"description": "short visual description, not web lookup",
|
|
112
|
+
"position_hint": "top-left / center / near USB connector / etc.",
|
|
113
|
+
"needs_review": true
|
|
114
|
+
}}
|
|
115
|
+
],
|
|
116
|
+
"warnings": []
|
|
117
|
+
}}
|
|
118
|
+
|
|
119
|
+
Rules:
|
|
120
|
+
- Return JSON only.
|
|
121
|
+
- Do not wrap the JSON in markdown.
|
|
122
|
+
- Do not identify parts from memory unless the marking is clearly visible.
|
|
123
|
+
- Do not use web lookup.
|
|
124
|
+
- If a marking is not readable, write "unreadable".
|
|
125
|
+
- If a component is visible but not identifiable, item_type should be "unknown".
|
|
126
|
+
- needs_review must be true when marking_confidence is "low" or "unreadable".
|
|
127
|
+
""".strip()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def build_ic_consensus_prompt(image_name: str, first_pass: Dict[str, Any]) -> str:
|
|
131
|
+
first_pass_json = json.dumps(first_pass, indent=2, ensure_ascii=False)
|
|
132
|
+
return f"""
|
|
133
|
+
Analyze this electronics image again, but focus ONLY on the IC package top markings.
|
|
134
|
+
|
|
135
|
+
Image filename: {image_name}
|
|
136
|
+
|
|
137
|
+
Important known constraint for this dataset:
|
|
138
|
+
- All ICs visible in one image should have the same part marking.
|
|
139
|
+
- The previous pass may contain OCR mistakes or hallucinated similar-looking 74LS part numbers.
|
|
140
|
+
- Do not trust the previous pass. Use it only as a list of candidates to verify against the image.
|
|
141
|
+
- Return ONE consensus IC inventory item for the shared marking, not separate items with different markings.
|
|
142
|
+
- If any character is unclear, use [?] in that character position and set needs_review=true.
|
|
143
|
+
- Prefer low confidence with [?] over a confident but guessed part number.
|
|
144
|
+
- Do not use web lookup.
|
|
145
|
+
|
|
146
|
+
Previous pass to verify:
|
|
147
|
+
{first_pass_json}
|
|
148
|
+
|
|
149
|
+
Return only valid JSON using this schema:
|
|
150
|
+
|
|
151
|
+
{{
|
|
152
|
+
"image": "{image_name}",
|
|
153
|
+
"items": [
|
|
154
|
+
{{
|
|
155
|
+
"item_type": "IC",
|
|
156
|
+
"count_index": 1,
|
|
157
|
+
"package_marking": "one consensus visible marking shared by all ICs, or [?]-marked partial text",
|
|
158
|
+
"marking_confidence": "high | medium | low | unreadable",
|
|
159
|
+
"likely_part": "same as package_marking if visible, or unknown",
|
|
160
|
+
"description": "consensus result; include the visible IC count if clear",
|
|
161
|
+
"position_hint": "multiple ICs / board-wide IC group / etc.",
|
|
162
|
+
"needs_review": true
|
|
163
|
+
}}
|
|
164
|
+
],
|
|
165
|
+
"warnings": [],
|
|
166
|
+
"ic_marking_observations": [
|
|
167
|
+
{{
|
|
168
|
+
"position_hint": "where this individual IC appears",
|
|
169
|
+
"package_marking": "best visible marking for this individual IC, or [?]-marked partial text",
|
|
170
|
+
"marking_confidence": "high | medium | low | unreadable"
|
|
171
|
+
}}
|
|
172
|
+
]
|
|
173
|
+
}}
|
|
174
|
+
|
|
175
|
+
Rules:
|
|
176
|
+
- Return JSON only.
|
|
177
|
+
- Return exactly one consensus IC item if ICs are visible.
|
|
178
|
+
- Also list each visible IC's individual marking observation in ic_marking_observations.
|
|
179
|
+
- Individual observations may disagree; do not force them to match. Use [?] for unclear characters.
|
|
180
|
+
- Do not return multiple different consensus IC items.
|
|
181
|
+
""".strip()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
mcp = FastMCP("Vision Inventory")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def error_response(message: str, **extra: Any) -> Dict[str, Any]:
|
|
188
|
+
response: Dict[str, Any] = {
|
|
189
|
+
"error": True,
|
|
190
|
+
"message": message,
|
|
191
|
+
}
|
|
192
|
+
response.update(extra)
|
|
193
|
+
return response
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def get_cloudflare_credentials() -> Tuple[Optional[str], Optional[str], Optional[Dict[str, Any]]]:
|
|
197
|
+
account_id = os.getenv("CLOUDFLARE_ACCOUNT_ID", "").strip()
|
|
198
|
+
api_token = (
|
|
199
|
+
os.getenv("CLOUDFLARE_AUTH_TOKEN", "").strip()
|
|
200
|
+
or os.getenv("CLOUDFLARE_API_TOKEN", "").strip()
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
if not account_id:
|
|
204
|
+
return None, None, error_response("Missing CLOUDFLARE_ACCOUNT_ID environment variable.")
|
|
205
|
+
|
|
206
|
+
if not api_token:
|
|
207
|
+
return None, None, error_response(
|
|
208
|
+
"Missing Cloudflare API token. Set CLOUDFLARE_AUTH_TOKEN or CLOUDFLARE_API_TOKEN."
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
return account_id, api_token, None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def validate_image_path(image_path: str) -> Tuple[Optional[Path], Optional[Dict[str, Any]]]:
|
|
215
|
+
if not image_path or not image_path.strip():
|
|
216
|
+
return None, error_response("image_path is required.")
|
|
217
|
+
|
|
218
|
+
path = Path(image_path).expanduser()
|
|
219
|
+
|
|
220
|
+
if not path.exists():
|
|
221
|
+
return None, error_response(f"Image file does not exist: {image_path}")
|
|
222
|
+
|
|
223
|
+
if not path.is_file():
|
|
224
|
+
return None, error_response(f"Path is not a file: {image_path}")
|
|
225
|
+
|
|
226
|
+
if path.suffix.lower() not in SUPPORTED_EXTENSIONS:
|
|
227
|
+
return None, error_response(
|
|
228
|
+
f"Unsupported image extension '{path.suffix}'. Supported extensions: "
|
|
229
|
+
f"{', '.join(sorted(SUPPORTED_EXTENSIONS))}"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
return path, None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def prepare_image_data_url(
|
|
236
|
+
image_path: Path,
|
|
237
|
+
max_side: int = DEFAULT_MAX_SIDE,
|
|
238
|
+
jpeg_quality: int = DEFAULT_JPEG_QUALITY,
|
|
239
|
+
) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
|
|
240
|
+
if max_side < 256:
|
|
241
|
+
return None, error_response("max_side must be at least 256.")
|
|
242
|
+
|
|
243
|
+
if jpeg_quality < 1 or jpeg_quality > 100:
|
|
244
|
+
return None, error_response("jpeg_quality must be between 1 and 100.")
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
image = Image.open(image_path)
|
|
248
|
+
image = ImageOps.exif_transpose(image)
|
|
249
|
+
|
|
250
|
+
resample = getattr(Image, "Resampling", Image).LANCZOS
|
|
251
|
+
image.thumbnail((max_side, max_side), resample)
|
|
252
|
+
|
|
253
|
+
# Convert transparency to white background before JPEG encoding.
|
|
254
|
+
if image.mode in ("RGBA", "LA"):
|
|
255
|
+
background = Image.new("RGB", image.size, (255, 255, 255))
|
|
256
|
+
alpha = image.getchannel("A")
|
|
257
|
+
background.paste(image, mask=alpha)
|
|
258
|
+
image = background
|
|
259
|
+
else:
|
|
260
|
+
image = image.convert("RGB")
|
|
261
|
+
|
|
262
|
+
buffer = io.BytesIO()
|
|
263
|
+
image.save(buffer, format="JPEG", quality=jpeg_quality, optimize=True)
|
|
264
|
+
encoded = base64.b64encode(buffer.getvalue()).decode("utf-8")
|
|
265
|
+
return f"data:image/jpeg;base64,{encoded}", None
|
|
266
|
+
|
|
267
|
+
except Exception as exc:
|
|
268
|
+
return None, error_response(f"Failed to prepare image: {exc}")
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def workers_ai_url(account_id: str, model: str) -> str:
|
|
272
|
+
return f"https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/{model}"
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def call_workers_ai(
|
|
276
|
+
image_data_url: str,
|
|
277
|
+
image_name: str,
|
|
278
|
+
user_prompt: str,
|
|
279
|
+
account_id: str,
|
|
280
|
+
api_token: str,
|
|
281
|
+
model: str = DEFAULT_MODEL,
|
|
282
|
+
) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
|
|
283
|
+
url = workers_ai_url(account_id, model)
|
|
284
|
+
headers = {
|
|
285
|
+
"Authorization": f"Bearer {api_token}",
|
|
286
|
+
"Content-Type": "application/json",
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
payload = {
|
|
290
|
+
"messages": [
|
|
291
|
+
{
|
|
292
|
+
"role": "system",
|
|
293
|
+
"content": SYSTEM_PROMPT,
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
"role": "user",
|
|
297
|
+
"content": [
|
|
298
|
+
{
|
|
299
|
+
"type": "text",
|
|
300
|
+
"text": user_prompt,
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
"type": "image_url",
|
|
304
|
+
"image_url": {
|
|
305
|
+
"url": image_data_url,
|
|
306
|
+
},
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
},
|
|
310
|
+
],
|
|
311
|
+
"max_tokens": DEFAULT_MAX_TOKENS,
|
|
312
|
+
"temperature": DEFAULT_TEMPERATURE,
|
|
313
|
+
"top_p": DEFAULT_TOP_P,
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
try:
|
|
317
|
+
response = requests.post(url, headers=headers, json=payload, timeout=180)
|
|
318
|
+
except requests.RequestException as exc:
|
|
319
|
+
return None, error_response(f"Cloudflare request failed: {exc}", image=image_name)
|
|
320
|
+
|
|
321
|
+
try:
|
|
322
|
+
data = response.json()
|
|
323
|
+
except ValueError:
|
|
324
|
+
return None, error_response(
|
|
325
|
+
"Cloudflare returned a non-JSON response.",
|
|
326
|
+
http_status=response.status_code,
|
|
327
|
+
body=response.text[:2000],
|
|
328
|
+
image=image_name,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
if not response.ok or not data.get("success", False):
|
|
332
|
+
return None, error_response(
|
|
333
|
+
"Cloudflare Workers AI request failed.",
|
|
334
|
+
http_status=response.status_code,
|
|
335
|
+
errors=data.get("errors", []),
|
|
336
|
+
messages=data.get("messages", []),
|
|
337
|
+
image=image_name,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
result = data.get("result", {})
|
|
341
|
+
|
|
342
|
+
# Most Workers AI text-generation model responses include result.response.
|
|
343
|
+
if isinstance(result, dict):
|
|
344
|
+
if isinstance(result.get("response"), str):
|
|
345
|
+
return result["response"], None
|
|
346
|
+
|
|
347
|
+
# Some OpenAI-style responses may include choices.
|
|
348
|
+
choices = result.get("choices")
|
|
349
|
+
if isinstance(choices, list) and choices:
|
|
350
|
+
message = choices[0].get("message", {}) if isinstance(choices[0], dict) else {}
|
|
351
|
+
content = message.get("content")
|
|
352
|
+
if isinstance(content, str):
|
|
353
|
+
return content, None
|
|
354
|
+
|
|
355
|
+
return json.dumps(result), None
|
|
356
|
+
|
|
357
|
+
if isinstance(result, str):
|
|
358
|
+
return result, None
|
|
359
|
+
|
|
360
|
+
return str(result), None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def strip_markdown_fences(text: str) -> str:
|
|
364
|
+
cleaned = text.strip()
|
|
365
|
+
|
|
366
|
+
if cleaned.startswith("```"):
|
|
367
|
+
lines = cleaned.splitlines()
|
|
368
|
+
if lines and lines[0].strip().startswith("```"):
|
|
369
|
+
lines = lines[1:]
|
|
370
|
+
if lines and lines[-1].strip() == "```":
|
|
371
|
+
lines = lines[:-1]
|
|
372
|
+
cleaned = "\n".join(lines).strip()
|
|
373
|
+
|
|
374
|
+
return cleaned
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def extract_json_object(text: str) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
|
378
|
+
"""Extract the first JSON object from model text."""
|
|
379
|
+
cleaned = strip_markdown_fences(text)
|
|
380
|
+
|
|
381
|
+
# Fast path: whole response is valid JSON.
|
|
382
|
+
try:
|
|
383
|
+
parsed = json.loads(cleaned)
|
|
384
|
+
if isinstance(parsed, dict):
|
|
385
|
+
return parsed, None
|
|
386
|
+
return None, "Model returned JSON, but it was not an object."
|
|
387
|
+
except json.JSONDecodeError:
|
|
388
|
+
pass
|
|
389
|
+
|
|
390
|
+
# Robust path: find the first object starting with '{' and decode from there.
|
|
391
|
+
decoder = json.JSONDecoder()
|
|
392
|
+
for index, char in enumerate(cleaned):
|
|
393
|
+
if char != "{":
|
|
394
|
+
continue
|
|
395
|
+
try:
|
|
396
|
+
parsed, _end = decoder.raw_decode(cleaned[index:])
|
|
397
|
+
if isinstance(parsed, dict):
|
|
398
|
+
return parsed, None
|
|
399
|
+
except json.JSONDecodeError:
|
|
400
|
+
continue
|
|
401
|
+
|
|
402
|
+
return None, "Model returned invalid JSON."
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def coerce_bool(value: Any) -> bool:
|
|
406
|
+
if isinstance(value, bool):
|
|
407
|
+
return value
|
|
408
|
+
if isinstance(value, str):
|
|
409
|
+
return value.strip().lower() in {"true", "yes", "1", "y"}
|
|
410
|
+
return bool(value)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def normalize_item(item: Any, fallback_index: int) -> Dict[str, Any]:
|
|
414
|
+
default_item: Dict[str, Any] = {
|
|
415
|
+
"item_type": "unknown",
|
|
416
|
+
"count_index": fallback_index,
|
|
417
|
+
"package_marking": "unknown",
|
|
418
|
+
"marking_confidence": "unreadable",
|
|
419
|
+
"likely_part": "unknown",
|
|
420
|
+
"description": "unknown",
|
|
421
|
+
"position_hint": "unknown",
|
|
422
|
+
"needs_review": True,
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if not isinstance(item, dict):
|
|
426
|
+
return default_item
|
|
427
|
+
|
|
428
|
+
normalized = dict(default_item)
|
|
429
|
+
normalized.update({k: v for k, v in item.items() if k in normalized})
|
|
430
|
+
|
|
431
|
+
try:
|
|
432
|
+
normalized["count_index"] = int(normalized.get("count_index", fallback_index))
|
|
433
|
+
except Exception:
|
|
434
|
+
normalized["count_index"] = fallback_index
|
|
435
|
+
|
|
436
|
+
confidence = str(normalized.get("marking_confidence", "unreadable")).strip().lower()
|
|
437
|
+
if confidence not in {"high", "medium", "low", "unreadable"}:
|
|
438
|
+
confidence = "low"
|
|
439
|
+
normalized["marking_confidence"] = confidence
|
|
440
|
+
|
|
441
|
+
normalized["needs_review"] = coerce_bool(normalized.get("needs_review", True))
|
|
442
|
+
if confidence in {"low", "unreadable"}:
|
|
443
|
+
normalized["needs_review"] = True
|
|
444
|
+
|
|
445
|
+
# Ensure string fields are strings and not nulls/lists.
|
|
446
|
+
for key in [
|
|
447
|
+
"item_type",
|
|
448
|
+
"package_marking",
|
|
449
|
+
"likely_part",
|
|
450
|
+
"description",
|
|
451
|
+
"position_hint",
|
|
452
|
+
]:
|
|
453
|
+
value = normalized.get(key)
|
|
454
|
+
if value is None:
|
|
455
|
+
normalized[key] = "unknown"
|
|
456
|
+
elif not isinstance(value, str):
|
|
457
|
+
normalized[key] = str(value)
|
|
458
|
+
elif not value.strip():
|
|
459
|
+
normalized[key] = "unknown"
|
|
460
|
+
|
|
461
|
+
return normalized
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def normalize_ic_marking_observations(value: Any) -> List[Dict[str, str]]:
|
|
465
|
+
if not isinstance(value, list):
|
|
466
|
+
return []
|
|
467
|
+
|
|
468
|
+
observations: List[Dict[str, str]] = []
|
|
469
|
+
for entry in value:
|
|
470
|
+
if not isinstance(entry, dict):
|
|
471
|
+
continue
|
|
472
|
+
confidence = str(entry.get("marking_confidence", "unreadable")).strip().lower()
|
|
473
|
+
if confidence not in {"high", "medium", "low", "unreadable"}:
|
|
474
|
+
confidence = "low"
|
|
475
|
+
observations.append({
|
|
476
|
+
"position_hint": str(entry.get("position_hint", "unknown") or "unknown"),
|
|
477
|
+
"package_marking": str(entry.get("package_marking", "unknown") or "unknown"),
|
|
478
|
+
"marking_confidence": confidence,
|
|
479
|
+
})
|
|
480
|
+
return observations
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def normalize_inventory_result(result: Dict[str, Any], image_name: str) -> Dict[str, Any]:
|
|
484
|
+
normalized: Dict[str, Any] = {
|
|
485
|
+
"image": image_name,
|
|
486
|
+
"items": [],
|
|
487
|
+
"warnings": [],
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if isinstance(result.get("image"), str) and result["image"].strip():
|
|
491
|
+
# Keep only the basename to avoid leaking full local paths through model output.
|
|
492
|
+
normalized["image"] = Path(result["image"]).name
|
|
493
|
+
|
|
494
|
+
raw_items = result.get("items", [])
|
|
495
|
+
if not isinstance(raw_items, list):
|
|
496
|
+
raw_items = []
|
|
497
|
+
normalized["warnings"].append("Model response did not contain a valid items list.")
|
|
498
|
+
|
|
499
|
+
normalized["items"] = [normalize_item(item, idx + 1) for idx, item in enumerate(raw_items)]
|
|
500
|
+
|
|
501
|
+
raw_warnings = result.get("warnings", [])
|
|
502
|
+
if isinstance(raw_warnings, list):
|
|
503
|
+
normalized["warnings"].extend(str(w) for w in raw_warnings if str(w).strip())
|
|
504
|
+
elif isinstance(raw_warnings, str) and raw_warnings.strip():
|
|
505
|
+
normalized["warnings"].append(raw_warnings.strip())
|
|
506
|
+
|
|
507
|
+
observations = normalize_ic_marking_observations(result.get("ic_marking_observations"))
|
|
508
|
+
if observations:
|
|
509
|
+
normalized["ic_marking_observations"] = observations
|
|
510
|
+
|
|
511
|
+
return normalized
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def visible_ic_items(result: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
515
|
+
items = result.get("items", [])
|
|
516
|
+
if not isinstance(items, list):
|
|
517
|
+
return []
|
|
518
|
+
return [
|
|
519
|
+
item for item in items
|
|
520
|
+
if isinstance(item, dict) and str(item.get("item_type", "")).strip().lower() == "ic"
|
|
521
|
+
]
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def verify_ic_consensus_pass(
|
|
525
|
+
image_data_url: str,
|
|
526
|
+
image_name: str,
|
|
527
|
+
first_pass: Dict[str, Any],
|
|
528
|
+
account_id: str,
|
|
529
|
+
api_token: str,
|
|
530
|
+
) -> Dict[str, Any]:
|
|
531
|
+
"""Run a second vision pass that enforces the project rule that all ICs match."""
|
|
532
|
+
ic_items = visible_ic_items(first_pass)
|
|
533
|
+
if len(ic_items) < 2:
|
|
534
|
+
return first_pass
|
|
535
|
+
|
|
536
|
+
response_text, cloudflare_error = call_workers_ai(
|
|
537
|
+
image_data_url=image_data_url,
|
|
538
|
+
image_name=image_name,
|
|
539
|
+
user_prompt=build_ic_consensus_prompt(image_name, first_pass),
|
|
540
|
+
account_id=account_id,
|
|
541
|
+
api_token=api_token,
|
|
542
|
+
model=DEFAULT_MODEL,
|
|
543
|
+
)
|
|
544
|
+
if cloudflare_error:
|
|
545
|
+
first_pass.setdefault("warnings", []).append(
|
|
546
|
+
f"IC consensus verification failed: {cloudflare_error.get('message', 'unknown error')}"
|
|
547
|
+
)
|
|
548
|
+
return first_pass
|
|
549
|
+
assert response_text is not None
|
|
550
|
+
|
|
551
|
+
parsed, parse_error = extract_json_object(response_text)
|
|
552
|
+
if parse_error or parsed is None:
|
|
553
|
+
first_pass.setdefault("warnings", []).append(
|
|
554
|
+
f"IC consensus verification returned invalid JSON: {parse_error or 'unknown parse error'}"
|
|
555
|
+
)
|
|
556
|
+
first_pass["ic_consensus_raw_response"] = response_text
|
|
557
|
+
return first_pass
|
|
558
|
+
|
|
559
|
+
consensus = normalize_inventory_result(parsed, image_name)
|
|
560
|
+
previous_markings = [str(item.get("package_marking", "unknown")) for item in ic_items]
|
|
561
|
+
consensus.setdefault("warnings", []).append("Multi-pass IC consensus verification applied.")
|
|
562
|
+
consensus.setdefault("warnings", []).append(
|
|
563
|
+
"First pass IC marking candidates: " + ", ".join(previous_markings)
|
|
564
|
+
)
|
|
565
|
+
consensus["first_pass_items"] = first_pass.get("items", [])
|
|
566
|
+
return consensus
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def process_image_impl(
|
|
570
|
+
image_path: str,
|
|
571
|
+
max_side: int = DEFAULT_MAX_SIDE,
|
|
572
|
+
jpeg_quality: int = DEFAULT_JPEG_QUALITY,
|
|
573
|
+
custom_prompt: Optional[str] = None,
|
|
574
|
+
) -> Dict[str, Any]:
|
|
575
|
+
image_file, validation_error = validate_image_path(image_path)
|
|
576
|
+
if validation_error:
|
|
577
|
+
return validation_error
|
|
578
|
+
assert image_file is not None
|
|
579
|
+
|
|
580
|
+
account_id, api_token, credential_error = get_cloudflare_credentials()
|
|
581
|
+
if credential_error:
|
|
582
|
+
return credential_error
|
|
583
|
+
assert account_id is not None and api_token is not None
|
|
584
|
+
|
|
585
|
+
image_data_url, image_error = prepare_image_data_url(
|
|
586
|
+
image_file,
|
|
587
|
+
max_side=max_side,
|
|
588
|
+
jpeg_quality=jpeg_quality,
|
|
589
|
+
)
|
|
590
|
+
if image_error:
|
|
591
|
+
return image_error
|
|
592
|
+
assert image_data_url is not None
|
|
593
|
+
|
|
594
|
+
image_name = image_file.name
|
|
595
|
+
prompt = custom_prompt.strip() if custom_prompt and custom_prompt.strip() else build_default_user_prompt(image_name)
|
|
596
|
+
|
|
597
|
+
response_text, cloudflare_error = call_workers_ai(
|
|
598
|
+
image_data_url=image_data_url,
|
|
599
|
+
image_name=image_name,
|
|
600
|
+
user_prompt=prompt,
|
|
601
|
+
account_id=account_id,
|
|
602
|
+
api_token=api_token,
|
|
603
|
+
model=DEFAULT_MODEL,
|
|
604
|
+
)
|
|
605
|
+
if cloudflare_error:
|
|
606
|
+
return cloudflare_error
|
|
607
|
+
assert response_text is not None
|
|
608
|
+
|
|
609
|
+
parsed, parse_error = extract_json_object(response_text)
|
|
610
|
+
if parse_error or parsed is None:
|
|
611
|
+
return {
|
|
612
|
+
"image": image_name,
|
|
613
|
+
"items": [],
|
|
614
|
+
"warnings": [parse_error or "Model returned invalid JSON."],
|
|
615
|
+
"raw_response": response_text,
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
first_pass = normalize_inventory_result(parsed, image_name)
|
|
619
|
+
return verify_ic_consensus_pass(
|
|
620
|
+
image_data_url=image_data_url,
|
|
621
|
+
image_name=image_name,
|
|
622
|
+
first_pass=first_pass,
|
|
623
|
+
account_id=account_id,
|
|
624
|
+
api_token=api_token,
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
@mcp.tool()
|
|
629
|
+
def process_image(
|
|
630
|
+
image_path: str,
|
|
631
|
+
max_side: int = DEFAULT_MAX_SIDE,
|
|
632
|
+
jpeg_quality: int = DEFAULT_JPEG_QUALITY,
|
|
633
|
+
custom_prompt: Optional[str] = None,
|
|
634
|
+
) -> Dict[str, Any]:
|
|
635
|
+
"""
|
|
636
|
+
Analyze one electronics/PCB image and return structured visible inventory data.
|
|
637
|
+
|
|
638
|
+
This tool does not do web lookup or datasheet lookup. It only extracts visible
|
|
639
|
+
components and markings from the image.
|
|
640
|
+
"""
|
|
641
|
+
return process_image_impl(
|
|
642
|
+
image_path=image_path,
|
|
643
|
+
max_side=max_side,
|
|
644
|
+
jpeg_quality=jpeg_quality,
|
|
645
|
+
custom_prompt=custom_prompt,
|
|
646
|
+
)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def find_images_in_folder(folder: Path, recursive: bool, limit: Optional[int]) -> List[Path]:
|
|
650
|
+
iterator = folder.rglob("*") if recursive else folder.iterdir()
|
|
651
|
+
images = sorted(
|
|
652
|
+
[p for p in iterator if p.is_file() and p.suffix.lower() in SUPPORTED_EXTENSIONS],
|
|
653
|
+
key=lambda p: str(p).lower(),
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
if limit is not None:
|
|
657
|
+
images = images[: max(0, int(limit))]
|
|
658
|
+
|
|
659
|
+
return images
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
@mcp.tool()
|
|
663
|
+
def process_image_folder(
|
|
664
|
+
folder_path: str,
|
|
665
|
+
recursive: bool = False,
|
|
666
|
+
max_side: int = DEFAULT_MAX_SIDE,
|
|
667
|
+
jpeg_quality: int = DEFAULT_JPEG_QUALITY,
|
|
668
|
+
limit: Optional[int] = None,
|
|
669
|
+
) -> Dict[str, Any]:
|
|
670
|
+
"""
|
|
671
|
+
Process all supported images in a folder and return a combined inventory object.
|
|
672
|
+
|
|
673
|
+
Individual image failures are included in the errors list; the folder operation
|
|
674
|
+
continues processing the remaining images.
|
|
675
|
+
"""
|
|
676
|
+
if not folder_path or not folder_path.strip():
|
|
677
|
+
return error_response("folder_path is required.")
|
|
678
|
+
|
|
679
|
+
folder = Path(folder_path).expanduser()
|
|
680
|
+
if not folder.exists():
|
|
681
|
+
return error_response(f"Folder does not exist: {folder_path}")
|
|
682
|
+
if not folder.is_dir():
|
|
683
|
+
return error_response(f"Path is not a folder: {folder_path}")
|
|
684
|
+
|
|
685
|
+
try:
|
|
686
|
+
images = find_images_in_folder(folder, recursive=recursive, limit=limit)
|
|
687
|
+
except Exception as exc:
|
|
688
|
+
return error_response(f"Failed to scan folder: {exc}")
|
|
689
|
+
|
|
690
|
+
if not images:
|
|
691
|
+
return {
|
|
692
|
+
"source_folder": str(folder),
|
|
693
|
+
"image_count": 0,
|
|
694
|
+
"processed_count": 0,
|
|
695
|
+
"failed_count": 0,
|
|
696
|
+
"results": [],
|
|
697
|
+
"errors": [],
|
|
698
|
+
"warnings": ["No supported image files found in folder."],
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
results: List[Dict[str, Any]] = []
|
|
702
|
+
errors: List[Dict[str, Any]] = []
|
|
703
|
+
|
|
704
|
+
for image_path in images:
|
|
705
|
+
try:
|
|
706
|
+
result = process_image_impl(
|
|
707
|
+
image_path=str(image_path),
|
|
708
|
+
max_side=max_side,
|
|
709
|
+
jpeg_quality=jpeg_quality,
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
if result.get("error"):
|
|
713
|
+
errors.append({
|
|
714
|
+
"image": image_path.name,
|
|
715
|
+
"error": result.get("message", "Unknown error"),
|
|
716
|
+
"details": result,
|
|
717
|
+
})
|
|
718
|
+
else:
|
|
719
|
+
results.append(result)
|
|
720
|
+
|
|
721
|
+
except Exception as exc:
|
|
722
|
+
errors.append({
|
|
723
|
+
"image": image_path.name,
|
|
724
|
+
"error": str(exc),
|
|
725
|
+
})
|
|
726
|
+
|
|
727
|
+
return {
|
|
728
|
+
"source_folder": str(folder),
|
|
729
|
+
"image_count": len(images),
|
|
730
|
+
"processed_count": len(results),
|
|
731
|
+
"failed_count": len(errors),
|
|
732
|
+
"results": results,
|
|
733
|
+
"errors": errors,
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def count_inventory_rows(inventory: Dict[str, Any]) -> int:
|
|
738
|
+
if isinstance(inventory.get("items"), list):
|
|
739
|
+
return len(inventory["items"])
|
|
740
|
+
|
|
741
|
+
results = inventory.get("results", [])
|
|
742
|
+
if isinstance(results, list):
|
|
743
|
+
count = 0
|
|
744
|
+
for result in results:
|
|
745
|
+
if isinstance(result, dict) and isinstance(result.get("items"), list):
|
|
746
|
+
count += len(result["items"])
|
|
747
|
+
return count
|
|
748
|
+
|
|
749
|
+
return 0
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def flatten_inventory_for_csv(inventory: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
753
|
+
rows: List[Dict[str, Any]] = []
|
|
754
|
+
|
|
755
|
+
if isinstance(inventory.get("items"), list):
|
|
756
|
+
image_results = [inventory]
|
|
757
|
+
else:
|
|
758
|
+
raw_results = inventory.get("results", [])
|
|
759
|
+
image_results = raw_results if isinstance(raw_results, list) else []
|
|
760
|
+
|
|
761
|
+
for result in image_results:
|
|
762
|
+
if not isinstance(result, dict):
|
|
763
|
+
continue
|
|
764
|
+
|
|
765
|
+
image_name = str(result.get("image", "unknown"))
|
|
766
|
+
warnings = result.get("warnings", [])
|
|
767
|
+
if isinstance(warnings, list):
|
|
768
|
+
warnings_text = " | ".join(str(w) for w in warnings)
|
|
769
|
+
else:
|
|
770
|
+
warnings_text = str(warnings)
|
|
771
|
+
|
|
772
|
+
items = result.get("items", [])
|
|
773
|
+
if not isinstance(items, list):
|
|
774
|
+
continue
|
|
775
|
+
|
|
776
|
+
for item in items:
|
|
777
|
+
if not isinstance(item, dict):
|
|
778
|
+
continue
|
|
779
|
+
rows.append({
|
|
780
|
+
"image": image_name,
|
|
781
|
+
"item_type": item.get("item_type", "unknown"),
|
|
782
|
+
"count_index": item.get("count_index", ""),
|
|
783
|
+
"package_marking": item.get("package_marking", "unknown"),
|
|
784
|
+
"marking_confidence": item.get("marking_confidence", "unreadable"),
|
|
785
|
+
"likely_part": item.get("likely_part", "unknown"),
|
|
786
|
+
"description": item.get("description", "unknown"),
|
|
787
|
+
"position_hint": item.get("position_hint", "unknown"),
|
|
788
|
+
"needs_review": item.get("needs_review", True),
|
|
789
|
+
"warnings": warnings_text,
|
|
790
|
+
})
|
|
791
|
+
|
|
792
|
+
return rows
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
@mcp.tool()
|
|
796
|
+
def save_inventory(
|
|
797
|
+
inventory: Dict[str, Any],
|
|
798
|
+
output_path: str,
|
|
799
|
+
format: str = "json",
|
|
800
|
+
) -> Dict[str, Any]:
|
|
801
|
+
"""
|
|
802
|
+
Save inventory results to disk as JSON or CSV.
|
|
803
|
+
|
|
804
|
+
The input inventory can be the result of process_image or process_image_folder.
|
|
805
|
+
"""
|
|
806
|
+
if not isinstance(inventory, dict):
|
|
807
|
+
return error_response("inventory must be an object/dict.")
|
|
808
|
+
|
|
809
|
+
if not output_path or not output_path.strip():
|
|
810
|
+
return error_response("output_path is required.")
|
|
811
|
+
|
|
812
|
+
output = Path(output_path).expanduser()
|
|
813
|
+
fmt = format.strip().lower()
|
|
814
|
+
|
|
815
|
+
if fmt not in {"json", "csv"}:
|
|
816
|
+
return error_response("format must be either 'json' or 'csv'.")
|
|
817
|
+
|
|
818
|
+
try:
|
|
819
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
820
|
+
|
|
821
|
+
if fmt == "json":
|
|
822
|
+
output.write_text(json.dumps(inventory, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
823
|
+
row_count = count_inventory_rows(inventory)
|
|
824
|
+
|
|
825
|
+
else:
|
|
826
|
+
rows = flatten_inventory_for_csv(inventory)
|
|
827
|
+
fieldnames = [
|
|
828
|
+
"image",
|
|
829
|
+
"item_type",
|
|
830
|
+
"count_index",
|
|
831
|
+
"package_marking",
|
|
832
|
+
"marking_confidence",
|
|
833
|
+
"likely_part",
|
|
834
|
+
"description",
|
|
835
|
+
"position_hint",
|
|
836
|
+
"needs_review",
|
|
837
|
+
"warnings",
|
|
838
|
+
]
|
|
839
|
+
|
|
840
|
+
with output.open("w", newline="", encoding="utf-8") as csv_file:
|
|
841
|
+
writer = csv.DictWriter(csv_file, fieldnames=fieldnames)
|
|
842
|
+
writer.writeheader()
|
|
843
|
+
writer.writerows(rows)
|
|
844
|
+
|
|
845
|
+
row_count = len(rows)
|
|
846
|
+
|
|
847
|
+
return {
|
|
848
|
+
"saved": True,
|
|
849
|
+
"output_path": str(output),
|
|
850
|
+
"format": fmt,
|
|
851
|
+
"row_count": row_count,
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
except Exception as exc:
|
|
855
|
+
return error_response(f"Failed to save inventory: {exc}", output_path=str(output), format=fmt)
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
if __name__ == "__main__":
|
|
859
|
+
mcp.run(transport="stdio")
|