Skip to content

Geometry API Reference

oriented_det.geometry

Geometry primitives and conversion helpers for oriented detection.

Polygon dataclass

Immutable polygon helper with a small but focused API.

Source code in oriented_det/geometry/poly.py
@dataclass(frozen=True)
class Polygon:
    """Immutable polygon helper with a small but focused API."""

    points: Tuple[Point, ...]

    def __init__(self, points: Iterable[Sequence[float]]):
        processed = _remove_redundant_points(_to_point(p) for p in points)
        if len(processed) < 3:
            raise ValueError("A polygon requires at least three distinct points.")
        object.__setattr__(self, "points", processed)
        if math.isclose(self.area, 0.0, abs_tol=1e-9):
            raise ValueError("Degenerate polygon detected (zero area).")

    def __iter__(self) -> Iterator[Point]:
        return iter(self.points)

    def __len__(self) -> int:
        return len(self.points)

    @property
    def signed_area(self) -> float:
        area = 0.0
        pts = self.points + (self.points[0],)
        for (x0, y0), (x1, y1) in zip(pts, pts[1:]):
            area += x0 * y1 - x1 * y0
        return area / 2.0

    @property
    def area(self) -> float:
        return abs(self.signed_area)

    @property
    def is_clockwise(self) -> bool:
        return self.signed_area < 0.0

    @property
    def bounds(self) -> Tuple[float, float, float, float]:
        xs, ys = zip(*self.points)
        return min(xs), min(ys), max(xs), max(ys)

    @property
    def centroid(self) -> Point:
        area = self.signed_area
        if math.isclose(area, 0.0, abs_tol=1e-12):
            raise ValueError("Cannot compute centroid of degenerate polygon.")
        cx = cy = 0.0
        pts = self.points + (self.points[0],)
        for (x0, y0), (x1, y1) in zip(pts, pts[1:]):
            factor = x0 * y1 - x1 * y0
            cx += (x0 + x1) * factor
            cy += (y0 + y1) * factor
        cx /= (6.0 * area)
        cy /= (6.0 * area)
        return (cx, cy)

    def translate(self, dx: float, dy: float) -> "Polygon":
        return Polygon((x + dx, y + dy) for x, y in self.points)

    def rotate(self, radians: float, origin: Point | None = None) -> "Polygon":
        if origin is None:
            origin = (0.0, 0.0)
        cos_a = math.cos(radians)
        sin_a = math.sin(radians)
        ox, oy = origin
        rotated = []
        for x, y in self.points:
            x0, y0 = x - ox, y - oy
            rotated.append((ox + x0 * cos_a - y0 * sin_a, oy + x0 * sin_a + y0 * cos_a))
        return Polygon(rotated)

    def ensure_orientation(self, clockwise: bool) -> "Polygon":
        if self.is_clockwise == clockwise:
            return self
        return Polygon(reversed(self.points))

    @classmethod
    def rectangle(cls, cx: float, cy: float, width: float, height: float) -> "Polygon":
        if width <= 0 or height <= 0:
            raise ValueError("Rectangle width and height must be positive.")
        w2, h2 = width / 2.0, height / 2.0
        pts = (
            (cx - w2, cy - h2),
            (cx + w2, cy - h2),
            (cx + w2, cy + h2),
            (cx - w2, cy + h2),
        )
        return cls(pts)

    def to_list(self) -> List[Point]:
        return list(self.points)

QBox dataclass

Simple quadrilateral box with geometry helpers.

