File size: 4,905 Bytes
97b64b9 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 | """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
|