416 lines
15 KiB
Python
416 lines
15 KiB
Python
"""Masthead logo segmentation for pages that embed only full-page rasters.
|
|
|
|
Some scanned documents carry no separate image XObject at all: every page is
|
|
one full-page bitmap, so the figure extractor correctly declines to embed any
|
|
of it (a page-sized raster per page is not a figure, it is the page). The
|
|
emblem at the top of page one is then lost entirely, even though it is the one
|
|
piece of artwork a reader expects to survive.
|
|
|
|
This module recovers it by segmenting the raster instead of the PDF: ink in the
|
|
masthead band that OCR did *not* claim as text is, by elimination, artwork.
|
|
|
|
Deliberately dependency-light — Pillow and numpy only, both already required by
|
|
the OCR path. No OpenCV, no paid SDK.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
from dataclasses import dataclass
|
|
|
|
# At most this many emblems per masthead. A letterhead carries an emblem and
|
|
# perhaps a wordmark or seal; more clusters than this means the band is text
|
|
# the OCR mask failed to claim, not artwork.
|
|
MAX_LOGOS = 3
|
|
# A second mark must be at least this share of the largest one's area to be a
|
|
# mark rather than a speck the text mask missed.
|
|
MIN_RELATIVE_MARK_AREA = 0.08
|
|
# Masthead band as a fraction of page height, measured from the top.
|
|
BAND_RATIO = 0.22
|
|
# A pixel this much darker than the page's paper white counts as ink.
|
|
INK_DELTA = 42
|
|
# Side strips of the masthead, as a fraction of page width. Letterhead marks
|
|
# live here; a full-width OCR envelope for the banner must not decide them.
|
|
SIDE_STRIP_RATIO = 0.36
|
|
# Fainter ink still counts as a mark in a side strip — gold/grey emblems sit
|
|
# closer to paper than black body text.
|
|
SIDE_INK_DELTA = 18
|
|
# Text boxes are grown by this fraction of their size before masking, so
|
|
# antialiased edges of glyphs do not survive as stray ink.
|
|
TEXT_PAD_RATIO = 0.14
|
|
# A logo must occupy at least this fraction of the band's width and height.
|
|
MIN_SIDE_RATIO = 0.025
|
|
# ...and no more than this, or it is the whole banner rather than an emblem.
|
|
MAX_WIDTH_RATIO = 0.55
|
|
MAX_HEIGHT_RATIO = 0.95
|
|
# A leftover banner stroke is a wide, short bar. Emblems are compact.
|
|
MAX_MARK_ASPECT = 2.0
|
|
# Below this ink density the crop is speckle, not artwork.
|
|
MIN_INK_DENSITY = 0.04
|
|
MIN_FAINT_INK_DENSITY = 0.022
|
|
# Columns with less than this share of the band's peak ink are gaps.
|
|
COLUMN_GAP_RATIO = 0.06
|
|
# Output is scaled down to keep the DOCX small; a masthead needs no more.
|
|
MAX_OUTPUT_SIDE = 420
|
|
|
|
|
|
@dataclass
|
|
class LogoCrop:
|
|
"""One emblem cropped from a masthead, with where it sat on the raster."""
|
|
|
|
png: bytes
|
|
# Pixel box on the page raster: (x, y, w, h), top-left origin.
|
|
bbox_px: tuple[int, int, int, int]
|
|
raster_size: tuple[int, int]
|
|
|
|
def bbox_points(self, page_width: float, page_height: float) -> tuple[float, float, float, float]:
|
|
"""The same box in PDF points on a page of the given size.
|
|
|
|
Without this the recovered artwork reached the writer with no geometry
|
|
at all, and every figure with no geometry was emitted at a hard-coded
|
|
5.5 inches — which is how a two-centimetre emblem became a full-width
|
|
banner across the top of the Word document.
|
|
"""
|
|
rw, rh = self.raster_size
|
|
if rw <= 0 or rh <= 0:
|
|
return 0.0, 0.0, 0.0, 0.0
|
|
sx = float(page_width) / float(rw)
|
|
sy = float(page_height) / float(rh)
|
|
x, y, w, h = self.bbox_px
|
|
# Raster y grows downwards, PDF y grows upwards.
|
|
return (x * sx, page_height - (y + h) * sy, w * sx, h * sy)
|
|
|
|
|
|
def extract_masthead_logos(
|
|
page_png: bytes,
|
|
text_boxes: list[tuple[float, float, float, float]] | None = None,
|
|
*,
|
|
band_ratio: float = BAND_RATIO,
|
|
max_logos: int = MAX_LOGOS,
|
|
) -> list[LogoCrop]:
|
|
"""Crop every emblem from the top band of a page raster, left to right.
|
|
|
|
``text_boxes`` are OCR boxes in the same pixel space as ``page_png``, as
|
|
``(x, y, w, h)``. Anything they cover is text and is masked out; the compact
|
|
ink regions left in the band are artwork.
|
|
|
|
Letterheads very often carry two marks — an emblem on one side and a
|
|
wordmark or seal on the other. Returning only the largest lost one of them
|
|
and, worse, stretched the survivor across the page. Every qualifying cluster
|
|
is returned, in reading order.
|
|
|
|
An empty list is the common case and must stay cheap and silent.
|
|
"""
|
|
try:
|
|
import numpy as np
|
|
from PIL import Image
|
|
except Exception:
|
|
return []
|
|
|
|
try:
|
|
with Image.open(io.BytesIO(page_png)) as im:
|
|
page = im.convert("L")
|
|
width, height = page.size
|
|
band_h = max(1, int(height * band_ratio))
|
|
band = np.asarray(page.crop((0, 0, width, band_h)), dtype="int16")
|
|
|
|
if band.size == 0:
|
|
return []
|
|
|
|
# Paper white is the band's brightest common value, not 255: a scan is
|
|
# grey, and thresholding against pure white marks the whole page as ink.
|
|
paper = int(np.percentile(band, 90))
|
|
ink = band < (paper - INK_DELTA)
|
|
ink_raw = ink.copy()
|
|
faint = band < (paper - SIDE_INK_DELTA)
|
|
|
|
_mask_text(ink, text_boxes, band_h, page_width=width)
|
|
clusters = _ink_regions(ink, np) if ink.any() else []
|
|
out = _crops_from_boxes(clusters, page_png, width, height, band_h, ink, max_logos)
|
|
|
|
# Prefer one mark in each side strip from ink that the banner envelope
|
|
# has not been allowed to erase. A letterhead is those two corners.
|
|
out = _prefer_side_marks(
|
|
out,
|
|
_side_strip_crops(
|
|
page_png, ink_raw, faint, width, height, band_h, np, text_boxes
|
|
),
|
|
width,
|
|
max_logos,
|
|
)
|
|
return out
|
|
except Exception:
|
|
return []
|
|
|
|
|
|
def extract_masthead_logo(
|
|
page_png: bytes,
|
|
text_boxes: list[tuple[float, float, float, float]] | None = None,
|
|
*,
|
|
band_ratio: float = BAND_RATIO,
|
|
) -> bytes | None:
|
|
"""The single most prominent emblem, as PNG bytes. Prefer the plural form."""
|
|
crops = extract_masthead_logos(page_png, text_boxes, band_ratio=band_ratio, max_logos=MAX_LOGOS)
|
|
if not crops:
|
|
return None
|
|
return max(crops, key=lambda c: c.bbox_px[2] * c.bbox_px[3]).png
|
|
|
|
|
|
def _qualifies_as_mark(w: int, h: int, width: int, band_h: int) -> bool:
|
|
if w < width * MIN_SIDE_RATIO or h < band_h * MIN_SIDE_RATIO:
|
|
return False
|
|
if w > width * MAX_WIDTH_RATIO or h > band_h * MAX_HEIGHT_RATIO:
|
|
return False
|
|
if h > 0 and (w / h) > MAX_MARK_ASPECT and h < band_h * 0.40:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _crop_area(crop: LogoCrop) -> int:
|
|
return crop.bbox_px[2] * crop.bbox_px[3]
|
|
|
|
|
|
def _crops_from_boxes(boxes, page_png, width, height, band_h, ink, max_logos) -> list[LogoCrop]:
|
|
out: list[LogoCrop] = []
|
|
for x0, y0, x1, y1 in boxes:
|
|
w, h = x1 - x0, y1 - y0
|
|
if not _qualifies_as_mark(w, h, width, band_h):
|
|
continue
|
|
if ink[y0:y1, x0:x1].mean() < MIN_INK_DENSITY:
|
|
continue
|
|
png = _crop_png(page_png, (x0, y0, x1, y1))
|
|
if not png:
|
|
continue
|
|
out.append(
|
|
LogoCrop(png=png, bbox_px=(x0, y0, w, h), raster_size=(width, height))
|
|
)
|
|
if len(out) >= max_logos:
|
|
break
|
|
return out
|
|
|
|
|
|
def _best_in_strip(
|
|
ink, x0: int, x1: int, page_png, width, height, band_h, np, *, min_density: float = MIN_INK_DENSITY
|
|
) -> LogoCrop | None:
|
|
if x1 <= x0 or ink.size == 0:
|
|
return None
|
|
strip = ink[:, x0:x1]
|
|
if not strip.any():
|
|
return None
|
|
boxes = _ink_regions(strip, np)
|
|
if not boxes:
|
|
return None
|
|
bx0, by0, bx1, by1 = max(boxes, key=lambda b: (b[2] - b[0]) * (b[3] - b[1]))
|
|
abs0, abs1 = bx0 + x0, bx1 + x0
|
|
w, h = abs1 - abs0, by1 - by0
|
|
if not _qualifies_as_mark(w, h, width, band_h):
|
|
return None
|
|
if ink[by0:by1, abs0:abs1].mean() < min_density:
|
|
return None
|
|
png = _crop_png(page_png, (abs0, by0, abs1, by1))
|
|
if not png:
|
|
return None
|
|
return LogoCrop(png=png, bbox_px=(abs0, by0, w, h), raster_size=(width, height))
|
|
|
|
|
|
def _mask_strip_text(ink, text_boxes, x0: int, x1: int, band_h: int) -> None:
|
|
"""Mask OCR boxes inside a side strip, with no centre carve-out.
|
|
|
|
Page-wide banner envelopes are skipped: those are the case the side pass
|
|
exists to recover. A compact box in the strip is real text and must go.
|
|
"""
|
|
if not text_boxes:
|
|
return
|
|
rows, cols = ink.shape
|
|
wide = cols * 0.55
|
|
outer = cols * 0.16
|
|
for bx, by, bw, bh in text_boxes:
|
|
if bw <= 0 or bh <= 0 or bw >= wide:
|
|
continue
|
|
if bh < band_h * 0.28 and bw > cols * 0.35:
|
|
continue
|
|
cx = bx + bw / 2
|
|
if cx < outer or cx > cols - outer:
|
|
continue
|
|
sx0 = max(x0, 0, int(bx))
|
|
sx1 = min(x1, cols, int(bx + bw))
|
|
y0 = max(0, int(by))
|
|
y1 = min(rows, int(by + bh), band_h)
|
|
if sx1 > sx0 and y1 > y0:
|
|
ink[y0:y1, sx0:sx1] = False
|
|
|
|
|
|
def _side_strip_crops(
|
|
page_png, ink_raw, faint, width, height, band_h, np, text_boxes
|
|
) -> list[LogoCrop]:
|
|
left_end = max(1, int(width * SIDE_STRIP_RATIO))
|
|
right_start = min(width - 1, int(width * (1.0 - SIDE_STRIP_RATIO)))
|
|
found: list[LogoCrop] = []
|
|
for x0, x1 in ((0, left_end), (right_start, width)):
|
|
masked = ink_raw.copy()
|
|
_mask_strip_text(masked, text_boxes, x0, x1, band_h)
|
|
crop = _best_in_strip(masked, x0, x1, page_png, width, height, band_h, np)
|
|
if crop is None:
|
|
# Do not mask OCR boxes on the faint pass: letterhead artwork is
|
|
# often boxed as text and the second mark disappears.
|
|
crop = _best_in_strip(
|
|
faint,
|
|
x0,
|
|
x1,
|
|
page_png,
|
|
width,
|
|
height,
|
|
band_h,
|
|
np,
|
|
min_density=MIN_FAINT_INK_DENSITY,
|
|
)
|
|
if crop is not None:
|
|
found.append(crop)
|
|
return found
|
|
|
|
|
|
def _centres_overlap(a: LogoCrop, b: LogoCrop) -> bool:
|
|
ax, ay, aw, ah = a.bbox_px
|
|
bx, by, bw, bh = b.bbox_px
|
|
acx, acy = ax + aw / 2, ay + ah / 2
|
|
return bx <= acx <= bx + bw and by <= acy <= by + bh
|
|
|
|
|
|
def _prefer_side_marks(
|
|
primary: list[LogoCrop],
|
|
sides: list[LogoCrop],
|
|
width: int,
|
|
max_logos: int,
|
|
) -> list[LogoCrop]:
|
|
"""Keep at most one mark per third of the masthead, sides first.
|
|
|
|
A leftover banner fragment in the centre is often larger than the real
|
|
emblems. Ranking by area then dropped the letterhead. The corners are the
|
|
letterhead; the middle is only kept when it is comparable to them.
|
|
"""
|
|
combined: list[LogoCrop] = []
|
|
for crop in list(sides) + list(primary):
|
|
if any(_centres_overlap(crop, existing) or _centres_overlap(existing, crop) for existing in combined):
|
|
continue
|
|
combined.append(crop)
|
|
|
|
def _bucket(crop: LogoCrop) -> str:
|
|
cx = crop.bbox_px[0] + crop.bbox_px[2] / 2
|
|
if cx < width * 0.40:
|
|
return "left"
|
|
if cx > width * 0.60:
|
|
return "right"
|
|
return "mid"
|
|
|
|
buckets: dict[str, list[LogoCrop]] = {"left": [], "mid": [], "right": []}
|
|
for crop in combined:
|
|
buckets[_bucket(crop)].append(crop)
|
|
|
|
kept: list[LogoCrop] = []
|
|
for key in ("left", "right"):
|
|
if buckets[key]:
|
|
kept.append(max(buckets[key], key=_crop_area))
|
|
if buckets["mid"] and len(kept) < max_logos:
|
|
mid = max(buckets["mid"], key=_crop_area)
|
|
floor = max((_crop_area(c) for c in kept), default=1) * MIN_RELATIVE_MARK_AREA
|
|
if _crop_area(mid) >= floor:
|
|
kept.append(mid)
|
|
kept.sort(key=lambda c: c.bbox_px[0])
|
|
return kept[:max_logos]
|
|
|
|
|
|
def _mask_text(ink, text_boxes, band_h: int, page_width: int | None = None) -> None:
|
|
"""Zero every pixel a text box covers, padded for antialiasing."""
|
|
if not text_boxes:
|
|
return
|
|
rows, cols = ink.shape
|
|
page_w = float(page_width or cols)
|
|
wide = page_w * 0.55
|
|
# Letterhead marks sit in the outer sixths. OCR often boxes them as if they
|
|
# were glyphs; masking those boxes is how one emblem survived and the other
|
|
# vanished. Never erase that margin.
|
|
margin = int(page_w * 0.16)
|
|
side_keep = int(page_w * 0.20)
|
|
for bx, by, bw, bh in text_boxes:
|
|
if bw <= 0 or bh <= 0:
|
|
continue
|
|
if bw >= page_w * 0.35 and bh < band_h * 0.28:
|
|
continue
|
|
pad_x = bw * TEXT_PAD_RATIO
|
|
pad_y = bh * TEXT_PAD_RATIO
|
|
x0 = max(0, int(bx - pad_x))
|
|
y0 = max(0, int(by - pad_y))
|
|
x1 = min(cols, int(bx + bw + pad_x))
|
|
y1 = min(rows, int(by + bh + pad_y))
|
|
if bw >= wide:
|
|
x0 = max(x0, side_keep)
|
|
x1 = min(x1, cols - side_keep)
|
|
x0 = max(x0, margin)
|
|
x1 = min(x1, cols - margin)
|
|
if y0 >= band_h:
|
|
continue
|
|
if x1 > x0 and y1 > y0:
|
|
ink[y0:y1, x0:x1] = False
|
|
|
|
|
|
def _ink_regions(ink, np) -> list[tuple[int, int, int, int]]:
|
|
"""Bounding boxes of the contiguous column runs of ink, left to right.
|
|
|
|
Column projection rather than connected components: an emblem is one
|
|
horizontal cluster separated from the rest of the masthead by whitespace,
|
|
and projection needs no scipy and no arbitrary structuring element.
|
|
"""
|
|
col_ink = ink.sum(axis=0)
|
|
peak = col_ink.max()
|
|
if peak <= 0:
|
|
return []
|
|
gap = max(1.0, peak * COLUMN_GAP_RATIO)
|
|
|
|
spans: list[tuple[int, int]] = []
|
|
start = None
|
|
for x, value in enumerate(col_ink):
|
|
if value >= gap:
|
|
if start is None:
|
|
start = x
|
|
elif start is not None:
|
|
spans.append((start, x))
|
|
start = None
|
|
if start is not None:
|
|
spans.append((start, len(col_ink)))
|
|
|
|
boxes: list[tuple[int, int, int, int]] = []
|
|
for x0, x1 in spans:
|
|
rows = np.where(ink[:, x0:x1].any(axis=1))[0]
|
|
if rows.size == 0:
|
|
continue
|
|
boxes.append((x0, int(rows[0]), x1, int(rows[-1]) + 1))
|
|
return boxes
|
|
|
|
|
|
def _largest_ink_region(ink, np):
|
|
"""Bounding box of the densest contiguous column run. Kept for callers."""
|
|
boxes = _ink_regions(ink, np)
|
|
if not boxes:
|
|
return None
|
|
return max(boxes, key=lambda b: (b[2] - b[0]) * (b[3] - b[1]))
|
|
|
|
|
|
def _crop_png(page_png: bytes, box: tuple[int, int, int, int]) -> bytes | None:
|
|
from PIL import Image
|
|
|
|
x0, y0, x1, y1 = box
|
|
with Image.open(io.BytesIO(page_png)) as im:
|
|
crop = im.convert("RGB").crop((x0, y0, x1, y1))
|
|
if crop.width < 2 or crop.height < 2:
|
|
return None
|
|
longest = max(crop.size)
|
|
if longest > MAX_OUTPUT_SIDE:
|
|
scale = MAX_OUTPUT_SIDE / longest
|
|
crop = crop.resize(
|
|
(max(1, int(crop.width * scale)), max(1, int(crop.height * scale))),
|
|
Image.LANCZOS,
|
|
)
|
|
buf = io.BytesIO()
|
|
crop.save(buf, format="PNG", optimize=True)
|
|
return buf.getvalue()
|