417 lines
19 KiB
Python
417 lines
19 KiB
Python
"""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)
|