Cropping investigation
This commit is contained in:
1 parent
48602c24f1
commit
bd1964ffcd
10 files changed
+814
-24
No files matched your search
+151
-13
@@ -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)
|
||||
Reference in new issue
Block a user