vision-electronic-indexing-pi 0.1.6 → 0.1.7
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 +6 -4
- package/.pi/extensions/vision-inventory-mcp/index.ts +74 -32
- package/.pi/skills/vision-inventory-workflow/SKILL.md +2 -2
- package/README.md +162 -17
- package/package.json +1 -1
- package/requirements.txt +5 -5
- package/scripts/inventory_folder_to_csv.py +153 -16
- package/vision_inventory_mcp.py +30 -15
|
@@ -16,7 +16,7 @@ Then in Pi:
|
|
|
16
16
|
/vision-inventory-setup
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
Setup checks Python dependencies,
|
|
19
|
+
Setup creates/checks a Pi-managed Python virtual environment at `~/.pi/agent/vision-inventory/.venv`, installs Python dependencies there when approved, warns that datasheet lookup needs a separate web-search/browser capability, and prompts for Cloudflare Workers AI API token credentials when needed.
|
|
20
20
|
|
|
21
21
|
Credentials are stored at:
|
|
22
22
|
|
|
@@ -24,6 +24,8 @@ Credentials are stored at:
|
|
|
24
24
|
~/.pi/agent/vision-inventory/credentials.json
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
+
The file is written with `chmod 600` when supported. Token input may be visible depending on your Pi UI; avoid entering credentials while screen sharing.
|
|
28
|
+
|
|
27
29
|
Change them later with:
|
|
28
30
|
|
|
29
31
|
```text
|
|
@@ -66,15 +68,15 @@ Options are forwarded to `scripts/inventory_folder_to_csv.py`, such as `--recurs
|
|
|
66
68
|
|
|
67
69
|
- `vision_inventory_process_image` — analyze one electronics/PCB image.
|
|
68
70
|
- `vision_inventory_process_folder` — analyze all supported images in a folder.
|
|
69
|
-
- `vision_inventory_save` — save inventory output as JSON or CSV.
|
|
71
|
+
- `vision_inventory_save` — save inventory output as JSON or quick CSV export. Use `/vision-inventory-bom` for the full BOM/evidence workflow.
|
|
70
72
|
|
|
71
73
|
## External dependencies not bundled
|
|
72
74
|
|
|
73
75
|
This package intentionally does **not** bundle:
|
|
74
76
|
|
|
75
|
-
- Python packages from `requirements.txt`: `mcp`, `requests`, `pillow`, `python-dotenv`; optional `pillow-heif`.
|
|
77
|
+
- Python packages from `requirements.txt`: `mcp`, `requests`, `pillow`, `python-dotenv`; optional `pillow-heif`. Pi setup installs these into the package-managed venv when approved.
|
|
76
78
|
- A Pi web-search/browser tool or skill for datasheet lookup.
|
|
77
|
-
- Cloudflare Workers AI credentials.
|
|
79
|
+
- Cloudflare Workers AI API token credentials.
|
|
78
80
|
|
|
79
81
|
## Output
|
|
80
82
|
|
|
@@ -9,10 +9,12 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
10
|
|
|
11
11
|
const SERVER_FILE = "vision_inventory_mcp.py";
|
|
12
|
-
const
|
|
12
|
+
const BASE_PYTHON_COMMAND = process.env.PI_VISION_INVENTORY_PYTHON || "python3";
|
|
13
13
|
const MAX_RESULT_CHARS = 50_000;
|
|
14
14
|
const EXTENSION_DIR = dirname(fileURLToPath(import.meta.url));
|
|
15
15
|
const CONFIG_DIR = join(homedir(), ".pi", "agent", "vision-inventory");
|
|
16
|
+
const VENV_DIR = join(CONFIG_DIR, ".venv");
|
|
17
|
+
const VENV_PYTHON = process.platform === "win32" ? join(VENV_DIR, "Scripts", "python.exe") : join(VENV_DIR, "bin", "python");
|
|
16
18
|
const CREDENTIALS_FILE = join(CONFIG_DIR, "credentials.json");
|
|
17
19
|
|
|
18
20
|
type VisionCredentials = {
|
|
@@ -57,7 +59,8 @@ class McpStdioClient {
|
|
|
57
59
|
throw new Error(`Cannot find ${SERVER_FILE} in ${this.packageRoot}`);
|
|
58
60
|
}
|
|
59
61
|
|
|
60
|
-
|
|
62
|
+
const pythonCommand = await getPythonCommand();
|
|
63
|
+
this.proc = spawn(pythonCommand, [serverPath], {
|
|
61
64
|
cwd: this.packageRoot,
|
|
62
65
|
env: await buildPythonEnv(),
|
|
63
66
|
stdio: ["pipe", "pipe", "pipe"],
|
|
@@ -72,7 +75,7 @@ class McpStdioClient {
|
|
|
72
75
|
this.lastStderr = (this.lastStderr + chunk).slice(-10_000);
|
|
73
76
|
});
|
|
74
77
|
this.proc.on("error", (error) => {
|
|
75
|
-
const err = new Error(`Failed to start Vision Inventory MCP server
|
|
78
|
+
const err = new Error(`Failed to start Vision Inventory MCP server: ${error.message}`);
|
|
76
79
|
for (const pending of this.pending.values()) pending.reject(err);
|
|
77
80
|
this.pending.clear();
|
|
78
81
|
this.initialized = false;
|
|
@@ -215,6 +218,44 @@ async function buildPythonEnv(): Promise<NodeJS.ProcessEnv> {
|
|
|
215
218
|
};
|
|
216
219
|
}
|
|
217
220
|
|
|
221
|
+
async function getPythonCommand(): Promise<string> {
|
|
222
|
+
return existsSync(VENV_PYTHON) ? VENV_PYTHON : BASE_PYTHON_COMMAND;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async function ensurePythonEnvironment(pi: ExtensionAPI, packageRoot: string, ctx: { ui: any; hasUI: boolean }): Promise<boolean> {
|
|
226
|
+
if (!existsSync(VENV_PYTHON)) {
|
|
227
|
+
if (!ctx.hasUI) {
|
|
228
|
+
ctx.ui.notify(`Vision Inventory Python venv is missing. Run /vision-inventory-setup interactively, or install dependencies manually with ${BASE_PYTHON_COMMAND} -m pip install -r ${join(packageRoot, "requirements.txt")}`, "error");
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const create = await ctx.ui.confirm("Create Vision Inventory Python virtual environment?", `${BASE_PYTHON_COMMAND} -m venv ${VENV_DIR}`);
|
|
233
|
+
if (!create) {
|
|
234
|
+
ctx.ui.notify("Python environment setup skipped. Vision Inventory tools may fail until dependencies are installed.", "error");
|
|
235
|
+
return false;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
239
|
+
const venvResult = await pi.exec(BASE_PYTHON_COMMAND, ["-m", "venv", VENV_DIR], { timeout: 120_000 });
|
|
240
|
+
if (venvResult.code !== 0) throw new Error(venvResult.stderr || venvResult.stdout || "Python venv creation failed");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const deps = await pi.exec(VENV_PYTHON, ["-c", "import mcp, requests, PIL, dotenv; print('ok')"], { timeout: 10_000 });
|
|
244
|
+
if (deps.code === 0) return true;
|
|
245
|
+
|
|
246
|
+
if (!ctx.hasUI) {
|
|
247
|
+
ctx.ui.notify(`Missing Python dependencies in ${VENV_DIR}. Run: ${VENV_PYTHON} -m pip install -r ${join(packageRoot, "requirements.txt")}`, "error");
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const install = await ctx.ui.confirm("Install Python dependencies into the Vision Inventory venv?", `${VENV_PYTHON} -m pip install -r ${join(packageRoot, "requirements.txt")}`);
|
|
252
|
+
if (!install) return false;
|
|
253
|
+
|
|
254
|
+
const result = await pi.exec(VENV_PYTHON, ["-m", "pip", "install", "-r", join(packageRoot, "requirements.txt")], { timeout: 120_000 });
|
|
255
|
+
if (result.code !== 0) throw new Error(result.stderr || result.stdout || "pip install failed");
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
|
|
218
259
|
function extractMcpPayload(result: unknown): unknown {
|
|
219
260
|
const maybe = result as { content?: Array<{ type?: string; text?: string }>; structuredContent?: unknown } | null;
|
|
220
261
|
if (maybe && typeof maybe === "object") {
|
|
@@ -304,9 +345,10 @@ async function runBatchWorkflow(packageRoot: string, userCwd: string, argsLine:
|
|
|
304
345
|
}
|
|
305
346
|
|
|
306
347
|
const env = await buildPythonEnv();
|
|
348
|
+
const pythonCommand = await getPythonCommand();
|
|
307
349
|
|
|
308
350
|
return new Promise((resolve, reject) => {
|
|
309
|
-
const proc = spawn(
|
|
351
|
+
const proc = spawn(pythonCommand, [scriptPath, ...args], {
|
|
310
352
|
cwd: packageRoot,
|
|
311
353
|
env,
|
|
312
354
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -421,14 +463,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
421
463
|
},
|
|
422
464
|
});
|
|
423
465
|
|
|
424
|
-
async function runSetup(ctx: { ui: any; hasUI: boolean }, forceCredentials = false): Promise<
|
|
466
|
+
async function runSetup(ctx: { ui: any; hasUI: boolean }, forceCredentials = false): Promise<boolean> {
|
|
425
467
|
const existing = await loadCredentials();
|
|
426
468
|
const hasEffectiveAccountId = Boolean(process.env.CLOUDFLARE_ACCOUNT_ID || existing.cloudflareAccountId);
|
|
427
469
|
const hasEffectiveToken = Boolean(process.env.CLOUDFLARE_AUTH_TOKEN || process.env.CLOUDFLARE_API_TOKEN || existing.cloudflareAuthToken);
|
|
428
470
|
if ((!hasEffectiveAccountId || !hasEffectiveToken || forceCredentials) && ctx.hasUI) {
|
|
429
|
-
ctx.ui.notify("Vision Inventory stores Cloudflare credentials in ~/.pi/agent/vision-inventory/credentials.json with chmod 600 when supported. Use /vision-inventory-credentials to change them.", "info");
|
|
471
|
+
ctx.ui.notify("Vision Inventory stores Cloudflare credentials in ~/.pi/agent/vision-inventory/credentials.json with chmod 600 when supported. Use /vision-inventory-credentials to change them. Token input may be visible depending on your Pi UI; avoid entering credentials while screen sharing.", "info");
|
|
430
472
|
const cloudflareAccountId = await ctx.ui.input("Cloudflare account ID", existing.cloudflareAccountId || "");
|
|
431
|
-
const cloudflareAuthToken = await ctx.ui.input("Cloudflare Workers AI token", existing.cloudflareAuthToken ? "<keep existing; paste new token to replace>" : "");
|
|
473
|
+
const cloudflareAuthToken = await ctx.ui.input("Cloudflare Workers AI API token (leave blank for default)", existing.cloudflareAuthToken ? "<keep existing; paste new token to replace>" : "");
|
|
432
474
|
const workersAiModel = await ctx.ui.input("Workers AI model", existing.workersAiModel || "@cf/meta/llama-4-scout-17b-16e-instruct");
|
|
433
475
|
await saveCredentials({
|
|
434
476
|
cloudflareAccountId: cloudflareAccountId || existing.cloudflareAccountId,
|
|
@@ -439,31 +481,16 @@ export default function (pi: ExtensionAPI) {
|
|
|
439
481
|
ctx.ui.notify("Missing Cloudflare credentials. Set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_AUTH_TOKEN/CLOUDFLARE_API_TOKEN, or run /vision-inventory-setup in interactive Pi.", "error");
|
|
440
482
|
}
|
|
441
483
|
|
|
442
|
-
const
|
|
443
|
-
if (
|
|
444
|
-
if (!ctx.hasUI) {
|
|
445
|
-
ctx.ui.notify(`Missing Python dependencies. Run: ${PYTHON_COMMAND} -m pip install -r ${join(packageRoot, "requirements.txt")}`, "error");
|
|
446
|
-
return;
|
|
447
|
-
}
|
|
448
|
-
const install = await ctx.ui.confirm("Install Python dependencies?", `${PYTHON_COMMAND} -m pip install -r ${join(packageRoot, "requirements.txt")}`);
|
|
449
|
-
if (install) {
|
|
450
|
-
const result = await pi.exec(PYTHON_COMMAND, ["-m", "pip", "install", "-r", join(packageRoot, "requirements.txt")], { timeout: 120_000 });
|
|
451
|
-
if (result.code !== 0) throw new Error(result.stderr || result.stdout || "pip install failed");
|
|
452
|
-
}
|
|
453
|
-
}
|
|
484
|
+
const pythonReady = await ensurePythonEnvironment(pi, packageRoot, ctx);
|
|
485
|
+
if (!pythonReady) return false;
|
|
454
486
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
if (!hasWebDependency) {
|
|
459
|
-
ctx.ui.notify("Agent datasheet enrichment requires a web-search/browser Pi tool or skill. This package intentionally does not bundle one; install/enable your preferred search dependency before using /vision-inventory-agent-bom.", "error");
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
ctx.ui.notify(`Vision Inventory setup checked. Package root: ${packageRoot}`, "info");
|
|
487
|
+
ctx.ui.notify("Datasheet enrichment requires a separate web-search/browser Pi tool or skill. This package does not provide one; if your agent cannot search the web, fill datasheet_cache.json manually.", "info");
|
|
488
|
+
ctx.ui.notify(`Vision Inventory setup checked. Package root: ${packageRoot}. Python: ${await getPythonCommand()}`, "info");
|
|
489
|
+
return true;
|
|
463
490
|
}
|
|
464
491
|
|
|
465
492
|
pi.registerCommand("vision-inventory-setup", {
|
|
466
|
-
description: "Configure Vision Inventory credentials and check Python
|
|
493
|
+
description: "Configure Vision Inventory credentials and check Python dependencies",
|
|
467
494
|
handler: async (args, ctx) => {
|
|
468
495
|
try {
|
|
469
496
|
await runSetup(ctx, args.includes("--reset") || args.includes("--credentials"));
|
|
@@ -510,10 +537,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
510
537
|
return;
|
|
511
538
|
}
|
|
512
539
|
|
|
513
|
-
await runSetup(ctx, false);
|
|
514
|
-
|
|
515
|
-
const
|
|
516
|
-
const
|
|
540
|
+
const setupOk = await runSetup(ctx, false);
|
|
541
|
+
if (!setupOk) return;
|
|
542
|
+
const normalized = normalizeWorkflowArgs(ctx.cwd, parsed);
|
|
543
|
+
const normalizedArgs = normalized.map((arg) => JSON.stringify(arg)).join(" ");
|
|
544
|
+
const outputDir = normalized[1];
|
|
545
|
+
const pythonCommand = await getPythonCommand();
|
|
546
|
+
|
|
547
|
+
ctx.ui.setStatus("vision-inventory", "Running initial vision workflow...");
|
|
548
|
+
try {
|
|
549
|
+
const initialOutput = await runBatchWorkflow(packageRoot, ctx.cwd, args || "");
|
|
550
|
+
ctx.ui.notify(initialOutput.slice(-4000), "info");
|
|
551
|
+
} catch (error) {
|
|
552
|
+
ctx.ui.notify(error instanceof Error ? error.message.slice(-4000) : String(error), "error");
|
|
553
|
+
ctx.ui.setStatus("vision-inventory", "");
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
ctx.ui.setStatus("vision-inventory", "");
|
|
557
|
+
|
|
558
|
+
const prompt = `Continue the Vision Electronic Indexing workflow as an agent.\n\nThe deterministic vision step has already been run by the Pi command.\nPackage root containing the bundled Python workflow: ${packageRoot}\nCommand arguments, already resolved relative to the user's cwd: ${normalizedArgs}\nOutput directory: ${outputDir}\n\nImportant external agent dependency: datasheet enrichment requires a web-search/browser Pi tool or skill. This package intentionally does not bundle a web-search dependency. If no search/browser tool is available, stop after reviewing ${outputDir}/parts_to_lookup.json and tell the user to fill ${outputDir}/datasheet_cache.json manually.\n\nDo these remaining steps end-to-end:\n1. Read ${outputDir}/parts_to_lookup.json.\n2. For every part, web-search for a datasheet. Prefer official manufacturer pages/PDFs.\n3. Write ${outputDir}/datasheet_cache.json using ${outputDir}/datasheet_cache.template.json as the exact shape.\n4. Rerun: ${pythonCommand} ${join(packageRoot, "scripts", "inventory_folder_to_csv.py")} ${normalizedArgs} --skip-vision\n5. Read ${outputDir}/inventory.csv and ${outputDir}/inventory_evidence.csv.\n6. Summarize final BOM rows and call out every uncertainty.\n\nRules:\n- Do not invent datasheets, manufacturers, or descriptions.\n- If an exact candidate part has no official datasheet but search results strongly indicate a likely OCR correction, keep the original candidate as the datasheet_cache key and set normalized_part to the official datasheet part number. Example: key SN74AS283N may normalize to SN74LS283N when official TI results match the family/function/package and the image could plausibly confuse A with 4/LS.\n- Only set verified=true for an OCR correction when official source evidence and visual/package context make the correction highly likely; otherwise set verified=false and explain in notes.\n- Include OCR correction notes such as: \"SN74AS283N appears to be OCR for SN74LS283N; verified against TI datasheet.\"\n- Set verified=false if the part or datasheet match is uncertain.\n- Keep descriptions short, like: \"74ls (4 bit) adder low power schottky ttl 5v DIP\".\n- Preserve raw JSON and evidence files.\n- Do not expose Cloudflare credentials.\n- If a command fails because credentials or Python dependencies are missing, tell the user to run /vision-inventory-setup or /vision-inventory-credentials.`;
|
|
517
559
|
|
|
518
560
|
await ctx.sendUserMessage(prompt);
|
|
519
561
|
},
|
|
@@ -13,9 +13,9 @@ This package intentionally does **not** bundle these dependencies:
|
|
|
13
13
|
|
|
14
14
|
- Python packages from `requirements.txt`: `mcp`, `requests`, `pillow`, `python-dotenv`; optional `pillow-heif` for HEIC/HEIF.
|
|
15
15
|
- A Pi web-search/browser tool or skill for datasheet enrichment.
|
|
16
|
-
- Cloudflare Workers AI credentials.
|
|
16
|
+
- Cloudflare Workers AI API token credentials.
|
|
17
17
|
|
|
18
|
-
Use `/vision-inventory-setup` to configure credentials
|
|
18
|
+
Use `/vision-inventory-setup` to configure credentials, create/check the Pi-managed Python venv, install Python dependencies when approved, and see the web-search/browser requirement warning. Use `/vision-inventory-credentials` to change stored Cloudflare credentials.
|
|
19
19
|
|
|
20
20
|
## Preferred Command
|
|
21
21
|
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Vision Electronic Indexing for Pi
|
|
2
2
|
|
|
3
|
-
Agent-assisted electronics/PCB photo indexing for Pi. The package processes images with Cloudflare Workers AI, extracts visible IC/package markings, prepares parts for datasheet lookup, and produces an enriched inventory CSV.
|
|
3
|
+
Agent-assisted electronics parts/PCB photo indexing for Pi. The package processes images with Cloudflare Workers AI, extracts visible IC/package markings, prepares parts for datasheet lookup, and produces an enriched inventory CSV.
|
|
4
4
|
|
|
5
5
|
Typical flow:
|
|
6
6
|
|
|
@@ -10,6 +10,12 @@ photos -> vision extraction -> raw JSON + evidence -> agent datasheet verificati
|
|
|
10
10
|
|
|
11
11
|
The vision step does **not** perform datasheet lookup or invent part details. Datasheet enrichment is handled by a Pi agent with a web-search/browser tool or by manual review.
|
|
12
12
|
|
|
13
|
+
## Which setup should I use?
|
|
14
|
+
|
|
15
|
+
- **Using Pi?** Install the package with `pi install npm:vision-electronic-indexing-pi`, then run `/vision-inventory-setup`.
|
|
16
|
+
- **Using Claude Code, Codex CLI, OpenCode, Cursor, or another MCP-capable harness?** Use the recommended universal installer in `.universal/scripts/quick-install.sh`.
|
|
17
|
+
- **Using plain Python or manual MCP configuration?** Install `requirements.txt`, configure Cloudflare credentials, and run `vision_inventory_mcp.py` directly.
|
|
18
|
+
|
|
13
19
|
## Quick setup with Pi
|
|
14
20
|
|
|
15
21
|
### 1. Install the Pi package
|
|
@@ -20,9 +26,9 @@ pi install npm:vision-electronic-indexing-pi
|
|
|
20
26
|
|
|
21
27
|
For local development from this repository, open the repo in Pi and trust the project. Do not also install the npm package while working inside this repo, because the project-local extension and npm package register the same tools.
|
|
22
28
|
|
|
23
|
-
### 2.
|
|
29
|
+
### 2. Plan for datasheet web search
|
|
24
30
|
|
|
25
|
-
Datasheet enrichment requires a Pi web-search or browser tool/skill. This package intentionally does **not** bundle one.
|
|
31
|
+
Datasheet enrichment requires a separate Pi web-search or browser tool/skill. This package intentionally does **not** bundle one and does not try to auto-detect one because detection is unreliable.
|
|
26
32
|
|
|
27
33
|
Examples of acceptable capabilities:
|
|
28
34
|
|
|
@@ -30,7 +36,7 @@ Examples of acceptable capabilities:
|
|
|
30
36
|
- a browser automation skill
|
|
31
37
|
- another trusted web-search extension/tool
|
|
32
38
|
|
|
33
|
-
If no search/browser capability is available, the
|
|
39
|
+
If no search/browser capability is available, the workflow can still generate `parts_to_lookup.json`, but it cannot verify datasheets. Fill `datasheet_cache.json` manually in that case.
|
|
34
40
|
|
|
35
41
|
### 3. Configure Cloudflare credentials
|
|
36
42
|
|
|
@@ -40,7 +46,7 @@ Start Pi and run:
|
|
|
40
46
|
/vision-inventory-setup
|
|
41
47
|
```
|
|
42
48
|
|
|
43
|
-
The setup command checks Python dependencies,
|
|
49
|
+
The setup command creates/checks a Pi-managed Python virtual environment under `~/.pi/agent/vision-inventory/.venv`, installs Python dependencies there when approved, warns that datasheet enrichment needs a separate web-search/browser capability, and prompts for Cloudflare Workers AI API token credentials the first time.
|
|
44
50
|
|
|
45
51
|
Credentials are stored at:
|
|
46
52
|
|
|
@@ -48,7 +54,7 @@ Credentials are stored at:
|
|
|
48
54
|
~/.pi/agent/vision-inventory/credentials.json
|
|
49
55
|
```
|
|
50
56
|
|
|
51
|
-
The file is written with `chmod 600` when supported.
|
|
57
|
+
The file is written with `chmod 600` when supported. Token input may be visible depending on your Pi UI; avoid entering credentials while screen sharing.
|
|
52
58
|
|
|
53
59
|
To change credentials later:
|
|
54
60
|
|
|
@@ -60,9 +66,9 @@ Environment variables also work and override stored credentials:
|
|
|
60
66
|
|
|
61
67
|
```bash
|
|
62
68
|
export CLOUDFLARE_ACCOUNT_ID=your_account_id
|
|
63
|
-
export CLOUDFLARE_AUTH_TOKEN=
|
|
64
|
-
#
|
|
65
|
-
export CLOUDFLARE_API_TOKEN=
|
|
69
|
+
export CLOUDFLARE_AUTH_TOKEN=your_workers_ai_api_token
|
|
70
|
+
# CLOUDFLARE_API_TOKEN is also accepted as an alias:
|
|
71
|
+
export CLOUDFLARE_API_TOKEN=your_workers_ai_api_token
|
|
66
72
|
```
|
|
67
73
|
|
|
68
74
|
Optional model override:
|
|
@@ -71,6 +77,112 @@ Optional model override:
|
|
|
71
77
|
export WORKERS_AI_MODEL=@cf/meta/llama-4-scout-17b-16e-instruct
|
|
72
78
|
```
|
|
73
79
|
|
|
80
|
+
## Other harnesses / universal MCP compatibility
|
|
81
|
+
|
|
82
|
+
_Contributed by user [@Brun0-v](https://github.com/Brun0-V)_
|
|
83
|
+
|
|
84
|
+
This repository also includes a harness-neutral compatibility layer in `.universal/` for MCP-capable coding agents such as OpenCode, Claude Code, Codex CLI, Cursor, and similar clients.
|
|
85
|
+
|
|
86
|
+
The universal layer does **not** replace the Pi package integration. Pi users should keep using the commands above.
|
|
87
|
+
|
|
88
|
+
For other harnesses, use the universal installer:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
curl -fsSL https://raw.githubusercontent.com/Pichi-Cell/vision-electronic-indexing-mcp/main/.universal/scripts/quick-install.sh -o /tmp/vei-install.sh && bash /tmp/vei-install.sh
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
This installs to `~/.vei/`, sets up a Python venv, prompts for Cloudflare Workers AI API token credentials, installs the agent skill, and **automatically configures the MCP server** in your agent's settings. **Requires an MCP-capable agent** (OpenCode, Claude Code, Codex CLI, Cursor, etc.).
|
|
95
|
+
|
|
96
|
+
Warning: some MCP clients store environment variables in plaintext JSON config files. Prefer shell environment variables or your agent's secret storage if available.
|
|
97
|
+
|
|
98
|
+
Other harnesses can also connect directly to the Python MCP server:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
python3 /path/to/vision-electronic-indexing-mcp/vision_inventory_mcp.py
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### 1. Manual setup: install Python dependencies
|
|
105
|
+
|
|
106
|
+
From the repository root:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
python3 -m pip install -r requirements.txt
|
|
110
|
+
# Optional for iPhone HEIC/HEIF photos:
|
|
111
|
+
# python3 -m pip install pillow-heif
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### 2. Configure Cloudflare credentials
|
|
115
|
+
|
|
116
|
+
Either copy `.env.example` to `.env` in the repository root:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
cp .env.example .env
|
|
120
|
+
# edit .env and set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_AUTH_TOKEN
|
|
121
|
+
# CLOUDFLARE_AUTH_TOKEN should be your Cloudflare Workers AI API token.
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
or put the credentials directly in your harness MCP server configuration. Be aware that many harness config files store these values in plaintext.
|
|
125
|
+
|
|
126
|
+
### 3. Add the MCP server to your harness
|
|
127
|
+
|
|
128
|
+
Example config snippets are provided in:
|
|
129
|
+
|
|
130
|
+
```text
|
|
131
|
+
.universal/configs/opencode.json.example
|
|
132
|
+
.universal/configs/claude.json.example
|
|
133
|
+
.universal/configs/codex.json.example
|
|
134
|
+
.universal/configs/cursor.json.example
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Each config should point to the repository-root server file, for example:
|
|
138
|
+
|
|
139
|
+
```json
|
|
140
|
+
{
|
|
141
|
+
"mcpServers": {
|
|
142
|
+
"vision-inventory": {
|
|
143
|
+
"command": "python3",
|
|
144
|
+
"args": ["/path/to/vision-electronic-indexing-mcp/vision_inventory_mcp.py"],
|
|
145
|
+
"env": {
|
|
146
|
+
"CLOUDFLARE_ACCOUNT_ID": "your_cloudflare_account_id",
|
|
147
|
+
"CLOUDFLARE_AUTH_TOKEN": "your_cloudflare_workers_ai_api_token"
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
The `.universal/configs/*.json.example` files are strict JSON; copy the appropriate file into your harness config location and adjust paths/credentials. OpenCode uses a harness-specific `mcp` shape; the other examples use `mcpServers`.
|
|
155
|
+
|
|
156
|
+
The older `.universal/setup/install.sh` and `.universal/setup/install.ps1` scripts are manual/legacy helpers. Prefer `.universal/scripts/quick-install.sh` for automated universal setup.
|
|
157
|
+
|
|
158
|
+
The raw MCP server exposes these tool names:
|
|
159
|
+
|
|
160
|
+
| Tool | Purpose |
|
|
161
|
+
|---|---|
|
|
162
|
+
| `process_image` | Analyze one electronics/PCB image. |
|
|
163
|
+
| `process_image_folder` | Analyze a folder of supported images. |
|
|
164
|
+
| `save_inventory` | Save inventory output as JSON or quick CSV export. Use the batch workflow for full BOM/evidence output. |
|
|
165
|
+
|
|
166
|
+
### 4. Install the universal skill/prompt, if your harness supports them
|
|
167
|
+
|
|
168
|
+
Universal workflow assets are available at:
|
|
169
|
+
|
|
170
|
+
```text
|
|
171
|
+
.universal/skills/vision-inventory-workflow/SKILL.md
|
|
172
|
+
.universal/prompts/vision-inventory-agent-bom.md
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Copy them into your harness-specific skills/prompts location. The skill instructs the agent to run the deterministic workflow, read `parts_to_lookup.json`, verify datasheets with web search, fill `datasheet_cache.json`, regenerate the CSV with `--skip-vision`, and summarize uncertainties.
|
|
176
|
+
|
|
177
|
+
The deterministic workflow command is the same as the manual shell workflow:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
python3 scripts/inventory_folder_to_csv.py ./photos ./output
|
|
181
|
+
python3 scripts/inventory_folder_to_csv.py ./photos ./output --skip-vision
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Datasheet enrichment still requires a separate web-search/browser capability in the agent. This package does not bundle one.
|
|
185
|
+
|
|
74
186
|
## Recommended workflow
|
|
75
187
|
|
|
76
188
|
### 1. Take photos
|
|
@@ -94,6 +206,13 @@ In Pi:
|
|
|
94
206
|
/vision-inventory-agent-bom ./photos ./output
|
|
95
207
|
```
|
|
96
208
|
|
|
209
|
+
In other harnesses:
|
|
210
|
+
|
|
211
|
+
```text
|
|
212
|
+
/vision-inventory-workflow ./photos ./output
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
|
|
97
216
|
Useful options:
|
|
98
217
|
|
|
99
218
|
```text
|
|
@@ -186,7 +305,7 @@ Command summary:
|
|
|
186
305
|
|---|---|
|
|
187
306
|
| `vision_inventory_process_image` | Analyze one electronics/PCB image. |
|
|
188
307
|
| `vision_inventory_process_folder` | Analyze a folder of supported images. |
|
|
189
|
-
| `vision_inventory_save` | Save inventory output as JSON or CSV. |
|
|
308
|
+
| `vision_inventory_save` | Save inventory output as JSON or quick CSV export. For the full BOM workflow, use `/vision-inventory-bom` or `scripts/inventory_folder_to_csv.py`. |
|
|
190
309
|
|
|
191
310
|
## Manual shell workflow
|
|
192
311
|
|
|
@@ -194,6 +313,7 @@ You can run the deterministic workflow without Pi commands:
|
|
|
194
313
|
|
|
195
314
|
```bash
|
|
196
315
|
python3 -m pip install -r requirements.txt
|
|
316
|
+
python3 scripts/inventory_folder_to_csv.py ./photos ./output --validate-setup
|
|
197
317
|
python3 scripts/inventory_folder_to_csv.py ./photos ./output
|
|
198
318
|
```
|
|
199
319
|
|
|
@@ -203,17 +323,42 @@ Then fill `output/datasheet_cache.json` manually or with an agent, and regenerat
|
|
|
203
323
|
python3 scripts/inventory_folder_to_csv.py ./photos ./output --skip-vision
|
|
204
324
|
```
|
|
205
325
|
|
|
326
|
+
## No-Cloudflare demo and sample output
|
|
327
|
+
|
|
328
|
+
A fixture-based demo is available at:
|
|
329
|
+
|
|
330
|
+
```text
|
|
331
|
+
examples/no-cloudflare-demo/
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
Run it without Cloudflare credentials:
|
|
335
|
+
|
|
336
|
+
```bash
|
|
337
|
+
python3 scripts/inventory_folder_to_csv.py ./examples/no-cloudflare-demo/photos ./examples/no-cloudflare-demo/output --skip-vision
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
The demo includes mock raw vision JSON, a small `datasheet_cache.json`, and generated sample outputs:
|
|
341
|
+
|
|
342
|
+
```text
|
|
343
|
+
examples/no-cloudflare-demo/output/parts_to_lookup.json
|
|
344
|
+
examples/no-cloudflare-demo/output/datasheet_cache.json
|
|
345
|
+
examples/no-cloudflare-demo/output/inventory.csv
|
|
346
|
+
examples/no-cloudflare-demo/output/inventory_evidence.csv
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
Use these files to understand the expected output shapes before running real image analysis.
|
|
350
|
+
|
|
206
351
|
## Python requirements
|
|
207
352
|
|
|
208
353
|
Python 3.10 or newer is recommended.
|
|
209
354
|
|
|
210
|
-
Required:
|
|
355
|
+
Required versions are constrained in `requirements.txt` for reproducible installs:
|
|
211
356
|
|
|
212
357
|
```text
|
|
213
|
-
mcp
|
|
214
|
-
requests
|
|
215
|
-
pillow
|
|
216
|
-
python-dotenv
|
|
358
|
+
mcp>=1.0,<2.0
|
|
359
|
+
requests>=2.31,<3.0
|
|
360
|
+
pillow>=10.0,<12.0
|
|
361
|
+
python-dotenv>=1.0,<2.0
|
|
217
362
|
```
|
|
218
363
|
|
|
219
364
|
Install:
|
|
@@ -301,7 +446,7 @@ MCP tools:
|
|
|
301
446
|
|---|---|
|
|
302
447
|
| `process_image` | Analyze one image and return structured visible inventory data. |
|
|
303
448
|
| `process_image_folder` | Analyze all supported images in a folder. |
|
|
304
|
-
| `save_inventory` | Save inventory output to JSON or CSV. |
|
|
449
|
+
| `save_inventory` | Save inventory output to JSON or quick CSV export. Use `scripts/inventory_folder_to_csv.py` for the full BOM/evidence workflow. |
|
|
305
450
|
|
|
306
451
|
Run directly for MCP-compatible clients:
|
|
307
452
|
|
|
@@ -319,7 +464,7 @@ Example MCP client configuration:
|
|
|
319
464
|
"args": ["/path/to/vision_inventory_mcp.py"],
|
|
320
465
|
"env": {
|
|
321
466
|
"CLOUDFLARE_ACCOUNT_ID": "your_cloudflare_account_id",
|
|
322
|
-
"CLOUDFLARE_AUTH_TOKEN": "
|
|
467
|
+
"CLOUDFLARE_AUTH_TOKEN": "your_cloudflare_workers_ai_api_token"
|
|
323
468
|
}
|
|
324
469
|
}
|
|
325
470
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vision-electronic-indexing-pi",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "Pi package for agent-assisted electronics/PCB image inventory with Cloudflare Workers AI vision and datasheet enrichment.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/requirements.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
mcp
|
|
2
|
-
requests
|
|
3
|
-
pillow
|
|
4
|
-
python-dotenv
|
|
1
|
+
mcp>=1.0,<2.0
|
|
2
|
+
requests>=2.31,<3.0
|
|
3
|
+
pillow>=10.0,<12.0
|
|
4
|
+
python-dotenv>=1.0,<2.0
|
|
5
5
|
# Optional: install for iPhone HEIC/HEIF image support
|
|
6
|
-
# pillow-heif
|
|
6
|
+
# pillow-heif>=0.16,<1.0
|
|
@@ -18,9 +18,9 @@ import csv
|
|
|
18
18
|
import json
|
|
19
19
|
import re
|
|
20
20
|
import sys
|
|
21
|
-
from collections import defaultdict
|
|
21
|
+
from collections import Counter, defaultdict
|
|
22
22
|
from pathlib import Path
|
|
23
|
-
from typing import Any, Dict,
|
|
23
|
+
from typing import Any, Dict, List, Optional
|
|
24
24
|
|
|
25
25
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
|
26
26
|
if str(PROJECT_ROOT) not in sys.path:
|
|
@@ -133,13 +133,30 @@ def extract_part_evidence(image_name: str, result: Dict[str, Any]) -> List[Dict[
|
|
|
133
133
|
return evidence
|
|
134
134
|
|
|
135
135
|
|
|
136
|
+
def preflight_credentials() -> None:
|
|
137
|
+
_account_id, _api_token, credential_error = vision.get_cloudflare_credentials()
|
|
138
|
+
if credential_error:
|
|
139
|
+
raise SystemExit(
|
|
140
|
+
f"{credential_error.get('message', 'Missing Cloudflare credentials.')} "
|
|
141
|
+
"Set CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_AUTH_TOKEN/CLOUDFLARE_API_TOKEN, "
|
|
142
|
+
"or run /vision-inventory-setup in Pi."
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
136
146
|
def process_images(args: argparse.Namespace, raw_dir: Path) -> List[Dict[str, Any]]:
|
|
137
147
|
image_folder = Path(args.image_folder).expanduser().resolve()
|
|
138
148
|
if not image_folder.is_dir():
|
|
139
149
|
raise SystemExit(f"Image folder does not exist or is not a directory: {image_folder}")
|
|
140
150
|
|
|
151
|
+
images = iter_images(image_folder, args.recursive, args.limit)
|
|
152
|
+
if not images:
|
|
153
|
+
print(f"No supported image files found in {image_folder}.")
|
|
154
|
+
return []
|
|
155
|
+
|
|
156
|
+
preflight_credentials()
|
|
157
|
+
|
|
141
158
|
results: List[Dict[str, Any]] = []
|
|
142
|
-
for image_path in
|
|
159
|
+
for image_path in images:
|
|
143
160
|
print(f"Processing {image_path}")
|
|
144
161
|
result = vision.process_image_impl(
|
|
145
162
|
image_path=str(image_path),
|
|
@@ -161,7 +178,30 @@ def load_raw_results(raw_dir: Path) -> List[Dict[str, Any]]:
|
|
|
161
178
|
return results
|
|
162
179
|
|
|
163
180
|
|
|
164
|
-
def
|
|
181
|
+
def classify_error(result: Dict[str, Any]) -> str:
|
|
182
|
+
message = str(result.get("message") or result.get("error") or "Unknown error")
|
|
183
|
+
lowered = message.lower()
|
|
184
|
+
if "credential" in lowered or "cloudflare_account_id" in lowered or "api token" in lowered:
|
|
185
|
+
return "credential errors"
|
|
186
|
+
if "cloudflare" in lowered or "workers ai" in lowered:
|
|
187
|
+
return "cloudflare api errors"
|
|
188
|
+
if "prepare image" in lowered or "unsupported image" in lowered or "image file" in lowered:
|
|
189
|
+
return "image preprocessing/input errors"
|
|
190
|
+
if result.get("parse_error"):
|
|
191
|
+
return "model json parse errors"
|
|
192
|
+
return "other errors"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def result_error_summary(results: List[Dict[str, Any]]) -> Counter[str]:
|
|
196
|
+
summary: Counter[str] = Counter()
|
|
197
|
+
for entry in results:
|
|
198
|
+
result = entry.get("result", {})
|
|
199
|
+
if isinstance(result, dict) and (result.get("error") or result.get("parse_error")):
|
|
200
|
+
summary[classify_error(result)] += 1
|
|
201
|
+
return summary
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def build_parts_to_lookup(results: List[Dict[str, Any]], output_dir: Path) -> Dict[str, Any]:
|
|
165
205
|
grouped: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
|
166
206
|
all_evidence: List[Dict[str, Any]] = []
|
|
167
207
|
|
|
@@ -196,16 +236,27 @@ def build_parts_to_lookup(results: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
|
196
236
|
}
|
|
197
237
|
})
|
|
198
238
|
|
|
239
|
+
warnings: List[str] = []
|
|
240
|
+
if not parts:
|
|
241
|
+
warnings.append(
|
|
242
|
+
"No candidate IC parts were extracted. Inspect raw JSON files for unreadable markings, "
|
|
243
|
+
"image quality issues, or model/API errors."
|
|
244
|
+
)
|
|
245
|
+
|
|
199
246
|
return {
|
|
247
|
+
"output_dir": str(output_dir),
|
|
248
|
+
"datasheet_cache_path": str(output_dir / "datasheet_cache.json"),
|
|
249
|
+
"datasheet_cache_template_path": str(output_dir / "datasheet_cache.template.json"),
|
|
200
250
|
"instructions": [
|
|
201
251
|
"Use web search to find each part datasheet, preferably from the manufacturer.",
|
|
202
|
-
"Fill
|
|
252
|
+
"Fill datasheet_cache.json in this same output directory, using datasheet_cache.template.json as the shape.",
|
|
203
253
|
"Keep descriptions short, e.g. '74ls (4 bit) adder low power schottky ttl 5v DIP'.",
|
|
204
254
|
"If exact candidate search fails but official results strongly indicate a likely OCR correction, keep the original candidate as this cache key and set normalized_part to the official datasheet part number.",
|
|
205
255
|
"Example: if SN74AS283N appears to be an OCR error for official SN74LS283N, use key SN74AS283N with normalized_part SN74LS283N and explain the correction in notes.",
|
|
206
256
|
"Only mark verified=true for a correction when the official datasheet and visual/package context make the correction highly likely; otherwise set verified=false and explain in notes.",
|
|
207
257
|
"If the visual marking is uncertain, set verified=false and explain in notes."
|
|
208
258
|
],
|
|
259
|
+
"warnings": warnings,
|
|
209
260
|
"parts": parts,
|
|
210
261
|
"all_evidence": all_evidence,
|
|
211
262
|
}
|
|
@@ -228,13 +279,20 @@ def lookup_enrichment(part: str, cache: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
228
279
|
return {}
|
|
229
280
|
|
|
230
281
|
|
|
282
|
+
def positive_int(value: Any) -> Optional[int]:
|
|
283
|
+
try:
|
|
284
|
+
parsed = int(value)
|
|
285
|
+
except Exception:
|
|
286
|
+
return None
|
|
287
|
+
return parsed if parsed > 0 else None
|
|
288
|
+
|
|
289
|
+
|
|
231
290
|
def estimate_amount_for_candidate(result: Dict[str, Any], candidate: str, evidence_count: int = 1) -> int:
|
|
232
291
|
"""Estimate physical IC quantity for one candidate in one image.
|
|
233
292
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
produce amount=4 without double-counting duplicate observations.
|
|
293
|
+
Prefer explicit visible_quantity when the model provides it. Older results only
|
|
294
|
+
have count_index, which may be either an ordinal index or a grouped count, so
|
|
295
|
+
the fallback remains heuristic and should be reviewed for important BOMs.
|
|
238
296
|
"""
|
|
239
297
|
items = result.get("items", [])
|
|
240
298
|
if not isinstance(items, list):
|
|
@@ -242,6 +300,7 @@ def estimate_amount_for_candidate(result: Dict[str, Any], candidate: str, eviden
|
|
|
242
300
|
|
|
243
301
|
matched = 0
|
|
244
302
|
count_values: List[int] = []
|
|
303
|
+
visible_quantities: List[int] = []
|
|
245
304
|
for item in items:
|
|
246
305
|
if not isinstance(item, dict):
|
|
247
306
|
continue
|
|
@@ -250,10 +309,15 @@ def estimate_amount_for_candidate(result: Dict[str, Any], candidate: str, eviden
|
|
|
250
309
|
if candidate_from_item(item).upper() != candidate.upper():
|
|
251
310
|
continue
|
|
252
311
|
matched += 1
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
312
|
+
visible_quantity = positive_int(item.get("visible_quantity"))
|
|
313
|
+
if visible_quantity is not None:
|
|
314
|
+
visible_quantities.append(visible_quantity)
|
|
315
|
+
count_index = positive_int(item.get("count_index"))
|
|
316
|
+
if count_index is not None:
|
|
317
|
+
count_values.append(count_index)
|
|
318
|
+
|
|
319
|
+
if visible_quantities:
|
|
320
|
+
return max(1, sum(visible_quantities))
|
|
257
321
|
|
|
258
322
|
return max([1, evidence_count, matched, *count_values])
|
|
259
323
|
|
|
@@ -265,6 +329,11 @@ def image_part_rows(results: List[Dict[str, Any]], cache: Dict[str, Any]) -> Lis
|
|
|
265
329
|
image_name = str(result.get("image") or Path(entry["image_path"]).name)
|
|
266
330
|
evidence = extract_part_evidence(image_name, result)
|
|
267
331
|
if not evidence:
|
|
332
|
+
notes = "No IC marking extracted"
|
|
333
|
+
if isinstance(result, dict) and result.get("error"):
|
|
334
|
+
notes = f"Vision processing error: {result.get('message', 'unknown error')}"
|
|
335
|
+
elif isinstance(result, dict) and result.get("warnings"):
|
|
336
|
+
notes = "; ".join(str(w) for w in result.get("warnings", []) if str(w).strip()) or notes
|
|
268
337
|
rows.append({
|
|
269
338
|
"image": image_name,
|
|
270
339
|
"candidate_part": "",
|
|
@@ -279,7 +348,7 @@ def image_part_rows(results: List[Dict[str, Any]], cache: Dict[str, Any]) -> Lis
|
|
|
279
348
|
"observed_markings": "",
|
|
280
349
|
"observations": "",
|
|
281
350
|
"raw_json": entry["raw_json"],
|
|
282
|
-
"notes":
|
|
351
|
+
"notes": notes,
|
|
283
352
|
})
|
|
284
353
|
continue
|
|
285
354
|
|
|
@@ -425,6 +494,63 @@ def write_final_csv(results: List[Dict[str, Any]], cache: Dict[str, Any], output
|
|
|
425
494
|
write_csv(output_csv.with_name(f"{output_csv.stem}_evidence{output_csv.suffix}"), evidence_fieldnames, evidence_rows)
|
|
426
495
|
|
|
427
496
|
|
|
497
|
+
def validate_setup(args: argparse.Namespace, output_dir: Path) -> None:
|
|
498
|
+
image_folder = Path(args.image_folder).expanduser().resolve()
|
|
499
|
+
print("Setup validation:")
|
|
500
|
+
print(f"- Python executable: {sys.executable}")
|
|
501
|
+
print("- Required imports: ok")
|
|
502
|
+
|
|
503
|
+
if image_folder.is_dir():
|
|
504
|
+
images = iter_images(image_folder, args.recursive, args.limit)
|
|
505
|
+
print(f"- Image folder: ok ({image_folder})")
|
|
506
|
+
print(f"- Supported images found: {len(images)}")
|
|
507
|
+
heic_images = [p for p in images if p.suffix.lower() in {".heic", ".heif"}]
|
|
508
|
+
if heic_images:
|
|
509
|
+
try:
|
|
510
|
+
import pillow_heif # noqa: F401
|
|
511
|
+
print("- HEIC/HEIF support: ok")
|
|
512
|
+
except Exception:
|
|
513
|
+
print("- HEIC/HEIF support: missing pillow-heif; install it to process HEIC/HEIF images")
|
|
514
|
+
else:
|
|
515
|
+
print(f"- Image folder: missing or not a directory ({image_folder})")
|
|
516
|
+
|
|
517
|
+
try:
|
|
518
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
519
|
+
probe = output_dir / ".vision_inventory_write_test"
|
|
520
|
+
probe.write_text("ok", encoding="utf-8")
|
|
521
|
+
probe.unlink(missing_ok=True)
|
|
522
|
+
print(f"- Output directory writable: ok ({output_dir})")
|
|
523
|
+
except Exception as exc:
|
|
524
|
+
print(f"- Output directory writable: failed ({exc})")
|
|
525
|
+
|
|
526
|
+
if args.skip_vision:
|
|
527
|
+
raw_dir = output_dir / "raw"
|
|
528
|
+
raw_count = len(list(raw_dir.glob("*.json"))) if raw_dir.exists() else 0
|
|
529
|
+
print(f"- Raw JSON files for --skip-vision: {raw_count}")
|
|
530
|
+
else:
|
|
531
|
+
_account_id, _api_token, credential_error = vision.get_cloudflare_credentials()
|
|
532
|
+
if credential_error:
|
|
533
|
+
print(f"- Cloudflare credentials: missing ({credential_error.get('message')})")
|
|
534
|
+
else:
|
|
535
|
+
print("- Cloudflare credentials: present")
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def print_workflow_summary(results: List[Dict[str, Any]], parts_to_lookup: Dict[str, Any]) -> None:
|
|
539
|
+
error_summary = result_error_summary(results)
|
|
540
|
+
evidence_count = len(parts_to_lookup.get("all_evidence", []))
|
|
541
|
+
part_count = len(parts_to_lookup.get("parts", []))
|
|
542
|
+
print("Workflow summary:")
|
|
543
|
+
print(f"- Processed/raw result files: {len(results)}")
|
|
544
|
+
print(f"- IC marking evidence rows: {evidence_count}")
|
|
545
|
+
print(f"- Candidate parts extracted: {part_count}")
|
|
546
|
+
if error_summary:
|
|
547
|
+
print("- Processing errors:")
|
|
548
|
+
for label, count in sorted(error_summary.items()):
|
|
549
|
+
print(f" - {label}: {count}")
|
|
550
|
+
if not part_count:
|
|
551
|
+
print("No candidate IC parts were extracted. Inspect output/raw/*.json for unreadable markings, image quality issues, or API errors.")
|
|
552
|
+
|
|
553
|
+
|
|
428
554
|
def parse_args() -> argparse.Namespace:
|
|
429
555
|
parser = argparse.ArgumentParser(description="Process electronics images and prepare datasheet-enriched CSV workflow.")
|
|
430
556
|
parser.add_argument("image_folder", help="Folder containing electronics/PCB images")
|
|
@@ -433,6 +559,7 @@ def parse_args() -> argparse.Namespace:
|
|
|
433
559
|
parser.add_argument("--recursive", action="store_true", help="Scan image_folder recursively")
|
|
434
560
|
parser.add_argument("--limit", type=int, default=None, help="Maximum number of images to process")
|
|
435
561
|
parser.add_argument("--skip-vision", action="store_true", help="Reuse existing output_dir/raw/*.json instead of calling vision AI")
|
|
562
|
+
parser.add_argument("--validate-setup", action="store_true", help="Check dependencies, paths, credentials, and image discovery without processing images")
|
|
436
563
|
parser.add_argument("--max-side", type=int, default=vision.DEFAULT_MAX_SIDE, help="Maximum resized image side; use 0 for full resolution (default)")
|
|
437
564
|
parser.add_argument("--jpeg-quality", type=int, default=vision.DEFAULT_JPEG_QUALITY, help="JPEG quality for model input")
|
|
438
565
|
return parser.parse_args()
|
|
@@ -441,6 +568,11 @@ def parse_args() -> argparse.Namespace:
|
|
|
441
568
|
def main() -> None:
|
|
442
569
|
args = parse_args()
|
|
443
570
|
output_dir = Path(args.output_dir).expanduser().resolve()
|
|
571
|
+
|
|
572
|
+
if args.validate_setup:
|
|
573
|
+
validate_setup(args, output_dir)
|
|
574
|
+
return
|
|
575
|
+
|
|
444
576
|
raw_dir = output_dir / "raw"
|
|
445
577
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
446
578
|
raw_dir.mkdir(parents=True, exist_ok=True)
|
|
@@ -452,7 +584,7 @@ def main() -> None:
|
|
|
452
584
|
else:
|
|
453
585
|
results = process_images(args, raw_dir)
|
|
454
586
|
|
|
455
|
-
parts_to_lookup = build_parts_to_lookup(results)
|
|
587
|
+
parts_to_lookup = build_parts_to_lookup(results, output_dir)
|
|
456
588
|
parts_path = output_dir / "parts_to_lookup.json"
|
|
457
589
|
template_path = output_dir / "datasheet_cache.template.json"
|
|
458
590
|
cache_path = output_dir / "datasheet_cache.json"
|
|
@@ -470,8 +602,13 @@ def main() -> None:
|
|
|
470
602
|
print(f"Datasheet cache template: {template_path}")
|
|
471
603
|
print(f"Datasheet cache used: {cache_path if cache_path.exists() else 'not found yet'}")
|
|
472
604
|
print(f"CSV written: {csv_path}")
|
|
605
|
+
print_workflow_summary(results, parts_to_lookup)
|
|
473
606
|
if not cache_path.exists():
|
|
474
|
-
print("Next step: copy datasheet_cache.template.json to datasheet_cache.json, enrich it via web search, then rerun with --skip-vision.")
|
|
607
|
+
print("Next step: copy datasheet_cache.template.json to datasheet_cache.json in the output directory, enrich it via web search, then rerun with --skip-vision.")
|
|
608
|
+
|
|
609
|
+
errors = result_error_summary(results)
|
|
610
|
+
if results and sum(errors.values()) == len(results):
|
|
611
|
+
raise SystemExit("All processed images returned errors; see the summary above and inspect raw JSON files.")
|
|
475
612
|
|
|
476
613
|
|
|
477
614
|
if __name__ == "__main__":
|
package/vision_inventory_mcp.py
CHANGED
|
@@ -105,6 +105,7 @@ Return only valid JSON using this schema:
|
|
|
105
105
|
{{
|
|
106
106
|
"item_type": "IC | connector | passive | module | switch | sensor | display | mechanical | unknown",
|
|
107
107
|
"count_index": 1,
|
|
108
|
+
"visible_quantity": 1,
|
|
108
109
|
"package_marking": "exact visible marking, unclear, unreadable, or [?]-marked partial text",
|
|
109
110
|
"marking_confidence": "high | medium | low | unreadable",
|
|
110
111
|
"likely_part": "visible part marking only, or unknown",
|
|
@@ -123,6 +124,7 @@ Rules:
|
|
|
123
124
|
- Do not use web lookup.
|
|
124
125
|
- If a marking is not readable, write "unreadable".
|
|
125
126
|
- If a component is visible but not identifiable, item_type should be "unknown".
|
|
127
|
+
- count_index is an ordinal item number; visible_quantity is the estimated number of matching visible physical components.
|
|
126
128
|
- needs_review must be true when marking_confidence is "low" or "unreadable".
|
|
127
129
|
""".strip()
|
|
128
130
|
|
|
@@ -362,6 +364,7 @@ def normalize_item(item: Any, fallback_index: int) -> Dict[str, Any]:
|
|
|
362
364
|
default_item: Dict[str, Any] = {
|
|
363
365
|
"item_type": "unknown",
|
|
364
366
|
"count_index": fallback_index,
|
|
367
|
+
"visible_quantity": 1,
|
|
365
368
|
"package_marking": "unknown",
|
|
366
369
|
"marking_confidence": "unreadable",
|
|
367
370
|
"likely_part": "unknown",
|
|
@@ -381,6 +384,11 @@ def normalize_item(item: Any, fallback_index: int) -> Dict[str, Any]:
|
|
|
381
384
|
except Exception:
|
|
382
385
|
normalized["count_index"] = fallback_index
|
|
383
386
|
|
|
387
|
+
try:
|
|
388
|
+
normalized["visible_quantity"] = max(1, int(normalized.get("visible_quantity", 1)))
|
|
389
|
+
except Exception:
|
|
390
|
+
normalized["visible_quantity"] = 1
|
|
391
|
+
|
|
384
392
|
confidence = str(normalized.get("marking_confidence", "unreadable")).strip().lower()
|
|
385
393
|
if confidence not in {"high", "medium", "low", "unreadable"}:
|
|
386
394
|
confidence = "low"
|
|
@@ -459,17 +467,6 @@ def normalize_inventory_result(result: Dict[str, Any], image_name: str) -> Dict[
|
|
|
459
467
|
return normalized
|
|
460
468
|
|
|
461
469
|
|
|
462
|
-
def visible_ic_items(result: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
463
|
-
items = result.get("items", [])
|
|
464
|
-
if not isinstance(items, list):
|
|
465
|
-
return []
|
|
466
|
-
return [
|
|
467
|
-
item for item in items
|
|
468
|
-
if isinstance(item, dict) and str(item.get("item_type", "")).strip().lower() == "ic"
|
|
469
|
-
]
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
470
|
def process_image_impl(
|
|
474
471
|
image_path: str,
|
|
475
472
|
max_side: int = DEFAULT_MAX_SIDE,
|
|
@@ -512,10 +509,15 @@ def process_image_impl(
|
|
|
512
509
|
|
|
513
510
|
parsed, parse_error = extract_json_object(response_text)
|
|
514
511
|
if parse_error or parsed is None:
|
|
512
|
+
message = parse_error or "Model returned invalid JSON."
|
|
515
513
|
return {
|
|
516
514
|
"image": image_name,
|
|
517
515
|
"items": [],
|
|
518
|
-
"warnings": [
|
|
516
|
+
"warnings": [message],
|
|
517
|
+
"parse_error": True,
|
|
518
|
+
"parse_error_message": message,
|
|
519
|
+
"raw_response_preview": response_text[:2000],
|
|
520
|
+
"raw_response_length": len(response_text),
|
|
519
521
|
"raw_response": response_text,
|
|
520
522
|
}
|
|
521
523
|
|
|
@@ -699,7 +701,10 @@ def flatten_inventory_for_csv(inventory: Dict[str, Any], enrichment_cache: Optio
|
|
|
699
701
|
# Keep the main part number as the observation, not the full package/date/lot marking.
|
|
700
702
|
row["observed_markings"].add(normalized)
|
|
701
703
|
try:
|
|
702
|
-
|
|
704
|
+
if "visible_quantity" in item:
|
|
705
|
+
row["amount"] = int(row["amount"]) + max(1, int(item.get("visible_quantity", 1)))
|
|
706
|
+
else:
|
|
707
|
+
row["amount"] = max(int(row["amount"]), int(item.get("count_index", 1)))
|
|
703
708
|
except Exception:
|
|
704
709
|
row["amount"] = max(int(row["amount"]), 1)
|
|
705
710
|
|
|
@@ -736,9 +741,12 @@ def save_inventory(
|
|
|
736
741
|
format: str = "json",
|
|
737
742
|
) -> Dict[str, Any]:
|
|
738
743
|
"""
|
|
739
|
-
Save inventory results to disk as JSON or CSV.
|
|
744
|
+
Save inventory results to disk as JSON or quick CSV export.
|
|
740
745
|
|
|
741
746
|
The input inventory can be the result of process_image or process_image_folder.
|
|
747
|
+
CSV output from this tool is a quick export; use scripts/inventory_folder_to_csv.py
|
|
748
|
+
or /vision-inventory-bom for the full BOM workflow with raw evidence files,
|
|
749
|
+
parts_to_lookup.json, datasheet_cache.json, and inventory_evidence.csv.
|
|
742
750
|
"""
|
|
743
751
|
if not isinstance(inventory, dict):
|
|
744
752
|
return error_response("inventory must be an object/dict.")
|
|
@@ -795,12 +803,19 @@ def save_inventory(
|
|
|
795
803
|
|
|
796
804
|
row_count = len(rows)
|
|
797
805
|
|
|
798
|
-
|
|
806
|
+
response = {
|
|
799
807
|
"saved": True,
|
|
800
808
|
"output_path": str(output),
|
|
801
809
|
"format": fmt,
|
|
802
810
|
"row_count": row_count,
|
|
803
811
|
}
|
|
812
|
+
if fmt == "csv":
|
|
813
|
+
response["note"] = (
|
|
814
|
+
"This is a quick CSV export. Use scripts/inventory_folder_to_csv.py "
|
|
815
|
+
"or /vision-inventory-bom for the full BOM workflow with raw evidence, "
|
|
816
|
+
"parts_to_lookup.json, datasheet_cache.json, and inventory_evidence.csv."
|
|
817
|
+
)
|
|
818
|
+
return response
|
|
804
819
|
|
|
805
820
|
except Exception as exc:
|
|
806
821
|
return error_response(f"Failed to save inventory: {exc}", output_path=str(output), format=fmt)
|