Skip to content

Geometry

The geometry module provides a comprehensive set of tools for working with geometric shapes and entities. It follows an inheritance-based design, where more complex shapes are derived from simpler ones.

Core Concepts

The geometry module is organized into two main categories:

  • Continuous: Represents shapes that are defined by continuous mathematical functions, such as circles and ellipses.
  • Discrete: Represents shapes that are defined by a finite set of points, such as polygons and line segments.

Available Modules

Below is a list of available modules and their functionalities:

Base Geometry

GeometryEntity module which allows to define transformation and property shared by all type of geometry objects

GeometryEntity

Bases: ABC

GeometryEntity class which is the abstract base class for all geometry classes

Source code in otary/geometry/entity.py
class GeometryEntity(ABC):
    """GeometryEntity class which is the abstract base class for all geometry classes"""

    # --------------------------------- PROPERTIES ------------------------------------

    @property
    @abstractmethod
    def shapely_edges(self) -> GeometryCollection:
        """Representation of the geometric object in the shapely library
        as a geometrical object defined only as a curve with no area. Particularly
        useful to look for points intersections
        """

    @property
    @abstractmethod
    def shapely_surface(self) -> GeometryCollection:
        """Representation of the geometric object in the shapely library
        as a geometrical object with an area and a border. Particularly useful
        to check if two geometrical objects are contained within each other or not.
        """

    @property
    @abstractmethod
    def area(self) -> float:
        """Compute the area of the geometry entity

        Returns:
            float: area value
        """

    @property
    @abstractmethod
    def perimeter(self) -> float:
        """Compute the perimeter of the geometry entity

        Returns:
            float: perimeter value
        """

    @property
    @abstractmethod
    def centroid(self) -> NDArray:
        """Compute the centroid point which can be seen as the center of gravity of
        the shape

        Returns:
            NDArray: centroid point
        """

    @property
    @abstractmethod
    def xmax(self) -> float:
        """Get the maximum X coordinate of the geometry entity

        Returns:
            NDArray: 2D point
        """

    @property
    @abstractmethod
    def xmin(self) -> float:
        """Get the minimum X coordinate of the geometry entity

        Returns:
            NDArray: 2D point
        """

    @property
    @abstractmethod
    def ymax(self) -> float:
        """Get the maximum Y coordinate of the geometry entity

        Returns:
            NDArray: 2D point
        """

    @property
    @abstractmethod
    def ymin(self) -> float:
        """Get the minimum Y coordinate of the geometry entity

        Returns:
            NDArray: 2D point
        """

    # ---------------------------- MODIFICATION METHODS -------------------------------

    @abstractmethod
    def rotate(
        self,
        angle: float,
        is_degree: bool = False,
        is_clockwise: bool = True,
        pivot: Optional[NDArray] = None,
    ) -> Self:
        """Rotate the geometry entity object.
        A pivot point can be passed as an argument to rotate the object around the pivot

        Args:
            angle (float): rotation angle
            is_degree (bool, optional): whether the angle is in degree or radian.
                Defaults to False which means radians.
            is_clockwise (bool, optional): whether the rotation is clockwise or
                counter-clockwise. Defaults to True.
            pivot (NDArray, optional): pivot point.
                Defaults to None which means that by default the centroid point of
                the shape is taken as the pivot point.

        Returns:
            GeometryEntity: rotated geometry entity object.
        """

    @abstractmethod
    def shift(self, vector: NDArray) -> Self:
        """Shift the geometry entity by the vector direction

        Args:
            vector (NDArray): vector that describes the shift as a array with
                two elements. Example: [2, -8] which describes the
                vector [[0, 0], [2, -8]]. The vector can also be a vector of shape
                (2, 2) of the form [[2, 6], [1, 3]].

        Returns:
            GeometryEntity: shifted geometrical object
        """

    @abstractmethod
    def normalize(self, x: float, y: float) -> Self:
        """Normalize the geometry entity by dividing the points by a norm on the
        x and y coordinates.

        Args:
            x (float): x coordinate norm
            y (float): y coordinate norm

        Returns:
            GeometryEntity: normalized GeometryEntity
        """

    # ------------------------------- CLASSIC METHODS ---------------------------------

    @abstractmethod
    def copy(self) -> Self:
        """Create a copy of the geometry entity object

        Returns:
            GeometryEntity: copy of the geometry entity object
        """

    def intersection(self, other: GeometryEntity, only_points: bool = True) -> NDArray:
        """Compute the intersections between two geometric objects.
        If the only_points parameter is True, then we only consider intersection points
        as valid. We can not have another type of intersection.

        Args:
            other (GeometryEntity): other GeometryEntity object
            only_points (bool, optional): whether to consider only points.
                Defaults to True.

        Returns:
            NDArray: list of n points of shape (n, 2)
        """
        it = self.shapely_edges.intersection(other=other.shapely_edges)

        if isinstance(it, SPoint):  # only one intersection point
            return np.array([[it.x, it.y]])
        if isinstance(it, MultiPoint):  # several intersection points
            return np.asanyarray([[pt.x, pt.y] for pt in it.geoms])
        if isinstance(it, LineString) and not only_points:  # one intersection line
            return NotImplemented
        if isinstance(it, MultiLineString) and not only_points:  # multilines
            return NotImplemented
        if isinstance(it, GeometryCollection):  # lines and pts
            return NotImplemented

        return np.array([])

    @abstractmethod
    def enclosing_axis_aligned_bbox(self) -> Rectangle:
        """Compute the smallest area enclosing Axis-Aligned Bounding Box (AABB)
        See: https://docs.opencv.org/3.4/dd/d49/tutorial_py_contour_features.html

        Returns:
            Rectangle: Rectangle object
        """

    @abstractmethod
    def enclosing_oriented_bbox(self) -> Rectangle:
        """Compute the smallest area enclosing Oriented Bounding Box (OBB)
        See: https://docs.opencv.org/3.4/dd/d49/tutorial_py_contour_features.html

        Returns:
            Rectangle: Rectangle object
        """

    @abstractmethod
    def enclosing_convex_hull(self) -> Polygon:
        """Compute the smallest area enclosing Convex Hull
        See: https://docs.opencv.org/3.4/dd/d49/tutorial_py_contour_features.html

        Returns:
            Polygon: Polygon object
        """

area abstractmethod property

Compute the area of the geometry entity

Returns:

Name Type Description
float float

area value

centroid abstractmethod property

Compute the centroid point which can be seen as the center of gravity of the shape

Returns:

Name Type Description
NDArray NDArray

centroid point

perimeter abstractmethod property

Compute the perimeter of the geometry entity

Returns:

Name Type Description
float float

perimeter value

shapely_edges abstractmethod property

Representation of the geometric object in the shapely library as a geometrical object defined only as a curve with no area. Particularly useful to look for points intersections

shapely_surface abstractmethod property

Representation of the geometric object in the shapely library as a geometrical object with an area and a border. Particularly useful to check if two geometrical objects are contained within each other or not.

xmax abstractmethod property

Get the maximum X coordinate of the geometry entity

Returns:

Name Type Description
NDArray float

2D point

xmin abstractmethod property

Get the minimum X coordinate of the geometry entity

Returns:

Name Type Description
NDArray float

2D point

ymax abstractmethod property

Get the maximum Y coordinate of the geometry entity

Returns:

Name Type Description
NDArray float

2D point

ymin abstractmethod property

Get the minimum Y coordinate of the geometry entity

Returns:

Name Type Description
NDArray float

2D point

copy() abstractmethod

Create a copy of the geometry entity object

Returns:

Name Type Description
GeometryEntity Self

copy of the geometry entity object

Source code in otary/geometry/entity.py
@abstractmethod
def copy(self) -> Self:
    """Create a copy of the geometry entity object

    Returns:
        GeometryEntity: copy of the geometry entity object
    """

enclosing_axis_aligned_bbox() abstractmethod

Compute the smallest area enclosing Axis-Aligned Bounding Box (AABB) See: https://docs.opencv.org/3.4/dd/d49/tutorial_py_contour_features.html

Returns:

Name Type Description
Rectangle Rectangle

Rectangle object

Source code in otary/geometry/entity.py
@abstractmethod
def enclosing_axis_aligned_bbox(self) -> Rectangle:
    """Compute the smallest area enclosing Axis-Aligned Bounding Box (AABB)
    See: https://docs.opencv.org/3.4/dd/d49/tutorial_py_contour_features.html

    Returns:
        Rectangle: Rectangle object
    """

enclosing_convex_hull() abstractmethod

Compute the smallest area enclosing Convex Hull See: https://docs.opencv.org/3.4/dd/d49/tutorial_py_contour_features.html

Returns:

Name Type Description
Polygon Polygon

Polygon object

Source code in otary/geometry/entity.py
@abstractmethod
def enclosing_convex_hull(self) -> Polygon:
    """Compute the smallest area enclosing Convex Hull
    See: https://docs.opencv.org/3.4/dd/d49/tutorial_py_contour_features.html

    Returns:
        Polygon: Polygon object
    """

enclosing_oriented_bbox() abstractmethod

Compute the smallest area enclosing Oriented Bounding Box (OBB) See: https://docs.opencv.org/3.4/dd/d49/tutorial_py_contour_features.html

Returns:

Name Type Description
Rectangle Rectangle

Rectangle object

Source code in otary/geometry/entity.py
@abstractmethod
def enclosing_oriented_bbox(self) -> Rectangle:
    """Compute the smallest area enclosing Oriented Bounding Box (OBB)
    See: https://docs.opencv.org/3.4/dd/d49/tutorial_py_contour_features.html

    Returns:
        Rectangle: Rectangle object
    """

intersection(other, only_points=True)

Compute the intersections between two geometric objects. If the only_points parameter is True, then we only consider intersection points as valid. We can not have another type of intersection.

Parameters:

Name Type Description Default
other GeometryEntity

other GeometryEntity object

required
only_points bool

whether to consider only points. Defaults to True.

True

Returns:

Name Type Description
NDArray NDArray

list of n points of shape (n, 2)

Source code in otary/geometry/entity.py
def intersection(self, other: GeometryEntity, only_points: bool = True) -> NDArray:
    """Compute the intersections between two geometric objects.
    If the only_points parameter is True, then we only consider intersection points
    as valid. We can not have another type of intersection.

    Args:
        other (GeometryEntity): other GeometryEntity object
        only_points (bool, optional): whether to consider only points.
            Defaults to True.

    Returns:
        NDArray: list of n points of shape (n, 2)
    """
    it = self.shapely_edges.intersection(other=other.shapely_edges)

    if isinstance(it, SPoint):  # only one intersection point
        return np.array([[it.x, it.y]])
    if isinstance(it, MultiPoint):  # several intersection points
        return np.asanyarray([[pt.x, pt.y] for pt in it.geoms])
    if isinstance(it, LineString) and not only_points:  # one intersection line
        return NotImplemented
    if isinstance(it, MultiLineString) and not only_points:  # multilines
        return NotImplemented
    if isinstance(it, GeometryCollection):  # lines and pts
        return NotImplemented

    return np.array([])

normalize(x, y) abstractmethod

Normalize the geometry entity by dividing the points by a norm on the x and y coordinates.

Parameters:

Name Type Description Default
x float

x coordinate norm

required
y float

y coordinate norm

required

Returns:

Name Type Description
GeometryEntity Self

normalized GeometryEntity

Source code in otary/geometry/entity.py
@abstractmethod
def normalize(self, x: float, y: float) -> Self:
    """Normalize the geometry entity by dividing the points by a norm on the
    x and y coordinates.

    Args:
        x (float): x coordinate norm
        y (float): y coordinate norm

    Returns:
        GeometryEntity: normalized GeometryEntity
    """

rotate(angle, is_degree=False, is_clockwise=True, pivot=None) abstractmethod

Rotate the geometry entity object. A pivot point can be passed as an argument to rotate the object around the pivot

Parameters:

Name Type Description Default
angle float

rotation angle

required
is_degree bool

whether the angle is in degree or radian. Defaults to False which means radians.

False
is_clockwise bool

whether the rotation is clockwise or counter-clockwise. Defaults to True.

True
pivot NDArray

pivot point. Defaults to None which means that by default the centroid point of the shape is taken as the pivot point.

None

Returns:

Name Type Description
GeometryEntity Self

rotated geometry entity object.

Source code in otary/geometry/entity.py
@abstractmethod
def rotate(
    self,
    angle: float,
    is_degree: bool = False,
    is_clockwise: bool = True,
    pivot: Optional[NDArray] = None,
) -> Self:
    """Rotate the geometry entity object.
    A pivot point can be passed as an argument to rotate the object around the pivot

    Args:
        angle (float): rotation angle
        is_degree (bool, optional): whether the angle is in degree or radian.
            Defaults to False which means radians.
        is_clockwise (bool, optional): whether the rotation is clockwise or
            counter-clockwise. Defaults to True.
        pivot (NDArray, optional): pivot point.
            Defaults to None which means that by default the centroid point of
            the shape is taken as the pivot point.

    Returns:
        GeometryEntity: rotated geometry entity object.
    """

shift(vector) abstractmethod

Shift the geometry entity by the vector direction

Parameters:

Name Type Description Default
vector NDArray

vector that describes the shift as a array with two elements. Example: [2, -8] which describes the vector [[0, 0], [2, -8]]. The vector can also be a vector of shape (2, 2) of the form [[2, 6], [1, 3]].

required

Returns:

Name Type Description
GeometryEntity Self

shifted geometrical object

Source code in otary/geometry/entity.py
@abstractmethod
def shift(self, vector: NDArray) -> Self:
    """Shift the geometry entity by the vector direction

    Args:
        vector (NDArray): vector that describes the shift as a array with
            two elements. Example: [2, -8] which describes the
            vector [[0, 0], [2, -8]]. The vector can also be a vector of shape
            (2, 2) of the form [[2, 6], [1, 3]].

    Returns:
        GeometryEntity: shifted geometrical object
    """

Continuous Geometry

ContinuousGeometryEntity module class

ContinuousGeometryEntity

Bases: GeometryEntity, ABC

ContinuousGeometryEntity class which is the abstract base class for continuous or smooth geometry objects like circles, ellipse, etc...

Source code in otary/geometry/continuous/entity.py
class ContinuousGeometryEntity(GeometryEntity, ABC):
    """
    ContinuousGeometryEntity class which is the abstract base class for
    continuous or smooth geometry objects like circles, ellipse, etc...
    """

    DEFAULT_N_POLY_APPROX = 1000  # number of pts to use in polygonal approximation

    def __init__(self, n_points_polygonal_approx: int = DEFAULT_N_POLY_APPROX):
        """Initialize a ContinuousGeometryEntity object

        Args:
            n_points_polygonal_approx (int, optional): n points to be used in
                the polygonal approximation.
                Defaults to DEFAULT_N_POINTS_POLYGONAL_APPROX.
        """
        self._n_points_polygonal_approx = n_points_polygonal_approx
        # self._polyapprox = is defined in subclasses

    # --------------------------------- PROPERTIES ------------------------------------

    @property
    def n_points_polygonal_approx(self) -> int:
        """Get the number of points for the polygonal approximation.

        Returns:
            int: The number of points used in the polygonal approximation.
        """
        return self._n_points_polygonal_approx

    @n_points_polygonal_approx.setter
    def n_points_polygonal_approx(self, value):
        """
        Set the number of points for the polygonal approximation.
        This would also update the polygonal approximation of the geometry entity.

        Args:
            value (int): The number of points to be used in the polygonal approximation.
        """
        self._n_points_polygonal_approx = value
        self.update_polyapprox()

    @property
    def polyaprox(self) -> Polygon:
        """Generate a polygonal approximation of the continuous geometry entity.

        Beware: No setter is defined for this property as it is a read-only property.
        You can update the polygonal approximation using the method named
        `update_polyapprox`.

        Returns:
            Polygon: polygonal approximation of the continuous geometry entity
        """
        return self._polyapprox

    @abstractmethod
    def polygonal_approx(self, n_points: int, is_cast_int: bool) -> Polygon:
        """Generate a polygonal approximation of the continuous geometry entity

        Args:
            n_points (int): number of points that make up the polygonal
                approximation. The bigger the better to obtain more precise
                results in intersection or other similar computations.

        Returns:
            Polygon: polygonal approximation of the continuous geometry entity
        """

    @abstractmethod
    def curvature(self, point: NDArray) -> float:
        """Curvature at the point defined as parameter

        Args:
            point (NDArray): input point.

        Returns:
            float: _description_
        """

    @property
    def xmax(self) -> float:
        """Get the maximum X coordinate of the geometry entity

        Returns:
            np.ndarray: 2D point
        """
        return self.polyaprox.xmax

    @property
    def xmin(self) -> float:
        """Get the minimum X coordinate of the geometry entity

        Returns:
            np.ndarray: 2D point
        """
        return self.polyaprox.xmin

    @property
    def ymax(self) -> float:
        """Get the maximum Y coordinate of the geometry entity

        Returns:
            np.ndarray: 2D point
        """
        return self.polyaprox.ymax

    @property
    def ymin(self) -> float:
        """Get the minimum Y coordinate of the geometry entity

        Returns:
            np.ndarray: 2D point
        """
        return self.polyaprox.ymin

    # ---------------------------- MODIFICATION METHODS -------------------------------

    # done in the derived classes

    # ------------------------------- CLASSIC METHODS ---------------------------------

    def update_polyapprox(self) -> None:
        """Update the polygonal approximation of the continuous geometry entity"""
        # pylint: disable=attribute-defined-outside-init
        self._polyapprox = self.polygonal_approx(
            n_points=self.n_points_polygonal_approx, is_cast_int=False
        )

    def enclosing_axis_aligned_bbox(self) -> Rectangle:
        """Compute the smallest area enclosing Axis-Aligned Bounding Box (AABB)
        See: https://docs.opencv.org/3.4/dd/d49/tutorial_py_contour_features.html

        Returns:
            Rectangle: Rectangle object
        """
        topleft_x, topleft_y, width, height = cv2.boundingRect(
            array=self.polyaprox.asarray.astype(np.float32)
        )

        # pylint: disable=duplicate-code
        bbox = np.array(
            [
                [topleft_x, topleft_y],
                [topleft_x + width, topleft_y],
                [topleft_x + width, topleft_y + height],
                [topleft_x, topleft_y + height],
            ]
        )
        return Rectangle(bbox)

    def enclosing_oriented_bbox(self) -> Rectangle:
        """Compute the smallest area enclosing Oriented Bounding Box (OBB)
        See: https://docs.opencv.org/3.4/dd/d49/tutorial_py_contour_features.html

        Returns:
            Rectangle: Rectangle object
        """
        rect = cv2.minAreaRect(self.polyaprox.asarray.astype(np.float32))
        bbox = cv2.boxPoints(rect)
        return Rectangle(bbox)

    def enclosing_convex_hull(self) -> Polygon:
        """Compute the smallest area enclosing Convex Hull
        See: https://docs.opencv.org/3.4/dd/d49/tutorial_py_contour_features.html

        Returns:
            Polygon: Polygon object
        """

        convexhull = np.squeeze(cv2.convexHull(self.polyaprox.asarray))
        return Polygon(convexhull)

n_points_polygonal_approx property writable

Get the number of points for the polygonal approximation.

Returns:

Name Type Description
int int

The number of points used in the polygonal approximation.

polyaprox property

Generate a polygonal approximation of the continuous geometry entity.

Beware: No setter is defined for this property as it is a read-only property. You can update the polygonal approximation using the method named update_polyapprox.

Returns:

Name Type Description
Polygon Polygon

polygonal approximation of the continuous geometry entity

xmax property

Get the maximum X coordinate of the geometry entity

Returns:

Type Description
float

np.ndarray: 2D point

xmin property

Get the minimum X coordinate of the geometry entity

Returns:

Type Description
float

np.ndarray: 2D point

ymax property

Get the maximum Y coordinate of the geometry entity

Returns:

Type Description
float

np.ndarray: 2D point

ymin property

Get the minimum Y coordinate of the geometry entity

Returns:

Type Description
float

np.ndarray: 2D point

__init__(n_points_polygonal_approx=DEFAULT_N_POLY_APPROX)

Initialize a ContinuousGeometryEntity object

Parameters:

Name Type Description Default
n_points_polygonal_approx int

n points to be used in the polygonal approximation. Defaults to DEFAULT_N_POINTS_POLYGONAL_APPROX.

DEFAULT_N_POLY_APPROX
Source code in otary/geometry/continuous/entity.py
def __init__(self, n_points_polygonal_approx: int = DEFAULT_N_POLY_APPROX):
    """Initialize a ContinuousGeometryEntity object

    Args:
        n_points_polygonal_approx (int, optional): n points to be used in
            the polygonal approximation.
            Defaults to DEFAULT_N_POINTS_POLYGONAL_APPROX.
    """
    self._n_points_polygonal_approx = n_points_polygonal_approx

curvature(point) abstractmethod

Curvature at the point defined as parameter

Parameters:

Name Type Description Default
point NDArray

input point.

required

Returns:

Name Type Description
float float

description

Source code in otary/geometry/continuous/entity.py
@abstractmethod
def curvature(self, point: NDArray) -> float:
    """Curvature at the point defined as parameter

    Args:
        point (NDArray): input point.

    Returns:
        float: _description_
    """

enclosing_axis_aligned_bbox()

Compute the smallest area enclosing Axis-Aligned Bounding Box (AABB) See: https://docs.opencv.org/3.4/dd/d49/tutorial_py_contour_features.html

Returns:

Name Type Description
Rectangle Rectangle

Rectangle object

Source code in otary/geometry/continuous/entity.py
def enclosing_axis_aligned_bbox(self) -> Rectangle:
    """Compute the smallest area enclosing Axis-Aligned Bounding Box (AABB)
    See: https://docs.opencv.org/3.4/dd/d49/tutorial_py_contour_features.html

    Returns:
        Rectangle: Rectangle object
    """
    topleft_x, topleft_y, width, height = cv2.boundingRect(
        array=self.polyaprox.asarray.astype(np.float32)
    )

    # pylint: disable=duplicate-code
    bbox = np.array(
        [
            [topleft_x, topleft_y],
            [topleft_x + width, topleft_y],
            [topleft_x + width, topleft_y + height],
            [topleft_x, topleft_y + height],
        ]
    )
    return Rectangle(bbox)

enclosing_convex_hull()

Compute the smallest area enclosing Convex Hull See: https://docs.opencv.org/3.4/dd/d49/tutorial_py_contour_features.html

Returns:

Name Type Description
Polygon Polygon

Polygon object

Source code in otary/geometry/continuous/entity.py
def enclosing_convex_hull(self) -> Polygon:
    """Compute the smallest area enclosing Convex Hull
    See: https://docs.opencv.org/3.4/dd/d49/tutorial_py_contour_features.html

    Returns:
        Polygon: Polygon object
    """

    convexhull = np.squeeze(cv2.convexHull(self.polyaprox.asarray))
    return Polygon(convexhull)

enclosing_oriented_bbox()

Compute the smallest area enclosing Oriented Bounding Box (OBB) See: https://docs.opencv.org/3.4/dd/d49/tutorial_py_contour_features.html

Returns:

Name Type Description
Rectangle Rectangle

Rectangle object

Source code in otary/geometry/continuous/entity.py
def enclosing_oriented_bbox(self) -> Rectangle:
    """Compute the smallest area enclosing Oriented Bounding Box (OBB)
    See: https://docs.opencv.org/3.4/dd/d49/tutorial_py_contour_features.html

    Returns:
        Rectangle: Rectangle object
    """
    rect = cv2.minAreaRect(self.polyaprox.asarray.astype(np.float32))
    bbox = cv2.boxPoints(rect)
    return Rectangle(bbox)

polygonal_approx(n_points, is_cast_int) abstractmethod

Generate a polygonal approximation of the continuous geometry entity

Parameters:

Name Type Description Default
n_points int

number of points that make up the polygonal approximation. The bigger the better to obtain more precise results in intersection or other similar computations.

required

Returns:

Name Type Description
Polygon Polygon

polygonal approximation of the continuous geometry entity

Source code in otary/geometry/continuous/entity.py
@abstractmethod
def polygonal_approx(self, n_points: int, is_cast_int: bool) -> Polygon:
    """Generate a polygonal approximation of the continuous geometry entity

    Args:
        n_points (int): number of points that make up the polygonal
            approximation. The bigger the better to obtain more precise
            results in intersection or other similar computations.

    Returns:
        Polygon: polygonal approximation of the continuous geometry entity
    """

update_polyapprox()

Update the polygonal approximation of the continuous geometry entity

Source code in otary/geometry/continuous/entity.py
def update_polyapprox(self) -> None:
    """Update the polygonal approximation of the continuous geometry entity"""
    # pylint: disable=attribute-defined-outside-init
    self._polyapprox = self.polygonal_approx(
        n_points=self.n_points_polygonal_approx, is_cast_int=False
    )

Shape

Circle Geometric Object

Circle

Bases: Ellipse

Circle geometrical object

Source code in otary/geometry/continuous/shape/circle.py
class Circle(Ellipse):
    """Circle geometrical object"""

    def __init__(
        self,
        center: NDArray | list,
        radius: float,
        n_points_polygonal_approx: int = ContinuousGeometryEntity.DEFAULT_N_POLY_APPROX,
    ):
        """Initialize a Circle geometrical object

        Args:
            center (NDArray): center 2D point
            radius (float): radius value
            n_points_polygonal_approx (int, optional): number of points to be used in
                the polygonal approximation of the circle. Defaults to
                ContinuousGeometryEntity.DEFAULT_N_POINTS_POLYGONAL_APPROX.
        """
        super().__init__(
            foci1=center,
            foci2=center,
            semi_major_axis=radius,
            n_points_polygonal_approx=n_points_polygonal_approx,
        )
        self.center = np.asarray(center)
        self.radius = radius
        self.update_polyapprox()

    # --------------------------------- PROPERTIES ------------------------------------

    @property
    def perimeter(self) -> float:
        """Perimeter of the circle

        Returns:
            float: perimeter value
        """
        return 2 * math.pi * self.radius

    @property
    def centroid(self) -> NDArray:
        """Center of the circle

        Returns:
            float: center 2D point
        """
        return self.center

    @property
    def shapely_surface(self) -> SPolygon:
        """Returns the Shapely.Polygon as an surface representation of the Circle.
        See https://shapely.readthedocs.io/en/stable/reference/shapely.Polygon.html

        Returns:
            Polygon: shapely.Polygon object
        """
        return SPolygon(self.polyaprox.asarray, holes=None)

    @property
    def shapely_edges(self) -> LinearRing:
        """Returns the Shapely.LinearRing as a curve representation of the Circle.
        See https://shapely.readthedocs.io/en/stable/reference/shapely.LinearRing.html

        Returns:
            LinearRing: shapely.LinearRing object
        """
        return LinearRing(coordinates=self.polyaprox.asarray)

    def polygonal_approx(self, n_points: int, is_cast_int: bool = False) -> Polygon:
        """Generate a Polygon object that is an approximation of the circle
        as a discrete geometrical object made up of only points and segments.

        Args:
            n_points (int): number of points that make up the circle
                polygonal approximation
            is_cast_int (bool): whether to cast to int the points coordinates or
                not. Defaults to False

        Returns:
            Polygon: Polygon representing the circle as a succession of n points

        """
        points = []
        for theta in np.linspace(0, 2 * math.pi, n_points):
            x = self.center[0] + self.radius * math.cos(theta)
            y = self.center[1] + self.radius * math.sin(theta)
            points.append([x, y])

        poly = Polygon(points=np.asarray(points), is_cast_int=is_cast_int)
        return poly

    def curvature(self, point: Optional[NDArray] = None) -> float:
        """Curvature of circle is a constant and does not depend on a position of
        a point

        Returns:
            float: curvature value
        """
        return 1 / self.radius

    @property
    def xmax(self) -> float:
        """Get the maximum X coordinate of the geometry entity

        Returns:
            NDArray: 2D point
        """
        return self.center[0] + self.radius

    @property
    def xmin(self) -> float:
        """Get the minimum X coordinate of the geometry entity

        Returns:
            NDArray: 2D point
        """
        return self.center[0] - self.radius

    @property
    def ymax(self) -> float:
        """Get the maximum Y coordinate of the geometry entity

        Returns:
            NDArray: 2D point
        """
        return self.center[1] + self.radius

    @property
    def ymin(self) -> float:
        """Get the minimum Y coordinate of the geometry entity

        Returns:
            NDArray: 2D point
        """
        return self.center[1] - self.radius

    @property
    def is_circle(self) -> bool:
        """Check if the circle is a circle

        Returns:
            bool: True if circle else False
        """
        return True

    # ---------------------------- MODIFICATION METHODS -------------------------------

    def rotate(
        self,
        angle: float,
        is_degree: bool = False,
        is_clockwise: bool = True,
        pivot: Optional[NDArray] = None,
    ) -> Self:
        """Rotate the circle around a pivot point.

        Args:
            angle (float): angle by which to rotate the circle
            is_degree (bool, optional): whether the angle is in degrees.
                Defaults to False.
            is_clockwise (bool, optional): whether the rotation is clockwise.
                Defaults to True.
            pivot (Optional[NDArray], optional): pivot point around which to rotate.
                Defaults to None.

        Returns:
            Self: rotated circle object
        """
        if pivot is None:
            # If no pivot is given, the circle is rotated around its center
            # and thus is not modified
            return self

        self.center = rotate_2d_points(
            points=self.center,
            angle=angle,
            is_degree=is_degree,
            is_clockwise=is_clockwise,
            pivot=pivot,
        )
        self.update_polyapprox()
        return self

    def shift(self, vector: NDArray) -> Self:
        """Shift the circle by a given vector.

        Args:
            vector (NDArray): 2D vector by which to shift the circle

        Returns:
            Self: shifted circle object
        """
        vector = assert_transform_shift_vector(vector=vector)
        self.center += vector
        self.update_polyapprox()
        return self

    def normalize(self, x: float, y: float) -> Self:
        """Normalize the circle by dividing the points by a norm on the x and y
        coordinates. This does not change the circle radius.

        Args:
            x (float): x coordinate norm
            y (float): y coordinate norm

        Returns:
            Self: normalized circle object
        """
        self.center = self.center / np.array([x, y])
        self.update_polyapprox()
        return self

    # ------------------------------- CLASSIC METHODS ---------------------------------

    def copy(self) -> Self:
        """Copy the circle object

        Returns:
            Self: copied circle object
        """
        return type(self)(
            center=self.center,
            radius=self.radius,
            n_points_polygonal_approx=self.n_points_polygonal_approx,
        )

    def __str__(self) -> str:
        return f"Circle(center={self.center}, radius={self.radius})"

    def __repr__(self):
        return f"Circle(center={self.center}, radius={self.radius})"

