"""This module implements helpers for converting shape entities."""
# Copyright (C) 2021-2022 Intel Corporation
# SPDX-License-Identifier: Apache-2.0
#
from otx.api.entities.shapes.ellipse import Ellipse
from otx.api.entities.shapes.polygon import Point, Polygon
from otx.api.entities.shapes.rectangle import Rectangle
from otx.api.entities.shapes.shape import ShapeEntity
[docs]
class ShapeFactory:
"""Helper class converting between shape types."""
[docs]
@staticmethod
def shape_as_rectangle(shape: ShapeEntity) -> Rectangle:
"""Get the outer-fitted rectangle representation of the shape.
`media_width` and `media_height` are the width and height of the media in
which the shape is expressed.
Example:
Let's assume a DatasetItem `dataset_item`.
To obtain the shapes inside the full annotation as rectangles, one could call:
>>> from otx.api.entities.dataset_item import DatasetItem
>>> from otx.api.entities.image import NullImage
>>> from otx.api.entities.annotation_scene import NullAnnotationScene
>>> dataset_item = DatasetItem(media=NullImage(),
>>> annotation=NullAnnotationScene())
>>> rectangles = [ShapeFactory.shape_as_rectangle(shape,
>>> dataset_item.media.width, dataset_item.media.height) for shape
... in dataset_item.annotation_scene.shapes]
To obtain the shapes inside the dataset item (note that dataset item can have
roi), one should call:
>>> rectangles = [ShapeFactory.shape_as_rectangle(shape) for shape in
>>> dataset_item.get_annotations()]
Since the shapes in the first call come from annotation directly, this means
they are expressed in the media coordinate system. Therefore,
dataset_item.media.width and dataset_item.media.height are passed.
While in the second call, the shapes come from denormalization results wrt.
dataset_item.roi and therefore expressed inside the roi. In this case,
dataset_item.width and dataset_item.height are passed.
Converting Ellipse to rectangle
>>> height = 240
>>> width = 480
>>> rectangle = Rectangle(x1=0.375, y1=0.25, x2=0.625, y2=0.75,
... labels=[]) # a square of 120 x 120 pixels at the center of the image
>>> ellipse = Ellipse(x1=0.5, y1=0.5, x2=0.625, y2=0.56125,
... labels=[]) # an ellipse of radius 60 pixels (x2 is wrt width) at the
center of the image
>>> ellipse_as_rectangle = ShapeFactory.shape_as_rectangle(ellipse) # get the
fitted rectangle for the ellipse
>>> str(rectangle) == str(ellipse_as_rectangle)
True
Converting triangle to rectangle
>>> points = [Point(x=0.5, y=0.25), Point(x=0.375, y=0.75),
>>> Point(x=0.625, y=0.75)]
>>> triangle = Polygon(points=points, labels=[])
>>> triangle_as_rectangle = ShapeFactory.shape_as_rectangle(triangle)
>>> str(triangle_as_rectangle) == str(rectangle)
True
Args:
shape (ShapeEntity): the shape to convert to rectangle
Returns:
Rectangle: bounding box of the shape
"""
if isinstance(shape, Rectangle):
return shape
if isinstance(shape, Ellipse):
x1 = shape.x1
y1 = shape.y1
x2 = shape.x2
y2 = shape.y2
elif isinstance(shape, Polygon):
x1 = shape.min_x
x2 = shape.max_x
y1 = shape.min_y
y2 = shape.max_y
else:
raise NotImplementedError(f"Conversion of a {type(shape)} to a rectangle is not implemented yet: {shape}")
new_shape = Rectangle(x1=x1, y1=y1, x2=x2, y2=y2)
return new_shape
[docs]
@staticmethod
def shape_as_polygon(shape: ShapeEntity) -> Polygon:
"""Return a shape converted as polygon.
For a rectangle, a polygon will be constructed with a point in each corner.
For a ellipse, 360 points will be made. Otherwise, the original shape will be returned.
The width/height for the parent need to be specified to make sure the aspect ratio is maintained.
Args:
shape (ShapeEntity): the shape to convert to polygon.
Returns:
Polygon: the polygon representation of the shape.
Raises:
NotImplementedError: if the shape is not a rectangle or ellipse.
"""
if isinstance(shape, Polygon):
new_shape = shape
elif isinstance(shape, Rectangle):
points = [
Point(x=shape.x1, y=shape.y1),
Point(x=shape.x2, y=shape.y1),
Point(x=shape.x2, y=shape.y2),
Point(x=shape.x1, y=shape.y2),
Point(x=shape.x1, y=shape.y1),
]
new_shape = Polygon(points=points)
elif isinstance(shape, Ellipse):
coordinates = shape.get_evenly_distributed_ellipse_coordinates()
points = [Point(x=point[0], y=point[1]) for point in coordinates]
new_shape = Polygon(points=points)
else:
raise NotImplementedError(f"Conversion of a {type(shape)} to a polygon is not implemented yet: " f"{shape}")
return new_shape
[docs]
@staticmethod
def shape_as_ellipse(shape: ShapeEntity) -> Ellipse:
"""Returns the inner-fitted ellipse for a given shape.
Args:
shape (ShapeEntity): Shape to convert.
Returns:
Ellipse: Ellipse representation of the shape.
Raises:
NotImplementedError: If the shape is not a rectangle or polygon.
"""
if isinstance(shape, Ellipse):
return shape
if isinstance(shape, Rectangle):
x1 = shape.x1
x2 = shape.x2
y1 = shape.y1
y2 = shape.y2
elif isinstance(shape, Polygon):
x1 = shape.min_x
x2 = shape.max_x
y1 = shape.min_y
y2 = shape.max_y
else:
raise NotImplementedError(f"Conversion of a {type(shape)} to an ellipse is not implemented yet: {shape}")
return Ellipse(x1=x1, y1=y1, x2=x2, y2=y2)
[docs]
@staticmethod
def shape_produces_valid_crop(shape: ShapeEntity, media_width: int, media_height: int) -> bool:
"""Check if crop is valid.
Checks if the shape produces a valid crop based on the image width and height,
regardless of the contents of the image.
Args:
shape (ShapeEntity): Shape to check
media_width (int): Width of the image
media_height (int): Height of the image
Returns:
bool: True if the shape produces a valid crop, False otherwise
"""
is_valid = True
try:
shape_as_rectangle = ShapeFactory.shape_as_rectangle(shape=shape)
except ValueError:
# Thrown if the resulting bounding box is invalid
return False
# The min and max are to handle out-of-bounds coordinates
x1 = min(max(round(shape_as_rectangle.x1 * media_width), 0), media_width)
x2 = min(max(round(shape_as_rectangle.x2 * media_width), 0), media_width)
y1 = min(max(round(shape_as_rectangle.y1 * media_height), 0), media_height)
y2 = min(max(round(shape_as_rectangle.y2 * media_height), 0), media_height)
if x1 == x2 or y1 == y2:
is_valid = False
return is_valid