277 lines
9.3 KiB
Python
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
|