Venket / src /templates /box_detector.py
venki101's picture
Add files using upload-large-folder tool
97b64b9 verified
Raw History Blame Contribute Delete
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__)
@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