"""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.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): """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. """ 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] 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 _neutral_paper_foreground(gray: np.ndarray, chroma: np.ndarray, dpi: float): """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) 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 or nv): 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) clean, repeated_holes, refined = _large_blank_ink(deskewed, clean, dpi) 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) -> 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)))) return keep[labels] def detect_bounds(rgb: np.ndarray, dpi: float = 200, padding_mm: float = 6, min_crop_mm: float = 5) -> 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. """ 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)) # 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. neutral_ink = gray < 95 cleaned, paper_info = _neutral_paper_foreground(gray, chroma, dpi) if cleaned is not None: neutral_ink = cleaned weak = ((chroma > 60) | ((gray < 175) & (residual > 25))).astype(np.uint8) 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]) continue seeds = _seed_mask((chroma > threshold) | neutral_ink, px) counts.append(int(np.count_nonzero(seeds))) if not np.any(seeds): 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) 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)) extents.append((int(ys.min()), int(ys.max())+1)) 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 not extents: 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) 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')) return result