File size: 3,791 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 | """Composites the placed (and signature-augmented) RGBA signature layer onto the document.
Implements the blend modes and realism passes required by the spec: multiply
blend, darken blend, alpha blend, configurable ink opacity, feathering
(alpha-edge softening), a slight blur to match scan resolution, sensor/scan
noise, and anti-aliasing.
"""
from __future__ import annotations
from dataclasses import dataclass
import cv2
import numpy as np
from src.config.schema import RenderingConfig
from src.utils.logging_utils import get_logger
from src.utils.random_utils import SeedManager, resolve_scalar_or_range
logger = get_logger(__name__)
@dataclass
class RenderResult:
"""The blended document image plus the resolved rendering parameters."""
image: np.ndarray
blend_mode: str
ink_opacity: float
blur_sigma: float
feather_px: float
scanner_noise: float
class Renderer:
"""Blends a signature RGBA layer onto a document canvas with configurable realism.
Example:
>>> renderer = Renderer(cfg.rendering)
>>> result = renderer.blend(document_rgb, signature_layer_rgba, seed_manager)
"""
def __init__(self, config: RenderingConfig) -> None:
self.config = config
def blend(
self, document_rgb: np.ndarray, signature_layer_rgba: np.ndarray, seed_manager: SeedManager,
) -> RenderResult:
"""Blend the signature layer onto the document and return the composited image.
Args:
document_rgb: The scanned document canvas, ``(H, W, 3)`` uint8.
signature_layer_rgba: A full-canvas-sized RGBA layer (as produced
by :class:`~src.placement.placement_engine.PlacementEngine`,
after any signature-level augmentation), transparent outside
the signature.
seed_manager: Per-sample deterministic RNG provider.
Returns:
A :class:`RenderResult` with the composited uint8 RGB image and
the resolved rendering parameters (for metadata export).
"""
cfg = self.config
rng = seed_manager.numpy_rng("rendering")
ink_opacity = resolve_scalar_or_range(cfg.ink_opacity, rng)
blur_sigma = resolve_scalar_or_range(cfg.blur_sigma, rng)
feather_px = resolve_scalar_or_range(cfg.feather_px, rng)
scanner_noise = resolve_scalar_or_range(cfg.scanner_noise, rng)
layer = signature_layer_rgba.astype(np.float32)
if feather_px > 0:
layer[:, :, 3] = cv2.GaussianBlur(layer[:, :, 3], (0, 0), sigmaX=feather_px)
if blur_sigma > 0:
layer = cv2.GaussianBlur(layer, (0, 0), sigmaX=blur_sigma)
sig_rgb = layer[:, :, :3]
alpha = np.clip(layer[:, :, 3] / 255.0, 0.0, 1.0) * float(np.clip(ink_opacity, 0.0, 1.0))
alpha3 = alpha[:, :, None]
base = document_rgb.astype(np.float32)
if cfg.blend_mode == "multiply":
blended_pixel = base * (sig_rgb / 255.0)
elif cfg.blend_mode == "darken":
blended_pixel = np.minimum(base, sig_rgb)
else: # alpha
blended_pixel = sig_rgb
composited = base * (1 - alpha3) + blended_pixel * alpha3
if scanner_noise > 0:
noise = rng.normal(0.0, scanner_noise * 255.0, size=composited.shape).astype(np.float32)
composited += noise
if cfg.anti_alias:
composited = cv2.GaussianBlur(composited, (0, 0), sigmaX=0.3)
out = np.clip(composited, 0, 255).astype(np.uint8)
return RenderResult(
image=out, blend_mode=cfg.blend_mode, ink_opacity=round(ink_opacity, 4),
blur_sigma=round(blur_sigma, 4), feather_px=round(feather_px, 4),
scanner_noise=round(scanner_noise, 4),
)
|