Files
Copies/copienator/ink_detection.py
T
2026-11-03 22:08:36 +01:00

599 lines
30 KiB
Python

"""Detect top and bottom content bounds in scanned student work.
Strong coloured strokes are never erased merely because they coincide with
paper ruling. The detector supports coloured handwriting and dark ruled scans;
it remains conservative for pencil-only scans and heavily saturated grids.
"""
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
@lru_cache(maxsize=8)
def _paper_kernel(sigma: float) -> np.ndarray:
"""Match OpenCV's uint8 Gaussian coefficients, including error diffusion.
See getGaussianKernelFixedPoint_ED in OpenCV's smooth.dispatch.cpp.
The 8-bit coefficients allow exact sums in the faster float filter path.
"""
size = round(sigma*6+1) | 1
kernel = cv2.getGaussianKernel(size, sigma).ravel()
fixed = np.zeros(size, np.float32)
error = 0.0
for i in range(size//2):
value = kernel[i]*256+error
weight = round(value)
error = value-weight
fixed[i] = fixed[-1-i] = weight
fixed[size//2] = 256-fixed.sum()
fixed /= 256
return fixed
def _paper_blur(gray: np.ndarray, sigma: float) -> np.ndarray:
kernel = _paper_kernel(sigma)
blurred = cv2.sepFilter2D(gray, cv2.CV_32F, kernel, kernel)
# GaussianBlur rounds positive half-integers upward, rather than to even.
np.add(blurred, .5, out=blurred)
np.floor(blurred, out=blurred)
return blurred.astype(np.uint8)
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.
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
dark = 255-gray
length = max(9, round(2.5*px))
tolerance = max(3, round(.6*px) | 1)
lines = []
for horizontal in (True, False):
broadened = cv2.dilate(dark, np.ones(
(tolerance, 1) if horizontal else (1, tolerance), np.uint8))
lines.append(cv2.morphologyEx(broadened, cv2.MORPH_OPEN, np.ones(
(1, length) if horizontal else (length, 1), np.uint8)))
residual = cv2.subtract(dark, np.maximum(*lines))
n, labels, stats, centers = cv2.connectedComponentsWithStats(
(residual > 35).astype(np.uint8), 8)
keep = np.zeros(n, bool)
xx = stats[1:, 0]
ww, hh, area = stats[1:, 2], stats[1:, 3], stats[1:, 4]
density = area / (ww*hh)
shortest, longest = np.minimum(ww, hh), np.maximum(ww, hh)
compact_ink = ((area >= .8*px*px) & (shortest >= .6*px)
& (density > .3) & (longest < 4*shortest))
# Ruling suppression can fragment faint pencil handwriting into sparse,
# elongated components. Admit those moderately more readily in the central
# 80% of the sheet. The outermost 9% deliberately uses a stricter filter:
# punched holes, torn binding edges, and page numbers usually occur there.
center_x = xx + ww/2
central = (center_x > width*.1) & (center_x < width*.9)
central_ink = (central & (area >= .55*px*px) & (shortest >= .45*px)
& (density > .18) & (longest < 6*shortest))
outer = (center_x < width*.09) | (center_x > width*.91)
outer_ink = (outer & (area >= 1.2*px*px) & (shortest >= .8*px)
& (density >= .4) & (longest < 3*shortest))
keep[1:] = np.where(outer, outer_ink, compact_ink | central_ink)
# Use side columns as a prior, then require repeated size and alignment. An
# isolated note in the same column remains eligible to protect the margin.
candidates = np.flatnonzero(
((xx < 15*px) | (xx+ww > width-15*px))
& (ww > px) & (ww < 9*px) & (hh > px) & (hh < 12*px)) + 1
if len(candidates) > 128:
# Bound the matching cost and retain ambiguous, very noisy margins.
return clean, 0, False
holes = set()
for i in candidates:
matches = []
for j in candidates:
if (abs(centers[i, 0]-centers[j, 0]) < 2*px
and .6 < stats[j, 2]/stats[i, 2] < 1.6
and .6 < stats[j, 3]/stats[i, 3] < 1.6):
matches.append(j)
if len(matches) >= 3 and np.ptp(centers[matches, 1]) > height*.35:
holes.update(matches)
keep[list(holes)] = False
refined = keep[labels]
# Directional opening also removes long fraction bars. Protect very dark,
# thick straight strokes independently, even if ruling crosses their ends.
long_strokes = cv2.morphologyEx((gray < 50).astype(np.uint8), cv2.MORPH_OPEN,
np.ones((1, max(9, round(width*.1))), np.uint8))
count, lab, st, _ = cv2.connectedComponentsWithStats(long_strokes, 8)
bars = np.zeros(count, bool)
bw, bh, ba = st[1:, 2], st[1:, 3], st[1:, 4]
bars[1:] = ((bw > width*.1) & (bh >= .3*px) & (bh < height*.015)
& (bw > 8*bh) & (ba/(bw*bh) > .5))
strong_ruling, strong_lines = _ruling((gray < 50).astype(np.uint8)*255, True)
if strong_lines:
# A family of equally dark parallel lines is paper, not fraction bars.
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):
return clean, 0, False
# Leave ordinary small crops to the more permissive detector. Keep a
# recovery neighbourhood around the refined bounds for broken/faint strokes.
top, bottom = max(0, int(ys.min()-2*px)), min(height, int(ys.max()+1+2*px))
result = clean.copy()
changed = False
if top-original.min() >= 30*px:
result[:top] = 0
changed = True
if original.max()+1-bottom >= 30*px:
result[bottom:] = 0
changed = True
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,
geometry: dict | None = None):
"""Clean confirmed dark ruling before it can seed whole pages.
Returns None for ordinary ink components or unconfirmed paper geometry.
The mask is transformed back to the original displayed pixel coordinates.
"""
height, width = gray.shape
# Only neutral darkness is relevant here: long blue equations are not
# evidence of dark paper. Broken ruling can form several medium-sized
# components instead of one page-spanning component. The broader threshold
# also admits faded gray grids; periodic ruling must still be confirmed.
_, _, stats, _ = cv2.connectedComponentsWithStats(
((gray < 160) & (chroma < 30)).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 > .1) >= 3):
return None, dict(paper_cleanup=False)
angle = _skew(gray, angle_step=.5)
matrix = cv2.getRotationMatrix2D((width/2, height/2), angle, 1)
corners = np.array([[0,0],[width,0],[0,height],[width,height]], dtype=float)
corners = cv2.transform(corners[None], 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)
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)
binary = cv2.adaptiveThreshold(normalized,255,cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
cv2.THRESH_BINARY_INV,block,9)
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 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)
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),
horizontal_lines=nh,vertical_lines=nv,
edge_artifacts=holes+repeated_holes,
large_blank_refinement=refined)
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, *, diagnostics: dict | None = None,
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
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.
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]
red, green, blue = cv2.split(rgb)
lowest = cv2.min(cv2.min(red, green), blue)
chroma = cv2.subtract(cv2.max(cv2.max(red, green), blue), lowest)
gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
darkness = 255-lowest
length = max(15, round(5.2*px)) | 1
horizontal = cv2.morphologyEx(darkness, cv2.MORPH_OPEN,
np.ones((1, length), np.uint8))
vertical = cv2.morphologyEx(darkness, cv2.MORPH_OPEN,
np.ones((length, 1), np.uint8))
residual = cv2.subtract(darkness, np.maximum(horizontal, vertical))
# The directional estimate alone cannot veto a strong fraction bar. Only
# independently confirmed paper geometry may suppress a strong candidate.
neutral_ink = gray < 95
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,
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.
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 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)
masks['weak_candidates'] = weak > 0
radius = max(1, round(2*px))
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2*radius+1, 2*radius+1))
extents = []
counts = []
same_thresholds = not np.any((chroma > 100) & (chroma <= 115) & ~neutral_ink)
for threshold in (100, 115):
if threshold == 115 and same_thresholds:
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) & ~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)
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
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 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))
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)))
bottom = min(h, int(np.ceil(max(e[1] for e in extents)+pad)))
uncertain = []
if len(extents) < 2:
uncertain.append('seed-threshold')
else:
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:
bottom = h
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)