"""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), )