"""Places a preprocessed signature stamp inside (or intentionally near) a target signature box. Handles aspect-ratio-preserving scale-to-fit, rotation, anchor-based positioning, sub-pixel offsets, and controlled "misplacement" (an intentional, probability-gated extra translation used to generate hard/misaligned samples for benchmarking). """ from __future__ import annotations from dataclasses import dataclass import cv2 import numpy as np from src.config.schema import PlacementConfig from src.utils.geometry_utils import Box from src.utils.logging_utils import get_logger from src.utils.random_utils import SeedManager, resolve_scalar_or_range from src.utils.transform_utils import rotate_and_scale_rgba logger = get_logger(__name__) AnchorOffsetFn = "dict[str, callable]" MIN_SIGNATURE_HEIGHT_PX = 16.0 @dataclass class PlacementResult: """Everything downstream stages need to know about how the signature was placed.""" layer: np.ndarray # full-canvas-sized RGBA layer, transparent outside the signature target_box: Box intended_bbox: Box # the signature's transformed bounding box in canvas coordinates x_offset: float y_offset: float rotation_deg: float scale_factor: float anchor_point: str misplaced: bool misplacement_dx: float misplacement_dy: float def _anchor_top_left(anchor: str, box_w: float, box_h: float, sig_w: float, sig_h: float) -> tuple[float, float]: positions = { "center": ((box_w - sig_w) / 2.0, (box_h - sig_h) / 2.0), "top_left": (0.0, 0.0), "top_right": (box_w - sig_w, 0.0), "bottom_left": (0.0, box_h - sig_h), "bottom_right": (box_w - sig_w, box_h - sig_h), "top_center": ((box_w - sig_w) / 2.0, 0.0), "bottom_center": ((box_w - sig_w) / 2.0, box_h - sig_h), "left_center": (0.0, (box_h - sig_h) / 2.0), "right_center": (box_w - sig_w, (box_h - sig_h) / 2.0), } return positions.get(anchor, positions["center"]) class PlacementEngine: """Fits, transforms, and positions a signature stamp relative to a target box. Example: >>> engine = PlacementEngine(cfg.placement) >>> result = engine.place(signature_rgba, target_box=(100, 100, 300, 160), ... canvas_size=(1024, 559), seed_manager=sm) """ def __init__(self, config: PlacementConfig) -> None: self.config = config def place( self, signature_rgba: np.ndarray, target_box: Box, canvas_size: tuple[int, int], seed_manager: SeedManager, ) -> PlacementResult: """Compute the placed signature layer for one sample. Args: signature_rgba: The tightly-cropped RGBA signature stamp from :class:`~src.rendering.signature_preprocessor.SignaturePreprocessor`. target_box: The ``(x1, y1, x2, y2)`` target signature field, in canvas pixel coordinates. canvas_size: ``(width, height)`` of the full document canvas. seed_manager: Per-sample deterministic RNG provider. Returns: A :class:`PlacementResult` with the full-canvas RGBA layer ready for blending, plus every resolved placement parameter for metadata export. """ cfg = self.config rng = seed_manager.numpy_rng("placement") canvas_w, canvas_h = canvas_size rotation_deg = resolve_scalar_or_range(cfg.rotation_deg, rng) scale_factor = resolve_scalar_or_range(cfg.scale_factor, rng) x_offset = resolve_scalar_or_range(cfg.x_offset, rng) y_offset = resolve_scalar_or_range(cfg.y_offset, rng) x1, y1, x2, y2 = target_box box_w, box_h = (x2 - x1), (y2 - y1) margin_x, margin_y = box_w * cfg.margin, box_h * cfg.margin inner_x1, inner_y1 = x1 + margin_x, y1 + margin_y inner_w = max(box_w - 2 * margin_x, 1.0) inner_h = max(box_h - 2 * margin_y, 1.0) rotated = rotate_and_scale_rgba(signature_rgba, rotation_deg, 1.0) sig_h, sig_w = rotated.shape[:2] fit_scale = min(inner_w / max(sig_w, 1), inner_h / max(sig_h, 1)) final_scale = max(fit_scale * scale_factor, 1e-3) # Never let the signature shrink below a legible minimum height, even # inside a very short box (real "sign here" lines are often only # 12-20px tall at document resolution) -- a genuinely tiny box should # make the signature overflow it, not vanish into sub-pixel noise. min_height_scale = MIN_SIGNATURE_HEIGHT_PX / max(sig_h, 1) final_scale = max(final_scale, min_height_scale) placed = cv2.resize( rotated, (max(int(round(sig_w * final_scale)), 1), max(int(round(sig_h * final_scale)), 1)), interpolation=cv2.INTER_AREA if final_scale < 1.0 else cv2.INTER_CUBIC, ) placed_h, placed_w = placed.shape[:2] ax, ay = _anchor_top_left(cfg.anchor_point, inner_w, inner_h, placed_w, placed_h) base_x = inner_x1 + ax + x_offset base_y = inner_y1 + ay + y_offset misplaced = bool(rng.uniform(0.0, 1.0) < cfg.misplacement_probability) misplacement_dx = misplacement_dy = 0.0 if misplaced: strength = resolve_scalar_or_range(cfg.misplacement_strength, rng) max_shift = strength * max(box_w, box_h) angle = rng.uniform(0, 2 * np.pi) radius = rng.uniform(0.3, 1.0) * max_shift misplacement_dx = float(np.cos(angle) * radius) misplacement_dy = float(np.sin(angle) * radius) final_x = base_x + misplacement_dx final_y = base_y + misplacement_dy if not cfg.allow_partial_outside and not misplaced: final_x = float(np.clip(final_x, inner_x1, inner_x1 + max(inner_w - placed_w, 0))) final_y = float(np.clip(final_y, inner_y1, inner_y1 + max(inner_h - placed_h, 0))) # Hard safety clamp: never let the signature drift entirely off the physical page. final_x = float(np.clip(final_x, -placed_w * 0.6, canvas_w - placed_w * 0.4)) final_y = float(np.clip(final_y, -placed_h * 0.6, canvas_h - placed_h * 0.4)) layer = np.zeros((canvas_h, canvas_w, 4), dtype=np.uint8) self._paste(layer, placed, int(round(final_x)), int(round(final_y))) intended_bbox: Box = (final_x, final_y, final_x + placed_w, final_y + placed_h) return PlacementResult( layer=layer, target_box=target_box, intended_bbox=intended_bbox, x_offset=x_offset, y_offset=y_offset, rotation_deg=rotation_deg, scale_factor=scale_factor, anchor_point=cfg.anchor_point, misplaced=misplaced, misplacement_dx=misplacement_dx, misplacement_dy=misplacement_dy, ) @staticmethod def _paste(canvas: np.ndarray, sprite: np.ndarray, x: int, y: int) -> None: """Alpha-aware paste of ``sprite`` onto ``canvas`` at ``(x, y)``, clipped to bounds.""" canvas_h, canvas_w = canvas.shape[:2] sprite_h, sprite_w = sprite.shape[:2] dst_x1, dst_y1 = max(x, 0), max(y, 0) dst_x2, dst_y2 = min(x + sprite_w, canvas_w), min(y + sprite_h, canvas_h) if dst_x2 <= dst_x1 or dst_y2 <= dst_y1: logger.warning("Placed signature falls entirely outside the canvas; skipping paste.") return src_x1, src_y1 = dst_x1 - x, dst_y1 - y src_x2, src_y2 = src_x1 + (dst_x2 - dst_x1), src_y1 + (dst_y2 - dst_y1) canvas[dst_y1:dst_y2, dst_x1:dst_x2] = sprite[src_y1:src_y2, src_x1:src_x2]