sundeep-news-scan/_lines.py

417 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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.

"""Separator-rule detection for newspaper pages (classical CV, no ML / no API).
Detects the thin printed grey/black straight lines the paper uses to divide
stories, with a TEXT-ISOLATION filter to reject false positives from dense
Telugu text (a real rule sits in a whitespace gutter; a text stroke has ink
immediately above/below it).
Public:
detect_separator_lines(png_path) -> {"h": [...], "v": [...], "size": (W,H)}
each line is a dict: {x1,y1,x2,y2,len,gray} (gray = mean intensity).
"""
import cv2
import numpy as np
def _candidates(bw, horizontal, min_len, max_thick=55):
if horizontal:
k = cv2.getStructuringElement(cv2.MORPH_RECT, (min_len // 2, 1))
else:
k = cv2.getStructuringElement(cv2.MORPH_RECT, (1, min_len // 2))
opened = cv2.morphologyEx(bw, cv2.MORPH_OPEN, k, iterations=1)
# bridge small collinear gaps along the line direction
if horizontal:
opened = cv2.dilate(opened, cv2.getStructuringElement(cv2.MORPH_RECT, (25, 3)))
else:
opened = cv2.dilate(opened, cv2.getStructuringElement(cv2.MORPH_RECT, (3, 25)))
cnts, _ = cv2.findContours(opened, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
out = []
for c in cnts:
x, y, w, h = cv2.boundingRect(c)
if horizontal and w >= min_len and h <= max_thick:
out.append((x, y + h // 2, x + w, y + h // 2, w))
if (not horizontal) and h >= min_len and w <= max_thick:
out.append((x + w // 2, y, x + w // 2, y + h, h))
return out
def _isolated(gray, line, horizontal, ink_thresh=110, gap=(5, 16),
max_adjacent_ink=0.12):
"""A true separator sits in a whitespace gutter: the tight band on BOTH sides
(parallel to the line) is mostly paper. Returns (is_isolated, mean_gray)."""
H, W = gray.shape
x1, y1, x2, y2, ln = line
if horizontal:
seg = gray[y1, x1:x2]
lo, hi = gap
above = gray[max(0, y1 - hi):max(0, y1 - lo), x1:x2]
below = gray[min(H, y1 + lo):min(H, y1 + hi), x1:x2]
else:
seg = gray[y1:y2, x1]
lo, hi = gap
above = gray[y1:y2, max(0, x1 - hi):max(0, x1 - lo)]
below = gray[y1:y2, min(W, x1 + lo):min(W, x1 + hi)]
if seg.size == 0 or above.size == 0 or below.size == 0:
return False, 255
ink_a = float((above < ink_thresh).mean())
ink_b = float((below < ink_thresh).mean())
isolated = (ink_a <= max_adjacent_ink) and (ink_b <= max_adjacent_ink)
return isolated, float(seg.mean())
def detect_separator_lines(png_path, h_frac=0.045, v_frac=0.06):
img = cv2.imread(str(png_path))
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
H, W = gray.shape
blur = cv2.GaussianBlur(gray, (3, 3), 0)
bw = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)[1]
h_min = int(h_frac * W)
v_min = int(v_frac * H)
h_out, v_out = [], []
for ln in _candidates(bw, True, h_min):
iso, g = _isolated(gray, ln, True)
if iso:
h_out.append({"x1": ln[0], "y1": ln[1], "x2": ln[2], "y2": ln[3],
"len": ln[4], "gray": round(g, 1)})
for ln in _candidates(bw, False, v_min):
iso, g = _isolated(gray, ln, False)
if iso:
v_out.append({"x1": ln[0], "y1": ln[1], "x2": ln[2], "y2": ln[3],
"len": ln[4], "gray": round(g, 1)})
return {"h": h_out, "v": v_out, "size": (W, H)}
def detect_content_bottom(png_path, search_frac=0.12, color_thr=0.06,
blank_color=0.02, blank_ink=0.035, peak_thr=0.15,
max_band=160, min_gap=25, min_margin=18):
"""Find the y below which a page holds only the printer's COLOUR-CALIBRATION
strip (the row of CMYK registration dots + grey bars) and the bottom paper
margin — i.e. the bottom of real article content.
The calibration strip is a thin band of strongly-saturated colour dots near
the page bottom, isolated by a white paper margin BELOW it (down to the page
edge) and a white gap ABOVE it (separating it from article content). Crops
should be clamped to the returned y so the dots never bleed into an article.
Returns the y at the TOP of the calibration band, or the full page height H
when no such strip is found (nothing to clamp)."""
img = cv2.imread(str(png_path))
if img is None:
return None
H, W = img.shape[:2]
b, g, r = img[..., 0].astype(np.int16), img[..., 1].astype(np.int16), img[..., 2].astype(np.int16)
mx = np.maximum(np.maximum(r, g), b)
mn = np.minimum(np.minimum(r, g), b)
sat = mx - mn
colored = (sat > 55) & (mx > 80) # saturated colour (registration dots)
dark = mx < 110 # ink / dark
col_frac = colored.mean(axis=1) # per-row colour fraction
ink_frac = dark.mean(axis=1) # per-row ink fraction
blank = (col_frac < blank_color) & (ink_frac < blank_ink)
y0 = int(H * (1 - search_frac))
# The calibration strip is a band of strongly-coloured rows; identify it as the
# bottom-most run of rows whose colour fraction clears `color_thr`.
strip_rows = [y for y in range(y0, H) if col_frac[y] >= color_thr]
if not strip_rows:
return H # no coloured strip near the bottom
band_bottom = strip_rows[-1]
# A real calibration strip is followed by white paper margin to the page edge.
below = blank[band_bottom + 1:H]
if below.size < min_margin or float(below.mean()) < 0.9:
return H
# Walk up from band_bottom while still in the coloured band (tolerate the small
# white gaps between dot rows).
band_top = band_bottom
gap = 0
y = band_bottom - 1
while y > y0:
if col_frac[y] >= color_thr:
band_top = y
gap = 0
else:
gap += 1
if gap > 12:
break
y -= 1
if (band_bottom - band_top + 1) > max_band:
return H # too tall -> fused with a photo, bail
# A genuine printer calibration strip is a dense row of saturated CMYK
# registration dots: its peak per-row colour fraction is high (~0.22-0.45 across
# Andhra Jyothi / Sakshi). A single-hue coloured ad/notice box embedded in
# article text peaks far lower (~0.11) -> reject it as a false positive.
peak = float(col_frac[band_top:band_bottom + 1].max())
if peak < peak_thr:
return H
return band_top
def _hspan_overlap(ax1, ax2, bx1, bx2):
ix = max(0, min(ax2, bx2) - max(ax1, bx1))
return ix / max(1, min(ax2 - ax1, bx2 - bx1))
def _faint_rule_above(gray, hbbox, max_gap=170, min_gap=6, gray_lo=140, gray_hi=215,
cover=0.6, max_dark=0.06):
"""Find a faint GREY horizontal rule sitting in the whitespace directly above a
(multi-column) header. Such a light rule reads as background to the ink-based
Otsu detector in `detect_separator_lines`, so we look for a thin row that is
mostly uniform mid-grey across the header's width and is isolated by white paper
above and below. Returns the rule's y, or None. (Used only above multi-column
headers, where it marks the article's TOP boundary.)"""
H, W = gray.shape
hx1, hy, hx2 = hbbox[0], hbbox[1], hbbox[2]
y_hi = max(0, hy - min_gap)
y_lo = max(0, hy - max_gap)
for y in range(y_hi, y_lo, -1):
row = gray[y, hx1:hx2].astype(np.int16)
if row.size == 0:
continue
mid = float(((row >= gray_lo) & (row <= gray_hi)).mean())
dark = float((row < gray_lo).mean())
if mid >= cover and dark <= max_dark:
above = gray[max(0, y - 6):max(0, y - 2), hx1:hx2]
below = gray[min(H, y + 2):min(H, y + 6), hx1:hx2]
if (above.size and below.size
and float((above > gray_hi).mean()) > 0.85
and float((below > gray_hi).mean()) > 0.85):
return y
return None
def _faint_grey_band_above(gray, hbbox, max_gap=185, min_gap=6, max_thick=12,
gray_lo=135, gray_hi=232, cover=0.8, max_dark=0.08,
white_thr=235, white_cover=0.9):
"""Find a faint GREY horizontal rule (1-`max_thick` px thick) sitting in the
whitespace directly above a SINGLE-column header. Per the layout convention a
grey rule whose length matches the article/header width marks the boundary
between two stacked stories, so the next story's headline must not fold into
the article above the rule. Such light rules read as background to the ink-based
Otsu detector in `detect_separator_lines`, and `_faint_rule_above` only accepts
a 1-px isolated rule, so a thicker grey band is missed; here we group the band
and require it to span the header's width and be isolated by white paper above
and below. Returns the band's centre y, or None."""
H, W = gray.shape
hx1, hy, hx2 = int(hbbox[0]), int(hbbox[1]), int(hbbox[2])
y_hi = max(0, hy - min_gap)
y_lo = max(0, hy - max_gap)
cols = slice(hx1, hx2)
grey_rows = []
for y in range(y_lo, y_hi):
row = gray[y, cols].astype(np.int16)
if row.size == 0:
continue
mid = float(((row >= gray_lo) & (row <= gray_hi)).mean())
dark = float((row < gray_lo).mean())
if mid >= cover and dark <= max_dark:
grey_rows.append(y)
if not grey_rows:
return None
bands = []
s = p = grey_rows[0]
for y in grey_rows[1:]:
if y == p + 1:
p = y
else:
bands.append((s, p)); s = p = y
bands.append((s, p))
for top, bot in sorted(bands, key=lambda b: -b[1]): # nearest the header first
if bot - top + 1 > max_thick:
continue
above = gray[max(0, top - 4):top, cols]
below = gray[bot + 1:min(H, bot + 5), cols]
if (above.size and below.size
and float((above > white_thr).mean()) >= white_cover
and float((below > white_thr).mean()) >= white_cover):
return (top + bot) // 2
return None
def _faint_grey_band_below(gray, bbox, max_gap=150, min_gap=4, max_thick=12,
gray_lo=135, gray_hi=232, cover=0.8, max_dark=0.08,
white_thr=235, white_cover=0.9):
"""Mirror of `_faint_grey_band_above`, searching DOWNWARD: find the faint grey
rule that sits in the whitespace just below an article (within `max_gap` px of
its bottom edge) and spans its column width. That rule is the article's BOTTOM
boundary, so the crop can be cut precisely at it. Returns the band's top y (the
cut line), or None when no clean full-width grey rule is found below."""
H, W = gray.shape
bx1, by, bx2 = int(bbox[0]), int(bbox[3]), int(bbox[2])
y_lo = min(H, by + min_gap)
y_hi = min(H, by + max_gap)
cols = slice(bx1, bx2)
grey_rows = []
for y in range(y_lo, y_hi):
row = gray[y, cols].astype(np.int16)
if row.size == 0:
continue
mid = float(((row >= gray_lo) & (row <= gray_hi)).mean())
dark = float((row < gray_lo).mean())
if mid >= cover and dark <= max_dark:
grey_rows.append(y)
if not grey_rows:
return None
bands = []
s = p = grey_rows[0]
for y in grey_rows[1:]:
if y == p + 1:
p = y
else:
bands.append((s, p)); s = p = y
bands.append((s, p))
for top, bot in sorted(bands, key=lambda b: b[0]): # nearest below first
if bot - top + 1 > max_thick:
continue
# Skip the 1-px anti-aliased fade row hugging the rule before checking for
# the white paper gutter above and below it.
above = gray[max(0, top - 7):max(0, top - 2), cols]
below = gray[bot + 2:min(H, bot + 7), cols]
if (above.size and below.size
and float((above > white_thr).mean()) >= white_cover
and float((below > white_thr).mean()) >= white_cover):
return top
return None
def separator_barriers(png_path, regions, near=80, min_overlap=0.3,
title_types=("doc_title", "paragraph_title", "figure_title"),
multicol_frac=0.22):
"""Article-boundary rules: the horizontal printed lines that mark the END of a
story. Per the layout convention, a rule with NORMAL text (or whitespace) above
it separates two stacked articles, while a rule sitting right under a BOLD
headline/title is a decorative flourish and must NOT split anything.
We approximate 'bold above' by a title-type region (doc_title / paragraph_title
/ figure_title) whose bottom edge is within `near` px above the rule and that
horizontally overlaps it. Such rules are dropped; the rest are returned as
barriers {y, x1, x2} for the clusterer to forbid cross-line folding.
Additionally, for every MULTI-COLUMN header (a doc_title at least `multicol_frac`
of the page width), we look for a faint grey rule directly above it and add it as
a barrier spanning the header's width: that rule is the article's TOP boundary, so
nothing above it folds into the multi-column article below."""
res = detect_separator_lines(png_path)
# A real article separator sits in a WHITESPACE GUTTER between two stacked
# boxes; it never lands deep inside a body-text region. PaddleOCR's morphology
# occasionally mistakes a dense Telugu text stroke for a thin rule, producing a
# phantom line straight through a paragraph. Reject any detected rule whose
# midpoint falls well inside (margin-shrunk) a 'text' box it column-overlaps.
_text_boxes = [r["bbox"] for r in regions if r.get("type") == "text"]
def _in_text(y, lx1, lx2, margin=28):
mx = (lx1 + lx2) / 2
for tx1, ty1, tx2, ty2 in _text_boxes:
if (tx1 <= mx <= tx2
and ty1 + margin <= y <= ty2 - margin
and _hspan_overlap(lx1, lx2, tx1, tx2) >= min_overlap):
return True
return False
# Column width (median text-region width). A horizontal line counts as an article
# separator only when it spans most of a column. NOTE: a strict ≥1.0× column floor is
# too aggressive — a SINGLE-column article's own boundary rule is itself <1 column wide,
# so ≥1.0× drops real separators and merges articles (measured: NT pg4 R121 → 2 datelines).
# 0.6× column is the safe floor: it removes only the short caption / decorative underlines
# and merges NO articles on AJ / NT / JG.
_twid = sorted((r["bbox"][2] - r["bbox"][0]) for r in regions if r.get("type") == "text")
_pitch = _twid[len(_twid) // 2] if _twid else 0
_len_floor = 0.6 * _pitch
bars = []
for L in res["h"]:
y, lx1, lx2 = L["y1"], L["x1"], L["x2"]
if _in_text(y, lx1, lx2):
continue
if _pitch and (lx2 - lx1) < _len_floor:
continue # too short to be an article separator (caption/underline)
bold_above = False
title_w_above = 0
for r in regions:
b = r["bbox"]
if (b[3] <= y and (y - b[3]) <= near
and r.get("type") in title_types
and _hspan_overlap(lx1, lx2, b[0], b[2]) >= min_overlap):
bold_above = True
title_w_above = max(title_w_above, b[2] - b[0])
# A rule under a bold title is a decorative flourish ONLY when it merely
# underlines that title (rule width ≈ title width). A rule spanning much
# WIDER than the title above it runs across several columns — e.g. a photo
# caption (figure_title) sits above ONE column while the rule runs the FULL
# article width — so it is a genuine article boundary and must be kept.
if (not bold_above) or ((lx2 - lx1) > 1.5 * title_w_above):
bars.append({"y": y, "x1": lx1, "x2": lx2})
gray = cv2.cvtColor(cv2.imread(str(png_path)), cv2.COLOR_BGR2GRAY)
W = gray.shape[1]
_img_boxes = [r["bbox"] for r in regions if r.get("type") == "image"]
def _lead_photo_above(yb, hx1, hx2, gap=80):
"""A faint rule directly under the article's OWN lead photo is that photo's
caption underline, not the article's top boundary. If an image sits flush
(within `gap`px) above the rule and overlaps the header's width, the rule must
NOT bound the article — otherwise the lead photo gets cut off."""
for ix1, iy1, ix2, iy2 in _img_boxes:
if (0 <= yb - iy2 <= gap
and _hspan_overlap(hx1, hx2, ix1, ix2) >= min_overlap):
return True
return False
for r in regions:
if r.get("type") != "doc_title":
continue
b = r["bbox"]
if (b[2] - b[0]) < multicol_frac * W:
continue # single-column header → skip
yb = _faint_rule_above(gray, b)
if yb is not None and not _lead_photo_above(yb, b[0], b[2]):
bars.append({"y": yb, "x1": b[0], "x2": b[2],
"mc": True, "anchor": r["id"]})
# Same rule for SINGLE-column headers: a faint grey rule spanning the header's
# width directly above it bounds the article ABOVE it, so the header (and its
# story) cannot fold upward across the rule. Plain barrier (no mc clamp).
for r in regions:
if r.get("type") not in ("doc_title", "paragraph_title"):
continue
b = r["bbox"]
if (b[2] - b[0]) >= multicol_frac * W:
continue # multi-column → handled above
yb = _faint_grey_band_above(gray, b)
if yb is not None and not _lead_photo_above(yb, b[0], b[2]):
bars.append({"y": yb, "x1": b[0], "x2": b[2]})
# Vertical column-divider rules. detect_separator_lines already requires each
# line to sit in a whitespace gutter (isolated on both sides), so photo/text
# internal strokes are filtered out. These fence rightward column-growth: an
# article must not grow across a printed vertical rule into the next column.
for L in res["v"]:
bars.append({"vert": True, "x": (L["x1"] + L["x2"]) // 2,
"y1": min(L["y1"], L["y2"]), "y2": max(L["y1"], L["y2"])})
return bars
def overlay(png_path, lines, out_path):
img = cv2.imread(str(png_path))
for L in lines["h"]:
cv2.line(img, (L["x1"], L["y1"]), (L["x2"], L["y2"]), (0, 0, 255), 6)
for L in lines["v"]:
cv2.line(img, (L["x1"], L["y1"]), (L["x2"], L["y2"]), (255, 0, 0), 6)
cv2.imwrite(str(out_path), img)
if __name__ == "__main__":
import sys
from pathlib import Path
p = Path(sys.argv[1])
res = detect_separator_lines(p)
print(f"{p.name} {res['size']} H={len(res['h'])} V={len(res['v'])}")
for L in sorted(res["h"], key=lambda d: d["y1"]):
print(f" H y={L['y1']:>5} x[{L['x1']:>4}..{L['x2']:>4}] len={L['len']:>4} gray={L['gray']}")
for L in sorted(res["v"], key=lambda d: d["x1"]):
print(f" V x={L['x1']:>5} y[{L['y1']:>4}..{L['y2']:>4}] len={L['len']:>4} gray={L['gray']}")
out = Path(sys.argv[2]) if len(sys.argv) > 2 else Path(f"/tmp/lines_{p.stem}.png")
overlay(p, res, out)
print("overlay:", out)