centroid property

Center of the circle

Returns:

Name Type Description
float NDArray

center 2D point

is_circle property

Check if the circle is a circle

Returns:

Name Type Description
bool bool

True if circle else False

perimeter property

Perimeter of the circle

Returns:

Name Type Description
float float

perimeter value

shapely_edges property

Returns the Shapely.LinearRing as a curve representation of the Circle. See https://shapely.readthedocs.io/en/stable/reference/shapely.LinearRing.html

Returns:

Name Type Description
LinearRing LinearRing

shapely.LinearRing object

shapely_surface property

Returns the Shapely.Polygon as an surface representation of the Circle. See https://shapely.readthedocs.io/en/stable/reference/shapely.Polygon.html

Returns:

Name Type Description
Polygon Polygon

shapely.Polygon object

xmax property

Get the maximum X coordinate of the geometry entity

Returns:

Name Type Description
NDArray float

2D point

xmin property

Get the minimum X coordinate of the geometry entity

Returns:

Name Type Description
NDArray float

2D point

ymax property

Get the maximum Y coordinate of the geometry entity

Returns:

Name Type Description
NDArray float

2D point

ymin property

Get the minimum Y coordinate of the geometry entity

Returns:

Name Type Description
NDArray float

2D point

__init__(center, radius, n_points_polygonal_approx=ContinuousGeometryEntity.DEFAULT_N_POLY_APPROX)

Initialize a Circle geometrical object

Parameters:

Name Type Description Default
center NDArray

center 2D point

required
radius float

radius value

required
n_points_polygonal_approx int

number of points to be used in the polygonal approximation of the circle. Defaults to ContinuousGeometryEntity.DEFAULT_N_POINTS_POLYGONAL_APPROX.

DEFAULT_N_POLY_APPROX
Source code in otary/geometry/continuous/shape/circle.py
def __init__(
    self,
    center: NDArray | list,
    radius: float,
    n_points_polygonal_approx: int = ContinuousGeometryEntity.DEFAULT_N_POLY_APPROX,
):
    """Initialize a Circle geometrical object

    Args:
        center (NDArray): center 2D point
        radius (float): radius value
        n_points_polygonal_approx (int, optional): number of points to be used in
            the polygonal approximation of the circle. Defaults to
            ContinuousGeometryEntity.DEFAULT_N_POINTS_POLYGONAL_APPROX.
    """
    super().__init__(
        foci1=center,
        foci2=center,
        semi_major_axis=radius,
        n_points_polygonal_approx=n_points_polygonal_approx,
    )
    self.center = np.asarray(center)
    self.radius = radius
    self.update_polyapprox()

copy()

Copy the circle object

Returns:

Name Type Description
Self Self

copied circle object

Source code in otary/geometry/continuous/shape/circle.py
def copy(self) -> Self:
    """Copy the circle object

    Returns:
        Self: copied circle object
    """
    return type(self)(
        center=self.center,
        radius=self.radius,
        n_points_polygonal_approx=self.n_points_polygonal_approx,
    )

curvature(point=None)

Curvature of circle is a constant and does not depend on a position of a point

Returns:

Name Type Description
float float

curvature value

Source code in otary/geometry/continuous/shape/circle.py
def curvature(self, point: Optional[NDArray] = None) -> float:
    """Curvature of circle is a constant and does not depend on a position of
    a point

    Returns:
        float: curvature value
    """
    return 1 / self.radius

normalize(x, y)

Normalize the circle by dividing the points by a norm on the x and y coordinates. This does not change the circle radius.

Parameters:

Name Type Description Default
x float

x coordinate norm

required
y float

y coordinate norm

required

Returns:

Name Type Description
Self Self

normalized circle object

Source code in otary/geometry/continuous/shape/circle.py
def normalize(self, x: float, y: float) -> Self:
    """Normalize the circle by dividing the points by a norm on the x and y
    coordinates. This does not change the circle radius.

    Args:
        x (float): x coordinate norm
        y (float): y coordinate norm

    Returns:
        Self: normalized circle object
    """
    self.center = self.center / np.array([x, y])
    self.update_polyapprox()
    return self

polygonal_approx(n_points, is_cast_int=False)

Generate a Polygon object that is an approximation of the circle as a discrete geometrical object made up of only points and segments.

Parameters:

Name Type Description Default
n_points int

number of points that make up the circle polygonal approximation

required
is_cast_int bool

whether to cast to int the points coordinates or not. Defaults to False

False

Returns:

Name Type Description
Polygon Polygon

Polygon representing the circle as a succession of n points

Source code in otary/geometry/continuous/shape/circle.py
def polygonal_approx(self, n_points: int, is_cast_int: bool = False) -> Polygon:
    """Generate a Polygon object that is an approximation of the circle
    as a discrete geometrical object made up of only points and segments.

    Args:
        n_points (int): number of points that make up the circle
            polygonal approximation
        is_cast_int (bool): whether to cast to int the points coordinates or
            not. Defaults to False

    Returns:
        Polygon: Polygon representing the circle as a succession of n points

    """
    points = []
    for theta in np.linspace(0, 2 * math.pi, n_points):
        x = self.center[0] + self.radius * math.cos(theta)
        y = self.center[1] + self.radius * math.sin(theta)
        points.append([x, y])

    poly = Polygon(points=np.asarray(points), is_cast_int=is_cast_int)
    return poly

rotate(angle, is_degree=False, is_clockwise=True, pivot=None)

Rotate the circle around a pivot point.

Parameters:

Name Type Description Default
angle float

angle by which to rotate the circle

required
is_degree bool

whether the angle is in degrees. Defaults to False.

False
is_clockwise bool

whether the rotation is clockwise. Defaults to True.

True
pivot Optional[NDArray]

pivot point around which to rotate. Defaults to None.

None

Returns:

Name Type Description
Self Self

rotated circle object

Source code in otary/geometry/continuous/shape/circle.py
def rotate(
    self,
    angle: float,
    is_degree: bool = False,
    is_clockwise: bool = True,
    pivot: Optional[NDArray] = None,
) -> Self:
    """Rotate the circle around a pivot point.

    Args:
        angle (float): angle by which to rotate the circle
        is_degree (bool, optional): whether the angle is in degrees.
            Defaults to False.
        is_clockwise (bool, optional): whether the rotation is clockwise.
            Defaults to True.
        pivot (Optional[NDArray], optional): pivot point around which to rotate.
            Defaults to None.

    Returns:
        Self: rotated circle object
    """
    if pivot is None:
        # If no pivot is given, the circle is rotated around its center
        # and thus is not modified
        return self

    self.center = rotate_2d_points(
        points=self.center,
        angle=angle,
        is_degree=is_degree,
        is_clockwise=is_clockwise,
        pivot=pivot,
    )
    self.update_polyapprox()
    return self

shift(vector)

Shift the circle by a given vector.

Parameters:

Name Type Description Default
vector NDArray

2D vector by which to shift the circle

required

Returns:

Name Type Description
Self Self

shifted circle object

Source code in otary/geometry/continuous/shape/circle.py
def shift(self, vector: NDArray) -> Self:
    """Shift the circle by a given vector.

    Args:
        vector (NDArray): 2D vector by which to shift the circle

    Returns:
        Self: shifted circle object
    """
    vector = assert_transform_shift_vector(vector=vector)
    self.center += vector
    self.update_polyapprox()
    return self

Ellipse Geometric Object

Ellipse

Bases: ContinuousGeometryEntity

Ellipse geometrical object

Source code in otary/geometry/continuous/shape/ellipse.py
class Ellipse(ContinuousGeometryEntity):
    """Ellipse geometrical object"""

    def __init__(
        self,
        foci1: NDArray | list,
        foci2: NDArray | list,
        semi_major_axis: float,
        n_points_polygonal_approx: int = ContinuousGeometryEntity.DEFAULT_N_POLY_APPROX,
    ):
        """Initialize a Ellipse geometrical object

        Args:
            foci1 (NDArray | list): first focal 2D point
            foci2 (NDArray | list): second focal 2D point
            semi_major_axis (float): semi major axis value
            n_points_polygonal_approx (int, optional): number of points to be used in
                the polygonal approximation.
                Defaults to ContinuousGeometryEntity.DEFAULT_N_POINTS_POLYGONAL_APPROX.
        """
        super().__init__(n_points_polygonal_approx=n_points_polygonal_approx)
        self.foci1 = np.asarray(foci1)
        self.foci2 = np.asarray(foci2)
        self.semi_major_axis = semi_major_axis  # also called "a" usually
        self.__assert_ellipse()

        if type(self) is Ellipse:  # pylint: disable=unidiomatic-typecheck
            # pylint check is wrong here since we want it to be ONLY an Ellipse
            # not a circle. isinstance() check make children classes return True
            # to avoid computation in circle class instantiation
            # since the center attribute is not defined in the Circle class yet
            self.update_polyapprox()

    def __assert_ellipse(self) -> None:
        """Assert the parameters of the ellipse.
        If the parameters proposed do not make up a ellipse raise an error.
        """
        if self.semi_major_axis <= self.linear_eccentricity:
            raise ValueError(
                f"The semi major-axis (a={self.semi_major_axis}) can not be smaller "
                f"than the linear eccentricity (c={self.linear_eccentricity}). "
                "The ellipse is thus not valid. Please increase the semi major-axis."
            )

    # --------------------------------- PROPERTIES ------------------------------------

    @property
    def centroid(self) -> NDArray:
        """Compute the center point of the ellipse

        Returns:
            NDArray: 2D point defining the center of the ellipse
        """
        return (self.foci1 + self.foci2) / 2

    @property
    def semi_minor_axis(self) -> float:
        """Computed semi minor axis (also called b usually)

        Returns:
            float: _description_
        """
        return math.sqrt(self.semi_major_axis**2 - self.linear_eccentricity**2)

    @property
    def linear_eccentricity(self) -> float:
        """Distance from any focal point to the center

        Returns:
            float: linear eccentricity value
        """
        return float(np.linalg.norm(self.foci2 - self.foci1) / 2)

    @property
    def focal_distance(self) -> float:
        """Distance from any focal point to the center

        Returns:
            float: focal distance value
        """
        return self.linear_eccentricity

    @property
    def eccentricity(self) -> float:
        """Eccentricity value of the ellipse

        Returns:
            float: eccentricity value
        """
        return self.linear_eccentricity / self.semi_major_axis

    @property
    def h(self) -> float:
        """h is a common ellipse value used in calculation and kind of
        represents the eccentricity of the ellipse but in another perspective.

        Circle would have a h = 0. A really stretch out ellipse would have a h value
        close o 1

        Returns:
            float: h value
        """
        return (self.semi_major_axis - self.semi_minor_axis) ** 2 / (
            self.semi_major_axis + self.semi_minor_axis
        ) ** 2

    @property
    def area(self) -> float:
        """Compute the area of the ellipse

        Returns:
            float: area value
        """
        return math.pi * self.semi_major_axis * self.semi_minor_axis

    @property
    def perimeter(self) -> float:
        """Compute the perimeter of the ellipse.
        Beware this is only an approximation due to the computation of both pi
        and the James Ivory's infinite serie.

        Returns:
            float: perimeter value
        """
        return self.perimeter_approx()

    @property
    def shapely_surface(self) -> SPolygon:
        """Returns the Shapely.Polygon as an surface representation of the Ellipse.
        See https://shapely.readthedocs.io/en/stable/reference/shapely.Polygon.html

        Returns:
            Polygon: shapely.Polygon object
        """
        return SPolygon(self.polyaprox.asarray, holes=None)

    @property
    def shapely_edges(self) -> LinearRing:
        """Returns the Shapely.LinearRing as a curve representation of the Ellipse.
        See https://shapely.readthedocs.io/en/stable/reference/shapely.LinearRing.html

        Returns:
            LinearRing: shapely.LinearRing object
        """
        return LinearRing(coordinates=self.polyaprox.asarray)

    @property
    def is_circle(self) -> bool:
        """Check if the ellipse is a circle

        Returns:
            bool: True if circle else False
        """
        return self.semi_major_axis == self.semi_minor_axis

    # ---------------------------- MODIFICATION METHODS -------------------------------

    def rotate(
        self,
        angle: float,
        is_degree: bool = False,
        is_clockwise: bool = True,
        pivot: Optional[NDArray] = None,
    ) -> Self:
        """Rotate the ellipse around a pivot point.

        Args:
            angle (float): angle to rotate the ellipse
            is_degree (bool, optional): whether the angle is in degrees.
                Defaults to False.
            is_clockwise (bool, optional): whether the rotation is clockwise.
                Defaults to True.
            pivot (Optional[NDArray], optional): pivot point to rotate around.
                Defaults to None.

        Returns:
            Self: rotated ellipse object
        """
        if is_degree:
            angle = math.radians(angle)
        if is_clockwise:
            angle = -angle

        if pivot is None:
            pivot = self.centroid

        self.foci1 = rotate_2d_points(self.foci1, angle, pivot)
        self.foci2 = rotate_2d_points(self.foci2, angle, pivot)
        self.update_polyapprox()
        return self

    def shift(self, vector: NDArray) -> Self:
        """Shift the ellipse by a given vector.

        Args:
            vector (NDArray): vector to shift the ellipse

        Returns:
            Self: shifted ellipse object
        """
        assert_transform_shift_vector(vector)
        self.foci1 += vector
        self.foci2 += vector
        self.update_polyapprox()
        return self

    def normalize(self, x: float, y: float) -> Self:
        """Normalize the ellipse to a given bounding box.

        Args:
            x (float): width of the bounding box
            y (float): height of the bounding box

        Returns:
            Self: normalized ellipse object
        """
        factor = np.array([x, y])
        self.foci1 = self.foci1 / factor
        self.foci2 = self.foci2 / factor

        self.update_polyapprox()
        return self

    # ------------------------------- CLASSIC METHODS ---------------------------------

    def perimeter_approx(self, n_terms: int = 5, is_ramanujan: bool = False) -> float:
        """Perimeter approximation of the ellipse using the James Ivory
        infinite serie. In the case of the circle this always converges to the
        exact value of the circumference no matter the number of terms.

        See: https://en.wikipedia.org/wiki/Ellipse#Circumference

        Args:
            n_terms (int, optional): number of n first terms to calculate and
                add up from the infinite series. Defaults to 5.
            is_ramanujan (bool, optional): whether to use the Ramanujan's best
                approximation.

        Returns:
            float: circumference approximation of the ellipse
        """
        if is_ramanujan:
            return (
                math.pi
                * (self.semi_major_axis + self.semi_minor_axis)
                * (1 + (3 * self.h) / (10 + math.sqrt(4 - 3 * self.h)))
            )

        _sum = 1  # pre-calculated term n=0 equal 1
        for n in range(1, n_terms):  # goes from term n=1 to n=(n_terms-1)
            _sum += (((1 / ((2 * n - 1) * (4**n))) * math.comb(2 * n, n)) ** 2) * (
                self.h**n
            )

        return math.pi * (self.semi_major_axis + self.semi_minor_axis) * _sum

    def polygonal_approx(self, n_points: int, is_cast_int: bool = False) -> Polygon:
        """Generate apolygonal approximation of the ellipse.

        The way is done is the following:
        1. suppose the ellipse centered at the origin
        2. suppose the ellipse semi major axis to be parallel with the x-axis
        3. compute pairs of (x, y) points that belong to the ellipse using the
            parametric equation of the ellipse.
        4. shift all points by the same shift as the center to origin
        5. rotate using the ellipse center pivot point

        Args:
            n_points (int): number of points that make up the ellipse
                polygonal approximation
            is_cast_int (bool): whether to cast to int the points coordinates or
                not. Defaults to False

        Returns:
            Polygon: Polygon representing the ellipse as a succession of n points
        """
        points = []
        for theta in np.linspace(0, 2 * math.pi, n_points):
            x = self.semi_major_axis * math.cos(theta)
            y = self.semi_minor_axis * math.sin(theta)
            points.append([x, y])

        poly = (
            Polygon(points=np.asarray(points), is_cast_int=False)
            .shift(vector=self.centroid)
            .rotate(angle=self.angle())
        )

        if is_cast_int:
            poly.asarray = poly.asarray.astype(int)

        return poly

    def angle(self, degree: bool = False, is_y_axis_down: bool = False) -> float:
        """Angle of the ellipse

        Args:
            degree (bool, optional): whether to output angle in degree,
                Defaults to False meaning radians.
            is_y_axis_down (bool, optional): whether the y axis is down.
                Defaults to False.

        Returns:
            float: angle value
        """
        seg = Segment([self.foci1, self.foci2])
        return seg.slope_angle(degree=degree, is_y_axis_down=is_y_axis_down)

    def curvature(self, point: NDArray) -> float:
        r"""Computes the curvature of a point on the ellipse.

        Equation is based on the following where a is semi major and b is minor axis.

        \kappa = \frac{1}{a^2 b^2}
            \left(
                \frac{x^2}{a^4} + \frac{y^2}{b^4}
            \right)^{-\frac{3}{2}}

        Args:
            point (NDArray): point on the ellipse

        Returns:
            float: curvature of the point
        """
        # TODO check that the point is on the ellipse
        x, y = point
        a = self.semi_major_axis
        b = self.semi_minor_axis

        numerator = 1 / (a * b) ** 2
        inner = (x**2) / (a**4) + (y**2) / (b**4)
        curvature = numerator * inner ** (-1.5)

        return curvature

    def copy(self) -> Self:
        """Copy the current ellipse object

        Returns:
            Self: copied ellipse object
        """
        return type(self)(
            foci1=self.foci1,
            foci2=self.foci2,
            semi_major_axis=self.semi_major_axis,
            n_points_polygonal_approx=self.n_points_polygonal_approx,
        )

    def enclosing_oriented_bbox(self):
        """
        Enclosing oriented bounding box.
        Manage the case where the ellipse is a circle and return the enclosing
        axis-aligned bounding box in that case.

        Returns:
            Rectangle: Enclosing oriented bounding box
        """
        if self.is_circle:
            # In a circle the enclosing oriented bounding box could be in any
            # direction. Thus we return the enclosing axis-aligned bounding box
            # by default.
            return self.enclosing_axis_aligned_bbox()
        return super().enclosing_oriented_bbox()

    def __str__(self) -> str:
        return (
            f"Ellipse(foci1={self.foci1}, foci2={self.foci2}, a={self.semi_major_axis})"
        )

    def __repr__(self) -> str:
        return (
            f"Ellipse(foci1={self.foci1}, foci2={self.foci2}, a={self.semi_major_axis})"
        )

area property

Compute the area of the ellipse

Returns:

Name Type Description
float float

area value

centroid property

Compute the center point of the ellipse

Returns:

Name Type Description
NDArray NDArray

2D point defining the center of the ellipse

eccentricity property

Eccentricity value of the ellipse

Returns:

Name Type Description
float float

eccentricity value

focal_distance property

Distance from any focal point to the center

Returns:

Name Type Description
float float

focal distance value

h property

h is a common ellipse value used in calculation and kind of represents the eccentricity of the ellipse but in another perspective.

Circle would have a h = 0. A really stretch out ellipse would have a h value close o 1

Returns:

Name Type Description
float float

h value

is_circle property

Check if the ellipse is a circle

Returns:

Name Type Description
bool bool

True if circle else False

linear_eccentricity property

Distance from any focal point to the center

Returns:

Name Type Description
float float

linear eccentricity value

perimeter property

Compute the perimeter of the ellipse. Beware this is only an approximation due to the computation of both pi and the James Ivory's infinite serie.

Returns:

Name Type Description
float float

perimeter value

semi_minor_axis property

Computed semi minor axis (also called b usually)

Returns:

Name Type Description
float float

description

shapely_edges property

Returns the Shapely.LinearRing as a curve representation of the Ellipse. See https://shapely.readthedocs.io/en/stable/reference/shapely.LinearRing.html

Returns:

Name Type Description
LinearRing LinearRing

shapely.LinearRing object

shapely_surface property

Returns the Shapely.Polygon as an surface representation of the Ellipse. See https://shapely.readthedocs.io/en/stable/reference/shapely.Polygon.html

Returns:

Name Type Description
Polygon Polygon

shapely.Polygon object

__assert_ellipse()

Assert the parameters of the ellipse. If the parameters proposed do not make up a ellipse raise an error.

Source code in otary/geometry/continuous/shape/ellipse.py
def __assert_ellipse(self) -> None:
    """Assert the parameters of the ellipse.
    If the parameters proposed do not make up a ellipse raise an error.
    """
    if self.semi_major_axis <= self.linear_eccentricity:
        raise ValueError(
            f"The semi major-axis (a={self.semi_major_axis}) can not be smaller "
            f"than the linear eccentricity (c={self.linear_eccentricity}). "
            "The ellipse is thus not valid. Please increase the semi major-axis."
        )

__init__(foci1, foci2, semi_major_axis, n_points_polygonal_approx=ContinuousGeometryEntity.DEFAULT_N_POLY_APPROX)

Initialize a Ellipse geometrical object

Parameters:

Name Type Description Default
foci1 NDArray | list

first focal 2D point

required
foci2 NDArray | list

second focal 2D point

required
semi_major_axis float

semi major axis value

required
n_points_polygonal_approx int

number of points to be used in the polygonal approximation. Defaults to ContinuousGeometryEntity.DEFAULT_N_POINTS_POLYGONAL_APPROX.

DEFAULT_N_POLY_APPROX
Source code in otary/geometry/continuous/shape/ellipse.py
def __init__(
    self,
    foci1: NDArray | list,
    foci2: NDArray | list,
    semi_major_axis: float,
    n_points_polygonal_approx: int = ContinuousGeometryEntity.DEFAULT_N_POLY_APPROX,
):
    """Initialize a Ellipse geometrical object

    Args:
        foci1 (NDArray | list): first focal 2D point
        foci2 (NDArray | list): second focal 2D point
        semi_major_axis (float): semi major axis value
        n_points_polygonal_approx (int, optional): number of points to be used in
            the polygonal approximation.
            Defaults to ContinuousGeometryEntity.DEFAULT_N_POINTS_POLYGONAL_APPROX.
    """
    super().__init__(n_points_polygonal_approx=n_points_polygonal_approx)
    self.foci1 = np.asarray(foci1)
    self.foci2 = np.asarray(foci2)
    self.semi_major_axis = semi_major_axis  # also called "a" usually
    self.__assert_ellipse()

    if type(self) is Ellipse:  # pylint: disable=unidiomatic-typecheck
        # pylint check is wrong here since we want it to be ONLY an Ellipse
        # not a circle. isinstance() check make children classes return True
        # to avoid computation in circle class instantiation
        # since the center attribute is not defined in the Circle class yet
        self.update_polyapprox()

angle(degree=False, is_y_axis_down=False)

Angle of the ellipse

Parameters:

Name Type Description Default
degree bool

whether to output angle in degree, Defaults to False meaning radians.

False
is_y_axis_down bool

whether the y axis is down. Defaults to False.

False

Returns:

Name Type Description
float float

angle value

Source code in otary/geometry/continuous/shape/ellipse.py
def angle(self, degree: bool = False, is_y_axis_down: bool = False) -> float:
    """Angle of the ellipse

    Args:
        degree (bool, optional): whether to output angle in degree,
            Defaults to False meaning radians.
        is_y_axis_down (bool, optional): whether the y axis is down.
            Defaults to False.

    Returns:
        float: angle value
    """
    seg = Segment([self.foci1, self.foci2])
    return seg.slope_angle(degree=degree, is_y_axis_down=is_y_axis_down)

copy()

Copy the current ellipse object

Returns:

Name Type Description
Self Self

copied ellipse object

Source code in otary/geometry/continuous/shape/ellipse.py
def copy(self) -> Self:
    """Copy the current ellipse object

    Returns:
        Self: copied ellipse object
    """
    return type(self)(
        foci1=self.foci1,
        foci2=self.foci2,
        semi_major_axis=self.semi_major_axis,
        n_points_polygonal_approx=self.n_points_polygonal_approx,
    )

curvature(point)

Computes the curvature of a point on the ellipse.

Equation is based on the following where a is semi major and b is minor axis.

\kappa = \frac{1}{a^2 b^2} \left( \frac{x^2}{a^4} + \frac{y^2}{b^4} \right)^{-\frac{3}{2}}

Parameters:

Name Type Description Default
point NDArray

point on the ellipse

required

Returns:

Name Type Description
float float

curvature of the point

Source code in otary/geometry/continuous/shape/ellipse.py
def curvature(self, point: NDArray) -> float:
    r"""Computes the curvature of a point on the ellipse.

    Equation is based on the following where a is semi major and b is minor axis.

    \kappa = \frac{1}{a^2 b^2}
        \left(
            \frac{x^2}{a^4} + \frac{y^2}{b^4}
        \right)^{-\frac{3}{2}}

    Args:
        point (NDArray): point on the ellipse

    Returns:
        float: curvature of the point
    """
    # TODO check that the point is on the ellipse
    x, y = point
    a = self.semi_major_axis
    b = self.semi_minor_axis

    numerator = 1 / (a * b) ** 2
    inner = (x**2) / (a**4) + (y**2) / (b**4)
    curvature = numerator * inner ** (-1.5)

    return curvature

enclosing_oriented_bbox()

Enclosing oriented bounding box. Manage the case where the ellipse is a circle and return the enclosing axis-aligned bounding box in that case.

