"""Shared geometric raster transforms used by both placement and augmentation.""" from __future__ import annotations import cv2 import numpy as np def rotate_and_scale_rgba(rgba: np.ndarray, angle_deg: float, scale: float = 1.0) -> np.ndarray: """Rotate (and optionally scale) an RGBA image about its own center. The output canvas automatically expands to tightly bound the rotated rectangle so no content is clipped; newly exposed corners are fully transparent. """ h, w = rgba.shape[:2] if h == 0 or w == 0: return rgba cx, cy = w / 2.0, h / 2.0 matrix = cv2.getRotationMatrix2D((cx, cy), angle_deg, scale) cos, sin = abs(matrix[0, 0]), abs(matrix[0, 1]) new_w = max(int(np.ceil(h * sin + w * cos)), 1) new_h = max(int(np.ceil(h * cos + w * sin)), 1) matrix[0, 2] += new_w / 2.0 - cx matrix[1, 2] += new_h / 2.0 - cy return cv2.warpAffine( rgba, matrix, (new_w, new_h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0, 0), ) def smooth_noise_field(shape: tuple[int, int], rng: np.random.Generator, blur_frac: float = 0.08) -> np.ndarray: """Generate a smooth, low-frequency noise field in ``[0, 1]`` of the given ``(h, w)`` shape. Built by upsampling coarse random noise, which is a fast and dependency-free stand-in for Perlin/simplex noise -- adequate for subtle shading effects like paper texture, discoloration, and ink fading. """ h, w = shape coarse_h = max(int(h * blur_frac), 4) coarse_w = max(int(w * blur_frac), 4) coarse = rng.uniform(0.0, 1.0, size=(coarse_h, coarse_w)).astype(np.float32) field = cv2.resize(coarse, (w, h), interpolation=cv2.INTER_CUBIC) field = cv2.GaussianBlur(field, (0, 0), sigmaX=max(w, h) * 0.01 + 1) field -= field.min() max_val = field.max() if max_val > 1e-6: field /= max_val return field