from operator import itemgetter
from abc import abstractmethod, ABC
import numpy as np
import PIL.Image
import PIL.ImageDraw
import PIL.ImageFont
from plums.plot.engine.utils import get_default_font, get_text_color
[docs]class Position(object):
"""Simple enum to represent the position of the confidence label."""
LEFT = 'left'
RIGHT = 'right'
[docs]class Geometry(object):
"""A class that represents a record geometry and computes meaningful properties.
Args:
record (tuple, list): The record to extract coordinates from.
zoom (int): Optional. Defaults to ``1``. Zoom level to use.
Raises:
TypeError: When ``coordinates`` is not a collection or a nested.
ValueError: When the record is invalid.
ValueError: When a point has not 2 coordinates
ValueError: When ``coordinates`` collection is empty.
ValueError: When first and last coordinates differ.
ValueError: When the record has an invalid geometry type (should be either ``Polygon`` or ``Point``).
"""
def __init__(self, record, zoom=1):
# Sanity checks
if not hasattr(record, 'type') or not hasattr(record, 'coordinates'):
raise ValueError('A record must expose a ``coordinates`` and a type attributes.')
if record.type == 'Point':
if not isinstance(record.coordinates, (tuple, list)):
raise TypeError('The coordinates arguments should be a collection.')
if len(record.coordinates) != 2:
raise ValueError('A point is made of 2 coordinates.')
# Apply zoom to coordinates
self._coordinates = [[int(coord * zoom) for coord in record.coordinates]]
elif record.type == 'Polygon':
coordinates = record.coordinates[0]
if not isinstance(coordinates, (tuple, list)):
raise TypeError('The coordinates arguments should be a collection.')
if not coordinates:
raise ValueError('Can\'t compute bounding box of empty list.')
if coordinates[0] != coordinates[-1]:
raise ValueError('Record geometry should be a closed polygon.')
# Re-map coordinates to Pillow format (e.g. [(x, y), (x, y), ...]) and apply zoom
self._coordinates = [tuple(int(x * zoom) for x in coord) for coord in coordinates]
else:
raise ValueError('Invalid geometry type {}'.format(record.type))
@property
def min_x(self):
"""int: The minimum value of the coordinates along ``X`` axis."""
return min(self._coordinates, key=itemgetter(0))[0]
@property
def max_x(self):
"""int: The maximum value of the coordinates along ``X`` axis."""
return max(self._coordinates, key=itemgetter(0))[0]
@property
def min_y(self):
"""int: The minimum value of the coordinates along ``Y`` axis."""
return min(self._coordinates, key=itemgetter(1))[1]
@property
def max_y(self):
"""int: The maximum value of the coordinates along ``Y`` axis."""
return max(self._coordinates, key=itemgetter(1))[1]
@property
def centroid(self):
"""tuple: The centroid of the given geometry (mean along ``X`` and ``Y`` axes)."""
coordinates = self._coordinates[:-1] if len(self._coordinates) > 1 else self._coordinates
x_list = [coord[0] for coord in coordinates]
y_list = [coord[1] for coord in coordinates]
x = sum(x_list) / float(len(coordinates))
y = sum(y_list) / float(len(coordinates))
return int(x), int(y)
@property
def coordinates(self):
"""list: Full list of coordinates, using PIL format (i.e. ``[(x1, y1), ...]``)."""
return self._coordinates
@property
def leftmost_coordinate(self):
"""tuple: The upper-leftmost coordinates as a ``(x, y)`` tuple."""
return tuple(sorted(self._coordinates, key=lambda x: (x[0], x[1]))[0])
@property
def rightmost_coordinate(self):
"""tuple: The upper-rightmost coordinates as a ``(x, y)`` tuple."""
return tuple(sorted(self._coordinates, key=lambda x: (-x[0], x[1]))[0])
[docs]class Draw(object):
"""A wrapper class arround :class:`~PIL.ImageDraw.ImageDraw` to customize drawing methods.
Args:
size (tuple): The tile dimensions as ``(width, height)`` tuple.
zoom (int): Zoom level to use.
mode (str): Image mode to use (matching :class:`~PIL.ImageMode.ImageMode`) (e.g. ``RGBA``)
background_color (tuple): Color to use for the image background as a tuple (``RGB`` or ``RGBA``)
Attributes:
_draw (:class:`~PIL.ImageDraw.ImageDraw`): An object that can be used to draw in the given
image :attr:`_overlay`.
"""
def __init__(self, size, zoom, mode, background_color):
# Make a blank image for the annotation, initialized to transparent text color
self._overlay = PIL.Image.new(mode=mode, size=size, color=background_color)
self._draw = PIL.ImageDraw.Draw(self.overlay)
self._zoom = zoom
@property
def overlay(self):
""":class:`~PIL.Image.Image`: The overlay on top of the tile, including annotation drawings."""
return self._overlay
[docs] def circle(self, centroid, fill_color):
"""Draw a particular ellipse with a radius depending on the zoom level.
Args:
centroid (tuple): The centroid coordinates as a ``(x, y)`` tuple.
fill_color (tuple): The color to use for the circle. ``RGB`` or ``RGBA`` format.
"""
radius = 2 + int(2 * (self._zoom - 1))
ellipse_coordinates = [
(int(centroid[0] - radius), int(centroid[1] - radius)),
(int(centroid[0] + radius), int(centroid[1] + radius))
]
# Draw a circle onto the image
self._draw.ellipse(ellipse_coordinates, fill=fill_color)
[docs] def line(self, points, fill_color):
"""Draw record geometry with successive lines since PIL does not support polygon thickness.
Args:
points (list): A list of points as ``(x, y)`` tuples.
fill_color (tuple): The color to use for the lines. ``RGB`` or ``RGBA`` format.
"""
line_width = 2 + int(2 * (self._zoom - 1))
self._draw.line(points, fill=fill_color, width=line_width, joint='curve')
[docs] def polygon(self, points, fill_color):
"""Draw a polygon, used for the confidence label.
Args:
points (list`): A list of point as ``(x, y)`` tuples.
fill_color (tuple): The color to use for the polygon. ``RGB`` or ``RGBA`` format.
"""
self._draw.polygon(points, fill=fill_color)
[docs] def text(self, text_coordinates, text, font, fill_color):
"""Draw a text string at the given position.
Args:
text_coordinates (tuple): Top-left corner text coordinates as a ``(x, y)`` tuple.
text (str): Text to be drawn.
font (:class:`~PIL.ImageFont.ImageFont`): Font familly to be used.
fill_color (tuple): The color to use for the text. ``RGB`` or ``RGBA`` format.
"""
self._draw.text(text_coordinates, text, font=font, fill=fill_color)
[docs]class TagPainter(object):
"""A tag artist class which draw a tag next to a given |Record| geometry.
Args:
descriptor (|Descriptor|): The |Descriptor| description to plot next to each |Record|.
.. note::
The |TagPainter| class assumes that each |Record| have already been described by the
provided |Descriptor|.
text_margin (int): Optional. Default to 2. The margin in pixel to leave between the tag text and its border.
text_size (int): Optional. Default to 11. The tag text font size in pixel.
zoom (int): Optional. Defaults to ``1``. Zoom level to use.
.. versionadded:: 0.2.0
"""
def __init__(self, descriptor, text_margin=2, text_size=11, zoom=1):
self._descriptor = descriptor
self._text_margin = text_margin
self._text_size = text_size
self._zoom = zoom
@staticmethod
def _compute_label_size(text, font, margin):
"""Determine the size of the label on which the text appears.
Args:
text (str): Text to be drawn.
font (:class:`~PIL.ImageFont.ImageFont`): Font familly to be used.
margin (int): Margin to be used around the text.
Returns:
tuple: A tuple representing the dimensions of the label as ``(width, height)`` tuple.
"""
text_width, text_height = font.getsize(text)
return text_width + 2 * margin, text_height + 2 * margin
@staticmethod
def _compute_label_position(max_x, label_width, max_width):
"""Determine the position of label according to remaining space on right side.
Args:
max_x (int): Rightmost value of the record geometry (in pixels).
label_width (int): Space needed by the label to be fully drawn (in pixels).
max_width (int): Image width that we can't exceed (in pixels).
Returns:
|Position|: The chosen position.
"""
if (max_x + label_width) > max_width:
return Position.LEFT
return Position.RIGHT
@staticmethod
def _compute_label_starting_point(geometry, position):
"""Compute the coordinates from which the confidence label should take as a reference.
Args:
geometry (|Geometry|): The record reformatted geometry.
position (|Position|): The agreed position of the confidence label.
Returns:
tuple: The coordinates (x, y) to use as a reference.
"""
if position == Position.RIGHT:
return geometry.rightmost_coordinate
return geometry.leftmost_coordinate
@staticmethod
def _compute_vertical_margin(min_y, label_height):
"""Compute vertical margin needed to properly draw the confidence label on the image.
Args:
min_y (int): Uppermost Y value of the record geometry (in pixels).
label_height (int): The heights the label needs to be fully drawn.
Returns:
int: The needed margin (in pixels).
"""
return int(max(0., -min_y + label_height / 2.))
def _compute_label_coordinates(self, geometry, text, font, tile_width):
"""Compute confidence label and text coordinates on the final image.
Args:
geometry (|Geometry|): The record reformatted geometry.
text (str): Text to be drawn.
font (:class:`~PIL.ImageFont.ImageFont`): Font familly to be used.
tile_width (int): Final image width (in pixels).
Returns:
tuple: Label and text coordinates (in pixels).
"""
# Compute label size
width, height = self._compute_label_size(text=text, font=font, margin=self._text_margin)
# Compute label position
position = self._compute_label_position(max_x=geometry.max_x,
label_width=1.5 * width,
max_width=tile_width)
# Now the position is set, get starting point of the label
x, y = self._compute_label_starting_point(geometry=geometry, position=position)
# Shift label downward if upper limit is reached (coordinates min_y)
y_margin = self._compute_vertical_margin(min_y=geometry.min_y, label_height=height)
# Compute label and text coordinates
sign = 1 if position == Position.RIGHT else -1
label_coordinates = [
(x, y + y_margin),
(x + sign * height / 2., y - height / 2. + y_margin),
(x + sign * (height / 2. + width), y - height / 2. + y_margin),
(x + sign * (height / 2. + width), y + height / 2. + y_margin),
(x + sign * height / 2., y + height / 2. + y_margin),
]
# We do not want the arrow extremity, so skip first coordinate
label_min_x, label_min_y = sorted(label_coordinates[1:], key=lambda val: (val[0], val[1]))[0]
text_coordinates = (label_min_x + self._text_margin, label_min_y + self._text_margin)
return label_coordinates, text_coordinates
[docs] def draw(self, record, draw):
"""Draw a tag next to the provided |Record| using the provided |Draw|.
Args:
record (|Record|): A |Record| on which to attach a tag containing its description.
draw (|Draw|): A |Draw| instance on which to draw the tag, preferably the |Record| draw instance.
"""
# Retrieve color to use
record_color = tuple(record.color.astype('sRGB255').components.astype(int)) + (255,)
# Re-map coordinates to Pillow format (e.g. [(x, y), (x, y), ...])
geometry = Geometry(record=record, zoom=self._zoom)
# Define font and text to use
text_font = get_default_font(text_size=int(self._text_size * self._zoom))
if self._descriptor.__descriptor__['type'] == 'categorical':
translate_dict = {value: key
for key, value in self._descriptor.__descriptor__['schema'].items()}
text_str = \
'{description}'.format(description=translate_dict[getattr(record, self._descriptor.property_name)])
else:
text_str = '{description}'.format(description=getattr(record, self._descriptor.property_name))
# Compute confidence label coordinates
label_coordinates, text_coordinates = \
self._compute_label_coordinates(geometry=geometry, text=text_str, font=text_font,
tile_width=draw.overlay.width)
# Draw text and tag on overlay
confidence_color = get_text_color(record_color)
draw.polygon(points=label_coordinates, fill_color=record_color)
draw.text(text_coordinates=text_coordinates, text=text_str, font=text_font, fill_color=confidence_color)
[docs]class PainterBase(ABC):
"""An abstract base class that defines the basic elements a Painter needs to draw a data point.
Args:
plot_centers (bool): Optional. Defaults to ``False``. Plot records centers (instead of full geometry).
plot_tag (|Descriptor|): Optional. Defaults to ``None``. Besides the geometry, display a |Record|
description in a tag attached to the |Record|.
.. note::
The |PainterBase| class assumes that each |Record| have already been described by the provided
|Descriptor|.
zoom (int): Optional. Defaults to ``1``. Zoom level to use.
fill (bool): Optional. Default to ``False``. A flag which represents whether to draw filled polygons.
alpha (int): Optional. Defaults to ``64``. A positive integer (between 0 and 255) that represents the fill
transparency.
"""
def __init__(self, plot_centers=False, plot_tag=None, zoom=1, fill=False, alpha=64, **kwargs):
# Set painter attributes
self._plot_centers = plot_centers
self._plot_tag = plot_tag is not None
self._tag_descriptor = plot_tag
self._zoom = zoom
self._fill = fill
self._alpha = alpha
[docs] @abstractmethod
def draw(self, data_point):
"""Draw a data point (annotations on a given tile) and return the composed :class:`~PIL.Image.Image`.
Arguments:
data_point (|DataPoint|): Data point to be drawn (tile + annotations).
Raises:
ValueError: When a record has an invalid geometry type.
Returns:
:class:`~PIL.Image.Image`: The composed image (tile with annotations)
"""
raise NotImplementedError
[docs]class Painter(PainterBase):
"""A basic Painter that implements the |PainterBase| class.
The role of the painter is to aggregate annotations on a given tile, giving to the user
a meaningful representation of the records.
Args:
plot_centers (bool): Optional. Defaults to ``False``. Plot records centers (instead of full geometry).
plot_tag (|Descriptor|): Defaults to ``None``. Besides the geometry, display a |Record| description
in a tag attached to the |Record|.
.. note::
The |Painter| class assumes that each |Record| have already been described by the provided |Descriptor|.
zoom (int): Optional. Defaults to ``1``. Zoom level to use.
fill (bool): Optional. Default to ``False``. A flag which represents whether to draw filled polygons.
alpha (int): Optional. Defaults to ``64``. A positive integer (between 0 and 255) that represents the fill
transparency.
"""
# CONSTANTS
MODE = 'RGBA'
TEXT_MARGIN = 2 # in pixels
TITLE_HEIGHT = 30 # in pixels
TITLE_SIZE = 15 # in pixels
TITLE_BACKGROUND_COLOR = (46, 49, 49)
TAG_TEXT_SIZE = 11 # in pixels
def __init__(self, plot_centers=False, plot_tag=None, zoom=1, fill=False, alpha=64, **kwargs):
super(Painter, self).__init__(plot_centers=plot_centers, plot_tag=plot_tag, zoom=zoom, fill=fill, alpha=alpha)
self._tag = TagPainter(self._tag_descriptor, text_margin=self.TEXT_MARGIN,
text_size=self.TAG_TEXT_SIZE, zoom=self._zoom)
def _add_title(self, image, title):
"""Add title on top of the given image.
Args:
image (:class:`~PIL.Image.Image`): The image to use.
title (str): The title to draw.
Returns:
:class:`~PIL.Image.Image`: The given image with a title.
"""
# Rescale title height
title_height = int(self.TITLE_HEIGHT * self._zoom)
# Compute final image size
width, height = image.size
final_image_size = (width, height + title_height)
# Create image
draw = Draw(size=final_image_size,
zoom=self._zoom,
mode=self.MODE,
background_color=self.TITLE_BACKGROUND_COLOR)
# Select font
text_font = get_default_font(text_size=int(self.TITLE_SIZE * self._zoom))
text_width, text_height = text_font.getsize(title)
text_color = get_text_color(self.TITLE_BACKGROUND_COLOR)
text_coordinates = ((width - text_width) / 2., (title_height - text_height) / 2.)
# Draw text
draw.text(text_coordinates=text_coordinates, text=title, font=text_font, fill_color=text_color)
# Fill empty space with original image
final_image = draw.overlay
final_image.paste(im=image, box=(0, title_height))
return final_image
[docs] def draw(self, data_point):
"""Draw a data point (annotations on a given tile) and return the composed :class:`~PIL.Image.Image`.
Arguments:
data_point (|DataPoint|): Data point to be drawn (tile + annotations).
Raises:
ValueError: When a record has an invalid geometry type.
Returns:
:class:`~PIL.Image.Image`: The composed image (tile with annotations)
"""
# Get image to show and rescale it, regarding the zoom
image = PIL.Image.fromarray(np.asarray(data_point.tile)).convert(self.MODE)
image = image.resize(tuple(int(self._zoom * x) for x in image.size), resample=PIL.Image.NEAREST)
# Iterate over the records, and draw the appropriate geometry
for record in data_point.annotation.record_collection.records:
# Make a blank image for the annotation, initialized to transparent text color
draw = Draw(size=image.size, zoom=self._zoom, mode=self.MODE, background_color=(255, 255, 255, 0))
# Retrieve color to use
record_color = tuple(record.color.astype('sRGB255').components.astype(int)) + (255, )
record_fill_color = tuple(record.color.astype('sRGB255').components.astype(int)) + (self._alpha,)
# Re-map coordinates to Pillow format (e.g. [(x, y), (x, y), ...])
geometry = Geometry(record=record, zoom=self._zoom)
# Draw proper geometry, according to the record geometry type (Points / Centers / Polygons)
if record.type == 'Point' or self._plot_centers is True:
draw.circle(centroid=geometry.centroid, fill_color=record_color)
elif record.type == 'Polygon':
if self._fill:
draw.polygon(points=geometry.coordinates, fill_color=record_fill_color)
draw.line(points=geometry.coordinates, fill_color=record_color)
else:
raise ValueError('Invalid record geometry: {}. Expect `Polygon` or `Point`.'.format(record.type))
# Add confidence scores
if self._plot_tag is True:
self._tag.draw(record, draw)
# Merge tile with annotations
image = PIL.Image.alpha_composite(im1=image, im2=draw.overlay)
# Add filename as a title
final_image = self._add_title(image=image, title=str(data_point.tile.filename))
return final_image