Returns:

Name Type Description
Rectangle

Enclosing oriented bounding box

Source code in otary/geometry/continuous/shape/ellipse.py
def enclosing_oriented_bbox(self):
    """
    Enclosing oriented bounding box.
    Manage the case where the ellipse is a circle and return the enclosing
    axis-aligned bounding box in that case.

    Returns:
        Rectangle: Enclosing oriented bounding box
    """
    if self.is_circle:
        # In a circle the enclosing oriented bounding box could be in any
        # direction. Thus we return the enclosing axis-aligned bounding box
        # by default.
        return self.enclosing_axis_aligned_bbox()
    return super().enclosing_oriented_bbox()

normalize(x, y)

Normalize the ellipse to a given bounding box.

Parameters:

Name Type Description Default
x float

width of the bounding box

required
y float

height of the bounding box

required

Returns:

Name Type Description
Self Self

normalized ellipse object

Source code in otary/geometry/continuous/shape/ellipse.py
def normalize(self, x: float, y: float) -> Self:
    """Normalize the ellipse to a given bounding box.

    Args:
        x (float): width of the bounding box
        y (float): height of the bounding box

    Returns:
        Self: normalized ellipse object
    """
    factor = np.array([x, y])
    self.foci1 = self.foci1 / factor
    self.foci2 = self.foci2 / factor

    self.update_polyapprox()
    return self

perimeter_approx(n_terms=5, is_ramanujan=False)

Perimeter approximation of the ellipse using the James Ivory infinite serie. In the case of the circle this always converges to the exact value of the circumference no matter the number of terms.

See: https://en.wikipedia.org/wiki/Ellipse#Circumference

Parameters:

Name Type Description Default
n_terms int

number of n first terms to calculate and add up from the infinite series. Defaults to 5.

5
is_ramanujan bool

whether to use the Ramanujan's best approximation.

False

Returns:

Name Type Description
float float

circumference approximation of the ellipse

Source code in otary/geometry/continuous/shape/ellipse.py
def perimeter_approx(self, n_terms: int = 5, is_ramanujan: bool = False) -> float:
    """Perimeter approximation of the ellipse using the James Ivory
    infinite serie. In the case of the circle this always converges to the
    exact value of the circumference no matter the number of terms.

    See: https://en.wikipedia.org/wiki/Ellipse#Circumference

    Args:
        n_terms (int, optional): number of n first terms to calculate and
            add up from the infinite series. Defaults to 5.
        is_ramanujan (bool, optional): whether to use the Ramanujan's best
            approximation.

    Returns:
        float: circumference approximation of the ellipse
    """
    if is_ramanujan:
        return (
            math.pi
            * (self.semi_major_axis + self.semi_minor_axis)
            * (1 + (3 * self.h) / (10 + math.sqrt(4 - 3 * self.h)))
        )

    _sum = 1  # pre-calculated term n=0 equal 1
    for n in range(1, n_terms):  # goes from term n=1 to n=(n_terms-1)
        _sum += (((1 / ((2 * n - 1) * (4**n))) * math.comb(2 * n, n)) ** 2) * (
            self.h**n
        )

    return math.pi * (self.semi_major_axis + self.semi_minor_axis) * _sum

polygonal_approx(n_points, is_cast_int=False)

Generate apolygonal approximation of the ellipse.

The way is done is the following: 1. suppose the ellipse centered at the origin 2. suppose the ellipse semi major axis to be parallel with the x-axis 3. compute pairs of (x, y) points that belong to the ellipse using the parametric equation of the ellipse. 4. shift all points by the same shift as the center to origin 5. rotate using the ellipse center pivot point

Parameters:

Name Type Description Default
n_points int

number of points that make up the ellipse polygonal approximation

required
is_cast_int bool

whether to cast to int the points coordinates or not. Defaults to False

False

Returns:

Name Type Description
Polygon Polygon

Polygon representing the ellipse as a succession of n points

Source code in otary/geometry/continuous/shape/ellipse.py
def polygonal_approx(self, n_points: int, is_cast_int: bool = False) -> Polygon:
    """Generate apolygonal approximation of the ellipse.

    The way is done is the following:
    1. suppose the ellipse centered at the origin
    2. suppose the ellipse semi major axis to be parallel with the x-axis
    3. compute pairs of (x, y) points that belong to the ellipse using the
        parametric equation of the ellipse.
    4. shift all points by the same shift as the center to origin
    5. rotate using the ellipse center pivot point

    Args:
        n_points (int): number of points that make up the ellipse
            polygonal approximation
        is_cast_int (bool): whether to cast to int the points coordinates or
            not. Defaults to False

    Returns:
        Polygon: Polygon representing the ellipse as a succession of n points
    """
    points = []
    for theta in np.linspace(0, 2 * math.pi, n_points):
        x = self.semi_major_axis * math.cos(theta)
        y = self.semi_minor_axis * math.sin(theta)
        points.append([x, y])

    poly = (
        Polygon(points=np.asarray(points), is_cast_int=False)
        .shift(vector=self.centroid)
        .rotate(angle=self.angle())
    )

    if is_cast_int:
        poly.asarray = poly.asarray.astype(int)

    return poly

rotate(angle, is_degree=False, is_clockwise=True, pivot=None)

Rotate the ellipse around a pivot point.

Parameters:

Name Type Description Default
angle float

angle to rotate the ellipse

required
is_degree bool

whether the angle is in degrees. Defaults to False.

False
is_clockwise bool

whether the rotation is clockwise. Defaults to True.

True
pivot Optional[NDArray]

pivot point to rotate around. Defaults to None.

None

Returns:

Name Type Description
Self Self

rotated ellipse object

Source code in otary/geometry/continuous/shape/ellipse.py
def rotate(
    self,
    angle: float,
    is_degree: bool = False,
    is_clockwise: bool = True,
    pivot: Optional[NDArray] = None,
) -> Self:
    """Rotate the ellipse around a pivot point.

    Args:
        angle (float): angle to rotate the ellipse
        is_degree (bool, optional): whether the angle is in degrees.
            Defaults to False.
        is_clockwise (bool, optional): whether the rotation is clockwise.
            Defaults to True.
        pivot (Optional[NDArray], optional): pivot point to rotate around.
            Defaults to None.

    Returns:
        Self: rotated ellipse object
    """
    if is_degree:
        angle = math.radians(angle)
    if is_clockwise:
        angle = -angle

    if pivot is None:
        pivot = self.centroid

    self.foci1 = rotate_2d_points(self.foci1, angle, pivot)
    self.foci2 = rotate_2d_points(self.foci2, angle, pivot)
    self.update_polyapprox()
    return self

shift(vector)

Shift the ellipse by a given vector.

Parameters:

Name Type Description Default
vector NDArray

vector to shift the ellipse

required

Returns:

Name Type Description
Self Self

shifted ellipse object

Source code in otary/geometry/continuous/shape/ellipse.py
def shift(self, vector: NDArray) -> Self:
    """Shift the ellipse by a given vector.

    Args:
        vector (NDArray): vector to shift the ellipse

    Returns:
        Self: shifted ellipse object
    """
    assert_transform_shift_vector(vector)
    self.foci1 += vector
    self.foci2 += vector
    self.update_polyapprox()
    return self

Discrete Geometry

DiscreteGeometryEntity module class

DiscreteGeometryEntity

Bases: GeometryEntity, ABC

GeometryEntity class which is the abstract base class for all geometry classes

Source code in otary/geometry/discrete/entity.py
 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
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
class DiscreteGeometryEntity(GeometryEntity, ABC):
    """GeometryEntity class which is the abstract base class for all geometry classes"""

    # pylint: disable=too-many-public-methods

    def __init__(self, points: NDArray | list, is_cast_int: bool = False) -> None:
        _arr = self._init_array(points, is_cast_int)
        self.points = copy.deepcopy(_arr)
        self.is_cast_int = is_cast_int

    def _init_array(self, points: NDArray | list, is_cast_int: bool = False) -> NDArray:
        """Initialize the array given the points.

        Args:
            points (NDArray | list): input points
            is_cast_int (bool, optional): whether to cast points to int.
                Defaults to False.

        Returns:
            NDArray: array describing the input points
        """
        tmp = np.asarray(points)
        is_all_elements_are_integer = np.all(np.equal(tmp, tmp.astype(int)))
        if is_cast_int or is_all_elements_are_integer:
            _arr = tmp.astype(np.int32)
        else:
            _arr = tmp.astype(np.float32)
        return _arr

    # --------------------------------- PROPERTIES ------------------------------------

    @property
    @abstractmethod
    def shapely_edges(self) -> GeometryCollection:
        """Representation of the geometric object in the shapely library
        as a geometrical object defined only as a curve with no area. Particularly
        useful to look for points intersections
        """

    @property
    @abstractmethod
    def shapely_surface(self) -> GeometryCollection:
        """Representation of the geometric object in the shapely library
        as a geometrical object with an area and a border. Particularly useful
        to check if two geometrical objects are contained within each other or not.
        """

    @property
    @abstractmethod
    def edges(self) -> NDArray:
        """Get the edges of the geometry entity

        Returns:
            NDArray: edges of the geometry entity
        """

    @property
    def segments(self) -> list[Segment]:
        """Get the segments of the geometry entity

        Returns:
            NDArray: segments of the geometry entity
        """
        # pylint: disable=import-outside-toplevel
        from otary.geometry import Segment  # delayed import to avoid circular import

        return [Segment(e) for e in self.edges]

    @property
    def n_points(self) -> int:
        """Returns the number of points this geometric object is made of

        Returns:
            int: number of points that composes the geomtric object
        """
        return self.points.shape[0]

    @property
    def asarray(self) -> NDArray:
        """Array representation of the geometry object"""
        return self.points

    @asarray.setter
    def asarray(self, value: NDArray):
        """Setter for the asarray property

        Args:
            value (NDArray): value of the asarray to be changed
        """
        self.points = value

    @property
    @abstractmethod
    def centroid(self) -> NDArray:
        """Compute the centroid point which can be seen as the center of gravity
        or center of mass of the shape

        Returns:
            NDArray: centroid point
        """

    @property
    def center_mean(self) -> NDArray:
        """Compute the center as the mean of all the points. This can be really
        different than the centroid.

        Returns:
            NDArray: center mean as a 2D point
        """
        return np.mean(self.points, axis=0)

    @property
    def xmax(self) -> float:
        """Get the maximum X coordinate of the geometry entity

        Returns:
            NDArray: 2D point
        """
        return np.max(self.asarray[:, 0])

    @property
    def xmin(self) -> float:
        """Get the minimum X coordinate of the geometry entity

        Returns:
            NDArray: 2D point
        """
        return np.min(self.asarray[:, 0])

    @property
    def ymax(self) -> float:
        """Get the maximum Y coordinate of the geometry entity

        Returns:
            NDArray: 2D point
        """
        return np.max(self.asarray[:, 1])

    @property
    def ymin(self) -> float:
        """Get the minimum Y coordinate of the geometry entity

        Returns:
            NDArray: 2D point
        """
        return np.min(self.asarray[:, 1])

    @property
    def lengths(self) -> NDArray:
        """Returns the length of all the segments that make up the geometry entity

        Returns:
            NDArray: array of shape (n_points)
        """
        lengths: NDArray = np.linalg.norm(np.diff(self.edges, axis=1), axis=2)
        return lengths.flatten()

    @property
    def crop_coordinates(self) -> NDArray:
        """Compute the coordinates of the geometry entity in the context of
        itself being in a crop image that make it fit pefectly

        Returns:
            Self: _description_
        """
        return self.asarray - np.array([self.xmin, self.ymin])

    # ---------------------------- MODIFICATION METHODS -------------------------------

    def rotate(
        self,
        angle: float,
        is_degree: bool = False,
        is_clockwise: bool = True,
        pivot: Optional[NDArray] = None,
    ) -> Self:
        """Rotate the geometry entity object.
        A pivot point can be passed as an argument to rotate the object around the pivot

        Args:
            angle (float): rotation angle
            is_degree (bool, optional): whether the angle is in degree or radian.
                Defaults to False which means radians.
            is_clockwise (bool, optional): whether the rotation is clockwise or
                counter-clockwise. Defaults to True.
            pivot (NDArray, optional): pivot point.
                Defaults to None which means that by default the centroid point of
                the shape is taken as the pivot point.

        Returns:
            GeometryEntity: rotated geometry entity object.
        """
        if pivot is None:
            pivot = self.centroid

        self.points = rotate_2d_points(
            points=self.points,
            angle=angle,
            pivot=pivot,
            is_degree=is_degree,
            is_clockwise=is_clockwise,
        )
        return self

    def rotate_around_image_center(
        self, img: NDArray, angle: float, degree: bool = False
    ) -> Self:
        """Given an geometric object and an image, rotate the object around
        the image center point.

        Args:
            img (NDArray): image as a shape (x, y) sized array
            angle (float): rotation angle
            degree (bool, optional): whether the angle is in degree or radian.
                Defaults to False which means radians.

        Returns:
            GeometryEntity: rotated geometry entity object.
        """
        img_center_point = np.array([img.shape[1], img.shape[0]]) / 2
        return self.rotate(angle=angle, pivot=img_center_point, is_degree=degree)

    def shift(self, vector: NDArray) -> Self:
        """Shift the geometry entity by the vector direction

        Args:
            vector (NDArray): vector that describes the shift as a array with
                two elements. Example: [2, -8] which describes the
                vector [[0, 0], [2, -8]]. The vector can also be a vector of shape
                (2, 2) of the form [[2, 6], [1, 3]].

        Returns:
            GeometryEntity: shifted geometrical object
        """
        vector = assert_transform_shift_vector(vector=vector)
        self.points = self.points + vector
        return self

    def clamp(
        self,
        xmin: float = -np.inf,
        xmax: float = np.inf,
        ymin: float = -np.inf,
        ymax: float = np.inf,
    ) -> Self:
        """Clamp the Geometry entity so that the x and y coordinates fit in the
        min and max values in parameters.

        Args:
            xmin (float): x coordinate minimum
            xmax (float): x coordinate maximum
            ymin (float): y coordinate minimum
            ymax (float): y coordinate maximum

        Returns:
            GeometryEntity: clamped GeometryEntity
        """
        self.asarray[:, 0] = np.clip(self.asarray[:, 0], xmin, xmax)  # x values
        self.asarray[:, 1] = np.clip(self.asarray[:, 1], ymin, ymax)  # y values
        return self

    def normalize(self, x: float, y: float) -> Self:
        """Normalize the geometry entity by dividing the points by a norm on the
        x and y coordinates.

        Args:
            x (float): x coordinate norm
            y (float): y coordinate norm

        Returns:
            GeometryEntity: normalized GeometryEntity
        """
        if x == 0 or y == 0:
            raise ValueError("x or y cannot be 0")
        self.asarray = self.asarray / np.array([x, y])
        return self

    # ------------------------------- CLASSIC METHODS ---------------------------------

    def copy(self) -> Self:
        """Create a copy of the geometry entity object

        Returns:
            GeometryEntity: copy of the geometry entity object
        """
        return type(self)(points=self.asarray.copy(), is_cast_int=self.is_cast_int)

    def enclosing_axis_aligned_bbox(self) -> Rectangle:
        """Compute the smallest area enclosing Axis-Aligned Bounding Box (AABB)
        See: https://docs.opencv.org/3.4/dd/d49/tutorial_py_contour_features.html

        Return the points in the following order:
        1. top left
        2. top right
        3. bottom right
        4. bottom left

        Returns:
            Rectangle: Rectangle object
        """
        # pylint: disable=import-outside-toplevel
        from otary.geometry import Rectangle  # delayed import to avoid circular import

        topleft_x, topleft_y, width, height = cv2.boundingRect(
            array=self.asarray.astype(np.float32)
        )

        # pylint: disable=duplicate-code
        bbox = np.array(
            [
                [topleft_x, topleft_y],
                [topleft_x + width, topleft_y],
                [topleft_x + width, topleft_y + height],
                [topleft_x, topleft_y + height],
            ]
        )
        return Rectangle(bbox)

    def enclosing_oriented_bbox(self) -> Rectangle:
        """Compute the smallest area enclosing Oriented Bounding Box (OBB)
        See: https://docs.opencv.org/3.4/dd/d49/tutorial_py_contour_features.html

        Returns:
            Rectangle: Rectangle object
        """
        # pylint: disable=import-outside-toplevel
        from otary.geometry import Rectangle  # delayed import to avoid circular import

        rect = cv2.minAreaRect(self.asarray.astype(np.float32))
        bbox = cv2.boxPoints(rect)
        return Rectangle(bbox)

    def enclosing_convex_hull(self) -> Polygon:
        """Compute the smallest area enclosing Convex Hull
        See: https://docs.opencv.org/3.4/dd/d49/tutorial_py_contour_features.html

        Returns:
            Polygon: Polygon object
        """
        # pylint: disable=import-outside-toplevel
        from otary.geometry import Polygon  # delayed import to avoid circular import

        convexhull = np.squeeze(cv2.convexHull(self.asarray.astype(np.float32)))
        return Polygon(convexhull)

    def distances_vertices_to_point(self, point: NDArray) -> NDArray:
        """Get the distance from all vertices in the geometry entity to the input point

        Args:
            point (NDArray): 2D point

        Returns:
            NDArray: array of the same len as the number of vertices in the geometry
                entity.
        """
        return np.linalg.norm(self.asarray - point, axis=1)

    def shortest_dist_vertices_to_point(self, point: NDArray) -> float:
        """Compute the shortest distance from the geometry entity vertices to the point

        Args:
            point (NDArray): 2D point

        Returns:
            float: shortest distance from the geometry entity vertices to the point
        """
        return np.min(self.distances_vertices_to_point(point=point))

    def longest_dist_vertices_to_point(self, point: NDArray) -> float:
        """Compute the longest distance from the geometry entity vertices to the point

        Args:
            point (NDArray): 2D point

        Returns:
            float: longest distance from the geometry entity vertices to the point
        """
        return np.max(self.distances_vertices_to_point(point=point))

    def find_vertice_ix_farthest_from(self, point: NDArray) -> int:
        """Get the index of the farthest vertice from a given point

        Args:
            point (NDArray): 2D point

        Returns:
            int: the index of the farthest vertice in the entity from the input point
        """
        return np.argmax(self.distances_vertices_to_point(point=point)).astype(int)

    def find_vertice_ix_closest_from(self, point: NDArray) -> int:
        """Get the index of the closest vertice from a given point

        Args:
            point (NDArray): 2D point

        Returns:
            int: the index of the closest point in the entity from the input point
        """
        return np.argmin(self.distances_vertices_to_point(point=point)).astype(int)

    def find_shared_approx_vertices_ix(
        self, other: DiscreteGeometryEntity, margin_dist_error: float = 5
    ) -> NDArray:
        """Compute the vertices indices from this entity that correspond to shared
        vertices with the other geometric entity.

        A vertice is considered shared if it is close enough to another vertice
        in the other geometric structure.

        Args:
            other (DiscreteGeometryEntity): other Discrete Geometry entity
            margin_dist_error (float, optional): minimum distance to have two vertices
                considered as close enough to be shared. Defaults to 5.

        Returns:
            NDArray: list of indices
        """
        return get_shared_point_indices(
            points_to_check=self.asarray,
            checkpoints=other.asarray,
            margin_dist_error=margin_dist_error,
            method="close",
            cond="any",
        )

    def find_shared_approx_vertices(
        self, other: DiscreteGeometryEntity, margin_dist_error: float = 5
    ) -> NDArray:
        """Get the shared vertices between two geometric objects.

        A vertice is considered shared if it is close enough to another vertice
        in the other geometric structure.

        Args:
            other (DiscreteGeometryEntity): a DiscreteGeometryEntity object
            margin_dist_error (float, optional): the threshold to define a vertice as
                shared or not. Defaults to 5.

        Returns:
            NDArray: list of vertices identified as shared between the two geometric
                objects
        """
        indices = self.find_shared_approx_vertices_ix(
            other=other, margin_dist_error=margin_dist_error
        )
        return self.asarray[indices]

    def find_vertices_far_from(
        self, points: NDArray, min_distance: float = 5
    ) -> NDArray:
        """Get vertices that belongs to the geometric structure far from the points in
        parameters.

        Args:
            points (NDArray): input list of points
            min_distance (float, optional): the threshold to define a point as
                far enough or not from a vertice. Defaults to 5.

        Returns:
            NDArray: vertices that belongs to the geometric structure and that
                are far from the input points.
        """
        indices = get_shared_point_indices(
            points_to_check=self.asarray,
            checkpoints=points,
            margin_dist_error=min_distance,
            method="far",
            cond="all",
        )
        return self.asarray[indices]

    def __eq__(self, value: object) -> bool:
        if not isinstance(value, DiscreteGeometryEntity):
            return False
        if not isinstance(self, type(value)):
            return False
        return np.array_equal(self.asarray, value.asarray)

    def __neg__(self) -> Self:
        return type(self)(-self.asarray)

    def __add__(self, other: NDArray | float | int) -> Self:
        return type(self)(self.asarray + other)

    def __sub__(self, other: NDArray | float | int) -> Self:
        return type(self)(self.asarray - other)

    def __mul__(self, other: NDArray | float | int) -> Self:
        return type(self)(self.asarray.astype(float) * other)

    def __truediv__(self, other: NDArray | float | int) -> Self:
        return type(self)(self.asarray / other)

    def __len__(self) -> int:
        return self.n_points

    def __getitem__(self, index: int) -> NDArray:
        return self.points[index]

    def __str__(self) -> str:
        return (
            self.__class__.__name__
            + "(start="
            + self.asarray[0].tolist().__str__()
            + ", end="
            + self.asarray[-1].tolist().__str__()
            + ")"
        )

    def __repr__(self) -> str:
        return str(self)

asarray property writable

Array representation of the geometry object

center_mean property

Compute the center as the mean of all the points. This can be really different than the centroid.

Returns:

Name Type Description
NDArray NDArray

center mean as a 2D point

centroid abstractmethod property

Compute the centroid point which can be seen as the center of gravity or center of mass of the shape

Returns:

Name Type Description
NDArray NDArray

centroid point

crop_coordinates property

Compute the coordinates of the geometry entity in the context of itself being in a crop image that make it fit pefectly

Returns:

Name Type Description
Self NDArray

description

edges abstractmethod property

Get the edges of the geometry entity

Returns:

Name Type Description
NDArray NDArray

edges of the geometry entity

lengths property

Returns the length of all the segments that make up the geometry entity

Returns:

Name Type Description
NDArray NDArray

array of shape (n_points)

n_points property

Returns the number of points this geometric object is made of

Returns:

Name Type Description
int int

number of points that composes the geomtric object

segments property

Get the segments of the geometry entity

Returns:

Name Type Description
NDArray list[Segment]

segments of the geometry entity

shapely_edges abstractmethod property

Representation of the geometric object in the shapely library as a geometrical object defined only as a curve with no area. Particularly useful to look for points intersections

shapely_surface abstractmethod property

Representation of the geometric object in the shapely library as a geometrical object with an area and a border. Particularly useful to check if two geometrical objects are contained within each other or not.

xmax property

Get the maximum X coordinate of the geometry entity

Returns:

Name Type Description
NDArray float

2D point

xmin property

Get the minimum X coordinate of the geometry entity

Returns:

Name Type Description
NDArray float

2D point

ymax property

Get the maximum Y coordinate of the geometry entity

Returns:

Name Type Description
NDArray float

2D point

ymin property

Get the minimum Y coordinate of the geometry entity

Returns:

Name Type Description
NDArray float

2D point

clamp(xmin=-np.inf, xmax=np.inf, ymin=-np.inf, ymax=np.inf)

Clamp the Geometry entity so that the x and y coordinates fit in the min and max values in parameters.

Parameters:

Name Type Description Default
xmin float

x coordinate minimum

-inf
xmax float

x coordinate maximum

inf
ymin float

y coordinate minimum

-inf
ymax float

y coordinate maximum

inf

Returns:

Name Type Description
GeometryEntity Self

clamped GeometryEntity

Source code in otary/geometry/discrete/entity.py
def clamp(
    self,
    xmin: float = -np.inf,
    xmax: float = np.inf,
    ymin: float = -np.inf,
    ymax: float = np.inf,
) -> Self:
    """Clamp the Geometry entity so that the x and y coordinates fit in the
    min and max values in parameters.

    Args:
        xmin (float): x coordinate minimum
        xmax (float): x coordinate maximum
        ymin (float): y coordinate minimum
        ymax (float): y coordinate maximum

    Returns:
        GeometryEntity: clamped GeometryEntity
    """
    self.asarray[:, 0] = np.clip(self.asarray[:, 0], xmin, xmax)  # x values
    self.asarray[:, 1] = np.clip(self.asarray[:, 1], ymin, ymax)  # y values
    return self

copy()

Create a copy of the geometry entity object

Returns:

Name Type Description
GeometryEntity Self

copy of the geometry entity object

Source code in otary/geometry/discrete/entity.py
def copy(self) -> Self:
    """Create a copy of the geometry entity object

    Returns:
        GeometryEntity: copy of the geometry entity object
    """
    return type(self)(points=self.asarray.copy(), is_cast_int=self.is_cast_int)

distances_vertices_to_point(point)

Get the distance from all vertices in the geometry entity to the input point

Parameters:

Name Type Description Default
point NDArray

2D point

required

Returns:

Name Type Description
NDArray NDArray

array of the same len as the number of vertices in the geometry entity.

Source code in otary/geometry/discrete/entity.py
def distances_vertices_to_point(self, point: NDArray) -> NDArray:
    """Get the distance from all vertices in the geometry entity to the input point

    Args:
        point (NDArray): 2D point

    Returns:
        NDArray: array of the same len as the number of vertices in the geometry
            entity.
    """
    return np.linalg.norm(self.asarray - point, axis=1)

enclosing_axis_aligned_bbox()

Compute the smallest area enclosing Axis-Aligned Bounding Box (AABB) See: https://docs.opencv.org/3.4/dd/d49/tutorial_py_contour_features.html

Return the points in the following order: 1. top left 2. top right 3. bottom right 4. bottom left

Returns:

Name Type Description
Rectangle Rectangle

Rectangle object

Source code in otary/geometry/discrete/entity.py
def enclosing_axis_aligned_bbox(self) -> Rectangle:
    """Compute the smallest area enclosing Axis-Aligned Bounding Box (AABB)
    See: https://docs.opencv.org/3.4/dd/d49/tutorial_py_contour_features.html

    Return the points in the following order:
    1. top left
    2. top right
    3. bottom right
    4. bottom left

    Returns:
        Rectangle: Rectangle object
    """
    # pylint: disable=import-outside-toplevel
    from otary.geometry import Rectangle  # delayed import to avoid circular import

    topleft_x, topleft_y, width, height = cv2.boundingRect(
        array=self.asarray.astype(np.float32)
    )

    # pylint: disable=duplicate-code
    bbox = np.array(
        [
            [topleft_x, topleft_y],
            [topleft_x + width, topleft_y],
            [topleft_x + width, topleft_y + height],
            [topleft_x, topleft_y + height],
        ]
    )
    return Rectangle(bbox)

enclosing_convex_hull()

Compute the smallest area enclosing Convex Hull See: https://docs.opencv.org/3.4/dd/d49/tutorial_py_contour_features.html

Returns:

Name Type Description
Polygon Polygon

Polygon object

Source code in otary/geometry/discrete/entity.py
def enclosing_convex_hull(self) -> Polygon:
    """Compute the smallest area enclosing Convex Hull
    See: https://docs.opencv.org/3.4/dd/d49/tutorial_py_contour_features.html

    Returns:
        Polygon: Polygon object
    """
    # pylint: disable=import-outside-toplevel
    from otary.geometry import Polygon  # delayed import to avoid circular import

    convexhull = np.squeeze(cv2.convexHull(self.asarray.astype(np.float32)))
    return Polygon(convexhull)

enclosing_oriented_bbox()

