Source code for otx.api.entities.shapes.rectangle

"""This module implements the Rectangle shape entity."""
# Copyright (C) 2021-2022 Intel Corporation
# SPDX-License-Identifier: Apache-2.0
#

# Conflict with Isort
# pylint: disable=wrong-import-order, cyclic-import

import datetime
import math
import warnings
from typing import Optional

import numpy as np
from shapely.geometry import Polygon as shapely_polygon

from otx.api.entities.shapes.shape import Shape, ShapeEntity, ShapeType
from otx.api.utils.time_utils import now

# pylint: disable=invalid-name


[docs] class Rectangle(Shape): """Rectangle represents a rectangular shape. Rectangle are used to annotate detection and classification tasks. In the classification case, the rectangle is a full rectangle spanning the whole related item (could be an image, video frame, a region of interest). - x1 and y1 represent the top-left coordinate of the rectangle - x2 and y2 representing the bottom-right coordinate of the rectangle Args: x1 (float): x-coordinate of the top-left corner of the rectangle y1 (float): y-coordinate of the top-left corner of the rectangle x2 (float): x-coordinate of the bottom-right corner of the rectangle y2 (float): y-coordinate of the bottom-right corner of the rectangle modification_date (datetime.datetime): Date of the last modification of the rectangle """ # pylint: disable=too-many-arguments; Requires refactor def __init__( self, x1: float, y1: float, x2: float, y2: float, modification_date: Optional[datetime.datetime] = None, ): modification_date = now() if modification_date is None else modification_date super().__init__( shape_type=ShapeType.RECTANGLE, modification_date=modification_date, ) is_valid = True for (x, y) in [(x1, y1), (x2, y2)]: is_valid = is_valid and self._validate_coordinates(x, y) if not is_valid: warnings.warn( f"{type(self).__name__} coordinates are invalid : x1={x1}, y1={y1}, x2={x2}, y2={y2}", UserWarning, ) self.x1 = x1 self.y1 = y1 self.x2 = x2 self.y2 = y2 if self.width <= 0 or self.height <= 0: raise ValueError( f"Invalid rectangle with coordinates: x1={self.x1}, y1={self.y1}, " f"x2={self.x2}, y2={self.y2}" ) def __repr__(self): """String representation of the rectangle.""" return f"Rectangle(x={self.x1}, y={self.y1}, width={self.width}, " f"height={self.height})" def __eq__(self, other: object): """Returns True if `other` is a `Rectangle` with the same coordinates.""" if isinstance(other, Rectangle): return ( self.x1 == other.x1 and self.y1 == other.y1 and self.x2 == other.x2 and self.y2 == other.y2 and self.modification_date == other.modification_date ) return False def __hash__(self): """Returns hash of the rectangle.""" return hash(str(self))
[docs] def clip_to_visible_region(self) -> "Rectangle": """Clip the rectangle to the [0, 1] visible region of an image. Returns: Rectangle: Clipped rectangle. """ x1 = min(max(0.0, self.x1), 1.0) y1 = min(max(0.0, self.y1), 1.0) x2 = min(max(0.0, self.x2), 1.0) y2 = min(max(0.0, self.y2), 1.0) return Rectangle(x1=x1, y1=y1, x2=x2, y2=y2, modification_date=self.modification_date)
[docs] def normalize_wrt_roi_shape(self, roi_shape: ShapeEntity) -> "Rectangle": """Transforms from the `roi` coordinate system to the normalized coordinate system. Example: Assume we have rectangle `b1` which lives in the top-right quarter of a 2D space. The 2D space where `b1` lives in is an `roi` living in the top-left quarter of the normalized coordinate space. This function returns rectangle `b1` expressed in the normalized coordinate space. >>> from otx.api.entities.annotation import Annotation >>> b1 = Rectangle(x1=0.5, x2=1.0, y1=0.0, y2=0.5) >>> roi = Rectangle(x1=0.0, x2=0.5, y1=0.0, y2=0.5) >>> normalized = b1.normalize_wrt_roi_shape(roi_shape) >>> normalized Box(, x=0.25, y=0.0, width=0.25, height=0.25) Args: roi_shape (ShapeEntity): Region of Interest. Raises: ValueError: If the `roi_shape` is not a `Rectangle`. Returns: New polygon in the image coordinate system """ if not isinstance(roi_shape, Rectangle): raise ValueError("roi_shape has to be a Rectangle.") roi_shape = roi_shape.clip_to_visible_region() return Rectangle( x1=self.x1 * roi_shape.width + roi_shape.x1, y1=self.y1 * roi_shape.height + roi_shape.y1, x2=self.x2 * roi_shape.width + roi_shape.x1, y2=self.y2 * roi_shape.height + roi_shape.y1, modification_date=self.modification_date, )
[docs] def denormalize_wrt_roi_shape(self, roi_shape: ShapeEntity) -> "Rectangle": """Transforming shape from the normalized coordinate system to the `roi` coordinate system. Example: Assume we have rectangle `b1` which lives in the top-right quarter of the normalized coordinate space. The `roi` is a rectangle living in the half right of the normalized coordinate space. This function returns rectangle `b1` expressed in the coordinate space of `roi`. (should return top-half) Box denormalized to a rectangle as ROI >>> from otx.api.entities.annotation import Annotation >>> b1 = Rectangle(x1=0.5, x2=1.0, y1=0.0, y2=0.5) # the top-right >>> roi = Annotation(Rectangle(x1=0.5, x2=1.0, y1=0.0, y2=1.0)) # the half-right >>> normalized = b1.denormalize_wrt_roi_shape(roi_shape) # should return top half >>> normalized Box(, x=0.0, y=0.0, width=1.0, height=0.5) Args: roi_shape (ShapeEntity): Region of Interest Raises: ValueError: If the `roi_shape` is not a `Rectangle`. Returns: Rectangle: New polygon in the ROI coordinate system """ if not isinstance(roi_shape, Rectangle): raise ValueError("roi_shape has to be a Rectangle.") roi_shape = roi_shape.clip_to_visible_region() x1 = (self.x1 - roi_shape.x1) / roi_shape.width y1 = (self.y1 - roi_shape.y1) / roi_shape.height x2 = (self.x2 - roi_shape.x1) / roi_shape.width y2 = (self.y2 - roi_shape.y1) / roi_shape.height return Rectangle( x1=x1, y1=y1, x2=x2, y2=y2, modification_date=self.modification_date, )
def _as_shapely_polygon(self) -> shapely_polygon: points = [ (self.x1, self.y1), (self.x2, self.y1), (self.x2, self.y2), (self.x1, self.y2), (self.x1, self.y1), ] return shapely_polygon(points)
[docs] @classmethod def generate_full_box(cls) -> "Rectangle": """Returns a rectangle that fully encapsulates the normalized coordinate space. Example: >>> Rectangle.generate_full_box() Box(, x=0.0, y=0.0, width=1.0, height=1.0) Returns: Rectangle: A rectangle that fully encapsulates the normalized coordinate space. """ return cls(x1=0.0, y1=0.0, x2=1.0, y2=1.0)
[docs] @staticmethod def is_full_box(rectangle: ShapeEntity) -> bool: """Returns true if rectangle is a full box (occupying the full normalized coordinate space). Example: >>> b1 = Rectangle(x1=0.5, x2=1.0, y1=0.0, y2=1.0) >>> Rectangle.is_full_box(b1) False >>> b2 = Rectangle(x1=0.0, x2=1.0, y1=0.0, y2=1.0) >>> Rectangle.is_full_box(b2) True Args: rectangle (ShapeEntity): rectangle to evaluate Returns: bool: true if it fully encapsulate normalized coordinate space. """ if ( isinstance(rectangle, Rectangle) and rectangle.x1 == 0 and rectangle.y1 == 0 and rectangle.height == 1 and rectangle.width == 1 ): return True return False
[docs] def crop_numpy_array(self, data: np.ndarray) -> np.ndarray: """Crop the given Numpy array to the region of interest represented by this rectangle. Args: data (np.ndarray): Image to crop. Returns: np.ndarray: Cropped image. """ # We clip negative values to zero since Numpy uses negative values # to represent indexing from the right side of the array. # However, on the other hand, it is safe to have indices larger than the size # of the dimension; therefore, we do not clip values larger than the width and # height. x1 = max(int(round(self.x1 * data.shape[1])), 0) x2 = max(int(round(self.x2 * data.shape[1])), 0) y1 = max(int(round(self.y1 * data.shape[0])), 0) y2 = max(int(round(self.y2 * data.shape[0])), 0) return data[y1:y2, x1:x2, ::]
@property def width(self) -> float: """Returns the width of the rectangle (x-axis). Example: >>> b1 = Rectangle(x1=0.5, x2=1.0, y1=0.0, y2=0.5) >>> b1.width 0.5 Returns: float: the width of the rectangle. (x-axis) """ return self.x2 - self.x1 @property def height(self) -> float: """Returns the height of the rectangle (y-axis). Example: >>> b1 = Rectangle(x1=0.5, x2=1.0, y1=0.0, y2=0.5) >>> b1.height 0.5 Returns: float: the height of the rectangle. (y-axis) """ return self.y2 - self.y1 @property def diagonal(self) -> float: """Returns the diagonal size/hypotenuse of the rectangle (x-axis). Example: >>> b1 = Rectangle(x1=0.0, x2=0.3, y1=0.0, y2=0.4) >>> b1.diagonal 0.5 Returns: float: the width of the rectangle. (x-axis) """ return math.hypot(self.width, self.height)
[docs] def get_area(self) -> float: """Computes the approximate area of the shape. Area is a value between 0 and 1, calculated as (x2-x1) * (y2-y1) NOTE: This method should not be relied on for exact area computation. The area is approximate, because shapes are continuous, but pixels are discrete. Example: >>> Rectangle(0, 0, 1, 1).get_area() 1.0 >>> Rectangle(0.5, 0.5, 1.0, 1.0).get_area() 0.25 Returns: float: Approximate area of the shape. """ return (self.x2 - self.x1) * (self.y2 - self.y1)