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

+116
View File
@@ -0,0 +1,116 @@
"""Optional detector explanations and independent, manually annotated crop checks.
Annotations are evaluation data: they are never passed to the ink detector.
"""
from __future__ import annotations
import json
from pathlib import Path
import cv2
import numpy as np
from PIL import Image, ImageDraw
MASK_DESCRIPTIONS = {
'isolated_edge_marks': 'Small border marks excluded by same-page edge position and isolation',
'confirmed_ruling': 'Confirmed neutral-paper ruling pixels',
'paper_suppressed': 'Foreground pixels suppressed by paper cleanup',
'rejected_artifacts': 'Pixels rejected as specks, thin ruling remnants or repeated holes',
'repeated_holes': 'Pixels rejected as repeated hole components during initial cleanup',
'refinement_removed': 'Pixels removed by the large-blank refinement',
'ruling_residuals': 'Thin seed-candidate remnants adjoining confirmed suppressed paper pixels',
'strong_seeds': 'Strong seed pixels, including any surviving paper or pagination',
'weak_candidates': 'Weak candidates before seed proximity and connectivity checks',
'foreground': 'All retained pixels that actually determine the crop',
'recovered_weak': 'Weak pixels recovered around strong seeds',
'unretained_weak': 'Weak candidates not retained; these may be ink OR paper',
}
def check_protected_regions(regions, result, *, width_mm, height_mm, shape):
"""Fail if either crop boundary crosses a reviewed content rectangle.
Rectangles are [left, top, right, bottom] in original displayed millimetres.
They conservatively protect their entire area, including enclosed whitespace.
"""
h = shape[0]
top = result['top_px'] / h * height_mm
bottom = result['bottom_px'] / h * height_mm
if not 0 <= result['top_px'] <= result['bottom_px'] <= h:
raise ValueError('Invalid crop bounds')
rows = []
seen = set()
for region in regions:
name = region['id']
if name in seen:
raise ValueError(f'Duplicate protected region: {name}')
seen.add(name)
x0, y0, x1, y1 = region['bbox_mm']
if not all(np.isfinite([x0,y0,x1,y1])) or not (
0 <= x0 < x1 <= width_mm and 0 <= y0 < y1 <= height_mm):
raise ValueError(f'Invalid protected rectangle: {name}')
top_loss = max(0.0, min(y1, top)-y0)
bottom_loss = max(0.0, y1-max(y0, bottom))
rows.append(dict(id=name, label=region['label'], bbox_mm=region['bbox_mm'],
passed=top_loss <= 1e-9 and bottom_loss <= 1e-9,
clipped_edges=[edge for edge, loss in [('top',top_loss),('bottom',bottom_loss)] if loss > 1e-9],
clipped_height_mm=round(top_loss+bottom_loss,4),
clipped_area_fraction=round(min(1.0,(top_loss+bottom_loss)/(y1-y0)),4)))
return rows
def save_diagnostics(rgb, result, diagnostics, destination: Path, *, regions=(),
width_mm, height_mm):
"""Export exact binary masks, readable overlays and JSON boundary explanations."""
destination.mkdir(parents=True, exist_ok=True)
masks = diagnostics['masks']
files = {}
for name, mask in masks.items():
if mask.shape != rgb.shape[:2] or mask.dtype != np.bool_:
raise ValueError(f'Invalid diagnostic mask: {name}')
path = destination / f'{name}.png'
Image.fromarray(mask).save(path)
files[name] = path.name
views = {
'strong-seeds': [('strong_seeds',(220,50,20))],
'retained-ink': [('strong_seeds',(220,50,20)),('recovered_weak',(245,140,0))],
'unretained-weak': [('unretained_weak',(155,40,200))],
'paper-and-artifacts': [('paper_suppressed',(20,140,200)),
('rejected_artifacts',(155,40,200)),('ruling_residuals',(155,40,200)),
('isolated_edge_marks',(220,105,0)),
('refinement_removed',(245,140,0))],
'protected-content': [],
}
preview = Image.fromarray(rgb)
preview.thumbnail((1000,1400))
sy = preview.height / rgb.shape[0]
overlays = {}
for name, layers in views.items():
canvas = np.array(preview).astype(np.float32)
for mask_name, colour in layers:
# Mark every source pixel represented by a preview pixel; thin strokes
# must not disappear merely because a preview was downsampled.
mask = masks[mask_name]
coverage = cv2.resize(mask.astype(np.float32), preview.size, interpolation=cv2.INTER_AREA) > 0
canvas[coverage] = .3*canvas[coverage] + .7*np.array(colour)
image = Image.fromarray(np.clip(canvas,0,255).astype(np.uint8))
draw = ImageDraw.Draw(image)
for region in regions:
x0,y0,x1,y1 = region['bbox_mm']
box = (x0/width_mm*image.width,y0/height_mm*image.height,
x1/width_mm*image.width,y1/height_mm*image.height)
clipped = result['top_px']*sy > box[1] or result['bottom_px']*sy < box[3]
colour = (200,0,160) if clipped else (0,150,170)
draw.rectangle(box, outline=colour, width=2)
for y in (result['top_px']*sy, result['bottom_px']*sy):
draw.line((0,min(y,image.height-1),image.width,min(y,image.height-1)),fill=(215,20,20),width=2)
path = destination / f'{name}.jpg'
image.save(path, quality=90)
overlays[name] = path.name
data = {key:value for key,value in diagnostics.items() if key != 'masks'}
data.update(detector=result, mask_files=files, mask_descriptions=MASK_DESCRIPTIONS,
overlays=overlays, protected_regions=list(regions))
report = destination/'explanation.json'
report.write_text(json.dumps(data,indent=2)+'\n')
return dict(report=report.name, overlays=overlays, masks=files)
+1 -7
View File
@@ -24,7 +24,6 @@ from copienator.ink_detection import detect_bounds
LINES_PER_PAGE = 36
MINIMUM_HEIGHT_LINES = 10
IGNORED_BOTTOM_LINES = 0.75
MINIMUM_CROP_LINES = 4
@@ -93,13 +92,8 @@ def process_exercise_pdf(
pixmap.height, pixmap.width, 3
)
pixels_per_point = pixmap.height / visible_height
ignored_pixels = min(
pixmap.height - 1,
round(IGNORED_BOTTOM_LINES * line_points * pixels_per_point),
)
analysis_bottom = pixmap.height - ignored_pixels
detection = detect_bounds(
rgb[:analysis_bottom], dpi=dpi, padding_mm=padding_mm, min_crop_mm=0
rgb, dpi=dpi, padding_mm=padding_mm, min_crop_mm=0
)
proposed_bottom = detection["bottom_px"]
removed_points = (pixmap.height - proposed_bottom) / pixels_per_point
+95
View File
@@ -0,0 +1,95 @@
"""Group small isolated border marks using evidence from one displayed page.
This identifies dispensable border marks, without recognizing pagination or
consulting other pages. Component IDs are local to the caller's foreground.
Long paper-line candidates are excluded from grouping but remain in the crop.
"""
import numpy as np
def classify_edge_marks(labels, stats, eligible, rgb, dpi):
px = dpi/25.4
height, width = labels.shape
components = []
for i, (x, y, w, h, area) in enumerate(stats[1:], 1):
if not eligible[i] or area < .15*px*px:
continue
# Exclusion from candidate grouping ONLY. Pixels remain in the crop mask.
if ((h > 25*px and w < 3*px) or (w > 25*px and h < .6*px)):
continue
components.append(i)
parent = {i:i for i in components}
def find(i):
while parent[i] != i:
parent[i] = parent[parent[i]]
i = parent[i]
return i
# A y sweep bounds the search on noisy pages; uncertain large groups stay.
edge_components = [i for i in components if stats[i,2] <= 40*px
and stats[i,3] <= 18*px and
(stats[i,1]+stats[i,3] <= 40*px
or stats[i,1] >= height-40*px)]
if len(edge_components) > 512:
return [], []
colours = {}
if rgb is not None:
for i in edge_components:
x,y,w,h,_ = stats[i]
values = rgb[y:y+h,x:x+w][labels[y:y+h,x:x+w]==i]
colours[i] = float(np.median(values.max(axis=1)-values.min(axis=1)))
ordered = sorted(edge_components, key=lambda i:stats[i,1])
for a, i in enumerate(ordered):
x,y,w,h,_ = stats[i]
for j in ordered[a+1:]:
xx,yy,ww,hh,_ = stats[j]
if yy > y+h+1.5*px:
break
gap_x = max(0, xx-(x+w), x-(xx+ww))
gap_y = max(0, yy-(y+h), y-(yy+hh))
incompatible = (rgb is not None and
((colours[i] < 35 and colours[j] > 60)
or (colours[j] < 35 and colours[i] > 60)))
if not incompatible and gap_x <= 3*px and gap_y <= 1.5*px:
parent[find(j)] = find(i)
groups = {}
for i in edge_components:
groups.setdefault(find(i), []).append(i)
rows = []
for ids in groups.values():
s=stats[ids];x,y=s[:,:2].min(axis=0);right,bottom=(s[:,:2]+s[:,2:4]).max(axis=0)
w,h=right-x,bottom-y
if not (w <= 40*px and h <= 18*px and h >= 1.5*px):
continue
edge = 'top' if bottom <= 40*px else 'bottom' if y >= height-40*px else None
if edge is None:
continue
underline_ids=[]
for i in ids:
xx,yy,ww,hh,area=stats[i]
if ww > 3*hh and ww >= .65*w and yy+hh >= bottom-1.5*px:
underline_ids.append(i)
lateral = (x+w/2 < width*.25 or x+w/2 > width*.75)
# Check separation from other compact strokes across the full width.
# Confirmed long line candidates remain in the actual crop mask.
body = [i for i in components if i not in ids and stats[i,3] < 18*px]
edge_distance = (y if edge == 'top' else height-bottom)/px
if body:
gap = ((min(stats[i,1] for i in body)-(y+h))/px if edge=='top' else
(y-max(stats[i,1]+stats[i,3] for i in body))/px)
else:
gap = None
truncated = (x <= px or y <= px or right >= width-px or bottom >= height-px)
reasons=[]
if lateral:reasons.append('lateral-placement')
if gap is not None and gap >= 20:reasons.append('all-column-stroke-gap>=20mm')
if edge_distance <= 15:reasons.append('within-15mm-of-edge')
if underline_ids:reasons.append('underline-protection')
if truncated:reasons.append('scan-edge-truncated')
proposed = (lateral and edge_distance <= 15 and gap is not None
and gap >= 20 and not underline_ids and not truncated)
rows.append(dict(bbox_mm=[round(float(v/px),2) for v in [x,y,x+w,y+h]],
component_ids=ids,edge=edge,edge_distance_mm=round(edge_distance,2),gap_mm=None if gap is None else round(gap,2),
evidence=reasons,proposed_pagination=bool(proposed)))
removed = [i for row in rows if row['proposed_pagination'] for i in row['component_ids']]
return removed, rows
+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)
+9 -2
View File
@@ -82,7 +82,7 @@ def _ruling(binary: np.ndarray, horizontal: bool) -> tuple[np.ndarray, int]:
def _foreground(gray: np.ndarray, dpi: float, threshold: int,
ruling: np.ndarray) -> np.ndarray:
ruling: np.ndarray, *, diagnostics: dict | None = None) -> np.ndarray:
block = max(15, int(dpi / 5) | 1)
binary = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY_INV, block, threshold)
@@ -100,12 +100,14 @@ def _foreground(gray: np.ndarray, dpi: float, threshold: int,
# locally thicker excess strokes inside the suppression mask.
excess_ink = cv2.erode(excess_ink, np.ones((2, 2), np.uint8)) > 0
mask &= ~excess_ink
if diagnostics is not None:
diagnostics['paper_suppressed'] = mask & (binary > 0)
binary[mask] = 0
return binary
def _content_mask(binary: np.ndarray, dpi: float, nh: int = 0,
nv: int = 0) -> tuple[np.ndarray, int]:
nv: int = 0, *, diagnostics: dict | None = None) -> tuple[np.ndarray, int]:
"""Filter only tiny speckles and repeated, matching edge-hole components."""
px = dpi / 25.4
# Small closing reconnects strokes interrupted by paper-line suppression.
@@ -142,5 +144,10 @@ def _content_mask(binary: np.ndarray, dpi: float, nh: int = 0,
holes.update(similar)
if holes:
keep[list(holes)] = False
if diagnostics is not None:
diagnostics['rejected_artifacts'] = ~keep[labels] & (binary > 0)
hole_labels = np.zeros(n, bool)
hole_labels[list(holes)] = True
diagnostics['repeated_holes'] = hole_labels[labels] & (binary > 0)
# Bound original residual ink, not the expanded/grouped mask.
return (keep[labels] & (binary > 0)).astype(np.uint8), len(holes)
+165
View File
@@ -0,0 +1,165 @@
"""Colour-aware suppression of pixels supported by recurring paper geometry.
This only identifies paper ruling. It does not classify pagination or introduce
an independent faint-writing detector.
"""
import cv2
import numpy as np
from copienator.paper_background import _ruling, _runs, _skew
def _maximum(image):
first, second, third = cv2.split(image)
return cv2.max(cv2.max(first, second), third)
def _minimum(image):
first, second, third = cv2.split(image)
return cv2.min(cv2.min(first, second), third)
def _thick_strokes(binary, dpi):
"""Protect substantial excess thickness without keeping grid junctions."""
height, width = binary.shape
widths = []
for horizontal in (True, False):
dimension = width if horizontal else height
length = max(25, round(dimension*.12))
kernel = np.ones((1,length) if horizontal else (length,1), np.uint8)
lines = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel)
counts = np.count_nonzero(lines, axis=1 if horizontal else 0)
widths.extend(b-a for a,b in _runs(counts > dimension*.3))
thickness = max(3, int(np.ceil(np.quantile(widths,.95)))+2) if widths else 3
kernel = np.ones((thickness,thickness), np.uint8)
cores = cv2.erode(binary, kernel)
count, labels, stats, _ = cv2.connectedComponentsWithStats(cores, 8)
keep = np.zeros(count, bool)
px = dpi/25.4
x, _, w, h, area = stats[1:].T
keep[1:] = ((area >= .3*px*px) & (np.maximum(w,h) >= 1.5*px)
& (np.minimum(w,h) >= 2))
# A slightly thicker full-height margin rule is still a paper candidate.
# It will only be removed if its colour also passes the ruling model below.
side_line = ((h > height*.7) & (w < 2*px)
& ((x < width*.3) | (x > width*.7)))
keep[1:] &= ~side_line
return cv2.dilate(keep[labels].astype(np.uint8), kernel) > 0
def _edge_connected_family(binary, bands, horizontal):
"""Require several ruling bands reaching a physical edge of the scan.
Regularly spaced fraction bars inside an answer are not enough evidence.
"""
dimension = binary.shape[1] if horizontal else binary.shape[0]
length = max(25, round(dimension*.12))
lines = cv2.morphologyEx(binary, cv2.MORPH_OPEN,
np.ones((1,length) if horizontal else (length,1), np.uint8))
edge = max(3, round(dimension*.08))
if horizontal:
touching = np.any(lines[:,:edge] | lines[:,-edge:], axis=1)
positions = np.any(bands, axis=1)
else:
touching = np.any(lines[:edge] | lines[-edge:], axis=0)
positions = np.any(bands, axis=0)
return sum(np.any(touching[a:b]) for a,b in _runs(positions)) >= 5
def ruling_suppression(rgb: np.ndarray, dpi: float):
"""Return a mask in original coordinates and evidence for paper cleanup.
Require a periodic horizontal or vertical family before modelling any line.
Compare actual RGB pixels with locally persistent line colour, retain darker
or different-colour strokes, and guard unusually thick strokes. A separate
full-height margin rule is eligible only alongside that confirmed family.
"""
height, width = rgb.shape[:2]
lowest = _minimum(rgb)
chroma = cv2.subtract(_maximum(rgb), lowest)
_, _, stats, _ = cv2.connectedComponentsWithStats(
((lowest < 215) & ((chroma > 20) | (lowest < 160))).astype(np.uint8), 8)
spans = np.maximum(stats[1:,2]/width, stats[1:,3]/height)
if not (np.any(spans > .35) or np.count_nonzero(spans > .05) >= 3):
return np.zeros((height,width), bool), {}
angle = _skew(lowest, angle_step=.5)
matrix = cv2.getRotationMatrix2D((width/2,height/2), angle, 1)
corners = cv2.transform(np.array([[[0,0],[width,0],[0,height],
[width,height]]], dtype=float), matrix)[0]
origin = np.floor(corners.min(axis=0))
size = np.ceil(corners.max(axis=0)-origin).astype(int)
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,
max(15,int(dpi/5) | 1), 5)
horizontal, nh = _ruling(binary, True)
vertical, nv = _ruling(binary, False)
if not ((nh and _edge_connected_family(binary,horizontal,True))
or (nv and _edge_connected_family(binary,vertical,False))):
return np.zeros((height,width), bool), {}
thick = _thick_strokes(binary, dpi)
removed = np.zeros(binary.shape, bool)
protected = np.zeros(binary.shape, bool)
for is_horizontal, bands, count in ((True,horizontal,nh),(False,vertical,nv)):
length = max(25, round(5.2*dpi/25.4))
tolerance = max(3, round(.8*dpi/25.4) | 1)
transverse = np.ones((tolerance,1) if is_horizontal else (1,tolerance), np.uint8)
along = np.ones((1,length) if is_horizontal else (length,1), np.uint8)
# Small transverse tolerance follows local scan warp. The RGB model is
# still tested against actual pixels; it does not erase entire bands.
broadened = cv2.dilate(dark, transverse)
model = cv2.morphologyEx(broadened, cv2.MORPH_OPEN, along)
support = _maximum(model) > 9
if count:
support &= bands > 0
close = _maximum(np.abs(dark16-model.astype(np.int16))) < 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.
first, second, third = cv2.split(model)
classes = np.full(binary.shape, 2, np.uint8)
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)
for colour in range(4):
selected = sample & (classes == colour)
if selected.sum() < 100:
continue
yy, xx = np.nonzero(selected)
spread = np.ptp(yy if is_horizontal else xx)
axis_size = image.shape[0] if is_horizontal else image.shape[1]
margin_rule = (not is_horizontal and np.ptp(yy) > image.shape[0]*.7
and (np.median(xx) < image.shape[1]*.3 or np.median(xx) > image.shape[1]*.7))
if spread < axis_size*.35 and not margin_rule:
continue
# 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))
match = close & credible & (darkness > 9) & ~thick
removed |= support & match
protected |= support & ~match & (binary > 0)
inverse = cv2.invertAffineTransform(matrix)
result = cv2.warpAffine(removed.astype(np.uint8), inverse,
(width,height), flags=cv2.INTER_LINEAR) > 0
guard = cv2.warpAffine(protected.astype(np.uint8), inverse,
(width,height), flags=cv2.INTER_LINEAR) > 0
# Cover one-pixel deskew/interpolation remnants, except next to writing that
# failed the paper test. Confirmed pixels of the other orientation remain
# removable at line crossings.
kernel = np.ones((3,3), np.uint8)
result |= ((cv2.dilate(result.astype(np.uint8),kernel) > 0)
& ~(cv2.dilate(guard.astype(np.uint8),kernel) > 0))
return result, dict(ruling_cleanup=True, ruling_angle_deg=round(angle,3),
ruling_horizontal_lines=nh, ruling_vertical_lines=nv)