Files

245 lines
12 KiB
Python

"""Conservative body-text margin suggestions for the cutleft reviewer."""
from __future__ import annotations
import cv2
import numpy as np
from PIL import Image
from copienator.ink_detection import detect_bounds
ANALYSIS_DPI = 100
WIDTH_TOLERANCE = .08
# Copie01's name/class block reaches about 27% of the full page height.
# Fallback when name localization is unavailable. A localized first-page
# name/class bottom replaces this cutoff on all pages, including repeated names.
BODY_TOP = .30
BODY_BOTTOM = .82
LABEL_BOTTOM = .92
LABEL_LEFT_FRACTION = .05
COHERENT_MARGIN_TOLERANCE_MM = 10
LABEL_MARGIN_GAP_MM = 10
DISCARD_LEFT_FRACTION = .10
STANDARD_MARGIN_MM = 40
MARGIN_CLEARANCE_MM = 20
# Minimum usable margin is independent of the strip clearance.
MIN_MARGIN_MM = 33
STANDARD_LINE_HEIGHT_MM = 8
MAX_EXCEPTION_LINES = 4
def margins_with_fallback(pages: list[dict]) -> list[dict]:
"""Keep measurements intact; unknown pages borrow the copy's measured mean."""
known = [page['edge_mm'] for page in pages if page.get('edge_mm') is not None]
average = sum(known) / len(known) if known else None
return [{**page, 'effective_edge_mm': (
page['edge_mm'] if page.get('edge_mm') is not None else average
)} for page in pages]
def trimmed_left_edge(starts: list[float]) -> float | None:
"""Discard the leftmost 10% of occupied bands; retain the next minimum."""
if not starts:
return None
ordered = sorted(starts)
return round(ordered[int(len(ordered) * DISCARD_LEFT_FRACTION)], 1)
def handwriting_mask(rgb: np.ndarray) -> np.ndarray:
"""Intersect rognage's foreground with a directional ruling filter."""
px = ANALYSIS_DPI / 25.4
gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
darkness = 255 - rgb.min(axis=2)
length = round(5.2 * px) | 1
horizontal = cv2.morphologyEx(darkness, cv2.MORPH_OPEN, np.ones((1, length), np.uint8))
vertical = cv2.morphologyEx(darkness, cv2.MORPH_OPEN, np.ones((length, 1), np.uint8))
residual = cv2.subtract(darkness, np.maximum(horizontal, vertical))
binary = ((residual > 32) & (gray < 200)).astype(np.uint8)
count, labels, stats, _ = cv2.connectedComponentsWithStats(binary, 8)
keep = np.zeros(count, bool)
for index, (_x, _y, width, height, area) in enumerate(stats[1:], 1):
keep[index] = (area >= max(5, .35 * px * px) and height >= .5 * px
and width >= .25 * px and height <= 6 * width and width <= 15 * height)
diagnostics = {}
detect_bounds(rgb, ANALYSIS_DPI, diagnostics=diagnostics)
return keep[labels] & diagnostics['masks']['foreground']
def occupied_bands(mask: np.ndarray, bottom_fraction: float, top_fraction: float = BODY_TOP) -> list[dict]:
"""Ignore empty bands and writing starting beyond the page midpoint."""
height, width = mask.shape
band_height = max(1, round(4 * ANALYSIS_DPI / 25.4))
bottom = round(bottom_fraction * height)
starts = []
for y in range(round(top_fraction * height), bottom, band_height):
band = mask[y:min(y + band_height, bottom)]
xs = np.flatnonzero(band.sum(axis=0) >= 2)
if band.sum() >= 40 and len(xs) >= 12 and xs[0] <= width / 2:
starts.append({'start_mm': float(xs[0]) * 25.4 / ANALYSIS_DPI,
'top_mm': y * 25.4 / ANALYSIS_DPI,
'bottom_mm': min(y + band_height, bottom) * 25.4 / ANALYSIS_DPI})
return starts
def occupied_band_starts(mask: np.ndarray, bottom_fraction: float, top_fraction: float = BODY_TOP) -> list[float]:
return [band['start_mm'] for band in occupied_bands(mask, bottom_fraction, top_fraction)]
def short_margin_exception(estimate: dict, margin: float) -> bool:
"""One block of at most four 8 mm lines may depart from an established margin."""
bands = estimate.get('body_bands', [])
low = [band for band in bands if band['start_mm'] < MIN_MARGIN_MM]
if not low or low[-1]['bottom_mm'] - low[0]['top_mm'] > MAX_EXCEPTION_LINES * STANDARD_LINE_HEIGHT_MM:
return False
# Require a majority of occupied bands outside that entire block, and a
# consistent decile there. Coordinates retain blank gaps between bands.
rest = [band['start_mm'] for band in bands
if band['bottom_mm'] <= low[0]['top_mm'] or band['top_mm'] >= low[-1]['bottom_mm']]
return (len(rest) >= 6 and len(rest) > len(bands) / 2
and abs(trimmed_left_edge(rest) - margin) <= COHERENT_MARGIN_TOLERANCE_MM)
def body_edge(mask: np.ndarray, top_fraction: float = BODY_TOP) -> dict:
"""Collect body and label bands below the name or the fallback header cutoff."""
bands = occupied_bands(mask, BODY_BOTTOM, top_fraction)
starts = [band['start_mm'] for band in bands]
return {
'edge_mm': trimmed_left_edge(starts) if len(starts) >= 6 else None,
'bands': len(starts),
'body_bands': bands,
'band_starts_mm': starts,
'label_band_starts_mm': occupied_band_starts(mask, LABEL_BOTTOM, top_fraction),
}
def estimate_page(image: Image.Image, render_dpi: int, *, top_fraction: float = BODY_TOP) -> dict:
scale = ANALYSIS_DPI / render_dpi
small = image.convert('RGB').resize(
(max(1, round(image.width * scale)), max(1, round(image.height * scale))),
Image.Resampling.LANCZOS,
)
return body_edge(handwriting_mask(np.asarray(small)), top_fraction)
def format_groups(images: list[Image.Image], reference_width: float) -> list[list[int]]:
"""Group similar widths; keep secondary narrow subpages in separate groups."""
groups: list[list[int]] = []
for index, image in enumerate(images):
narrow = image.width < .8 * reference_width
for group in groups:
width = float(np.median([images[i].width for i in group]))
if (images[group[0]].width < .8 * reference_width) == narrow and abs(image.width - width) <= width * WIDTH_TOLERANCE:
group.append(index)
break
else:
groups.append([index])
return groups
def analyse_margins(images: list[Image.Image], render_dpi: int, reference_width: float,
*, name_bottom_height_fraction: float | None = None) -> dict:
"""Estimate body margins, then retain left labels supported across coherent pages."""
name_localized = (type(name_bottom_height_fraction) in (int, float)
and 0 < name_bottom_height_fraction <= 1)
analysis_top = float(name_bottom_height_fraction) if name_localized else BODY_TOP
starts = [0] * len(images)
groups = []
page_estimates = {}
def page_estimate(index):
if index not in page_estimates:
page_estimates[index] = (estimate_page(images[index], render_dpi, top_fraction=analysis_top)
if name_localized else estimate_page(images[index], render_dpi))
return page_estimates[index]
page_estimates = dict(enumerate(margins_with_fallback(
[page_estimate(index) for index in range(len(images))])))
for indices in format_groups(images, reference_width):
estimates = {index: page_estimate(index) for index in indices}
values = [item['effective_edge_mm'] for item in estimates.values()
if item['effective_edge_mm'] is not None]
band_starts = [start for item in estimates.values() for start in item['band_starts_mm']]
status = 'insufficient-ink'
edge = round(float(np.median(values)), 1) if values else None
left_mm = 0.0
if len(values) >= min(2, len(indices)):
status = 'near-edge' if edge < MIN_MARGIN_MM else 'margin'
left_mm = (0.0 if edge < MIN_MARGIN_MM else
max(edge / 2, edge - MARGIN_CLEARANCE_MM))
# Only independently coherent pages supply evidence for labels in the margin.
coherent = [index for index in indices
if edge is not None and page_estimate(index)['edge_mm'] is not None
and page_estimate(index)['edge_mm'] >= MIN_MARGIN_MM
and abs(page_estimate(index)['edge_mm'] - edge) <= COHERENT_MARGIN_TOLERANCE_MM]
label_starts = [start for index in coherent for start in
page_estimate(index).get('label_band_starts_mm',
page_estimate(index)['band_starts_mm'])]
label_edge = (round(sorted(label_starts)[int(len(label_starts) * LABEL_LEFT_FRACTION)], 1)
if label_starts else None)
labels_in_margin = (status == 'margin' and len(coherent) >= min(2, len(indices))
and label_edge is not None and edge - label_edge >= LABEL_MARGIN_GAP_MM)
body_left_mm = left_mm
if labels_in_margin:
status = 'labels-in-margin'
left_mm = 0.0
# Secondary subpages can start in the middle of an answer. Preserve
# their left edge even when the only visible writing is indented.
if images[indices[0]].width < .8 * reference_width:
status = 'narrow-page'
left_mm = 0.0
start = round(left_mm * render_dpi / 25.4)
previous_coherent = 0
for index in indices:
estimate = page_estimate(index)
effective_edge = estimate['effective_edge_mm']
low = effective_edge is not None and effective_edge < MIN_MARGIN_MM
tolerated = (status == 'margin' and low and previous_coherent >= 2
and short_margin_exception(estimate, edge))
starts[index] = 0 if low and not tolerated else start
if estimate['edge_mm'] is None and status not in {'narrow-page', 'labels-in-margin'}:
fallback_left = (max(effective_edge / 2, effective_edge - MARGIN_CLEARANCE_MM)
if effective_edge is not None and effective_edge >= MIN_MARGIN_MM else 0)
starts[index] = round(fallback_left * render_dpi / 25.4)
estimate['short_margin_exception'] = tolerated
estimate['decision'] = ('short-exception' if tolerated else
'page-near-edge' if low else status)
if index in coherent:
previous_coherent += 1
groups.append({
'pages': [index + 1 for index in indices],
'status': status,
'edge_mm': edge,
'left_mm': round(left_mm, 1),
'body_left_mm': round(body_left_mm, 1),
'labels_in_margin': labels_in_margin,
'coherent_pages': [index + 1 for index in coherent],
'label_edge_mm': label_edge,
'label_bands': len(label_starts),
'bands': len(band_starts),
'aggregation': 'median-of-page-deciles',
'samples': [{'page': index + 1, **item} for index, item in estimates.items()],
})
pages = []
for index in range(len(images)):
estimate = page_estimate(index)
edge = estimate['edge_mm']
pages.append({'page': index + 1, **estimate,
'status': 'unknown' if edge is None else
('low' if edge < MIN_MARGIN_MM else 'normal')})
return {'version': 14, 'pages': pages, 'starts_px': starts, 'groups': groups,
'no_margin_from_page': None,
'discard_left_fraction': DISCARD_LEFT_FRACTION, 'margin_clearance_mm': MARGIN_CLEARANCE_MM,
'cut_fraction_of_margin': .5, 'cut_rule': 'maximum',
'standard_margin_mm': STANDARD_MARGIN_MM, 'minimum_margin_mm': MIN_MARGIN_MM,
'name_bottom_height_fraction': name_bottom_height_fraction if name_localized else None,
'top_cutoff_source': 'localized-name' if name_localized else 'fallback',
'body_height_fraction': [analysis_top, BODY_BOTTOM],
'label_height_fraction': [analysis_top, LABEL_BOTTOM],
'label_left_fraction': LABEL_LEFT_FRACTION,
'coherent_margin_tolerance_mm': COHERENT_MARGIN_TOLERANCE_MM,
'label_margin_gap_mm': LABEL_MARGIN_GAP_MM,
'maximum_band_start_width_fraction': .5,
'standard_line_height_mm': STANDARD_LINE_HEIGHT_MM,
'max_exception_lines': MAX_EXCEPTION_LINES}