Coverage for src/bob/bio/face/annotator/utils.py: 79%
173 statements
« prev ^ index » next coverage.py v7.6.0, created at 2024-07-13 00:04 +0200
« prev ^ index » next coverage.py v7.6.0, created at 2024-07-13 00:04 +0200
1import math
3available_sources = {
4 "direct": ("topleft", "bottomright"),
5 "eyes": ("leye", "reye"),
6 "left-profile": ("eye", "mouth"),
7 "right-profile": ("eye", "mouth"),
8 "ellipse": ("center", "angle", "axis_radius"),
9}
11# This struct specifies, which paddings should be applied to which source.
12# All values are relative to the inter-node distance
13default_paddings = {
14 "direct": None,
15 "eyes": {
16 "left": -1.0,
17 "right": +1.0,
18 "top": -0.7,
19 "bottom": 1.7,
20 }, # These parameters are used to match Cosmin's implementation (which was buggy...)
21 "left-profile": {"left": -0.2, "right": +0.8, "top": -1.0, "bottom": 1.0},
22 "right-profile": {"left": -0.8, "right": +0.2, "top": -1.0, "bottom": 1.0},
23 "ellipse": None,
24}
27def _to_int(value):
28 """Converts a value to int by rounding"""
29 if isinstance(value, tuple):
30 return tuple(map(_to_int, value))
31 return int(round(value))
34class BoundingBox:
35 """A bounding box class storing top, left, height and width of an rectangle."""
37 def __init__(self, topleft: tuple, size: tuple = None, **kwargs):
38 """Creates a new BoundingBox
39 Parameters
40 ----------
41 topleft
42 The top left corner of the bounding box as (y, x) tuple
43 size
44 The size of the bounding box as (height, width) tuple
45 """
46 super().__init__(**kwargs)
47 if isinstance(topleft, BoundingBox):
48 self.__init__(topleft.topleft, topleft.size)
49 return
51 if topleft is None:
52 raise ValueError(
53 "BoundingBox must be initialized with a topleft and a size"
54 )
55 self._topleft = tuple(topleft)
56 if size is None:
57 raise ValueError("BoundingBox needs a size")
58 self._size = tuple(size)
60 @property
61 def topleft_f(self):
62 """The top-left position of the bounding box as floating point values, read access only"""
63 return self._topleft
65 @property
66 def topleft(self):
67 """The top-left position of the bounding box as integral values, read access only"""
68 return _to_int(self.topleft_f)
70 @property
71 def size_f(self):
72 """The size of the bounding box as floating point values, read access only"""
73 return self._size
75 @property
76 def size(self):
77 """The size of the bounding box as integral values, read access only"""
78 return _to_int(self.size_f)
80 @property
81 def top_f(self):
82 """The top position of the bounding box as floating point values, read access only"""
83 return self._topleft[0]
85 @property
86 def top(self):
87 """The top position of the bounding box as integral values, read access only"""
88 return _to_int(self.top_f)
90 @property
91 def left_f(self):
92 """The left position of the bounding box as floating point values, read access only"""
93 return self._topleft[1]
95 @property
96 def left(self):
97 """The left position of the bounding box as integral values, read access only"""
98 return _to_int(self.left_f)
100 @property
101 def height_f(self):
102 """The height of the bounding box as floating point values, read access only"""
103 return self._size[0]
105 @property
106 def height(self):
107 """The height of the bounding box as integral values, read access only"""
108 return _to_int(self.height_f)
110 @property
111 def width_f(self):
112 """The width of the bounding box as floating point values, read access only"""
113 return self._size[1]
115 @property
116 def width(self):
117 """The width of the bounding box as integral values, read access only"""
118 return _to_int(self.width_f)
120 @property
121 def right_f(self):
122 """The right position of the bounding box as floating point values, read access only"""
123 return self.left_f + self.width_f
125 @property
126 def right(self):
127 """The right position of the bounding box as integral values, read access only"""
128 return _to_int(self.right_f)
130 @property
131 def bottom_f(self):
132 """The bottom position of the bounding box as floating point values, read access only"""
133 return self.top_f + self.height_f
135 @property
136 def bottom(self):
137 """The bottom position of the bounding box as integral values, read access only"""
138 return _to_int(self.bottom_f)
140 @property
141 def bottomright_f(self):
142 """The bottom right corner of the bounding box as floating point values, read access only"""
143 return (self.bottom_f, self.right_f)
145 @property
146 def bottomright(self):
147 """The bottom right corner of the bounding box as integral values, read access only"""
148 return _to_int(self.bottomright_f)
150 @property
151 def center(self):
152 """The center of the bounding box, read access only"""
153 return (self.top_f + self.bottom_f) // 2, (
154 self.left_f + self.right_f
155 ) // 2
157 @property
158 def area(self):
159 """The area (height x width) of the bounding box, read access only"""
160 return self.height_f * self.width_f
162 def contains(self, point):
163 """Returns True if the given point is inside the bounding box
164 Parameters
165 ----------
166 point : tuple
167 A point as (x, y) tuple
168 Returns
169 -------
170 bool
171 True if the point is inside the bounding box
172 """
173 return (
174 self.top_f <= point[0] < self.bottom_f
175 and self.left_f <= point[1] < self.right_f
176 )
178 def is_valid_for(self, size: tuple) -> bool:
179 """Checks if the bounding box is inside the given image size
180 Parameters
181 ----------
182 size
183 The size of the image to testA size as (height, width) tuple
184 Returns
185 -------
186 bool
187 True if the bounding box is inside the image boundaries
188 """
189 return (
190 self.top_f >= 0
191 and self.left_f >= 0
192 and self.bottom_f <= size[0]
193 and self.right_f <= size[1]
194 )
196 def mirror_x(self, width: int) -> "BoundingBox":
197 """Returns a horizontally mirrored version of this BoundingBox
198 Parameters
199 ----------
200 width
201 The width of the image at which this bounding box should be mirrored
202 Returns
203 -------
204 bounding_box
205 The mirrored version of this bounding box
206 """
207 return BoundingBox((self.top_f, width - self.right_f), self.size_f)
209 def overlap(self, other: "BoundingBox") -> "BoundingBox":
210 """Returns the overlapping bounding box between this and the given bounding box
211 Parameters
212 ----------
213 other
214 The other bounding box to compute the overlap with
215 Returns
216 -------
217 bounding_box
218 The overlap between this and the other bounding box
219 """
220 if self.top_f > other.bottom_f or other.top_f > self.bottom_f:
221 return BoundingBox((0, 0), (0, 0))
222 if self.left_f > other.right_f or other.left_f > self.right_f:
223 return BoundingBox((0, 0), (0, 0))
224 max_top = max(self.top_f, other.top_f)
225 max_left = max(self.left_f, other.left_f)
226 min_bottom = min(self.bottom_f, other.bottom_f)
227 min_right = min(self.right_f, other.right_f)
228 return BoundingBox(
229 (
230 max_top,
231 max_left,
232 ),
233 (
234 min_bottom - max_top,
235 min_right - max_left,
236 ),
237 )
239 def scale(self, scale: float, centered=False) -> "BoundingBox":
240 """Returns a scaled version of this BoundingBox
241 When the centered parameter is set to True, the transformation center will be in the center of this bounding box, otherwise it will be at (0,0)
242 Parameters
243 ----------
244 scale
245 The scale with which this bounding box should be shifted
246 centered
247 Should the scaling done with repect to the center of the bounding box?
248 Returns
249 -------
250 bounding_box
251 The scaled version of this bounding box
252 """
253 if centered:
254 return BoundingBox(
255 (
256 self.top_f - self.height_f / 2 * (scale - 1),
257 self.left_f - self.width_f / 2 * (scale - 1),
258 ),
259 (self.height_f * scale, self.width_f * scale),
260 )
261 else:
262 return BoundingBox(
263 (self.top_f * scale, self.left_f * scale),
264 (self.height_f * scale, self.width_f * scale),
265 )
267 def shift(self, offset: tuple) -> "BoundingBox":
268 """Returns a shifted version of this BoundingBox
269 Parameters
270 ----------
271 offset
272 The offset with which this bounding box should be shifted
273 Returns
274 -------
275 bounding_box
276 The shifted version of this bounding box
277 """
278 return BoundingBox(
279 (self.top_f + offset[0], self.left_f + offset[1]), self.size_f
280 )
282 def similarity(self, other: "BoundingBox") -> float:
283 """Returns the Jaccard similarity index between this and the given BoundingBox
284 The Jaccard similarity coefficient between two bounding boxes is defined as their intersection divided by their union.
285 Parameters
286 ----------
287 other
288 The other bounding box to compute the overlap with
289 Returns
290 -------
291 sim : float
292 The Jaccard similarity index between this and the given BoundingBox
293 """
294 max_top = max(self.top_f, other.top_f)
295 max_left = max(self.left_f, other.left_f)
296 min_bottom = min(self.bottom_f, other.bottom_f)
297 min_right = min(self.right_f, other.right_f)
299 # no overlap?
300 if max_left >= min_right or max_top >= min_bottom:
301 return 0.0
303 # compute overlap
304 intersection = (min_bottom - max_top) * (min_right - max_left)
305 return intersection / (self.area + other.area - intersection)
307 def __eq__(self, other: object) -> bool:
308 return self.topleft_f == other.topleft_f and self.size_f == other.size_f
311def bounding_box_from_annotation(source=None, padding=None, **kwargs):
312 """bounding_box_from_annotation(source, padding, **kwargs) -> bounding_box
314 Creates a bounding box from the given parameters, which are, in general, annotations read using :py:func:`bob.bio.base.utils.annotations.read_annotation_file`.
315 Different kinds of annotations are supported, given by the ``source`` keyword:
317 * ``direct`` : bounding boxes are directly specified by keyword arguments ``topleft`` and ``bottomright``
318 * ``eyes`` : the left and right eyes are specified by keyword arguments ``leye`` and ``reye``
319 * ``left-profile`` : the left eye and the mouth are specified by keyword arguments ``eye`` and ``mouth``
320 * ``right-profile`` : the right eye and the mouth are specified by keyword arguments ``eye`` and ``mouth``
321 * ``ellipse`` : the face ellipse as well as face angle and axis radius is provided by keyword arguments ``center``, ``angle`` and ``axis_radius``
323 If a ``source`` is specified, the according keywords must be given as well.
324 Otherwise, the source is estimated from the given keyword parameters if possible.
326 If 'topleft' and 'bottomright' are given (i.e., the 'direct' source), they are taken as is.
327 Note that the 'bottomright' is NOT included in the bounding box.
328 Please assure that the aspect ratio of the bounding box is 6:5 (height : width).
330 For source 'ellipse', the bounding box is computed to capture the whole ellipse, even if it is rotated.
332 For other sources (i.e., 'eyes'), the center of the two given positions is computed, and the ``padding`` is applied, which is relative to the distance between the two given points.
333 If ``padding`` is ``None`` (the default) the default_paddings of this source are used instead.
334 These padding is required to keep an aspect ratio of 6:5.
336 Parameters
337 ----------
339 source : str or ``None``
340 The type of annotations present in the list of keyword arguments, see above.
342 padding : {'top':float, 'bottom':float, 'left':float, 'right':float}
343 This padding is added to the center between the given points, to define the top left and bottom right positions in the bounding box; values are relative to the distance between the two given points; ignored for some of the ``source``\\s
345 kwargs : key=value
346 Further keyword arguments specifying the annotations.
348 Returns
349 -------
351 bounding_box : :py:class:`BoundingBox`
352 The bounding box that was estimated from the given annotations.
353 """
355 if source is None:
356 # try to estimate the source
357 for s, k in available_sources.items():
358 # check if the according keyword arguments are given
359 if k[0] in kwargs and k[1] in kwargs:
360 # check if we already assigned a source before
361 if source is not None:
362 raise ValueError(
363 "The given list of keywords (%s) is ambiguous. Please specify a source"
364 % kwargs
365 )
366 # assign source
367 source = s
369 # check if a source could be estimated from the keywords
370 if source is None:
371 raise ValueError(
372 "The given list of keywords (%s) could not be interpreted"
373 % kwargs
374 )
376 assert source in available_sources
378 # use default padding if not specified
379 if padding is None:
380 padding = default_paddings[source]
382 keys = available_sources[source]
383 if source == "ellipse":
384 # compute the tight bounding box for the ellipse
385 angle = kwargs["angle"]
386 axis = kwargs["axis_radius"]
387 center = kwargs["center"]
388 dx = abs(math.cos(angle) * axis[0]) + abs(math.sin(angle) * axis[1])
389 dy = abs(math.sin(angle) * axis[0]) + abs(math.cos(angle) * axis[1])
390 top = center[0] - dy
391 bottom = center[0] + dy
392 left = center[1] - dx
393 right = center[1] + dx
394 elif padding is None:
395 # There is no padding to be applied -> take nodes as they are
396 top = kwargs[keys[0]][0]
397 bottom = kwargs[keys[1]][0]
398 left = kwargs[keys[0]][1]
399 right = kwargs[keys[1]][1]
400 else:
401 # apply padding
402 pos_0 = kwargs[keys[0]]
403 pos_1 = kwargs[keys[1]]
404 tb_center = float(pos_0[0] + pos_1[0]) / 2.0
405 lr_center = float(pos_0[1] + pos_1[1]) / 2.0
406 distance = math.sqrt(
407 (pos_0[0] - pos_1[0]) ** 2 + (pos_0[1] - pos_1[1]) ** 2
408 )
410 top = tb_center + padding["top"] * distance
411 bottom = tb_center + padding["bottom"] * distance
412 left = lr_center + padding["left"] * distance
413 right = lr_center + padding["right"] * distance
415 return BoundingBox((top, left), (bottom - top, right - left))
418def expected_eye_positions(bounding_box, padding=None):
419 """expected_eye_positions(bounding_box, padding) -> eyes
421 Computes the expected eye positions based on the relative coordinates of the bounding box.
423 This function can be used to translate between bounding-box-based image cropping and eye-location-based alignment.
424 The returned eye locations return the **average** eye locations, no landmark detection is performed.
426 **Parameters:**
428 ``bounding_box`` : :py:class:`BoundingBox`
429 The face bounding box.
431 ``padding`` : {'top':float, 'bottom':float, 'left':float, 'right':float}
432 The padding that was used for the ``eyes`` source in :py:func:`bounding_box_from_annotation`, has a proper default.
434 **Returns:**
436 ``eyes`` : {'reye' : (rey, rex), 'leye' : (ley, lex)}
437 A dictionary containing the average left and right eye annotation.
438 """
439 if padding is None:
440 padding = default_paddings["eyes"]
441 top, left, right = padding["top"], padding["left"], padding["right"]
442 inter_eye_distance = (bounding_box.size[1]) / (right - left)
443 return {
444 "reye": (
445 bounding_box.top_f - top * inter_eye_distance,
446 bounding_box.left_f - left / 2.0 * inter_eye_distance,
447 ),
448 "leye": (
449 bounding_box.top_f - top * inter_eye_distance,
450 bounding_box.right_f - right / 2.0 * inter_eye_distance,
451 ),
452 }
455def bounding_box_to_annotations(bbx):
456 """Converts :any:`BoundingBox` to dictionary annotations.
458 Parameters
459 ----------
460 bbx : :any:`BoundingBox`
461 The given bounding box.
463 Returns
464 -------
465 dict
466 A dictionary with topleft and bottomright keys.
467 """
468 landmarks = {
469 "topleft": bbx.topleft,
470 "bottomright": bbx.bottomright,
471 }
472 return landmarks
475def min_face_size_validator(annotations, min_face_size=(32, 32)):
476 """Validates annotations based on face's minimal size.
478 Parameters
479 ----------
480 annotations : dict
481 The annotations in dictionary format.
482 min_face_size : (:obj:`int`, :obj:`int`), optional
483 The minimal size of a face.
485 Returns
486 -------
487 bool
488 True, if the face is large enough.
489 """
490 if not annotations:
491 return False
492 for source in ("direct", "eyes", None):
493 try:
494 bbx = bounding_box_from_annotation(source=source, **annotations)
495 break
496 except Exception:
497 if source is None:
498 raise
499 else:
500 pass
501 if bbx.size[0] < min_face_size[0] or bbx.size[1] < min_face_size[1]:
502 return False
503 return True