Cropping investigation
This commit is contained in:
1 parent
48602c24f1
commit
bd1964ffcd
10 files changed
+814
-24
No files matched your search
@@ -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)
|
||||
Reference in new issue
Block a user