579 lines
20 KiB
Python
Raw Normal View History

2019-04-28 11:16:27 +02:00
# 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