Source code in oriented_det/geometry/qbox.py
@dataclass(frozen=True)
class QBox:
    """Simple quadrilateral box with geometry helpers."""

    points: Tuple[Point, Point, Point, Point]

    def __init__(self, points: Iterable[Sequence[float]]):
        object.__setattr__(self, "points", _normalize_points(points))

    def to_polygon(self) -> Polygon:
        return Polygon(self.points)

    @property
    def center(self) -> Point:
        xs, ys = zip(*self.points)
        return (sum(xs) / 4.0, sum(ys) / 4.0)

    @property
    def edges(self) -> Tuple[float, float, float, float]:
        lengths = []
        pts = self.points + (self.points[0],)
        for (x0, y0), (x1, y1) in zip(pts, pts[1:]):
            lengths.append(math.hypot(x1 - x0, y1 - y0))
        return tuple(lengths)  # type: ignore[return-value]

    @property
    def width(self) -> float:
        e0, _, e2, _ = self.edges
        return (e0 + e2) / 2.0

    @property
    def height(self) -> float:
        _, e1, _, e3 = self.edges
        return (e1 + e3) / 2.0

    @property
    def angle(self) -> float:
        (x0, y0), (x1, y1) = self.points[0], self.points[1]
        return math.atan2(y1 - y0, x1 - x0)

    def as_tuple(self) -> Tuple[Point, Point, Point, Point]:
        return self.points

RBox dataclass

Immutable representation of a rotated rectangle.

Source code in oriented_det/geometry/rbox.py
@dataclass(frozen=True)
class RBox:
    """Immutable representation of a rotated rectangle."""

    cx: float
    cy: float
    width: float
    height: float
    angle: float = 0.0

    def __post_init__(self):
        if self.width <= 0 or self.height <= 0:
            raise ValueError("RBox width and height must be positive.")
        object.__setattr__(self, "cx", float(self.cx))
        object.__setattr__(self, "cy", float(self.cy))
        object.__setattr__(self, "width", float(self.width))
        object.__setattr__(self, "height", float(self.height))
        object.__setattr__(self, "angle", _normalize_angle(float(self.angle)))

    @property
    def area(self) -> float:
        return self.width * self.height

    @property
    def aspect_ratio(self) -> float:
        return self.width / self.height

    def is_valid(self, min_size: float = 1.0) -> bool:
        """Check if this RBox is valid (not degenerated).

        Args:
            min_size: Minimum width and height in pixels (default: 1.0)

        Returns:
            True if RBox is valid, False if degenerated
        """
        try:
            # Check for minimum size (width and height must be >= min_size)
            if self.width < min_size or self.height < min_size:
                return False

            # Check for NaN or inf values
            if (not math.isfinite(self.cx) or not math.isfinite(self.cy) or
                not math.isfinite(self.width) or not math.isfinite(self.height) or
                not math.isfinite(self.angle)):
                return False

            # Check for positive area
            if self.area <= 0:
                return False

            # Try to compute corners to catch any other issues
            corners = self.corners()
            if len(corners) != 4:
                return False

            return True
        except (ValueError, AttributeError, TypeError):
            return False

    def with_angle(self, radians: float) -> "RBox":
        return RBox(self.cx, self.cy, self.width, self.height, radians)

    def corners(self) -> Tuple[Point, Point, Point, Point]:
        cos_a = math.cos(self.angle)
        sin_a = math.sin(self.angle)
        w2, h2 = self.width / 2.0, self.height / 2.0
        local = (
            (-w2, -h2),
            (w2, -h2),
            (w2, h2),
            (-w2, h2),
        )
        pts = []
        for x, y in local:
            rx = self.cx + x * cos_a - y * sin_a
            ry = self.cy + x * sin_a + y * cos_a
            pts.append((rx, ry))
        return tuple(pts)  # type: ignore[return-value]

    def to_polygon(self) -> Polygon:
        return Polygon(self.corners())

    def to_qbox(self) -> QBox:
        return QBox(self.corners())

    def axis_aligned_bounds(self) -> Tuple[float, float, float, float]:
        return self.to_polygon().bounds

    @classmethod
    def from_points(cls, points: Iterable[Sequence[float]]) -> "RBox":
        qbox = QBox(points)
        return cls.from_qbox(qbox)

    @classmethod
    def from_qbox(cls, qbox: QBox) -> "RBox":
        cx, cy = qbox.center
        width = qbox.width
        height = qbox.height
        angle = qbox.angle
        return cls(cx, cy, width, height, angle)

    @classmethod
    def from_polygon(cls, polygon: Polygon) -> "RBox":
        """Create RBox from a quadrilateral polygon.

        Args:
            polygon: Polygon with exactly 4 points

        Returns:
            RBox representing the polygon

        Raises:
            ValueError: If polygon doesn't have exactly 4 points
        """
        if len(polygon) != 4:
            raise ValueError("Only quadrilateral polygons can be converted to RBox.")
        return cls.from_points(polygon.points)

    @classmethod
    def from_xyxytheta(
        cls,
        x1: float,
        y1: float,
        x2: float,
        y2: float,
        theta: float,
    ) -> "RBox":
        """Create RBox from axis-aligned bounding box and rotation angle.

        This is useful when you have an axis-aligned box (x1, y1, x2, y2)
        and a rotation angle theta.

        Args:
            x1: X coordinate of the top-left corner of the axis-aligned box.
            y1: Y coordinate of the top-left corner of the axis-aligned box.
            x2: X coordinate of the bottom-right corner of the axis-aligned box.
            y2: Y coordinate of the bottom-right corner of the axis-aligned box.
            theta: Rotation angle in radians.

        Returns:
            RBox with center at the center of the bounding box,
            dimensions from the bounding box, and the specified angle

        Example:
            >>> # Axis-aligned box from (10, 20) to (50, 60) rotated 30°
            >>> rbox = RBox.from_xyxytheta(10, 20, 50, 60, math.radians(30))
            >>> # rbox.cx = 30, rbox.cy = 40, rbox.width = 40, rbox.height = 40
        """
        cx = (x1 + x2) / 2.0
        cy = (y1 + y2) / 2.0
        width = abs(x2 - x1)
        height = abs(y2 - y1)
        return cls(cx, cy, width, height, theta)

