sundeep-news-scan/crop_political_articles.py

171 lines
5.8 KiB
Python

"""
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/<run>/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/<run>/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/<run>/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())