Download src/analysis/geometry_analyzer.py from InternsProgressUpdate/Venket: direct link, hf CLI and curl.
- Browser
- Download file 10.3 kB
-
https://huggingface.co/InternsProgressUpdate/Venket/resolve/main/src/analysis/geometry_analyzer.py
- Command line
-
hf download hf://InternsProgressUpdate/Venket/src/analysis/geometry_analyzer.py
-
curl -L -o geometry_analyzer.py https://huggingface.co/InternsProgressUpdate/Venket/resolve/main/src/analysis/geometry_analyzer.py
10.3 kB
| """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 | |
| 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) | |
| 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), | |
| ) | |
| 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, | |
| ) | |