171 lines
5.8 KiB
Python
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())
|