from_polygon(polygon) classmethod

Create RBox from a quadrilateral polygon.

Parameters:

Name Type Description Default
polygon Polygon

Polygon with exactly 4 points

required

Returns:

Type Description
'RBox'

RBox representing the polygon

Raises:

Type Description
ValueError

If polygon doesn't have exactly 4 points

Source code in oriented_det/geometry/rbox.py
@classmethod
def from_polygon(cls, polygon: Polygon) -> "RBox":
    """Create RBox from a quadrilateral polygon.

    Args:
        polygon: Polygon with exactly 4 points

    Returns:
        RBox representing the polygon

    Raises:
        ValueError: If polygon doesn't have exactly 4 points
    """
    if len(polygon) != 4:
        raise ValueError("Only quadrilateral polygons can be converted to RBox.")
    return cls.from_points(polygon.points)

from_xyxytheta(x1, y1, x2, y2, theta) classmethod

Create RBox from axis-aligned bounding box and rotation angle.

This is useful when you have an axis-aligned box (x1, y1, x2, y2) and a rotation angle theta.

Parameters:

Name Type Description Default
x1 float

X coordinate of the top-left corner of the axis-aligned box.

required
y1 float

Y coordinate of the top-left corner of the axis-aligned box.

required
x2 float

X coordinate of the bottom-right corner of the axis-aligned box.

required
y2 float

Y coordinate of the bottom-right corner of the axis-aligned box.

required
theta float

Rotation angle in radians.

required

Returns:

Type Description
'RBox'

RBox with center at the center of the bounding box,

'RBox'

dimensions from the bounding box, and the specified angle

Example
Axis-aligned box from (10, 20) to (50, 60) rotated 30°

rbox = RBox.from_xyxytheta(10, 20, 50, 60, math.radians(30))

