""" crop_political_articles.py ========================== Standalone re-cropping pass for political articles in the latest extractor run. What it does ------------ - Picks the most recent run folder under `output/`. - For each article folder under `output//articles/pXXX_aYYY/` (these are the political articles — the extractor only writes folders for them), reads `info.json` to get the page number and the member region IDs. - Opens `output//pages/page_XXX.png` and computes a TIGHT crop: union(member region bboxes) with a tiny 5px padding, clamped to the page image. This is tighter than the extractor's default 15px padding around the same union. - Writes the result to `output//cropped_articles/pXXX_aYYY.png`. Guarantees ---------- - Does NOT touch any existing files (articles/, all_political_images/, pages/, political_articles.json, political_articles.pdf, etc). - Only creates a new folder `cropped_articles/` inside the run folder. Usage ----- python crop_political_articles.py python crop_political_articles.py --run output/20260528_203315 python crop_political_articles.py --padding 0 """ from __future__ import annotations import argparse import json import sys from pathlib import Path from PIL import Image def find_latest_run(output_root: Path) -> Path: if not output_root.exists(): sys.exit(f"output folder not found: {output_root}") runs = sorted( [p for p in output_root.iterdir() if p.is_dir() and p.name[0].isdigit()], key=lambda p: p.name, ) if not runs: sys.exit(f"no run folders found under {output_root}") return runs[-1] def load_regions(pages_dir: Path, page_num: int) -> dict[int, list[int]]: """Return {region_id: [x1, y1, x2, y2]} for the given page.""" regions_path = pages_dir / f"page_{page_num:03d}.regions.json" if not regions_path.exists(): return {} with regions_path.open() as f: data = json.load(f) out: dict[int, list[int]] = {} for r in data.get("regions", []): rid = r.get("id") bbox = r.get("bbox") if rid is None or not bbox or len(bbox) != 4: continue out[int(rid)] = [int(v) for v in bbox] return out def tight_bbox(member_ids: list[int], region_map: dict[int, list[int]], fallback_bbox: list[int] | None) -> list[int] | None: """Compute union bbox of member regions; fall back to info.json bbox.""" boxes = [region_map[rid] for rid in member_ids if rid in region_map] if boxes: x1 = min(b[0] for b in boxes) y1 = min(b[1] for b in boxes) x2 = max(b[2] for b in boxes) y2 = max(b[3] for b in boxes) return [x1, y1, x2, y2] if fallback_bbox and len(fallback_bbox) == 4: return [int(v) for v in fallback_bbox] return None def crop_one(article_dir: Path, pages_dir: Path, out_dir: Path, padding: int) -> tuple[bool, str]: info_path = article_dir / "info.json" if not info_path.exists(): return False, f"{article_dir.name}: no info.json" with info_path.open() as f: info = json.load(f) page_num = int(info.get("page", 0)) member_ids = [int(x) for x in info.get("member_region_ids", []) or []] fallback = info.get("bbox") page_img_path = pages_dir / f"page_{page_num:03d}.png" if not page_img_path.exists(): return False, f"{article_dir.name}: page image missing ({page_img_path.name})" region_map = load_regions(pages_dir, page_num) bbox = tight_bbox(member_ids, region_map, fallback) if bbox is None: return False, f"{article_dir.name}: could not compute bbox" with Image.open(page_img_path) as im: pw, ph = im.size x1 = max(0, bbox[0] - padding) y1 = max(0, bbox[1] - padding) x2 = min(pw, bbox[2] + padding) y2 = min(ph, bbox[3] + padding) if x2 <= x1 or y2 <= y1: return False, f"{article_dir.name}: empty crop {[x1,y1,x2,y2]}" cropped = im.crop((x1, y1, x2, y2)) out_path = out_dir / f"{article_dir.name}.png" cropped.save(out_path) return True, f"{article_dir.name}: {x2-x1}x{y2-y1}px -> {out_path.name}" def main() -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument( "--run", type=Path, default=None, help="Path to a specific run folder. Default: latest under ./output", ) ap.add_argument( "--output-root", type=Path, default=Path(__file__).parent / "output", help="Root output dir (default: ./output next to this script)", ) ap.add_argument( "--padding", type=int, default=5, help="Pixels of padding around the tight region union (default: 5)", ) args = ap.parse_args() run_dir = args.run if args.run is not None else find_latest_run(args.output_root) run_dir = run_dir.resolve() print(f"Run folder: {run_dir}") articles_dir = run_dir / "articles" pages_dir = run_dir / "pages" if not articles_dir.is_dir(): sys.exit(f"missing articles/ in {run_dir}") if not pages_dir.is_dir(): sys.exit(f"missing pages/ in {run_dir}") out_dir = run_dir / "cropped_articles" out_dir.mkdir(exist_ok=True) print(f"Writing to: {out_dir}") article_folders = sorted(p for p in articles_dir.iterdir() if p.is_dir()) if not article_folders: print("No political article folders found.") return 0 ok = 0 for adir in article_folders: success, msg = crop_one(adir, pages_dir, out_dir, args.padding) print((" ok " if success else " -- ") + msg) if success: ok += 1 print(f"\nDone. {ok}/{len(article_folders)} articles cropped into " f"{out_dir.relative_to(run_dir.parent)}") return 0 if __name__ == "__main__": raise SystemExit(main())