Compute the smallest area enclosing Oriented Bounding Box (OBB) See: https://docs.opencv.org/3.4/dd/d49/tutorial_py_contour_features.html

Returns:

Name Type Description
Rectangle Rectangle

Rectangle object

Source code in otary/geometry/discrete/entity.py
def enclosing_oriented_bbox(self) -> Rectangle:
    """Compute the smallest area enclosing Oriented Bounding Box (OBB)
    See: https://docs.opencv.org/3.4/dd/d49/tutorial_py_contour_features.html

    Returns:
        Rectangle: Rectangle object
    """
    # pylint: disable=import-outside-toplevel
    from otary.geometry import Rectangle  # delayed import to avoid circular import

    rect = cv2.minAreaRect(self.asarray.astype(np.float32))
    bbox = cv2.boxPoints(rect)
    return Rectangle(bbox)

find_shared_approx_vertices(other, margin_dist_error=5)

Get the shared vertices between two geometric objects.

A vertice is considered shared if it is close enough to another vertice in the other geometric structure.

Parameters:

Name Type Description Default
other DiscreteGeometryEntity

a DiscreteGeometryEntity object

required
margin_dist_error float

the threshold to define a vertice as shared or not. Defaults to 5.

5

Returns:

Name Type Description
NDArray NDArray

list of vertices identified as shared between the two geometric objects

Source code in otary/geometry/discrete/entity.py
def find_shared_approx_vertices(
    self, other: DiscreteGeometryEntity, margin_dist_error: float = 5
) -> NDArray:
    """Get the shared vertices between two geometric objects.

    A vertice is considered shared if it is close enough to another vertice
    in the other geometric structure.

    Args:
        other (DiscreteGeometryEntity): a DiscreteGeometryEntity object
        margin_dist_error (float, optional): the threshold to define a vertice as
            shared or not. Defaults to 5.

    Returns:
        NDArray: list of vertices identified as shared between the two geometric
            objects
    """
    indices = self.find_shared_approx_vertices_ix(
        other=other, margin_dist_error=margin_dist_error
    )
    return self.asarray[indices]

find_shared_approx_vertices_ix(other, margin_dist_error=5)

Compute the vertices indices from this entity that correspond to shared vertices with the other geometric entity.

A vertice is considered shared if it is close enough to another vertice in the other geometric structure.

Parameters:

Name Type Description Default
other DiscreteGeometryEntity

other Discrete Geometry entity

required
margin_dist_error float

minimum distance to have two vertices considered as close enough to be shared. Defaults to 5.

5

Returns:

Name Type Description
NDArray NDArray

list of indices

Source code in otary/geometry/discrete/entity.py
def find_shared_approx_vertices_ix(
    self, other: DiscreteGeometryEntity, margin_dist_error: float = 5
) -> NDArray:
    """Compute the vertices indices from this entity that correspond to shared
    vertices with the other geometric entity.

    A vertice is considered shared if it is close enough to another vertice
    in the other geometric structure.

    Args:
        other (DiscreteGeometryEntity): other Discrete Geometry entity
        margin_dist_error (float, optional): minimum distance to have two vertices
            considered as close enough to be shared. Defaults to 5.

    Returns:
        NDArray: list of indices
    """
    return get_shared_point_indices(
        points_to_check=self.asarray,
        checkpoints=other.asarray,
        margin_dist_error=margin_dist_error,
        method="close",
        cond="any",
    )

find_vertice_ix_closest_from(point)

Get the index of the closest vertice from a given point

Parameters:

Name Type Description Default
point NDArray

2D point

required

Returns:

Name Type Description
int int

the index of the closest point in the entity from the input point

Source code in otary/geometry/discrete/entity.py
def find_vertice_ix_closest_from(self, point: NDArray) -> int:
    """Get the index of the closest vertice from a given point

    Args:
        point (NDArray): 2D point

    Returns:
        int: the index of the closest point in the entity from the input point
    """
    return np.argmin(self.distances_vertices_to_point(point=point)).astype(int)

find_vertice_ix_farthest_from(point)

Get the index of the farthest vertice from a given point

Parameters:

Name Type Description Default
point NDArray

2D point

required

Returns:

Name Type Description
int int

the index of the farthest vertice in the entity from the input point

Source code in otary/geometry/discrete/entity.py
def find_vertice_ix_farthest_from(self, point: NDArray) -> int:
    """Get the index of the farthest vertice from a given point

    Args:
        point (NDArray): 2D point

    Returns:
        int: the index of the farthest vertice in the entity from the input point
    """
    return np.argmax(self.distances_vertices_to_point(point=point)).astype(int)

find_vertices_far_from(points, min_distance=5)

Get vertices that belongs to the geometric structure far from the points in parameters.

Parameters:

Name Type Description Default
points NDArray

input list of points

required
min_distance float

the threshold to define a point as far enough or not from a vertice. Defaults to 5.

5

Returns:

Name Type Description
NDArray NDArray

vertices that belongs to the geometric structure and that are far from the input points.

Source code in otary/geometry/discrete/entity.py
def find_vertices_far_from(
    self, points: NDArray, min_distance: float = 5
) -> NDArray:
    """Get vertices that belongs to the geometric structure far from the points in
    parameters.

    Args:
        points (NDArray): input list of points
        min_distance (float, optional): the threshold to define a point as
            far enough or not from a vertice. Defaults to 5.

    Returns:
        NDArray: vertices that belongs to the geometric structure and that
            are far from the input points.
    """
    indices = get_shared_point_indices(
        points_to_check=self.asarray,
        checkpoints=points,
        margin_dist_error=min_distance,
        method="far",
        cond="all",
    )
    return self.asarray[indices]

longest_dist_vertices_to_point(point)

Compute the longest distance from the geometry entity vertices to the point

Parameters:

Name Type Description Default
point NDArray

2D point

required

Returns:

Name Type Description
float float

longest distance from the geometry entity vertices to the point

Source code in otary/geometry/discrete/entity.py
def longest_dist_vertices_to_point(self, point: NDArray) -> float:
    """Compute the longest distance from the geometry entity vertices to the point

    Args:
        point (NDArray): 2D point

    Returns:
        float: longest distance from the geometry entity vertices to the point
    """
    return np.max(self.distances_vertices_to_point(point=point))

normalize(x, y)

Normalize the geometry entity by dividing the points by a norm on the x and y coordinates.

Parameters:

Name Type Description Default
x float

x coordinate norm

required
y float

y coordinate norm

required

Returns:

Name Type Description
GeometryEntity Self

normalized GeometryEntity

Source code in otary/geometry/discrete/entity.py
def normalize(self, x: float, y: float) -> Self:
    """Normalize the geometry entity by dividing the points by a norm on the
    x and y coordinates.

    Args:
        x (float): x coordinate norm
        y (float): y coordinate norm

    Returns:
        GeometryEntity: normalized GeometryEntity
    """
    if x == 0 or y == 0:
        raise ValueError("x or y cannot be 0")
    self.asarray = self.asarray / np.array([x, y])
    return self

rotate(angle, is_degree=False, is_clockwise=True, pivot=None)

Rotate the geometry entity object. A pivot point can be passed as an argument to rotate the object around the pivot

Parameters:

Name Type Description Default
angle float

rotation angle

required
is_degree bool

whether the angle is in degree or radian. Defaults to False which means radians.

False
is_clockwise bool

whether the rotation is clockwise or counter-clockwise. Defaults to True.

True
pivot NDArray

pivot point. Defaults to None which means that by default the centroid point of the shape is taken as the pivot point.

None

Returns:

Name Type Description
GeometryEntity Self

rotated geometry entity object.

Source code in otary/geometry/discrete/entity.py
def rotate(
    self,
    angle: float,
    is_degree: bool = False,
    is_clockwise: bool = True,
    pivot: Optional[NDArray] = None,
) -> Self:
    """Rotate the geometry entity object.
    A pivot point can be passed as an argument to rotate the object around the pivot

    Args:
        angle (float): rotation angle
        is_degree (bool, optional): whether the angle is in degree or radian.
            Defaults to False which means radians.
        is_clockwise (bool, optional): whether the rotation is clockwise or
            counter-clockwise. Defaults to True.
        pivot (NDArray, optional): pivot point.
            Defaults to None which means that by default the centroid point of
            the shape is taken as the pivot point.

    Returns:
        GeometryEntity: rotated geometry entity object.
    """
    if pivot is None:
        pivot = self.centroid

    self.points = rotate_2d_points(
        points=self.points,
        angle=angle,
        pivot=pivot,
        is_degree=is_degree,
        is_clockwise=is_clockwise,
    )
    return self

rotate_around_image_center(img, angle, degree=False)

Given an geometric object and an image, rotate the object around the image center point.

Parameters:

Name Type Description Default
img NDArray

image as a shape (x, y) sized array

required
angle float

rotation angle

required
degree bool

whether the angle is in degree or radian. Defaults to False which means radians.

False

Returns:

Name Type Description
GeometryEntity Self

rotated geometry entity object.

Source code in otary/geometry/discrete/entity.py
def rotate_around_image_center(
    self, img: NDArray, angle: float, degree: bool = False
) -> Self:
    """Given an geometric object and an image, rotate the object around
    the image center point.

    Args:
        img (NDArray): image as a shape (x, y) sized array
        angle (float): rotation angle
        degree (bool, optional): whether the angle is in degree or radian.
            Defaults to False which means radians.

    Returns:
        GeometryEntity: rotated geometry entity object.
    """
    img_center_point = np.array([img.shape[1], img.shape[0]]) / 2
    return self.rotate(angle=angle, pivot=img_center_point, is_degree=degree)

shift(vector)

Shift the geometry entity by the vector direction

Parameters:

Name Type Description Default
vector NDArray

vector that describes the shift as a array with two elements. Example: [2, -8] which describes the vector [[0, 0], [2, -8]]. The vector can also be a vector of shape (2, 2) of the form [[2, 6], [1, 3]].

required

Returns:

Name Type Description
GeometryEntity Self

shifted geometrical object

Source code in otary/geometry/discrete/entity.py
def shift(self, vector: NDArray) -> Self:
    """Shift the geometry entity by the vector direction

    Args:
        vector (NDArray): vector that describes the shift as a array with
            two elements. Example: [2, -8] which describes the
            vector [[0, 0], [2, -8]]. The vector can also be a vector of shape
            (2, 2) of the form [[2, 6], [1, 3]].

    Returns:
        GeometryEntity: shifted geometrical object
    """
    vector = assert_transform_shift_vector(vector=vector)
    self.points = self.points + vector
    return self

shortest_dist_vertices_to_point(point)

Compute the shortest distance from the geometry entity vertices to the point

Parameters:

Name Type Description Default
point NDArray

2D point

required

Returns:

Name Type Description
float float

shortest distance from the geometry entity vertices to the point

Source code in otary/geometry/discrete/entity.py
def shortest_dist_vertices_to_point(self, point: NDArray) -> float:
    """Compute the shortest distance from the geometry entity vertices to the point

    Args:
        point (NDArray): 2D point

    Returns:
        float: shortest distance from the geometry entity vertices to the point
    """
    return np.min(self.distances_vertices_to_point(point=point))

Point class useful to describe any kind of points

Point

Bases: DiscreteGeometryEntity

Point class

Source code in otary/geometry/discrete/point.py
class Point(DiscreteGeometryEntity):
    """Point class"""

    def __init__(self, point: NDArray, is_cast_int: bool = False) -> None:
        point = self._ensure_transform_point_array(point=point)
        super().__init__(points=point, is_cast_int=is_cast_int)

    @staticmethod
    def _ensure_transform_point_array(point: NDArray) -> NDArray:
        point = np.asarray(point)
        if point.shape == (2,):
            point = point.reshape((1, 2))
        if len(point) != 1:
            raise ValueError(f"The input point has not the expected shape {point}")
        return point

    @property
    def asarray(self):
        return self.points

    @asarray.setter
    def asarray(self, value: NDArray):
        """Setter for the asarray property

        Args:
            value (NDArray): value of the asarray to be changed
        """
        self.points = self._ensure_transform_point_array(point=value)

    @property
    def centroid(self) -> NDArray:
        """Return the point as the centroid of a point is simply the point

        Returns:
            NDArray: centroid of the point
        """
        return self.asarray[0]

    @property
    def shapely_edges(self) -> SPoint:
        """Returns the Shapely.Point representation of the point.
        See https://shapely.readthedocs.io/en/stable/reference/shapely.Point.html

        Returns:
            Point: shapely.Point object
        """
        return SPoint(self.asarray)

    @property
    def shapely_surface(self) -> SPoint:
        """Returns None since a point has no surface

        Returns:
            None: None value
        """
        return None

    @property
    def area(self) -> float:
        """Compute the area of the geometry entity

        Returns:
            float: area value
        """
        return 0

    @property
    def perimeter(self) -> float:
        """Compute the perimeter of the geometry entity

        Returns:
            float: perimeter value
        """
        return 0

    @property
    def edges(self) -> NDArray:
        """Get the edges of the point which returns empty array
        since a point has no edges

        Returns:
            NDArray: empty array of shape (0, 2, 2)
        """
        return np.empty(shape=(0, 2, 2))

    @staticmethod
    def order_idxs_points_by_dist(points: NDArray, desc: bool = False) -> NDArray:
        """Beware the method expects points to be collinear.

        Given four points [p0, p1, p2, p3], we wish to have the order in which each
        point is separated.
        The one closest to the origin is placed at the origin and relative to this
        point we are able to know at which position are the other points.

        If p0 is closest to the origin and the closest points from p0 are in order
        p2, p1 and p3. Thus the array returned by the function is [0, 2, 1, 3].

        Args:
            points (NDArray): numpy array of shape (n, 2)
            desc (bool): if True returns the indices based on distances descending
                order. Otherwise ascending order which is the default.

        Returns:
            NDArray: indices of the points
        """
        distances = np.linalg.norm(x=points, axis=1)
        idxs_order_by_dist = np.argsort(distances)
        if not desc:  # change the order if in descending order
            idxs_order_by_dist = idxs_order_by_dist[::-1]
        return idxs_order_by_dist

    def distances_vertices_to_point(self, point: NDArray) -> NDArray:
        """Compute the distances to a given point

        Args:
            point (NDArray): point to which we want to compute the distances

        Returns:
            NDArray: distance to the given point
        """
        return np.linalg.norm(self.points - point, axis=1)

    def __str__(self) -> str:
        return self.__class__.__name__ + "(" + self.asarray[0].tolist().__str__() + ")"

    def __repr__(self) -> str:
        return str(self)

area property

Compute the area of the geometry entity

Returns:

Name Type Description
float float

area value

centroid property

Return the point as the centroid of a point is simply the point

Returns:

Name Type Description
NDArray NDArray

centroid of the point

edges property

Get the edges of the point which returns empty array since a point has no edges

Returns:

Name Type Description
NDArray NDArray

empty array of shape (0, 2, 2)

perimeter property

Compute the perimeter of the geometry entity

Returns:

Name Type Description
float float

perimeter value

shapely_edges property

Returns the Shapely.Point representation of the point. See https://shapely.readthedocs.io/en/stable/reference/shapely.Point.html

Returns:

Name Type Description
Point Point

shapely.Point object

shapely_surface property

Returns None since a point has no surface

Returns:

Name Type Description
None Point

None value

distances_vertices_to_point(point)

Compute the distances to a given point

Parameters:

Name Type Description Default
point NDArray

point to which we want to compute the distances

required

Returns:

Name Type Description
NDArray NDArray

distance to the given point

Source code in otary/geometry/discrete/point.py
def distances_vertices_to_point(self, point: NDArray) -> NDArray:
    """Compute the distances to a given point

    Args:
        point (NDArray): point to which we want to compute the distances

    Returns:
        NDArray: distance to the given point
    """
    return np.linalg.norm(self.points - point, axis=1)

order_idxs_points_by_dist(points, desc=False) staticmethod

Beware the method expects points to be collinear.

Given four points [p0, p1, p2, p3], we wish to have the order in which each point is separated. The one closest to the origin is placed at the origin and relative to this point we are able to know at which position are the other points.

If p0 is closest to the origin and the closest points from p0 are in order p2, p1 and p3. Thus the array returned by the function is [0, 2, 1, 3].

Parameters:

Name Type Description Default
points NDArray

numpy array of shape (n, 2)

required
desc bool

if True returns the indices based on distances descending order. Otherwise ascending order which is the default.

False

Returns:

Name Type Description
NDArray NDArray

indices of the points

Source code in otary/geometry/discrete/point.py
@staticmethod
def order_idxs_points_by_dist(points: NDArray, desc: bool = False) -> NDArray:
    """Beware the method expects points to be collinear.

    Given four points [p0, p1, p2, p3], we wish to have the order in which each
    point is separated.
    The one closest to the origin is placed at the origin and relative to this
    point we are able to know at which position are the other points.

    If p0 is closest to the origin and the closest points from p0 are in order
    p2, p1 and p3. Thus the array returned by the function is [0, 2, 1, 3].

    Args:
        points (NDArray): numpy array of shape (n, 2)
        desc (bool): if True returns the indices based on distances descending
            order. Otherwise ascending order which is the default.

    Returns:
        NDArray: indices of the points
    """
    distances = np.linalg.norm(x=points, axis=1)
    idxs_order_by_dist = np.argsort(distances)
    if not desc:  # change the order if in descending order
        idxs_order_by_dist = idxs_order_by_dist[::-1]
    return idxs_order_by_dist

Shape

Polygon class to handle complexity with polygon calculation

Polygon

Bases: DiscreteGeometryEntity

Polygon class which defines a polygon object which means any closed-shape