rbox.cx = 30, rbox.cy = 40, rbox.width = 40, rbox.height = 40
Source code in oriented_det/geometry/rbox.py
@classmethod
def from_xyxytheta(
    cls,
    x1: float,
    y1: float,
    x2: float,
    y2: float,
    theta: float,
) -> "RBox":
    """Create RBox from axis-aligned bounding box and rotation angle.

    This is useful when you have an axis-aligned box (x1, y1, x2, y2)
    and a rotation angle theta.

    Args:
        x1: X coordinate of the top-left corner of the axis-aligned box.
        y1: Y coordinate of the top-left corner of the axis-aligned box.
        x2: X coordinate of the bottom-right corner of the axis-aligned box.
        y2: Y coordinate of the bottom-right corner of the axis-aligned box.
        theta: Rotation angle in radians.

    Returns:
        RBox with center at the center of the bounding box,
        dimensions from the bounding box, and the specified angle

    Example:
        >>> # Axis-aligned box from (10, 20) to (50, 60) rotated 30°
        >>> rbox = RBox.from_xyxytheta(10, 20, 50, 60, math.radians(30))
        >>> # rbox.cx = 30, rbox.cy = 40, rbox.width = 40, rbox.height = 40
    """
    cx = (x1 + x2) / 2.0
    cy = (y1 + y2) / 2.0
    width = abs(x2 - x1)
    height = abs(y2 - y1)
    return cls(cx, cy, width, height, theta)

is_valid(min_size=1.0)

Check if this RBox is valid (not degenerated).

Parameters:

Name Type Description Default
min_size float

Minimum width and height in pixels (default: 1.0)

1.0

Returns:

Type Description
bool

True if RBox is valid, False if degenerated

Source code in oriented_det/geometry/rbox.py
def is_valid(self, min_size: float = 1.0) -> bool:
    """Check if this RBox is valid (not degenerated).

    Args:
        min_size: Minimum width and height in pixels (default: 1.0)

    Returns:
        True if RBox is valid, False if degenerated
    """
    try:
        # Check for minimum size (width and height must be >= min_size)
        if self.width < min_size or self.height < min_size:
            return False

        # Check for NaN or inf values
        if (not math.isfinite(self.cx) or not math.isfinite(self.cy) or
            not math.isfinite(self.width) or not math.isfinite(self.height) or
            not math.isfinite(self.angle)):
            return False

        # Check for positive area
        if self.area <= 0:
            return False

        # Try to compute corners to catch any other issues
        corners = self.corners()
        if len(corners) != 4:
            return False

        return True
    except (ValueError, AttributeError, TypeError):
        return False

normalize_le90(rbox)

Normalize RBox angle to [-π/2, π/2) using le90 convention.

The "le90" (long edge 90°) convention ensures that: - The angle is in [-π/2, π/2) - Width is always >= height (swaps dimensions if needed) - This makes boxes with the same orientation have the same representation

The algorithm: 1. First normalize angle to [-π/2, π/2) by adding/subtracting integer multiples of π (without swapping width/height). 2. If width < height, swap dimensions and adjust angle by π/2. 3. Normalize angle again while preserving width >= height.

Parameters:

Name Type Description Default
rbox RBox

Input RBox to normalize

required

Returns:

Type Description
RBox

Normalized RBox with angle in [-π/2, π/2) and width >= height

Example

rbox = RBox(100, 100, 50, 100, math.pi) # width < height, angle = π normalized = normalize_le90(rbox)

normalized.width = 100, normalized.height = 50, normalized.angle ≈ -π/2

Source code in oriented_det/geometry/rbox.py
def normalize_le90(rbox: RBox) -> RBox:
    """Normalize RBox angle to [-π/2, π/2) using le90 convention.

    The "le90" (long edge 90°) convention ensures that:
    - The angle is in [-π/2, π/2)
    - Width is always >= height (swaps dimensions if needed)
    - This makes boxes with the same orientation have the same representation

    The algorithm:
    1. First normalize angle to [-π/2, π/2) by adding/subtracting integer multiples of π
       (without swapping width/height).
    2. If width < height, swap dimensions and adjust angle by π/2.
    3. Normalize angle again while preserving width >= height.

    Args:
        rbox: Input RBox to normalize

    Returns:
        Normalized RBox with angle in [-π/2, π/2) and width >= height

    Example:
        >>> rbox = RBox(100, 100, 50, 100, math.pi)  # width < height, angle = π
        >>> normalized = normalize_le90(rbox)
        >>> # normalized.width = 100, normalized.height = 50, normalized.angle ≈ -π/2
    """
    angle = rbox.angle
    width = rbox.width
    height = rbox.height

    # Step 1: Normalize angle to [-π/2, π/2) via π-period wrapping.
    # A π rotation does NOT swap rectangle side assignment.
    pi = math.pi
    pi_half = pi / 2
    k = math.floor((angle + pi_half) / pi)
    angle = angle - k * pi

    # Step 2: Ensure width >= height by rotating by π/2 if needed
    # When we swap w/h, we need to adjust the angle to represent the new long edge
    if width < height:
        width, height = height, width
        angle += pi_half
        # Normalize to [-π/2, π/2) while preserving width >= height.
        angle = math.fmod(angle + pi_half, pi) - pi_half

    return RBox(rbox.cx, rbox.cy, width, height, angle)

