File size: 7,712 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
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
"""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]