"""Mode 1: automatic signature-field detection via classical computer vision. Bank document signature fields are almost always denoted by a horizontal "sign here" rule (an underscore/line) with a caption below it ("Signature", "Authorized Signatory", ...). This detector isolates long, thin horizontal line segments via morphological opening, filters them by geometry, and places a signature box directly above each surviving line -- the same region a human would use to place a pen. This is a heuristic, not a learned layout model; it is intentionally used only as a *fallback* when no manual annotation file exists (Mode 2 is preferred whenever available, see :class:`~src.templates.template_loader.TemplateLoader`). """ from __future__ import annotations from dataclasses import dataclass import cv2 import numpy as np from src.utils.logging_utils import get_logger logger = get_logger(__name__) @dataclass class DetectedLine: x: int y: int w: int h: int @property def aspect_ratio(self) -> float: return self.w / max(self.h, 1) def _binarize(gray: np.ndarray) -> np.ndarray: """Return a binary ink mask (255 = ink) using adaptive thresholding.""" blurred = cv2.GaussianBlur(gray, (3, 3), 0) binary = cv2.adaptiveThreshold( blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 25, 10, ) return binary def _detect_horizontal_lines(binary: np.ndarray, min_width_frac: float = 0.08) -> list[DetectedLine]: """Detect long horizontal rule segments (signature/blank-field underscores).""" h, w = binary.shape[:2] kernel_w = max(int(w * min_width_frac), 15) horiz_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (kernel_w, 1)) opened = cv2.morphologyEx(binary, cv2.MORPH_OPEN, horiz_kernel, iterations=1) opened = cv2.dilate(opened, cv2.getStructuringElement(cv2.MORPH_RECT, (3, 1)), iterations=1) contours, _ = cv2.findContours(opened, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) lines: list[DetectedLine] = [] for c in contours: x, y, cw, ch = cv2.boundingRect(c) line = DetectedLine(x, y, cw, ch) if line.w < w * min_width_frac: continue if line.aspect_ratio < 6.0: continue if line.h > 12: continue lines.append(line) return lines def _merge_nearby_lines(lines: list[DetectedLine], y_tol: int = 8) -> list[DetectedLine]: """Merge line fragments that are on the same visual row into one segment.""" if not lines: return [] lines = sorted(lines, key=lambda l: (l.y, l.x)) merged: list[DetectedLine] = [lines[0]] for line in lines[1:]: last = merged[-1] if abs(line.y - last.y) <= y_tol and line.x <= last.x + last.w + 40: new_x2 = max(last.x + last.w, line.x + line.w) merged[-1] = DetectedLine(last.x, min(last.y, line.y), new_x2 - last.x, max(last.h, line.h)) else: merged.append(line) return merged def detect_signature_boxes( image_rgb: np.ndarray, max_boxes: int = 4, box_height_frac: float = 0.55, ) -> list[tuple[float, float, float, float]]: """Detect likely signature box regions above horizontal rule lines. Args: image_rgb: The template page as an ``(H, W, 3)`` uint8 array. max_boxes: Maximum number of candidate boxes to return (keeps the widest/most line-like candidates, which correlate with genuine signature/date fields rather than table gridlines). box_height_frac: Height of the returned box above the line, as a fraction of the line's width (wider lines get proportionally taller signature boxes, capped below). Returns: A list of ``(x1, y1, x2, y2)`` boxes in image pixel coordinates, sorted left-to-right, top-to-bottom. Empty if nothing was detected. """ h, w = image_rgb.shape[:2] gray = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2GRAY) binary = _binarize(gray) lines = _detect_horizontal_lines(binary) lines = _merge_nearby_lines(lines) if not lines: logger.warning("Automatic signature box detection found no candidate lines.") return [] lines = sorted(lines, key=lambda l: l.w, reverse=True)[: max_boxes * 2] boxes: list[tuple[float, float, float, float]] = [] for line in lines: box_h = min(max(int(line.w * box_height_frac * 0.25), 20), 80) x1 = float(max(line.x, 0)) x2 = float(min(line.x + line.w, w)) y2 = float(line.y) y1 = float(max(line.y - box_h, 0)) if (x2 - x1) < 20 or (y2 - y1) < 8: continue boxes.append((x1, y1, x2, y2)) boxes = boxes[:max_boxes] boxes.sort(key=lambda b: (round(b[1] / 50.0), b[0])) logger.info("Automatic detection found %d candidate signature box(es).", len(boxes)) return boxes