Source code in otary/geometry/discrete/shape/polygon.py
 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
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
class Polygon(DiscreteGeometryEntity):
    """Polygon class which defines a polygon object which means any closed-shape"""

    # pylint: disable=too-many-public-methods

    def __init__(self, points: NDArray | list, is_cast_int: bool = False) -> None:
        if len(points) <= 2:
            raise ValueError(
                "Cannot create a Polygon since it must have 3 or more points"
            )
        super().__init__(points=points, is_cast_int=is_cast_int)

    # ---------------------------------- OTHER CONSTRUCTORS ----------------------------

    @classmethod
    def from_lines(cls, lines: NDArray) -> Polygon:
        """The lines should describe a perfect closed shape polygon

        Args:
            lines (NDArray): array of lines of shape (n, 2, 2)

        Returns:
            (Polygon): a Polygon object
        """
        nlines = len(lines)
        shifted_lines = np.roll(
            np.array(lines).reshape(nlines * 2, 2), shift=1, axis=0
        ).reshape(nlines, 2, 2)
        distances = np.linalg.norm(np.diff(shifted_lines, axis=1), axis=2)
        if np.any(distances):  # a distance is different from 0
            bad_idxs = np.nonzero(distances > 0)
            raise ValueError(
                f"Could not construct the polygon from the given lines."
                f"Please check at those indices: {bad_idxs}"
            )
        points = lines[:, 0]
        return Polygon(points=points)

    @classmethod
    def from_linear_entities_returns_vertices_ix(
        cls, linear_entities: Sequence[LinearEntity]
    ) -> tuple[Polygon, list[int]]:
        """Convert a list of linear entities to polygon.

        Beware: this method assumes entities are sorted and connected.
        Conneted means that the last point of each entity is the first point
        of the next entity.
        This implies that the polygon is necessarily closed.

        Args:
            linear_entities (Sequence[LinearEntity]): List of linear entities.

        Returns:
            (Polygon, list[int]): polygon and indices of first vertex of each entity
        """
        points = []
        vertices_ix: list[int] = []
        current_ix = 0
        for i, linear_entity in enumerate(linear_entities):
            if not isinstance(linear_entity, LinearEntity):
                raise TypeError(
                    f"Expected a list of LinearEntity, but got {type(linear_entity)}"
                )

            cond_first_pt_is_equal_prev_entity_last_pt = np.array_equal(
                linear_entity.points[0], linear_entities[i - 1].points[-1]
            )
            if not cond_first_pt_is_equal_prev_entity_last_pt:
                raise ValueError(
                    f"The first point of entity {i} ({linear_entity.points[0]}) "
                    f"is not equal to the last point of entity {i-1} "
                    f"({linear_entities[i-1].points[-1]})"
                )
            pts_except_last = linear_entity.points[:-1, :]
            points.append(pts_except_last)
            vertices_ix.append(current_ix)
            current_ix += len(pts_except_last)

        points = np.concatenate(points, axis=0)
        polygon = Polygon(points=points)
        return polygon, vertices_ix

    @classmethod
    def from_linear_entities(
        cls,
        linear_entities: Sequence[LinearEntity],
    ) -> Polygon:
        """Convert a list of linear entities to polygon.

        Beware: the method assumes entities are sorted and connected.

        Args:
            linear_entities (Sequence[LinearEntity]): List of linear entities.

        Returns:
            Polygon: polygon representation of the linear entity
        """
        return cls.from_linear_entities_returns_vertices_ix(linear_entities)[0]

    @classmethod
    def from_unordered_lines_approx(
        cls,
        lines: NDArray,
        max_dist_thresh: float = 50,
        max_iterations: int = 50,
        start_line_index: int = 0,
        img: Optional[NDArray] = None,
        is_debug_enabled: bool = False,
    ) -> Polygon:
        """Create a Polygon object from an unordered list of lines that approximate a
        closed-shape. They approximate in the sense that they do not necessarily
        share common points. This method computes the intersection points between lines.

        Args:
            img (_type_): array of shape (lx, ly)
            lines (NDArray): array of lines of shape (n, 2, 2)
            max_dist_thresh (float, optional): For any given point,
                the maximum distance to consider two points as close. Defaults to 50.
            max_iterations (float, optional): Maximum number of iterations before
                finding a polygon.
                It defines also the maximum number of lines in the polygon to be found.
            start_line_index (int, optional): The starting line to find searching for
                the polygon. Defaults to 0.

        Returns:
            (Polygon): a Polygon object
        """
        # pylint: disable=too-many-locals
        # pylint: disable=too-many-positional-arguments, too-many-arguments
        lines = np.asarray(lines)
        Segment.assert_list_of_lines(lines=lines)

        def debug_visualize(seg: NDArray):  # pragma: no cover
            if is_debug_enabled and img is not None:
                im = img.copy()
                im = cv2.cvtColor(im, cv2.COLOR_BGR2RGB)
                im = cv2.line(
                    img=im, pt1=seg[0], pt2=seg[1], color=(0, 250, 126), thickness=5
                )
                plt.imshow(im)
                plt.xticks([])
                plt.yticks([])
                plt.show()

        _lines = copy.deepcopy(lines)
        list_build_cnt = []
        is_polygon_found = False
        idx_seg_closest = start_line_index
        i = 0
        while not is_polygon_found and i < max_iterations:
            curseg = Segment(_lines[idx_seg_closest])
            curpoint = curseg.asarray[1]
            list_build_cnt.append(curseg.asarray)
            _lines = np.delete(_lines, idx_seg_closest, axis=0)

            if len(_lines) == 0:
                logging.debug("No more lines to be processed.")

            # find the closest point to the current one and associated line
            lines2points = _lines.reshape(len(_lines) * 2, 2)
            dist_from_curpoint = np.linalg.norm(lines2points - curpoint, axis=1)
            idx_closest_points = np.nonzero(dist_from_curpoint < max_dist_thresh)[0]

            debug_visualize(seg=curseg.asarray)

            if len(idx_closest_points) > 1:
                # more than one point close to the current point - take the closest
                idx_closest_points = np.array([np.argmin(dist_from_curpoint)])
            if len(idx_closest_points) == 0:
                # no point detected - can mean that the polygon is done or not
                first_seg = Segment(list_build_cnt[0])
                if np.linalg.norm(first_seg.asarray[0] - curpoint) < max_dist_thresh:
                    # TODO sometimes multiples intersection example 7
                    intersect_point = curseg.intersection_line(first_seg)
                    list_build_cnt[-1][1] = intersect_point
                    list_build_cnt[0][0] = intersect_point
                    is_polygon_found = True
                    break
                raise RuntimeError("No point detected close to the current point")

            # only one closest point - get indices of unique closest point on segment
            idx_point_closest = int(idx_closest_points[0])
            idx_seg_closest = int(np.floor(idx_point_closest / 2))

            # arrange the line so that the closest point is in the first place
            idx_point_in_line = 0 if (idx_point_closest / 2).is_integer() else 1
            seg_closest = _lines[idx_seg_closest]
            if idx_point_in_line == 1:  # flip points positions
                seg_closest = np.flip(seg_closest, axis=0)
            _lines[idx_seg_closest] = seg_closest

            # find intersection point between the two lines
            intersect_point = curseg.intersection_line(Segment(seg_closest))

            # update arrays with the intersection point
            _lines[idx_seg_closest][0] = intersect_point
            list_build_cnt[i][1] = intersect_point

            i += 1

        cnt = Polygon.from_lines(np.array(list_build_cnt, dtype=np.int32))
        return cnt

    # --------------------------------- PROPERTIES ------------------------------------

    @property
    def shapely_surface(self) -> SPolygon:
        """Returns the Shapely.Polygon as an surface representation of the Polygon.
        See https://shapely.readthedocs.io/en/stable/reference/shapely.Polygon.html

        Returns:
            Polygon: shapely.Polygon object
        """
        return SPolygon(self.asarray, holes=None)

    @property
    def shapely_edges(self) -> LinearRing:
        """Returns the Shapely.LinearRing as a curve representation of the Polygon.
        See https://shapely.readthedocs.io/en/stable/reference/shapely.LinearRing.html

        Returns:
            LinearRing: shapely.LinearRing object
        """
        return LinearRing(coordinates=self.asarray)

    @property
    def centroid(self) -> NDArray:
        """Compute the centroid point which can be seen as the center of gravity
        or center of mass of the shape.

        Beware: if the shape is degenerate, the centroid will be undefined.
        In that case, the mean of the points is returned.

        Returns:
            NDArray: centroid point
        """
        M = cv2.moments(self.asarray.astype(np.float32).reshape((-1, 1, 2)))

        # Avoid division by zero
        if M["m00"] != 0:
            cx = M["m10"] / M["m00"]
            cy = M["m01"] / M["m00"]
            centroid = np.asarray([cx, cy])
        else:
            centroid = self.center_mean

        return centroid

    @property
    def area(self) -> float:
        """Compute the area of the geometry entity

        Returns:
            float: area value
        """
        return cv2.contourArea(self.points.astype(np.int32))

    @property
    def perimeter(self) -> float:
        """Compute the perimeter of the geometry entity

        Returns:
            float: perimeter value
        """
        return cv2.arcLength(self.points.astype(np.float32), True)

    @property
    def is_self_intersected(self) -> bool:
        """Whether any of the segments intersect another segment in the same set

        Returns:
            bool: True if at least two lines intersect, False otherwise
        """
        return not self.shapely_edges.is_simple

    @property
    def is_convex(self) -> bool:
        """Whether the Polygon describes a convex shape of not.

        Returns:
            bool: True if convex else False
        """
        return cv2.isContourConvex(contour=self.asarray)

    @property
    def edges(self) -> NDArray:
        """Get the lines that compose the geometry entity.

        Args:
            points (NDArray): array of points of shape (n, 2)

        Returns:
            NDArray: array of lines of shape (n, 2, 2)
        """
        return np.stack([self.points, np.roll(self.points, shift=-1, axis=0)], axis=1)

    # ------------------------------- CLASSIC METHODS ----------------------------------

    def is_regular(self, margin_dist_error_pct: float = 0.01) -> bool:
        """Identifies whether the polygon is regular, this means is rectangular or is
        a square.

        Args:
            margin_area_error (float, optional): area error. Defaults to 25.

        Returns:
            bool: True if the polygon describes a rectangle or square.
        """
        # check we have four points
        if len(self.asarray) != 4:
            return False

        # compute diagonal 1 = taking reference index as 1st point in list - index 0
        refpoint = self.asarray[0]
        idx_max_dist = self.find_vertice_ix_farthest_from(point=refpoint)
        farther_point = self.asarray[idx_max_dist]
        diag1 = Segment(points=[refpoint, farther_point])

        # compute diagonal 2
        diag2_idxs = [1, 2, 3]  # every index except 0
        diag2_idxs.remove(idx_max_dist)  # delete index of point in first diag
        diag2 = Segment(points=self.asarray[diag2_idxs])

        # rectangular criteria = the diagonals have same lengths
        normed_length = np.sqrt(diag1.length * diag2.length)
        if np.abs(diag1.length - diag2.length) > normed_length * margin_dist_error_pct:
            return False

        # there should exist only one intersection point
        intersection_points = diag1.intersection(other=diag2)
        if len(intersection_points) != 1:
            return False

        # diagonals bisect on the center of both diagonal
        cross_point = intersection_points[0]
        dist_mid_cross_diag1 = np.linalg.norm(cross_point - diag1.centroid)
        dist_mid_cross_diag2 = np.linalg.norm(cross_point - diag2.centroid)
        if (
            np.abs(dist_mid_cross_diag1) > normed_length * margin_dist_error_pct
            or np.abs(dist_mid_cross_diag2) > normed_length * margin_dist_error_pct
        ):
            return False

        return True

    def is_clockwise(self, is_y_axis_down: bool = False) -> bool:
        """Determine if a polygon points go clockwise using the Shoelace formula.

        True if polygon vertices order is clockwise in the "y-axis points up"
        referential.

        Args:
            is_y_axis_down (bool, optional): If is_y_axis_down is True, then the image
                referential is used where y axis points down.

        Returns:
            bool: True if clockwise, False if counter-clockwise
        """
        x = self.asarray[:, 0]
        y = self.asarray[:, 1]

        x_next = np.roll(x, -1)
        y_next = np.roll(y, -1)

        s = np.sum((x_next - x) * (y_next + y))

        is_clockwise = bool(s > 0)  # Clockwise if positive (OpenCV's convention)

        if is_y_axis_down:  # in referential where y axis points down
            return not is_clockwise

        return is_clockwise

    def as_linear_spline(self, index: int = 0) -> LinearSpline:
        """Get the polygon as a LinearSpline object.
        This simply means a LinearSpline object with the same points as the Polygon
        but with an extra point: the one at the index.

        Returns:
            LinearSpline: linear spline from polygon
        """
        if index < 0:
            index += len(self)

        index = index % len(self)

        return LinearSpline(
            points=np.concat(
                [self.asarray[index : len(self)], self.asarray[0 : index + 1]], axis=0
            )
        )

    def contains(self, other: GeometryEntity, dilate_scale: float = 1) -> bool:
        """Whether the geometry contains the other or not

        Args:
            other (GeometryEntity): a GeometryEntity object
            dilate_scale (float): if greater than 1, the object will be scaled up
                before checking if it contains the other Geometry Entity. Can not be
                a value less than 1.

        Returns:
            bool: True if the entity contains the other
        """
        if dilate_scale != 1:
            surface = self.copy().expand(scale=dilate_scale).shapely_surface
        else:
            surface = self.shapely_surface
        return surface.contains(other.shapely_surface)

    def score_vertices_in_points(self, points: NDArray, max_distance: float) -> NDArray:
        """Returns a score of 0 or 1 for each point in the polygon if it is close
        enough to any point in the input points.

        Args:
            points (NDArray): list of 2D points
            margin_dist_error (float): mininum distance to consider two points as
                close enough to be considered as the same points

        Returns:
            NDArray: a list of score for each point in the contour
        """

        indices = get_shared_point_indices(
            points_to_check=self.asarray,
            checkpoints=points,
            margin_dist_error=max_distance,
            method="close",
            cond="any",
        )
        score = np.bincount(indices, minlength=len(self))
        return score

    def find_vertices_between(self, start_index: int, end_index: int) -> NDArray:
        """Get the vertices between two indices.

        Returns always the vertices between start_index and end_index using the
        natural order of the vertices in the contour.

        By convention, if start_index == end_index, then it returns the whole contour
        plus the vertice at start_index.

        Args:
            start_index (int): index of the first vertex
            end_index (int): index of the last vertex

        Returns:
            NDArray: array of vertices
        """
        if start_index < 0:
            start_index += len(self)
        if end_index < 0:
            end_index += len(self)

        start_index = start_index % len(self)
        end_index = end_index % len(self)

        if start_index > end_index:
            vertices = np.concat(
                [
                    self.asarray[start_index : len(self)],
                    self.asarray[0 : end_index + 1],
                ],
                axis=0,
            )
        elif start_index == end_index:
            vertices = self.as_linear_spline(index=start_index).asarray
        else:
            vertices = self.asarray[start_index : end_index + 1]

        return vertices

    def find_interpolated_point_and_prev_ix(
        self, start_index: int, end_index: int, pct_dist: float
    ) -> tuple[NDArray, int]:
        """Return a point along the contour path from start_idx to end_idx (inclusive),
        at a relative distance pct_dist ∈ [0, 1] along that path.

        By convention, if start_index == end_index, then use the whole contour
        start at this index position.

        Parameters:
            start_idx (int): Index of the start point in the contour
            end_idx (int): Index of the end point in the contour
            pct_dist (float): Value in [0, 1], 0 returns start, 1 returns end.
                Any value in [0, 1] returns a point between start and end that is
                pct_dist along the path.

        Returns:
            NDArray: Interpolated point [x, y]
        """
        if not 0 <= pct_dist <= 1:
            raise ValueError("pct_dist must be in [0, 1]")

        if start_index < 0:
            start_index += len(self)
        if end_index < 0:
            end_index += len(self)

        start_index = start_index % len(self)
        end_index = end_index % len(self)

        path = LinearSpline(
            points=self.find_vertices_between(
                start_index=start_index, end_index=end_index
            )
        )

        point, index = path.find_interpolated_point_and_prev_ix(pct_dist=pct_dist)
        index = (index + start_index) % len(self)

        return point, index

    def find_interpolated_point(
        self, start_index: int, end_index: int, pct_dist: float
    ) -> NDArray:
        """Return a point along the contour path from start_idx to end_idx (inclusive),
        at a relative distance pct_dist ∈ [0, 1] along that path.

        By convention, if start_index == end_index, then use the whole contour
        start at this index position.

        Parameters:
            start_idx (int): Index of the start point in the contour
            end_idx (int): Index of the end point in the contour
            pct_dist (float): Value in [0, 1], 0 returns start, 1 returns end.
                Any value in [0, 1] returns a point between start and end that is
                pct_dist along the path.

        Returns:
            NDArray: Interpolated point [x, y]
        """
        return self.find_interpolated_point_and_prev_ix(
            start_index=start_index, end_index=end_index, pct_dist=pct_dist
        )[0]

    def normal_point(
        self,
        start_index: int,
        end_index: int,
        dist_along_edge_pct: float,
        dist_from_edge: float,
        is_outward: bool = True,
    ) -> NDArray:
        """Compute the outward normal point.
        This is a point that points toward the outside of the polygon

        Args:
            start_index (int): start index for the edge selection
            end_index (int): end index for the edge selection
            dist_along_edge_pct (float): distance along the edge to place the point
            dist_from_edge (float): distance outward from the edge
            is_outward (bool, optional): True if the normal points to the outside of
                the polygon. False if the normal points to the inside of the polygon.
                Defaults to True.

        Returns:
            NDArray: 2D point as array
        """
        # pylint: disable=too-many-locals
        # pylint: disable=too-many-arguments, too-many-positional-arguments,
        if not 0.0 <= dist_along_edge_pct <= 1.0:
            raise ValueError("dist_along_edge_pct must be in [0, 1]")

        pt_interpolated, prev_ix = self.find_interpolated_point_and_prev_ix(
            start_index=start_index, end_index=end_index, pct_dist=dist_along_edge_pct
        )
        next_ix = (prev_ix + 1) % len(self)

        is_interpolated_pt_existing_edge = np.array_equal(
            pt_interpolated, self.asarray[prev_ix]
        ) or np.array_equal(pt_interpolated, self.asarray[next_ix])
        if is_interpolated_pt_existing_edge:
            raise ValueError(
                "Interpolated point for normal computation is an existing vertice "
                "along polygon. Please choose another dist_along_edge_pct parameter."
            )

        edge = Vector(points=[self.asarray[prev_ix], self.asarray[next_ix]])

        normal = edge.normal().normalized

        pt_plus = pt_interpolated + dist_from_edge * normal
        pt_minus = pt_interpolated - dist_from_edge * normal

        dist_plus = np.linalg.norm(pt_plus - self.centroid)
        dist_minus = np.linalg.norm(pt_minus - self.centroid)

        # choose the point which distance to the center is greater
        if dist_plus > dist_minus:
            if is_outward:
                return pt_plus
            return pt_minus

        if is_outward:
            return pt_minus
        return pt_plus

    def inter_area(self, other: Polygon) -> float:
        """Inter area with another Polygon

        Args:
            other (Polygon): other Polygon

        Returns:
            float: inter area value
        """
        inter_pts = cv2.intersectConvexConvex(self.asarray, other.asarray)
        if inter_pts[0] > 0:
            inter_area = cv2.contourArea(inter_pts[1])
        else:
            inter_area = 0.0
        return inter_area

    def union_area(self, other: Polygon) -> float:
        """Union area with another Polygon

        Args:
            other (Polygon): other Polygon

        Returns:
            float: union area value
        """
        return self.area + other.area - self.inter_area(other)

    def iou(self, other: Polygon) -> float:
        """Intersection over union with another Polygon

        Args:
            other (Polygon): other Polygon

        Returns:
            float: intersection over union value
        """
        inter_area = self.inter_area(other)

        # optimized not to compute twice the inter area
        union_area = self.area + other.area - inter_area

        if union_area == 0:
            return 0.0
        return inter_area / union_area

    # ---------------------------- MODIFICATION METHODS -------------------------------

    def add_vertice(self, point: NDArray, index: int) -> Self:
        """Add a point at a given index in the Polygon object

        Args:
            point (NDArray): point to be added
            index (int): index where the point will be added

        Returns:
            Polygon: Polygon object with an added point
        """
        size = len(self)
        if index >= size:
            raise ValueError(
                f"The index value {index} is too big. "
                f"The maximum possible index value is {size-1}."
            )
        if index < 0:
            if abs(index) > size + 1:
                raise ValueError(
                    f"The index value {index} is too small. "
                    f"The minimum possible index value is {-(size+1)}"
                )
            index = size + index + 1

        self.points = np.concatenate(
            [self.points[:index], [point], self.points[index:]]
        )
        return self

    def rearrange_first_vertice_at_index(self, index: int) -> Self:
        """Rearrange the list of points that defines the Polygon so that the first
        point in the list of points is the one at index given by the argument of this
        function.

        Args:
            index (int): index value

        Returns:
            Polygon: Polygon which is the exact same one but with a rearranged list
                of points.
        """
        size = len(self)
        if index >= size:
            raise ValueError(
                f"The index value {index} is too big. "
                f"The maximum possible index value is {size-1}."
            )
        if index < 0:
            if abs(index) > size:
                raise ValueError(
                    f"The index value {index} is too small. "
                    f"The minimum possible index value is {-size}"
                )
            index = size + index

        self.points = np.concatenate([self.points[index:], self.points[:index]])
        return self

    def rearrange_first_vertice_closest_to_point(
        self, point: NDArray = np.zeros(shape=(2,))
    ) -> Polygon:
        """Rearrange the list of vertices that defines the Polygon so that the first
        point in the list of vertices is the one that is the closest by distance to
        the reference point.

        Args:
            reference_point (NDArray): point that is taken as a reference in the
                space to find the one in the Polygon list of points that is the
                closest to this reference point. Default to origin point [0, 0].

        Returns:
            Polygon: Polygon which is the exact same one but with a rearranged list
                of points.
        """
        idx_min_dist = self.find_vertice_ix_closest_from(point=point)
        return self.rearrange_first_vertice_at_index(index=idx_min_dist)

    def reorder_clockwise(self, is_y_axis_down: bool = False) -> Polygon:
        """Reorder the vertices of the polygon in clockwise order where the first point
        stays the same.

        Args:
            is_y_axis_down (bool, optional): True if cv2 is used. Defaults to False.

        Returns:
            Polygon: reordered polygon
        """
        if self.is_clockwise(is_y_axis_down=is_y_axis_down):
            return self
        self.asarray = np.roll(self.asarray[::-1], shift=1, axis=0)
        return self

    def __rescale(self, scale: float) -> Polygon:
        """Create a new polygon that is scaled up or down.

        The rescale method compute the vector that is directed from the polygon center
        to each point. Then it rescales each vector and use the head point of each
        vector to compose the new scaled polygon.

        Args:
            scale (float): float value to scale the polygon

        Returns:
            Polygon: scaled polygon
        """
        if scale == 1.0:  # no rescaling
            return self

        center = self.centroid
        self.asarray = self.asarray.astype(float)
        for i, point in enumerate(self.asarray):
            self.asarray[i] = Vector([center, point]).rescale_head(scale).head
        return self

    def expand(self, scale: float) -> Polygon:
        """Stretch, dilate or expand a polygon

        Args:
            scale (float): scale expanding factor. Must be greater than 1.

        Returns:
            Polygon: new bigger polygon
        """
        if scale < 1:
            raise ValueError(
                "The scale value can not be less than 1 when expanding a polygon. "
                f"Found {scale}"
            )
        return self.__rescale(scale=scale)

    def shrink(self, scale: float) -> Polygon:
        """Contract or shrink a polygon

        Args:
            scale (float): scale shrinking factor. Must be greater than 1.

        Returns:
            Polygon: new bigger polygon
        """
        if scale < 1:
            raise ValueError(
                "The scale value can not be less than 1 when shrinking a polygon. "
                f"Found {scale}"
            )
        return self.__rescale(scale=1 / scale)

    def to_image_crop_referential(
        self,
        other: Polygon,
        crop: Rectangle,
        image_crop_shape: Optional[tuple[int, int]] = None,
    ) -> Polygon:
        """This function can be useful for a very specific need:
        In a single image you have two same polygons and their coordinates are defined
        in this image referential.

        You want to obtain the original polygon and all its vertices information
        in the image crop referential to match the other polygon within it.

        This method manipulates three referentials:
        1. image referential (main referential)
        2. crop referential
        3. image crop referential. It is different from the crop referential
            because the width and height of the crop referential may not be the same.

        Args:
            other (Polygon): other Polygon in the image referential
            crop_rect (Rectangle): crop rectangle in the image referential
            image_crop_shape (tuple[int, int], optionla): [width, height] of the crop
                image. If None, the shape is assumed to be directly the crop shape.


        Returns:
            Polygon: original polygon in the image crop referential
        """
        if not crop.contains(other=other):
            raise ValueError(
                f"The crop rectangle {crop} does not contain the other polygon {other}"
            )
        crop_width = int(crop.get_width_from_topleft(0))
        crop_height = int(crop.get_height_from_topleft(0))

        if image_crop_shape is None:
            image_crop_shape = (crop_width, crop_height)

        # self polygon in the original image shifted and normalized
        aabb_main = self.enclosing_axis_aligned_bbox()
        contour_main_shifted_normalized = self.copy().shift(
            vector=-np.asarray([self.xmin, self.ymin])
        ) / np.array(
            [aabb_main.get_width_from_topleft(0), aabb_main.get_height_from_topleft(0)]
        )

        # AABB of the polygon in the crop referential
        aabb_crop = other.enclosing_axis_aligned_bbox()
        aabb_crop_normalized = (
            aabb_crop - np.asarray([crop.xmin, crop.ymin])
        ) / np.array([crop_width, crop_height])

        # obtain the self polygon in the image crop referential
        aabb_crop2 = aabb_crop_normalized * np.array(image_crop_shape)
        new_polygon = contour_main_shifted_normalized * np.array(
            [
                aabb_crop2.get_width_from_topleft(0),
                aabb_crop2.get_height_from_topleft(0),
            ]
        ) + np.asarray([aabb_crop2.xmin, aabb_crop2.ymin])

        return new_polygon

    # ------------------------------- Fundamental Methods ------------------------------

    def is_equal(self, polygon: Polygon, dist_margin_error: float = 5) -> bool:
        """Check whether two polygons objects are equal by considering a margin of
        error based on a distance between points.

        Args:
            polygon (Polygon): Polygon object
            dist_margin_error (float, optional): distance margin of error.
                Defaults to 5.

        Returns:
            bool: True if the polygon are equal, False otherwise
        """
        if self.n_points != polygon.n_points:
            # if polygons do not have the same number of points they can not be similar
            return False

        # check if each points composing the polygons are close to each other
        new_cnt = polygon.copy().rearrange_first_vertice_closest_to_point(
            self.points[0]
        )
        points_diff = new_cnt.points - self.points
        distances = np.linalg.norm(points_diff, axis=1)
        max_distance = np.max(distances)
        return max_distance <= dist_margin_error

area property

Compute the area of the geometry entity

Returns:

Name Type Description
float float

area value

centroid property

Compute the centroid point which can be seen as the center of gravity or center of mass of the shape.

Beware: if the shape is degenerate, the centroid will be undefined. In that case, the mean of the points is returned.

Returns:

Name Type Description
NDArray NDArray

centroid point

edges property

Get the lines that compose the geometry entity.

Parameters:

Name Type Description Default
points NDArray

array of points of shape (n, 2)

required

Returns:

Name Type Description
NDArray NDArray

array of lines of shape (n, 2, 2)

is_convex property

Whether the Polygon describes a convex shape of not.

Returns:

Name Type Description
bool bool

True if convex else False

is_self_intersected property

Whether any of the segments intersect another segment in the same set

Returns:

Name Type Description
bool bool

True if at least two lines intersect, False otherwise

perimeter property

Compute the perimeter of the geometry entity

Returns:

Name Type Description
float float

perimeter value

shapely_edges property

Returns the Shapely.LinearRing as a curve representation of the Polygon. See https://shapely.readthedocs.io/en/stable/reference/shapely.LinearRing.html

Returns:

Name Type Description
LinearRing LinearRing

shapely.LinearRing object

shapely_surface property

Returns the Shapely.Polygon as an surface representation of the Polygon. See https://shapely.readthedocs.io/en/stable/reference/shapely.Polygon.html

Returns:

Name Type Description
Polygon Polygon

shapely.Polygon object

__rescale(scale)

Create a new polygon that is scaled up or down.

The rescale method compute the vector that is directed from the polygon center to each point. Then it rescales each vector and use the head point of each vector to compose the new scaled polygon.

Parameters:

Name Type Description Default
scale float

float value to scale the polygon

required

Returns:

Name Type Description
Polygon Polygon

scaled polygon

Source code in otary/geometry/discrete/shape/polygon.py
def __rescale(self, scale: float) -> Polygon:
    """Create a new polygon that is scaled up or down.

    The rescale method compute the vector that is directed from the polygon center
    to each point. Then it rescales each vector and use the head point of each
    vector to compose the new scaled polygon.

    Args:
        scale (float): float value to scale the polygon

    Returns:
        Polygon: scaled polygon
    """
    if scale == 1.0:  # no rescaling
        return self

    center = self.centroid
    self.asarray = self.asarray.astype(float)
    for i, point in enumerate(self.asarray):
        self.asarray[i] = Vector([center, point]).rescale_head(scale).head
    return self

add_vertice(point, index)

Add a point at a given index in the Polygon object

Parameters:

Name Type Description Default
point NDArray

point to be added

required
index int

index where the point will be added

required

Returns:

Name Type Description
Polygon Self

Polygon object with an added point

Source code in otary/geometry/discrete/shape/polygon.py
def add_vertice(self, point: NDArray, index: int) -> Self:
    """Add a point at a given index in the Polygon object

    Args:
        point (NDArray): point to be added
        index (int): index where the point will be added

    Returns:
        Polygon: Polygon object with an added point
    """
    size = len(self)
    if index >= size:
        raise ValueError(
            f"The index value {index} is too big. "
            f"The maximum possible index value is {size-1}."
        )
    if index < 0:
        if abs(index) > size + 1:
            raise ValueError(
                f"The index value {index} is too small. "
                f"The minimum possible index value is {-(size+1)}"
            )
        index = size + index + 1

    self.points = np.concatenate(
        [self.points[:index], [point], self.points[index:]]
    )
    return self

as_linear_spline(index=0)

Get the polygon as a LinearSpline object. This simply means a LinearSpline object with the same points as the Polygon but with an extra point: the one at the index.

Returns:

Name Type Description
LinearSpline LinearSpline

linear spline from polygon

Source code in otary/geometry/discrete/shape/polygon.py
def as_linear_spline(self, index: int = 0) -> LinearSpline:
    """Get the polygon as a LinearSpline object.
    This simply means a LinearSpline object with the same points as the Polygon
    but with an extra point: the one at the index.

    Returns:
        LinearSpline: linear spline from polygon
    """
    if index < 0:
        index += len(self)

    index = index % len(self)

    return LinearSpline(
        points=np.concat(
            [self.asarray[index : len(self)], self.asarray[0 : index + 1]], axis=0
        )
    )

contains(other, dilate_scale=1)

Whether the geometry contains the other or not

Parameters:

Name Type Description Default
other GeometryEntity

a GeometryEntity object

required
dilate_scale float

if greater than 1, the object will be scaled up before checking if it contains the other Geometry Entity. Can not be a value less than 1.

1

Returns:

Name Type Description
bool bool

True if the entity contains the other

Source code in otary/geometry/discrete/shape/polygon.py
def contains(self, other: GeometryEntity, dilate_scale: float = 1) -> bool:
    """Whether the geometry contains the other or not

    Args:
        other (GeometryEntity): a GeometryEntity object
        dilate_scale (float): if greater than 1, the object will be scaled up
            before checking if it contains the other Geometry Entity. Can not be
            a value less than 1.

    Returns:
        bool: True if the entity contains the other
    """
    if dilate_scale != 1:
        surface = self.copy().expand(scale=dilate_scale).shapely_surface
    else:
        surface = self.shapely_surface
    return surface.contains(other.shapely_surface)

expand(scale)

Stretch, dilate or expand a polygon

Parameters:

Name Type Description Default
scale float

scale expanding factor. Must be greater than 1.

required

Returns:

Name Type Description
Polygon Polygon

new bigger polygon

Source code in otary/geometry/discrete/shape/polygon.py
def expand(self, scale: float) -> Polygon:
    """Stretch, dilate or expand a polygon

    Args:
        scale (float): scale expanding factor. Must be greater than 1.

    Returns:
        Polygon: new bigger polygon
    """
    if scale < 1:
        raise ValueError(
            "The scale value can not be less than 1 when expanding a polygon. "
            f"Found {scale}"
        )
    return self.__rescale(scale=scale)

find_interpolated_point(start_index, end_index, pct_dist)

Return a point along the contour path from start_idx to end_idx (inclusive), at a relative distance pct_dist ∈ [0, 1] along that path.

By convention, if start_index == end_index, then use the whole contour start at this index position.

Parameters:

Name Type Description Default
start_idx int

Index of the start point in the contour

required
end_idx int

Index of the end point in the contour

required
pct_dist float

Value in [0, 1], 0 returns start, 1 returns end. Any value in [0, 1] returns a point between start and end that is pct_dist along the path.

required

Returns:

Name Type Description
NDArray NDArray

Interpolated point [x, y]

Source code in otary/geometry/discrete/shape/polygon.py
def find_interpolated_point(
    self, start_index: int, end_index: int, pct_dist: float
) -> NDArray:
    """Return a point along the contour path from start_idx to end_idx (inclusive),
    at a relative distance pct_dist ∈ [0, 1] along that path.

    By convention, if start_index == end_index, then use the whole contour
    start at this index position.

    Parameters:
        start_idx (int): Index of the start point in the contour
        end_idx (int): Index of the end point in the contour
        pct_dist (float): Value in [0, 1], 0 returns start, 1 returns end.
            Any value in [0, 1] returns a point between start and end that is
            pct_dist along the path.

    Returns:
        NDArray: Interpolated point [x, y]
    """
    return self.find_interpolated_point_and_prev_ix(
        start_index=start_index, end_index=end_index, pct_dist=pct_dist
    )[0]

find_interpolated_point_and_prev_ix(start_index, end_index, pct_dist)

Return a point along the contour path from start_idx to end_idx (inclusive), at a relative distance pct_dist ∈ [0, 1] along that path.

By convention, if start_index == end_index, then use the whole contour start at this index position.

Parameters:

Name Type Description Default
start_idx int

Index of the start point in the contour

required
end_idx int

Index of the end point in the contour

required
pct_dist float

Value in [0, 1], 0 returns start, 1 returns end. Any value in [0, 1] returns a point between start and end that is pct_dist along the path.

required

Returns:

Name Type Description
NDArray tuple[NDArray, int]

Interpolated point [x, y]

Source code in otary/geometry/discrete/shape/polygon.py
def find_interpolated_point_and_prev_ix(
    self, start_index: int, end_index: int, pct_dist: float
) -> tuple[NDArray, int]:
    """Return a point along the contour path from start_idx to end_idx (inclusive),
    at a relative distance pct_dist ∈ [0, 1] along that path.

    By convention, if start_index == end_index, then use the whole contour
    start at this index position.

    Parameters:
        start_idx (int): Index of the start point in the contour
        end_idx (int): Index of the end point in the contour
        pct_dist (float): Value in [0, 1], 0 returns start, 1 returns end.
            Any value in [0, 1] returns a point between start and end that is
            pct_dist along the path.

    Returns:
        NDArray: Interpolated point [x, y]
    """
    if not 0 <= pct_dist <= 1:
        raise ValueError("pct_dist must be in [0, 1]")

    if start_index < 0:
        start_index += len(self)
    if end_index < 0:
        end_index += len(self)

    start_index = start_index % len(self)
    end_index = end_index % len(self)

    path = LinearSpline(
        points=self.find_vertices_between(
            start_index=start_index, end_index=end_index
        )
    )

    point, index = path.find_interpolated_point_and_prev_ix(pct_dist=pct_dist)
    index = (index + start_index) % len(self)

    return point, index

find_vertices_between(start_index, end_index)

Get the vertices between two indices.

Returns always the vertices between start_index and end_index using the natural order of the vertices in the contour.

By convention, if start_index == end_index, then it returns the whole contour plus the vertice at start_index.

Parameters:

Name Type Description Default
start_index int

index of the first vertex

required
end_index int

index of the last vertex

required

Returns:

Name Type Description
NDArray NDArray

array of vertices

Source code in otary/geometry/discrete/shape/polygon.py
def find_vertices_between(self, start_index: int, end_index: int) -> NDArray:
    """Get the vertices between two indices.

    Returns always the vertices between start_index and end_index using the
    natural order of the vertices in the contour.

    By convention, if start_index == end_index, then it returns the whole contour
    plus the vertice at start_index.

    Args:
        start_index (int): index of the first vertex
        end_index (int): index of the last vertex

    Returns:
        NDArray: array of vertices
    """
    if start_index < 0:
        start_index += len(self)
    if end_index < 0:
        end_index += len(self)

    start_index = start_index % len(self)
    end_index = end_index % len(self)

    if start_index > end_index:
        vertices = np.concat(
            [
                self.asarray[start_index : len(self)],
                self.asarray[0 : end_index + 1],
            ],
            axis=0,
        )
    elif start_index == end_index:
        vertices = self.as_linear_spline(index=start_index).asarray
    else:
        vertices = self.asarray[start_index : end_index + 1]

    return vertices

from_linear_entities(linear_entities) classmethod

Convert a list of linear entities to polygon.

Beware: the method assumes entities are sorted and connected.

Parameters:

Name Type Description Default
linear_entities Sequence[LinearEntity]

List of linear entities.

required

Returns:

Name Type Description
Polygon Polygon

polygon representation of the linear entity

Source code in otary/geometry/discrete/shape/polygon.py
@classmethod
def from_linear_entities(
    cls,
    linear_entities: Sequence[LinearEntity],
) -> Polygon:
    """Convert a list of linear entities to polygon.

    Beware: the method assumes entities are sorted and connected.

    Args:
        linear_entities (Sequence[LinearEntity]): List of linear entities.

    Returns:
        Polygon: polygon representation of the linear entity
    """
    return cls.from_linear_entities_returns_vertices_ix(linear_entities)[0]

