Venket / src /rendering /signature_preprocessor.py
venki101's picture
Add files using upload-large-folder tool
97b64b9 verified
Raw History Blame Contribute Delete
4.98 kB
"""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()