Files
2026-10-11 10:12:59 +02:00

96 lines
4.3 KiB
Python

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