"""This module implements helpers for drawing shapes."""
# Copyright (C) 2021-2022 Intel Corporation
# SPDX-License-Identifier: Apache-2.0
#
# pylint: disable=too-many-instance-attributes, too-many-arguments, too-many-locals
import abc
from typing import (
Callable,
Generic,
List,
NewType,
Optional,
Sequence,
Tuple,
Type,
TypeVar,
Union,
)
import cv2
import numpy as np
from otx.api.entities.annotation import (
Annotation,
AnnotationSceneEntity,
NullAnnotationSceneEntity,
)
from otx.api.entities.coordinate import Coordinate
from otx.api.entities.label import LabelEntity
from otx.api.entities.scored_label import ScoredLabel
from otx.api.entities.shapes.ellipse import Ellipse
from otx.api.entities.shapes.polygon import Polygon
from otx.api.entities.shapes.rectangle import Rectangle
from otx.api.entities.shapes.shape import ShapeEntity
CvTextSize = NewType("CvTextSize", Tuple[Tuple[int, int], int])
_Any = TypeVar("_Any")
[docs]
class DrawerEntity(Generic[_Any]):
"""An interface to draw a shape of type ``T`` onto an image."""
supported_types: Sequence[Type[ShapeEntity]] = []
[docs]
@abc.abstractmethod
def draw(self, image: np.ndarray, entity: _Any, labels: List[ScoredLabel]) -> np.ndarray:
"""Draw an entity to a given frame.
Args:
image (np.ndarray): The image to draw the entity on.
entity (T): The entity to draw.
labels (List[ScoredLabel]): Labels of the shapes to draw
Returns:
np.ndarray: frame with shape drawn on it
"""
raise NotImplementedError
[docs]
class Helpers:
"""Contains variables which are used by all subclasses.
Contains functions which help with generating coordinates, text and text scale.
These functions are use by the DrawerEntity Classes when drawing to an image.
"""
def __init__(self) -> None:
# Same alpha value that the UI uses for Labels
self.alpha_shape = 100 / 256
self.alpha_labels = 153 / 256
self.assumed_image_width_for_text_scale = 1280 # constant number for size of classification/counting overlay
self.top_margin = 0.07 # part of the top screen reserved for top left classification/counting overlay
self.content_padding = 3
self.top_left_box_thickness = 1
self.content_margin = 2
self.label_offset_box_shape = 0
self.black = (0, 0, 0)
self.white = (255, 255, 255)
self.yellow = (255, 255, 0)
self.cursor_pos = Coordinate(0, 0)
self.line_height = 0
[docs]
@staticmethod
def draw_transparent_rectangle(
img: np.ndarray,
x1: int,
y1: int,
x2: int,
y2: int,
color: Tuple[int, int, int],
alpha: float,
) -> np.ndarray:
"""Draw a rectangle on an image.
Args:
img (np.ndarray): Image
x1 (int): Left side
y1 (int): Top side
x2 (int): Right side
y2 (int): Bottom side
color (Tuple[int, int, int]): Color
alpha (float): Alpha value between 0 and 1
"""
x1 = np.clip(x1, 0, img.shape[1] - 1)
y1 = np.clip(y1, 0, img.shape[0] - 1)
x2 = np.clip(x2 + 1, 0, img.shape[1] - 1)
y2 = np.clip(y2 + 1, 0, img.shape[0] - 1)
rect = img[y1:y2, x1:x2]
rect[:] = (alpha * np.array(color))[np.newaxis, np.newaxis] + (1 - alpha) * rect
return img
[docs]
def generate_text_scale(self, image: np.ndarray) -> float:
"""Calculates the scale of the text.
Args:
image (np.ndarray): Image to calculate the text scale for.
Returns:
scale for the text
"""
return round(image.shape[1] / self.assumed_image_width_for_text_scale, 1)
[docs]
@staticmethod
def generate_text_for_label(
label: Union[LabelEntity, ScoredLabel], show_labels: bool, show_confidence: bool
) -> str:
"""Return a string representing a given label and its associated probability if label is a ScoredLabel.
The exact format of the string depends on the function parameters described below.
Args:
label (Union[LabelEntity, ScoredLabel]): Label
show_labels (bool): Whether to render the labels above the shape
show_confidence (bool): Whether to render the confidence above the
shape
Returns:
str: Formatted string (e.g. `"Cat 58%"`)
"""
text = ""
if show_labels:
text += label.name
if show_confidence and isinstance(label, ScoredLabel):
if len(text) > 0:
text += " "
text += f"{label.probability:.0%}"
return text
[docs]
def generate_draw_command_for_labels(
self,
labels: Sequence[Union[LabelEntity, ScoredLabel]],
image: np.ndarray,
show_labels: bool,
show_confidence: bool,
) -> Tuple[Callable[[np.ndarray], np.ndarray], int, int]:
"""Generate draw function and content width and height for labels.
Generates a function which can be called to draw a list of labels onto an image relatively to the
cursor position.
The width and height of the content is also returned and can be determined to compute
the best position for content before actually drawing it.
Args:
labels (Sequence[Union[LabelEntity, ScoredLabel]]): List of labels
image (np.ndarray): Image (used to compute font size)
show_labels (bool): Whether to show the label name
show_confidence (bool): Whether to show the confidence probability
Returns:
A tuple containing the drawing function, the content width,
and the content height
"""
draw_commands = []
content_width = 0
content_height = 0
# Loop through the list of labels and create a function which can be used to draw the label.
for label in labels:
text = self.generate_text_for_label(label, show_labels, show_confidence)
text_scale = self.generate_text_scale(image)
thickness = int(text_scale / 2)
color = label.color.bgr_tuple
item_command, item_width, item_height = self.generate_draw_command_for_text(
text, text_scale, thickness, color
)
draw_commands.append(item_command)
content_width += item_width
content_height = max(content_height, item_height)
def draw_command(img: np.ndarray) -> np.ndarray:
for command in draw_commands:
img = command(img)
return img
return draw_command, content_width, content_height
[docs]
def generate_draw_command_for_text(
self, text: str, text_scale: float, thickness: int, color: Tuple[int, int, int]
) -> Tuple[Callable[[np.ndarray], np.ndarray], int, int]:
"""Generate function to draw text on image relative to cursor position.
Generate a function which can be called to draw the given text onto an image
relatively to the cursor position.
The width and height of the content is also returned and can be determined to compute
the best position for content before actually drawing it.
Args:
text (str): Text to draw
text_scale (float): Font size
thickness (int): Thickness of the text
color (Tuple[int, int, int]): Color of the text
Returns:
A tuple containing the drawing function, the content width,
and the content height
"""
padding = self.content_padding
margin = self.content_margin
label_size = cv2.getTextSize(text, cv2.FONT_HERSHEY_SIMPLEX, fontScale=text_scale, thickness=thickness)
baseline = label_size[1]
text_width = label_size[0][0]
text_height = label_size[0][1]
width = text_width + 2 * padding
height = text_height + baseline + 2 * padding
content_width = width + margin
if (color[0] + color[1] + color[2]) / 3 > 200:
text_color = self.black
else:
text_color = self.white
def draw_command(img: np.ndarray) -> np.ndarray:
cursor_pos = Coordinate(int(self.cursor_pos.x), int(self.cursor_pos.y))
self.draw_transparent_rectangle(
img,
int(cursor_pos.x),
int(cursor_pos.y),
int(cursor_pos.x + width),
int(cursor_pos.y + height),
color,
self.alpha_labels,
)
img = cv2.putText(
img=img,
text=text,
org=(
cursor_pos.x + padding,
cursor_pos.y + height - padding - baseline,
),
fontFace=cv2.FONT_HERSHEY_SIMPLEX,
fontScale=text_scale,
color=text_color,
thickness=thickness,
lineType=cv2.LINE_AA,
)
self.cursor_pos.x += content_width
self.line_height = height
return img
return draw_command, content_width, height
[docs]
@staticmethod
def draw_flagpole(
image: np.ndarray,
flagpole_start_point: Coordinate,
flagpole_end_point: Coordinate,
):
"""Draw a small flagpole between two points.
Args:
image: Image
flagpole_start_point: Start of the flagpole
flagpole_end_point: End of the flagpole
Returns:
Image
"""
return cv2.line(
image,
flagpole_start_point.as_int_tuple(),
flagpole_end_point.as_int_tuple(),
color=[0, 0, 0],
thickness=2,
)
[docs]
def newline(self):
"""Move the cursor to the next line."""
self.cursor_pos.x = 0
self.cursor_pos.y += self.line_height + self.content_margin
[docs]
def set_cursor_pos(self, cursor_pos: Optional[Coordinate] = None):
"""Move the cursor to a new position.
Args:
cursor_pos (Optional[Coordinate]): New position of the cursor; (0,0) if not specified.
"""
if cursor_pos is None:
cursor_pos = Coordinate(0, 0)
self.cursor_pos = cursor_pos
[docs]
class ShapeDrawer(DrawerEntity[AnnotationSceneEntity]):
"""ShapeDrawer to draw any shape on a numpy array. Will overlay the shapes in the same way that the UI does.
Args:
show_count: Whether or not to render the amount of objects on
screen in the top left.
is_one_label: Whether there is only one label present in the
project.
"""
# TODO Connect show_count,is_is_one_label to the UI for toggling.
def __init__(self, show_count, is_one_label):
super().__init__()
self.show_labels = True
self.show_confidence = True
self.show_count = show_count
self.is_one_label = is_one_label
if self.is_one_label and not self.show_count:
self.show_labels = False
self.shape_drawers = [
self.RectangleDrawer(self.show_labels, self.show_confidence),
self.PolygonDrawer(self.show_labels, self.show_confidence),
self.EllipseDrawer(self.show_labels, self.show_confidence),
]
# Always show global labels, especially if shape labels are disabled (because of is_one_label).
self.top_left_drawer = self.TopLeftDrawer(True, self.show_confidence, self.is_one_label)
[docs]
def draw(
self,
image: np.ndarray,
entity: AnnotationSceneEntity,
labels: List[ScoredLabel],
) -> np.ndarray:
"""Use a compatible drawer to draw all shapes of an annotation to the corresponding image.
Also render a label in the top left if we need to.
Args:
image: Numpy image, one frame of a video on which to draw
something
entity: AnnotationSceneEntity entity corresponding to this
particular frame of the video
labels: Can be passed as an empty list since they are
already present in annotation_scene
Returns:
Modified image.
"""
num_annotations = 0
self.top_left_drawer.set_cursor_pos()
if not isinstance(entity, NullAnnotationSceneEntity):
for annotation in entity.annotations:
if (
isinstance(annotation.shape, Rectangle)
and annotation.shape.x1 == 0
and annotation.shape.y1 == 0
and annotation.shape.x2 == 1
and annotation.shape.y2 == 1
):
# If is_one_label is activated, don't draw the labels here
# because we will draw them again outside the loop.
if not self.is_one_label:
image = self.top_left_drawer.draw(image, annotation, labels=[])
else:
num_annotations += 1
for drawer in self.shape_drawers:
if type(annotation.shape) in drawer.supported_types and len(annotation.get_labels()) > 0:
image = drawer.draw(image, annotation.shape, labels=annotation.get_labels())
if self.is_one_label:
image = self.top_left_drawer.draw_labels(image, entity.get_labels())
if self.show_count:
image = self.top_left_drawer.draw_annotation_count(image, num_annotations)
return image
[docs]
class TopLeftDrawer(Helpers, DrawerEntity[Annotation]):
"""Draws labels in an image's top left corner."""
def __init__(self, show_labels, show_confidence, is_one_label):
super().__init__()
self.show_labels = show_labels
self.show_confidence = show_confidence
self.is_one_label = is_one_label
[docs]
def draw(self, image: np.ndarray, entity: Annotation, labels: List[ScoredLabel]) -> np.ndarray:
"""Draw the labels of a shape in the image top left corner.
Args:
image (np.ndarray): Image
entity (Annotation): Annotation
labels (List[ScoredLabels]): (Unused) labels to be drawn on the image
Returns:
np.ndarray: Image with label on top.
"""
return self.draw_labels(image, entity.get_labels())
[docs]
def draw_labels(self, image: np.ndarray, labels: Sequence[Union[LabelEntity, ScoredLabel]]) -> np.ndarray:
"""Draw the labels in the image top left corner.
Args:
image (np.ndarray): Image
labels (Sequence[Union[LabelEntity, ScoredLabel]]): Sequence of labels
Returns:
np.ndarray: Image with label on top.
"""
show_confidence = self.show_confidence if not self.is_one_label else False
draw_command, _, _ = self.generate_draw_command_for_labels(labels, image, self.show_labels, show_confidence)
image = draw_command(image)
if len(labels) > 0:
self.newline()
return image
[docs]
def draw_annotation_count(self, image: np.ndarray, num_annotations: int) -> np.ndarray:
"""Draw the number of annotations to the top left corner of the image.
Args:
image (np.ndarray): Image
num_annotations (int): Number of annotations
Returns:
np.ndarray: Image with annotation count on top.
"""
text = f"Count: {num_annotations}"
color = self.yellow
text_scale = self.generate_text_scale(image)
draw_command, _, _ = self.generate_draw_command_for_text(
text, text_scale, self.top_left_box_thickness, color
)
image = draw_command(image)
self.newline()
return image
[docs]
class RectangleDrawer(Helpers, DrawerEntity[Rectangle]):
"""Draws rectangles."""
supported_types = [Rectangle]
def __init__(self, show_labels, show_confidence):
super().__init__()
self.show_labels = show_labels
self.show_confidence = show_confidence
[docs]
def draw(self, image: np.ndarray, entity: Rectangle, labels: List[ScoredLabel]) -> np.ndarray:
"""Draws a rectangle on the image along with labels.
Args:
image (np.ndarray): Image to draw on.
entity (Rectangle): Rectangle to draw.
labels (List[ScoredLabel]): List of labels.
Returns:
np.ndarray: Image with rectangle drawn on it.
"""
base_color = labels[0].color.bgr_tuple
# Draw the rectangle on the image
x1, y1 = int(entity.x1 * image.shape[1]), int(entity.y1 * image.shape[0])
x2, y2 = int(entity.x2 * image.shape[1]), int(entity.y2 * image.shape[0])
image = self.draw_transparent_rectangle(image, x1, y1, x2, y2, base_color, self.alpha_shape)
image = cv2.rectangle(img=image, pt1=(x1, y1), pt2=(x2, y2), color=base_color, thickness=2)
(
draw_command,
content_width,
content_height,
) = self.generate_draw_command_for_labels(labels, image, self.show_labels, self.show_confidence)
# Generate a command to draw the list of labels
# and compute the actual size of the list of labels.
y_coord = y1 - self.label_offset_box_shape - content_height
x_coord = x1
# put label inside if it is out of bounds at the top of the shape, and shift label to left if needed
if y_coord < self.top_margin * image.shape[0]:
y_coord = y1 + self.label_offset_box_shape
if x_coord + content_width > image.shape[1]:
x_coord = x2 - content_width
# Draw the list of labels.
self.set_cursor_pos(Coordinate(x_coord, y_coord))
image = draw_command(image)
return image
[docs]
class EllipseDrawer(Helpers, DrawerEntity[Ellipse]):
"""Draws ellipses."""
supported_types = [Ellipse]
def __init__(self, show_labels, show_confidence):
super().__init__()
self.show_labels = show_labels
self.show_confidence = show_confidence
[docs]
def draw(self, image: np.ndarray, entity: Ellipse, labels: List[ScoredLabel]) -> np.ndarray:
"""Draw the ellipse on the image.
Args:
image (np.ndarray): Image to draw on.
entity (Ellipse): Ellipse to draw.
labels (List[ScoredLabel]): Labels to draw.
Returns:
np.ndarray: Image with the ellipse drawn on it.
"""
base_color = labels[0].color.bgr_tuple
if entity.width > entity.height:
axes = (
int(entity.major_axis * image.shape[1]),
int(entity.minor_axis * image.shape[0]),
)
else:
axes = (
int(entity.major_axis * image.shape[0]),
int(entity.minor_axis * image.shape[1]),
)
center = (
int(entity.x_center * image.shape[1]),
int(entity.y_center * image.shape[0]),
)
# Draw the shape on the image
alpha = self.alpha_shape
overlay = cv2.ellipse(
img=image.copy(),
center=center,
axes=axes,
angle=0,
startAngle=0,
endAngle=360,
color=base_color,
thickness=cv2.FILLED,
)
result_without_border = cv2.addWeighted(overlay, alpha, image, 1 - alpha, 0)
result_with_border = cv2.ellipse(
img=result_without_border,
center=center,
axes=axes,
angle=0,
startAngle=0,
endAngle=360,
color=base_color,
lineType=cv2.LINE_AA,
)
# Generate a command to draw the list of labels
# and compute the actual size of the list of labels.
(
draw_command,
content_width,
content_height,
) = self.generate_draw_command_for_labels(labels, image, self.show_labels, self.show_confidence)
# get top left corner of imaginary bbox around circle
offset = self.label_offset_box_shape
x_coord = entity.x1 * image.shape[1]
y_coord = entity.y1 * image.shape[0] - offset - content_height
flagpole_end_point = Coordinate(int(x_coord + 1), int(entity.y_center * image.shape[0]))
# put label at bottom if it is out of bounds at the top of the shape, and shift label to left if needed
if y_coord < self.top_margin * image.shape[0]:
y_coord = (entity.y1 * image.shape[0]) + (entity.y2 * image.shape[0]) + offset
flagpole_start_point = Coordinate(x_coord + 1, y_coord)
else:
flagpole_start_point = Coordinate(x_coord + 1, y_coord + content_height)
if x_coord + content_width > result_with_border.shape[1]:
# The list of labels is too close to the right side of the image.
# Move it slightly to the left.
x_coord = result_with_border.shape[1] - content_width
# Draw the list of labels and a small flagpole.
self.set_cursor_pos(Coordinate(x_coord, y_coord))
image = draw_command(result_with_border)
image = self.draw_flagpole(image, flagpole_start_point, flagpole_end_point)
return image
[docs]
class PolygonDrawer(Helpers, DrawerEntity[Polygon]):
"""Draws polygons."""
supported_types = [Polygon]
def __init__(self, show_labels, show_confidence):
super().__init__()
self.show_labels = show_labels
self.show_confidence = show_confidence
[docs]
def draw(self, image: np.ndarray, entity: Polygon, labels: List[ScoredLabel]) -> np.ndarray:
"""Draw polygon and labels on image.
Args:
image (np.ndarray): Image to draw on.
entity (Polygon): Polygon to draw.
labels (List[ScoredLabel]): List of labels to draw.
Returns:
np.ndarray: Image with polygon drawn on it.
"""
base_color = labels[0].color.bgr_tuple
# Draw the shape on the image
alpha = self.alpha_shape
contours = np.array(
[[point.x * image.shape[1], point.y * image.shape[0]] for point in entity.points],
dtype=np.int32,
)
overlay = cv2.drawContours(
image=image.copy(),
contours=[contours],
contourIdx=-1,
color=base_color,
thickness=cv2.FILLED,
)
result_without_border = cv2.addWeighted(overlay, alpha, image, 1 - alpha, 0)
result_with_border = cv2.drawContours(
image=result_without_border,
contours=[contours],
contourIdx=-1,
color=base_color,
thickness=2,
lineType=cv2.LINE_AA,
)
# Generate a command to draw the list of labels
# and compute the actual size of the list of labels.
(
draw_command,
content_width,
content_height,
) = self.generate_draw_command_for_labels(labels, image, self.show_labels, self.show_confidence)
# get top left corner of imaginary bbox around polygon
x_coord = min(point[0] for point in contours)
y_coord = min(point[1] for point in contours) - self.label_offset_box_shape - content_height
# end point = Y in polygon where X is lowest, x offset to make line flush with text rectangle
_, idx = min((val, idx) for (idx, val) in enumerate([point[0] for point in contours]))
flagpole_end_point = Coordinate(x_coord + 1, [point[1] for point in contours][idx])
if y_coord < self.top_margin * image.shape[0]:
# The polygon is too close to the top of the image.
# Draw the labels underneath the polygon instead.
y_coord = max(point[1] for point in contours) + self.label_offset_box_shape
flagpole_start_point = Coordinate(x_coord + 1, y_coord)
else:
flagpole_start_point = Coordinate(x_coord + 1, y_coord + content_height)
if x_coord + content_width > result_with_border.shape[1]:
# The list of labels is too close to the right side of the image.
# Move it slightly to the left.
x_coord = result_with_border.shape[1] - content_width
# Draw the list of labels and a small flagpole.
self.set_cursor_pos(Coordinate(x_coord, y_coord))
image = draw_command(result_with_border)
image = self.draw_flagpole(image, flagpole_start_point, flagpole_end_point)
return image