"""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 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] for indices in format_groups(images, reference_width): estimates = {index: page_estimate(index) for index in indices} values = [item['edge_mm'] for item in estimates.values() if item['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) low = estimate['edge_mm'] is not None and estimate['edge_mm'] < 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 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': 13, '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}