Coverage for src/bob/bio/face/annotator/utils.py: 79%

173 statements  

« prev     ^ index     » next       coverage.py v7.6.0, created at 2024-07-18 22:01 +0200

1import math 

2 

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} 

10 

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} 

25 

26 

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)) 

32 

33 

34class BoundingBox: 

35 """A bounding box class storing top, left, height and width of an rectangle.""" 

36 

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 

50 

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) 

59 

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 

64 

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) 

69 

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 

74 

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) 

79 

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] 

84 

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) 

89 

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] 

94 

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) 

99 

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] 

104 

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) 

109 

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] 

114 

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) 

119 

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 

124 

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) 

129 

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 

134 

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) 

139 

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) 

144 

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) 

149 

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 

156 

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 

161 

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 ) 

177 

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 ) 

195 

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) 

208 

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 ) 

238 

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 ) 

266 

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 ) 

281 

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) 

298 

299 # no overlap? 

300 if max_left >= min_right or max_top >= min_bottom: 

301 return 0.0 

302 

303 # compute overlap 

304 intersection = (min_bottom - max_top) * (min_right - max_left) 

305 return intersection / (self.area + other.area - intersection) 

306 

307 def __eq__(self, other: object) -> bool: 

308 return self.topleft_f == other.topleft_f and self.size_f == other.size_f 

309 

310 

311def bounding_box_from_annotation(source=None, padding=None, **kwargs): 

312 """bounding_box_from_annotation(source, padding, **kwargs) -> bounding_box 

313 

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: 

316 

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`` 

322 

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. 

325 

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). 

329 

330 For source 'ellipse', the bounding box is computed to capture the whole ellipse, even if it is rotated. 

331 

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. 

335 

336 Parameters 

337 ---------- 

338 

339 source : str or ``None`` 

340 The type of annotations present in the list of keyword arguments, see above. 

341 

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 

344 

345 kwargs : key=value 

346 Further keyword arguments specifying the annotations. 

347 

348 Returns 

349 ------- 

350 

351 bounding_box : :py:class:`BoundingBox` 

352 The bounding box that was estimated from the given annotations. 

353 """ 

354 

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 

368 

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 ) 

375 

376 assert source in available_sources 

377 

378 # use default padding if not specified 

379 if padding is None: 

380 padding = default_paddings[source] 

381 

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 ) 

409 

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 

414 

415 return BoundingBox((top, left), (bottom - top, right - left)) 

416 

417 

418def expected_eye_positions(bounding_box, padding=None): 

419 """expected_eye_positions(bounding_box, padding) -> eyes 

420 

421 Computes the expected eye positions based on the relative coordinates of the bounding box. 

422 

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. 

425 

426 **Parameters:** 

427 

428 ``bounding_box`` : :py:class:`BoundingBox` 

429 The face bounding box. 

430 

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. 

433 

434 **Returns:** 

435 

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 } 

453 

454 

455def bounding_box_to_annotations(bbx): 

456 """Converts :any:`BoundingBox` to dictionary annotations. 

457 

458 Parameters 

459 ---------- 

460 bbx : :any:`BoundingBox` 

461 The given bounding box. 

462 

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 

473 

474 

475def min_face_size_validator(annotations, min_face_size=(32, 32)): 

476 """Validates annotations based on face's minimal size. 

477 

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. 

484 

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