File size: 4,979 Bytes
3f820d9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Signature cleaning: white-background removal and alpha-mask extraction.

Public handwritten signature datasets are typically flat scans: dark ink
strokes on an off-white/paper background, with no alpha channel. This module
converts such a scan into a clean RGBA "stamp" -- background fully
transparent, ink strokes opaque-to-translucent according to how dark the
original pixel was (preserving natural stroke texture: thin/faint pen
lift-offs stay translucent, heavy strokes stay opaque) -- ready to be
transformed and composited onto a document by :class:`~src.placement.placement_engine.PlacementEngine`
and :class:`~src.rendering.renderer.Renderer`.
"""

from __future__ import annotations

import cv2
import numpy as np

from src.utils.logging_utils import get_logger

logger = get_logger(__name__)

DEFAULT_INK_COLOR: tuple[int, int, int] = (18, 18, 24)


class SignaturePreprocessor:
    """Cleans a raw signature scan into a tightly-cropped, transparent RGBA stamp.

    Example:
        >>> pre = SignaturePreprocessor()
        >>> rgba = pre.process(raw_signature_array)
    """

    def __init__(
        self,
        ink_color: tuple[int, int, int] = DEFAULT_INK_COLOR,
        min_component_area: int = 4,
        pad_px: int = 6,
        alpha_gamma: float = 0.85,
    ) -> None:
        self.ink_color = ink_color
        self.min_component_area = min_component_area
        self.pad_px = pad_px
        self.alpha_gamma = alpha_gamma

    def process(self, raw_image: np.ndarray, ink_color: "tuple[int, int, int] | None" = None) -> np.ndarray:
        """Convert a raw signature scan into a clean, tightly-cropped RGBA stamp.

        Args:
            raw_image: The raw signature image as loaded (grayscale ``(H, W)``,
                RGB ``(H, W, 3)``, or RGBA ``(H, W, 4)`` uint8).
            ink_color: Optional override for the flat ink color used for the
                RGB channels (alpha still carries the natural stroke texture).

        Returns:
            An ``(H', W', 4)`` uint8 RGBA array, cropped tightly to the ink
            content with a small transparent pad.
        """
        gray, existing_alpha = self._to_gray(raw_image)
        gray = cv2.medianBlur(gray, 3)
        alpha = self._compute_alpha(gray)
        if existing_alpha is not None:
            alpha = np.minimum(alpha, existing_alpha)
        alpha = self._remove_speckles(alpha)
        rgba = self._compose_rgba(alpha, ink_color or self.ink_color)
        rgba = self._crop_to_content(rgba, pad=self.pad_px)
        return rgba

    @staticmethod
    def _to_gray(raw_image: np.ndarray) -> tuple[np.ndarray, "np.ndarray | None"]:
        if raw_image.ndim == 2:
            return raw_image.copy(), None
        if raw_image.shape[2] == 4:
            rgb = raw_image[:, :, :3]
            alpha = raw_image[:, :, 3]
            gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
            return gray, alpha
        gray = cv2.cvtColor(raw_image[:, :, :3], cv2.COLOR_RGB2GRAY)
        return gray, None

    @staticmethod
    def _compute_alpha(gray: np.ndarray) -> np.ndarray:
        gray_f = gray.astype(np.float64)
        background = float(np.percentile(gray_f, 92))
        dark_pixels = gray_f[gray_f < background - 8]
        dark_level = float(np.percentile(dark_pixels, 5)) if dark_pixels.size > 20 else max(background - 140, 0.0)
        denom = max(background - dark_level, 1e-3)

        raw = (background - gray_f) / denom
        raw = np.clip(raw, 0.0, 1.0)
        # Discard near-background noise (sub-threshold jitter from scanning artifacts).
        raw[raw < 0.06] = 0.0
        softened = raw**0.85
        return (softened * 255.0).astype(np.uint8)

    def _remove_speckles(self, alpha: np.ndarray) -> np.ndarray:
        binary = (alpha > 15).astype(np.uint8)
        n_labels, labels, stats, _ = cv2.connectedComponentsWithStats(binary, connectivity=8)
        if n_labels <= 1:
            return alpha
        cleaned = alpha.copy()
        for label in range(1, n_labels):
            area = stats[label, cv2.CC_STAT_AREA]
            if area < self.min_component_area:
                cleaned[labels == label] = 0
        return cleaned

    @staticmethod
    def _compose_rgba(alpha: np.ndarray, ink_color: tuple[int, int, int]) -> np.ndarray:
        h, w = alpha.shape
        rgb = np.empty((h, w, 3), dtype=np.uint8)
        rgb[:, :] = ink_color
        return np.dstack([rgb, alpha])

    @staticmethod
    def _crop_to_content(rgba: np.ndarray, pad: int) -> np.ndarray:
        alpha = rgba[:, :, 3]
        ys, xs = np.where(alpha > 4)
        if ys.size == 0:
            logger.warning("Signature preprocessing found no ink pixels; returning a 1x1 transparent stamp.")
            return np.zeros((1, 1, 4), dtype=np.uint8)
        y1, y2 = max(ys.min() - pad, 0), min(ys.max() + pad + 1, rgba.shape[0])
        x1, x2 = max(xs.min() - pad, 0), min(xs.max() + pad + 1, rgba.shape[1])
        return rgba[y1:y2, x1:x2].copy()