Cropping investigation

This commit is contained in:
sebastien committed 2026-10-11 10:12:59 +02:00
1 parent 48602c24f1
commit bd1964ffcd
10 files changed
+814 -24

No files matched your search

+116
View File
@@ -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)