Venket / src /utils /geometry_utils.py
venki101's picture
Add files using upload-large-folder tool
97b64b9 verified
Raw History Blame Contribute Delete
5.82 kB
"""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)