Download src/templates/box_detector.py from InternsProgressUpdate/Venket: direct link, hf CLI and curl.
- Browser
- Download file 4.91 kB
-
https://huggingface.co/InternsProgressUpdate/Venket/resolve/main/src/templates/box_detector.py
- Command line
-
hf download hf://InternsProgressUpdate/Venket/src/templates/box_detector.py
-
curl -L -o box_detector.py https://huggingface.co/InternsProgressUpdate/Venket/resolve/main/src/templates/box_detector.py
4.91 kB
| """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__) | |
| class DetectedLine: | |
| x: int | |
| y: int | |
| w: int | |
| h: int | |
| 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 | |