"""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 MARGIN_CLEARANCE_MM = 20 MIN_MARGIN_MM = MARGIN_CLEARANCE_MM 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 and use the larger half-margin or 20 mm-clearance cut.""" 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 max(edge / 2, edge - MARGIN_CLEARANCE_MM)) # 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': 8, 'pages': pages, 'starts_px': starts, 'groups': groups, 'no_margin_from_page': no_margin_from_page, '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, 'body_height_fraction': [BODY_TOP, BODY_BOTTOM]}