File size: 5,822 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 | """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)
|