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.
@@ -0,0 +1,477 @@
1
+ #!/usr/bin/env python3
2
+ """Batch vision inventory workflow with agent-assisted datasheet enrichment.
3
+
4
+ Option A workflow:
5
+ 1. Process every image in a folder with vision_inventory_mcp.py.
6
+ 2. Save one raw JSON file per image for auditability.
7
+ 3. Build parts_to_lookup.json for the agent/user to enrich from datasheets.
8
+ 4. If datasheet_cache.json exists, write a final enriched CSV.
9
+
10
+ The script intentionally does not browse the web. Fill datasheet_cache.json manually or with Pi
11
+ web-search assistance, then rerun this script with --skip-vision to regenerate the CSV.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import csv
18
+ import json
19
+ import re
20
+ import sys
21
+ from collections import defaultdict
22
+ from pathlib import Path
23
+ from typing import Any, Dict, Iterable, List, Optional
24
+
25
+ PROJECT_ROOT = Path(__file__).resolve().parents[1]
26
+ if str(PROJECT_ROOT) not in sys.path:
27
+ sys.path.insert(0, str(PROJECT_ROOT))
28
+
29
+ import vision_inventory_mcp as vision # noqa: E402
30
+
31
+ UNKNOWN_MARKINGS = {"", "unknown", "unreadable", "unclear", "none", "n/a"}
32
+ PART_PATTERN = re.compile(r"\b[A-Z]{1,4}[A-Z0-9]{2,}[A-Z0-9?\[\]-]*\b", re.IGNORECASE)
33
+
34
+
35
+ def safe_stem(path: Path) -> str:
36
+ stem = re.sub(r"[^A-Za-z0-9_.-]+", "_", path.stem).strip("_")
37
+ return stem or "image"
38
+
39
+
40
+ def load_json(path: Path, default: Any) -> Any:
41
+ if not path.exists():
42
+ return default
43
+ return json.loads(path.read_text(encoding="utf-8"))
44
+
45
+
46
+ def write_json(path: Path, data: Any) -> None:
47
+ path.parent.mkdir(parents=True, exist_ok=True)
48
+ path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
49
+
50
+
51
+ def is_supported_image(path: Path) -> bool:
52
+ return path.is_file() and path.suffix.lower() in vision.SUPPORTED_EXTENSIONS
53
+
54
+
55
+ def iter_images(folder: Path, recursive: bool, limit: Optional[int]) -> List[Path]:
56
+ iterator = folder.rglob("*") if recursive else folder.iterdir()
57
+ images = sorted([p for p in iterator if is_supported_image(p)], key=lambda p: str(p).lower())
58
+ return images[:limit] if limit is not None else images
59
+
60
+
61
+ def clean_marking(value: Any) -> str:
62
+ text = str(value or "").strip()
63
+ text = re.sub(r"\s+", " ", text)
64
+ return text
65
+
66
+
67
+ def likely_base_part(marking: str) -> str:
68
+ """Extract the most likely device part from a longer package marking line."""
69
+ cleaned = marking.replace("[?]", "?").upper()
70
+ matches = PART_PATTERN.findall(cleaned)
71
+ if not matches:
72
+ return cleaned.strip()
73
+
74
+ # Prefer tokens that look like common IC identifiers and contain digits.
75
+ candidates = [m.strip("-_") for m in matches if any(ch.isdigit() for ch in m)]
76
+ if not candidates:
77
+ candidates = matches
78
+
79
+ # Date/lot codes are usually short and mostly numeric; prefer longer alphanumeric tokens.
80
+ candidates.sort(key=lambda s: (len(re.sub(r"[^A-Z]", "", s)) > 0, len(s)), reverse=True)
81
+ return candidates[0]
82
+
83
+
84
+ def candidate_from_item(item: Dict[str, Any]) -> str:
85
+ for key in ("likely_part", "package_marking"):
86
+ value = clean_marking(item.get(key))
87
+ if value.lower() not in UNKNOWN_MARKINGS:
88
+ return likely_base_part(value)
89
+ return "unknown"
90
+
91
+
92
+ def extract_part_evidence(image_name: str, result: Dict[str, Any]) -> List[Dict[str, Any]]:
93
+ evidence: List[Dict[str, Any]] = []
94
+
95
+ for source, items in (
96
+ ("items", result.get("items", [])),
97
+ ("first_pass_items", result.get("first_pass_items", [])),
98
+ ):
99
+ if not isinstance(items, list):
100
+ continue
101
+ for item in items:
102
+ if not isinstance(item, dict):
103
+ continue
104
+ if str(item.get("item_type", "")).strip().lower() != "ic":
105
+ continue
106
+ marking = clean_marking(item.get("package_marking"))
107
+ if marking.lower() in UNKNOWN_MARKINGS:
108
+ continue
109
+ evidence.append({
110
+ "image": image_name,
111
+ "source": source,
112
+ "position_hint": item.get("position_hint", "unknown"),
113
+ "observed_marking": marking,
114
+ "candidate_part": candidate_from_item(item),
115
+ "marking_confidence": item.get("marking_confidence", "unknown"),
116
+ "needs_review": bool(item.get("needs_review", True)),
117
+ })
118
+
119
+ observations = result.get("ic_marking_observations", [])
120
+ if isinstance(observations, list):
121
+ for obs in observations:
122
+ if not isinstance(obs, dict):
123
+ continue
124
+ marking = clean_marking(obs.get("package_marking"))
125
+ if marking.lower() in UNKNOWN_MARKINGS:
126
+ continue
127
+ evidence.append({
128
+ "image": image_name,
129
+ "source": "ic_marking_observations",
130
+ "position_hint": obs.get("position_hint", "unknown"),
131
+ "observed_marking": marking,
132
+ "candidate_part": likely_base_part(marking),
133
+ "marking_confidence": obs.get("marking_confidence", "unknown"),
134
+ "needs_review": str(obs.get("marking_confidence", "")).lower() in {"low", "unreadable"},
135
+ })
136
+
137
+ return evidence
138
+
139
+
140
+ def process_images(args: argparse.Namespace, raw_dir: Path) -> List[Dict[str, Any]]:
141
+ image_folder = Path(args.image_folder).expanduser().resolve()
142
+ if not image_folder.is_dir():
143
+ raise SystemExit(f"Image folder does not exist or is not a directory: {image_folder}")
144
+
145
+ results: List[Dict[str, Any]] = []
146
+ for image_path in iter_images(image_folder, args.recursive, args.limit):
147
+ print(f"Processing {image_path}")
148
+ result = vision.process_image_impl(
149
+ image_path=str(image_path),
150
+ max_side=args.max_side,
151
+ jpeg_quality=args.jpeg_quality,
152
+ )
153
+ raw_path = raw_dir / f"{safe_stem(image_path)}.json"
154
+ write_json(raw_path, result)
155
+ results.append({"image_path": str(image_path), "raw_json": str(raw_path), "result": result})
156
+
157
+ return results
158
+
159
+
160
+ def load_raw_results(raw_dir: Path) -> List[Dict[str, Any]]:
161
+ results = []
162
+ for raw_path in sorted(raw_dir.glob("*.json")):
163
+ result = load_json(raw_path, {})
164
+ results.append({"image_path": result.get("image", raw_path.stem), "raw_json": str(raw_path), "result": result})
165
+ return results
166
+
167
+
168
+ def build_parts_to_lookup(results: List[Dict[str, Any]]) -> Dict[str, Any]:
169
+ grouped: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
170
+ all_evidence: List[Dict[str, Any]] = []
171
+
172
+ for entry in results:
173
+ result = entry["result"]
174
+ image_name = str(result.get("image") or Path(entry["image_path"]).name)
175
+ evidence = extract_part_evidence(image_name, result)
176
+ all_evidence.extend(evidence)
177
+ for row in evidence:
178
+ part = row["candidate_part"].upper()
179
+ if part and part.lower() not in UNKNOWN_MARKINGS:
180
+ grouped[part].append(row)
181
+
182
+ parts = []
183
+ for part, evidence_rows in sorted(grouped.items()):
184
+ observed = sorted({row["observed_marking"] for row in evidence_rows})
185
+ images = sorted({row["image"] for row in evidence_rows})
186
+ parts.append({
187
+ "part": part,
188
+ "query": f"{part} datasheet",
189
+ "status": "needs_datasheet_lookup",
190
+ "observed_markings": observed,
191
+ "images": images,
192
+ "evidence": evidence_rows,
193
+ "enrichment_template": {
194
+ "normalized_part": part,
195
+ "description": "",
196
+ "datasheet_url": "",
197
+ "manufacturer": "",
198
+ "verified": False,
199
+ "notes": ""
200
+ }
201
+ })
202
+
203
+ return {
204
+ "instructions": [
205
+ "Use web search to find each part datasheet, preferably from the manufacturer.",
206
+ "Fill output/datasheet_cache.json using the template shape shown in datasheet_cache.template.json.",
207
+ "Keep descriptions short, e.g. '74ls (4 bit) adder low power schottky ttl 5v DIP'.",
208
+ "If the visual marking is uncertain, set verified=false and explain in notes."
209
+ ],
210
+ "parts": parts,
211
+ "all_evidence": all_evidence,
212
+ }
213
+
214
+
215
+ def build_cache_template(parts_to_lookup: Dict[str, Any]) -> Dict[str, Any]:
216
+ cache = {}
217
+ for part in parts_to_lookup.get("parts", []):
218
+ key = part["part"]
219
+ cache[key] = part["enrichment_template"]
220
+ return cache
221
+
222
+
223
+ def lookup_enrichment(part: str, cache: Dict[str, Any]) -> Dict[str, Any]:
224
+ if part in cache and isinstance(cache[part], dict):
225
+ return cache[part]
226
+ upper = part.upper()
227
+ if upper in cache and isinstance(cache[upper], dict):
228
+ return cache[upper]
229
+ return {}
230
+
231
+
232
+ def estimate_amount_for_candidate(result: Dict[str, Any], candidate: str) -> int:
233
+ """Estimate physical IC quantity for one image-level sighting.
234
+
235
+ The schema's historical field name is count_index, but in this lab workflow
236
+ the model often uses it as the count for a grouped consensus item. We sum
237
+ matching primary items and fall back to one when no usable count is available.
238
+ """
239
+ items = result.get("items", [])
240
+ if not isinstance(items, list):
241
+ return 1
242
+
243
+ amount = 0
244
+ matched = 0
245
+ for item in items:
246
+ if not isinstance(item, dict):
247
+ continue
248
+ if str(item.get("item_type", "")).strip().lower() != "ic":
249
+ continue
250
+ if candidate_from_item(item).upper() != candidate.upper():
251
+ continue
252
+ matched += 1
253
+ try:
254
+ count = int(item.get("count_index", 1))
255
+ except Exception:
256
+ count = 1
257
+ amount += max(1, count)
258
+
259
+ if amount > 0:
260
+ return amount
261
+ if matched > 0:
262
+ return matched
263
+ return 1
264
+
265
+
266
+ def image_part_rows(results: List[Dict[str, Any]], cache: Dict[str, Any]) -> List[Dict[str, Any]]:
267
+ rows: List[Dict[str, Any]] = []
268
+ for entry in results:
269
+ result = entry["result"]
270
+ image_name = str(result.get("image") or Path(entry["image_path"]).name)
271
+ evidence = extract_part_evidence(image_name, result)
272
+ if not evidence:
273
+ rows.append({
274
+ "image": image_name,
275
+ "candidate_part": "",
276
+ "normalized_part": "",
277
+ "amount": 0,
278
+ "description": "",
279
+ "datasheet_url": "",
280
+ "manufacturer": "",
281
+ "verified": False,
282
+ "vision_confidence": "unreadable",
283
+ "needs_review": True,
284
+ "observed_markings": "",
285
+ "observations": "",
286
+ "raw_json": entry["raw_json"],
287
+ "notes": "No IC marking extracted",
288
+ })
289
+ continue
290
+
291
+ # One image contributes one sighting for its most common candidate.
292
+ counts: Dict[str, int] = defaultdict(int)
293
+ for row in evidence:
294
+ counts[row["candidate_part"].upper()] += 1
295
+ candidate = sorted(counts.items(), key=lambda kv: kv[1], reverse=True)[0][0]
296
+ enrichment = lookup_enrichment(candidate, cache)
297
+ amount = estimate_amount_for_candidate(result, candidate)
298
+ observed_markings = sorted({row["observed_marking"] for row in evidence})
299
+ observations = "; ".join(
300
+ f"{row['position_hint']}: {row['observed_marking']} ({row['marking_confidence']})"
301
+ for row in evidence
302
+ )
303
+ confidence_values = [str(row.get("marking_confidence", "unknown")) for row in evidence]
304
+ needs_review = any(row.get("needs_review", True) for row in evidence) or not enrichment.get("verified", False)
305
+
306
+ rows.append({
307
+ "image": image_name,
308
+ "candidate_part": candidate,
309
+ "normalized_part": enrichment.get("normalized_part", candidate),
310
+ "amount": amount,
311
+ "description": enrichment.get("description", ""),
312
+ "datasheet_url": enrichment.get("datasheet_url", ""),
313
+ "manufacturer": enrichment.get("manufacturer", ""),
314
+ "verified": bool(enrichment.get("verified", False)),
315
+ "vision_confidence": "/".join(sorted(set(confidence_values))),
316
+ "needs_review": needs_review,
317
+ "observed_markings": " | ".join(observed_markings),
318
+ "observations": observations,
319
+ "raw_json": entry["raw_json"],
320
+ "notes": enrichment.get("notes", "Missing datasheet enrichment"),
321
+ })
322
+ return rows
323
+
324
+
325
+ def write_csv(path: Path, fieldnames: List[str], rows: List[Dict[str, Any]]) -> None:
326
+ path.parent.mkdir(parents=True, exist_ok=True)
327
+ with path.open("w", newline="", encoding="utf-8") as csv_file:
328
+ writer = csv.DictWriter(csv_file, fieldnames=fieldnames)
329
+ writer.writeheader()
330
+ writer.writerows(rows)
331
+
332
+
333
+ def write_final_csv(results: List[Dict[str, Any]], cache: Dict[str, Any], output_csv: Path) -> None:
334
+ """Write the default deduplicated BOM CSV and a per-image evidence CSV."""
335
+ evidence_rows = image_part_rows(results, cache)
336
+ evidence_fieldnames = [
337
+ "image",
338
+ "candidate_part",
339
+ "normalized_part",
340
+ "amount",
341
+ "description",
342
+ "datasheet_url",
343
+ "manufacturer",
344
+ "verified",
345
+ "vision_confidence",
346
+ "needs_review",
347
+ "observed_markings",
348
+ "observations",
349
+ "raw_json",
350
+ "notes",
351
+ ]
352
+
353
+ grouped: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
354
+ no_part_rows: List[Dict[str, Any]] = []
355
+ for row in evidence_rows:
356
+ part = str(row.get("normalized_part") or row.get("candidate_part") or "").strip().upper()
357
+ if not part:
358
+ no_part_rows.append(row)
359
+ else:
360
+ grouped[part].append(row)
361
+
362
+ bom_rows: List[Dict[str, Any]] = []
363
+ for part, rows_for_part in sorted(grouped.items()):
364
+ first = rows_for_part[0]
365
+ images = sorted({str(row["image"]) for row in rows_for_part})
366
+ observed_markings = sorted({marking for row in rows_for_part for marking in str(row["observed_markings"]).split(" | ") if marking})
367
+ raw_json_files = sorted({str(row["raw_json"]) for row in rows_for_part})
368
+ confidence_values = sorted({value for row in rows_for_part for value in str(row["vision_confidence"]).split("/") if value})
369
+ notes = sorted({str(row.get("notes", "")) for row in rows_for_part if str(row.get("notes", "")).strip()})
370
+ amount = sum(int(row.get("amount", 0) or 0) for row in rows_for_part)
371
+
372
+ bom_rows.append({
373
+ "normalized_part": part,
374
+ "candidate_parts": " | ".join(sorted({str(row["candidate_part"]) for row in rows_for_part if row.get("candidate_part")})),
375
+ "amount": amount,
376
+ "sighting_count": len(rows_for_part),
377
+ "description": first.get("description", ""),
378
+ "datasheet_url": first.get("datasheet_url", ""),
379
+ "manufacturer": first.get("manufacturer", ""),
380
+ "verified": all(bool(row.get("verified", False)) for row in rows_for_part),
381
+ "vision_confidence": "/".join(confidence_values),
382
+ "needs_review": any(bool(row.get("needs_review", True)) for row in rows_for_part),
383
+ "images": " | ".join(images),
384
+ "observed_markings": " | ".join(observed_markings),
385
+ "raw_json": " | ".join(raw_json_files),
386
+ "notes": " | ".join(notes),
387
+ })
388
+
389
+ for row in no_part_rows:
390
+ bom_rows.append({
391
+ "normalized_part": "",
392
+ "candidate_parts": "",
393
+ "amount": 0,
394
+ "sighting_count": 1,
395
+ "description": "",
396
+ "datasheet_url": "",
397
+ "manufacturer": "",
398
+ "verified": False,
399
+ "vision_confidence": row.get("vision_confidence", "unreadable"),
400
+ "needs_review": True,
401
+ "images": row.get("image", ""),
402
+ "observed_markings": "",
403
+ "raw_json": row.get("raw_json", ""),
404
+ "notes": row.get("notes", "No IC marking extracted"),
405
+ })
406
+
407
+ bom_fieldnames = [
408
+ "normalized_part",
409
+ "candidate_parts",
410
+ "amount",
411
+ "sighting_count",
412
+ "description",
413
+ "datasheet_url",
414
+ "manufacturer",
415
+ "verified",
416
+ "vision_confidence",
417
+ "needs_review",
418
+ "images",
419
+ "observed_markings",
420
+ "raw_json",
421
+ "notes",
422
+ ]
423
+ write_csv(output_csv, bom_fieldnames, bom_rows)
424
+ write_csv(output_csv.with_name(f"{output_csv.stem}_evidence{output_csv.suffix}"), evidence_fieldnames, evidence_rows)
425
+
426
+
427
+ def parse_args() -> argparse.Namespace:
428
+ parser = argparse.ArgumentParser(description="Process electronics images and prepare datasheet-enriched CSV workflow.")
429
+ parser.add_argument("image_folder", help="Folder containing electronics/PCB images")
430
+ parser.add_argument("output_dir", help="Output directory for raw JSON, lookup files, and CSV")
431
+ parser.add_argument("--csv", default="inventory.csv", help="CSV filename/path, relative to output_dir unless absolute")
432
+ parser.add_argument("--recursive", action="store_true", help="Scan image_folder recursively")
433
+ parser.add_argument("--limit", type=int, default=None, help="Maximum number of images to process")
434
+ parser.add_argument("--skip-vision", action="store_true", help="Reuse existing output_dir/raw/*.json instead of calling vision AI")
435
+ parser.add_argument("--max-side", type=int, default=vision.DEFAULT_MAX_SIDE, help="Maximum resized image side")
436
+ parser.add_argument("--jpeg-quality", type=int, default=vision.DEFAULT_JPEG_QUALITY, help="JPEG quality for model input")
437
+ return parser.parse_args()
438
+
439
+
440
+ def main() -> None:
441
+ args = parse_args()
442
+ output_dir = Path(args.output_dir).expanduser().resolve()
443
+ raw_dir = output_dir / "raw"
444
+ output_dir.mkdir(parents=True, exist_ok=True)
445
+ raw_dir.mkdir(parents=True, exist_ok=True)
446
+
447
+ if args.skip_vision:
448
+ results = load_raw_results(raw_dir)
449
+ if not results:
450
+ raise SystemExit(f"No raw JSON files found in {raw_dir}")
451
+ else:
452
+ results = process_images(args, raw_dir)
453
+
454
+ parts_to_lookup = build_parts_to_lookup(results)
455
+ parts_path = output_dir / "parts_to_lookup.json"
456
+ template_path = output_dir / "datasheet_cache.template.json"
457
+ cache_path = output_dir / "datasheet_cache.json"
458
+ write_json(parts_path, parts_to_lookup)
459
+ write_json(template_path, build_cache_template(parts_to_lookup))
460
+
461
+ cache = load_json(cache_path, {})
462
+ csv_path = Path(args.csv)
463
+ if not csv_path.is_absolute():
464
+ csv_path = output_dir / csv_path
465
+ write_final_csv(results, cache, csv_path)
466
+
467
+ print(f"Raw results: {raw_dir}")
468
+ print(f"Parts to lookup: {parts_path}")
469
+ print(f"Datasheet cache template: {template_path}")
470
+ print(f"Datasheet cache used: {cache_path if cache_path.exists() else 'not found yet'}")
471
+ print(f"CSV written: {csv_path}")
472
+ if not cache_path.exists():
473
+ print("Next step: copy datasheet_cache.template.json to datasheet_cache.json, enrich it via web search, then rerun with --skip-vision.")
474
+
475
+
476
+ if __name__ == "__main__":
477
+ main()