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