"""Coordinate transformations — convert between provider space and canonical space. All transformations are explicit and documented. No implicit heavy conversion is allowed in serializers (see AGENTS.md rule §5). """ from __future__ import annotations from typing import TYPE_CHECKING if TYPE_CHECKING: from src.app.geometry.bbox import BBoxTuple from src.app.geometry.polygon import PolygonPoints def rescale_bbox(bbox: BBoxTuple, factor: float) -> BBoxTuple: """Scale a bbox by a multiplicative factor. Used when the provider received a resized image. factor > 1 = upscale, factor < 1 = downscale. """ if factor <= 0: raise ValueError(f"Scale factor must be > 0, got {factor}") return ( bbox[0] * factor, bbox[1] * factor, bbox[2] * factor, bbox[3] * factor, ) def rescale_polygon(polygon: PolygonPoints, factor: float) -> PolygonPoints: """Scale all polygon points by a multiplicative factor.""" if factor <= 0: raise ValueError(f"Scale factor must be > 0, got {factor}") return [(x * factor, y * factor) for x, y in polygon] def rescale_point(point: tuple[float, float], factor: float) -> tuple[float, float]: """Scale a single point by a factor.""" if factor <= 0: raise ValueError(f"Scale factor must be > 0, got {factor}") return (point[0] * factor, point[1] * factor) def clip_bbox_to_page( bbox: BBoxTuple, page_width: float, page_height: float ) -> BBoxTuple: """Clip a bbox so it fits within page boundaries. Clamps x, y to [0, page_width/height] and adjusts width/height. Returns a bbox with width/height >= 1 (avoids degenerate boxes). """ x, y, w, h = bbox # Clamp top-left cx = max(0.0, min(x, page_width - 1)) cy = max(0.0, min(y, page_height - 1)) # Clamp bottom-right cx2 = max(cx + 1, min(x + w, page_width)) cy2 = max(cy + 1, min(y + h, page_height)) return (cx, cy, cx2 - cx, cy2 - cy) def clip_polygon_to_page( polygon: PolygonPoints, page_width: float, page_height: float ) -> PolygonPoints: """Clamp all polygon points to page boundaries.""" return [ (max(0.0, min(x, page_width)), max(0.0, min(y, page_height))) for x, y in polygon ] def rotate_bbox_90( bbox: BBoxTuple, page_width: float, page_height: float, times: int = 1 ) -> BBoxTuple: """Rotate a bbox by 90° clockwise around the page center. *times* = number of 90° rotations (1, 2, 3). page_width/height are the dimensions BEFORE rotation. """ times = times % 4 x, y, w, h = bbox for _ in range(times): # 90° clockwise: (x, y) -> (page_h - y - h, x) new_x = page_height - y - h new_y = x x, y, w, h = new_x, new_y, h, w # Swap page dimensions for next iteration page_width, page_height = page_height, page_width return (x, y, w, h) def rotate_point_90( point: tuple[float, float], page_width: float, page_height: float, times: int = 1 ) -> tuple[float, float]: """Rotate a point by 90° clockwise around the page origin.""" times = times % 4 x, y = point for _ in range(times): x, y = page_height - y, x page_width, page_height = page_height, page_width return (x, y) def rotate_polygon_90( polygon: PolygonPoints, page_width: float, page_height: float, times: int = 1 ) -> PolygonPoints: """Rotate all polygon points by 90° clockwise.""" return [ rotate_point_90(p, page_width, page_height, times) for p in polygon ] def translate_bbox(bbox: BBoxTuple, dx: float, dy: float) -> BBoxTuple: """Translate a bbox by (dx, dy) pixels.""" return (bbox[0] + dx, bbox[1] + dy, bbox[2], bbox[3]) def translate_polygon(polygon: PolygonPoints, dx: float, dy: float) -> PolygonPoints: """Translate all polygon points by (dx, dy).""" return [(x + dx, y + dy) for x, y in polygon]