Download src/rendering/signature_preprocessor.py from venki101/Venket: direct link, hf CLI and curl.
- Browser
- Download file 4.98 kB
-
https://huggingface.co/venki101/Venket/resolve/main/src/rendering/signature_preprocessor.py
- Command line
-
hf download hf://venki101/Venket/src/rendering/signature_preprocessor.py
-
curl -L -o signature_preprocessor.py https://huggingface.co/venki101/Venket/resolve/main/src/rendering/signature_preprocessor.py
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 | |
| 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 | |
| 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 | |
| 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]) | |
| 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() | |