sundeep-news-scan/scratch_py/_test_boundaries50.py

171 lines
6.2 KiB
Python

"""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)