sundeep-news-scan/scratch_py/_continuation.py

463 lines
19 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Cross-page article-continuation linker (classical, no Claude / no API).
Telugu papers "jump" long stories across pages:
• bottom of the source article : 'మిగతా <N>వ పేజీలో...' (rest is on page N)
• top of the continuation page : the SAME headline repeated, plus the marker
'(మొదటి పేజీ తరువాయి)' (continued from page 1)
We REQUIRE BOTH signals: the jump-from marker on the source page (which also tells
us the target page N) AND a repeated headline on page N that matches the source
article's headline. We then build a boundary box for the continuation by injecting
a SYNTHETIC dateline anchored on that repeated headline and running the existing
boundary engine (so the continuation floors correctly against page-N's own
articles), and finally STITCH the source crop + continuation crop into ONE tall
image per article.
Pure geometry + Tesseract on saved fixtures (page PNGs + regions.json + the
per-article info.json the pipeline already wrote). No Claude.
link_continuations(run_dir) -> list of link records (also written to
<run>/continuations/links.json, stitched PNGs alongside).
"""
import difflib
import json
import re
import unicodedata
from pathlib import Path
from PIL import Image
from _lines import detect_separator_lines
try:
from smart_extractor import _dateline_in_text
except Exception: # keep the module importable in isolation
def _dateline_in_text(_t, _paper=None):
return None
_ZW = "" # zero-width non-joiner / joiner
_TERMINATORS = ".।?!" # sentence-enders: full stop, danda, ?, !
_TELUGU_DIGITS = {ord("") + i: str(i) for i in range(10)}
# jump-FROM: 'మిగతా <num> వ పేజీ...' — tolerate OCR noise between the tokens.
_JUMP_FROM = re.compile(r"మిగ[తథధ]ా.{0,10}?([0-9-౯]{1,2})\s*వ?\s*పే[జీిౌ]", re.UNICODE)
# jump-TO: 'మొదటి పేజీ తరువాయి' — the continuation marker on the target page.
_JUMP_TO = re.compile(r"మొదట[ిి]?\s*పే[జీి].{0,6}?తరు[వ]?ా[యి]", re.UNICODE)
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 _ocr(page, box):
import pytesseract
try:
return pytesseract.image_to_string(page.crop((int(box[0]), int(box[1]),
int(box[2]), int(box[3]))),
lang="tel")
except Exception:
return ""
def _page_num_from(text):
m = _JUMP_FROM.search(text or "")
if not m:
return None
digits = m.group(1).translate(_TELUGU_DIGITS)
dm = re.search(r"\d+", digits)
if not dm:
return None
d = dm.group()
# OCR frequently DOUBLE-RECOGNISES a single glyph as the Latin digit followed
# by its Telugu twin (e.g. '3౩' → '33'). District editions only ever run a
# handful of pages, so a run of identical digits is that artefact, not page 33.
if len(d) == 2 and d[0] == d[1]:
d = d[0]
return int(d)
def _norm(s):
s = unicodedata.normalize("NFC", s or "")
s = "".join(c for c in s if c not in _ZW)
return re.sub(r"[\s.,!?:;\-()।\"']", "", s)
def _similar(a, b):
a, b = _norm(a), _norm(b)
if not a or not b:
return 0.0
return difflib.SequenceMatcher(None, a, b).ratio()
def _find_jump_from(page, art_bbox):
"""Scan the BOTTOM band of the source article for the 'మిగతా Nవ పేజీలో' marker;
fall back to the whole article box. Returns the target page number or None."""
l, t, r, b = art_bbox
band_top = max(t, b - 340)
n = _page_num_from(_ocr(page, (l, band_top, r, b)))
if n is None: # marker may sit a little higher
n = _page_num_from(_ocr(page, (l, t, r, b)))
return n
def _best_headline(regs, page, headline_tel, thresh=0.60):
"""Among the doc_titles on the target page, return (region, score, has_marker)
for the one whose OCR text best matches the source headline (>= thresh)."""
best, best_score = None, 0.0
for r in regs:
if r.get("type") != "doc_title":
continue
score = _similar(_ocr(page, r["bbox"]), headline_tel)
if score > best_score:
best, best_score = r, score
if not best or best_score < thresh:
return None, best_score, False
hb = best["bbox"]
below = _ocr(page, (hb[0], hb[3], hb[2], min(hb[3] + 240, page.height)))
has_marker = bool(_JUMP_TO.search(below) or _JUMP_TO.search(_ocr(page, hb)))
return best, best_score, has_marker
def _ends_terminated(text):
"""True if the column's last word ends a sentence (., danda, ?, !), ignoring
trailing quotes/brackets. This is the (noisy, OCR-based) 'article concluded'
signal — used only AFTER the structural stop-tests have had their say."""
t = (text or "").rstrip()
t = t.rstrip("\"'’”)]》」』 ")
return bool(t) and t[-1] in _TERMINATORS
def _col_width(regs):
"""Robust single-column width = median width of the page's text boxes."""
ws = sorted(r["bbox"][2] - r["bbox"][0] for r in regs if r.get("type") == "text")
return ws[len(ws) // 2] if ws else 700
def _has_body_below(regs, b):
"""True if a text box sits below box `b` within its horizontal span — i.e. `b`
is a headline opening its OWN article (a new story), not a trailing label or an
inline emphasis run. Used to let a paragraph_title act as an article floor."""
for r in regs:
if r.get("type") == "text":
rb = r["bbox"]
if rb[1] > b[1] + 5 and rb[0] < b[2] and rb[2] > b[0]:
return True
return False
def _is_floor_head(regs, r, hid):
"""A region that floors a continuation: any doc_title, OR a paragraph_title that
has its own body beneath it (the start of a DIFFERENT article — e.g. a
'continued from page N' sub-head sitting below our continuation block)."""
if r.get("id") == hid:
return False
t = r.get("type")
if t == "doc_title":
return True
return t == "paragraph_title" and _has_body_below(regs, r["bbox"])
def _floor_under(regs, lines, hb, hid, x0, x1, H):
"""Lowest boundary below the header within the corridor [x0,x1]: the top of the
next headline (doc_title, or an article-starting paragraph_title), or a
horizontal rule, whichever comes first (else page bottom)."""
f = H
for r in regs:
b = r["bbox"]
if (_is_floor_head(regs, r, hid)
and b[1] > hb[3] + 20 and b[0] < x1 and b[2] > x0):
f = min(f, b[1])
for ln in (lines.get("h") or []):
y = ln["y1"]
if hb[3] + 20 < y < f and ln["x1"] < x1 and ln["x2"] > x0:
f = min(f, y)
return f
def _floor_below_body(regs, lines, hb, hid, x0, x1, body, H):
"""Lowest article boundary in corridor [x0,x1]. A next-headline or horizontal
rule only counts as the floor when NO included body text continues *below* it in
an overlapping column — that distinguishes the next article's wide headline (no
body beneath, within this block) from an interior sub-head / column-top rule
(article body keeps going underneath it)."""
def _no_body_below(bx0, bx2, yt):
for bb in body:
if bb[3] > yt + 10 and bb[0] < bx2 and bb[2] > bx0:
return False
return True
f = H
for r in regs:
b = r["bbox"]
if (_is_floor_head(regs, r, hid)
and b[1] > hb[3] + 20 and b[0] < x1 and b[2] > x0
and _no_body_below(b[0], b[2], b[1])):
f = min(f, b[1])
for ln in (lines.get("h") or []):
y = ln["y1"]
if hb[3] + 20 < y < f and ln["x1"] < x1 and ln["x2"] > x0 \
and _no_body_below(ln["x1"], ln["x2"], y):
f = min(f, y)
return f
def _vrule_in_gutter(lines, x0, x1, y0, y1):
"""A printed vertical rule sitting in the gutter [x0,x1] across most of [y0,y1]."""
for ln in (lines.get("v") or []):
if x0 - 8 <= ln["x1"] <= x1 + 8:
ov = min(ln["y2"], y1) - max(ln["y1"], y0)
if ov > 0.4 * (y1 - y0):
return True
return False
def _track_starts_with_title(regs, items, hb):
"""STOP-test 5: does a headline / sub-head box open the next column?"""
xs = min(it["bbox"][0] for it in items)
xe = max(it["bbox"][2] for it in items)
ytop = min(it["bbox"][1] for it in items)
for r in regs:
if r.get("type") in ("doc_title", "paragraph_title"):
b = r["bbox"]
if b[0] < xe and b[2] > xs and b[1] > hb[3] + 20 and abs(b[1] - ytop) < 130:
return True
return False
def _continuation_crop(regs, png_path, paper, headline_region):
"""Crop the FULL continuation article by GROWING RIGHT from the headline.
A continuation's body is usually wider than its header and spans several columns.
We start at the header's column and walk right column-by-column, absorbing the
next column unless a STOP-test fires:
1 no next column 5 next column opens with a headline/sub-head
2 vertical-rule wall 6 current column's last word ends with a period
3 horizontal-rule break 7 grammatical bridge fails (approx: 4+6 absent)
4 next column opens a dateline
Structural tests (1-5) outrank the OCR period (6). The crop is the single
rectangle enclosing every absorbed column (+ any photo inside the block),
floored at the next headline / horizontal rule.
Returns (PIL.Image, debug_bbox) or (None, None)."""
page = Image.open(png_path).convert("RGB")
H = page.height
hb = headline_region["bbox"]
hid = headline_region.get("id")
cx = (hb[0] + hb[2]) / 2
lines = detect_separator_lines(png_path)
colW = _col_width(regs)
# provisional floor under the header's own span gives us the band to cluster in
F0 = _floor_under(regs, lines, hb, hid, hb[0], hb[2], H)
# body text boxes in the band, clustered into left-to-right COLUMN TRACKS
band = [r for r in regs if r.get("type") == "text"
and (r["bbox"][1] + r["bbox"][3]) / 2 < F0
and r["bbox"][3] > hb[1]
and r["bbox"][0] >= hb[0] - colW * 0.5] # drop columns left of the header
if not band:
return None, None
tracks = []
for r in sorted(band, key=lambda r: r["bbox"][0]):
for tr in tracks:
if abs(r["bbox"][0] - tr["x0"]) < colW * 0.5:
tr["items"].append(r)
break
else:
tracks.append({"x0": r["bbox"][0], "items": [r]})
tracks.sort(key=lambda t: t["x0"])
# START column = the track straddling the headline centre (else nearest)
start = None
for i, t in enumerate(tracks):
if min(it["bbox"][0] for it in t["items"]) <= cx <= max(it["bbox"][2] for it in t["items"]):
start = i
break
if start is None:
start = min(range(len(tracks)), key=lambda i: abs(tracks[i]["x0"] - cx))
import os
DBG = os.environ.get("CONT_DEBUG")
if DBG:
print(f"[dbg] hb={hb} cx={cx} colW={colW} F0={F0}")
for ti, t in enumerate(tracks):
print(f"[dbg] track {ti} x0={t['x0']} items="
+ ",".join(f"{it.get('id')}{it['bbox']}" for it in t['items']))
print(f"[dbg] start track = {start}")
# GROW RIGHT, applying the stop-tests at each gutter
included = [start]
i = start
while True:
cur = tracks[i]["items"]
cur_r = max(it["bbox"][2] for it in cur)
nxt = next((j for j in range(i + 1, len(tracks))
if tracks[j]["x0"] > cur_r - colW * 0.3), None)
if nxt is None: # 1 no next column
if DBG: print(f"[dbg] from {i}: STOP test1 no next column")
break
nt = tracks[nxt]["items"]
nt_l = min(it["bbox"][0] for it in nt)
y0, y1 = hb[1], F0
if _vrule_in_gutter(lines, cur_r, nt_l, y0, y1): # 2 vertical wall
if DBG: print(f"[dbg] from {i} to {nxt}: STOP test2 vrule wall {cur_r}-{nt_l}")
break
top_reg = min(nt, key=lambda it: it["bbox"][1])
tb = top_reg["bbox"]
head_txt = _ocr(page, (tb[0], tb[1], tb[2], min(tb[1] + 150, tb[3])))
if _dateline_in_text(head_txt, paper): # 4 dateline opens next col
if DBG: print(f"[dbg] from {i} to {nxt}: STOP test4 dateline '{head_txt[:40]}'")
break
if _track_starts_with_title(regs, nt, hb): # 5 headline opens next col
if DBG: print(f"[dbg] from {i} to {nxt}: STOP test5 title opens next")
break
bot_reg = max(cur, key=lambda it: it["bbox"][3])
bot_txt = _ocr(page, bot_reg["bbox"])
if _ends_terminated(bot_txt): # 6 period concludes
if DBG: print(f"[dbg] from {i} to {nxt}: STOP test6 period; tail='{bot_txt[-30:]}'")
break
if DBG: print(f"[dbg] grow {i} -> {nxt}")
included.append(nxt) # 7 bridge holds -> grow
i = nxt
if DBG: print(f"[dbg] included tracks = {included}")
# FINAL rectangle over the absorbed columns, floored against the real corridor
left = hb[0]
right = max(max(it["bbox"][2] for it in tracks[k]["items"]) for k in included)
body = [it["bbox"] for k in included for it in tracks[k]["items"]]
F = _floor_below_body(regs, lines, hb, hid, left, right, body, H)
if DBG: print(f"[dbg] left={left} right={right} F={F}")
members = list(body)
for r in regs: # sweep in photos / captions inside the block
if r.get("type") in ("image", "figure_title", "paragraph_title", "doc_title"):
b = r["bbox"]
bcx = (b[0] + b[2]) / 2
if left - 10 <= bcx <= right + 10 and b[3] <= F and b[3] > hb[1] - 320:
members.append(b)
left = min(left, min(m[0] for m in members))
top = min(hb[1], min(m[1] for m in members))
bot = min(int(F) - 8, max(m[3] for m in members) + 22)
box = (int(left), int(max(0, top - 6)), int(right), int(bot))
return page.crop(box), list(box)
def _stitch(top_png, cont_img, out_path):
"""Stack the source page-1 crop above the composed continuation image."""
top = Image.open(top_png).convert("RGB")
W = max(top.width, cont_img.width)
sep = 18
combo = Image.new("RGB", (W, top.height + sep + cont_img.height), "white")
combo.paste(top, (0, 0))
combo.paste(cont_img, (0, top.height + sep))
combo.save(out_path)
return combo.size
def link_continuations(run_dir, out_dir=None, match_thresh=0.60, verbose=True):
run = Path(run_dir)
pages_dir = run / "pages"
arts_dir = run / "articles"
out_dir = Path(out_dir) if out_dir else (run / "continuations")
out_dir.mkdir(parents=True, exist_ok=True)
p1 = pages_dir / "page_001.png"
paper = _paper_of_height(Image.open(p1).height) if p1.exists() else "unknown"
results = []
for info_path in sorted(arts_dir.glob("*/info.json")):
info = json.loads(info_path.read_text())
P, bbox = info.get("page"), info.get("bbox")
headline = info.get("headline_telugu") or ""
if not P or not bbox:
continue
# Continuations only ever START on page 1 — the front page jumps long lead
# stories onto inside pages. Articles on pages 2+ are self-contained, so we
# never scan them for a jump-from marker.
if P != 1:
continue
src_png = pages_dir / f"page_{P:03d}.png"
if not src_png.exists():
continue
N = _find_jump_from(Image.open(src_png), bbox) # SIGNAL 1: jump-from
if not N or N == P:
continue
# The jump-from page DIGIT is OCR-noisy (Telugu ౪/౬ confusion) and can point
# at a page outside this extraction (here the front page said '6వ పేజీ' but
# the run only has 4 pages). So treat the marker as "this front-page lead
# continues on a later page" and resolve the ACTUAL target by the repeated-
# headline signal: score every OTHER page's doc_titles against the source
# headline and take the best match, breaking ties toward the OCR'd page N.
existing = sorted(
int(m.group(1))
for q in pages_dir.glob("page_*.png")
if (m := re.match(r"page_(\d+)\.png$", q.name)))
best = None # (score, page, tregs, tgt_png_str, hl, marker)
for q in existing:
if q == P:
continue
qr = pages_dir / f"page_{q:03d}.regions.json"
qp = pages_dir / f"page_{q:03d}.png"
if not qr.exists() or not qp.exists():
continue
qregs = json.loads(qr.read_text())["regions"]
hl_q, score_q, marker_q = _best_headline(qregs, Image.open(qp), headline, match_thresh)
if hl_q is None:
continue
if best is None or score_q > best[0] or (score_q == best[0] and q == N):
best = (score_q, q, qregs, str(qp), hl_q, marker_q)
if best is None:
rec = {"article": info_path.parent.name, "src_page": P, "headline": headline,
"target_page": N, "match_score": 0.0, "taruvaayi_marker": False,
"linked": False,
"reason": "no matching repeated headline on any inside page"}
results.append(rec)
if verbose:
print(f"{rec['article']} → p{N}: jump-from found but no repeated "
f"headline match on any inside page (NOT linked)")
continue
score, N, tregs, tgt_png, hl, marker = best
rec = {"article": info_path.parent.name, "src_page": P, "headline": headline,
"target_page": N, "match_score": round(score, 2),
"taruvaayi_marker": marker, "linked": False}
cont_img, cbox = _continuation_crop(tregs, tgt_png, paper, hl)
if cont_img is None:
rec["reason"] = "no body under repeated headline"
results.append(rec)
continue
out_png = out_dir / f"{info_path.parent.name}__cont_p{N:03d}.png"
size = _stitch(arts_dir / info_path.parent.name / "article.png",
cont_img, out_png)
rec.update({"linked": True, "cont_bbox": cbox,
"stitched": str(out_png), "stitched_size": size})
results.append(rec)
if verbose:
print(f"{rec['article']} '{headline[:26]}' → p{N} "
f"(headline {score:.2f}, తరువాయి={marker}) → {out_png.name}")
(out_dir / "links.json").write_text(json.dumps(results, indent=2, ensure_ascii=False))
linked = sum(1 for r in results if r["linked"])
if verbose:
print(f"\n {linked} continuation(s) linked & stitched; report: {out_dir/'links.json'}")
return results
if __name__ == "__main__":
import sys
link_continuations(sys.argv[1] if len(sys.argv) > 1 else "output/20260601_234709")