Important Notes

Angle Convention

The geometry module uses a full circle angle representation by default:

  • Range: Angles are normalized to (-π, π] interval
  • Direction: Positive angles rotate counter-clockwise (mathematical convention)
  • Origin: Top-left corner of the image (x-axis right, y-axis down)
  • Normalization: Angles are automatically normalized using atan2(sin, cos) to handle periodicity

le90 Convention

For DOTA compatibility, use the normalize_le90() function to convert to the "long edge 90°" convention:

  • Range: [-π/2, π/2)
  • Constraint: Width is always >= height (dimensions are swapped if needed)
  • Usage: Ensures boxes with the same orientation have the same representation
from oriented_det.geometry import RBox, normalize_le90
import math

rbox = RBox(100, 100, 50, 100, math.pi)  # width < height, angle = π
normalized = normalize_le90(rbox)
# normalized.width = 100, normalized.height = 50, normalized.angle ≈ -π/2

Round-trip Conversions

All conversions between Polygon, QBox, and RBox are designed to be lossless within floating-point precision:

from oriented_det.geometry import RBox, transforms
import math

rbox = RBox(10.0, -3.0, 5.0, 2.0, math.radians(30))
qbox = transforms.rbox_to_qbox(rbox)
recovered = transforms.qbox_to_rbox(qbox)

assert abs(recovered.cx - rbox.cx) < 1e-6
assert abs(recovered.angle - rbox.angle) < 1e-6

DOTA Polygon Format

When working with DOTA annotations, polygons use 8 coordinates:

x1 y1 x2 y2 x3 y3 x4 y4 class_name difficult

The loader automatically: 1. Parses polygons from annotation files 2. Converts to QBox (normalizes point order, ensures counter-clockwise) 3. Converts to RBox (computes center, dimensions, angle)

Examples

Creating Geometric Primitives

from oriented_det.geometry import Polygon, QBox, RBox
from math import radians

# Polygon from points
poly = Polygon([(0, 0), (2, 0), (2, 1), (0, 1)])

# RBox with rotation
rbox = RBox(cx=512, cy=384, width=120, height=48, angle=radians(20))

# QBox from 4 points
qbox = QBox([(0, 0), (4, 0), (4, 2), (0, 2)])

Geometric Operations

# Translation
translated = poly.translate(dx=10, dy=20)

# Rotation around origin
rotated = poly.rotate(radians=0.5, origin=(0, 0))

# Ensure counter-clockwise orientation
ccw_poly = poly.ensure_orientation(clockwise=False)

Conversions

from oriented_det.geometry import transforms

# RBox ↔ QBox
qbox = transforms.rbox_to_qbox(rbox)
rbox = transforms.qbox_to_rbox(qbox)

# RBox ↔ Polygon
polygon = transforms.rbox_to_polygon(rbox)
rbox = transforms.polygon_to_rbox(polygon)

# QBox ↔ Polygon
polygon = transforms.qbox_to_polygon(qbox)
qbox = transforms.polygon_to_qbox(polygon)

Transformations

from oriented_det.geometry.transforms import flip_horizontal, rotate_rbox

# Flip horizontally (mirror across vertical axis)
flipped = flip_horizontal(rbox, image_width=1024)

# Rotate around center
rotated = rotate_rbox(rbox, angle=radians(90), origin=(512, 512))