"""50-run validation harness (no Claude). For every page with a regions.json in the last N runs: detect rules -> dateline scan -> construct article boundaries -> validate. Writes overlays for FAILING pages and a per-paper pass/fail report. Usage: venv/bin/python3 _test_boundaries50.py [N=50] [out_dir=/tmp/bnd50] """ import json import sys import traceback from collections import Counter from pathlib import Path import cv2 from PIL import Image from _lines import detect_separator_lines from _boundaries import construct_boundaries from smart_extractor import find_article_starts_by_dateline OUT = Path("output") DL_CACHE = Path("/tmp/dl_cache") DL_CACHE.mkdir(parents=True, exist_ok=True) def cached_datelines(regs, png, paper, key): """Datelines depend only on the (fixed) regions + page image, so cache the Tesseract scan to disk — re-validation after a _boundaries.py edit is then instant (no OCR).""" cf = DL_CACHE / f"{key}.json" if cf.exists(): return json.loads(cf.read_text()) dls = find_article_starts_by_dateline(regs, png, paper) cf.write_text(json.dumps(dls)) return dls def paper_of_height(h): if 7040 <= h <= 7060: return "sakshi" if h in (6833, 6549): return "andhra_jyothi" if h == 6422: return "namaste_telangana" return f"h{h}" def iou(a, b): ix = max(0, min(a[2], b[2]) - max(a[0], b[0])) iy = max(0, min(a[3], b[3]) - max(a[1], b[1])) inter = ix * iy if not inter: return 0.0 ua = (a[2]-a[0])*(a[3]-a[1]) + (b[2]-b[0])*(b[3]-b[1]) - inter return inter / ua if ua else 0.0 def validate(boxes, rmap): issues = [] for bx in boxes: db = rmap[bx["region_id"]]["bbox"] bb = bx["bbox"] if not (bb[0] <= db[0]+2 and bb[1] <= db[1]+2 and bb[2] >= db[2]-2 and bb[3] >= db[3]-2): issues.append(("no-contain", bx["region_id"])) if bb[2]-bb[0] < 60 or bb[3]-bb[1] < 60: issues.append(("degenerate", bx["region_id"])) for i in range(len(boxes)): for j in range(i+1, len(boxes)): if iou(boxes[i]["bbox"], boxes[j]["bbox"]) > 0.25: issues.append(("overlap", (boxes[i]["region_id"], boxes[j]["region_id"]))) return issues def draw(png, lines, boxes, out_path): img = cv2.imread(str(png)) for L in lines["h"]: cv2.line(img, (L["x1"], L["y1"]), (L["x2"], L["y2"]), (0, 0, 255), 4) for L in lines["v"]: cv2.line(img, (L["x1"], L["y1"]), (L["x2"], L["y2"]), (255, 0, 0), 4) for bx in boxes: b = bx["bbox"] cv2.rectangle(img, (b[0], b[1]), (b[2], b[3]), (0, 180, 0), 8) cv2.imwrite(str(out_path), img) def main(n=50, out_dir="/tmp/bnd50"): out_dir = Path(out_dir) out_dir.mkdir(parents=True, exist_ok=True) runs = sorted([d for d in OUT.iterdir() if d.is_dir()], key=lambda d: d.stat().st_mtime, reverse=True)[:n] per_paper_pages = Counter() per_paper_fail = Counter() issue_kinds = Counter() fail_list = [] total_pages = 0 total_boxes = 0 total_dls = 0 for run in runs: pages = run / "pages" if not pages.exists(): continue for reg_path in sorted(pages.glob("page_*.regions.json")): png = reg_path.with_name(reg_path.name.replace(".regions.json", ".png")) if not png.exists(): continue try: regs = json.loads(reg_path.read_text())["regions"] rmap = {r["id"]: r for r in regs} paper = paper_of_height(Image.open(png).height) lines = detect_separator_lines(png) dls = cached_datelines(regs, str(png), paper, f"{run.name}_{reg_path.stem}") if not dls: continue # no datelines -> nothing to bound boxes = construct_boundaries(regs, dls, lines, lines["size"]) issues = validate(boxes, rmap) except Exception: issues = [("exception", reg_path.name)] boxes, dls, lines = [], [], {"h": [], "v": []} traceback.print_exc() total_pages += 1 total_boxes += len(boxes) total_dls += len(dls) per_paper_pages[paper] += 1 if issues: per_paper_fail[paper] += 1 for k, _ in issues: issue_kinds[k] += 1 tag = f"{run.name}_{reg_path.stem}" fail_list.append((tag, paper, len(dls), len(boxes), issues)) try: draw(png, lines, boxes, out_dir / f"FAIL_{tag}.png") except Exception: pass print("\n==================== BOUNDARY VALIDATION (last %d runs) ====================" % n) print(f"{'paper':<20}{'pages':>8}{'fail':>7}{'pass%':>8}") for paper in sorted(per_paper_pages): pg = per_paper_pages[paper] fl = per_paper_fail[paper] print(f"{paper:<20}{pg:>8}{fl:>7}{100*(pg-fl)/pg:>7.0f}%") tot = total_pages fl = sum(per_paper_fail.values()) print(f"{'TOTAL':<20}{tot:>8}{fl:>7}{100*(tot-fl)/max(1,tot):>7.0f}%") print(f"\ndatelines total={total_dls} boxes total={total_boxes} " f"(boxes should equal datelines: {'OK' if total_dls==total_boxes else 'MISMATCH'})") print(f"issue kinds: {dict(issue_kinds)}") if fail_list: print(f"\n--- {len(fail_list)} failing page(s) (overlays in {out_dir}/FAIL_*.png) ---") for tag, paper, nd, nb, issues in fail_list[:60]: ic = Counter(k for k, _ in issues) print(f" [{paper}] {tag} dls={nd} boxes={nb} {dict(ic)}") # machine-readable (out_dir / "report.json").write_text(json.dumps({ "pages": total_pages, "fail": fl, "per_paper": {p: [per_paper_pages[p], per_paper_fail[p]] for p in per_paper_pages}, "issue_kinds": dict(issue_kinds), "fails": [[t, p, nd, nb, [list(x) if isinstance(x, tuple) else x for _, x in iss]] for t, p, nd, nb, iss in fail_list], }, indent=2)) print(f"\nreport: {out_dir/'report.json'}") if __name__ == "__main__": n = int(sys.argv[1]) if len(sys.argv) > 1 else 50 od = sys.argv[2] if len(sys.argv) > 2 else "/tmp/bnd50" main(n, od)