# Copyright (c) 2016-2017 Anki, Inc. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License in the file LICENSE.txt or at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. '''Camera image annotation. .. image:: ../images/annotate.jpg This module defines an :class:`ImageAnnotator` class used by :class:`cozmo.world.World` to add annotations to camera images received by Cozmo. This can include the location of cubes, faces and pets that Cozmo currently sees, along with user-defined custom annotations. The ImageAnnotator instance can be accessed as :attr:`cozmo.world.World.image_annotator`. ''' # __all__ should order by constants, event classes, other classes, functions. __all__ = ['DEFAULT_OBJECT_COLORS', 'TOP_LEFT', 'TOP_RIGHT', 'BOTTOM_LEFT', 'BOTTOM_RIGHT', 'RESAMPLE_MODE_NEAREST', 'RESAMPLE_MODE_BILINEAR', 'ImageText', 'Annotator', 'ObjectAnnotator', 'FaceAnnotator', 'PetAnnotator', 'TextAnnotator', 'ImageAnnotator', 'add_img_box_to_image', 'add_polygon_to_image', 'annotator'] import collections import functools try: from PIL import Image, ImageDraw except (ImportError, SyntaxError): # may get SyntaxError if accidentally importing old Python 2 version of PIL ImageDraw = None from . import event from . import objects DEFAULT_OBJECT_COLORS = { objects.LightCube: 'yellow', objects.CustomObject: 'purple', 'default': 'red' } LEFT = 1 RIGHT = 2 TOP = 4 BOTTOM = 8 #: Top left position TOP_LEFT = TOP | LEFT #: Bottom left position BOTTOM_LEFT = BOTTOM | LEFT #: Top right position TOP_RIGHT = TOP | RIGHT #: Bottom right position BOTTOM_RIGHT = BOTTOM | RIGHT if ImageDraw is not None: #: Fastest resampling mode, use nearest pixel RESAMPLE_MODE_NEAREST = Image.NEAREST #: Slower, but smoother, resampling mode - linear interpolation from 2x2 grid of pixels RESAMPLE_MODE_BILINEAR = Image.BILINEAR else: RESAMPLE_MODE_NEAREST = None RESAMPLE_MODE_BILINEAR = None class ImageText: '''ImageText represents some text that can be applied to an image. The class allows the text to be placed at various positions inside a bounding box within the image itself. Args: text (string): The text to display; may contain newlines position (int): Where on the screen to render the text - A constant such at TOP_LEFT or BOTTOM_RIGHT align (string): Text alignment for multi-line strings color (string): Color to use for the text - see :mod:`PIL.ImageColor` font (:mod:`PIL.ImageFont`): Font to use (None for a default font) line_spacing (int): The vertical spacing for multi-line strings outline_color (string): Color to use for the outline - see :mod:`PIL.ImageColor` - use None for no outline. full_outline (bool): True if the outline should surround the text, otherwise a cheaper drop-shadow is displayed. Only relevant if outline_color is specified. ''' def __init__(self, text, position=BOTTOM_RIGHT, align="left", color="white", font=None, line_spacing=3, outline_color=None, full_outline=True): self.text = text self.position = position self.align = align self.color = color self.font = font self.line_spacing = line_spacing self.outline_color = outline_color self.full_outline = full_outline def render(self, draw, bounds): '''Renders the text onto an image within the specified bounding box. Args: draw (:class:`PIL.ImageDraw.ImageDraw`): The drawable surface to write on bounds (tuple of int) (top_left_x, top_left_y, bottom_right_x, bottom_right_y): bounding box Returns: The same :class:`PIL.ImageDraw.ImageDraw` object as was passed-in with text applied. ''' (bx1, by1, bx2, by2) = bounds text_width, text_height = draw.textsize(self.text, font=self.font) if self.position & TOP: y = by1 else: y = by2 - text_height if self.position & LEFT: x = bx1 else: x = bx2 - text_width # helper method for each draw call below def _draw_text(pos, color): draw.text(pos, self.text, font=self.font, fill=color, align=self.align, spacing=self.line_spacing) if self.outline_color is not None: # Pillow doesn't support outlined or shadowed text directly. # We manually draw the text multiple times to achieve the effect. if self.full_outline: _draw_text((x-1, y), self.outline_color) _draw_text((x+1, y), self.outline_color) _draw_text((x, y-1), self.outline_color) _draw_text((x, y+1), self.outline_color) else: # just draw a drop shadow (cheaper) _draw_text((x+1, y+1), self.outline_color) _draw_text((x,y), self.color) return draw def add_img_box_to_image(image, box, color, text=None): '''Draw a box on an image and optionally add text. This will draw the outline of a rectangle to the passed in image in the specified color and optionally add one or more pieces of text along the inside edge of the rectangle. Args: image (:class:`PIL.Image.Image`): The image to draw on box (:class:`cozmo.util.ImageBox`): The ImageBox defining the rectangle to draw color (string): A color string suitable for use with PIL - see :mod:`PIL.ImageColor` text (instance or iterable of :class:`ImageText`): The text to display - may be a single ImageText instance, or any iterable (eg a list of ImageText instances) to display multiple pieces of text. ''' d = ImageDraw.Draw(image) x1, y1 = box.left_x, box.top_y x2, y2 = box.right_x, box.bottom_y d.rectangle([x1, y1, x2, y2], outline=color) if text is not None: if isinstance(text, collections.Iterable): for t in text: t.render(d, (x1, y1, x2, y2)) else: text.render(d, (x1, y1, x2, y2)) def add_polygon_to_image(image, poly_points, scale, line_color, fill_color=None): '''Draw a polygon on an image This will draw a polygon on the passed-in image in the specified colors and scale. Args: image (:class:`PIL.Image.Image`): The image to draw on poly_points: A sequence of points representing the polygon, where each point has float members (x, y) scale (float): Scale to multiply each point to match the image scaling line_color (string): The color for the outline of the polygon. The string value must be a color string suitable for use with PIL - see :mod:`PIL.ImageColor` fill_color (string): The color for the inside of the polygon. The string value must be a color string suitable for use with PIL - see :mod:`PIL.ImageColor` ''' if len(poly_points) < 2: # Need at least 2 points to draw any lines return d = ImageDraw.Draw(image) # Convert poly_points to the PIL format and scale them to the image pil_poly_points = [] for pt in poly_points: pil_poly_points.append((pt.x * scale, pt.y * scale)) d.polygon(pil_poly_points, fill=fill_color, outline=line_color) def _find_key_for_cls(d, cls): for cls in cls.__mro__: result = d.get(cls, None) if result: return result return d['default'] class Annotator: '''Annotation base class Subclasses of Annotator handle applying a single annotation to an image. ''' #: int: The priority of the annotator - Annotators with higher numbered #: priorities are applied first. priority = 100 def __init__(self, img_annotator, priority=None): #: :class:`ImageAnnotator`: The object managing camera annotations self.img_annotator = img_annotator #: :class:`~cozmo.world.World`: The world object for the robot who owns the camera self.world = img_annotator.world #: bool: Set enabled to false to prevent the annotator being called self.enabled = True if priority is not None: self.priority = priority def apply(self, image, scale): '''Applies the annotation to the image.''' # should be overriden by a subclass raise NotImplementedError() def __hash__(self): return id(self) class ObjectAnnotator(Annotator): '''Adds object annotations to an Image. This handles :class:`cozmo.objects.LightCube` objects as well as custom objects. ''' priority = 100 object_colors = DEFAULT_OBJECT_COLORS def __init__(self, img_annotator, object_colors=None): super().__init__(img_annotator) if object_colors is not None: self.object_colors = object_colors def apply(self, image, scale): d = ImageDraw.Draw(image) for obj in self.world.visible_objects: color = _find_key_for_cls(self.object_colors, obj.__class__) text = self.label_for_obj(obj) box = obj.last_observed_image_box if scale != 1: box *= scale add_img_box_to_image(image, box, color, text=text) def label_for_obj(self, obj): '''Fetch a label to display for the object. Override or replace to customize. ''' return ImageText(obj.descriptive_name) class FaceAnnotator(Annotator): '''Adds annotations of currently detected faces to a camera image. This handles the display of :class:`cozmo.faces.Face` objects. ''' priority = 100 box_color = 'green' def __init__(self, img_annotator, box_color=None): super().__init__(img_annotator) if box_color is not None: self.box_color = box_color def apply(self, image, scale): d = ImageDraw.Draw(image) for obj in self.world.visible_faces: text = self.label_for_face(obj) box = obj.last_observed_image_box if scale != 1: box *= scale add_img_box_to_image(image, box, self.box_color, text=text) add_polygon_to_image(image, obj.left_eye, scale, self.box_color) add_polygon_to_image(image, obj.right_eye, scale, self.box_color) add_polygon_to_image(image, obj.nose, scale, self.box_color) add_polygon_to_image(image, obj.mouth, scale, self.box_color) def label_for_face(self, obj): '''Fetch a label to display for the face. Override or replace to customize. ''' expression = obj.known_expression if len(expression) > 0: # if there is a specific known expression, then also show the score # (display a % to make it clear the value is out of 100) expression += "=%s%% " % obj.expression_score if obj.name: return ImageText('%s%s (%d)' % (expression, obj.name, obj.face_id)) return ImageText('(unknown%s face %d)' % (expression, obj.face_id)) class PetAnnotator(Annotator): '''Adds annotations of currently detected pets to a camera image. This handles the display of :class:`cozmo.pets.Pet` objects. ''' priority = 100 box_color = 'lightgreen' def __init__(self, img_annotator, box_color=None): super().__init__(img_annotator) if box_color is not None: self.box_color = box_color def apply(self, image, scale): d = ImageDraw.Draw(image) for obj in self.world.visible_pets: text = self.label_for_pet(obj) box = obj.last_observed_image_box if scale != 1: box *= scale add_img_box_to_image(image, box, self.box_color, text=text) def label_for_pet(self, obj): '''Fetch a label to display for the pet. Override or replace to customize. ''' return ImageText('%d: %s' % (obj.pet_id, obj.pet_type)) class TextAnnotator(Annotator): '''Adds simple text annotations to a camera image. ''' priority = 50 def __init__(self, img_annotator, text): super().__init__(img_annotator) self.text = text def apply(self, image, scale): d = ImageDraw.Draw(image) self.text.render(d, (0, 0, image.width, image.height)) class _AnnotatorHelper(Annotator): def __init__(self, img_annotator, wrapped): super().__init__(img_annotator) self._wrapped = wrapped def apply(self, image, scale): self._wrapped(image, scale, world=self.world, img_annotator=self.img_annotator) def annotator(f): '''A decorator for converting a regular function/method into an Annotator. The wrapped function should have a signature of ``(image, scale, img_annotator=None, world=None, **kw)`` ''' @functools.wraps(f) def wrapper(img_annotator): return _AnnotatorHelper(img_annotator, f) return wrapper class ImageAnnotator(event.Dispatcher): '''ImageAnnotator applies annotations to the camera image received from the robot. This is instantiated by :class:`cozmo.world.World` and is accessible as :class:`cozmo.world.World.image_annotator`. By default it defines three active annotators named ``objects``, ``faces`` and ``pets``. The ``objects`` annotator adds a box around each object (such as light cubes) that Cozmo can see. The ``faces`` annotator adds a box around each person's face that Cozmo can recognize. The ``pets`` annotator adds a box around each pet face that Cozmo can recognize. Custom annotations can be defined by calling :meth:`add_annotator` with a name of your choosing and an instance of a :class:`Annotator` subclass, or use a regular function wrapped with the :func:`annotator` decorator. Individual annotations can be disabled and re-enabled using the :meth:`disable_annotator` and :meth:`enable_annotator` methods. All annotations can be disabled by setting the :attr:`annotation_enabled` property to False. E.g. to disable face annotations, call ``coz.world.image_annotator.disable_annotator('faces')`` Annotators each have a priority number associated with them. Annotators with a larger priority number are rendered first and may be overdrawn by those with a lower/smaller priority number. ''' def __init__(self, world, **kw): super().__init__(**kw) #: :class:`cozmo.world.World`: World object that created the annotator. self.world = world self._annotators = {} self._sorted_annotators = [] self.add_annotator('objects', ObjectAnnotator(self)) self.add_annotator('faces', FaceAnnotator(self)) self.add_annotator('pets', PetAnnotator(self)) #: If this attribute is set to false, the :meth:`annotate_image` method #: will continue to provide a scaled image, but will not apply any annotations. self.annotation_enabled = True def _sort_annotators(self): self._sorted_annotators = sorted(self._annotators.values(), key=lambda an: an.priority, reverse=True) def add_annotator(self, name, annotator): '''Adds a new annotator for display. Annotators are enabled by default. Args: name (string): An arbitrary name for the annotator; must not already be defined annotator (:class:`Annotator` or callable): The annotator to add may either by an instance of Annotator, or a factory callable that will return an instance of Annotator. The callable will be called with an ImageAnnotator instance as its first argument. Raises: :class:`ValueError` if the annotator is already defined. ''' if name in self._annotators: raise ValueError('Annotator "%s" is already defined' % (name)) if not isinstance(annotator, Annotator): annotator = annotator(self) self._annotators[name] = annotator self._sort_annotators() def remove_annotator(self, name): '''Remove an annotator. Args: name (string): The name of the annotator to remove as passed to :meth:`add_annotator`. Raises: KeyError if the annotator isn't registered ''' del self._annotators[name] self._sort_annotators() def get_annotator(self, name): '''Return a named annotator. Args: name (string): The name of the annotator to return Raises: KeyError if the annotator isn't registered ''' return self._annotators[name] def disable_annotator(self, name): '''Disable a named annotator. Leaves the annotator as registered, but does not include its output in the annotated image. Args: name (string): The name of the annotator to disable ''' if name in self._annotators: self._annotators[name].enabled = False def enable_annotator(self, name): '''Enabled a named annotator. (re)enable an annotator if it was previously disabled. Args: name (string): The name of the annotator to enable ''' self._annotators[name].enabled = True def add_static_text(self, name, text, color='white', position=TOP_LEFT): '''Add some static text to annotated images. This is a convenience method to create a :class:`TextAnnnotator` and add it to the image. Args: name (string): An arbitrary name for the annotator; must not already be defined text (str or :class:`ImageText` instance): The text to display may be a plain string, or an ImageText instance color (string): Used if text is a string; defaults to white position (int): Used if text is a string; defaults to TOP_LEFT ''' if isinstance(text, str): text = ImageText(text, position=position, color=color) self.add_annotator(name, TextAnnotator(self, text)) def annotate_image(self, image, scale=None, fit_size=None, resample_mode=RESAMPLE_MODE_NEAREST): '''Called by :class:`~cozmo.world.World` to annotate camera images. Args: image (:class:`PIL.Image.Image`): The image to annotate scale (float): If set then the base image will be scaled by the supplied multiplier. Cannot be combined with fit_size fit_size (tuple of int): If set, then scale the image to fit inside the supplied (width, height) dimensions. The original aspect ratio will be preserved. Cannot be combined with scale. resample_mode (int): The resampling mode to use when scaling the image. Should be either :attr:`RESAMPLE_MODE_NEAREST` (fast) or :attr:`RESAMPLE_MODE_BILINEAR` (slower, but smoother). Returns: :class:`PIL.Image.Image` ''' if ImageDraw is None: return image if scale is not None: if scale == 1: image = image.copy() else: image = image.resize((int(image.width * scale), int(image.height * scale)), resample=resample_mode) elif fit_size is not None: if fit_size == (image.width, image.height): image = image.copy() scale = 1 else: img_ratio = image.width / image.height fit_width, fit_height = fit_size fit_ratio = fit_width / fit_height if img_ratio > fit_ratio: fit_height = int(fit_width / img_ratio) elif img_ratio < fit_ratio: fit_width = int(fit_height * img_ratio) scale = fit_width / image.width image = image.resize((fit_width, fit_height)) else: scale = 1 if not self.annotation_enabled: return image for an in self._sorted_annotators: if an.enabled: an.apply(image, scale) return image