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,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()
|