Files
Copies/copienator/fast_page_split.py
2026-11-03 22:08:36 +01:00

277 lines
9.3 KiB
Python

"""Standard coarse-to-fine splitting: cheap positive content gate, precise fold.
The whole-page gate only establishes obvious writing. All uncertain/sparse pages
use the reference blank detector. Full-resolution fold processing is restricted
to a central strip; edge-reaching overflow and ambiguous ownership fall back to
the complete reference analysis. Full-spread analysis remains an internal fallback.
"""
from __future__ import annotations
import cv2
import numpy as np
import pymupdf
from copienator.auto_page_split import analyse_ink, _analyse_page_full, plan_masks
def quick_page_content(rgb, dpi, target_dpi=75, *, native=True, _fallback=None):
"""Positive-only coarse gate. Ambiguous/sparse pages use full detection."""
h, w = rgb.shape[:2]
small = cv2.resize(
rgb,
(round(w * target_dpi / dpi), round(h * target_dpi / dpi)),
interpolation=cv2.INTER_AREA,
)
px = target_dpi / 25.4
lo = small.min(axis=2)
ch = small.max(axis=2) - lo
gray = cv2.cvtColor(small, cv2.COLOR_RGB2GRAY)
strong = (ch > 100) | (gray < 95)
n, lab, stats, _ = cv2.connectedComponentsWithStats(strong.astype("uint8"), 8)
ok = np.zeros(n, bool)
x, y, bw, bh, area = stats[1:].T
# Only compact interior pen components can establish an obvious written page.
ok[1:] = (
(area >= 0.25 * px * px)
& (bw >= 0.35 * px)
& (bh >= 0.5 * px)
& (bw < 20 * px)
& (bh < 15 * px)
& (x > 0.09 * small.shape[1])
& (x + bw < 0.91 * small.shape[1])
)
selected = ok[lab]
grouped = cv2.dilate(
selected.astype("uint8"),
np.ones((max(1, round(px)), max(1, round(3 * px))), np.uint8),
)
ng, groups, gs, _ = cv2.connectedComponentsWithStats(grouped, 8)
comp_group = np.zeros(n, np.int32)
ys, xs = np.nonzero(selected)
comp_group[lab[ys, xs]] = groups[ys, xs]
counts = np.bincount(comp_group[ok], minlength=ng)
amounts = np.bincount(groups[selected], minlength=ng)
good = (
(counts >= 4)
& (amounts >= 3 * px * px)
& (gs[:, 2] >= 8 * px)
& (gs[:, 3] >= 2 * px)
)
good[0] = False
if good.any():
# Retain all strong ink for ownership support; the gate alone is interior-only.
return (
strong
if native
else cv2.resize(
strong.astype("uint8"), (w, h), interpolation=cv2.INTER_NEAREST
)
> 0
), True
return (_fallback() if _fallback is not None else analyse_ink(rgb, dpi)[0]), False
def _a4_plan(mask, gate, h, w, dpi, gate_dpi):
count = int(mask.sum())
return dict(
format="a4",
size_px=[w, h],
outputs=[
dict(
side="whole",
rect_px=[0, 0, w, h],
state="content" if count else "blank",
keep=bool(count),
ink_pixels=count,
content_dpi=gate_dpi if gate else dpi,
)
],
crossings=[],
review_reasons=[],
extensions_mm=[0, 0],
analysis=dict(
mode="fast",
early_written=[gate],
fold_fraction=0,
gate_dpi=gate_dpi,
fallback=None,
),
)
def analyse_page_fast(rgb, dpi, kind, *, fold_fraction=0.1, gate_dpi=75):
"""Fast analysis for an existing raster image."""
if kind not in {"a3", "a4"}:
return _analyse_page_full(rgb, dpi, kind)
gate_dpi = min(gate_dpi, dpi)
h, w = rgb.shape[:2]
if kind == "a4":
mask, gate = quick_page_content(rgb, dpi, gate_dpi, native=True)
return _a4_plan(mask, gate, h, w, dpi, gate_dpi)
middle = w // 2
halves = []
early = []
for a, b in ((0, middle), (middle, w)):
mask, gate = quick_page_content(rgb[:, a:b], dpi, gate_dpi, native=True)
halves.append(mask)
early.append(gate)
return _assemble_fast(
halves,
early,
h,
w,
dpi,
fold_fraction,
gate_dpi,
lambda x0, x1: rgb[:, x0:x1],
lambda: _analyse_page_full(rgb, dpi, kind),
)
def _assemble_fast(
halves, early, h, w, dpi, fold_fraction, gate_dpi, render_roi, reference
):
middle = w // 2
half = max(1, round(w * fold_fraction / 2))
x0 = max(0, middle - half)
x1 = min(w, middle + half)
roi = render_roi(x0, x1)
content = np.zeros(roi.shape[:2], bool)
for i, (a, b) in enumerate(((0, middle), (middle, w))):
lo = max(a, x0)
hi = min(b, x1)
mask = halves[i]
ys = np.arange(h) * mask.shape[0] // h
xs = (np.arange(lo, hi) - a) * mask.shape[1] // (b - a)
content[:, lo - x0 : hi - x0] = mask[ys[:, None], xs[None, :]]
ink, _ = analyse_ink(roi, dpi, spread=True)
remaining = {}
plan = plan_masks(
ink, dpi, "a3", content_ink=content, rgb=roi, _remaining_content=remaining
)
# If a proposed overflow reaches the strip's edge or ownership remains
# ambiguous, use the complete reference analysis rather than truncate it.
margin = round(2 * dpi / 25.4)
if (
plan["review_reasons"]
or plan["outputs"][0]["rect_px"][2] >= x1 - x0 - margin
or plan["outputs"][1]["rect_px"][0] <= margin
):
plan = reference()
plan["analysis"] = dict(mode="fast", fallback="full-spread")
return plan
for i, (a, b) in enumerate(((0, middle), (middle, w))):
mask = halves[i]
side = "left" if i == 0 else "right"
if not early[i]:
lo = max(a, x0)
hi = min(b, x1)
mask[:, lo - a : hi - a] = remaining[side]
count = int(mask.sum())
else:
# Exact count of the implied nearest-neighbour full-resolution mask,
# without allocating it. Coarse masks establish obvious content only.
wy = np.diff(
np.ceil(np.arange(mask.shape[0] + 1) * h / mask.shape[0]).astype(
"int64"
)
)
wx = np.diff(
np.ceil(np.arange(mask.shape[1] + 1) * (b - a) / mask.shape[1]).astype(
"int64"
)
)
count = int(np.sum(mask * wy[:, None] * wx[None, :]))
o = plan["outputs"][i]
o["ink_pixels"] = count
o["keep"] = count > 0
o["state"] = "content" if count else "blank"
o["rect_px"] = (
[0, 0, o["rect_px"][2] + x0, h]
if i == 0
else [o["rect_px"][0] + x0, 0, w, h]
)
for c in plan["crossings"]:
c["box"][0] += x0
c["box"][2] += x0
for box in c.get("anchor_boxes", []):
if box is not None:
box[0] += x0
box[2] += x0
plan["size_px"] = [w, h]
plan["analysis"] = dict(
mode="fast",
gate_dpi=gate_dpi,
fold_fraction=fold_fraction,
early_written=early,
fallback=None,
)
return plan
def analyse_pdf_page_fast(page, dpi, kind, *, fold_fraction=0.1, gate_dpi=75):
"""Render a small overview and the precise fold directly from the PDF.
Full-resolution halves are rendered only when the positive overview gate
cannot establish writing. Raster clips are aligned to the same integer
pixel grid as a complete reference render, including rotated CropBoxes.
"""
gate_dpi = min(gate_dpi, dpi)
bounds = (page.rect * pymupdf.Matrix(dpi / 72, dpi / 72)).irect
h, w = bounds.height, bounds.width
coarse_pix = page.get_pixmap(
dpi=round(gate_dpi), colorspace=pymupdf.csRGB, alpha=False
)
coarse = np.frombuffer(coarse_pix.samples, np.uint8).reshape(
coarse_pix.height, coarse_pix.width, 3
)
def render(x0, x1):
rect = pymupdf.Rect(x0 * 72 / dpi, 0, x1 * 72 / dpi, page.rect.height)
pix = page.get_pixmap(
dpi=round(dpi), clip=rect, colorspace=pymupdf.csRGB, alpha=False
)
data = np.frombuffer(pix.samples, np.uint8).reshape(pix.height, pix.width, 3)
out = np.full((h, x1 - x0, 3), 255, np.uint8)
left = max(x0, pix.x)
top = max(0, pix.y)
right = min(x1, pix.x + pix.width)
bottom = min(h, pix.y + pix.height)
out[top:bottom, left - x0 : right - x0] = data[
top - pix.y : bottom - pix.y, left - pix.x : right - pix.x
]
return out
def reference():
return _analyse_page_full(render(0, w), dpi, kind)
if kind not in {"a3", "a4"}:
return reference(), coarse
if kind == "a4":
mask, gate = quick_page_content(
coarse,
gate_dpi,
gate_dpi,
native=True,
_fallback=lambda: analyse_ink(render(0, w), dpi)[0],
)
return _a4_plan(mask, gate, h, w, dpi, gate_dpi), coarse
middle = w // 2
cm = coarse.shape[1] // 2
halves = []
early = []
for a, b, ca, cb in ((0, middle, 0, cm), (middle, w, cm, coarse.shape[1])):
mask, gate = quick_page_content(
coarse[:, ca:cb],
gate_dpi,
gate_dpi,
native=True,
_fallback=lambda a=a, b=b: analyse_ink(render(a, b), dpi)[0],
)
halves.append(mask)
early.append(gate)
plan = _assemble_fast(
halves, early, h, w, dpi, fold_fraction, gate_dpi, render, reference
)
plan["analysis"]["rendering"] = "direct-pdf-roi"
return plan, coarse