Automatic page splitting
This commit is contained in:
1 parent
d4404e4c27
commit
78f3e4b2f0
13 files changed
+1959
-40
No files matched your search
@@ -0,0 +1,377 @@
|
||||
"""Experimental, non-destructive spread splitting with an HTML review gallery.
|
||||
|
||||
python -m copienator.auto_page_split INPUT OUTPUT [--workers 4]
|
||||
No booklet reordering, secondary cuts, or GUI changes. Unknown formats and
|
||||
uncertain content are retained. OUTPUT must not already exist.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from concurrent.futures import ProcessPoolExecutor
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import multiprocessing
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pymupdf
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from copienator.ink_detection import detect_bounds, detect_page_content
|
||||
|
||||
|
||||
def classify_format(width: float, height: float) -> str:
|
||||
"""Dimensions are displayed PDF points; tolerate 5% scan size error."""
|
||||
w, h = width * 25.4 / 72, height * 25.4 / 72
|
||||
if abs(min(w, h) / 210 - 1) < .05 and abs(max(w, h) / 297 - 1) < .05:
|
||||
return 'a4'
|
||||
if abs(w / 420 - 1) < .05 and abs(h / 297 - 1) < .05:
|
||||
return 'a3'
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def analyse_ink(rgb: np.ndarray, dpi: float, *, spread: bool = False) -> tuple[np.ndarray, dict]:
|
||||
"""Use normal rognage on logical pages; colour cleanup only for overflow.
|
||||
|
||||
Blankness uses the shared crop detector's content-analysis entry point.
|
||||
No independent weak-pixel veto or secondary artifact detector is applied.
|
||||
"""
|
||||
if not spread:
|
||||
return detect_page_content(rgb,dpi)
|
||||
masks = {}
|
||||
result = detect_bounds(rgb, dpi, content_masks=masks,
|
||||
colour_cleanup=True, edge_mark_cleanup=False)
|
||||
ink = masks['foreground']
|
||||
# For ownership, follow connected faint pen strokes beyond rognage's local
|
||||
# 2 mm recovery radius. Do not restore page-wide connected ruling networks.
|
||||
candidate = ink | masks['weak_candidates']
|
||||
n,labels,stats,_ = cv2.connectedComponentsWithStats(candidate.astype(np.uint8),8)
|
||||
seeded = np.zeros(n,bool)
|
||||
seeded[np.unique(labels[ink])] = True
|
||||
seeded[0] = False
|
||||
giant = (stats[:,2] > .65*ink.shape[1]) & (stats[:,3] > .4*ink.shape[0])
|
||||
seeded[giant] = False
|
||||
ink = ink | seeded[labels]
|
||||
return ink, result
|
||||
|
||||
|
||||
def _analyse_page_full(rgb: np.ndarray, dpi: float, kind: str) -> dict:
|
||||
"""Keep spread geometry analysis separate from logical-page blankness."""
|
||||
h, w = rgb.shape[:2]
|
||||
boundaries = [0, w//2, w] if kind == 'a3' else [0, w]
|
||||
content = np.zeros((h,w), bool)
|
||||
evidence = []
|
||||
for x0,x1 in zip(boundaries,boundaries[1:]):
|
||||
foreground, result = analyse_ink(rgb[:,x0:x1],dpi)
|
||||
content[:,x0:x1] = foreground
|
||||
evidence.append(result)
|
||||
ink = analyse_ink(rgb,dpi,spread=True)[0] if kind == 'a3' else content
|
||||
plan = plan_masks(ink,dpi,kind,content_ink=content,rgb=rgb)
|
||||
for output,result in zip(plan['outputs'],evidence):
|
||||
output['crop_analysis'] = result
|
||||
return plan
|
||||
|
||||
|
||||
def analyse_page(rgb: np.ndarray, dpi: float, kind: str) -> dict:
|
||||
"""Analyse a raster with the standard coarse-to-fine splitter."""
|
||||
from copienator.fast_page_split import analyse_page_fast
|
||||
return analyse_page_fast(rgb, dpi, kind)
|
||||
|
||||
|
||||
def _fold_regions(ink: np.ndarray, credible: np.ndarray, dpi: float,
|
||||
rgb: np.ndarray | None = None) -> tuple[list[dict], np.ndarray]:
|
||||
"""Grow local word groups towards page anchors, stopping at supported text.
|
||||
|
||||
Broader gaps are allowed for ownership evidence without merging both pages
|
||||
into one giant line or changing output bounds to include the anchor word.
|
||||
"""
|
||||
import heapq
|
||||
px = dpi/25.4
|
||||
h,w = ink.shape
|
||||
middle = w//2
|
||||
ignored = []
|
||||
if rgb is not None:
|
||||
# Remove weak thin ruling *before* word grouping: otherwise a nearby
|
||||
# dark word can lend its colour/anchor evidence to a pale grid fragment.
|
||||
count,raw_labels,raw_stats,_ = cv2.connectedComponentsWithStats(ink.astype(np.uint8),8)
|
||||
ink = ink.copy()
|
||||
for i,(x,y,bw,bh,area) in enumerate(raw_stats[1:],1):
|
||||
if not (x < middle+2*px and x+bw > middle-2*px and bh<px and bw>4*bh):
|
||||
continue
|
||||
region = raw_labels[y:y+bh,x:x+bw]==i
|
||||
values = rgb[y:y+bh,x:x+bw][region].astype(np.int16)
|
||||
if np.median(np.ptp(values,axis=1))<=80 and np.median(values.min(axis=1))>=110:
|
||||
ink[y:y+bh,x:x+bw][region] = False
|
||||
ignored.append(dict(owner='artifact',box=[int(x),int(y),int(x+bw),int(y+bh)],
|
||||
component_id=0,anchor_boxes=[None,None],anchor_distance_mm=[None,None],reason='weak-thin-ruling'))
|
||||
kernel = np.ones((max(1,round(.5*px)),max(1,round(3*px))),np.uint8)
|
||||
joined = cv2.dilate(ink.astype(np.uint8),kernel)
|
||||
n,labels,stats,_ = cv2.connectedComponentsWithStats(joined,8)
|
||||
boxes = np.zeros((n,4),np.int32)
|
||||
areas = np.bincount(labels[ink],minlength=n)
|
||||
pen = np.zeros(n,bool)
|
||||
for i in range(1,n):
|
||||
x,y,bw,bh,_ = stats[i]
|
||||
yy,xx = np.nonzero((labels[y:y+bh,x:x+bw]==i) & ink[y:y+bh,x:x+bw])
|
||||
boxes[i] = [x+xx.min(),y+yy.min(),x+xx.max()+1,y+yy.max()+1]
|
||||
if rgb is not None and len(xx)>=.4*px*px:
|
||||
values=rgb[y+yy,x+xx].astype(np.int16)
|
||||
# Between the crop detector's weak (60) and strong (100) colour
|
||||
# thresholds: pale blue ruling must not become an ownership anchor.
|
||||
pen[i] = np.median(np.ptp(values,axis=1))>80 or np.median(values.min(axis=1))<110
|
||||
support = credible & ink
|
||||
# The blank classifier deliberately ignores short isolated pen marks.
|
||||
# A two-dimensional letter/terminal stroke can still link to its page here.
|
||||
support |= pen[labels] & ink
|
||||
left_limit,right_limit = max(0,round(middle-12*px)),min(w,round(middle+12*px))
|
||||
left_counts = np.bincount(labels[:,:left_limit][support[:,:left_limit]],minlength=n)
|
||||
right_counts = np.bincount(labels[:,right_limit:][support[:,right_limit:]],minlength=n)
|
||||
anchors = np.c_[left_counts>=max(2,.2*px*px),right_counts>=max(2,.2*px*px)]
|
||||
backed = np.bincount(labels[support],minlength=n)>=max(2,.12*px*px)
|
||||
backed[0] = False
|
||||
near = max(1,round(2*px))
|
||||
roots = np.unique(labels[:,max(0,middle-near):middle+near])
|
||||
regions = ignored
|
||||
for root in roots:
|
||||
if root == 0:continue
|
||||
x0,y0,x1,y1 = boxes[root]
|
||||
if x1 < middle-near or x0 > middle+near:continue
|
||||
thin_unanchored = (not anchors[root].any() and
|
||||
((not pen[root] and y1-y0<px and x1-x0>4*(y1-y0)) or areas[root]<.4*px*px))
|
||||
if thin_unanchored:
|
||||
regions.append(dict(owner='artifact',box=boxes[root].tolist(),component_id=int(root),anchor_boxes=[None,None],anchor_distance_mm=[None,None]))
|
||||
continue
|
||||
distances = np.full(n,np.inf)
|
||||
distances[root]=0
|
||||
queue=[(0.,int(root))]
|
||||
reached=[np.inf,np.inf]
|
||||
evidence=[None,None]
|
||||
# Only supported writing may carry a connection onwards. Otherwise
|
||||
# rows of grid fragments could form a bridge all the way into an answer.
|
||||
allowed = backed.copy();allowed[root]=True
|
||||
while queue:
|
||||
cost,node=heapq.heappop(queue)
|
||||
if cost != distances[node] or cost>45*px:continue
|
||||
if anchors[node].any():
|
||||
for side in (0,1):
|
||||
if anchors[node,side] and cost<reached[side]:
|
||||
reached[side]=cost;evidence[side]=boxes[node].tolist()
|
||||
continue
|
||||
a,b,c,d = boxes[node]
|
||||
dx=np.maximum(0,np.maximum(boxes[:,0]-c,a-boxes[:,2]))
|
||||
dy=np.maximum(0,np.maximum(boxes[:,1]-d,b-boxes[:,3]))
|
||||
# Wide mathematical spaces are plausible between strong pen
|
||||
# regions; unsupported shadows get only the shorter word gap.
|
||||
gap = 32*px if pen[root] else 8*px
|
||||
neighbours=np.flatnonzero(allowed & (dx<=gap) & (dy<=2*px))
|
||||
for other in neighbours:
|
||||
step=float(np.hypot(dx[other],3*dy[other]))+1
|
||||
new=cost+step
|
||||
if new<distances[other]:
|
||||
distances[other]=new;heapq.heappush(queue,(new,int(other)))
|
||||
if not np.isfinite(reached).any():owner='artifact'
|
||||
elif abs(reached[0]-reached[1])<=px:owner='uncertain'
|
||||
else:owner='left' if reached[0]<reached[1] else 'right'
|
||||
regions.append(dict(owner=owner,box=[int(x0),int(y0),int(x1),int(y1)],
|
||||
component_id=int(root),anchor_boxes=evidence,anchor_distance_mm=[round(c/px,2) if np.isfinite(c) else None for c in reached]))
|
||||
return regions,labels
|
||||
|
||||
|
||||
def plan_masks(ink: np.ndarray, dpi: float, kind: str, *,
|
||||
content_ink: np.ndarray | None = None,
|
||||
rgb: np.ndarray | None = None,
|
||||
_remaining_content: dict | None = None) -> dict:
|
||||
"""Produce rectangles in image coordinates and explicit review reasons."""
|
||||
h, w = ink.shape
|
||||
px = dpi / 25.4
|
||||
middle = w // 2
|
||||
reasons = []
|
||||
crossings = []
|
||||
left_end, right_start = middle, middle
|
||||
owned = (ink if content_ink is None else content_ink).copy()
|
||||
left_owned, right_owned = owned[:, :middle].copy(), owned[:, middle:].copy()
|
||||
if kind == 'a3':
|
||||
pad = round(2*px)
|
||||
crossings,group_labels = _fold_regions(ink,owned,dpi,rgb)
|
||||
for crossing in crossings:
|
||||
owner = crossing['owner']
|
||||
x0,y0,x1,y1 = crossing['box']
|
||||
if owner == 'artifact':
|
||||
continue
|
||||
if owner == 'left':
|
||||
left_end = max(left_end,min(w,x1+pad))
|
||||
if x1>middle:
|
||||
right_owned[y0:y1,max(x0,middle)-middle:x1-middle] &= ~(ink[y0:y1,max(x0,middle):x1] & (group_labels[y0:y1,max(x0,middle):x1]==crossing['component_id']))
|
||||
elif owner == 'right':
|
||||
right_start = min(right_start,max(0,x0-pad))
|
||||
if x0<middle:
|
||||
left_owned[y0:y1,x0:min(x1,middle)] &= ~(ink[y0:y1,x0:min(x1,middle)] & (group_labels[y0:y1,x0:min(x1,middle)]==crossing['component_id']))
|
||||
else:
|
||||
left_end = max(left_end,min(w,x1+pad))
|
||||
right_start = min(right_start,max(0,x0-pad))
|
||||
reasons.append('ambiguous-centre-writing')
|
||||
regions = [('left', [0,0,left_end,h], left_owned),
|
||||
('right', [right_start,0,w,h], right_owned)]
|
||||
else:
|
||||
regions = [('whole', [0,0,w,h], owned)]
|
||||
if kind == 'unknown':
|
||||
reasons.append('unknown-format-or-orientation')
|
||||
|
||||
outputs = []
|
||||
if _remaining_content is not None:
|
||||
_remaining_content.update({side: content for side,_,content in regions})
|
||||
for side, rect, content in regions:
|
||||
state = 'content' if content.any() else 'blank'
|
||||
if kind == 'unknown':
|
||||
state = 'uncertain'
|
||||
outputs.append(dict(side=side, rect_px=rect, state=state,
|
||||
keep=state != 'blank', ink_pixels=int(content.sum())))
|
||||
return dict(format=kind, size_px=[w,h], outputs=outputs, crossings=crossings,
|
||||
review_reasons=sorted(set(reasons)),
|
||||
extensions_mm=[round((left_end-middle)/px,2),
|
||||
round((middle-right_start)/px,2)] if kind == 'a3' else [0,0])
|
||||
|
||||
|
||||
def save_preview(rgb: np.ndarray, plan: dict, path: Path) -> None:
|
||||
im = Image.fromarray(rgb)
|
||||
im.thumbnail((1200, 1000))
|
||||
sx, sy = im.width/plan['size_px'][0], im.height/plan['size_px'][1]
|
||||
overlay = Image.new('RGBA', im.size)
|
||||
draw = ImageDraw.Draw(overlay)
|
||||
for output in plan['outputs']:
|
||||
x0,y0,x1,y1 = output['rect_px']
|
||||
box = (x0*sx,y0*sy,x1*sx-1,y1*sy-1)
|
||||
colour = '#187941' if output['side'] == 'left' else '#a832a6'
|
||||
draw.rectangle(box, outline=colour, width=3,
|
||||
fill=(255,30,30,65) if not output['keep'] else None)
|
||||
for crossing in plan['crossings']:
|
||||
x0,y0,x1,y1 = crossing['box']
|
||||
draw.rectangle((x0*sx,y0*sy,x1*sx,y1*sy), outline='#999999' if crossing['owner']=='artifact' else '#e88b00', width=2)
|
||||
Image.alpha_composite(im.convert('RGBA'), overlay).convert('RGB').save(path, quality=90)
|
||||
|
||||
|
||||
def process_pdf(job: tuple[str, str, int]) -> list[dict]:
|
||||
source_s, destination_s, dpi = job
|
||||
source, destination = Path(source_s), Path(destination_s)
|
||||
cv2.setNumThreads(1)
|
||||
digest = hashlib.sha256(source.read_bytes()).hexdigest()
|
||||
records = []
|
||||
with pymupdf.open(source) as doc, pymupdf.open() as result:
|
||||
for i, page in enumerate(doc):
|
||||
rotation = page.rotation
|
||||
kind = classify_format(page.rect.width, page.rect.height)
|
||||
from copienator.fast_page_split import analyse_pdf_page_fast
|
||||
plan,rgb = analyse_pdf_page_fast(page,dpi,kind)
|
||||
pixel_width,pixel_height = plan['size_px']
|
||||
plan.update(file=source.name, source_page=i+1, source_sha256=digest,
|
||||
source_rotation=rotation, source_cropbox=list(page.cropbox))
|
||||
preview = f'{source.stem}-{i+1:03}.jpg'
|
||||
plan['preview'] = f'review/{preview}'
|
||||
save_preview(rgb, plan, destination/'review'/preview)
|
||||
# Normalize through a full-page PDF form: remove_rotation() loses
|
||||
# non-default CropBoxes on quarter-turn pages in some MuPDF builds.
|
||||
w,h = page.rect.width,page.rect.height
|
||||
normalized = pymupdf.open()
|
||||
flat = normalized.new_page(width=w,height=h)
|
||||
page.set_rotation(0)
|
||||
if page.get_contents():
|
||||
flat.show_pdf_page(flat.rect,doc,i,rotate=-rotation)
|
||||
for output in plan['outputs']:
|
||||
x0,y0,x1,y1 = output['rect_px']
|
||||
clip = pymupdf.Rect(x0/pixel_width*w,y0/pixel_height*h,
|
||||
x1/pixel_width*w,y1/pixel_height*h)
|
||||
output['crop_points'] = list(clip)
|
||||
if output['keep']:
|
||||
target = result.new_page(width=clip.width,height=clip.height)
|
||||
# Truly empty PDF pages have no content stream to show.
|
||||
if flat.get_contents():
|
||||
target.show_pdf_page(target.rect,normalized,0,clip=clip)
|
||||
output['output_page'] = len(result)
|
||||
records.append(plan)
|
||||
normalized.close()
|
||||
# An entirely blank copy has no output PDF; its report is still kept.
|
||||
if len(result):
|
||||
result.save(destination/'pdfs'/source.name, garbage=3, deflate=True)
|
||||
if hashlib.sha256(source.read_bytes()).hexdigest() != digest:
|
||||
raise RuntimeError(f'Source changed during analysis: {source}')
|
||||
output_path = destination/'pdfs'/source.name
|
||||
expected = sum(o['keep'] for r in records for o in r['outputs'])
|
||||
if expected:
|
||||
with pymupdf.open(output_path) as check:
|
||||
if len(check) != expected or any(p.rect.is_empty for p in check):
|
||||
raise RuntimeError(f'Invalid output: {output_path}')
|
||||
return records
|
||||
|
||||
|
||||
def run(source: Path, destination: Path, *, dpi: int = 200, workers: int = 1) -> dict:
|
||||
if dpi < 100 or workers < 1:
|
||||
raise ValueError('Use dpi >= 100 and workers >= 1')
|
||||
sources = sorted(source.glob('*.pdf')) if source.is_dir() else [source]
|
||||
if not sources or any(not p.is_file() or p.suffix.lower() != '.pdf' for p in sources):
|
||||
raise ValueError('No input PDFs found')
|
||||
# Refuse reuse: this makes accidental source replacement impossible.
|
||||
destination.mkdir(parents=True, exist_ok=False)
|
||||
(destination/'review').mkdir()
|
||||
(destination/'pdfs').mkdir()
|
||||
jobs = [(str(p),str(destination),dpi) for p in sources]
|
||||
records = []
|
||||
if workers == 1:
|
||||
batches = map(process_pdf, jobs)
|
||||
for batch in batches:
|
||||
records.extend(batch)
|
||||
print(f'{batch[0]["file"]}: {len(batch)} scanned pages', flush=True)
|
||||
else:
|
||||
with ProcessPoolExecutor(max_workers=workers,
|
||||
mp_context=multiprocessing.get_context('spawn')) as pool:
|
||||
for batch in pool.map(process_pdf,jobs):
|
||||
records.extend(batch)
|
||||
print(f'{batch[0]["file"]}: {len(batch)} scanned pages', flush=True)
|
||||
return write_report(records,destination,len(sources))
|
||||
|
||||
|
||||
def write_report(records: list[dict], destination: Path, file_count: int) -> dict:
|
||||
"""Rebuild the gallery from saved decisions without reanalysing scans."""
|
||||
summary = dict(files=file_count, source_pages=len(records),
|
||||
a3=sum(r['format']=='a3' for r in records), a4=sum(r['format']=='a4' for r in records),
|
||||
output_pages=sum(o['keep'] for r in records for o in r['outputs']),
|
||||
blank_removed=sum(not o['keep'] for r in records for o in r['outputs']),
|
||||
review_pages=sum(bool(r['review_reasons']) for r in records),
|
||||
extended_spreads=sum(any(r['extensions_mm']) for r in records))
|
||||
(destination/'report.json').write_text(json.dumps(dict(summary=summary,pages=records),indent=2)+'\n')
|
||||
cards = []
|
||||
for row in records:
|
||||
name = html.escape(row['file'])
|
||||
states = ', '.join(f'{o["side"]}: {o["state"]}' for o in row['outputs'])
|
||||
reasons = ', '.join(row['review_reasons']) or 'No review flag'
|
||||
links = ' · '.join(f'<a href="pdfs/{quote(row["file"])}#page={o["output_page"]}">{o["side"]} PDF</a>'
|
||||
for o in row['outputs'] if o['keep'])
|
||||
cards.append(f'<article class="{"review" if row["review_reasons"] else "ok"}">'
|
||||
f'<h2>{name} / {row["source_page"]}</h2><p>{row["format"]} | {states}</p>'
|
||||
f'<p>Extensions L/R: {row["extensions_mm"]} mm | {reasons}</p><p>{links}</p>'
|
||||
f'<a href="{html.escape(row["preview"])}"><img loading="lazy" src="{html.escape(row["preview"])}"></a></article>')
|
||||
(destination/'index.html').write_text('<!doctype html><meta charset="utf-8">'
|
||||
'<title>Automatic page splitting - review</title><style>'
|
||||
'body{font:16px system-ui;margin:24px;background:#eee}main{display:grid;grid-template-columns:repeat(auto-fit,minmax(550px,1fr));gap:16px}'
|
||||
'article{background:white;padding:16px}article.review{border-top:5px solid #e88b00}img{width:100%}h2{font-size:18px}p{font-size:13px}</style>'
|
||||
'<h1>Automatic page splitting - experimental</h1><p>Source order; no booklet reordering. '
|
||||
'Green: left output. Purple: right/whole output. Orange: centre writing. Red shading: removed blank.</p>'
|
||||
f'<pre>{html.escape(json.dumps(summary,indent=2))}</pre><main>'+''.join(cards)+'</main>')
|
||||
return summary
|
||||
|
||||
|
||||
def main(argv=None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('input',type=Path)
|
||||
parser.add_argument('output',type=Path)
|
||||
parser.add_argument('--dpi',type=int,default=200)
|
||||
parser.add_argument('--workers',type=int,default=1)
|
||||
args = parser.parse_args(argv)
|
||||
print(json.dumps(run(args.input,args.output,dpi=args.dpi,workers=args.workers),indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Publish automatic splits into an evaluation, preserving sources and review data."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from copienator import CliError, ExitCode
|
||||
from copienator.auto_page_split import run as analyse_batch
|
||||
from copienator.copy_errors import clear_copy_error, copy_errors, mark_copy_error
|
||||
from copienator.filesystem import publish_file_sets
|
||||
from copienator.orientation import report_path
|
||||
|
||||
|
||||
def decision_path(workspace, stem):
|
||||
return workspace.metadata_dir / "page_splitting" / f"{stem}.json"
|
||||
|
||||
|
||||
def load_decisions(workspace, source):
|
||||
"""Only reuse decisions for the exact preserved input and published output."""
|
||||
try:
|
||||
data = json.loads(decision_path(workspace, source.stem).read_text())
|
||||
if data["source_sha256"] != hashlib.sha256(source.read_bytes()).hexdigest():
|
||||
return None
|
||||
output = workspace.copies_dir / source.name
|
||||
digest = (
|
||||
hashlib.sha256(output.read_bytes()).hexdigest() if output.exists() else None
|
||||
)
|
||||
if digest != data["output_sha256"]:
|
||||
return None
|
||||
pages = data["pages"]
|
||||
if not isinstance(pages, list) or not all(
|
||||
isinstance(row, dict)
|
||||
and type(row.get("source_page")) is int
|
||||
and isinstance(row.get("outputs"), list)
|
||||
and all(
|
||||
isinstance(o, dict)
|
||||
and o.get("side") in {"left", "right", "whole"}
|
||||
and type(o.get("keep")) is bool
|
||||
and isinstance(o.get("crop_points"), list)
|
||||
and len(o["crop_points"]) == 4
|
||||
for o in row["outputs"]
|
||||
)
|
||||
for row in pages
|
||||
):
|
||||
return None
|
||||
return pages
|
||||
except (OSError, ValueError, KeyError, TypeError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def run(workspace, inputs: list[Path], *, workers=4):
|
||||
if workers < 1:
|
||||
raise CliError(
|
||||
"Le nombre de traitements parallèles doit être positif.",
|
||||
ExitCode.INVALID_ARGUMENTS,
|
||||
)
|
||||
if not inputs:
|
||||
print("Aucune copie PDF à traiter.", flush=True)
|
||||
return ExitCode.SUCCESS
|
||||
if len({p.name for p in inputs}) != len(inputs):
|
||||
raise CliError(
|
||||
"Plusieurs copies portent le même nom.", ExitCode.INVALID_ARGUMENTS
|
||||
)
|
||||
copy_errors(workspace) # Validate existing review metadata before publishing.
|
||||
workspace.runs_dir.mkdir(parents=True, exist_ok=True)
|
||||
run_dir = Path(tempfile.mkdtemp(prefix="page-split-", dir=workspace.runs_dir))
|
||||
snapshots = run_dir / "inputs"
|
||||
snapshots.mkdir()
|
||||
for source in inputs:
|
||||
shutil.copy2(source, snapshots / source.name)
|
||||
result = run_dir / "result"
|
||||
print(f"Découpage automatique de {len(inputs)} copie(s).", flush=True)
|
||||
summary = analyse_batch(snapshots, result, workers=workers)
|
||||
records = json.loads((result / "report.json").read_text())["pages"]
|
||||
stages = {
|
||||
name: run_dir / name for name in ("copies", "originals", "origins", "decisions")
|
||||
}
|
||||
for stage in stages.values():
|
||||
stage.mkdir()
|
||||
for source in inputs:
|
||||
rows = [r for r in records if r["file"] == source.name]
|
||||
expected = rows[0]["source_sha256"]
|
||||
if hashlib.sha256(source.read_bytes()).hexdigest() != expected:
|
||||
raise CliError(
|
||||
f"{source.name} a changé pendant l’analyse. Aucun PDF remplacé."
|
||||
)
|
||||
shutil.copy2(snapshots / source.name, stages["originals"] / source.name)
|
||||
for directory, label in (
|
||||
(workspace.copies_dir, "previous_copies"),
|
||||
(workspace.original_copies_dir, "previous_originals"),
|
||||
):
|
||||
prior = directory / source.name
|
||||
if prior.exists():
|
||||
backup = run_dir / label
|
||||
backup.mkdir(exist_ok=True)
|
||||
shutil.copy2(prior, backup / source.name)
|
||||
output = result / "pdfs" / source.name
|
||||
digest = (
|
||||
hashlib.sha256(output.read_bytes()).hexdigest() if output.exists() else None
|
||||
)
|
||||
if digest:
|
||||
shutil.copy2(output, stages["copies"] / source.name)
|
||||
origins = [
|
||||
{"origin": r["source_page"], "crop_points": o["crop_points"]}
|
||||
for r in rows
|
||||
for o in r["outputs"]
|
||||
if o["keep"]
|
||||
]
|
||||
if digest:
|
||||
(stages["origins"] / (source.stem + ".json")).write_text(
|
||||
json.dumps({"version": 1, "sha256": digest, "pages": origins}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(stages["decisions"] / (source.stem + ".json")).write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"source_sha256": expected,
|
||||
"output_sha256": digest,
|
||||
"pages": rows,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
names = [p.name for p in inputs]
|
||||
for source in inputs:
|
||||
if source.read_bytes() != (snapshots / source.name).read_bytes():
|
||||
raise CliError(
|
||||
f"{source.name} a changé pendant l’analyse. Aucun PDF remplacé."
|
||||
)
|
||||
publish_file_sets(
|
||||
{
|
||||
workspace.copies_dir: (stages["copies"], names),
|
||||
workspace.original_copies_dir: (stages["originals"], []),
|
||||
workspace.metadata_dir / "page_origins": (
|
||||
stages["origins"],
|
||||
[p.stem + ".json" for p in inputs],
|
||||
),
|
||||
workspace.metadata_dir / "page_splitting": (stages["decisions"], []),
|
||||
}
|
||||
)
|
||||
for source in inputs:
|
||||
if (
|
||||
source.parent.resolve()
|
||||
not in {
|
||||
workspace.copies_dir.resolve(),
|
||||
workspace.original_copies_dir.resolve(),
|
||||
}
|
||||
and source.read_bytes() == (snapshots / source.name).read_bytes()
|
||||
):
|
||||
# Root scans become preserved originals, as in the manual workflow.
|
||||
source.unlink()
|
||||
rows = [r for r in records if r["file"] == source.name]
|
||||
output = workspace.copies_dir / source.name
|
||||
clear_copy_error(workspace, output, source="orientation")
|
||||
report_path(workspace, source.stem).unlink(missing_ok=True)
|
||||
reasons = {reason for r in rows for reason in r["review_reasons"]}
|
||||
if reasons or not output.exists():
|
||||
mark_copy_error(
|
||||
workspace,
|
||||
output,
|
||||
"Découpage automatique à vérifier manuellement."
|
||||
if reasons
|
||||
else "Toutes les pages semblent vides : vérifier l’original.",
|
||||
source="page_splitter",
|
||||
)
|
||||
else:
|
||||
clear_copy_error(workspace, output, source="page_splitter")
|
||||
print(
|
||||
f"Terminé : {summary['output_pages']} pages conservées, {summary['blank_removed']} pages vides retirées. "
|
||||
"Ordre des pages inchangé.",
|
||||
flush=True,
|
||||
)
|
||||
print(
|
||||
f"Rapport et sauvegardes : {run_dir}\n"
|
||||
"Choisissez Manuellement pour vérifier ou modifier les propositions.",
|
||||
flush=True,
|
||||
)
|
||||
return ExitCode.SUCCESS
|
||||
@@ -27,7 +27,7 @@ from copienator import (
|
||||
)
|
||||
from copienator.platform import launch_pdf_arranger
|
||||
from copienator.copy_errors import marked_copy_paths, clear_copy_error
|
||||
from copienator.orientation import save_provenance, report_path
|
||||
from copienator.orientation import save_provenance, report_path, provenance_path
|
||||
|
||||
# --- Constants ---
|
||||
# Conversion factor: 1 cm to points (1 inch = 2.54 cm, 72 points = 1 inch)
|
||||
@@ -49,7 +49,7 @@ def _temporary_sibling(path: Path, purpose: str) -> Path:
|
||||
def commit_processed_pdf(
|
||||
workspace: EvaluationWorkspace,
|
||||
original_path: Path,
|
||||
generated_path: Path,
|
||||
generated_path: Path | None,
|
||||
) -> Path:
|
||||
"""Commit a processed copy and its original backup with rollback."""
|
||||
backup_path = workspace.original_copies_dir / original_path.name
|
||||
@@ -73,8 +73,9 @@ def commit_processed_pdf(
|
||||
backup_replaced = True
|
||||
if output_path.exists():
|
||||
output_path.replace(saved_output)
|
||||
generated_path.replace(output_path)
|
||||
output_replaced = True
|
||||
if generated_path is not None:
|
||||
generated_path.replace(output_path)
|
||||
output_replaced = True
|
||||
if original_path.resolve() not in {
|
||||
backup_path.resolve(),
|
||||
output_path.resolve(),
|
||||
@@ -120,6 +121,10 @@ class PDFPreviewer:
|
||||
try:
|
||||
self.doc = pymupdf.open(self.pdf_path)
|
||||
self.original_page_ids = {page.xref: index + 1 for index, page in enumerate(self.doc)}
|
||||
from copienator.commands.automatic_page_splitter import load_decisions
|
||||
saved = load_decisions(self.workspace, self.pdf_path)
|
||||
self.automatic_plans = {r['source_page']: r for r in saved} if saved else {}
|
||||
self.automatic_review = bool(self.automatic_plans)
|
||||
except (OSError, RuntimeError, ValueError) as e:
|
||||
self.failed = True
|
||||
self._temporary_directory.cleanup()
|
||||
@@ -176,6 +181,12 @@ class PDFPreviewer:
|
||||
f"{fmt('add_secondary_splitter')}: add secondary line, {fmt('delete_secondary_splitter')}: delete newest (arrows move newest; main line red, active secondary orange)\n"
|
||||
f"'{fmt('reverse_pages')}': reverse page order and restart from the new first page\n"
|
||||
)
|
||||
if self.automatic_review:
|
||||
instructions += (
|
||||
"\nPropositions automatiques : la touche suivante valide le choix affiché. "
|
||||
"Croix rouge : page retirée.\n"
|
||||
"Déplacer une ligne reprend le découpage manuel pour cette page."
|
||||
)
|
||||
|
||||
self.info_label = tk.Label(master, text=instructions, justify=tk.LEFT)
|
||||
self.info_label.pack(pady=5, side=tk.TOP)
|
||||
@@ -255,6 +266,25 @@ class PDFPreviewer:
|
||||
self.current_line_x = page.rect.width / 2
|
||||
self.current_rotation = 0
|
||||
self.secondary_splitters = []
|
||||
origin = getattr(self, 'original_page_ids', {}).get(page.xref, self.current_page_index+1)
|
||||
plan = getattr(self, 'automatic_plans', {}).get(origin)
|
||||
self.current_automatic_outputs = plan['outputs'] if plan else None
|
||||
|
||||
def _automatic_outputs(self):
|
||||
"""Express saved asymmetric crops in the current preview orientation."""
|
||||
outputs = getattr(self, 'current_automatic_outputs', None)
|
||||
if outputs is None:
|
||||
return None
|
||||
flipped = (self.current_rotation+self.file_rotation+self.global_rotation) % 360
|
||||
if not flipped:
|
||||
return outputs
|
||||
width, height = self.doc[self.current_page_index].rect.width, self.doc[self.current_page_index].rect.height
|
||||
result = []
|
||||
for output in outputs:
|
||||
x0,y0,x1,y1 = output['crop_points']
|
||||
result.append(dict(output, side={'left':'right','right':'left'}.get(output['side'], 'whole'),
|
||||
crop_points=[width-x1,height-y1,width-x0,height-y0]))
|
||||
return result
|
||||
|
||||
def load_page(self):
|
||||
"""Loads and displays the current page on the canvas, scaled to fit."""
|
||||
@@ -296,9 +326,20 @@ class PDFPreviewer:
|
||||
# --- Draw Line and Rotate ---
|
||||
draw = ImageDraw.Draw(img)
|
||||
# The line position is scaled by the same zoom factor
|
||||
line_x_scaled = self.current_line_x * self.current_zoom
|
||||
draw.line([(line_x_scaled, 0), (line_x_scaled, pix.height)],
|
||||
fill="red", width=3)
|
||||
proposed = self._automatic_outputs()
|
||||
if proposed is not None:
|
||||
for output in proposed:
|
||||
x0,y0,x1,y1 = output['crop_points']
|
||||
box = tuple(v*self.current_zoom for v in (x0,y0,x1,y1))
|
||||
draw.rectangle(box, outline='green' if output['side']=='left' else 'purple', width=3)
|
||||
if not output['keep']:
|
||||
draw.line((box[0],box[1],box[2],box[3]), fill='red', width=3)
|
||||
draw.line((box[0],box[3],box[2],box[1]), fill='red', width=3)
|
||||
self.page_label.config(text=self.page_label.cget('text')+' — Proposition automatique (croix rouge : page retirée)')
|
||||
else:
|
||||
line_x_scaled = self.current_line_x * self.current_zoom
|
||||
draw.line([(line_x_scaled, 0), (line_x_scaled, pix.height)],
|
||||
fill="red", width=3)
|
||||
for index, position in enumerate(self.secondary_splitters):
|
||||
x = position * self.current_zoom
|
||||
active = index == len(self.secondary_splitters) - 1
|
||||
@@ -353,6 +394,7 @@ class PDFPreviewer:
|
||||
def add_secondary_splitter(self, event=None):
|
||||
"""Start a new line in the widest remaining interval of this page."""
|
||||
width = self.doc.load_page(self.current_page_index).rect.width
|
||||
self.current_automatic_outputs = None
|
||||
boundaries = sorted({0, width, self.current_line_x, *self.secondary_splitters})
|
||||
left, right = max(zip(boundaries, boundaries[1:]), key=lambda pair: pair[1] - pair[0])
|
||||
self.secondary_splitters.append((left + right) / 2)
|
||||
@@ -364,6 +406,7 @@ class PDFPreviewer:
|
||||
self.load_page()
|
||||
|
||||
def _move_active_line(self, delta):
|
||||
self.current_automatic_outputs = None
|
||||
width = self.doc.load_page(self.current_page_index).rect.width
|
||||
if self.secondary_splitters:
|
||||
self.secondary_splitters[-1] = min(width, max(0, self.secondary_splitters[-1] + delta))
|
||||
@@ -415,13 +458,19 @@ class PDFPreviewer:
|
||||
# Reload the canvas to show the new page
|
||||
self.load_page()
|
||||
|
||||
def confirm_and_next_page(self, event=None, keep="both"):
|
||||
def confirm_and_next_page(self, event=None, keep=None):
|
||||
"""Saves the settings for the current page and moves to the next."""
|
||||
proposed = self._automatic_outputs()
|
||||
if keep is None:
|
||||
sides = {o['side'] for o in proposed if o['keep']} if proposed is not None else {'left','right'}
|
||||
keep = ('as_is' if 'whole' in sides else 'both' if len(sides)==2
|
||||
else next(iter(sides)) if sides else 'none')
|
||||
self.page_settings.append({
|
||||
"line_x": self.current_line_x,
|
||||
"secondary_splitters": list(self.secondary_splitters),
|
||||
"rotation": self.current_rotation,
|
||||
"keep": keep
|
||||
"keep": keep,
|
||||
"automatic_outputs": proposed,
|
||||
})
|
||||
|
||||
self.current_page_index += 1
|
||||
@@ -442,12 +491,18 @@ class PDFPreviewer:
|
||||
def finish_and_process(self) -> bool:
|
||||
"""Render and transactionally install the processed PDF."""
|
||||
try:
|
||||
self.split_pdf()
|
||||
self.reorder_pdfs()
|
||||
self.concate_files()
|
||||
output = commit_processed_pdf(self.workspace, self.pdf_path, self.final_file)
|
||||
save_provenance(self.workspace, output, self.output_origins)
|
||||
if all(s['keep']=='none' for s in self.page_settings):
|
||||
self.doc.close()
|
||||
output = commit_processed_pdf(self.workspace, self.pdf_path, None)
|
||||
provenance_path(self.workspace, output.stem).unlink(missing_ok=True)
|
||||
else:
|
||||
self.split_pdf()
|
||||
self.reorder_pdfs()
|
||||
self.concate_files()
|
||||
output = commit_processed_pdf(self.workspace, self.pdf_path, self.final_file)
|
||||
save_provenance(self.workspace, output, self.output_origins)
|
||||
clear_copy_error(self.workspace, output, source='orientation')
|
||||
clear_copy_error(self.workspace, output, source='page_splitter')
|
||||
report_path(self.workspace, output.stem).unlink(missing_ok=True)
|
||||
except Exception as exc: # noqa: BLE001 - interactive boundary
|
||||
self.failed = True
|
||||
@@ -514,15 +569,27 @@ class PDFPreviewer:
|
||||
line_x = settings['line_x']
|
||||
rotation_settings = settings['rotation']
|
||||
keep = settings['keep']
|
||||
# Bake source rotation and the preview's extra rotation into content,
|
||||
# so all cut coordinates refer to the displayed page.
|
||||
page.remove_rotation()
|
||||
page.set_rotation((rotation_settings + self.file_rotation + self.global_rotation) % 360)
|
||||
page.remove_rotation()
|
||||
# Normalize the displayed page without losing an offset CropBox.
|
||||
# This matches the automatic export's coordinate system.
|
||||
source_rotation = page.rotation
|
||||
width, height = page.rect.width, page.rect.height
|
||||
normalized = pymupdf.open()
|
||||
flat = normalized.new_page(width=width, height=height)
|
||||
page.set_rotation(0)
|
||||
if page.get_contents():
|
||||
flat.show_pdf_page(flat.rect, self.doc, i, rotate=-source_rotation)
|
||||
flat.set_rotation((rotation_settings+self.file_rotation+self.global_rotation) % 360)
|
||||
flat.remove_rotation()
|
||||
sides = []
|
||||
if keep == "as_is":
|
||||
sides.append((0, width, self.split_filename_left(i)))
|
||||
elif settings.get('automatic_outputs') is not None:
|
||||
for output in settings['automatic_outputs']:
|
||||
side = output['side']
|
||||
if keep == 'both' or keep == side:
|
||||
x0,_,x1,_ = output['crop_points']
|
||||
filename = self.split_filename_left(i) if side=='left' else self.split_filename_right(i)
|
||||
sides.append((x0,x1,filename))
|
||||
else:
|
||||
if keep in {"both", "left"} and line_x > 0:
|
||||
sides.append((0, line_x, self.split_filename_left(i)))
|
||||
@@ -539,10 +606,12 @@ class PDFPreviewer:
|
||||
for x0, x1 in zip(boundaries, boundaries[1:]):
|
||||
clip = pymupdf.Rect(x0, 0, x1, height)
|
||||
target = output.new_page(width=clip.width, height=clip.height)
|
||||
target.show_pdf_page(target.rect, self.doc, i, clip=clip)
|
||||
if flat.get_contents():
|
||||
target.show_pdf_page(target.rect, normalized, 0, clip=clip)
|
||||
origins.append({'origin': origin, 'crop_points': list(clip)})
|
||||
output.save(output_path)
|
||||
self.fragment_origins[str(output_path)] = origins
|
||||
normalized.close()
|
||||
|
||||
self.doc.close()
|
||||
print(f"\nProcessing complete. Files are in '{self.split_dir}' directory.")
|
||||
@@ -564,7 +633,7 @@ class PDFPreviewer:
|
||||
psk = ps[i]['keep']
|
||||
|
||||
# Si c'est une copie double (on s'assure qu'on a bien 2 pages consécutives modifiables)
|
||||
if psk in ["both", "right", "left", "none"] and i < len(ps)-1 and ps[i+1]['keep'] in ["both", "right", "left", "none"]:
|
||||
if not getattr(self, 'automatic_review', False) and psk in ["both", "right", "left", "none"] and i < len(ps)-1 and ps[i+1]['keep'] in ["both", "right", "left", "none"]:
|
||||
|
||||
# 1. Page de garde (Extérieur Droit)
|
||||
if ps[i]['keep'] in ["both", "right"]:
|
||||
@@ -678,11 +747,17 @@ def _selected_inputs(
|
||||
]
|
||||
else:
|
||||
candidates = list_pdf_files(directory, workspace)
|
||||
if target == workspace.root and not candidates:
|
||||
candidates = list_pdf_files(workspace.original_copies_dir, workspace)
|
||||
return list(reversed(candidates))
|
||||
|
||||
|
||||
def run(workspace: EvaluationWorkspace, target: Path, *, marked: bool = False) -> ExitCode:
|
||||
def run(workspace: EvaluationWorkspace, target: Path, *, marked: bool = False,
|
||||
automatic: bool = False, workers: int = 4) -> ExitCode:
|
||||
inputs = list(reversed(marked_copy_paths(workspace, originals=True))) if marked else _selected_inputs(workspace, target)
|
||||
if automatic:
|
||||
from copienator.commands.automatic_page_splitter import run as run_automatic
|
||||
return run_automatic(workspace, list(reversed(inputs)), workers=workers)
|
||||
if not inputs:
|
||||
print(f"No PDF files found in {target}")
|
||||
return ExitCode.SUCCESS
|
||||
@@ -695,6 +770,8 @@ def run(workspace: EvaluationWorkspace, target: Path, *, marked: bool = False) -
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = target_parser("Interactively split and reorder scanned PDF pages")
|
||||
parser.add_argument("--marked", action="store_true", help="Process only copies flagged during margin review")
|
||||
parser.add_argument("--automatic", action="store_true", help="Split automatically and save editable proposals without reordering")
|
||||
parser.add_argument("--workers", type=int, default=4, help="Copies analysed simultaneously in automatic mode")
|
||||
return parser
|
||||
|
||||
|
||||
@@ -703,7 +780,7 @@ def main(argv: Sequence[str] | None = None) -> int:
|
||||
|
||||
def handle(args: argparse.Namespace) -> ExitCode:
|
||||
workspace, target = workspace_from_target(args)
|
||||
return run(workspace, target, marked=args.marked)
|
||||
return run(workspace, target, marked=args.marked, automatic=args.automatic, workers=args.workers)
|
||||
|
||||
return execute(parser, argv, handle)
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ def _validate(value) -> dict[str, dict[str, str]]:
|
||||
if isinstance(flags, str):
|
||||
flags = {"cutleft": flags}
|
||||
if (not isinstance(flags, dict) or not flags
|
||||
or any(step not in {"cutleft", "plotting", "orientation"} or not isinstance(reason, str)
|
||||
or any(step not in {"cutleft", "plotting", "orientation", "page_splitter"} or not isinstance(reason, str)
|
||||
for step, reason in flags.items())):
|
||||
raise CliError("Invalid copy_errors.json: invalid step flags")
|
||||
normalized[name] = dict(flags)
|
||||
@@ -35,7 +35,7 @@ def copy_errors(workspace: EvaluationWorkspace) -> dict[str, str]:
|
||||
|
||||
def mark_copy_error(workspace: EvaluationWorkspace, pdf: Path, reason: str,
|
||||
*, source: str = "cutleft") -> None:
|
||||
if source not in {"cutleft", "plotting", "orientation"}:
|
||||
if source not in {"cutleft", "plotting", "orientation", "page_splitter"}:
|
||||
raise ValueError(f"Unknown copy flag source: {source}")
|
||||
def update(errors):
|
||||
normalized = _validate(errors)
|
||||
@@ -47,7 +47,7 @@ def mark_copy_error(workspace: EvaluationWorkspace, pdf: Path, reason: str,
|
||||
|
||||
def clear_copy_error(workspace: EvaluationWorkspace, pdf: Path,
|
||||
*, source: str = "cutleft") -> None:
|
||||
if source not in {"cutleft", "plotting", "orientation"}:
|
||||
if source not in {"cutleft", "plotting", "orientation", "page_splitter"}:
|
||||
raise ValueError(f"Unknown copy flag source: {source}")
|
||||
if not _path(workspace).exists():
|
||||
return
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
"""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
|
||||
+192
-5
@@ -45,12 +45,15 @@ def _paper_blur(gray: np.ndarray, sigma: float) -> np.ndarray:
|
||||
return blurred.astype(np.uint8)
|
||||
|
||||
|
||||
def _large_blank_ink(gray: np.ndarray, clean: np.ndarray, dpi: float):
|
||||
def _large_blank_ink(gray: np.ndarray, clean: np.ndarray, dpi: float, *,
|
||||
content_only: bool = False):
|
||||
"""Refine confirmed ruling, only accepting substantial extra blank margins.
|
||||
|
||||
Short directional openings tolerate locally bent/broken paper lines. A
|
||||
physical component-size threshold rejects their remaining tiny fragments.
|
||||
This is deliberately limited to already-confirmed ruled paper.
|
||||
Crop refinement is limited to already-confirmed ruled paper. Content-only
|
||||
analysis also uses this stroke filter on seedless plain pages and can return
|
||||
an empty mask; the normal crop path deliberately preserves its input then.
|
||||
"""
|
||||
px = dpi / 25.4
|
||||
height, width = gray.shape
|
||||
@@ -120,6 +123,14 @@ def _large_blank_ink(gray: np.ndarray, clean: np.ndarray, dpi: float):
|
||||
overlap = np.bincount(lab[strong_ruling > 0], minlength=count)
|
||||
bars &= overlap < st[:, 4]*.5
|
||||
refined |= bars[lab]
|
||||
if content_only:
|
||||
# A crop must preserve its input when there is no reliable content.
|
||||
# A blank-page classifier needs that empty result instead. Recover the
|
||||
# same nearby original strokes without restoring remote paper artifacts.
|
||||
radius = max(1, round(2*px))
|
||||
nearby = cv2.dilate(refined.astype(np.uint8),
|
||||
cv2.getStructuringElement(cv2.MORPH_ELLIPSE,(2*radius+1,2*radius+1))) > 0
|
||||
return refined | ((clean > 0) & nearby), len(holes), True
|
||||
ys = np.flatnonzero(np.any(refined, axis=1))
|
||||
original = np.flatnonzero(np.any(clean, axis=1))
|
||||
if not len(ys) or not len(original):
|
||||
@@ -138,8 +149,171 @@ def _large_blank_ink(gray: np.ndarray, clean: np.ndarray, dpi: float):
|
||||
return result, len(holes), changed
|
||||
|
||||
|
||||
def _blank_content_support(content: np.ndarray, dpi: float) -> tuple[np.ndarray, dict]:
|
||||
"""Require a glyph, neighbouring strokes, or a substantial diagram.
|
||||
|
||||
A few remote dots and a short loose curve do not establish a written page.
|
||||
Grouping protects fragmented pencil letters without a page-wide ink quota.
|
||||
This evidence filter is for blank classification, never margin cropping.
|
||||
"""
|
||||
px = dpi/25.4
|
||||
n,labels,stats,_ = cv2.connectedComponentsWithStats(content.astype(np.uint8),8)
|
||||
if n == 1:
|
||||
return content,dict(content_supported_groups=0,content_unsupported_pixels=0)
|
||||
radius = max(1,round(1.5*px))
|
||||
joined = cv2.dilate(content.astype(np.uint8),
|
||||
cv2.getStructuringElement(cv2.MORPH_ELLIPSE,(2*radius+1,2*radius+1)))
|
||||
_,groups = cv2.connectedComponents(joined,8)
|
||||
ys,xs = np.nonzero(content)
|
||||
component_groups = np.zeros(n,np.int32)
|
||||
component_groups[labels[ys,xs]] = groups[ys,xs]
|
||||
area = stats[:,4]
|
||||
bw,bh = stats[:,2],stats[:,3]
|
||||
density = area/np.maximum(1,bw*bh)
|
||||
letter = ((area >= .8*px*px) & (np.minimum(bw,bh) >= .6*px)
|
||||
& (density >= .18))
|
||||
# A dense sub-2 mm ink spot is not a glyph on its own. Keep it eligible
|
||||
# for a supported group (decimal points, dots over letters, punctuation).
|
||||
compact_spot = np.zeros(n,bool)
|
||||
spot_candidates = np.flatnonzero((density > .55) & (np.maximum(bw,bh) < 2*px))
|
||||
for i in spot_candidates:
|
||||
if i == 0:
|
||||
continue
|
||||
x,y,ww,hh,_ = stats[i]
|
||||
yy,xx = np.nonzero(labels[y:y+hh,x:x+ww] == i)
|
||||
hull_area = cv2.contourArea(cv2.convexHull(np.c_[xx,yy].astype(np.int32)))
|
||||
# Small x's have empty wedges inside their convex hull; solid spots
|
||||
# fill it. Pixel-centre hull area can be smaller than the pixel count.
|
||||
compact_spot[i] = area[i] >= .9*max(1.,hull_area)
|
||||
letter &= ~compact_spot
|
||||
substantial = (area >= 4*px*px) & (np.maximum(bw,bh) >= 5*px)
|
||||
# A lone short smear at the physical top/bottom edge can pass both area
|
||||
# tests. Require its pixels to be almost collinear before discounting it;
|
||||
# normal digits, letters and closed diagrams have broader stroke geometry.
|
||||
# Neighbouring meaningful strokes still support it through the group rule.
|
||||
near_edge = (stats[:,1] < 15*px) | (stats[:,1]+bh > content.shape[0]-15*px)
|
||||
edge_smear = np.zeros(n,bool)
|
||||
candidates = np.flatnonzero(near_edge & (area < 6*px*px)
|
||||
& (np.maximum(bw,bh) < 8*px)
|
||||
& (np.minimum(bw,bh) > 2*px) & (density < .35))
|
||||
for i in candidates:
|
||||
if i == 0:
|
||||
continue
|
||||
x,y,ww,hh,_ = stats[i]
|
||||
yy,xx = np.nonzero(labels[y:y+hh,x:x+ww] == i)
|
||||
values = np.linalg.eigvalsh(np.cov(np.c_[xx,yy].T))
|
||||
edge_smear[i] = values[0] < .05*values[1]
|
||||
letter &= ~edge_smear
|
||||
substantial &= ~edge_smear
|
||||
credible = area >= .2*px*px
|
||||
credible[0] = False
|
||||
counts = np.bincount(component_groups[credible],minlength=int(groups.max())+1)
|
||||
totals = np.bincount(component_groups,weights=area,minlength=len(counts))
|
||||
supported = (counts >= 2) & (totals >= 1.2*px*px)
|
||||
supported[component_groups[letter | substantial]] = True
|
||||
supported[0] = False
|
||||
retained = supported[groups] & content
|
||||
return retained,dict(content_supported_groups=int(supported.sum()),
|
||||
content_unsupported_pixels=int(content.sum()-retained.sum()),
|
||||
content_compact_spots=int(compact_spot[1:].sum()),
|
||||
content_edge_smears=int(edge_smear.sum()))
|
||||
|
||||
|
||||
def detect_page_content(rgb: np.ndarray, dpi: float = 200) -> tuple[np.ndarray, dict]:
|
||||
"""Expose rognage's content evidence on a single logical page.
|
||||
|
||||
Normal crop behaviour stays unchanged. On confirmed ruled paper, use its
|
||||
large-blank refinement even when that refinement finds no content, instead of
|
||||
the crop-specific fallback that keeps the original page. No separate weak-mark
|
||||
detector is used. The returned mask is in the input image coordinates.
|
||||
"""
|
||||
content_masks = {}
|
||||
paper_geometry = {}
|
||||
result = detect_bounds(rgb,dpi,content_masks=content_masks,_paper_geometry=paper_geometry)
|
||||
content = content_masks['foreground']
|
||||
gray = cv2.cvtColor(rgb,cv2.COLOR_RGB2GRAY)
|
||||
# Use the same deskew and paper-family confirmation as the crop pipeline.
|
||||
h,w = gray.shape
|
||||
if paper_geometry:
|
||||
matrix,size,deskewed = (paper_geometry[key] for key in ('matrix','size','deskewed'))
|
||||
else:
|
||||
angle = _skew(gray,angle_step=.5)
|
||||
matrix = cv2.getRotationMatrix2D((w/2,h/2),angle,1)
|
||||
corners = cv2.transform(np.array([[[0,0],[w,0],[0,h],[w,h]]],float),matrix)[0]
|
||||
origin = np.floor(corners.min(axis=0))
|
||||
size = np.ceil(corners.max(axis=0)-origin).astype(int)
|
||||
matrix[:,2] -= origin
|
||||
deskewed = cv2.warpAffine(gray,matrix,tuple(size),borderValue=255)
|
||||
binary = cv2.adaptiveThreshold(deskewed,255,cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
||||
cv2.THRESH_BINARY_INV,max(15,int(dpi/5)|1),9)
|
||||
horizontal,nh = _ruling(binary,True)
|
||||
vertical,nv = _ruling(binary,False)
|
||||
confirmed = ((nh and _edge_connected_family(binary,horizontal,True)) or
|
||||
(nv and _edge_connected_family(binary,vertical,False)))
|
||||
if not confirmed:
|
||||
# Slight local warp breaks one-pixel pale grid lines. Confirm the same
|
||||
# periodic, edge-reaching family with a small physical-width tolerance.
|
||||
tolerance = max(3,round(.3*dpi/25.4)|1)
|
||||
tolerant = cv2.dilate(binary,np.ones((tolerance,tolerance),np.uint8))
|
||||
th,tnh = _ruling(tolerant,True)
|
||||
tv,tnv = _ruling(tolerant,False)
|
||||
confirmed = ((tnh and _edge_connected_family(tolerant,th,True)) or
|
||||
(tnv and _edge_connected_family(tolerant,tv,False)))
|
||||
result['content_tolerant_ruling'] = bool(confirmed)
|
||||
# With no seeds, the same coherent-stroke filter can recover pencil-only
|
||||
# writing on plain paper. Arbitrary weak pixels alone are not a veto.
|
||||
refine = bool(confirmed) or not content.any()
|
||||
result['content_refinement'] = refine
|
||||
result['content_ruling_confirmed'] = bool(confirmed)
|
||||
if refine:
|
||||
clean = cv2.warpAffine(content.astype(np.uint8),matrix,tuple(size),
|
||||
flags=cv2.INTER_NEAREST,borderValue=0)
|
||||
refined,holes,_ = _large_blank_ink(deskewed,clean,dpi,content_only=True)
|
||||
content = cv2.warpAffine(refined.astype(np.uint8),cv2.invertAffineTransform(matrix),
|
||||
(w,h),flags=cv2.INTER_NEAREST,borderValue=0)>0
|
||||
result['content_holes'] = holes
|
||||
result['content_pixels'] = int(content.sum())
|
||||
if not confirmed and not content.any():
|
||||
# The directional refinement can erase very thin pencil letters on
|
||||
# plain paper. Reuse the crop pipeline's adaptive foreground instead,
|
||||
# requiring coherent letter-sized components rather than arbitrary
|
||||
# weak pixels. This fallback is confined to seedless/plain pages.
|
||||
weak = _foreground(gray,dpi,9,np.zeros_like(gray))
|
||||
weak,_ = _content_mask(weak,dpi)
|
||||
count,labels,stats,_ = cv2.connectedComponentsWithStats(weak,8)
|
||||
keep = np.zeros(count,bool)
|
||||
px = dpi/25.4
|
||||
keep[1:] = ((stats[1:,4] >= .5*px*px)
|
||||
& (stats[1:,2] >= px) & (stats[1:,3] >= px))
|
||||
# Faint scanner borders can be one huge connected component. They are
|
||||
# not coherent writing merely because their bounding box is large.
|
||||
x,y,bw,bh,area = stats[1:].T
|
||||
sparse = area / np.maximum(1,bw*bh) < .1
|
||||
edge_frame = (((bw > .9*w) & ((y < px) | (y+bh > h-px)))
|
||||
| ((bh > .9*h) & ((x < px) | (x+bw > w-px))))
|
||||
edge_frame |= ((bw > .8*w) & ((x < 2*px) | (x+bw > w-2*px)))
|
||||
keep[1:] &= ~(sparse & edge_frame)
|
||||
# A faint narrow scanner-edge band is not a pencil letter. Require
|
||||
# both edge placement and pale neutral appearance, not position alone.
|
||||
for i in np.flatnonzero(keep[1:])+1:
|
||||
xx,yy,ww,hh,_ = stats[i]
|
||||
if ww < 3*px and (xx < 2*px or xx+ww > w-2*px) and hh > ww:
|
||||
values = rgb[yy:yy+hh,xx:xx+ww][labels[yy:yy+hh,xx:xx+ww]==i]
|
||||
if np.median(values.min(axis=1)) > 205 and np.median(np.ptp(values.astype(np.int16),axis=1)) < 30:
|
||||
keep[i] = False
|
||||
content = keep[labels]
|
||||
result['content_pixels'] = int(content.sum())
|
||||
result['content_pencil_recovery'] = bool(content.any())
|
||||
content,support = _blank_content_support(content,dpi)
|
||||
result.update(support)
|
||||
result['content_pixels'] = int(content.sum())
|
||||
result['content_state'] = 'content' if content.any() else 'blank'
|
||||
return content,result
|
||||
|
||||
|
||||
def _neutral_paper_foreground(gray: np.ndarray, chroma: np.ndarray, dpi: float,
|
||||
*, diagnostics: dict | None = None):
|
||||
*, diagnostics: dict | None = None,
|
||||
geometry: dict | None = None):
|
||||
"""Clean confirmed dark ruling before it can seed whole pages.
|
||||
|
||||
Returns None for ordinary ink components or unconfirmed paper geometry.
|
||||
@@ -163,6 +337,8 @@ def _neutral_paper_foreground(gray: np.ndarray, chroma: np.ndarray, dpi: float,
|
||||
size = np.ceil(corners.max(axis=0)-origin).astype(int)
|
||||
matrix[:, 2] -= origin
|
||||
deskewed = cv2.warpAffine(gray, matrix, tuple(size), borderValue=255)
|
||||
if geometry is not None:
|
||||
geometry.update(matrix=matrix,size=size,deskewed=deskewed)
|
||||
background = _paper_blur(deskewed, dpi/8)
|
||||
normalized = cv2.divide(deskewed, np.maximum(background,1), scale=255)
|
||||
block = max(15, int(dpi/5) | 1)
|
||||
@@ -220,7 +396,9 @@ def _seed_mask(mask: np.ndarray, px: float, *, paper_nearby=None,
|
||||
|
||||
def detect_bounds(rgb: np.ndarray, dpi: float = 200, padding_mm: float = 6,
|
||||
min_crop_mm: float = 5, *, diagnostics: dict | None = None,
|
||||
colour_cleanup: bool = False, edge_mark_cleanup: bool = True) -> dict:
|
||||
colour_cleanup: bool = False, edge_mark_cleanup: bool = True,
|
||||
content_masks: dict | None = None,
|
||||
_paper_geometry: dict | None = None) -> dict:
|
||||
"""Locate strong ink, recover adjacent faint strokes, and retain padding.
|
||||
|
||||
Neutral punched-hole shadows usually have neither sufficient chroma nor
|
||||
@@ -237,6 +415,9 @@ def detect_bounds(rgb: np.ndarray, dpi: float = 200, padding_mm: float = 6,
|
||||
Optional diagnostics are written to a separate caller-owned dictionary.
|
||||
Its masks use the original image coordinates and never influence decisions
|
||||
or enter the JSON-serializable return value.
|
||||
|
||||
content_masks requests only foreground and weak-candidate masks, avoiding
|
||||
diagnostic component explanations and intermediate paper-mask exports.
|
||||
"""
|
||||
px = dpi/25.4
|
||||
h, w = rgb.shape[:2]
|
||||
@@ -259,7 +440,8 @@ def detect_bounds(rgb: np.ndarray, dpi: float = 200, padding_mm: float = 6,
|
||||
diagnostics['masks'] = {}
|
||||
diagnostics['thresholds'] = []
|
||||
masks = diagnostics['masks'] if diagnostics is not None else None
|
||||
cleaned, paper_info = _neutral_paper_foreground(gray, chroma, dpi, diagnostics=masks)
|
||||
cleaned, paper_info = _neutral_paper_foreground(gray, chroma, dpi, diagnostics=masks,
|
||||
geometry=_paper_geometry)
|
||||
if cleaned is not None:
|
||||
# Adaptive neutral cleanup includes faint paper fragments. Such pixels
|
||||
# need local contrast or substantial darkness before they seed a crop.
|
||||
@@ -279,6 +461,9 @@ def detect_bounds(rgb: np.ndarray, dpi: float = 200, padding_mm: float = 6,
|
||||
masks['isolated_edge_marks'] = np.zeros((h,w), bool)
|
||||
weak = ((chroma > 60) | ((gray < 175) & (residual > 25))).astype(np.uint8)
|
||||
weak[suppressed] = 0
|
||||
if content_masks is not None:
|
||||
content_masks.clear()
|
||||
content_masks.update(foreground=np.zeros((h,w),bool),weak_candidates=weak>0)
|
||||
if masks is not None:
|
||||
masks['strong_seeds'] = np.zeros((h,w), bool)
|
||||
masks['foreground'] = np.zeros((h,w), bool)
|
||||
@@ -329,6 +514,8 @@ def detect_bounds(rgb: np.ndarray, dpi: float = 200, padding_mm: float = 6,
|
||||
masks['isolated_edge_marks'] |= discarded[labels]
|
||||
seeded_labels = remaining
|
||||
retained = seeded_labels[labels]
|
||||
if content_masks is not None:
|
||||
content_masks['foreground'] |= retained
|
||||
if masks is not None:
|
||||
masks['strong_seeds'] |= seeds & retained
|
||||
ys = np.flatnonzero(np.any(retained, axis=1))
|
||||
|
||||
@@ -93,7 +93,6 @@ def ruling_suppression(rgb: np.ndarray, dpi: float):
|
||||
matrix[:,2] -= origin
|
||||
image = cv2.warpAffine(rgb, matrix, tuple(size), borderValue=(255,255,255))
|
||||
dark = 255-image
|
||||
dark16 = dark.astype(np.int16)
|
||||
darkness = _maximum(dark)
|
||||
binary = cv2.adaptiveThreshold(_minimum(image), 255,
|
||||
cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV,
|
||||
@@ -119,7 +118,7 @@ def ruling_suppression(rgb: np.ndarray, dpi: float):
|
||||
support = _maximum(model) > 9
|
||||
if count:
|
||||
support &= bands > 0
|
||||
close = _maximum(np.abs(dark16-model.astype(np.int16))) < 40
|
||||
close = _maximum(cv2.absdiff(dark,model)) < 40
|
||||
sample = support & (darkness > 9) & close & ~thick
|
||||
# Neutral, red, green and blue line families are modelled separately so
|
||||
# a red margin rule cannot be mistaken for unusually dark blue writing.
|
||||
@@ -128,7 +127,10 @@ def ruling_suppression(rgb: np.ndarray, dpi: float):
|
||||
classes[second <= third] = 1
|
||||
classes[(first <= second) & (first <= third)] = 0
|
||||
classes[cv2.subtract(_maximum(model),_minimum(model)) < 25] = 3
|
||||
credible = np.zeros(binary.shape, bool)
|
||||
# Rejected classes have ceiling -1, below every uint8 darkness value.
|
||||
# Compute each class ceiling once, then test pixels by lookup instead
|
||||
# of subtracting and splitting full RGB images for every colour class.
|
||||
ceilings = np.full((4,3),-1,np.int16)
|
||||
for colour in range(4):
|
||||
selected = sample & (classes == colour)
|
||||
if selected.sum() < 100:
|
||||
@@ -143,9 +145,10 @@ def ruling_suppression(rgb: np.ndarray, dpi: float):
|
||||
# An isolated long fraction bar must not define its own paper colour.
|
||||
# The upper quantile admits darker major rules within an established
|
||||
# family while rejecting excess ink on most of its pixels.
|
||||
ceiling = np.floor(np.quantile(dark[selected],.99,axis=0)+25).astype(np.int16)
|
||||
credible |= ((classes == colour)
|
||||
& (_maximum(dark16-ceiling) <= 0))
|
||||
ceilings[colour] = np.floor(np.quantile(dark[selected],.99,axis=0)+25).astype(np.int16)
|
||||
credible = ((dark[:,:,0] <= ceilings[classes,0])
|
||||
& (dark[:,:,1] <= ceilings[classes,1])
|
||||
& (dark[:,:,2] <= ceilings[classes,2]))
|
||||
match = close & credible & (darkness > 9) & ~thick
|
||||
removed |= support & match
|
||||
protected |= support & ~match & (binary > 0)
|
||||
|
||||
Reference in new issue
Block a user