File size: 10,254 Bytes
1a12965
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
"""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


@dataclass
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)

    @staticmethod
    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),
        )

    @staticmethod
    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,
        )