You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

annotate.py 20KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578
  1. # Copyright (c) 2016-2017 Anki, Inc.
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License");
  4. # you may not use this file except in compliance with the License.
  5. # You may obtain a copy of the License in the file LICENSE.txt or at
  6. #
  7. # http://www.apache.org/licenses/LICENSE-2.0
  8. #
  9. # Unless required by applicable law or agreed to in writing, software
  10. # distributed under the License is distributed on an "AS IS" BASIS,
  11. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. # See the License for the specific language governing permissions and
  13. # limitations under the License.
  14. '''Camera image annotation.
  15. .. image:: ../images/annotate.jpg
  16. This module defines an :class:`ImageAnnotator` class used by
  17. :class:`cozmo.world.World` to add annotations to camera images received by Cozmo.
  18. This can include the location of cubes, faces and pets that Cozmo currently sees,
  19. along with user-defined custom annotations.
  20. The ImageAnnotator instance can be accessed as
  21. :attr:`cozmo.world.World.image_annotator`.
  22. '''
  23. # __all__ should order by constants, event classes, other classes, functions.
  24. __all__ = ['DEFAULT_OBJECT_COLORS',
  25. 'TOP_LEFT', 'TOP_RIGHT', 'BOTTOM_LEFT', 'BOTTOM_RIGHT',
  26. 'RESAMPLE_MODE_NEAREST', 'RESAMPLE_MODE_BILINEAR',
  27. 'ImageText', 'Annotator', 'ObjectAnnotator', 'FaceAnnotator',
  28. 'PetAnnotator', 'TextAnnotator', 'ImageAnnotator',
  29. 'add_img_box_to_image', 'add_polygon_to_image', 'annotator']
  30. import collections
  31. import functools
  32. try:
  33. from PIL import Image, ImageDraw
  34. except (ImportError, SyntaxError):
  35. # may get SyntaxError if accidentally importing old Python 2 version of PIL
  36. ImageDraw = None
  37. from . import event
  38. from . import objects
  39. DEFAULT_OBJECT_COLORS = {
  40. objects.LightCube: 'yellow',
  41. objects.CustomObject: 'purple',
  42. 'default': 'red'
  43. }
  44. LEFT = 1
  45. RIGHT = 2
  46. TOP = 4
  47. BOTTOM = 8
  48. #: Top left position
  49. TOP_LEFT = TOP | LEFT
  50. #: Bottom left position
  51. BOTTOM_LEFT = BOTTOM | LEFT
  52. #: Top right position
  53. TOP_RIGHT = TOP | RIGHT
  54. #: Bottom right position
  55. BOTTOM_RIGHT = BOTTOM | RIGHT
  56. if ImageDraw is not None:
  57. #: Fastest resampling mode, use nearest pixel
  58. RESAMPLE_MODE_NEAREST = Image.NEAREST
  59. #: Slower, but smoother, resampling mode - linear interpolation from 2x2 grid of pixels
  60. RESAMPLE_MODE_BILINEAR = Image.BILINEAR
  61. else:
  62. RESAMPLE_MODE_NEAREST = None
  63. RESAMPLE_MODE_BILINEAR = None
  64. class ImageText:
  65. '''ImageText represents some text that can be applied to an image.
  66. The class allows the text to be placed at various positions inside a
  67. bounding box within the image itself.
  68. Args:
  69. text (string): The text to display; may contain newlines
  70. position (int): Where on the screen to render the text
  71. - A constant such at TOP_LEFT or BOTTOM_RIGHT
  72. align (string): Text alignment for multi-line strings
  73. color (string): Color to use for the text - see :mod:`PIL.ImageColor`
  74. font (:mod:`PIL.ImageFont`): Font to use (None for a default font)
  75. line_spacing (int): The vertical spacing for multi-line strings
  76. outline_color (string): Color to use for the outline - see
  77. :mod:`PIL.ImageColor` - use None for no outline.
  78. full_outline (bool): True if the outline should surround the text,
  79. otherwise a cheaper drop-shadow is displayed. Only relevant if
  80. outline_color is specified.
  81. '''
  82. def __init__(self, text, position=BOTTOM_RIGHT, align="left", color="white",
  83. font=None, line_spacing=3, outline_color=None, full_outline=True):
  84. self.text = text
  85. self.position = position
  86. self.align = align
  87. self.color = color
  88. self.font = font
  89. self.line_spacing = line_spacing
  90. self.outline_color = outline_color
  91. self.full_outline = full_outline
  92. def render(self, draw, bounds):
  93. '''Renders the text onto an image within the specified bounding box.
  94. Args:
  95. draw (:class:`PIL.ImageDraw.ImageDraw`): The drawable surface to write on
  96. bounds (tuple of int)
  97. (top_left_x, top_left_y, bottom_right_x, bottom_right_y):
  98. bounding box
  99. Returns:
  100. The same :class:`PIL.ImageDraw.ImageDraw` object as was passed-in with text applied.
  101. '''
  102. (bx1, by1, bx2, by2) = bounds
  103. text_width, text_height = draw.textsize(self.text, font=self.font)
  104. if self.position & TOP:
  105. y = by1
  106. else:
  107. y = by2 - text_height
  108. if self.position & LEFT:
  109. x = bx1
  110. else:
  111. x = bx2 - text_width
  112. # helper method for each draw call below
  113. def _draw_text(pos, color):
  114. draw.text(pos, self.text, font=self.font, fill=color,
  115. align=self.align, spacing=self.line_spacing)
  116. if self.outline_color is not None:
  117. # Pillow doesn't support outlined or shadowed text directly.
  118. # We manually draw the text multiple times to achieve the effect.
  119. if self.full_outline:
  120. _draw_text((x-1, y), self.outline_color)
  121. _draw_text((x+1, y), self.outline_color)
  122. _draw_text((x, y-1), self.outline_color)
  123. _draw_text((x, y+1), self.outline_color)
  124. else:
  125. # just draw a drop shadow (cheaper)
  126. _draw_text((x+1, y+1), self.outline_color)
  127. _draw_text((x,y), self.color)
  128. return draw
  129. def add_img_box_to_image(image, box, color, text=None):
  130. '''Draw a box on an image and optionally add text.
  131. This will draw the outline of a rectangle to the passed in image
  132. in the specified color and optionally add one or more pieces of text
  133. along the inside edge of the rectangle.
  134. Args:
  135. image (:class:`PIL.Image.Image`): The image to draw on
  136. box (:class:`cozmo.util.ImageBox`): The ImageBox defining the rectangle to draw
  137. color (string): A color string suitable for use with PIL - see :mod:`PIL.ImageColor`
  138. text (instance or iterable of :class:`ImageText`): The text to display
  139. - may be a single ImageText instance, or any iterable (eg a list
  140. of ImageText instances) to display multiple pieces of text.
  141. '''
  142. d = ImageDraw.Draw(image)
  143. x1, y1 = box.left_x, box.top_y
  144. x2, y2 = box.right_x, box.bottom_y
  145. d.rectangle([x1, y1, x2, y2], outline=color)
  146. if text is not None:
  147. if isinstance(text, collections.Iterable):
  148. for t in text:
  149. t.render(d, (x1, y1, x2, y2))
  150. else:
  151. text.render(d, (x1, y1, x2, y2))
  152. def add_polygon_to_image(image, poly_points, scale, line_color, fill_color=None):
  153. '''Draw a polygon on an image
  154. This will draw a polygon on the passed-in image in the specified
  155. colors and scale.
  156. Args:
  157. image (:class:`PIL.Image.Image`): The image to draw on
  158. poly_points: A sequence of points representing the polygon,
  159. where each point has float members (x, y)
  160. scale (float): Scale to multiply each point to match the image scaling
  161. line_color (string): The color for the outline of the polygon. The string value
  162. must be a color string suitable for use with PIL - see :mod:`PIL.ImageColor`
  163. fill_color (string): The color for the inside of the polygon. The string value
  164. must be a color string suitable for use with PIL - see :mod:`PIL.ImageColor`
  165. '''
  166. if len(poly_points) < 2:
  167. # Need at least 2 points to draw any lines
  168. return
  169. d = ImageDraw.Draw(image)
  170. # Convert poly_points to the PIL format and scale them to the image
  171. pil_poly_points = []
  172. for pt in poly_points:
  173. pil_poly_points.append((pt.x * scale, pt.y * scale))
  174. d.polygon(pil_poly_points, fill=fill_color, outline=line_color)
  175. def _find_key_for_cls(d, cls):
  176. for cls in cls.__mro__:
  177. result = d.get(cls, None)
  178. if result:
  179. return result
  180. return d['default']
  181. class Annotator:
  182. '''Annotation base class
  183. Subclasses of Annotator handle applying a single annotation to an image.
  184. '''
  185. #: int: The priority of the annotator - Annotators with higher numbered
  186. #: priorities are applied first.
  187. priority = 100
  188. def __init__(self, img_annotator, priority=None):
  189. #: :class:`ImageAnnotator`: The object managing camera annotations
  190. self.img_annotator = img_annotator
  191. #: :class:`~cozmo.world.World`: The world object for the robot who owns the camera
  192. self.world = img_annotator.world
  193. #: bool: Set enabled to false to prevent the annotator being called
  194. self.enabled = True
  195. if priority is not None:
  196. self.priority = priority
  197. def apply(self, image, scale):
  198. '''Applies the annotation to the image.'''
  199. # should be overriden by a subclass
  200. raise NotImplementedError()
  201. def __hash__(self):
  202. return id(self)
  203. class ObjectAnnotator(Annotator):
  204. '''Adds object annotations to an Image.
  205. This handles :class:`cozmo.objects.LightCube` objects
  206. as well as custom objects.
  207. '''
  208. priority = 100
  209. object_colors = DEFAULT_OBJECT_COLORS
  210. def __init__(self, img_annotator, object_colors=None):
  211. super().__init__(img_annotator)
  212. if object_colors is not None:
  213. self.object_colors = object_colors
  214. def apply(self, image, scale):
  215. d = ImageDraw.Draw(image)
  216. for obj in self.world.visible_objects:
  217. color = _find_key_for_cls(self.object_colors, obj.__class__)
  218. text = self.label_for_obj(obj)
  219. box = obj.last_observed_image_box
  220. if scale != 1:
  221. box *= scale
  222. add_img_box_to_image(image, box, color, text=text)
  223. def label_for_obj(self, obj):
  224. '''Fetch a label to display for the object.
  225. Override or replace to customize.
  226. '''
  227. return ImageText(obj.descriptive_name)
  228. class FaceAnnotator(Annotator):
  229. '''Adds annotations of currently detected faces to a camera image.
  230. This handles the display of :class:`cozmo.faces.Face` objects.
  231. '''
  232. priority = 100
  233. box_color = 'green'
  234. def __init__(self, img_annotator, box_color=None):
  235. super().__init__(img_annotator)
  236. if box_color is not None:
  237. self.box_color = box_color
  238. def apply(self, image, scale):
  239. d = ImageDraw.Draw(image)
  240. for obj in self.world.visible_faces:
  241. text = self.label_for_face(obj)
  242. box = obj.last_observed_image_box
  243. if scale != 1:
  244. box *= scale
  245. add_img_box_to_image(image, box, self.box_color, text=text)
  246. add_polygon_to_image(image, obj.left_eye, scale, self.box_color)
  247. add_polygon_to_image(image, obj.right_eye, scale, self.box_color)
  248. add_polygon_to_image(image, obj.nose, scale, self.box_color)
  249. add_polygon_to_image(image, obj.mouth, scale, self.box_color)
  250. def label_for_face(self, obj):
  251. '''Fetch a label to display for the face.
  252. Override or replace to customize.
  253. '''
  254. expression = obj.known_expression
  255. if len(expression) > 0:
  256. # if there is a specific known expression, then also show the score
  257. # (display a % to make it clear the value is out of 100)
  258. expression += "=%s%% " % obj.expression_score
  259. if obj.name:
  260. return ImageText('%s%s (%d)' % (expression, obj.name, obj.face_id))
  261. return ImageText('(unknown%s face %d)' % (expression, obj.face_id))
  262. class PetAnnotator(Annotator):
  263. '''Adds annotations of currently detected pets to a camera image.
  264. This handles the display of :class:`cozmo.pets.Pet` objects.
  265. '''
  266. priority = 100
  267. box_color = 'lightgreen'
  268. def __init__(self, img_annotator, box_color=None):
  269. super().__init__(img_annotator)
  270. if box_color is not None:
  271. self.box_color = box_color
  272. def apply(self, image, scale):
  273. d = ImageDraw.Draw(image)
  274. for obj in self.world.visible_pets:
  275. text = self.label_for_pet(obj)
  276. box = obj.last_observed_image_box
  277. if scale != 1:
  278. box *= scale
  279. add_img_box_to_image(image, box, self.box_color, text=text)
  280. def label_for_pet(self, obj):
  281. '''Fetch a label to display for the pet.
  282. Override or replace to customize.
  283. '''
  284. return ImageText('%d: %s' % (obj.pet_id, obj.pet_type))
  285. class TextAnnotator(Annotator):
  286. '''Adds simple text annotations to a camera image.
  287. '''
  288. priority = 50
  289. def __init__(self, img_annotator, text):
  290. super().__init__(img_annotator)
  291. self.text = text
  292. def apply(self, image, scale):
  293. d = ImageDraw.Draw(image)
  294. self.text.render(d, (0, 0, image.width, image.height))
  295. class _AnnotatorHelper(Annotator):
  296. def __init__(self, img_annotator, wrapped):
  297. super().__init__(img_annotator)
  298. self._wrapped = wrapped
  299. def apply(self, image, scale):
  300. self._wrapped(image, scale, world=self.world, img_annotator=self.img_annotator)
  301. def annotator(f):
  302. '''A decorator for converting a regular function/method into an Annotator.
  303. The wrapped function should have a signature of
  304. ``(image, scale, img_annotator=None, world=None, **kw)``
  305. '''
  306. @functools.wraps(f)
  307. def wrapper(img_annotator):
  308. return _AnnotatorHelper(img_annotator, f)
  309. return wrapper
  310. class ImageAnnotator(event.Dispatcher):
  311. '''ImageAnnotator applies annotations to the camera image received from the robot.
  312. This is instantiated by :class:`cozmo.world.World` and is accessible as
  313. :class:`cozmo.world.World.image_annotator`.
  314. By default it defines three active annotators named ``objects``, ``faces`` and ``pets``.
  315. The ``objects`` annotator adds a box around each object (such as light cubes)
  316. that Cozmo can see. The ``faces`` annotator adds a box around each person's
  317. face that Cozmo can recognize. The ``pets`` annotator adds a box around each pet
  318. face that Cozmo can recognize.
  319. Custom annotations can be defined by calling :meth:`add_annotator` with
  320. a name of your choosing and an instance of a :class:`Annotator` subclass,
  321. or use a regular function wrapped with the :func:`annotator` decorator.
  322. Individual annotations can be disabled and re-enabled using the
  323. :meth:`disable_annotator` and :meth:`enable_annotator` methods.
  324. All annotations can be disabled by setting the
  325. :attr:`annotation_enabled` property to False.
  326. E.g. to disable face annotations, call
  327. ``coz.world.image_annotator.disable_annotator('faces')``
  328. Annotators each have a priority number associated with them. Annotators
  329. with a larger priority number are rendered first and may be overdrawn by those
  330. with a lower/smaller priority number.
  331. '''
  332. def __init__(self, world, **kw):
  333. super().__init__(**kw)
  334. #: :class:`cozmo.world.World`: World object that created the annotator.
  335. self.world = world
  336. self._annotators = {}
  337. self._sorted_annotators = []
  338. self.add_annotator('objects', ObjectAnnotator(self))
  339. self.add_annotator('faces', FaceAnnotator(self))
  340. self.add_annotator('pets', PetAnnotator(self))
  341. #: If this attribute is set to false, the :meth:`annotate_image` method
  342. #: will continue to provide a scaled image, but will not apply any annotations.
  343. self.annotation_enabled = True
  344. def _sort_annotators(self):
  345. self._sorted_annotators = sorted(self._annotators.values(),
  346. key=lambda an: an.priority, reverse=True)
  347. def add_annotator(self, name, annotator):
  348. '''Adds a new annotator for display.
  349. Annotators are enabled by default.
  350. Args:
  351. name (string): An arbitrary name for the annotator; must not
  352. already be defined
  353. annotator (:class:`Annotator` or callable): The annotator to add
  354. may either by an instance of Annotator, or a factory callable
  355. that will return an instance of Annotator. The callable will
  356. be called with an ImageAnnotator instance as its first argument.
  357. Raises:
  358. :class:`ValueError` if the annotator is already defined.
  359. '''
  360. if name in self._annotators:
  361. raise ValueError('Annotator "%s" is already defined' % (name))
  362. if not isinstance(annotator, Annotator):
  363. annotator = annotator(self)
  364. self._annotators[name] = annotator
  365. self._sort_annotators()
  366. def remove_annotator(self, name):
  367. '''Remove an annotator.
  368. Args:
  369. name (string): The name of the annotator to remove as passed to
  370. :meth:`add_annotator`.
  371. Raises:
  372. KeyError if the annotator isn't registered
  373. '''
  374. del self._annotators[name]
  375. self._sort_annotators()
  376. def get_annotator(self, name):
  377. '''Return a named annotator.
  378. Args:
  379. name (string): The name of the annotator to return
  380. Raises:
  381. KeyError if the annotator isn't registered
  382. '''
  383. return self._annotators[name]
  384. def disable_annotator(self, name):
  385. '''Disable a named annotator.
  386. Leaves the annotator as registered, but does not include its output
  387. in the annotated image.
  388. Args:
  389. name (string): The name of the annotator to disable
  390. '''
  391. if name in self._annotators:
  392. self._annotators[name].enabled = False
  393. def enable_annotator(self, name):
  394. '''Enabled a named annotator.
  395. (re)enable an annotator if it was previously disabled.
  396. Args:
  397. name (string): The name of the annotator to enable
  398. '''
  399. self._annotators[name].enabled = True
  400. def add_static_text(self, name, text, color='white', position=TOP_LEFT):
  401. '''Add some static text to annotated images.
  402. This is a convenience method to create a :class:`TextAnnnotator`
  403. and add it to the image.
  404. Args:
  405. name (string): An arbitrary name for the annotator; must not
  406. already be defined
  407. text (str or :class:`ImageText` instance): The text to display
  408. may be a plain string, or an ImageText instance
  409. color (string): Used if text is a string; defaults to white
  410. position (int): Used if text is a string; defaults to TOP_LEFT
  411. '''
  412. if isinstance(text, str):
  413. text = ImageText(text, position=position, color=color)
  414. self.add_annotator(name, TextAnnotator(self, text))
  415. def annotate_image(self, image, scale=None, fit_size=None, resample_mode=RESAMPLE_MODE_NEAREST):
  416. '''Called by :class:`~cozmo.world.World` to annotate camera images.
  417. Args:
  418. image (:class:`PIL.Image.Image`): The image to annotate
  419. scale (float): If set then the base image will be scaled by the
  420. supplied multiplier. Cannot be combined with fit_size
  421. fit_size (tuple of int): If set, then scale the image to fit inside
  422. the supplied (width, height) dimensions. The original aspect
  423. ratio will be preserved. Cannot be combined with scale.
  424. resample_mode (int): The resampling mode to use when scaling the
  425. image. Should be either :attr:`RESAMPLE_MODE_NEAREST` (fast) or
  426. :attr:`RESAMPLE_MODE_BILINEAR` (slower, but smoother).
  427. Returns:
  428. :class:`PIL.Image.Image`
  429. '''
  430. if ImageDraw is None:
  431. return image
  432. if scale is not None:
  433. if scale == 1:
  434. image = image.copy()
  435. else:
  436. image = image.resize((int(image.width * scale), int(image.height * scale)),
  437. resample=resample_mode)
  438. elif fit_size is not None:
  439. if fit_size == (image.width, image.height):
  440. image = image.copy()
  441. scale = 1
  442. else:
  443. img_ratio = image.width / image.height
  444. fit_width, fit_height = fit_size
  445. fit_ratio = fit_width / fit_height
  446. if img_ratio > fit_ratio:
  447. fit_height = int(fit_width / img_ratio)
  448. elif img_ratio < fit_ratio:
  449. fit_width = int(fit_height * img_ratio)
  450. scale = fit_width / image.width
  451. image = image.resize((fit_width, fit_height))
  452. else:
  453. scale = 1
  454. if not self.annotation_enabled:
  455. return image
  456. for an in self._sorted_annotators:
  457. if an.enabled:
  458. an.apply(image, scale)
  459. return image