Source code for plums.plot.engine.legend_painter

import re
from abc import abstractmethod, ABC

import numpy as np
import PIL.Image
import PIL.ImageDraw

from plums.commons import Record, RecordCollection
from plums.plot.engine.painter import TagPainter, Draw
from plums.plot.engine.color import Color, color_map
from plums.plot.engine.position_generator import LegendItemPositionGenerator
from plums.plot.engine.utils import get_default_font, get_outline_color, get_text_color
from plums.plot.engine.descriptor import Confidence, CategoricalDescriptor


[docs]class LegendItemDrawer(object): """Class that draw legend items, depending on their types. Items either be: * **categorical**: item would look like a colored box with the name of the category next to it. * **nested categorical**: a category name with several categorical items under it. * **continuous**: a color map with its range and, optionally, a descriptor name. Args: background_color (tuple): Legend background color (``RGB`` or ``RGBA`` format). scale (float): Optional. Defaults to ``1.0`` Scale to use, regarding the default dimensions used. margins (tuple): Optional. Defaults to ``(15, 15)``. Margins around the item (left/right and top/bottom). """ # Default parameters # General MODE = 'RGBA' ITEM_BACKGROUND_COLOR = (255, 255, 255, 0) # RGBA # Main categories BOX_DIMENSIONS = (80, 40) # in pixels BOX_OUTLINE_WIDTH = 1 # in pixels SEPARATION_DISTANCE = 20 # in pixels TEXT_SIZE = 20 # in pixels # Outline OUTLINE_HEADER_MARGIN = 5 # in pixel # Sub categories SUB_ITEMS_AXIS = 0 # either 0 (vertical) or 1 (horizontal) SUB_ITEM_SCALE = 0.7 SUB_ITEM_OFFSET = 20 # in pixels # Color maps COLOR_MAP_DIMENSIONS = (200, 50) # in pixels COLOR_MAP_MARGIN = 8 # in pixels COLOR_MAP_TITLE_HEIGHT = 18 # in pixels COLOR_MAP_TEXT_HEIGHT = 13 # in pixels def __init__(self, background_color, scale=1., margins=(15, 15)): self._background_color = background_color self._scale = scale self._margins = margins
[docs] def draw_simple_category(self, name, fill_color, scale=None, margins=None): """Draw a simple category item: a colored box with a category name next to it. Args: name (str): Category name to draw. fill_color (|Color|): Color of the category. scale (float): Optional. Defaults to ``None`` Scale to use, regarding the default dimensions used. margins (tuple): Optional. Defaults to ``None``. Margins around the item (left/right and top/bottom). Returns: :class:`~PIL.Image.Image`: A simple categorical item. """ # Override default scale and margins (for composite category) scale = float(scale) if scale is not None else self._scale margins = tuple(margins) if margins is not None else self._margins # Scale dimensions text_size = int(self.TEXT_SIZE * scale) separation_distance = int(self.SEPARATION_DISTANCE * scale) box_width, box_height = tuple(map(lambda x: int(x * scale), self.BOX_DIMENSIONS)) # Category parameters (coordinates, color) box_coordinates = [margins, (box_width + margins[0], box_height + margins[1])] box_fill_color = tuple(fill_color.astype('sRGB255').components.astype(int)) box_outline_color = get_text_color(self._background_color) # Text parameters (font, dimensions, color, coordinates) text_font = get_default_font(text_size=text_size) text_width, text_height = text_font.getsize(name) text_color = get_text_color(self._background_color) text_coordinates = (box_width + separation_distance + margins[0], int((box_height - text_height) / 2) + margins[1]) # Legend item parameters item_size = (box_width + separation_distance + text_width + 2 * self.BOX_OUTLINE_WIDTH + 2 * margins[0], box_height + 2 * self.BOX_OUTLINE_WIDTH + 2 * margins[1]) # Create legend item (colored rectangle + category name next to it) item = PIL.Image.new(mode=self.MODE, size=item_size, color=self.ITEM_BACKGROUND_COLOR) draw = PIL.ImageDraw.Draw(item) # Draw item (box + text on left side) draw.rectangle(xy=box_coordinates, fill=box_fill_color, outline=box_outline_color, width=self.BOX_OUTLINE_WIDTH) draw.text(xy=text_coordinates, text=name, fill=text_color, font=text_font) return item
[docs] def draw_composite_category(self, descriptor_name, category_name, color_engine_interface, max_category_height): """Draw a nested category item: a category name with several categorical items under it. Args: descriptor_name (str): Name of the |CategoricalDescriptor| used. category_name (str): Name of the primary category. color_engine_interface (dict): Interface of the |ColorEngine| object. (key = category name, value = color/colormaps). max_category_height (int): Height of the item to not exceed, in pixels. Returns: :class:`~PIL.Image.Image`: A nested categorical item. """ # Scale dimensions text_size = int(self.TEXT_SIZE * self._scale) sub_item_margins = tuple(map(lambda x: int(x * self.SUB_ITEM_SCALE * self._scale), self._margins)) # Create sub categories items = [ self.draw_simple_category(name=name, fill_color=fill_color, scale=self.SUB_ITEM_SCALE, margins=sub_item_margins) for name, fill_color in sorted(color_engine_interface.items(), key=lambda x: x[0]) ] # Main category parameters text_font = get_default_font(text_size=text_size) text_width, text_height = text_font.getsize(category_name) text_color = get_text_color(self._background_color) text_coordinates = (self._margins[0], self._margins[1]) offset_y = int((self.BOX_DIMENSIONS[1] - 2 * text_height) / 2) + self._margins[1] # Compute final PIL Image dimensions item_sizes = [item.size for item in items] position_generator = LegendItemPositionGenerator( items_sizes=item_sizes, axis=self.SUB_ITEMS_AXIS, max_size_along_axis=max_category_height, main_axis_align='start', minor_axis_align='start' ) # Compute position for each item items_with_position = [(items[i], position) for i, position in enumerate(list(position_generator))] # Compute legend size item_size = ( position_generator.legend_size[0] + self.SUB_ITEM_OFFSET + 2 * self._margins[0], items_with_position[-1][1][1] + item_sizes[-1][1] + text_height + 2 * self._margins[1] + offset_y ) # Create legend item = PIL.Image.new(mode=self.MODE, size=item_size, color=self.ITEM_BACKGROUND_COLOR) draw = PIL.ImageDraw.Draw(item) draw.text(xy=text_coordinates, text=category_name, fill=text_color, font=text_font) # Add items to the legend image for item_, position_ in items_with_position: top_left_corner = (position_[0] + self.SUB_ITEM_OFFSET + self._margins[0], position_[1] + text_height + self._margins[1] + offset_y) item.alpha_composite(im=item_, dest=top_left_corner) self.draw_outline_with_title(descriptor_name, item, scale=self.SUB_ITEM_SCALE, margins=(self.SUB_ITEM_OFFSET, text_size + offset_y, 0, 0, 0)) return item
[docs] def draw_outline_with_title(self, descriptor_name, legend_set_item, scale=None, margins=(0, 0, 0, 0)): """Draw a set of category item: a descriptor name with a border and several categorical items under it. Args: descriptor_name (str): Name of the |CategoricalDescriptor| used. legend_set_item (:class:`~PIL.Image.Image`): A nested legend categorical set item. scale (float): Optional. Defaults to ``None`` Scale to use, regarding the default dimensions used. margins (tuple): Optional. Default to (0, 0, 0, 0). Optional margin in between legend item exterior and outline start coordinates Returns: :class:`~PIL.Image.Image`: A nested categorical item. """ scale = float(scale) if scale is not None else self._scale # Scale dimensions text_size = int(self.TEXT_SIZE * scale) text_margin = int(self.OUTLINE_HEADER_MARGIN * scale) # Compute legend size item_size = legend_set_item.size # Main category parameters text_font = get_default_font(text_size=text_size) text_width, text_height = text_font.getsize(descriptor_name) outline_color = get_outline_color(self._background_color) text_coordinates = (item_size[0] - text_width - margins[2], margins[1]) # Create legend item = legend_set_item draw = PIL.ImageDraw.Draw(item) draw.rectangle(xy=(0.15 * text_width + margins[0], 0.5 * text_height + margins[1], item_size[0] - margins[2] - 0.15 * text_width - 1, item_size[1] - 0.5 * text_height - margins[3] - 1), outline=outline_color) # RGBA draw.rectangle(xy=((text_coordinates[0] - text_margin, text_coordinates[1] - text_margin), (text_coordinates[0] + text_width + text_margin, text_coordinates[1] + text_height + text_margin)), fill=self._background_color) # RGBA draw.text(xy=text_coordinates, text=descriptor_name, fill=outline_color, font=text_font) return item
[docs] def draw_colormap(self, descriptor_name, color_map, category_name=None): """Draw a color map: a color map with its range and descriptor name (optional). Args: descriptor_name (str): The name of the descriptor. color_map (|ContinuousColorMap|): A |ContinuousColorMap| which vary the the color around a reference |Color|. category_name (str): Optional. Defaults to ``None``. The name of the category. Returns: :class:`~PIL.Image.Image`: The computed colormap item. """ # Rescale dimensions box_width = int(self.COLOR_MAP_DIMENSIONS[0] * self._scale) box_height = int(self.COLOR_MAP_DIMENSIONS[1] * self._scale) text_margin = int(self.COLOR_MAP_MARGIN * self._scale) text_height = int(self.COLOR_MAP_TEXT_HEIGHT * self._scale) title_height = int(self.COLOR_MAP_TITLE_HEIGHT * self._scale) if category_name is not None else 0 # Text parameters start, end = color_map.range avg = (start + end) / 2. start_str = str(round(start, 2)) avg_str = str(round(avg, 2)) end_str = str(round(end, 2)) text_color = get_text_color(self._background_color) text_font = get_default_font(text_size=text_height) avg_text_width, _ = text_font.getsize(avg_str) end_text_width, _ = text_font.getsize(end_str) name_text_width, _ = text_font.getsize(str(descriptor_name)) start_coordinates = (self._margins[0], title_height + box_height + 2 * text_margin + self._margins[1]) avg_coordinates = (self._margins[0] + int((box_width - avg_text_width) / 2.), title_height + box_height + 2 * text_margin + self._margins[1]) end_coordinates = (box_width - end_text_width + self._margins[0], title_height + box_height + 2 * text_margin + self._margins[1]) name_coordinates = (self._margins[0] + int((box_width - name_text_width) / 2.), title_height + box_height + 4 * text_margin + self._margins[1]) # Item parameters item_size = (box_width + 2 * self._margins[0], title_height + 2 * text_height + box_height + text_margin + 2 * (self._margins[1] + text_margin)) box_coordinates = (self._margins[0], title_height + text_margin + self._margins[1]) # Create colored array array = np.tile(np.linspace(start, end, box_width), (box_height, 1)) color_array = np.clip(color_map.astype('sRGB1')(array), 0., 1.) color_array = (color_array * 255).astype(np.uint8) # Create PIL image item = PIL.Image.new(mode=self.MODE, size=item_size, color=self.ITEM_BACKGROUND_COLOR) item.paste(im=PIL.Image.fromarray(color_array, mode='RGB'), box=box_coordinates) # Draw range values (min, avg & max) under color map draw = PIL.ImageDraw.Draw(item) if category_name is not None: title_font = get_default_font(text_size=title_height) draw.text(xy=self._margins, text=str(category_name), fill=text_color, font=title_font) draw.text(xy=start_coordinates, text=start_str, fill=text_color, font=text_font) draw.text(xy=avg_coordinates, text=avg_str, fill=text_color, font=text_font) draw.text(xy=end_coordinates, text=end_str, fill=text_color, font=text_font) draw.text(xy=name_coordinates, text=str(descriptor_name), fill=text_color, font=text_font) return item
[docs]class LegendPainterBase(ABC): """An abstract base class that defines the interface a legend painter should implement. Args: color_engine_interface (dict): A mapping of categories with colors/colormaps to draw. mosaic_size (tuple): The size, in pixels, of the mosaic computed by the |Compositor|. scale (float): Optional. Defaults to ``1.0``. Scale to use, regarding the default dimensions used. axis (int): Optional. Defaults to ``0``. Main direction of the legend (**0** = vertically, **1** = horizontally) background_color (tuple): Optional. Defaults to ``(255, 255, 255)``. Legend background color (``RGB`` or ``RGBA`` format). item_margins (tuple): Optional. Defaults to ``(10, 10)``. Margins around the item (left/right and top/bottom). main_axis_align (str): Optional. Defaults to ``start``. Alignment of the item in its cell along the main axis. minor_axis_align (str): Optional. Defaults to ``start``. Alignment of the item in its cell along the minor axis. """ def __init__(self, color_engine_interface, mosaic_size, scale=1.0, axis=0, background_color=(255, 255, 255), item_margins=(10, 10), main_axis_align='start', minor_axis_align='start', **kwargs): # Set compiled descriptor name regex self._descriptor_name_regex = re.compile(r'[\w]+\(([a-zA-Z_ ]+)(?:, ([a-zA-Z_ ]+))?\)') # Set legend painter attributes self._color_engine_interface = color_engine_interface self._scale = scale self._background_color = background_color self._axis = axis self._item_margins = item_margins self._main_axis_align = main_axis_align self._minor_axis_align = minor_axis_align # Set tag legend attributes self._plot_tag = kwargs.get('plot_confidences') if kwargs.get('plot_confidences') is not None \ else kwargs.get('plot_tag') is not None self._tag_descriptor = kwargs.get('plot_tag') if kwargs.get('plot_confidences') is None else Confidence() # Computed property self._max_size_along_axis = mosaic_size[1] if axis == 0 else mosaic_size[0] @property def _primary_descriptor_name(self): return self._descriptor_name_regex.search(self._color_engine_interface['name']).group(1) @property def _secondary_descriptor_name(self): return self._descriptor_name_regex.search(self._color_engine_interface['name']).group(2)
[docs] @abstractmethod def draw(self): """Draw the legend, along the given axis. The generated legend is a grid with each cell having the dimensions of the biggest item to draw. The grid is then filled along the main axis first. The size of the legend along the main axis is fixed while the size along the minor one is variable. Returns: :class:`~PIL.Image.Image`: The output legend. """ raise NotImplementedError
[docs]class LegendPainter(LegendPainterBase): """Class that draws a legend from a given color mapping. The legend could be drawn either **horizontally** or **vertically**. Moreover, the item could occupy different positions in its cell. Args: color_engine_interface (dict): A mapping of categories with colors/colormaps to draw. mosaic_size (tuple): The size, in pixels, of the mosaic computed by the |Compositor|. scale (float): Optional. Defaults to ``1.0``. Scale to use, regarding the default dimensions used. axis (int): Optional. Defaults to ``0``. Main direction of the legend (**0** = vertically, **1** = horizontally) background_color (tuple): Optional. Defaults to ``(255, 255, 255)``. Legend background color (``RGB`` or ``RGBA`` format). item_margins (tuple): Optional. Defaults to ``(10, 10)``. Margins around the item (left/right and top/bottom). main_axis_align (str): Optional. Defaults to ``start``. Alignment of the item in its cell along the main axis. minor_axis_align (str): Optional. Defaults to ``start``. Alignment of the item in its cell along the minor axis. """ def __init__(self, color_engine_interface, mosaic_size, scale=1.0, axis=0, background_color=(255, 255, 255), item_margins=(10, 10), main_axis_align='start', minor_axis_align='start', **kwargs): super(LegendPainter, self).__init__(color_engine_interface=color_engine_interface, scale=scale, axis=axis, mosaic_size=mosaic_size, background_color=background_color, item_margins=item_margins, main_axis_align=main_axis_align, minor_axis_align=minor_axis_align, **kwargs) def _draw_items(self, drawer): """Draw items depending on their type (categorical, continuous, etc.). Returns: list: List of the drawn items. """ # Depending on the type items = [] if self._color_engine_interface['type'] == 'categorical': for name, value in sorted(self._color_engine_interface['schema'].items(), key=lambda x: x[0]): # Depending on the type, draw corresponding item if isinstance(value, Color): item = drawer.draw_simple_category(name=name, fill_color=value) items.append(item) elif isinstance(value, dict): item = drawer.draw_composite_category( descriptor_name=self._secondary_descriptor_name, category_name=name, color_engine_interface=value, max_category_height=self._max_size_along_axis - self._item_margins[1], ) items.append(item) elif isinstance(value, color_map.ColorMap): item = drawer.draw_colormap(color_map=value, category_name=name, descriptor_name=self._secondary_descriptor_name) items.append(item) elif self._color_engine_interface['type'] == 'continuous': if isinstance(self._color_engine_interface['schema'], color_map.ColorMap): # Draw given colormap with PIL item = drawer.draw_colormap(color_map=self._color_engine_interface['schema'], descriptor_name=self._primary_descriptor_name) items.append(item) return items
[docs] def draw(self): """Draw the legend, along the given axis. The generated legend is a grid with each cell having the dimensions of the biggest item to draw. The grid is then filled along the main axis first. The size of the legend along the main axis is fixed while the size along the minor one is variable. Returns: :class:`~PIL.Image.Image`: The output legend. """ # Init legend item drawer drawer = LegendItemDrawer(background_color=self._background_color, scale=self._scale, margins=self._item_margins) margin = int(drawer.TEXT_SIZE * self._scale) # Draw all items items = self._draw_items(drawer) # No items to draw means empty legend if not items: return PIL.Image.new(mode='RGBA', size=(0, 0)) # Compute position for each item position_generator = LegendItemPositionGenerator( items_sizes=[item.size for item in items], axis=self._axis, max_size_along_axis=self._max_size_along_axis - 2 * margin, main_axis_align=self._main_axis_align, minor_axis_align=self._minor_axis_align ) items_with_position = [(items[i], position) for i, position in enumerate(list(position_generator))] # Prepare tag if needed upper_margin = 0 draw = None if self._plot_tag: upper_margin = int((2 * 2 + 0.75 * drawer.TEXT_SIZE + 2 * 4) * self._scale) # Prepare record and descriptor tag_descriptor = CategoricalDescriptor(name='name') tag_record = Record(coordinates=[[[0.5 * margin, int(upper_margin / 2)], [0.5 * margin, int(upper_margin / 2) + 1], [0.5 * margin + 1, int(upper_margin / 2) + 1], [0.5 * margin + 1, int(upper_margin / 2)], [0.5 * margin, int(upper_margin / 2)]]], labels=['Dummy'], name=self._tag_descriptor.__descriptor__['name'], categorical_descriptor_name=0.5, color=Color(*get_outline_color(self._background_color), ctype='sRGB255')) tag_descriptor.update(RecordCollection(tag_record)) # Prepare TagPainter and Draw tag_painter = TagPainter(tag_descriptor, text_margin=2 * self._scale, text_size=0.75 * drawer.TEXT_SIZE * self._scale) draw = Draw(size=(int(position_generator.legend_size[0] + 2 * margin), upper_margin), zoom=1, mode='RGBA', background_color=self._background_color) # Draw legend tag tag_painter.draw(tag_record, draw=draw) # Create legend legend = PIL.Image.new(mode='RGBA', size=(position_generator.legend_size[0] + 2 * margin, position_generator.legend_size[1] + 2 * margin + upper_margin), color=self._background_color) for item, position in items_with_position: legend.alpha_composite(im=item, dest=(position[0] + margin, position[1] + margin + upper_margin)) if draw is not None: legend.alpha_composite(im=draw.overlay, dest=(0, 4)) if self._color_engine_interface['type'] == 'categorical': bottom_margin = position_generator.legend_size[1] - position_generator.true_legend_size[1] drawer.draw_outline_with_title(self._primary_descriptor_name, legend, margins=(0, upper_margin, 0, bottom_margin)) return legend