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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+151
-13
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user