"""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)