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