"""Geometry helper functions built on Shapely and NumPy. Centralizes every polygon/box computation used by placement and analysis so that the same, well-tested math backs both "where should the signature go" and "how well did it land" — avoiding subtle mismatches between the two. """ from __future__ import annotations from typing import Sequence import numpy as np from shapely.geometry import Polygon, box as shapely_box from shapely.validation import make_valid Box = tuple[float, float, float, float] # x1, y1, x2, y2 def box_to_polygon(box_coords: Box) -> Polygon: """Convert an ``(x1, y1, x2, y2)`` axis-aligned box into a Shapely polygon.""" x1, y1, x2, y2 = box_coords return shapely_box(min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2)) def points_to_polygon(points: Sequence[Sequence[float]]) -> Polygon: """Build a valid Shapely polygon from an ordered list of ``(x, y)`` points. Degenerate inputs (fewer than 3 points, zero area) are expanded into a minimal valid polygon by buffering, so downstream geometry ops never raise on edge cases like a single-pixel detected signature. """ pts = np.asarray(points, dtype=float) if len(pts) < 3: if len(pts) == 0: return Polygon() center = pts.mean(axis=0) return shapely_box(center[0] - 0.5, center[1] - 0.5, center[0] + 0.5, center[1] + 0.5) poly = Polygon(pts) if not poly.is_valid: poly = make_valid(poly) if poly.geom_type != "Polygon": poly = poly.convex_hull if poly.area <= 0: poly = poly.buffer(0.5) return poly def safe_area(poly: Polygon) -> float: """Return polygon area, guarding against ``None``/empty geometries.""" if poly is None or poly.is_empty: return 0.0 return float(poly.area) def intersection_area(poly_a: Polygon, poly_b: Polygon) -> float: """Return the intersection area of two polygons, 0 if either is empty/invalid.""" if poly_a is None or poly_b is None or poly_a.is_empty or poly_b.is_empty: return 0.0 if not poly_a.is_valid: poly_a = make_valid(poly_a) if not poly_b.is_valid: poly_b = make_valid(poly_b) try: return float(poly_a.intersection(poly_b).area) except Exception: return 0.0 def iou(poly_a: Polygon, poly_b: Polygon) -> float: """Intersection-over-Union of two polygons.""" inter = intersection_area(poly_a, poly_b) union = safe_area(poly_a) + safe_area(poly_b) - inter if union <= 0: return 0.0 return float(np.clip(inter / union, 0.0, 1.0)) def coverage_pct(target_box_poly: Polygon, signature_poly: Polygon) -> float: """``100 * intersection(target_box, polygon) / area(target_box)``.""" target_area = safe_area(target_box_poly) if target_area <= 0: return 0.0 inter = intersection_area(target_box_poly, signature_poly) return float(np.clip(100.0 * inter / target_area, 0.0, 100.0)) def containment_pct(target_box_poly: Polygon, signature_poly: Polygon) -> float: """``100 * intersection(target_box, polygon) / area(polygon)``.""" sig_area = safe_area(signature_poly) if sig_area <= 0: return 0.0 inter = intersection_area(target_box_poly, signature_poly) return float(np.clip(100.0 * inter / sig_area, 0.0, 100.0)) def centroid_of(poly: Polygon) -> tuple[float, float]: """Return the ``(x, y)`` centroid of a polygon, ``(nan, nan)`` if empty.""" if poly is None or poly.is_empty: return (float("nan"), float("nan")) c = poly.centroid return (float(c.x), float(c.y)) def box_center(box_coords: Box) -> tuple[float, float]: """Return the center point of an axis-aligned box.""" x1, y1, x2, y2 = box_coords return ((x1 + x2) / 2.0, (y1 + y2) / 2.0) def box_size(box_coords: Box) -> tuple[float, float]: """Return ``(width, height)`` of an axis-aligned box.""" x1, y1, x2, y2 = box_coords return (abs(x2 - x1), abs(y2 - y1)) def distance_between(p1: tuple[float, float], p2: tuple[float, float]) -> float: """Euclidean distance between two points, ``nan``-safe.""" if any(np.isnan(v) for v in (*p1, *p2)): return float("nan") return float(np.hypot(p1[0] - p2[0], p1[1] - p2[1])) def margin_utilization(box_coords: Box, signature_poly: Polygon) -> float: """Fraction of the box's available margin consumed by the signature (0..1+). 0 means the signature sits exactly at the box center with no spatial extent; 1 means it exactly fills the box's half-diagonal. Values above 1 indicate the signature protrudes outside the box. """ if signature_poly is None or signature_poly.is_empty: return 0.0 bx, by = box_center(box_coords) bw, bh = box_size(box_coords) half_diag = float(np.hypot(bw, bh) / 2.0) or 1e-6 minx, miny, maxx, maxy = signature_poly.bounds corners = [(minx, miny), (minx, maxy), (maxx, miny), (maxx, maxy)] max_dist = max(distance_between((bx, by), c) for c in corners) return float(max_dist / half_diag) def clamp01(value: float) -> float: """Clamp a float into ``[0, 1]``, mapping NaN to 0.""" if value is None or np.isnan(value): return 0.0 return float(np.clip(value, 0.0, 1.0)) def rotated_rect_polygon(center: tuple[float, float], size: tuple[float, float], angle_deg: float) -> Polygon: """Build a Shapely polygon for a rectangle rotated about its center (OpenCV convention).""" cx, cy = center w, h = size theta = np.deg2rad(angle_deg) dx = w / 2.0 dy = h / 2.0 corners = np.array([(-dx, -dy), (dx, -dy), (dx, dy), (-dx, dy)]) rot = np.array([[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]]) rotated = corners @ rot.T + np.array([cx, cy]) return Polygon(rotated)