Files
pdf/gateway/app/services/convert/layout/logo_crop.py
T

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()