from_linear_entities_returns_vertices_ix(linear_entities) classmethod

Convert a list of linear entities to polygon.

Beware: this method assumes entities are sorted and connected. Conneted means that the last point of each entity is the first point of the next entity. This implies that the polygon is necessarily closed.

Parameters:

Name Type Description Default
linear_entities Sequence[LinearEntity]

List of linear entities.

required

Returns:

Type Description
(Polygon, list[int])

polygon and indices of first vertex of each entity

Source code in otary/geometry/discrete/shape/polygon.py
@classmethod
def from_linear_entities_returns_vertices_ix(
    cls, linear_entities: Sequence[LinearEntity]
) -> tuple[Polygon, list[int]]:
    """Convert a list of linear entities to polygon.

    Beware: this method assumes entities are sorted and connected.
    Conneted means that the last point of each entity is the first point
    of the next entity.
    This implies that the polygon is necessarily closed.

    Args:
        linear_entities (Sequence[LinearEntity]): List of linear entities.

    Returns:
        (Polygon, list[int]): polygon and indices of first vertex of each entity
    """
    points = []
    vertices_ix: list[int] = []
    current_ix = 0
    for i, linear_entity in enumerate(linear_entities):
        if not isinstance(linear_entity, LinearEntity):
            raise TypeError(
                f"Expected a list of LinearEntity, but got {type(linear_entity)}"
            )

        cond_first_pt_is_equal_prev_entity_last_pt = np.array_equal(
            linear_entity.points[0], linear_entities[i - 1].points[-1]
        )
        if not cond_first_pt_is_equal_prev_entity_last_pt:
            raise ValueError(
                f"The first point of entity {i} ({linear_entity.points[0]}) "
                f"is not equal to the last point of entity {i-1} "
                f"({linear_entities[i-1].points[-1]})"
            )
        pts_except_last = linear_entity.points[:-1, :]
        points.append(pts_except_last)
        vertices_ix.append(current_ix)
        current_ix += len(pts_except_last)

    points = np.concatenate(points, axis=0)
    polygon = Polygon(points=points)
    return polygon, vertices_ix

from_lines(lines) classmethod

The lines should describe a perfect closed shape polygon

Parameters:

Name Type Description Default
lines NDArray

array of lines of shape (n, 2, 2)

required

Returns:

Type Description
Polygon

a Polygon object

Source code in otary/geometry/discrete/shape/polygon.py
@classmethod
def from_lines(cls, lines: NDArray) -> Polygon:
    """The lines should describe a perfect closed shape polygon

    Args:
        lines (NDArray): array of lines of shape (n, 2, 2)

    Returns:
        (Polygon): a Polygon object
    """
    nlines = len(lines)
    shifted_lines = np.roll(
        np.array(lines).reshape(nlines * 2, 2), shift=1, axis=0
    ).reshape(nlines, 2, 2)
    distances = np.linalg.norm(np.diff(shifted_lines, axis=1), axis=2)
    if np.any(distances):  # a distance is different from 0
        bad_idxs = np.nonzero(distances > 0)
        raise ValueError(
            f"Could not construct the polygon from the given lines."
            f"Please check at those indices: {bad_idxs}"
        )
    points = lines[:, 0]
    return Polygon(points=points)

from_unordered_lines_approx(lines, max_dist_thresh=50, max_iterations=50, start_line_index=0, img=None, is_debug_enabled=False) classmethod

Create a Polygon object from an unordered list of lines that approximate a closed-shape. They approximate in the sense that they do not necessarily share common points. This method computes the intersection points between lines.

Parameters:

Name Type Description Default
img _type_

array of shape (lx, ly)

None
lines NDArray

array of lines of shape (n, 2, 2)

required
max_dist_thresh float

For any given point, the maximum distance to consider two points as close. Defaults to 50.

50
max_iterations float

Maximum number of iterations before finding a polygon. It defines also the maximum number of lines in the polygon to be found.

50
start_line_index int

The starting line to find searching for the polygon. Defaults to 0.

0

Returns:

Type Description
Polygon

a Polygon object

Source code in otary/geometry/discrete/shape/polygon.py
@classmethod
def from_unordered_lines_approx(
    cls,
    lines: NDArray,
    max_dist_thresh: float = 50,
    max_iterations: int = 50,
    start_line_index: int = 0,
    img: Optional[NDArray] = None,
    is_debug_enabled: bool = False,
) -> Polygon:
    """Create a Polygon object from an unordered list of lines that approximate a
    closed-shape. They approximate in the sense that they do not necessarily
    share common points. This method computes the intersection points between lines.

    Args:
        img (_type_): array of shape (lx, ly)
        lines (NDArray): array of lines of shape (n, 2, 2)
        max_dist_thresh (float, optional): For any given point,
            the maximum distance to consider two points as close. Defaults to 50.
        max_iterations (float, optional): Maximum number of iterations before
            finding a polygon.
            It defines also the maximum number of lines in the polygon to be found.
        start_line_index (int, optional): The starting line to find searching for
            the polygon. Defaults to 0.

    Returns:
        (Polygon): a Polygon object
    """
    # pylint: disable=too-many-locals
    # pylint: disable=too-many-positional-arguments, too-many-arguments
    lines = np.asarray(lines)
    Segment.assert_list_of_lines(lines=lines)

    def debug_visualize(seg: NDArray):  # pragma: no cover
        if is_debug_enabled and img is not None:
            im = img.copy()
            im = cv2.cvtColor(im, cv2.COLOR_BGR2RGB)
            im = cv2.line(
                img=im, pt1=seg[0], pt2=seg[1], color=(0, 250, 126), thickness=5
            )
            plt.imshow(im)
            plt.xticks([])
            plt.yticks([])
            plt.show()

    _lines = copy.deepcopy(lines)
    list_build_cnt = []
    is_polygon_found = False
    idx_seg_closest = start_line_index
    i = 0
    while not is_polygon_found and i < max_iterations:
        curseg = Segment(_lines[idx_seg_closest])
        curpoint = curseg.asarray[1]
        list_build_cnt.append(curseg.asarray)
        _lines = np.delete(_lines, idx_seg_closest, axis=0)

        if len(_lines) == 0:
            logging.debug("No more lines to be processed.")

        # find the closest point to the current one and associated line
        lines2points = _lines.reshape(len(_lines) * 2, 2)
        dist_from_curpoint = np.linalg.norm(lines2points - curpoint, axis=1)
        idx_closest_points = np.nonzero(dist_from_curpoint < max_dist_thresh)[0]

        debug_visualize(seg=curseg.asarray)

        if len(idx_closest_points) > 1:
            # more than one point close to the current point - take the closest
            idx_closest_points = np.array([np.argmin(dist_from_curpoint)])
        if len(idx_closest_points) == 0:
            # no point detected - can mean that the polygon is done or not
            first_seg = Segment(list_build_cnt[0])
            if np.linalg.norm(first_seg.asarray[0] - curpoint) < max_dist_thresh:
                # TODO sometimes multiples intersection example 7
                intersect_point = curseg.intersection_line(first_seg)
                list_build_cnt[-1][1] = intersect_point
                list_build_cnt[0][0] = intersect_point
                is_polygon_found = True
                break
            raise RuntimeError("No point detected close to the current point")

        # only one closest point - get indices of unique closest point on segment
        idx_point_closest = int(idx_closest_points[0])
        idx_seg_closest = int(np.floor(idx_point_closest / 2))

        # arrange the line so that the closest point is in the first place
        idx_point_in_line = 0 if (idx_point_closest / 2).is_integer() else 1
        seg_closest = _lines[idx_seg_closest]
        if idx_point_in_line == 1:  # flip points positions
            seg_closest = np.flip(seg_closest, axis=0)
        _lines[idx_seg_closest] = seg_closest

        # find intersection point between the two lines
        intersect_point = curseg.intersection_line(Segment(seg_closest))

        # update arrays with the intersection point
        _lines[idx_seg_closest][0] = intersect_point
        list_build_cnt[i][1] = intersect_point

        i += 1

    cnt = Polygon.from_lines(np.array(list_build_cnt, dtype=np.int32))
    return cnt

inter_area(other)

Inter area with another Polygon

Parameters:

Name Type Description Default
other Polygon

other Polygon

required

Returns:

Name Type Description
float float

inter area value

Source code in otary/geometry/discrete/shape/polygon.py
def inter_area(self, other: Polygon) -> float:
    """Inter area with another Polygon

    Args:
        other (Polygon): other Polygon

    Returns:
        float: inter area value
    """
    inter_pts = cv2.intersectConvexConvex(self.asarray, other.asarray)
    if inter_pts[0] > 0:
        inter_area = cv2.contourArea(inter_pts[1])
    else:
        inter_area = 0.0
    return inter_area

iou(other)

Intersection over union with another Polygon

Parameters:

Name Type Description Default
other Polygon

other Polygon

required

Returns:

Name Type Description
float float

intersection over union value

Source code in otary/geometry/discrete/shape/polygon.py
def iou(self, other: Polygon) -> float:
    """Intersection over union with another Polygon

    Args:
        other (Polygon): other Polygon

    Returns:
        float: intersection over union value
    """
    inter_area = self.inter_area(other)

    # optimized not to compute twice the inter area
    union_area = self.area + other.area - inter_area

    if union_area == 0:
        return 0.0
    return inter_area / union_area

is_clockwise(is_y_axis_down=False)

Determine if a polygon points go clockwise using the Shoelace formula.

True if polygon vertices order is clockwise in the "y-axis points up" referential.

Parameters:

Name Type Description Default
is_y_axis_down bool

If is_y_axis_down is True, then the image referential is used where y axis points down.

False

Returns:

Name Type Description
bool bool

True if clockwise, False if counter-clockwise

Source code in otary/geometry/discrete/shape/polygon.py
def is_clockwise(self, is_y_axis_down: bool = False) -> bool:
    """Determine if a polygon points go clockwise using the Shoelace formula.

    True if polygon vertices order is clockwise in the "y-axis points up"
    referential.

    Args:
        is_y_axis_down (bool, optional): If is_y_axis_down is True, then the image
            referential is used where y axis points down.

    Returns:
        bool: True if clockwise, False if counter-clockwise
    """
    x = self.asarray[:, 0]
    y = self.asarray[:, 1]

    x_next = np.roll(x, -1)
    y_next = np.roll(y, -1)

    s = np.sum((x_next - x) * (y_next + y))

    is_clockwise = bool(s > 0)  # Clockwise if positive (OpenCV's convention)

    if is_y_axis_down:  # in referential where y axis points down
        return not is_clockwise

    return is_clockwise

is_equal(polygon, dist_margin_error=5)

Check whether two polygons objects are equal by considering a margin of error based on a distance between points.

Parameters:

Name Type Description Default
polygon Polygon

Polygon object

required
dist_margin_error float

distance margin of error. Defaults to 5.

5

Returns:

Name Type Description
bool bool

True if the polygon are equal, False otherwise

Source code in otary/geometry/discrete/shape/polygon.py
def is_equal(self, polygon: Polygon, dist_margin_error: float = 5) -> bool:
    """Check whether two polygons objects are equal by considering a margin of
    error based on a distance between points.

    Args:
        polygon (Polygon): Polygon object
        dist_margin_error (float, optional): distance margin of error.
            Defaults to 5.

    Returns:
        bool: True if the polygon are equal, False otherwise
    """
    if self.n_points != polygon.n_points:
        # if polygons do not have the same number of points they can not be similar
        return False

    # check if each points composing the polygons are close to each other
    new_cnt = polygon.copy().rearrange_first_vertice_closest_to_point(
        self.points[0]
    )
    points_diff = new_cnt.points - self.points
    distances = np.linalg.norm(points_diff, axis=1)
    max_distance = np.max(distances)
    return max_distance <= dist_margin_error

is_regular(margin_dist_error_pct=0.01)

Identifies whether the polygon is regular, this means is rectangular or is a square.

Parameters:

Name Type Description Default
margin_area_error float

area error. Defaults to 25.

required

Returns:

Name Type Description
bool bool

True if the polygon describes a rectangle or square.

Source code in otary/geometry/discrete/shape/polygon.py
def is_regular(self, margin_dist_error_pct: float = 0.01) -> bool:
    """Identifies whether the polygon is regular, this means is rectangular or is
    a square.

    Args:
        margin_area_error (float, optional): area error. Defaults to 25.

    Returns:
        bool: True if the polygon describes a rectangle or square.
    """
    # check we have four points
    if len(self.asarray) != 4:
        return False

    # compute diagonal 1 = taking reference index as 1st point in list - index 0
    refpoint = self.asarray[0]
    idx_max_dist = self.find_vertice_ix_farthest_from(point=refpoint)
    farther_point = self.asarray[idx_max_dist]
    diag1 = Segment(points=[refpoint, farther_point])

    # compute diagonal 2
    diag2_idxs = [1, 2, 3]  # every index except 0
    diag2_idxs.remove(idx_max_dist)  # delete index of point in first diag
    diag2 = Segment(points=self.asarray[diag2_idxs])

    # rectangular criteria = the diagonals have same lengths
    normed_length = np.sqrt(diag1.length * diag2.length)
    if np.abs(diag1.length - diag2.length) > normed_length * margin_dist_error_pct:
        return False

    # there should exist only one intersection point
    intersection_points = diag1.intersection(other=diag2)
    if len(intersection_points) != 1:
        return False

    # diagonals bisect on the center of both diagonal
    cross_point = intersection_points[0]
    dist_mid_cross_diag1 = np.linalg.norm(cross_point - diag1.centroid)
    dist_mid_cross_diag2 = np.linalg.norm(cross_point - diag2.centroid)
    if (
        np.abs(dist_mid_cross_diag1) > normed_length * margin_dist_error_pct
        or np.abs(dist_mid_cross_diag2) > normed_length * margin_dist_error_pct
    ):
        return False

    return True

normal_point(start_index, end_index, dist_along_edge_pct, dist_from_edge, is_outward=True)

Compute the outward normal point. This is a point that points toward the outside of the polygon

Parameters:

Name Type Description Default
start_index int

start index for the edge selection

required
end_index int

end index for the edge selection

required
dist_along_edge_pct float

distance along the edge to place the point

required
dist_from_edge float

distance outward from the edge

required
is_outward bool

True if the normal points to the outside of the polygon. False if the normal points to the inside of the polygon. Defaults to True.

True

Returns:

Name Type Description
NDArray NDArray

2D point as array

Source code in otary/geometry/discrete/shape/polygon.py
def normal_point(
    self,
    start_index: int,
    end_index: int,
    dist_along_edge_pct: float,
    dist_from_edge: float,
    is_outward: bool = True,
) -> NDArray:
    """Compute the outward normal point.
    This is a point that points toward the outside of the polygon

    Args:
        start_index (int): start index for the edge selection
        end_index (int): end index for the edge selection
        dist_along_edge_pct (float): distance along the edge to place the point
        dist_from_edge (float): distance outward from the edge
        is_outward (bool, optional): True if the normal points to the outside of
            the polygon. False if the normal points to the inside of the polygon.
            Defaults to True.

    Returns:
        NDArray: 2D point as array
    """
    # pylint: disable=too-many-locals
    # pylint: disable=too-many-arguments, too-many-positional-arguments,
    if not 0.0 <= dist_along_edge_pct <= 1.0:
        raise ValueError("dist_along_edge_pct must be in [0, 1]")

    pt_interpolated, prev_ix = self.find_interpolated_point_and_prev_ix(
        start_index=start_index, end_index=end_index, pct_dist=dist_along_edge_pct
    )
    next_ix = (prev_ix + 1) % len(self)

    is_interpolated_pt_existing_edge = np.array_equal(
        pt_interpolated, self.asarray[prev_ix]
    ) or np.array_equal(pt_interpolated, self.asarray[next_ix])
    if is_interpolated_pt_existing_edge:
        raise ValueError(
            "Interpolated point for normal computation is an existing vertice "
            "along polygon. Please choose another dist_along_edge_pct parameter."
        )

    edge = Vector(points=[self.asarray[prev_ix], self.asarray[next_ix]])

    normal = edge.normal().normalized

    pt_plus = pt_interpolated + dist_from_edge * normal
    pt_minus = pt_interpolated - dist_from_edge * normal

    dist_plus = np.linalg.norm(pt_plus - self.centroid)
    dist_minus = np.linalg.norm(pt_minus - self.centroid)

    # choose the point which distance to the center is greater
    if dist_plus > dist_minus:
        if is_outward:
            return pt_plus
        return pt_minus

    if is_outward:
        return pt_minus
    return pt_plus

rearrange_first_vertice_at_index(index)

Rearrange the list of points that defines the Polygon so that the first point in the list of points is the one at index given by the argument of this function.

Parameters:

Name Type Description Default
index int

index value

required

Returns:

Name Type Description
Polygon Self

Polygon which is the exact same one but with a rearranged list of points.

Source code in otary/geometry/discrete/shape/polygon.py
def rearrange_first_vertice_at_index(self, index: int) -> Self:
    """Rearrange the list of points that defines the Polygon so that the first
    point in the list of points is the one at index given by the argument of this
    function.

    Args:
        index (int): index value

    Returns:
        Polygon: Polygon which is the exact same one but with a rearranged list
            of points.
    """
    size = len(self)
    if index >= size:
        raise ValueError(
            f"The index value {index} is too big. "
            f"The maximum possible index value is {size-1}."
        )
    if index < 0:
        if abs(index) > size:
            raise ValueError(
                f"The index value {index} is too small. "
                f"The minimum possible index value is {-size}"
            )
        index = size + index

    self.points = np.concatenate([self.points[index:], self.points[:index]])
    return self

rearrange_first_vertice_closest_to_point(point=np.zeros(shape=(2,)))

Rearrange the list of vertices that defines the Polygon so that the first point in the list of vertices is the one that is the closest by distance to the reference point.

Parameters:

Name Type Description Default
reference_point NDArray

point that is taken as a reference in the space to find the one in the Polygon list of points that is the closest to this reference point. Default to origin point [0, 0].

required

Returns:

Name Type Description
Polygon Polygon

Polygon which is the exact same one but with a rearranged list of points.

Source code in otary/geometry/discrete/shape/polygon.py
def rearrange_first_vertice_closest_to_point(
    self, point: NDArray = np.zeros(shape=(2,))
) -> Polygon:
    """Rearrange the list of vertices that defines the Polygon so that the first
    point in the list of vertices is the one that is the closest by distance to
    the reference point.

    Args:
        reference_point (NDArray): point that is taken as a reference in the
            space to find the one in the Polygon list of points that is the
            closest to this reference point. Default to origin point [0, 0].

    Returns:
        Polygon: Polygon which is the exact same one but with a rearranged list
            of points.
    """
    idx_min_dist = self.find_vertice_ix_closest_from(point=point)
    return self.rearrange_first_vertice_at_index(index=idx_min_dist)

reorder_clockwise(is_y_axis_down=False)

Reorder the vertices of the polygon in clockwise order where the first point stays the same.

Parameters:

Name Type Description Default
is_y_axis_down bool

True if cv2 is used. Defaults to False.

False

Returns:

Name Type Description
Polygon Polygon

reordered polygon

Source code in otary/geometry/discrete/shape/polygon.py
def reorder_clockwise(self, is_y_axis_down: bool = False) -> Polygon:
    """Reorder the vertices of the polygon in clockwise order where the first point
    stays the same.

    Args:
        is_y_axis_down (bool, optional): True if cv2 is used. Defaults to False.

    Returns:
        Polygon: reordered polygon
    """
    if self.is_clockwise(is_y_axis_down=is_y_axis_down):
        return self
    self.asarray = np.roll(self.asarray[::-1], shift=1, axis=0)
    return self

score_vertices_in_points(points, max_distance)

Returns a score of 0 or 1 for each point in the polygon if it is close enough to any point in the input points.

Parameters:

Name Type Description Default
points NDArray

list of 2D points

required
margin_dist_error float

mininum distance to consider two points as close enough to be considered as the same points

required

Returns:

Name Type Description
NDArray NDArray

a list of score for each point in the contour

Source code in otary/geometry/discrete/shape/polygon.py
def score_vertices_in_points(self, points: NDArray, max_distance: float) -> NDArray:
    """Returns a score of 0 or 1 for each point in the polygon if it is close
    enough to any point in the input points.

    Args:
        points (NDArray): list of 2D points
        margin_dist_error (float): mininum distance to consider two points as
            close enough to be considered as the same points

    Returns:
        NDArray: a list of score for each point in the contour
    """

    indices = get_shared_point_indices(
        points_to_check=self.asarray,
        checkpoints=points,
        margin_dist_error=max_distance,
        method="close",
        cond="any",
    )
    score = np.bincount(indices, minlength=len(self))
    return score

shrink(scale)

Contract or shrink a polygon

Parameters:

Name Type Description Default
scale float

scale shrinking factor. Must be greater than 1.

required

Returns:

Name Type Description
Polygon Polygon

new bigger polygon

Source code in otary/geometry/discrete/shape/polygon.py
def shrink(self, scale: float) -> Polygon:
    """Contract or shrink a polygon

    Args:
        scale (float): scale shrinking factor. Must be greater than 1.

    Returns:
        Polygon: new bigger polygon
    """
    if scale < 1:
        raise ValueError(
            "The scale value can not be less than 1 when shrinking a polygon. "
            f"Found {scale}"
        )
    return self.__rescale(scale=1 / scale)

to_image_crop_referential(other, crop, image_crop_shape=None)

This function can be useful for a very specific need: In a single image you have two same polygons and their coordinates are defined in this image referential.

You want to obtain the original polygon and all its vertices information in the image crop referential to match the other polygon within it.

This method manipulates three referentials: 1. image referential (main referential) 2. crop referential 3. image crop referential. It is different from the crop referential because the width and height of the crop referential may not be the same.

Parameters:

Name Type Description Default
other Polygon

other Polygon in the image referential

required
crop_rect Rectangle

crop rectangle in the image referential

required
image_crop_shape (tuple[int, int], optionla)

[width, height] of the crop image. If None, the shape is assumed to be directly the crop shape.

None

Returns:

Name Type Description
Polygon Polygon

original polygon in the image crop referential

Source code in otary/geometry/discrete/shape/polygon.py
def to_image_crop_referential(
    self,
    other: Polygon,
    crop: Rectangle,
    image_crop_shape: Optional[tuple[int, int]] = None,
) -> Polygon:
    """This function can be useful for a very specific need:
    In a single image you have two same polygons and their coordinates are defined
    in this image referential.

    You want to obtain the original polygon and all its vertices information
    in the image crop referential to match the other polygon within it.

    This method manipulates three referentials:
    1. image referential (main referential)
    2. crop referential
    3. image crop referential. It is different from the crop referential
        because the width and height of the crop referential may not be the same.

    Args:
        other (Polygon): other Polygon in the image referential
        crop_rect (Rectangle): crop rectangle in the image referential
        image_crop_shape (tuple[int, int], optionla): [width, height] of the crop
            image. If None, the shape is assumed to be directly the crop shape.


    Returns:
        Polygon: original polygon in the image crop referential
    """
    if not crop.contains(other=other):
        raise ValueError(
            f"The crop rectangle {crop} does not contain the other polygon {other}"
        )
    crop_width = int(crop.get_width_from_topleft(0))
    crop_height = int(crop.get_height_from_topleft(0))

    if image_crop_shape is None:
        image_crop_shape = (crop_width, crop_height)

    # self polygon in the original image shifted and normalized
    aabb_main = self.enclosing_axis_aligned_bbox()
    contour_main_shifted_normalized = self.copy().shift(
        vector=-np.asarray([self.xmin, self.ymin])
    ) / np.array(
        [aabb_main.get_width_from_topleft(0), aabb_main.get_height_from_topleft(0)]
    )

    # AABB of the polygon in the crop referential
    aabb_crop = other.enclosing_axis_aligned_bbox()
    aabb_crop_normalized = (
        aabb_crop - np.asarray([crop.xmin, crop.ymin])
    ) / np.array([crop_width, crop_height])

    # obtain the self polygon in the image crop referential
    aabb_crop2 = aabb_crop_normalized * np.array(image_crop_shape)
    new_polygon = contour_main_shifted_normalized * np.array(
        [
            aabb_crop2.get_width_from_topleft(0),
            aabb_crop2.get_height_from_topleft(0),
        ]
    ) + np.asarray([aabb_crop2.xmin, aabb_crop2.ymin])

    return new_polygon

union_area(other)

Union area with another Polygon

Parameters:

Name Type Description Default
other Polygon

other Polygon

required

Returns:

Name Type Description
float float

union area value

Source code in otary/geometry/discrete/shape/polygon.py
def union_area(self, other: Polygon) -> float:
    """Union area with another Polygon

    Args:
        other (Polygon): other Polygon

    Returns:
        float: union area value
    """
    return self.area + other.area - self.inter_area(other)

Rectangle class. It will be particularly useful for the AITT project for describing bounding boxes.

Rectangle

Bases: Polygon

Rectangle class to manipulate rectangle object

