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.
@@ -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: 4000 })),
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: 4000 })),
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 4000`, and `--jpeg-quality 96`.
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 4000 --jpeg-quality 96
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 normalized part number. Multiple images, or multiple candidates from one image, can merge into one BOM row.
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` | Final normalized part number, usually from datasheet enrichment. |
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. Resizes only if the image is larger than `max_side`.
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: 4000
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",
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
- Count separate matching IC items. The schema field count_index is treated as
232
- an ordinal/index, not a quantity. Fall back to the number of candidate
233
- evidence rows when only observations are available.
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
- if matched > 0:
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 = sorted({row["observed_marking"] for row in candidate_evidence})
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": enrichment.get("normalized_part", candidate),
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
 
@@ -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 = 4000
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
- resample = getattr(Image, "Resampling", Image).LANCZOS
198
- image.thumbnail((max_side, max_side), resample)
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
- rows: List[Dict[str, Any]] = []
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
- rows.append({
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
- "item_type": item.get("item_type", "unknown"),
678
- "count_index": item.get("count_index", ""),
679
- "package_marking": item.get("package_marking", "unknown"),
680
- "marking_confidence": item.get("marking_confidence", "unreadable"),
681
- "likely_part": item.get("likely_part", "unknown"),
682
- "description": item.get("description", "unknown"),
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
- rows = flatten_inventory_for_csv(inventory)
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
- "image",
725
- "item_type",
726
- "count_index",
727
- "package_marking",
728
- "marking_confidence",
729
- "likely_part",
775
+ "normalized_part",
776
+ "candidate_parts",
777
+ "amount",
778
+ "sighting_count",
730
779
  "description",
731
- "position_hint",
780
+ "datasheet_url",
781
+ "manufacturer",
782
+ "verified",
783
+ "vision_confidence",
732
784
  "needs_review",
733
- "warnings",
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: