Cropping investigation

This commit is contained in:
sebastien committed 2026-10-11 10:12:59 +02:00
1 parent 48602c24f1
commit bd1964ffcd
10 files changed
+814 -24

No files matched your search

+151 -13
View File
@@ -10,6 +10,8 @@ from functools import lru_cache
import cv2
import numpy as np
from copienator.edge_marks import classify_edge_marks
from copienator.paper_ruling import ruling_suppression, _edge_connected_family
from copienator.paper_background import _skew, _ruling, _foreground, _content_mask
@@ -136,7 +138,8 @@ def _large_blank_ink(gray: np.ndarray, clean: np.ndarray, dpi: float):
return result, len(holes), changed
def _neutral_paper_foreground(gray: np.ndarray, chroma: np.ndarray, dpi: float):
def _neutral_paper_foreground(gray: np.ndarray, chroma: np.ndarray, dpi: float,
*, diagnostics: dict | None = None):
"""Clean confirmed dark ruling before it can seed whole pages.
Returns None for ordinary ink components or unconfirmed paper geometry.
@@ -167,15 +170,26 @@ def _neutral_paper_foreground(gray: np.ndarray, chroma: np.ndarray, dpi: float):
cv2.THRESH_BINARY_INV,block,9)
horizontal, nh = _ruling(binary,True)
vertical, nv = _ruling(binary,False)
if not (nh or nv):
if not ((nh and _edge_connected_family(binary,horizontal,True))
or (nv and _edge_connected_family(binary,vertical,False))):
return None, dict(paper_cleanup=False)
# The permissive mask retains faint, isolated marks.
# Bands identify where ruling is expected, but only actual dark pixels
# inside them may be suppressed. Erasing the complete band loses faint
# writing alongside a dark grid line.
paper_pixels = (horizontal|vertical) & ((normalized < 160).astype(np.uint8)*255)
clean, holes = _content_mask(_foreground(normalized,dpi,9,paper_pixels),dpi,nh,nv)
stages = {} if diagnostics is not None else None
foreground = _foreground(normalized,dpi,9,paper_pixels, diagnostics=stages)
clean, holes = _content_mask(foreground,dpi,nh,nv, diagnostics=stages)
before_refinement = clean.copy() if diagnostics is not None else None
clean, repeated_holes, refined = _large_blank_ink(deskewed, clean, dpi)
if diagnostics is not None:
stages['confirmed_ruling'] = paper_pixels > 0
stages['refinement_removed'] = (before_refinement > 0) & (clean == 0)
inverse = cv2.invertAffineTransform(matrix)
for name, mask in stages.items():
diagnostics[name] = cv2.warpAffine(mask.astype(np.uint8), inverse,
(width,height), flags=cv2.INTER_NEAREST, borderValue=0) > 0
restored = cv2.warpAffine(clean, cv2.invertAffineTransform(matrix),
(width,height), flags=cv2.INTER_NEAREST, borderValue=0)
return restored > 0, dict(paper_cleanup=True, angle_deg=round(angle,3),
@@ -184,22 +198,45 @@ def _neutral_paper_foreground(gray: np.ndarray, chroma: np.ndarray, dpi: float):
large_blank_refinement=refined)
def _seed_mask(mask: np.ndarray, px: float) -> np.ndarray:
def _seed_mask(mask: np.ndarray, px: float, *, paper_nearby=None,
diagnostics=None) -> np.ndarray:
n, labels, stats, _ = cv2.connectedComponentsWithStats(mask.astype(np.uint8), 8)
keep = np.zeros(n, bool)
keep[1:] = ((stats[1:, 4] >= max(4, .12*px*px))
& (stats[1:, 2] >= max(2, round(.35*px)))
& (stats[1:, 3] >= max(2, round(.35*px))))
if paper_nearby is not None:
w, h = stats[:,2], stats[:,3]
thin = max(2, round(.45*px))
aligned = ((np.minimum(w,h) <= thin)
& (np.maximum(w,h) >= 3*np.minimum(w,h)))
overlap = np.bincount(labels[paper_nearby], minlength=n)
remnants = keep & aligned & (overlap >= .8*stats[:,4])
keep[remnants] = False
if diagnostics is not None:
diagnostics['ruling_residuals'] |= remnants[labels]
return keep[labels]
def detect_bounds(rgb: np.ndarray, dpi: float = 200, padding_mm: float = 6,
min_crop_mm: float = 5) -> dict:
min_crop_mm: float = 5, *, diagnostics: dict | None = None,
colour_cleanup: bool = False, edge_mark_cleanup: bool = True) -> dict:
"""Locate strong ink, recover adjacent faint strokes, and retain padding.
Neutral punched-hole shadows usually have neither sufficient chroma nor
sufficient darkness to seed a region. Nothing is discarded merely because
it is in a side margin. Two seed thresholds expose unstable boundaries.
Small isolated side-margin marks near the physical top/bottom edge are
excluded using same-page position and separation. Marks near text remain.
edge_mark_cleanup=False is available for evaluation comparisons.
Experimental colour cleanup is opt-in because its full-resolution model
is too expensive for the default PDF processing path.
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.
"""
px = dpi/25.4
h, w = rgb.shape[:2]
@@ -214,14 +251,38 @@ def detect_bounds(rgb: np.ndarray, dpi: float = 200, padding_mm: float = 6,
vertical = cv2.morphologyEx(darkness, cv2.MORPH_OPEN,
np.ones((length, 1), np.uint8))
residual = cv2.subtract(darkness, np.maximum(horizontal, vertical))
# Strong strokes bypass the line-background estimate entirely: even a long
# isolated black or coloured fraction bar must survive. Local contrast is
# used only to recover weaker surrounding strokes.
# The directional estimate alone cannot veto a strong fraction bar. Only
# independently confirmed paper geometry may suppress a strong candidate.
neutral_ink = gray < 95
cleaned, paper_info = _neutral_paper_foreground(gray, chroma, dpi)
if diagnostics is not None:
diagnostics.clear()
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)
if cleaned is not None:
neutral_ink = cleaned
# Adaptive neutral cleanup includes faint paper fragments. Such pixels
# need local contrast or substantial darkness before they seed a crop.
neutral_ink = cleaned & ((residual > 25) | (gray < 95))
if colour_cleanup:
suppressed, ruling_info = ruling_suppression(rgb, dpi)
else:
suppressed, ruling_info = np.zeros((h,w), bool), {}
paper_info.update(ruling_info)
neutral_ink &= ~suppressed
paper_nearby = (cv2.dilate(suppressed.astype(np.uint8),
np.ones((3,3),np.uint8)) > 0) if np.any(suppressed) else None
if masks is not None:
masks['paper_suppressed'] = masks.get('paper_suppressed', np.zeros((h,w), bool)) | suppressed
masks['confirmed_ruling'] = masks.get('confirmed_ruling', np.zeros((h,w), bool)) | suppressed
masks['ruling_residuals'] = np.zeros((h,w), bool)
masks['isolated_edge_marks'] = np.zeros((h,w), bool)
weak = ((chroma > 60) | ((gray < 175) & (residual > 25))).astype(np.uint8)
weak[suppressed] = 0
if masks is not None:
masks['strong_seeds'] = np.zeros((h,w), bool)
masks['foreground'] = np.zeros((h,w), bool)
masks['weak_candidates'] = weak > 0
radius = max(1, round(2*px))
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2*radius+1, 2*radius+1))
extents = []
@@ -232,26 +293,67 @@ def detect_bounds(rgb: np.ndarray, dpi: float = 200, padding_mm: float = 6,
counts.append(counts[0])
if extents:
extents.append(extents[0])
if diagnostics is not None:
diagnostics['thresholds'].append(dict(
diagnostics['thresholds'][0], threshold=115, reused=True))
continue
seeds = _seed_mask((chroma > threshold) | neutral_ink, px)
seeds = _seed_mask(((chroma > threshold) | neutral_ink) & ~suppressed, px,
paper_nearby=paper_nearby, diagnostics=masks)
counts.append(int(np.count_nonzero(seeds)))
if not np.any(seeds):
if diagnostics is not None:
diagnostics['thresholds'].append(dict(threshold=threshold,
seed_pixels=0, extent=None, limiting_components={'top': [], 'bottom': []}))
continue
# Limit weak recovery to a physical neighbourhood: faint grid lines
# connected to a letter cannot grow into a full-page foreground mask.
nearby = cv2.dilate(seeds.astype(np.uint8), kernel)
candidate = (weak & nearby) | seeds.astype(np.uint8)
n, labels = cv2.connectedComponents(candidate, 8)
if edge_mark_cleanup:
n, labels, stats, _ = cv2.connectedComponentsWithStats(candidate, 8)
else:
n, labels = cv2.connectedComponents(candidate, 8)
seeded_labels = np.zeros(n, bool)
seeded_labels[np.unique(labels[seeds])] = True
seeded_labels[0] = False
ys = np.flatnonzero(np.any(seeded_labels[labels], axis=1))
removed, groups = (classify_edge_marks(labels, stats, seeded_labels, rgb, dpi)
if edge_mark_cleanup else ([], []))
if removed:
remaining = seeded_labels.copy()
remaining[removed] = False
# Keep the original ink bounds if no foreground would remain.
if np.any(remaining):
if masks is not None:
discarded = np.zeros(n,bool)
discarded[removed] = True
masks['isolated_edge_marks'] |= discarded[labels]
seeded_labels = remaining
retained = seeded_labels[labels]
if masks is not None:
masks['strong_seeds'] |= seeds & retained
ys = np.flatnonzero(np.any(retained, axis=1))
extents.append((int(ys.min()), int(ys.max())+1))
if diagnostics is not None:
masks['foreground'] |= retained
diagnostics['thresholds'].append(_explain_extent(retained, seeds,
chroma, neutral_ink, threshold, extents[-1], px))
diagnostics['thresholds'][-1]['edge_mark_groups'] = groups
result = dict(top_px=0, bottom_px=h, angle_deg=None,
horizontal_lines=0, vertical_lines=0, edge_artifacts=0,
detector="ink", seed_pixels=counts, status="review-no-ink-seeds")
result.update(paper_info)
if diagnostics is not None:
masks['recovered_weak'] = masks['foreground'] & ~masks['strong_seeds']
masks['unretained_weak'] = masks['weak_candidates'] & ~masks['foreground']
for name in ('confirmed_ruling', 'paper_suppressed', 'rejected_artifacts',
'repeated_holes', 'refinement_removed'):
masks.setdefault(name, np.zeros((h,w), bool))
diagnostics['shape'] = [h,w]
diagnostics['mask_pixels'] = {name: int(mask.sum()) for name, mask in masks.items()}
if not extents:
if diagnostics is not None:
diagnostics['edges'] = {edge: {'reason': 'no-reliable-ink-seeds',
'boundary_px': 0 if edge == 'top' else h} for edge in ('top', 'bottom')}
return result
pad = padding_mm*px
top = max(0, int(np.floor(min(e[0] for e in extents)-pad)))
@@ -263,6 +365,7 @@ def detect_bounds(rgb: np.ndarray, dpi: float = 200, padding_mm: float = 6,
for edge, name in ((0, 'top'), (1, 'bottom')):
if abs(extents[0][edge]-extents[1][edge]) > max(pad, 3*px):
uncertain.append(name)
proposed_top, proposed_bottom = top, bottom
if top < min_crop_mm*px:
top = 0
if h-bottom < min_crop_mm*px:
@@ -270,4 +373,39 @@ def detect_bounds(rgb: np.ndarray, dpi: float = 200, padding_mm: float = 6,
result.update(top_px=top, bottom_px=bottom,
status=('review-'+'-'.join(uncertain) if uncertain else
'cropped' if top or bottom < h else 'unchanged'))
if diagnostics is not None:
diagnostics['edges'] = {}
for edge, boundary, proposed, extent in (
('top', top, proposed_top, min(e[0] for e in extents)),
('bottom', bottom, proposed_bottom, max(e[1] for e in extents))):
removal = boundary if edge == 'top' else h-boundary
reason = 'crop-to-retained-ink-with-padding' if removal else (
'below-minimum-crop' if boundary != proposed else 'ink-or-padding-reaches-edge')
diagnostics['edges'][edge] = dict(boundary_px=boundary,
proposed_boundary_px=proposed, ink_extent_px=extent,
reason=reason, threshold_disagreement=edge in uncertain,
limiting_thresholds=[i['threshold'] for i in diagnostics['thresholds']
if i['extent'] is not None and i['extent'][0 if edge == 'top' else 1] == extent])
return result
def _explain_extent(retained, seeds, chroma, neutral, threshold, extent, px):
"""Describe actual boundary-setting regions; infer no handwriting semantics."""
_, labels, stats, _ = cv2.connectedComponentsWithStats(retained.astype(np.uint8), 8)
limiting = {}
for edge, row in (('top', extent[0]), ('bottom', extent[1]-1)):
ids = [int(i) for i in np.unique(labels[row]) if i]
components = []
for identifier in ids:
x,y,w,h,area = map(int, stats[identifier])
region = labels[y:y+h,x:x+w] == identifier
strong = seeds[y:y+h,x:x+w] & region
components.append(dict(component_id=identifier, bbox_px=[x,y,x+w,y+h],
bbox_mm=[round(v/px,3) for v in (x,y,x+w,y+h)], area_px=area,
seed_pixels=int(strong.sum()),
coloured_seed_pixels=int((strong & (chroma[y:y+h,x:x+w] > threshold)).sum()),
neutral_seed_pixels=int((strong & neutral[y:y+h,x:x+w]).sum()),
reason='retained-component-touches-' + edge + '-ink-extent'))
limiting[edge] = components
return dict(threshold=threshold, seed_pixels=int(seeds.sum()),
extent=list(extent), limiting_components=limiting)