From bd1964ffcd06eaab7a43eb328932d1f4a8e62e83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Miquel?= Date: Sun, 11 Oct 2026 10:12:59 +0200 Subject: [PATCH] Cropping investigation --- copienator/crop_diagnostics.py | 116 +++++++++++++++++++ copienator/crop_exercise_bottoms.py | 8 +- copienator/edge_marks.py | 95 ++++++++++++++++ copienator/ink_detection.py | 164 ++++++++++++++++++++++++--- copienator/paper_background.py | 11 +- copienator/paper_ruling.py | 165 ++++++++++++++++++++++++++++ tests/test_crop_diagnostics.py | 118 ++++++++++++++++++++ tests/test_crop_exercise_bottoms.py | 16 ++- tests/test_edge_marks.py | 58 ++++++++++ tests/test_paper_ruling.py | 87 +++++++++++++++ 10 files changed, 814 insertions(+), 24 deletions(-) create mode 100644 copienator/crop_diagnostics.py create mode 100644 copienator/edge_marks.py create mode 100644 copienator/paper_ruling.py create mode 100644 tests/test_crop_diagnostics.py create mode 100644 tests/test_edge_marks.py create mode 100644 tests/test_paper_ruling.py diff --git a/copienator/crop_diagnostics.py b/copienator/crop_diagnostics.py new file mode 100644 index 0000000..f03daa8 --- /dev/null +++ b/copienator/crop_diagnostics.py @@ -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) diff --git a/copienator/crop_exercise_bottoms.py b/copienator/crop_exercise_bottoms.py index 0d005eb..f8752f8 100644 --- a/copienator/crop_exercise_bottoms.py +++ b/copienator/crop_exercise_bottoms.py @@ -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 diff --git a/copienator/edge_marks.py b/copienator/edge_marks.py new file mode 100644 index 0000000..72025f7 --- /dev/null +++ b/copienator/edge_marks.py @@ -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 + diff --git a/copienator/ink_detection.py b/copienator/ink_detection.py index fbd74ac..9709784 100644 --- a/copienator/ink_detection.py +++ b/copienator/ink_detection.py @@ -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) diff --git a/copienator/paper_background.py b/copienator/paper_background.py index 0d43241..a271afa 100644 --- a/copienator/paper_background.py +++ b/copienator/paper_background.py @@ -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) diff --git a/copienator/paper_ruling.py b/copienator/paper_ruling.py new file mode 100644 index 0000000..fe4fce4 --- /dev/null +++ b/copienator/paper_ruling.py @@ -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) diff --git a/tests/test_crop_diagnostics.py b/tests/test_crop_diagnostics.py new file mode 100644 index 0000000..27ef6ab --- /dev/null +++ b/tests/test_crop_diagnostics.py @@ -0,0 +1,118 @@ +import json +import tempfile +import unittest +from pathlib import Path + +import cv2 +import numpy as np +from PIL import Image + +from copienator.crop_diagnostics import check_protected_regions, save_diagnostics +from copienator.ink_detection import detect_bounds + + +class CropDiagnosticsTests(unittest.TestCase): + def scan(self, *, dark=False, skew=False): + rgb = np.full((900,650,3),255,np.uint8) + if dark: + for y in range(15,900,20): + cv2.line(rgb,(0,y),(649,y),(65,65,65),1) + for x in range(10,650,20): + cv2.line(rgb,(x,0),(x,899),(65,65,65),1) + cv2.putText(rgb,'answer',(100,400),cv2.FONT_HERSHEY_SIMPLEX,1,(20,50,180),2) + cv2.putText(rgb,'more',(100,560),cv2.FONT_HERSHEY_SIMPLEX,.8,(30,30,30),2) + if skew: + rgb = cv2.warpAffine(rgb,cv2.getRotationMatrix2D((325,450),2,1), + (650,900),borderValue=(255,255,255)) + return rgb + + def test_diagnostics_leave_decisions_and_json_contract_unchanged(self): + for dark,skew in [(False,False),(True,False),(True,True)]: + with self.subTest(dark=dark,skew=skew): + rgb = self.scan(dark=dark,skew=skew) + before = rgb.copy() + normal = detect_bounds(rgb,dpi=150) + debug = {'stale': 'discard this'} + observed = detect_bounds(rgb,dpi=150,diagnostics=debug) + self.assertEqual(normal,observed) + json.dumps(observed) + np.testing.assert_array_equal(rgb,before) + self.assertNotIn('stale',debug) + for name,mask in debug['masks'].items(): + self.assertEqual(mask.shape,rgb.shape[:2],name) + self.assertEqual(mask.dtype,np.bool_,name) + masks = debug['masks'] + self.assertFalse(np.any(masks['strong_seeds'] & ~masks['foreground'])) + self.assertFalse(np.any(masks['recovered_weak'] & masks['strong_seeds'])) + for item in debug['thresholds']: + for edge in ['top','bottom']: + for component in item['limiting_components'][edge]: + box = component['bbox_px'] + self.assertEqual(box[1] if edge=='top' else box[3], + item['extent'][0 if edge=='top' else 1]) + self.assertGreater(component['seed_pixels'],0) + + def test_blank_fallback_has_empty_foreground_and_explanation(self): + rgb = np.full((400,300,3),255,np.uint8) + debug = {} + result = detect_bounds(rgb,diagnostics=debug) + self.assertEqual((result['top_px'],result['bottom_px']),(0,400)) + self.assertFalse(debug['masks']['foreground'].any()) + self.assertEqual(debug['edges']['bottom']['reason'],'no-reliable-ink-seeds') + + def test_faint_unseeded_candidate_is_distinguished_from_recovered_ink(self): + rgb = self.scan() + cv2.putText(rgb,'weak',(100,800),cv2.FONT_HERSHEY_SIMPLEX,1,(130,160,205),2) + debug = {} + detect_bounds(rgb,dpi=150,diagnostics=debug) + self.assertTrue(debug['masks']['unretained_weak'][770:810,100:220].any()) + self.assertFalse(debug['masks']['foreground'][770:810,100:220].any()) + + def test_exported_masks_match_actual_pixels_and_report_is_serializable(self): + rgb = self.scan(dark=True,skew=True) + debug = {} + result = detect_bounds(rgb,dpi=150,diagnostics=debug) + with tempfile.TemporaryDirectory() as tmp: + files = save_diagnostics(rgb,result,debug,Path(tmp),width_mm=110,height_mm=152) + for name,path in files['masks'].items(): + with Image.open(Path(tmp)/path) as im: + np.testing.assert_array_equal(np.array(im).astype(bool),debug['masks'][name]) + report = json.loads((Path(tmp)/files['report']).read_text()) + self.assertEqual(report['detector'],result) + self.assertEqual(report['shape'],list(rgb.shape[:2])) + for path in files['overlays'].values(): + self.assertTrue((Path(tmp)/path).is_file()) + + +class ProtectedContentTests(unittest.TestCase): + def check(self,top,bottom,box): + return check_protected_regions([dict(id='ink',label='Real ink',bbox_mm=box)], + dict(top_px=top,bottom_px=bottom),width_mm=80,height_mm=100,shape=(1000,800))[0] + + def test_both_boundaries_can_clip_a_protected_region(self): + top = self.check(210,800,[10,20,50,30]) + self.assertFalse(top['passed']) + self.assertEqual(top['clipped_edges'],['top']) + self.assertAlmostEqual(top['clipped_area_fraction'],.1) + bottom = self.check(100,790,[10,70,50,80]) + self.assertFalse(bottom['passed']) + self.assertEqual(bottom['clipped_edges'],['bottom']) + self.assertAlmostEqual(bottom['clipped_area_fraction'],.1) + + def test_contact_at_boundary_and_short_page_physical_height(self): + self.assertTrue(self.check(200,300,[10,20,50,30])['passed']) + row = check_protected_regions([dict(id='end',label='End',bbox_mm=[2,17,20,19])], + dict(top_px=0,bottom_px=180),width_mm=40,height_mm=20,shape=(200,400))[0] + self.assertFalse(row['passed']) + self.assertAlmostEqual(row['clipped_area_fraction'],.5) + + def test_full_removal_and_invalid_ground_truth(self): + self.assertEqual(self.check(400,800,[10,20,50,30])['clipped_area_fraction'],1) + with self.assertRaises(ValueError): + self.check(0,1000,[10,20,90,30]) + with self.assertRaises(ValueError): + self.check(0,1000,[10,30,50,20]) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_crop_exercise_bottoms.py b/tests/test_crop_exercise_bottoms.py index 2088fa7..18647ba 100644 --- a/tests/test_crop_exercise_bottoms.py +++ b/tests/test_crop_exercise_bottoms.py @@ -20,7 +20,7 @@ class CropExerciseBottomsTests(unittest.TestCase): page = document.new_page(width=600, height=height) page.insert_text((80, text_y), "student answer", fontsize=16) if footer: - page.insert_text((20, height - 3), "Ex 2", fontsize=10) + page.insert_text((20, height - 15), "9/9", fontsize=10) document.save(path) return path @@ -33,7 +33,7 @@ class CropExerciseBottomsTests(unittest.TestCase): self.assertEqual(records[0]["status"], "skipped-short") self.assertFalse(destination.exists()) - def test_footer_fragment_is_ignored_for_a_large_bottom_crop(self): + def test_isolated_page_number_allows_a_large_bottom_crop(self): source = self.make_pdf("large.pdf", 400, 100) destination = self.root / "out" / source.name records = process_exercise_pdf( @@ -45,6 +45,18 @@ class CropExerciseBottomsTests(unittest.TestCase): self.assertLess(result[0].rect.height, 250) self.assertAlmostEqual(result[0].rect.width, 600) + def test_content_in_former_ignored_bottom_strip_is_retained(self): + source = self.make_pdf("bottom-content.pdf", 400, 100, footer=False) + with pymupdf.open(source) as doc: + doc[0].insert_text((20, 399), "Ex 2", fontsize=10) + doc.save(self.root / "bottom-label.pdf") + records = process_exercise_pdf( + self.root / "bottom-label.pdf", self.root / "retained.pdf", + self.review, 800, dpi=150 + ) + self.assertEqual(records[0]["status"], "unchanged-small-crop") + self.assertFalse((self.root / "retained.pdf").exists()) + def test_crop_smaller_than_four_lines_is_not_written(self): source = self.make_pdf("small.pdf", 400, 330, footer=False) destination = self.root / "out" / source.name diff --git a/tests/test_edge_marks.py b/tests/test_edge_marks.py new file mode 100644 index 0000000..551dbce --- /dev/null +++ b/tests/test_edge_marks.py @@ -0,0 +1,58 @@ +import unittest + +import cv2 +import numpy as np + +from copienator.ink_detection import detect_bounds + + +class EdgeMarksTests(unittest.TestCase): + def page(self, last_answer=1000): + rgb = np.full((2340,1654,3),255,np.uint8) + cv2.putText(rgb,'answer',(500,600),cv2.FONT_HERSHEY_SIMPLEX,1.5,(20,60,180),2) + cv2.putText(rgb,'more',(500,last_answer),cv2.FONT_HERSHEY_SIMPLEX,1.5,(20,60,180),2) + return rgb + + def test_isolated_footer_fraction_is_removed(self): + rgb=self.page() + cv2.putText(rgb,'9/9',(1400,2220),cv2.FONT_HERSHEY_SIMPLEX,1.2,(20,20,20),2) + debug={} + result=detect_bounds(rgb,diagnostics=debug) + self.assertLess(result['bottom_px'],1100) + self.assertTrue(debug['masks']['isolated_edge_marks'].any()) + self.assertFalse(debug['masks']['foreground'][2150:].any()) + self.assertFalse(debug['masks']['strong_seeds'][2150:].any()) + self.assertEqual(result,detect_bounds(rgb)) + + def test_close_footer_stays_with_answer(self): + rgb=self.page(last_answer=2170) + cv2.putText(rgb,'9/9',(1400,2220),cv2.FONT_HERSHEY_SIMPLEX,1.2,(20,20,20),2) + self.assertGreater(detect_bounds(rgb)['bottom_px'],2220) + + def test_central_sparse_ratio_stays(self): + rgb=self.page() + cv2.putText(rgb,'9/9',(760,2220),cv2.FONT_HERSHEY_SIMPLEX,1.2,(20,20,20),2) + self.assertGreater(detect_bounds(rgb)['bottom_px'],2220) + + def test_isolated_bare_border_digit_needs_no_recognition(self): + rgb=self.page() + cv2.putText(rgb,'7',(1400,2240),cv2.FONT_HERSHEY_SIMPLEX,1.2,(20,20,20),2) + self.assertLess(detect_bounds(rgb)['bottom_px'],1100) + + def test_underlined_question_at_upper_edge_stays(self): + rgb=self.page() + cv2.putText(rgb,'16',(310,85),cv2.FONT_HERSHEY_SIMPLEX,1.2,(20,20,20),2) + cv2.line(rgb,(310,90),(355,90),(20,20,20),2) + self.assertLess(detect_bounds(rgb)['top_px'],60) + + def test_name_in_other_margin_blocks_header_removal(self): + rgb=self.page() + cv2.circle(rgb,(90,55),22,(20,20,20),2) + cv2.putText(rgb,'1',(81,64),cv2.FONT_HERSHEY_SIMPLEX,.7,(20,20,20),1) + cv2.putText(rgb,'Name',(1350,120),cv2.FONT_HERSHEY_SIMPLEX,1,(20,20,20),2) + result=detect_bounds(rgb) + self.assertEqual(result['top_px'],0) + + +if __name__=='__main__': + unittest.main() diff --git a/tests/test_paper_ruling.py b/tests/test_paper_ruling.py new file mode 100644 index 0000000..7e1ae0c --- /dev/null +++ b/tests/test_paper_ruling.py @@ -0,0 +1,87 @@ +import unittest +from unittest.mock import patch + +import cv2 +import numpy as np + +from copienator.ink_detection import detect_bounds + + +class PaperRulingTests(unittest.TestCase): + def scan(self, colour, *, warp=False): + image = np.full((1200, 850, 3), 255, np.uint8) + for y in range(20, 1200, 20): + cv2.line(image, (0, y), (849, y), colour, 1) + for x in range(10, 850, 20): + cv2.line(image, (x, 0), (x, 1199), colour, 1) + cv2.line(image, (80, 0), (80, 1199), (210, 70, 80), 2) + cv2.putText(image, 'answer', (120, 410), cv2.FONT_HERSHEY_SIMPLEX, + 1, (20, 60, 180), 2) + cv2.putText(image, 'more', (120, 710), cv2.FONT_HERSHEY_SIMPLEX, + 1, (20, 60, 180), 2) + if warp: + yy, xx = np.indices(image.shape[:2], dtype=np.float32) + image = cv2.remap(image, xx+2*np.sin(yy/95), yy, + cv2.INTER_LINEAR, borderValue=(255,255,255)) + return image + + def test_default_path_does_not_run_experimental_colour_model(self): + image = self.scan((70, 110, 225)) + with patch('copienator.ink_detection.ruling_suppression', + side_effect=AssertionError('Expensive colour model invoked')): + result = detect_bounds(image, dpi=150) + self.assertLess(result['top_px'], 410) + self.assertGreater(result['bottom_px'], 710) + self.assertFalse(result.get('ruling_cleanup', False)) + + def test_saturated_coloured_grid_and_margin_do_not_seed_blank_edges(self): + for colour in [(65, 65, 65), (70, 110, 225), (155, 170, 240)]: + for warp in [False, True]: + with self.subTest(colour=colour, warp=warp): + image = self.scan(colour, warp=warp) + debug = {} + result = detect_bounds(image, dpi=150, colour_cleanup=True, diagnostics=debug) + self.assertGreater(result['top_px'], 280) + self.assertLess(result['bottom_px'], 810) + self.assertTrue(result['ruling_cleanup']) + self.assertTrue(debug['masks']['paper_suppressed'].any()) + self.assertFalse(debug['masks']['foreground'][:250].any()) + + def test_writing_crossing_ruling_keeps_both_outer_bounds(self): + image = self.scan((70, 110, 225)) + cv2.putText(image, 'top', (120, 200), cv2.FONT_HERSHEY_SIMPLEX, + .7, (190, 20, 20), 2) + cv2.putText(image, 'bottom', (120, 1000), cv2.FONT_HERSHEY_SIMPLEX, + .7, (20, 20, 20), 2) + result = detect_bounds(image, dpi=150, colour_cleanup=True) + self.assertLess(result['top_px'], 180) + self.assertGreater(result['bottom_px'], 1000) + + def test_isolated_bars_and_axes_are_not_a_ruling_colour_family(self): + for colour in [(190, 20, 20), (20, 20, 20), (20, 90, 190)]: + with self.subTest(colour=colour): + image = self.scan((70, 110, 225)) + cv2.line(image, (100, 1020), (700, 1020), colour, 3) + cv2.line(image, (425, 980), (425, 1120), colour, 3) + result = detect_bounds(image, dpi=150, colour_cleanup=True) + self.assertGreater(result['bottom_px'], 1120) + + def test_grid_colour_with_a_thicker_answer_bar_is_preserved(self): + colour = (70, 110, 225) + image = self.scan(colour) + cv2.line(image, (150, 1020), (650, 1020), colour, 5) + self.assertGreater(detect_bounds(image, dpi=150, colour_cleanup=True)['bottom_px'], 1020) + + def test_regular_fraction_bars_inside_an_answer_are_not_paper(self): + image = np.full((1200,850,3),255,np.uint8) + cv2.putText(image,'answer',(120,300),cv2.FONT_HERSHEY_SIMPLEX, + 1,(20,60,180),2) + for y in (400,550,700,850,1000): + cv2.line(image,(130,y),(700,y),(20,20,20),3) + result = detect_bounds(image,dpi=150, colour_cleanup=True) + self.assertGreater(result['bottom_px'],1000) + self.assertFalse(result.get('ruling_cleanup',False)) + + +if __name__ == '__main__': + unittest.main()