Venket / src /placement /placement_engine.py
venki101's picture
Add files using upload-large-folder tool
97b64b9 verified
Raw History Blame Contribute Delete
7.71 kB
"""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]