Files
Copies/copienator/left_margin.py
T

160 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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.
# Start below it with clearance, including on later pages with repeated names.
BODY_TOP = .30
BODY_BOTTOM = .82
DISCARD_LEFT_FRACTION = .10
STANDARD_MARGIN_MM = 40
MIN_MARGIN_MM = STANDARD_MARGIN_MM / 2
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 body_edge(mask: np.ndarray) -> dict:
"""Estimate a left edge from occupied 4 mm bands within 30–82% height."""
height = mask.shape[0]
band_height = max(1, round(4 * ANALYSIS_DPI / 25.4))
bottom = round(BODY_BOTTOM * height)
starts = []
for y in range(round(BODY_TOP * 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:
starts.append(float(xs[0]) * 25.4 / ANALYSIS_DPI)
return {
'edge_mm': trimmed_left_edge(starts) if len(starts) >= 6 else None,
'bands': len(starts),
'band_starts_mm': starts,
}
def estimate_page(image: Image.Image, render_dpi: int) -> 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)))
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) -> dict:
"""Pool occupied bands per format, trim outliers, and crop at half the edge."""
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)
return page_estimates[index]
for indices in format_groups(images, reference_width):
# Spread samples through the copy rather than relying on the header page.
sample = sorted({indices[0], indices[min(1, len(indices) - 1)],
indices[len(indices) // 2], indices[-1]})
estimates = {index: page_estimate(index) for index in sample}
values = [item['edge_mm'] for item in estimates.values() if item['edge_mm'] is not None]
# Trim across the copy's format group, not separately on each page:
# one marginal comment must not veto the other pages' shared margin.
band_starts = [start for item in estimates.values() for start in item['band_starts_mm']]
status = 'insufficient-ink'
edge = trimmed_left_edge(band_starts)
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 edge / 2
# 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)
for index in indices:
starts[index] = start
groups.append({
'pages': [index + 1 for index in indices],
'status': status,
'edge_mm': edge,
'left_mm': round(left_mm, 1),
'bands': len(band_starts),
'discarded_bands': int(len(band_starts) * DISCARD_LEFT_FRACTION),
'samples': [{'page': index + 1, **item} for index, item in estimates.items()],
})
# Inspect actual neighbours, including pages outside the format samples.
# Blank/uncertain pages do not establish or extend a no-margin run.
no_margin_run = 0
no_margin_from_page = None
for index in range(len(images)):
edge = page_estimate(index)['edge_mm']
no_margin_run = no_margin_run + 1 if edge is not None and edge < MIN_MARGIN_MM else 0
if no_margin_run >= 2:
run_start = index - 1
no_margin_from_page = run_start + 1
starts[run_start:] = [0] * (len(images) - run_start)
break
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': 6, 'pages': pages, 'starts_px': starts, 'groups': groups,
'no_margin_from_page': no_margin_from_page,
'discard_left_fraction': DISCARD_LEFT_FRACTION, 'cut_fraction_of_margin': .5,
'standard_margin_mm': STANDARD_MARGIN_MM, 'minimum_margin_mm': MIN_MARGIN_MM,
'body_height_fraction': [BODY_TOP, BODY_BOTTOM]}