"""Post-render signature detection and geometry/coverage metric computation. Rather than searching the entire page (which would pick up printed field labels like "By:" or "Print Name:"), detection is scoped to the known signature field location -- exactly as a production pipeline would use the document's layout annotation to know where to look. A tight pass is tried first; if no ink is found (e.g. the signature was intentionally misplaced far from its box) the search window progressively expands, bounded by the page, before giving up. """ from __future__ import annotations from dataclasses import dataclass, field import cv2 import numpy as np from src.utils.geometry_utils import ( Box, box_center, box_to_polygon, centroid_of, containment_pct, coverage_pct, distance_between, iou, margin_utilization, points_to_polygon, safe_area, ) from src.utils.logging_utils import get_logger logger = get_logger(__name__) MIN_INK_PIXELS = 12 @dataclass class GeometryResult: """Full geometric + photometric characterization of the detected signature.""" found: bool signature_bbox_px: Box min_rotated_rect: dict signature_polygon_px: list[list[float]] convex_hull_px: list[list[float]] centroid_px: tuple[float, float] orientation_deg: float ink_pixels: int target_box: Box actual_box: Box search_box: Box box_coverage_pct: float containment_pct: float ink_fill_ratio: float polygon_overlap_proxy: float distance_from_box_center_px: float margin_utilization: float mean_dark_intensity: float gray_stddev: float signature_area_ratio: float whitespace_ratio: float def _expand_box(box: Box, canvas_size: tuple[int, int], factor: float, min_pad: float) -> Box: x1, y1, x2, y2 = box w, h = canvas_size bw, bh = (x2 - x1), (y2 - y1) pad_x = max(bw * (factor - 1.0) / 2.0, min_pad) pad_y = max(bh * (factor - 1.0) / 2.0, min_pad) return ( float(max(x1 - pad_x, 0)), float(max(y1 - pad_y, 0)), float(min(x2 + pad_x, w)), float(min(y2 + pad_y, h)), ) def _union_box(a: Box, b: Box) -> Box: return (min(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), max(a[3], b[3])) def _is_printed_rule_line(width: int, height: int, area: int) -> bool: """Heuristic filter for printed "sign here" guideline rules vs. handwritten ink. Printed rules are thin (<=3px tall), very wide relative to their height, and nearly solid (high fill ratio) -- unlike handwriting, which varies in stroke height and leaves gaps even in a single connected component. """ if height > 3: return False if width < 8 * max(height, 1): return False fill_ratio = area / max(width * height, 1) return fill_ratio > 0.6 def _ink_mask(gray_crop: np.ndarray) -> "np.ndarray | None": if gray_crop.size == 0: return None background = float(np.percentile(gray_crop, 90)) threshold = background - max(18.0, background * 0.15) binary = (gray_crop.astype(np.float32) < threshold).astype(np.uint8) * 255 if binary.sum() == 0: return binary kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2, 2)) opened = cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) n_labels, labels, stats, _ = cv2.connectedComponentsWithStats(opened, connectivity=8) cleaned = np.zeros_like(opened) for label in range(1, n_labels): area = stats[label, cv2.CC_STAT_AREA] w = stats[label, cv2.CC_STAT_WIDTH] h = stats[label, cv2.CC_STAT_HEIGHT] if area < 2: continue if _is_printed_rule_line(w, h, area): continue cleaned[labels == label] = 255 return cleaned class GeometryAnalyzer: """Detects the rendered signature within its known field and computes all geometry metrics. Example: >>> analyzer = GeometryAnalyzer() >>> result = analyzer.analyze(final_image, target_box=(100, 100, 300, 160), ... canvas_size=(1024, 559)) """ def analyze( self, image_rgb: np.ndarray, target_box: Box, canvas_size: tuple[int, int], placement_hint_bbox: "Box | None" = None, ) -> GeometryResult: """Detect the rendered signature and compute every geometry/coverage metric. Args: image_rgb: The fully rendered (and augmented) document, ``(H, W, 3)`` uint8. target_box: The annotated ``(x1, y1, x2, y2)`` signature field. canvas_size: ``(width, height)`` of the document. placement_hint_bbox: Optional pre-augmentation placement bounding box, used to widen the search if a tight pass finds nothing (e.g. after a large intentional misplacement). Returns: A :class:`GeometryResult` with detection status and every metric. """ gray = cv2.cvtColor(image_rgb, cv2.COLOR_RGB2GRAY) search_box = _expand_box(target_box, canvas_size, factor=1.15, min_pad=6) mask, offset = self._try_detect(gray, search_box) if mask is None or int((mask > 0).sum()) < MIN_INK_PIXELS: union = _union_box(target_box, placement_hint_bbox) if placement_hint_bbox else target_box search_box = _expand_box(union, canvas_size, factor=2.2, min_pad=50) mask, offset = self._try_detect(gray, search_box) if mask is None or int((mask > 0).sum()) < MIN_INK_PIXELS: logger.warning("No signature ink detected near target box %s.", target_box) return self._empty_result(target_box, search_box, canvas_size) return self._build_result(mask, offset, gray, target_box, search_box, canvas_size) @staticmethod def _try_detect(gray: np.ndarray, box: Box) -> tuple["np.ndarray | None", tuple[int, int]]: x1, y1, x2, y2 = (int(round(v)) for v in box) crop = gray[y1:y2, x1:x2] mask = _ink_mask(crop) return mask, (x1, y1) def _build_result( self, mask: np.ndarray, offset: tuple[int, int], full_gray: np.ndarray, target_box: Box, search_box: Box, canvas_size: tuple[int, int], ) -> GeometryResult: ox, oy = offset ys, xs = np.where(mask > 0) points = np.column_stack([xs + ox, ys + oy]).astype(np.float32) ink_pixels = int(len(points)) x1, y1 = float(points[:, 0].min()), float(points[:, 1].min()) x2, y2 = float(points[:, 0].max()) + 1.0, float(points[:, 1].max()) + 1.0 bbox: Box = (x1, y1, x2, y2) rect = cv2.minAreaRect(points) (rcx, rcy), (rw, rh), rangle = rect rotated_rect = { "center": [round(float(rcx), 2), round(float(rcy), 2)], "size": [round(float(rw), 2), round(float(rh), 2)], "angle_deg": round(float(rangle), 2), } contour_points = points.astype(np.int32) hull = cv2.convexHull(contour_points) hull_xy = [[int(p[0][0]), int(p[0][1])] for p in hull] mean_pt, eigvecs, eigvals = cv2.PCACompute2(points, mean=None) orientation_deg = float(np.degrees(np.arctan2(eigvecs[0][1], eigvecs[0][0]))) orientation_deg = ((orientation_deg + 90) % 180) - 90 centroid = (float(points[:, 0].mean()), float(points[:, 1].mean())) bbox_crop = full_gray[int(y1):int(y2), int(x1):int(x2)] mean_dark_intensity = float(bbox_crop[mask.astype(bool)].mean()) if bbox_crop.shape == mask.shape else float( full_gray[oy:oy + mask.shape[0], ox:ox + mask.shape[1]][mask.astype(bool)].mean() ) gray_stddev = float(bbox_crop.std()) if bbox_crop.size > 0 else 0.0 target_poly = box_to_polygon(target_box) signature_poly = points_to_polygon(hull_xy) bbox_area = max((x2 - x1) * (y2 - y1), 1.0) ink_fill_ratio = float(np.clip(ink_pixels / bbox_area, 0.0, 1.0)) canvas_area = max(canvas_size[0] * canvas_size[1], 1) signature_area_ratio = float(bbox_area / canvas_area) return GeometryResult( found=True, signature_bbox_px=bbox, min_rotated_rect=rotated_rect, signature_polygon_px=hull_xy, convex_hull_px=hull_xy, centroid_px=(round(centroid[0], 2), round(centroid[1], 2)), orientation_deg=round(orientation_deg, 2), ink_pixels=ink_pixels, target_box=target_box, actual_box=bbox, search_box=search_box, box_coverage_pct=round(coverage_pct(target_poly, signature_poly), 3), containment_pct=round(containment_pct(target_poly, signature_poly), 3), ink_fill_ratio=round(ink_fill_ratio, 4), polygon_overlap_proxy=round(iou(target_poly, signature_poly), 4), distance_from_box_center_px=round(distance_between(box_center(target_box), centroid), 2), margin_utilization=round(margin_utilization(target_box, signature_poly), 4), mean_dark_intensity=round(mean_dark_intensity, 2), gray_stddev=round(gray_stddev, 2), signature_area_ratio=round(signature_area_ratio, 5), whitespace_ratio=round(float(np.clip(1.0 - ink_fill_ratio, 0.0, 1.0)), 4), ) @staticmethod def _empty_result(target_box: Box, search_box: Box, canvas_size: tuple[int, int]) -> GeometryResult: return GeometryResult( found=False, signature_bbox_px=target_box, min_rotated_rect={"center": [0.0, 0.0], "size": [0.0, 0.0], "angle_deg": 0.0}, signature_polygon_px=[], convex_hull_px=[], centroid_px=(float("nan"), float("nan")), orientation_deg=0.0, ink_pixels=0, target_box=target_box, actual_box=target_box, search_box=search_box, box_coverage_pct=0.0, containment_pct=0.0, ink_fill_ratio=0.0, polygon_overlap_proxy=0.0, distance_from_box_center_px=float("nan"), margin_utilization=0.0, mean_dark_intensity=255.0, gray_stddev=0.0, signature_area_ratio=0.0, whitespace_ratio=1.0, )