vision-electronic-indexing-pi 0.1.3 → 0.1.5
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 +1 -1
- package/.pi/extensions/vision-inventory-mcp/index.ts +2 -2
- package/.pi/skills/vision-inventory-workflow/SKILL.md +1 -1
- package/README.md +5 -5
- package/package.json +1 -1
- package/scripts/inventory_folder_to_csv.py +15 -9
- package/vision_inventory_mcp.py +87 -32
|
@@ -60,7 +60,7 @@ The agent workflow:
|
|
|
60
60
|
- `/vision-inventory-agent-bom` runs the full agent-assisted datasheet-enrichment workflow.
|
|
61
61
|
- `/vision-inventory-restart` restarts the local Python vision bridge.
|
|
62
62
|
|
|
63
|
-
Options are forwarded to `scripts/inventory_folder_to_csv.py`, such as `--recursive`, `--limit`, `--max-side`, and `--jpeg-quality`.
|
|
63
|
+
Options are forwarded to `scripts/inventory_folder_to_csv.py`, such as `--recursive`, `--limit`, `--max-side`, and `--jpeg-quality`. The default `--max-side 0` sends images at full resolution; set a positive value to resize.
|
|
64
64
|
|
|
65
65
|
## Agent tools
|
|
66
66
|
|
|
@@ -362,7 +362,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
362
362
|
],
|
|
363
363
|
parameters: Type.Object({
|
|
364
364
|
image_path: Type.String({ description: "Path to the image file, relative to the project root or absolute." }),
|
|
365
|
-
max_side: Type.Optional(Type.Integer({ description: "Maximum resized image side before submission.", default:
|
|
365
|
+
max_side: Type.Optional(Type.Integer({ description: "Maximum resized image side before submission. Use 0 for full resolution.", default: 0 })),
|
|
366
366
|
jpeg_quality: Type.Optional(Type.Integer({ description: "JPEG conversion quality from 1 to 100.", default: 96 })),
|
|
367
367
|
custom_prompt: Type.Optional(Type.String({ description: "Optional custom analysis prompt." })),
|
|
368
368
|
}),
|
|
@@ -383,7 +383,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
383
383
|
parameters: Type.Object({
|
|
384
384
|
folder_path: Type.String({ description: "Folder path, relative to the project root or absolute." }),
|
|
385
385
|
recursive: Type.Optional(Type.Boolean({ description: "Whether to scan subfolders.", default: false })),
|
|
386
|
-
max_side: Type.Optional(Type.Integer({ description: "Maximum resized image side before submission.", default:
|
|
386
|
+
max_side: Type.Optional(Type.Integer({ description: "Maximum resized image side before submission. Use 0 for full resolution.", default: 0 })),
|
|
387
387
|
jpeg_quality: Type.Optional(Type.Integer({ description: "JPEG conversion quality from 1 to 100.", default: 96 })),
|
|
388
388
|
limit: Type.Optional(Type.Integer({ description: "Optional maximum number of images to process." })),
|
|
389
389
|
}),
|
|
@@ -23,7 +23,7 @@ Use `/vision-inventory-setup` to configure credentials and check/install Python
|
|
|
23
23
|
/vision-inventory-agent-bom <image_folder> <output_dir> [options]
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
Options are forwarded to `scripts/inventory_folder_to_csv.py`, for example `--recursive`, `--limit 3`, `--max-side
|
|
26
|
+
Options are forwarded to `scripts/inventory_folder_to_csv.py`, for example `--recursive`, `--limit 3`, `--max-side 0`, and `--jpeg-quality 96`. `--max-side 0` means full resolution and is the default.
|
|
27
27
|
|
|
28
28
|
## Agent Rules
|
|
29
29
|
|
package/README.md
CHANGED
|
@@ -99,7 +99,7 @@ Useful options:
|
|
|
99
99
|
```text
|
|
100
100
|
/vision-inventory-agent-bom ./photos ./output --recursive
|
|
101
101
|
/vision-inventory-agent-bom ./photos ./output --limit 3
|
|
102
|
-
/vision-inventory-agent-bom ./photos ./output --max-side
|
|
102
|
+
/vision-inventory-agent-bom ./photos ./output --max-side 0 --jpeg-quality 96
|
|
103
103
|
```
|
|
104
104
|
|
|
105
105
|
The agent workflow will:
|
|
@@ -135,13 +135,13 @@ verified=false
|
|
|
135
135
|
|
|
136
136
|
## CSV output columns
|
|
137
137
|
|
|
138
|
-
`inventory.csv` is deduplicated by
|
|
138
|
+
`inventory.csv` is deduplicated by `normalized_part`, the main/final part number column derived from the vision `likely_part` and datasheet enrichment. Multiple images, or multiple candidates from one image, can merge into one BOM row when they resolve to the same `normalized_part`.
|
|
139
139
|
|
|
140
140
|
Columns:
|
|
141
141
|
|
|
142
142
|
| Column | Description |
|
|
143
143
|
|---|---|
|
|
144
|
-
| `normalized_part` |
|
|
144
|
+
| `normalized_part` | Main dedupe key/final part number, usually from datasheet enrichment and based on the vision `likely_part`. |
|
|
145
145
|
| `candidate_parts` | Candidate part numbers extracted from visual markings. |
|
|
146
146
|
| `amount` | Estimated quantity for the merged BOM row. |
|
|
147
147
|
| `sighting_count` | Number of evidence rows merged into this BOM row. |
|
|
@@ -242,7 +242,7 @@ Before sending an image to Cloudflare Workers AI, the Python server:
|
|
|
242
242
|
|
|
243
243
|
1. Opens the image with Pillow.
|
|
244
244
|
2. Applies EXIF orientation correction.
|
|
245
|
-
3.
|
|
245
|
+
3. Sends full resolution by default; resizes only when `max_side` is set to a positive value and the image is larger than that limit.
|
|
246
246
|
4. Converts transparency to a white background.
|
|
247
247
|
5. Converts the image to RGB.
|
|
248
248
|
6. Encodes it as JPEG.
|
|
@@ -251,7 +251,7 @@ Before sending an image to Cloudflare Workers AI, the Python server:
|
|
|
251
251
|
Defaults:
|
|
252
252
|
|
|
253
253
|
```text
|
|
254
|
-
max_side:
|
|
254
|
+
max_side: 0 (full resolution)
|
|
255
255
|
jpeg_quality: 96
|
|
256
256
|
model: @cf/meta/llama-4-scout-17b-16e-instruct
|
|
257
257
|
```
|
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.5",
|
|
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": {
|
|
@@ -228,15 +228,17 @@ def lookup_enrichment(part: str, cache: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
228
228
|
def estimate_amount_for_candidate(result: Dict[str, Any], candidate: str, evidence_count: int = 1) -> int:
|
|
229
229
|
"""Estimate physical IC quantity for one candidate in one image.
|
|
230
230
|
|
|
231
|
-
|
|
232
|
-
an ordinal
|
|
233
|
-
|
|
231
|
+
Some vision results use count_index as a grouped visible count, while others
|
|
232
|
+
use it as an ordinal. Use the maximum of matching item count, evidence count,
|
|
233
|
+
and any numeric count_index values so grouped detections like count_index=4
|
|
234
|
+
produce amount=4 without double-counting duplicate observations.
|
|
234
235
|
"""
|
|
235
236
|
items = result.get("items", [])
|
|
236
237
|
if not isinstance(items, list):
|
|
237
238
|
return max(1, evidence_count)
|
|
238
239
|
|
|
239
240
|
matched = 0
|
|
241
|
+
count_values: List[int] = []
|
|
240
242
|
for item in items:
|
|
241
243
|
if not isinstance(item, dict):
|
|
242
244
|
continue
|
|
@@ -245,10 +247,12 @@ def estimate_amount_for_candidate(result: Dict[str, Any], candidate: str, eviden
|
|
|
245
247
|
if candidate_from_item(item).upper() != candidate.upper():
|
|
246
248
|
continue
|
|
247
249
|
matched += 1
|
|
250
|
+
try:
|
|
251
|
+
count_values.append(max(1, int(item.get("count_index", 1))))
|
|
252
|
+
except Exception:
|
|
253
|
+
pass
|
|
248
254
|
|
|
249
|
-
|
|
250
|
-
return matched
|
|
251
|
-
return max(1, evidence_count)
|
|
255
|
+
return max([1, evidence_count, matched, *count_values])
|
|
252
256
|
|
|
253
257
|
|
|
254
258
|
def image_part_rows(results: List[Dict[str, Any]], cache: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
@@ -286,8 +290,10 @@ def image_part_rows(results: List[Dict[str, Any]], cache: Dict[str, Any]) -> Lis
|
|
|
286
290
|
|
|
287
291
|
for candidate, candidate_evidence in sorted(evidence_by_candidate.items()):
|
|
288
292
|
enrichment = lookup_enrichment(candidate, cache)
|
|
293
|
+
likely_part = str(enrichment.get("normalized_part") or candidate).strip().upper()
|
|
289
294
|
amount = estimate_amount_for_candidate(result, candidate, evidence_count=len(candidate_evidence))
|
|
290
|
-
observed_markings
|
|
295
|
+
# Keep observed_markings normalized to the main visible part number, not full date/lot/package text.
|
|
296
|
+
observed_markings = [likely_part]
|
|
291
297
|
observations = "; ".join(
|
|
292
298
|
f"{row['position_hint']}: {row['observed_marking']} ({row['marking_confidence']})"
|
|
293
299
|
for row in candidate_evidence
|
|
@@ -298,7 +304,7 @@ def image_part_rows(results: List[Dict[str, Any]], cache: Dict[str, Any]) -> Lis
|
|
|
298
304
|
rows.append({
|
|
299
305
|
"image": image_name,
|
|
300
306
|
"candidate_part": candidate,
|
|
301
|
-
"normalized_part":
|
|
307
|
+
"normalized_part": likely_part,
|
|
302
308
|
"amount": amount,
|
|
303
309
|
"description": enrichment.get("description", ""),
|
|
304
310
|
"datasheet_url": enrichment.get("datasheet_url", ""),
|
|
@@ -424,7 +430,7 @@ def parse_args() -> argparse.Namespace:
|
|
|
424
430
|
parser.add_argument("--recursive", action="store_true", help="Scan image_folder recursively")
|
|
425
431
|
parser.add_argument("--limit", type=int, default=None, help="Maximum number of images to process")
|
|
426
432
|
parser.add_argument("--skip-vision", action="store_true", help="Reuse existing output_dir/raw/*.json instead of calling vision AI")
|
|
427
|
-
parser.add_argument("--max-side", type=int, default=vision.DEFAULT_MAX_SIDE, help="Maximum resized image side")
|
|
433
|
+
parser.add_argument("--max-side", type=int, default=vision.DEFAULT_MAX_SIDE, help="Maximum resized image side; use 0 for full resolution (default)")
|
|
428
434
|
parser.add_argument("--jpeg-quality", type=int, default=vision.DEFAULT_JPEG_QUALITY, help="JPEG quality for model input")
|
|
429
435
|
return parser.parse_args()
|
|
430
436
|
|
package/vision_inventory_mcp.py
CHANGED
|
@@ -56,7 +56,7 @@ except ImportError: # pragma: no cover - compatibility fallback
|
|
|
56
56
|
|
|
57
57
|
|
|
58
58
|
DEFAULT_MODEL = os.getenv("WORKERS_AI_MODEL", "@cf/meta/llama-4-scout-17b-16e-instruct")
|
|
59
|
-
DEFAULT_MAX_SIDE =
|
|
59
|
+
DEFAULT_MAX_SIDE = 0
|
|
60
60
|
DEFAULT_JPEG_QUALITY = 96
|
|
61
61
|
DEFAULT_MAX_TOKENS = 1600
|
|
62
62
|
DEFAULT_TEMPERATURE = 0.05
|
|
@@ -184,8 +184,8 @@ def prepare_image_data_url(
|
|
|
184
184
|
max_side: int = DEFAULT_MAX_SIDE,
|
|
185
185
|
jpeg_quality: int = DEFAULT_JPEG_QUALITY,
|
|
186
186
|
) -> Tuple[Optional[str], Optional[Dict[str, Any]]]:
|
|
187
|
-
if max_side < 256:
|
|
188
|
-
return None, error_response("max_side must be at least 256.")
|
|
187
|
+
if max_side and max_side < 256:
|
|
188
|
+
return None, error_response("max_side must be 0 for full resolution or at least 256.")
|
|
189
189
|
|
|
190
190
|
if jpeg_quality < 1 or jpeg_quality > 100:
|
|
191
191
|
return None, error_response("jpeg_quality must be between 1 and 100.")
|
|
@@ -194,8 +194,9 @@ def prepare_image_data_url(
|
|
|
194
194
|
image = Image.open(image_path)
|
|
195
195
|
image = ImageOps.exif_transpose(image)
|
|
196
196
|
|
|
197
|
-
|
|
198
|
-
|
|
197
|
+
if max_side:
|
|
198
|
+
resample = getattr(Image, "Resampling", Image).LANCZOS
|
|
199
|
+
image.thumbnail((max_side, max_side), resample)
|
|
199
200
|
|
|
200
201
|
# Convert transparency to white background before JPEG encoding.
|
|
201
202
|
if image.mode in ("RGBA", "LA"):
|
|
@@ -645,8 +646,15 @@ def count_inventory_rows(inventory: Dict[str, Any]) -> int:
|
|
|
645
646
|
return 0
|
|
646
647
|
|
|
647
648
|
|
|
648
|
-
def flatten_inventory_for_csv(inventory: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
649
|
-
|
|
649
|
+
def flatten_inventory_for_csv(inventory: Dict[str, Any], enrichment_cache: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]:
|
|
650
|
+
"""Flatten raw vision output into BOM-style, likely-part-deduped CSV rows.
|
|
651
|
+
|
|
652
|
+
This is intentionally less complete than scripts/inventory_folder_to_csv.py
|
|
653
|
+
because the save tool only receives in-memory vision output. If a
|
|
654
|
+
datasheet_cache.json object is provided, matching enrichment fields are used.
|
|
655
|
+
"""
|
|
656
|
+
grouped: Dict[str, List[Dict[str, Any]]] = {}
|
|
657
|
+
cache = enrichment_cache or {}
|
|
650
658
|
|
|
651
659
|
if isinstance(inventory.get("items"), list):
|
|
652
660
|
image_results = [inventory]
|
|
@@ -659,31 +667,64 @@ def flatten_inventory_for_csv(inventory: Dict[str, Any]) -> List[Dict[str, Any]]
|
|
|
659
667
|
continue
|
|
660
668
|
|
|
661
669
|
image_name = str(result.get("image", "unknown"))
|
|
662
|
-
warnings = result.get("warnings", [])
|
|
663
|
-
if isinstance(warnings, list):
|
|
664
|
-
warnings_text = " | ".join(str(w) for w in warnings)
|
|
665
|
-
else:
|
|
666
|
-
warnings_text = str(warnings)
|
|
667
|
-
|
|
668
670
|
items = result.get("items", [])
|
|
669
671
|
if not isinstance(items, list):
|
|
670
672
|
continue
|
|
671
673
|
|
|
674
|
+
by_image_part: Dict[Tuple[str, str], Dict[str, Any]] = {}
|
|
672
675
|
for item in items:
|
|
673
676
|
if not isinstance(item, dict):
|
|
674
677
|
continue
|
|
675
|
-
|
|
678
|
+
if str(item.get("item_type", "")).strip().lower() != "ic":
|
|
679
|
+
continue
|
|
680
|
+
|
|
681
|
+
candidate = str(item.get("likely_part") or item.get("package_marking") or "unknown").strip().upper()
|
|
682
|
+
if not candidate or candidate.lower() in {"unknown", "unreadable", "unclear", "none", "n/a"}:
|
|
683
|
+
continue
|
|
684
|
+
enrichment = cache.get(candidate, {}) if isinstance(cache.get(candidate, {}), dict) else {}
|
|
685
|
+
normalized = str(enrichment.get("normalized_part") or candidate).strip().upper()
|
|
686
|
+
key = (image_name, normalized)
|
|
687
|
+
row = by_image_part.setdefault(key, {
|
|
676
688
|
"image": image_name,
|
|
677
|
-
"
|
|
678
|
-
"
|
|
679
|
-
"
|
|
680
|
-
"
|
|
681
|
-
"
|
|
682
|
-
"
|
|
683
|
-
"position_hint": item.get("position_hint", "unknown"),
|
|
684
|
-
"needs_review": item.get("needs_review", True),
|
|
685
|
-
"warnings": warnings_text,
|
|
689
|
+
"normalized_part": normalized,
|
|
690
|
+
"candidate_parts": set(),
|
|
691
|
+
"amount": 0,
|
|
692
|
+
"vision_confidence": set(),
|
|
693
|
+
"needs_review": False,
|
|
694
|
+
"observed_markings": set(),
|
|
686
695
|
})
|
|
696
|
+
row["candidate_parts"].add(candidate)
|
|
697
|
+
row["vision_confidence"].add(str(item.get("marking_confidence", "unknown")))
|
|
698
|
+
row["needs_review"] = bool(row["needs_review"] or item.get("needs_review", True))
|
|
699
|
+
# Keep the main part number as the observation, not the full package/date/lot marking.
|
|
700
|
+
row["observed_markings"].add(normalized)
|
|
701
|
+
try:
|
|
702
|
+
row["amount"] = max(int(row["amount"]), int(item.get("count_index", 1)))
|
|
703
|
+
except Exception:
|
|
704
|
+
row["amount"] = max(int(row["amount"]), 1)
|
|
705
|
+
|
|
706
|
+
for row in by_image_part.values():
|
|
707
|
+
grouped.setdefault(str(row["normalized_part"]), []).append(row)
|
|
708
|
+
|
|
709
|
+
rows: List[Dict[str, Any]] = []
|
|
710
|
+
for part, part_rows in sorted(grouped.items()):
|
|
711
|
+
enrichment = cache.get(part, {}) if isinstance(cache.get(part, {}), dict) else {}
|
|
712
|
+
rows.append({
|
|
713
|
+
"normalized_part": part,
|
|
714
|
+
"candidate_parts": " | ".join(sorted({candidate for row in part_rows for candidate in row["candidate_parts"]})),
|
|
715
|
+
"amount": sum(int(row.get("amount", 0) or 0) for row in part_rows),
|
|
716
|
+
"sighting_count": len(part_rows),
|
|
717
|
+
"description": enrichment.get("description", ""),
|
|
718
|
+
"datasheet_url": enrichment.get("datasheet_url", ""),
|
|
719
|
+
"manufacturer": enrichment.get("manufacturer", ""),
|
|
720
|
+
"verified": bool(enrichment.get("verified", False)),
|
|
721
|
+
"vision_confidence": "/".join(sorted({value for row in part_rows for value in row["vision_confidence"]})),
|
|
722
|
+
"needs_review": any(bool(row.get("needs_review", True)) for row in part_rows) or not bool(enrichment.get("verified", False)),
|
|
723
|
+
"images": " | ".join(sorted({str(row["image"]) for row in part_rows})),
|
|
724
|
+
"observed_markings": " | ".join(sorted({marking for row in part_rows for marking in row["observed_markings"]})),
|
|
725
|
+
"raw_json": "",
|
|
726
|
+
"notes": enrichment.get("notes", "Missing datasheet enrichment"),
|
|
727
|
+
})
|
|
687
728
|
|
|
688
729
|
return rows
|
|
689
730
|
|
|
@@ -719,18 +760,32 @@ def save_inventory(
|
|
|
719
760
|
row_count = count_inventory_rows(inventory)
|
|
720
761
|
|
|
721
762
|
else:
|
|
722
|
-
|
|
763
|
+
cache_path = output.parent / "datasheet_cache.json"
|
|
764
|
+
enrichment_cache: Dict[str, Any] = {}
|
|
765
|
+
if cache_path.exists():
|
|
766
|
+
try:
|
|
767
|
+
loaded_cache = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
768
|
+
if isinstance(loaded_cache, dict):
|
|
769
|
+
enrichment_cache = loaded_cache
|
|
770
|
+
except Exception:
|
|
771
|
+
enrichment_cache = {}
|
|
772
|
+
|
|
773
|
+
rows = flatten_inventory_for_csv(inventory, enrichment_cache)
|
|
723
774
|
fieldnames = [
|
|
724
|
-
"
|
|
725
|
-
"
|
|
726
|
-
"
|
|
727
|
-
"
|
|
728
|
-
"marking_confidence",
|
|
729
|
-
"likely_part",
|
|
775
|
+
"normalized_part",
|
|
776
|
+
"candidate_parts",
|
|
777
|
+
"amount",
|
|
778
|
+
"sighting_count",
|
|
730
779
|
"description",
|
|
731
|
-
"
|
|
780
|
+
"datasheet_url",
|
|
781
|
+
"manufacturer",
|
|
782
|
+
"verified",
|
|
783
|
+
"vision_confidence",
|
|
732
784
|
"needs_review",
|
|
733
|
-
"
|
|
785
|
+
"images",
|
|
786
|
+
"observed_markings",
|
|
787
|
+
"raw_json",
|
|
788
|
+
"notes",
|
|
734
789
|
]
|
|
735
790
|
|
|
736
791
|
with output.open("w", newline="", encoding="utf-8") as csv_file:
|