Source code in otary/geometry/discrete/shape/rectangle.py
 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
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
class Rectangle(Polygon):
    """Rectangle class to manipulate rectangle object"""

    def __init__(
        self,
        points: NDArray | list,
        is_cast_int: bool = False,
        desintersect: bool = False,
    ) -> None:
        """Create a Rectangle object

        Args:
            points (NDArray | list): 2D points that define the rectangle
            is_cast_int (bool, optional): cast points to int. Defaults to False.
            desintersect (bool, optional): whether to desintersect the rectangle or not.
                Can be useful if the input points are in a random order and
                self-intersection is possible. Defaults to False.
        """
        if len(points) != 4:
            raise ValueError("Cannot create a Rectangle since it must have 4 points")
        super().__init__(points=points, is_cast_int=is_cast_int)

        if desintersect:
            self.desintersect()

    @classmethod
    def unit(cls) -> Rectangle:
        """Create a unit Rectangle object

        Returns:
            Rectangle: new Rectangle object
        """
        return cls(points=[[0, 0], [0, 1], [1, 1], [1, 0]])

    @classmethod
    def from_center(
        cls,
        center: NDArray,
        width: float,
        height: float,
        angle: float = 0.0,
        is_cast_int: bool = False,
    ) -> Rectangle:
        # pylint: disable=too-many-arguments, too-many-positional-arguments
        """Create a Rectangle object using the center point, width, height and angle.

        Convention to create the rectangle is:
            index 0: top left point
            index 1: top right point
            index 2: bottom right point
            index 3: bottom left point

        Args:
            center (NDArray): center point of the rectangle
            width (float): width of the rectangle
            height (float): height of the rectangle
            angle (float, optional): radian rotation angle for the rectangle.
                Defaults to 0.

        Returns:
            Rectangle: Rectangle object
        """
        # compute the halves lengths
        half_width = width / 2
        half_height = height / 2

        # get center coordinates
        center_x, center_y = center[0], center[1]

        # get the rectangle coordinates
        points = np.array(
            [
                [center_x - half_width, center_y - half_height],
                [center_x + half_width, center_y - half_height],
                [center_x + half_width, center_y + half_height],
                [center_x - half_width, center_y + half_height],
            ]
        )

        rect = Rectangle(points=points, is_cast_int=is_cast_int)

        if angle != 0:
            rect = rect.rotate(angle=angle)
            if is_cast_int:
                rect.asarray = rect.asarray.astype(int)

        return rect

    @classmethod
    def from_topleft_bottomright(
        cls,
        topleft: NDArray,
        bottomright: NDArray,
        is_cast_int: bool = False,
    ) -> Rectangle:
        """Create a Rectangle object using the top left and bottom right points.

        Convention to create the rectangle is:
            index 0: top left point
            index 1: top right point
            index 2: bottom right point
            index 3: bottom left point

        Args:
            topleft (NDArray): top left point of the rectangle
            bottomright (NDArray): bottom right point of the rectangle

        Returns:
            Rectangle: new Rectangle object
        """
        topright_vertice = np.array([bottomright[0], topleft[1]])
        bottomleft_vertice = np.array([topleft[0], bottomright[1]])
        return cls(
            np.asarray([topleft, topright_vertice, bottomright, bottomleft_vertice]),
            is_cast_int=is_cast_int,
        )

    @classmethod
    def from_topleft(
        cls,
        topleft: NDArray,
        width: float,
        height: float,
        is_cast_int: bool = False,
    ) -> Rectangle:
        """Create a Rectangle object using the top left point, width, height and angle.

        Convention to create the rectangle is:
            index 0: top left point
            index 1: top right point
            index 2: bottom right point
            index 3: bottom left point

        Args:
            topleft (NDArray): top left point of the rectangle
            width (float): width of the rectangle
            height (float): height of the rectangle
            is_cast_int (bool, optional): whether to cast int or not. Defaults to False.

        Returns:
            Rectangle: Rectangle object
        """
        # pylint: disable=too-many-arguments, too-many-positional-arguments
        bottomright_vertice = np.array([topleft[0] + width, topleft[1] + height])
        return cls.from_topleft_bottomright(
            topleft=topleft,
            bottomright=bottomright_vertice,
            is_cast_int=is_cast_int,
        )

    @property
    def is_axis_aligned(self) -> bool:
        """Check if the rectangle is axis-aligned

        Returns:
            bool: True if the rectangle is axis-aligned, False otherwise
        """
        if self.is_self_intersected:
            return False

        precision = 3
        longside_cond = bool(
            (round(self.longside_slope_angle(degree=True), precision) + 90) % 90 == 0
        )
        shortside_cond = bool(
            (round(self.shortside_slope_angle(degree=True), precision) + 90) % 90 == 0
        )
        return longside_cond and shortside_cond

    @property
    def as_pymupdf_rect(self) -> pymupdf.Rect:
        """Get the pymupdf representation of the given Rectangle.
        Beware a pymupdf can only be straight or axis-aligned.

        See: https://pymupdf.readthedocs.io/en/latest/rect.html

        Returns:
            pymupdf.Rect: pymupdf axis-aligned Rect object
        """
        if not self.is_axis_aligned:
            raise RuntimeError(
                "The rectangle is not axis-aligned, thus it cannot be converted to a "
                "pymupdf Rect object."
            )
        return pymupdf.Rect(x0=self.xmin, y0=self.ymin, x1=self.xmax, y1=self.ymax)

    @property
    def longside_length(self) -> float:
        """Compute the biggest side of the rectangle

        Returns:
            float: the biggest side length
        """
        seg1 = Segment(points=[self.points[0], self.points[1]])
        seg2 = Segment(points=[self.points[1], self.points[2]])
        return seg1.length if seg1.length > seg2.length else seg2.length

    @property
    def shortside_length(self) -> float:
        """Compute the smallest side of the rectangle

        Returns:
            float: the smallest side length
        """
        seg1 = Segment(points=[self.points[0], self.points[1]])
        seg2 = Segment(points=[self.points[1], self.points[2]])
        return seg2.length if seg1.length > seg2.length else seg1.length

    def longside_slope_angle(
        self, degree: bool = False, is_y_axis_down: bool = False
    ) -> float:
        """Compute the biggest slope of the rectangle

        Returns:
            float: the biggest slope
        """
        seg1 = Segment(points=[self.points[0], self.points[1]])
        seg2 = Segment(points=[self.points[1], self.points[2]])
        seg_bigside = seg1 if seg1.length > seg2.length else seg2
        return seg_bigside.slope_angle(degree=degree, is_y_axis_down=is_y_axis_down)

    def shortside_slope_angle(
        self, degree: bool = False, is_y_axis_down: bool = False
    ) -> float:
        """Compute the smallest slope of the rectangle

        Returns:
            float: the smallest slope
        """
        seg1 = Segment(points=[self.points[0], self.points[1]])
        seg2 = Segment(points=[self.points[1], self.points[2]])
        seg_smallside = seg2 if seg1.length > seg2.length else seg1
        return seg_smallside.slope_angle(degree=degree, is_y_axis_down=is_y_axis_down)

    def desintersect(self) -> Self:
        """Desintersect the rectangle if it is self-intersected.
        If the rectangle is not self-intersected, returns the same rectangle.

        Returns:
            Rectangle: the desintersected Rectangle object
        """
        if not self.is_self_intersected:
            return self

        # Sort points based on angle from centroid
        def angle_from_center(pt):
            return np.arctan2(pt[1] - self.centroid[1], pt[0] - self.centroid[0])

        sorted_vertices = sorted(self.asarray, key=angle_from_center)
        self.asarray = np.array(sorted_vertices)
        return self

    def join(
        self, rect: Rectangle, margin_dist_error: float = 1e-5
    ) -> Optional[Rectangle]:
        """Join two rectangles into a single one.
        If they share no point in common or only a single point returns None.
        If they share two points, returns a new Rectangle that is the concatenation
        of the two rectangles and that is not self-intersected.
        If they share 3 or more points they represent the same rectangle, thus
        returns this object.

        Args:
            rect (Rectangle): the other Rectangle object
            margin_dist_error (float, optional): the threshold to consider whether the
                rectangle share a common point. Defaults to 1e-5.

        Returns:
            Rectangle: the join new Rectangle object
        """
        shared_points = self.find_shared_approx_vertices(rect, margin_dist_error)
        n_shared_points = len(shared_points)

        if n_shared_points in (0, 1):
            return None
        if n_shared_points == 2:
            new_rect_points = np.concatenate(
                (
                    self.find_vertices_far_from(shared_points, margin_dist_error),
                    rect.find_vertices_far_from(shared_points, margin_dist_error),
                ),
                axis=0,
            )
            return Rectangle(points=new_rect_points).desintersect()
        # if 3 or more points in common it is the same rectangle
        return self

    def _topright_vertice_from_topleft(self, topleft_index: int) -> NDArray:
        """Get the top-right vertice from the topleft vertice

        Args:
            topleft_index (int): index of the topleft vertice

        Returns:
            NDArray: topright vertice
        """
        if self.is_clockwise(is_y_axis_down=True):
            return self.asarray[(topleft_index + 1) % len(self)]
        return self.asarray[topleft_index - 1]

    def _bottomleft_vertice_from_topleft(self, topleft_index: int) -> NDArray:
        """Get the bottom-left vertice from the topleft vertice

        Args:
            topleft_index (int): index of the topleft vertice

        Returns:
            NDArray: topright vertice
        """
        if self.is_clockwise(is_y_axis_down=True):
            return self.asarray[topleft_index - 1]
        return self.asarray[(topleft_index + 1) % len(self)]

    def _bottomright_vertice_from_topleft(self, topleft_index: int) -> NDArray:
        """Get the bottom-right vertice from the topleft vertice

        Args:
            topleft_index (int): index of the topleft vertice

        Returns:
            NDArray: topright vertice
        """
        return self.asarray[(topleft_index + 2) % len(self)]

    def get_vertice_from_topleft(
        self, topleft_index: int, vertice: str = "topright"
    ) -> NDArray:
        """Get vertice from the topleft vertice. You can use this method to
        obtain the topright, bottomleft, bottomright vertice from the topleft vertice.

        Returns:
            NDArray: topright vertice
        """
        if vertice not in ("topright", "bottomleft", "bottomright"):
            raise ValueError(
                "Parameter vertice must be one of"
                "'topright', 'bottomleft', 'bottomright'"
                f"but got {vertice}"
            )
        return getattr(self, f"_{vertice}_vertice_from_topleft")(topleft_index)

    def get_width_from_topleft(self, topleft_index: int) -> float:
        """Get the width from the topleft vertice

        Args:
            topleft_index (int): top-left vertice index

        Returns:
            float: width value
        """
        return float(
            np.linalg.norm(
                self.asarray[topleft_index]
                - self.get_vertice_from_topleft(topleft_index, "topright")
            )
        )

    def get_height_from_topleft(self, topleft_index: int) -> float:
        """Get the heigth from the topleft vertice

        Args:
            topleft_index (int): top-left vertice index

        Returns:
            float: height value
        """
        return float(
            np.linalg.norm(
                self.asarray[topleft_index]
                - self.get_vertice_from_topleft(topleft_index, "bottomleft")
            )
        )

    def get_vector_up_from_topleft(self, topleft_index: int) -> Vector:
        """Get the vector that goes from the bottomleft vertice to the topleft vertice

        Args:
            topleft_index (int): top-left vertice index

        Returns:
            Vector: Vector object descripting the vector
        """
        bottomleft_vertice = self.get_vertice_from_topleft(
            topleft_index=topleft_index, vertice="bottomleft"
        )
        return Vector([bottomleft_vertice, self[topleft_index]])

    def get_vector_left_from_topleft(self, topleft_index: int) -> Vector:
        """Get the vector that goes from the topleft vertice to the topright vertice

        Args:
            topleft_index (int): top-left vertice index

        Returns:
            Vector: Vector object descripting the vector
        """
        rect_topright_vertice = self.get_vertice_from_topleft(
            topleft_index=topleft_index, vertice="topright"
        )
        return Vector([self[topleft_index], rect_topright_vertice])

    def __str__(self) -> str:
        return (  # pylint: disable=duplicate-code
            self.__class__.__name__
            + "(["
            + self.asarray[0].tolist().__str__()
            + ", "
            + self.asarray[1].tolist().__str__()
            + ", "
            + self.asarray[2].tolist().__str__()
            + ", "
            + self.asarray[3].tolist().__str__()
            + "])"
        )

    def __repr__(self) -> str:
        return str(self)

as_pymupdf_rect property

Get the pymupdf representation of the given Rectangle. Beware a pymupdf can only be straight or axis-aligned.

See: https://pymupdf.readthedocs.io/en/latest/rect.html

Returns:

Type Description
Rect

pymupdf.Rect: pymupdf axis-aligned Rect object

is_axis_aligned property

Check if the rectangle is axis-aligned

Returns:

Name Type Description
bool bool

True if the rectangle is axis-aligned, False otherwise

longside_length property

Compute the biggest side of the rectangle

Returns:

Name Type Description
float float

the biggest side length

shortside_length property

Compute the smallest side of the rectangle

Returns:

Name Type Description
float float

the smallest side length

__init__(points, is_cast_int=False, desintersect=False)

Create a Rectangle object

Parameters:

Name Type Description Default
points NDArray | list

2D points that define the rectangle

required
is_cast_int bool

cast points to int. Defaults to False.

False
desintersect bool

whether to desintersect the rectangle or not. Can be useful if the input points are in a random order and self-intersection is possible. Defaults to False.

False
Source code in otary/geometry/discrete/shape/rectangle.py
def __init__(
    self,
    points: NDArray | list,
    is_cast_int: bool = False,
    desintersect: bool = False,
) -> None:
    """Create a Rectangle object

    Args:
        points (NDArray | list): 2D points that define the rectangle
        is_cast_int (bool, optional): cast points to int. Defaults to False.
        desintersect (bool, optional): whether to desintersect the rectangle or not.
            Can be useful if the input points are in a random order and
            self-intersection is possible. Defaults to False.
    """
    if len(points) != 4:
        raise ValueError("Cannot create a Rectangle since it must have 4 points")
    super().__init__(points=points, is_cast_int=is_cast_int)

    if desintersect:
        self.desintersect()

desintersect()

Desintersect the rectangle if it is self-intersected. If the rectangle is not self-intersected, returns the same rectangle.

Returns:

Name Type Description
Rectangle Self

the desintersected Rectangle object

Source code in otary/geometry/discrete/shape/rectangle.py
def desintersect(self) -> Self:
    """Desintersect the rectangle if it is self-intersected.
    If the rectangle is not self-intersected, returns the same rectangle.

    Returns:
        Rectangle: the desintersected Rectangle object
    """
    if not self.is_self_intersected:
        return self

    # Sort points based on angle from centroid
    def angle_from_center(pt):
        return np.arctan2(pt[1] - self.centroid[1], pt[0] - self.centroid[0])

    sorted_vertices = sorted(self.asarray, key=angle_from_center)
    self.asarray = np.array(sorted_vertices)
    return self

from_center(center, width, height, angle=0.0, is_cast_int=False) classmethod

Create a Rectangle object using the center point, width, height and angle.

Convention to create the rectangle is

index 0: top left point index 1: top right point index 2: bottom right point index 3: bottom left point

Parameters:

Name Type Description Default
center NDArray

center point of the rectangle

required
width float

width of the rectangle

required
height float

height of the rectangle

required
angle float

radian rotation angle for the rectangle. Defaults to 0.

0.0

Returns:

Name Type Description
Rectangle Rectangle

Rectangle object

Source code in otary/geometry/discrete/shape/rectangle.py
@classmethod
def from_center(
    cls,
    center: NDArray,
    width: float,
    height: float,
    angle: float = 0.0,
    is_cast_int: bool = False,
) -> Rectangle:
    # pylint: disable=too-many-arguments, too-many-positional-arguments
    """Create a Rectangle object using the center point, width, height and angle.

    Convention to create the rectangle is:
        index 0: top left point
        index 1: top right point
        index 2: bottom right point
        index 3: bottom left point

    Args:
        center (NDArray): center point of the rectangle
        width (float): width of the rectangle
        height (float): height of the rectangle
        angle (float, optional): radian rotation angle for the rectangle.
            Defaults to 0.

    Returns:
        Rectangle: Rectangle object
    """
    # compute the halves lengths
    half_width = width / 2
    half_height = height / 2

    # get center coordinates
    center_x, center_y = center[0], center[1]

    # get the rectangle coordinates
    points = np.array(
        [
            [center_x - half_width, center_y - half_height],
            [center_x + half_width, center_y - half_height],
            [center_x + half_width, center_y + half_height],
            [center_x - half_width, center_y + half_height],
        ]
    )

    rect = Rectangle(points=points, is_cast_int=is_cast_int)

    if angle != 0:
        rect = rect.rotate(angle=angle)
        if is_cast_int:
            rect.asarray = rect.asarray.astype(int)

    return rect

from_topleft(topleft, width, height, is_cast_int=False) classmethod

Create a Rectangle object using the top left point, width, height and angle.

Convention to create the rectangle is

index 0: top left point index 1: top right point index 2: bottom right point index 3: bottom left point

Parameters:

Name Type Description Default
topleft NDArray

top left point of the rectangle

required
width float

width of the rectangle

required
height float

height of the rectangle

required
is_cast_int bool

whether to cast int or not. Defaults to False.

False

Returns:

Name Type Description
Rectangle Rectangle

Rectangle object

Source code in otary/geometry/discrete/shape/rectangle.py
@classmethod
def from_topleft(
    cls,
    topleft: NDArray,
    width: float,
    height: float,
    is_cast_int: bool = False,
) -> Rectangle:
    """Create a Rectangle object using the top left point, width, height and angle.

    Convention to create the rectangle is:
        index 0: top left point
        index 1: top right point
        index 2: bottom right point
        index 3: bottom left point

    Args:
        topleft (NDArray): top left point of the rectangle
        width (float): width of the rectangle
        height (float): height of the rectangle
        is_cast_int (bool, optional): whether to cast int or not. Defaults to False.

    Returns:
        Rectangle: Rectangle object
    """
    # pylint: disable=too-many-arguments, too-many-positional-arguments
    bottomright_vertice = np.array([topleft[0] + width, topleft[1] + height])
    return cls.from_topleft_bottomright(
        topleft=topleft,
        bottomright=bottomright_vertice,
        is_cast_int=is_cast_int,
    )

from_topleft_bottomright(topleft, bottomright, is_cast_int=False) classmethod

Create a Rectangle object using the top left and bottom right points.

Convention to create the rectangle is

index 0: top left point index 1: top right point index 2: bottom right point index 3: bottom left point

Parameters:

Name Type Description Default
topleft NDArray

top left point of the rectangle

required
bottomright NDArray

bottom right point of the rectangle

required

Returns:

Name Type Description
Rectangle Rectangle

new Rectangle object

Source code in otary/geometry/discrete/shape/rectangle.py
@classmethod
def from_topleft_bottomright(
    cls,
    topleft: NDArray,
    bottomright: NDArray,
    is_cast_int: bool = False,
) -> Rectangle:
    """Create a Rectangle object using the top left and bottom right points.

    Convention to create the rectangle is:
        index 0: top left point
        index 1: top right point
        index 2: bottom right point
        index 3: bottom left point

    Args:
        topleft (NDArray): top left point of the rectangle
        bottomright (NDArray): bottom right point of the rectangle

    Returns:
        Rectangle: new Rectangle object
    """
    topright_vertice = np.array([bottomright[0], topleft[1]])
    bottomleft_vertice = np.array([topleft[0], bottomright[1]])
    return cls(
        np.asarray([topleft, topright_vertice, bottomright, bottomleft_vertice]),
        is_cast_int=is_cast_int,
    )

get_height_from_topleft(topleft_index)

Get the heigth from the topleft vertice

Parameters:

Name Type Description Default
topleft_index int

top-left vertice index

required

Returns:

Name Type Description
float float

height value

Source code in otary/geometry/discrete/shape/rectangle.py
def get_height_from_topleft(self, topleft_index: int) -> float:
    """Get the heigth from the topleft vertice

    Args:
        topleft_index (int): top-left vertice index

    Returns:
        float: height value
    """
    return float(
        np.linalg.norm(
            self.asarray[topleft_index]
            - self.get_vertice_from_topleft(topleft_index, "bottomleft")
        )
    )

get_vector_left_from_topleft(topleft_index)

Get the vector that goes from the topleft vertice to the topright vertice

Parameters:

Name Type Description Default
topleft_index int

top-left vertice index

required

Returns:

Name Type Description
Vector Vector

Vector object descripting the vector

Source code in otary/geometry/discrete/shape/rectangle.py
def get_vector_left_from_topleft(self, topleft_index: int) -> Vector:
    """Get the vector that goes from the topleft vertice to the topright vertice

    Args:
        topleft_index (int): top-left vertice index

    Returns:
        Vector: Vector object descripting the vector
    """
    rect_topright_vertice = self.get_vertice_from_topleft(
        topleft_index=topleft_index, vertice="topright"
    )
    return Vector([self[topleft_index], rect_topright_vertice])

get_vector_up_from_topleft(topleft_index)

Get the vector that goes from the bottomleft vertice to the topleft vertice

Parameters:

Name Type Description Default
topleft_index int

top-left vertice index

required

Returns:

Name Type Description
Vector Vector

Vector object descripting the vector

Source code in otary/geometry/discrete/shape/rectangle.py
def get_vector_up_from_topleft(self, topleft_index: int) -> Vector:
    """Get the vector that goes from the bottomleft vertice to the topleft vertice

    Args:
        topleft_index (int): top-left vertice index

    Returns:
        Vector: Vector object descripting the vector
    """
    bottomleft_vertice = self.get_vertice_from_topleft(
        topleft_index=topleft_index, vertice="bottomleft"
    )
    return Vector([bottomleft_vertice, self[topleft_index]])

get_vertice_from_topleft(topleft_index, vertice='topright')

Get vertice from the topleft vertice. You can use this method to obtain the topright, bottomleft, bottomright vertice from the topleft vertice.

Returns:

Name Type Description
NDArray NDArray

topright vertice

Source code in otary/geometry/discrete/shape/rectangle.py
def get_vertice_from_topleft(
    self, topleft_index: int, vertice: str = "topright"
) -> NDArray:
    """Get vertice from the topleft vertice. You can use this method to
    obtain the topright, bottomleft, bottomright vertice from the topleft vertice.

    Returns:
        NDArray: topright vertice
    """
    if vertice not in ("topright", "bottomleft", "bottomright"):
        raise ValueError(
            "Parameter vertice must be one of"
            "'topright', 'bottomleft', 'bottomright'"
            f"but got {vertice}"
        )
    return getattr(self, f"_{vertice}_vertice_from_topleft")(topleft_index)

get_width_from_topleft(topleft_index)

Get the width from the topleft vertice

Parameters:

Name Type Description Default
topleft_index int

top-left vertice index

required

Returns:

Name Type Description
float float

width value

Source code in otary/geometry/discrete/shape/rectangle.py
def get_width_from_topleft(self, topleft_index: int) -> float:
    """Get the width from the topleft vertice

    Args:
        topleft_index (int): top-left vertice index

    Returns:
        float: width value
    """
    return float(
        np.linalg.norm(
            self.asarray[topleft_index]
            - self.get_vertice_from_topleft(topleft_index, "topright")
        )
    )

join(rect, margin_dist_error=1e-05)

Join two rectangles into a single one. If they share no point in common or only a single point returns None. If they share two points, returns a new Rectangle that is the concatenation of the two rectangles and that is not self-intersected. If they share 3 or more points they represent the same rectangle, thus returns this object.

Parameters:

Name Type Description Default
rect Rectangle

the other Rectangle object

required
margin_dist_error float

the threshold to consider whether the rectangle share a common point. Defaults to 1e-5.

1e-05

Returns:

Name Type Description
Rectangle Optional[Rectangle]

the join new Rectangle object

Source code in otary/geometry/discrete/shape/rectangle.py
def join(
    self, rect: Rectangle, margin_dist_error: float = 1e-5
) -> Optional[Rectangle]:
    """Join two rectangles into a single one.
    If they share no point in common or only a single point returns None.
    If they share two points, returns a new Rectangle that is the concatenation
    of the two rectangles and that is not self-intersected.
    If they share 3 or more points they represent the same rectangle, thus
    returns this object.

    Args:
        rect (Rectangle): the other Rectangle object
        margin_dist_error (float, optional): the threshold to consider whether the
            rectangle share a common point. Defaults to 1e-5.

    Returns:
        Rectangle: the join new Rectangle object
    """
    shared_points = self.find_shared_approx_vertices(rect, margin_dist_error)
    n_shared_points = len(shared_points)

    if n_shared_points in (0, 1):
        return None
    if n_shared_points == 2:
        new_rect_points = np.concatenate(
            (
                self.find_vertices_far_from(shared_points, margin_dist_error),
                rect.find_vertices_far_from(shared_points, margin_dist_error),
            ),
            axis=0,
        )
        return Rectangle(points=new_rect_points).desintersect()
    # if 3 or more points in common it is the same rectangle
    return self

longside_slope_angle(degree=False, is_y_axis_down=False)

Compute the biggest slope of the rectangle

Returns:

Name Type Description
float float

the biggest slope

Source code in otary/geometry/discrete/shape/rectangle.py
def longside_slope_angle(
    self, degree: bool = False, is_y_axis_down: bool = False
) -> float:
    """Compute the biggest slope of the rectangle

    Returns:
        float: the biggest slope
    """
    seg1 = Segment(points=[self.points[0], self.points[1]])
    seg2 = Segment(points=[self.points[1], self.points[2]])
    seg_bigside = seg1 if seg1.length > seg2.length else seg2
    return seg_bigside.slope_angle(degree=degree, is_y_axis_down=is_y_axis_down)

shortside_slope_angle(degree=False, is_y_axis_down=False)

Compute the smallest slope of the rectangle

Returns:

Name Type Description
float float

the smallest slope

Source code in otary/geometry/discrete/shape/rectangle.py
def shortside_slope_angle(
    self, degree: bool = False, is_y_axis_down: bool = False
) -> float:
    """Compute the smallest slope of the rectangle

    Returns:
        float: the smallest slope
    """
    seg1 = Segment(points=[self.points[0], self.points[1]])
    seg2 = Segment(points=[self.points[1], self.points[2]])
    seg_smallside = seg2 if seg1.length > seg2.length else seg1
    return seg_smallside.slope_angle(degree=degree, is_y_axis_down=is_y_axis_down)

unit() classmethod

Create a unit Rectangle object

Returns:

Name Type Description
Rectangle Rectangle

new Rectangle object

Source code in otary/geometry/discrete/shape/rectangle.py
@classmethod
def unit(cls) -> Rectangle:
    """Create a unit Rectangle object

    Returns:
        Rectangle: new Rectangle object
    """
    return cls(points=[[0, 0], [0, 1], [1, 1], [1, 0]])

Triangle class module

Triangle

Bases: Polygon

Triangle class

Source code in otary/geometry/discrete/shape/triangle.py
class Triangle(Polygon):
    """Triangle class"""

    def __init__(self, points: np.ndarray | list, is_cast_int: bool = False) -> None:
        if len(points) != 3:
            raise ValueError("Cannot create a Triangle since it must have 3 points")
        super().__init__(points=points, is_cast_int=is_cast_int)

    def __str__(self) -> str:
        return (  # pylint: disable=duplicate-code
            self.__class__.__name__
            + "(["
            + self.asarray[0].tolist().__str__()
            + ", "
            + self.asarray[1].tolist().__str__()
            + ", "
            + self.asarray[2].tolist().__str__()
            + "])"
        )

    def __repr__(self) -> str:
        return str(self)

Linear

Segment class to describe defined lines and segments

Segment

Bases: LinearEntity

Segment Class to manipulate easily segments objects

