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