Source code for matmmextract.inference.cropper

"""
matmmextract.inference.cropper
==================================
Crop detected panels from images using the JSON files produced by
:func:`~matmmextract.inference.detector.run`.

Output naming convention
------------------------
``<stem>_<label>.jpg``   — highest-confidence detection for that label.
When multiple detections share a label, only the one with the highest
score is saved.  These names are matched by dataset_builder's IMG_RE pattern.
"""

from __future__ import annotations

import argparse
import json
import os
from dataclasses import dataclass, field
from pathlib import Path

from PIL import Image
from tqdm import tqdm

IMAGE_EXTS: tuple[str, ...] = (
    ".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff", ".webp"
)


[docs] @dataclass class CropResult: n_crops: int = 0 n_skipped: int = 0 n_no_detections: int = 0 output_dir: str = "" failed: list[str] = field(default_factory=list)
[docs] def crop( image_dir: str | Path, json_dir: str | Path, output_dir: str | Path, limit: int | None = None, verbose: bool = True, ) -> CropResult: """Crop all detected panels and save them as individual JPEG files. Parameters ---------- image_dir: Directory containing the original flat images. json_dir: Directory containing per-image ``.json`` files from :func:`~matmmextract.inference.detector.run`. ``_summary.json`` is automatically skipped. output_dir: Directory where cropped panel images are saved. limit: Process at most this many JSON files (useful for testing). verbose: Print progress. """ image_dir = Path(image_dir) json_dir = Path(json_dir) output_dir = Path(output_dir) output_dir.mkdir(parents=True, exist_ok=True) json_files = sorted( f for f in os.listdir(json_dir) if f.endswith(".json") and f != "_summary.json" ) if not json_files: raise RuntimeError(f"No JSON files found in: {json_dir}") if limit is not None: json_files = json_files[:limit] if verbose: print(f"[cropper] {len(json_files)} JSON files → {output_dir}") result = CropResult(output_dir=str(output_dir)) for jname in tqdm(json_files, desc="Cropping", disable=not verbose): jpath = json_dir / jname with open(jpath) as fh: data = json.load(fh) stem = Path(data["file"]).stem # Find the matching source image img_path: Path | None = None for ext in IMAGE_EXTS: candidate = image_dir / (stem + ext) if candidate.exists(): img_path = candidate break if img_path is None: result.n_skipped += 1 if verbose: print(f"[cropper] skip — image not found for {jname}") continue if not data["detections"]: result.n_no_detections += 1 continue try: pil = Image.open(img_path).convert("RGB") except Exception as exc: result.failed.append(str(img_path)) if verbose: print(f"[cropper] ERROR opening {img_path}: {exc}") continue # Keep only the highest-confidence detection per label best: dict[str, dict] = {} for det in data["detections"]: lbl = det["label_name"] if lbl not in best or det["score"] > best[lbl]["score"]: best[lbl] = det for det in best.values(): label = det["label_name"] out_name = f"{stem}_{label}.jpg" if label == "single": pil.save(output_dir / out_name) else: x1, y1, x2, y2 = [int(round(v)) for v in det["bbox"]] crop = pil.crop((x1, y1, x2, y2)) crop.save(output_dir / out_name) result.n_crops += 1 if verbose: print( f"[cropper] done — crops={result.n_crops} " f"skipped={result.n_skipped} no_detections={result.n_no_detections}" ) return result
def _parse_args() -> argparse.Namespace: p = argparse.ArgumentParser("Crop detected panels from images") p.add_argument("--image-dir", required=True) p.add_argument("--json-dir", required=True) p.add_argument("--output-dir", required=True) p.add_argument("--limit", type=int, default=None) return p.parse_args() def main() -> None: args = _parse_args() crop( image_dir=args.image_dir, json_dir=args.json_dir, output_dir=args.output_dir, limit=args.limit, ) if __name__ == "__main__": main()