Source code in otary/geometry/discrete/linear/segment.py
class Segment(LinearEntity):
    """Segment Class to manipulate easily segments objects"""

    def __init__(self, points: NDArray | list, is_cast_int: bool = False) -> None:
        assert len(points) == 2
        assert len(points[0]) == 2
        assert len(points[1]) == 2
        super().__init__(points=points, is_cast_int=is_cast_int)

    @property
    def centroid(self) -> NDArray:
        """Returns the center point of the segment

        Returns:
            NDArray: point of shape (1, 2)
        """
        return np.sum(self.points, axis=0) / 2

    @property
    def midpoint(self) -> NDArray:
        """In the Segment, this is equivalent to the centroid

        Returns:
            NDArray: point of shape (1, 2)
        """
        return self.centroid

    @property
    def slope(self) -> float:
        """Returns the segment slope in the classical XY coordinates referential

        Returns:
            float: segment slope value
        """
        p1, p2 = self.points[0], self.points[1]
        try:
            slope = (p2[1] - p1[1]) / (p2[0] - p1[0] + 1e-9)
        except ZeroDivisionError:
            slope = np.inf
        return slope

    @property
    def slope_cv2(self) -> float:
        """Compute the slope seen as in the cv2 coordinates with y-axis inverted

        Returns:
            float: segment slope value
        """
        return -self.slope

    @staticmethod
    def assert_list_of_lines(lines: NDArray) -> None:
        """Check that the lines argument is really a list of lines

        Args:
            lines (NDArray): a expected list of lines
        """
        if lines.shape[1:] != (2, 2):
            raise ValueError(
                "The input segments argument has not the expected shape. "
                f"Input shape {lines.shape[1:]}, expected shape (2, 2)."
            )

    def slope_angle(self, degree: bool = False, is_y_axis_down: bool = False) -> float:
        """Calculate the slope angle of a single line in the cartesian space

        Args:
            degree (bool): whether to output the result in degree. By default in radian.

        Returns:
            float: slope angle in ]-pi/2, pi/2[
        """
        angle = np.arctan(self.slope_cv2) if is_y_axis_down else np.arctan(self.slope)
        if degree:
            angle = np.rad2deg(angle)
        return angle

    def is_parallel(
        self, segment: Segment, margin_error_angle: float = DEFAULT_MARGIN_ANGLE_ERROR
    ) -> bool:
        """Check if two lines are parallel by calculating the slope of the two lines

        Angle Difference = |theta_0 - theta_1| mod pi
        Because always returns positive results due to the modulo we took into account
        the special case where angle difference = np.pi - epsilon ~ 3.139,
        this implies also two parralel lines.

        Args:
            segment (np.array): segment of shape (2, 2)
            margin_error_angle (float, optional): Threshold value for validating
                if the lines are parallel. Defaults to DEFAULT_MARGIN_ANGLE_ERROR.

        Returns:
            bool: whether we qualify the lines as parallel or not
        """
        angle_difference = np.mod(
            np.abs(self.slope_angle() - segment.slope_angle()), np.pi
        )
        test = bool(
            angle_difference < margin_error_angle
            or np.abs(angle_difference - np.pi) < margin_error_angle
        )
        return test

    @staticmethod
    def is_points_collinear(
        p1: NDArray,
        p2: NDArray,
        p3: NDArray,
        margin_error_angle: float = DEFAULT_MARGIN_ANGLE_ERROR,
    ) -> bool:
        """Verify whether three points on the plane are collinear or not.
        Method by angle or slope: For three points, slope of any pair of points must
        be same as other pair.

        Args:
            p1 (np.array): point of shape (2,)
            p2 (np.array): point of shape (2,)
            p3 (np.array): point of shape (2,)
            margin_error_angle (float, optional): Threshold value for validating
                collinearity. Defaults to DEFAULT_MARGIN_ANGLE_ERROR.

        Returns:
            bool: 1 if colinear, 0 otherwise
        """
        p1, p2, p3 = np.array(p1), np.array(p2), np.array(p3)

        # 2 or 3 points equal
        if (
            not np.logical_or(*(p1 - p2))
            or not np.logical_or(*(p1 - p3))
            or not np.logical_or(*(p2 - p3))
        ):
            return True

        segment1, segment2 = Segment([p1, p2]), Segment([p1, p3])
        return segment1.is_parallel(
            segment=segment2, margin_error_angle=margin_error_angle
        )

    def is_point_collinear(
        self,
        point: NDArray,
        margin_error_angle: float = DEFAULT_MARGIN_ANGLE_ERROR,
    ) -> bool:
        """Check whether a point is collinear with the segment

        Args:
            point (NDArray): point of shape (2,)
            margin_error_angle (float, optional): Threshold value for validating
                collinearity. Defaults to DEFAULT_MARGIN_ANGLE_ERROR.

        Returns:
            bool: True if the point is collinear with the segment
        """
        return self.is_points_collinear(
            p1=self.asarray[0],
            p2=self.asarray[1],
            p3=point,
            margin_error_angle=margin_error_angle,
        )

    def is_collinear(
        self, segment: Segment, margin_error_angle: float = DEFAULT_MARGIN_ANGLE_ERROR
    ) -> bool:
        """Verify whether two segments on the plane are collinear or not.
        This means that they are parallel and have at least three points in common.
        We needed to make all the combination verification in order to proove cause we
        could end up with two points very very close by and it would end up not
        providing the expected result. Consider the following example:

        segment1 = Segment([[339, 615], [564, 650]])
        segment2 = Segment([[340, 614], [611, 657]])
        segment1.is_collinear(segment2)
        Angle difference: 0.9397169393235674 Margin: 0.06283185307179587
        False

        Only because [339, 615] and [340, 614] are really close and do not provide the
        appropriate slope does not means that overall the two segments are not
        collinear.

        Args:
            segment (np.array): segment of shape (2, 2)
            margin_error_angle (float, optional): Threshold value for validating
                collinearity.

        Returns:
            bool: 1 if colinear, 0 otherwise
        """
        cur2lines = np.array([self.asarray, segment.asarray])
        points = np.concatenate(cur2lines, axis=0)
        val_arr = np.zeros(shape=4)
        for i, combi in enumerate(
            itertools.combinations(np.linspace(0, 3, 4, dtype=int), 3)
        ):
            val_arr[i] = Segment.is_points_collinear(
                p1=points[combi[0]],
                p2=points[combi[1]],
                p3=points[combi[2]],
                margin_error_angle=margin_error_angle,
            )

        _is_parallel = self.is_parallel(
            segment=segment, margin_error_angle=margin_error_angle
        )
        _is_collinear = 1 in val_arr
        return bool(_is_parallel and _is_collinear)

    def intersection_line(self, other: Segment) -> NDArray:
        """Compute the intersection point that would exist between two segments if we
        consider them as lines - which means as lines with infinite length.

        Lines would thus define infinite extension in both extremities directions
        of the input segments objects.

        Args:
            other (Segment): other Segment object

        Returns:
            NDArray: intersection point between the two lines
        """
        if self.is_parallel(segment=other, margin_error_angle=0):
            return np.array([])
        line0 = Line(self.asarray[0], self.asarray[1])
        line1 = Line(other.asarray[0], other.asarray[1])
        intersection = np.array(line0.intersection(line1)[0].evalf(n=7))
        return intersection

    def normal(self) -> Self:
        """
        Returns the normal segment of the segment.
        The normal segment is a segment that is orthogonal to the input segment.

        Please note that the normal segment have the same length as the input segment.
        Moreover the normal segment is rotated by 90 degrees clockwise.

        Returns:
            Segment: normal segment centered at the original segment centroid
        """
        normal = self.copy().rotate(
            angle=math.pi / 2, is_degree=False, is_clockwise=True
        )
        return normal

centroid property

Returns the center point of the segment

Returns:

Name Type Description
NDArray NDArray

point of shape (1, 2)

midpoint property

In the Segment, this is equivalent to the centroid

Returns:

Name Type Description
NDArray NDArray

point of shape (1, 2)

slope property

Returns the segment slope in the classical XY coordinates referential

Returns:

Name Type Description
float float

segment slope value

slope_cv2 property

Compute the slope seen as in the cv2 coordinates with y-axis inverted

Returns:

Name Type Description
float float

segment slope value

assert_list_of_lines(lines) staticmethod

Check that the lines argument is really a list of lines

Parameters:

Name Type Description Default
lines NDArray

a expected list of lines

required
Source code in otary/geometry/discrete/linear/segment.py
@staticmethod
def assert_list_of_lines(lines: NDArray) -> None:
    """Check that the lines argument is really a list of lines

    Args:
        lines (NDArray): a expected list of lines
    """
    if lines.shape[1:] != (2, 2):
        raise ValueError(
            "The input segments argument has not the expected shape. "
            f"Input shape {lines.shape[1:]}, expected shape (2, 2)."
        )

intersection_line(other)

Compute the intersection point that would exist between two segments if we consider them as lines - which means as lines with infinite length.

Lines would thus define infinite extension in both extremities directions of the input segments objects.

Parameters:

Name Type Description Default
other Segment

other Segment object

required

Returns:

Name Type Description
NDArray NDArray

intersection point between the two lines

Source code in otary/geometry/discrete/linear/segment.py
def intersection_line(self, other: Segment) -> NDArray:
    """Compute the intersection point that would exist between two segments if we
    consider them as lines - which means as lines with infinite length.

    Lines would thus define infinite extension in both extremities directions
    of the input segments objects.

    Args:
        other (Segment): other Segment object

    Returns:
        NDArray: intersection point between the two lines
    """
    if self.is_parallel(segment=other, margin_error_angle=0):
        return np.array([])
    line0 = Line(self.asarray[0], self.asarray[1])
    line1 = Line(other.asarray[0], other.asarray[1])
    intersection = np.array(line0.intersection(line1)[0].evalf(n=7))
    return intersection

is_collinear(segment, margin_error_angle=DEFAULT_MARGIN_ANGLE_ERROR)

Verify whether two segments on the plane are collinear or not. This means that they are parallel and have at least three points in common. We needed to make all the combination verification in order to proove cause we could end up with two points very very close by and it would end up not providing the expected result. Consider the following example:

segment1 = Segment([[339, 615], [564, 650]]) segment2 = Segment([[340, 614], [611, 657]]) segment1.is_collinear(segment2) Angle difference: 0.9397169393235674 Margin: 0.06283185307179587 False

Only because [339, 615] and [340, 614] are really close and do not provide the appropriate slope does not means that overall the two segments are not collinear.

Parameters:

Name Type Description Default
segment array

segment of shape (2, 2)

required
margin_error_angle float

Threshold value for validating collinearity.

DEFAULT_MARGIN_ANGLE_ERROR

Returns:

Name Type Description
bool bool

1 if colinear, 0 otherwise

Source code in otary/geometry/discrete/linear/segment.py
def is_collinear(
    self, segment: Segment, margin_error_angle: float = DEFAULT_MARGIN_ANGLE_ERROR
) -> bool:
    """Verify whether two segments on the plane are collinear or not.
    This means that they are parallel and have at least three points in common.
    We needed to make all the combination verification in order to proove cause we
    could end up with two points very very close by and it would end up not
    providing the expected result. Consider the following example:

    segment1 = Segment([[339, 615], [564, 650]])
    segment2 = Segment([[340, 614], [611, 657]])
    segment1.is_collinear(segment2)
    Angle difference: 0.9397169393235674 Margin: 0.06283185307179587
    False

    Only because [339, 615] and [340, 614] are really close and do not provide the
    appropriate slope does not means that overall the two segments are not
    collinear.

    Args:
        segment (np.array): segment of shape (2, 2)
        margin_error_angle (float, optional): Threshold value for validating
            collinearity.

    Returns:
        bool: 1 if colinear, 0 otherwise
    """
    cur2lines = np.array([self.asarray, segment.asarray])
    points = np.concatenate(cur2lines, axis=0)
    val_arr = np.zeros(shape=4)
    for i, combi in enumerate(
        itertools.combinations(np.linspace(0, 3, 4, dtype=int), 3)
    ):
        val_arr[i] = Segment.is_points_collinear(
            p1=points[combi[0]],
            p2=points[combi[1]],
            p3=points[combi[2]],
            margin_error_angle=margin_error_angle,
        )

    _is_parallel = self.is_parallel(
        segment=segment, margin_error_angle=margin_error_angle
    )
    _is_collinear = 1 in val_arr
    return bool(_is_parallel and _is_collinear)

is_parallel(segment, margin_error_angle=DEFAULT_MARGIN_ANGLE_ERROR)

Check if two lines are parallel by calculating the slope of the two lines

Angle Difference = |theta_0 - theta_1| mod pi Because always returns positive results due to the modulo we took into account the special case where angle difference = np.pi - epsilon ~ 3.139, this implies also two parralel lines.

Parameters:

Name Type Description Default
segment array

segment of shape (2, 2)

required
margin_error_angle float

Threshold value for validating if the lines are parallel. Defaults to DEFAULT_MARGIN_ANGLE_ERROR.

DEFAULT_MARGIN_ANGLE_ERROR

Returns:

Name Type Description
bool bool

whether we qualify the lines as parallel or not

Source code in otary/geometry/discrete/linear/segment.py
def is_parallel(
    self, segment: Segment, margin_error_angle: float = DEFAULT_MARGIN_ANGLE_ERROR
) -> bool:
    """Check if two lines are parallel by calculating the slope of the two lines

    Angle Difference = |theta_0 - theta_1| mod pi
    Because always returns positive results due to the modulo we took into account
    the special case where angle difference = np.pi - epsilon ~ 3.139,
    this implies also two parralel lines.

    Args:
        segment (np.array): segment of shape (2, 2)
        margin_error_angle (float, optional): Threshold value for validating
            if the lines are parallel. Defaults to DEFAULT_MARGIN_ANGLE_ERROR.

    Returns:
        bool: whether we qualify the lines as parallel or not
    """
    angle_difference = np.mod(
        np.abs(self.slope_angle() - segment.slope_angle()), np.pi
    )
    test = bool(
        angle_difference < margin_error_angle
        or np.abs(angle_difference - np.pi) < margin_error_angle
    )
    return test

is_point_collinear(point, margin_error_angle=DEFAULT_MARGIN_ANGLE_ERROR)

Check whether a point is collinear with the segment

Parameters:

Name Type Description Default
point NDArray

point of shape (2,)

required
margin_error_angle float

Threshold value for validating collinearity. Defaults to DEFAULT_MARGIN_ANGLE_ERROR.

DEFAULT_MARGIN_ANGLE_ERROR

Returns:

Name Type Description
bool bool

True if the point is collinear with the segment

Source code in otary/geometry/discrete/linear/segment.py
def is_point_collinear(
    self,
    point: NDArray,
    margin_error_angle: float = DEFAULT_MARGIN_ANGLE_ERROR,
) -> bool:
    """Check whether a point is collinear with the segment

    Args:
        point (NDArray): point of shape (2,)
        margin_error_angle (float, optional): Threshold value for validating
            collinearity. Defaults to DEFAULT_MARGIN_ANGLE_ERROR.

    Returns:
        bool: True if the point is collinear with the segment
    """
    return self.is_points_collinear(
        p1=self.asarray[0],
        p2=self.asarray[1],
        p3=point,
        margin_error_angle=margin_error_angle,
    )

is_points_collinear(p1, p2, p3, margin_error_angle=DEFAULT_MARGIN_ANGLE_ERROR) staticmethod

Verify whether three points on the plane are collinear or not. Method by angle or slope: For three points, slope of any pair of points must be same as other pair.

Parameters:

Name Type Description Default
p1 array

point of shape (2,)

required
p2 array

point of shape (2,)

required
p3 array

point of shape (2,)

required
margin_error_angle float

Threshold value for validating collinearity. Defaults to DEFAULT_MARGIN_ANGLE_ERROR.

DEFAULT_MARGIN_ANGLE_ERROR

Returns:

Name Type Description
bool bool

1 if colinear, 0 otherwise

Source code in otary/geometry/discrete/linear/segment.py
@staticmethod
def is_points_collinear(
    p1: NDArray,
    p2: NDArray,
    p3: NDArray,
    margin_error_angle: float = DEFAULT_MARGIN_ANGLE_ERROR,
) -> bool:
    """Verify whether three points on the plane are collinear or not.
    Method by angle or slope: For three points, slope of any pair of points must
    be same as other pair.

    Args:
        p1 (np.array): point of shape (2,)
        p2 (np.array): point of shape (2,)
        p3 (np.array): point of shape (2,)
        margin_error_angle (float, optional): Threshold value for validating
            collinearity. Defaults to DEFAULT_MARGIN_ANGLE_ERROR.

    Returns:
        bool: 1 if colinear, 0 otherwise
    """
    p1, p2, p3 = np.array(p1), np.array(p2), np.array(p3)

    # 2 or 3 points equal
    if (
        not np.logical_or(*(p1 - p2))
        or not np.logical_or(*(p1 - p3))
        or not np.logical_or(*(p2 - p3))
    ):
        return True

    segment1, segment2 = Segment([p1, p2]), Segment([p1, p3])
    return segment1.is_parallel(
        segment=segment2, margin_error_angle=margin_error_angle
    )

normal()

Returns the normal segment of the segment. The normal segment is a segment that is orthogonal to the input segment.

Please note that the normal segment have the same length as the input segment. Moreover the normal segment is rotated by 90 degrees clockwise.

Returns:

Name Type Description
Segment Self

normal segment centered at the original segment centroid

Source code in otary/geometry/discrete/linear/segment.py
def normal(self) -> Self:
    """
    Returns the normal segment of the segment.
    The normal segment is a segment that is orthogonal to the input segment.

    Please note that the normal segment have the same length as the input segment.
    Moreover the normal segment is rotated by 90 degrees clockwise.

    Returns:
        Segment: normal segment centered at the original segment centroid
    """
    normal = self.copy().rotate(
        angle=math.pi / 2, is_degree=False, is_clockwise=True
    )
    return normal

slope_angle(degree=False, is_y_axis_down=False)

Calculate the slope angle of a single line in the cartesian space

Parameters:

Name Type Description Default
degree bool

whether to output the result in degree. By default in radian.

False

Returns:

Name Type Description
float float

slope angle in ]-pi/2, pi/2[

Source code in otary/geometry/discrete/linear/segment.py
def slope_angle(self, degree: bool = False, is_y_axis_down: bool = False) -> float:
    """Calculate the slope angle of a single line in the cartesian space

    Args:
        degree (bool): whether to output the result in degree. By default in radian.

    Returns:
        float: slope angle in ]-pi/2, pi/2[
    """
    angle = np.arctan(self.slope_cv2) if is_y_axis_down else np.arctan(self.slope)
    if degree:
        angle = np.rad2deg(angle)
    return angle

Curve class useful to describe any kind of curves

LinearSpline

Bases: LinearEntity

Curve class

Source code in otary/geometry/discrete/linear/linear_spline.py
class LinearSpline(LinearEntity):
    """Curve class"""

    def __init__(self, points: NDArray | list, is_cast_int: bool = False) -> None:
        if len(points) < 2:
            raise ValueError(
                "Cannot create a LinearSpline since it must have 2 or more points"
            )
        super().__init__(points=points, is_cast_int=is_cast_int)

    @property
    def curvature(self) -> float:
        """Get the curvature of the linear spline as-if it had a well-defined
        curvature, meaning as-if it were a continuous curve.

        Returns:
            float: curvature value
        """
        # TODO
        raise NotImplementedError

    @property
    def centroid(self) -> NDArray:
        """Returns the center point that is within the linear spline.
        This means that this points necessarily belongs to the linear spline.

        This can be useful when the centroid is not a good representation of what
        is needed as 'center'.

        Returns:
            NDArray: point of shape (1, 2)
        """
        total_length: float = 0.0
        cx: float = 0.0
        cy: float = 0.0

        for i in range(len(self.points) - 1):
            p1, p2 = self.points[i], self.points[i + 1]
            mid = (p1 + p2) / 2
            length = float(np.linalg.norm(p2 - p1))
            cx += mid[0] * length
            cy += mid[1] * length
            total_length += length

        if total_length == 0:
            return self.points[0]  # or handle degenerate case
        return np.asarray([cx / total_length, cy / total_length])

    @property
    def midpoint(self) -> NDArray:
        """Returns the center point that is within the linear spline.
        This means that this points necessarily belongs to the linear spline.

        This can be useful when the centroid is not a good representation of what
        is needed as 'center'.

        Returns:
            NDArray: point of shape (1, 2)
        """
        return self.find_interpolated_point(pct_dist=0.5)

    def find_interpolated_point_and_prev_ix(
        self, pct_dist: float
    ) -> tuple[NDArray, int]:
        """Return a point along the curve at a relative distance pct_dist ∈ [0, 1]

        Parameters:
            pct_dist (float): Value in [0, 1], 0 returns start, 1 returns end.
                Any value in [0, 1] returns a point between start and end that is
                pct_dist along the path.

        Returns:
            tuple[NDArray, int]: Interpolated point [x, y] and previous index in path.
        """
        if not 0 <= pct_dist <= 1:
            raise ValueError("pct_dist must be in [0, 1]")

        if self.length == 0 or pct_dist == 0:
            return self[0], 0
        if pct_dist == 1:
            return self[-1], len(self) - 1

        # Walk along the path to find the point at pct_dist * total_dist
        target_dist = pct_dist * self.length
        accumulated = 0
        for i in range(len(self.edges)):
            cur_edge_length = self.lengths[i]
            if accumulated + cur_edge_length >= target_dist:
                remain = target_dist - accumulated
                direction = self[i + 1] - self[i]
                unit_dir = direction / cur_edge_length
                return self[i] + remain * unit_dir, i
            accumulated += cur_edge_length

        # Fallback
        return self[-1], i

    def find_interpolated_point(self, pct_dist: float) -> NDArray:
        """Return a point along the curve at a relative distance pct_dist ∈ [0, 1]

        Parameters:
            pct_dist (float): Value in [0, 1], 0 returns start, 1 returns end.
                Any value in [0, 1] returns a point between start and end that is
                pct_dist along the path.

        Returns:
            NDArray: Interpolated point [x, y]
        """
        return self.find_interpolated_point_and_prev_ix(pct_dist)[0]

centroid property

Returns the center point that is within the linear spline. This means that this points necessarily belongs to the linear spline.

This can be useful when the centroid is not a good representation of what is needed as 'center'.

Returns:

Name Type Description
NDArray NDArray

point of shape (1, 2)

curvature property

Get the curvature of the linear spline as-if it had a well-defined curvature, meaning as-if it were a continuous curve.

Returns:

Name Type Description
float float

curvature value

midpoint property

Returns the center point that is within the linear spline. This means that this points necessarily belongs to the linear spline.

This can be useful when the centroid is not a good representation of what is needed as 'center'.

Returns:

Name Type Description
NDArray NDArray

point of shape (1, 2)

find_interpolated_point(pct_dist)

Return a point along the curve at a relative distance pct_dist ∈ [0, 1]

Parameters:

Name Type Description Default
pct_dist float

Value in [0, 1], 0 returns start, 1 returns end. Any value in [0, 1] returns a point between start and end that is pct_dist along the path.

required

Returns:

Name Type Description
NDArray NDArray

Interpolated point [x, y]

Source code in otary/geometry/discrete/linear/linear_spline.py
def find_interpolated_point(self, pct_dist: float) -> NDArray:
    """Return a point along the curve at a relative distance pct_dist ∈ [0, 1]

    Parameters:
        pct_dist (float): Value in [0, 1], 0 returns start, 1 returns end.
            Any value in [0, 1] returns a point between start and end that is
            pct_dist along the path.

    Returns:
        NDArray: Interpolated point [x, y]
    """
    return self.find_interpolated_point_and_prev_ix(pct_dist)[0]

find_interpolated_point_and_prev_ix(pct_dist)

Return a point along the curve at a relative distance pct_dist ∈ [0, 1]

Parameters:

Name Type Description Default
pct_dist float

Value in [0, 1], 0 returns start, 1 returns end. Any value in [0, 1] returns a point between start and end that is pct_dist along the path.

required

Returns:

Type Description
tuple[NDArray, int]

tuple[NDArray, int]: Interpolated point [x, y] and previous index in path.

Source code in otary/geometry/discrete/linear/linear_spline.py
def find_interpolated_point_and_prev_ix(
    self, pct_dist: float
) -> tuple[NDArray, int]:
    """Return a point along the curve at a relative distance pct_dist ∈ [0, 1]

    Parameters:
        pct_dist (float): Value in [0, 1], 0 returns start, 1 returns end.
            Any value in [0, 1] returns a point between start and end that is
            pct_dist along the path.

    Returns:
        tuple[NDArray, int]: Interpolated point [x, y] and previous index in path.
    """
    if not 0 <= pct_dist <= 1:
        raise ValueError("pct_dist must be in [0, 1]")

    if self.length == 0 or pct_dist == 0:
        return self[0], 0
    if pct_dist == 1:
        return self[-1], len(self) - 1

    # Walk along the path to find the point at pct_dist * total_dist
    target_dist = pct_dist * self.length
    accumulated = 0
    for i in range(len(self.edges)):
        cur_edge_length = self.lengths[i]
        if accumulated + cur_edge_length >= target_dist:
            remain = target_dist - accumulated
            direction = self[i + 1] - self[i]
            unit_dir = direction / cur_edge_length
            return self[i] + remain * unit_dir, i
        accumulated += cur_edge_length

    # Fallback
    return self[-1], i

Vectors class they are like segments, but with a given direction

Vector

Bases: Segment, DirectedLinearEntity

Vector class to manipulate vector which can be seen as Segment with direction

Source code in otary/geometry/discrete/linear/directed/vector.py
class Vector(Segment, DirectedLinearEntity):
    """Vector class to manipulate vector which can be seen as Segment with direction"""

    @classmethod
    def from_single_point(cls, point: NDArray) -> Vector:
        """Get vector that goes from [0, 0] to point

        Args:
            point (NDArray): point of shape 2

        Returns:
            Vector: new vector object
        """
        return cls(points=[[0, 0], point])

    @property
    def cardinal_degree(self) -> float:
        """Returns the cardinal degree of the vector in the cv2 space.
        We consider the top of the image to point toward the north as default and thus
        represent the cardinal degree value 0 mod 360.

        Returns:
            float: cardinal degree
        """
        angle = self.slope_angle(degree=True, is_y_axis_down=True)

        # if angle is negative
        is_neg_sign_angle = bool(np.sign(angle) - 1)
        if is_neg_sign_angle:
            angle = 90 + np.abs(angle)
        else:
            angle = 90 - angle

        # if vector points towards west
        if self.is_x_first_pt_gt_x_last_pt:
            angle += 180

        cardinal_degree = np.mod(360 + angle, 360)  # avoid negative value case
        return cardinal_degree

    @property
    def coordinates_shift(self) -> NDArray:
        """Return the vector as a single point (x1-x0, y1-y0)

        Returns:
            NDArray: coordinates shift
        """
        return self.origin[1]

    @property
    def normalized(self) -> NDArray:
        """Nornalized vector

        Returns:
            NDArray: normalized vector
        """
        return self.coordinates_shift / np.linalg.norm(self.coordinates_shift) + 1e-9

    def rescale_head(self, scale: float) -> Vector:
        """Rescale the head part of the vector without moving the first point.
        This method only updates the second point that composes the vector.

        Args:
            scale (float): scale factor

        Returns:
            Vector: scaled vector
        """
        self.asarray = (self.asarray - self.tail) * scale + self.tail
        return self

cardinal_degree property

Returns the cardinal degree of the vector in the cv2 space. We consider the top of the image to point toward the north as default and thus represent the cardinal degree value 0 mod 360.

Returns:

Name Type Description
float float

cardinal degree

coordinates_shift property

Return the vector as a single point (x1-x0, y1-y0)

Returns:

Name Type Description
NDArray NDArray

coordinates shift

normalized property

Nornalized vector

Returns:

Name Type Description
NDArray NDArray

normalized vector

from_single_point(point) classmethod

Get vector that goes from [0, 0] to point

Parameters:

Name Type Description Default
point NDArray

point of shape 2

required

Returns:

Name Type Description
Vector Vector

new vector object

Source code in otary/geometry/discrete/linear/directed/vector.py
@classmethod
def from_single_point(cls, point: NDArray) -> Vector:
    """Get vector that goes from [0, 0] to point

    Args:
        point (NDArray): point of shape 2

    Returns:
        Vector: new vector object
    """
    return cls(points=[[0, 0], point])

rescale_head(scale)

Rescale the head part of the vector without moving the first point. This method only updates the second point that composes the vector.

Parameters:

Name Type Description Default
scale float

scale factor

required

Returns:

Name Type Description
Vector Vector

scaled vector

Source code in otary/geometry/discrete/linear/directed/vector.py
def rescale_head(self, scale: float) -> Vector:
    """Rescale the head part of the vector without moving the first point.
    This method only updates the second point that composes the vector.

    Args:
        scale (float): scale factor

    Returns:
        Vector: scaled vector
    """
    self.asarray = (self.asarray - self.tail) * scale + self.tail
    return self

Vectorized Curve class useful to describe any kind of vectorized curves

VectorizedLinearSpline

Bases: LinearSpline, DirectedLinearEntity

VectorizedLinearSpline class:

  • it IS a linear spline
  • it HAS a vector since a vector IS a segment and however the curve CANNOT be a segment. The vector is thus an attribute in this class. The vector does inherit from Vector class.
Source code in otary/geometry/discrete/linear/directed/vectorized_linear_spline.py
class VectorizedLinearSpline(LinearSpline, DirectedLinearEntity):
    """VectorizedLinearSpline class:

    - it IS a linear spline
    - it HAS a vector since a vector IS a segment and however the curve CANNOT be
        a segment. The vector is thus an attribute in this class.
        The vector does inherit from Vector class.
    """

    def __init__(self, points, is_cast_int=False):
        super().__init__(points, is_cast_int)
        self.vector_extremities = Vector(points=np.array([points[0], points[-1]]))

    @property
    def is_simple_vector(self) -> bool:
        """Whether the VectorizedLinearSpline is just a two points vector or not

        Returns:
            bool: True or false
        """
        return np.array_equal(self.asarray, self.vector_extremities.asarray)

    @property
    def cardinal_degree(self) -> float:
        """Returns the cardinal degree of the VectorizedLinearSpline in the cv2 space.
        It is calculated using the two extremities points that compose the object.

        We consider the top of the image to point toward the north as default and thus
        represent the cardinal degree value 0 mod 360.

        Returns:
            float: cardinal degree
        """
        return self.vector_extremities.cardinal_degree

cardinal_degree property

Returns the cardinal degree of the VectorizedLinearSpline in the cv2 space. It is calculated using the two extremities points that compose the object.

We consider the top of the image to point toward the north as default and thus represent the cardinal degree value 0 mod 360.

Returns:

Name Type Description
float float

cardinal degree

is_simple_vector property

Whether the VectorizedLinearSpline is just a two points vector or not

Returns:

Name Type Description
bool bool

True or false