Coverage for /home/ubuntu/cv-depot/python/cv_depot/ops/edit.py: 82%

263 statements  

« prev     ^ index     » next       coverage.py v7.8.0, created at 2025-04-08 20:26 +0000

1from typing import Any # noqa F401 

2from cv_depot.core.types import AnyAnchor, AnyColor, OptArray # noqa F401 

3 

4import math 

5 

6from lunchbox.enforce import Enforce 

7import cv2 

8import numpy as np 

9 

10from cv_depot.core.color import BasicColor 

11from cv_depot.core.enum import Anchor 

12from cv_depot.core.image import BitDepth, Image 

13import cv_depot.core.enforce as enf 

14import cv_depot.ops.draw as cvdraw 

15# ------------------------------------------------------------------------------ 

16 

17 

18def reformat(image, width, height): 

19 # type: (Image, float, float) -> Image 

20 ''' 

21 Reformat given image by given width and height factors. 

22 

23 Args: 

24 image (Image): Image instance. 

25 width (float): Factor to scale image width by. 

26 height (float): Factor to scale image height by. 

27 

28 Raises: 

29 EnforceError: If image is not an Image instance. 

30 ValueError: If width or height of reformatted image is less than 1 pixel. 

31 

32 Returns: 

33 Image: Reformatted image. 

34 ''' 

35 Enforce(image, 'instance of', Image) 

36 # -------------------------------------------------------------------------- 

37 

38 source_bit_depth = image.bit_depth 

39 image = image.to_bit_depth(BitDepth.FLOAT32) 

40 

41 x = round(image.width * width) 

42 y = round(image.height * height) 

43 if x < 1 or y < 1: 

44 msg = 'Invalid scale factors. Width and height must be at least 1 pixel' 

45 msg += f'. Format shape in pixels: ({width}, {height}).' 

46 raise ValueError(msg) 

47 

48 tmp = cv2.resize(image.data, (x, y)) 

49 output = Image.from_array(tmp).to_bit_depth(source_bit_depth) 

50 return output 

51 

52 

53def crop(image, width_mult, height_mult, width_offset=0, height_offset=0): 

54 # type: (Image, float, float, int, int) -> Image 

55 ''' 

56 Crop a given image according to a width and height multipliers and offsets. 

57 

58 Args: 

59 image (Image): Image to be cropped. 

60 width_mult (float): Width multiplier. 

61 height_mult (float): Height multiplier. 

62 width_offset (float): Width offset. 

63 height_offset (float): Height offset. 

64 

65 Raises: 

66 EnforeError: If image is not an instance of Image. 

67 EnforeError: If width_mult is not > 0 and <= 1. 

68 EnforeError: If height_mult is not > 0 and <= 1. 

69 EnforceError: If crop dimensions are 0 in width or height. 

70 EnforceError: If crop width bounds are outside image dimensions. 

71 EnforceError: If crop height bounds are outside image dimensions. 

72 

73 Returns: 

74 Image: Cropped image. 

75 ''' 

76 Enforce(image, 'instance of', Image) 

77 Enforce(width_mult, '>', 0) 

78 Enforce(width_mult, '<=', 1) 

79 Enforce(height_mult, '>', 0) 

80 Enforce(height_mult, '<=', 1) 

81 # -------------------------------------------------------------------------- 

82 

83 height_offset *= -1 

84 

85 w, h = image.width_and_height 

86 cw = w * 0.5 

87 ch = h * 0.5 

88 wb = cw * width_mult 

89 hb = ch * height_mult 

90 

91 w0 = int((cw - wb) + width_offset) 

92 w1 = int(cw + wb + width_offset) 

93 h0 = int((ch - hb) + height_offset) 

94 h1 = int(ch + hb + height_offset) 

95 

96 cw = int(cw) 

97 ch = int(ch) 

98 

99 # ensure crop bounds are within image dimensions 

100 dw = int(w * width_mult) 

101 dh = int(h * height_mult) 

102 msg = 'Crop width and height must be greater than 0. ' 

103 msg += f'Crop dimensions: {dw}, {dh}' 

104 Enforce(w * width_mult, '>=', 1, message=msg) 

105 Enforce(h * height_mult, '>=', 1, message=msg) 

106 

107 msg = f'Crop width bounds: ({w0 - cw}, {w1 - cw}) outside of ' 

108 msg += f'image width bounds: ({-cw}, {cw})' 

109 Enforce(w0, '>=', 0, message=msg) 

110 Enforce(w1, '>=', 0, message=msg) 

111 Enforce(w0, '<=', w, message=msg) 

112 Enforce(w1, '<=', w, message=msg) 

113 

114 msg = f'Crop height bounds: ({h0 - ch}, {h1 - ch}) outside of ' 

115 msg += f'image height bounds: ({-ch}, {ch})' 

116 Enforce(h0, '>=', 0, message=msg) 

117 Enforce(h1, '>=', 0, message=msg) 

118 Enforce(h0, '<=', h, message=msg) 

119 Enforce(h1, '<=', h, message=msg) 

120 

121 return image[w0:w1, h0:h1] 

122 

123 

124def pad(image, shape, anchor=Anchor.TOP_LEFT, color=BasicColor.BLACK.name): 

125 # type: (Image, tuple[int, int, int], AnyAnchor, AnyColor) -> Image 

126 ''' 

127 Pads a given image into a new image of a given shape. 

128 

129 The anchor argument determines which corner of the given image will be 

130 anchored to the padded images respective corner. For instance, an anchor of 

131 'top-left' will anchor the top-left corner of the given image to the 

132 top-left corner of the padded image. 'center-left' will vertically center 

133 the image and horizontally anchor to the left of the image. 'center-center' 

134 will vertically and horizontally center the image. 

135 

136 Args: 

137 image (Image): Image to be padded. 

138 shape (tuple[int]): Shape (width, height, channels) of padded image. 

139 anchor (Anchor or str, optional): Where the given image will be placed within the 

140 new image. Default: top-left. 

141 color (Color or BasicColor, optional): Padding color. 

142 Default: BasicColor.BLACK 

143 

144 Returns: 

145 Image: Padded image. 

146 ''' 

147 if len(shape) != 3: 

148 msg = f'Shape must be of length 3. Given shape: {shape}.' 

149 raise ValueError(msg) 

150 

151 w, h, c = image.shape 

152 dw = shape[0] - w 

153 dh = shape[1] - h 

154 c = max(c, shape[2]) 

155 

156 if dw < 0 or dh < 0: 

157 msg = 'Output shape must be greater than or equal to input shape in each' 

158 msg += f' dimension. {shape} !>= {image.shape}.' 

159 raise ValueError(msg) 

160 # -------------------------------------------------------------------------- 

161 

162 # resolve anchor 

163 if isinstance(anchor, str): 

164 dir_h, dir_w = Anchor.from_string(anchor).value 

165 else: 

166 dir_h, dir_w = anchor.value 

167 

168 lut = dict( 

169 center='center', top='below', bottom='above', left='right', right='left' 

170 ) 

171 dir_h = lut[dir_h] 

172 dir_w = lut[dir_w] 

173 # -------------------------------------------------------------------------- 

174 

175 bit_depth = image.bit_depth 

176 output = image.to_bit_depth(BitDepth.FLOAT32) 

177 if dh > 0: 

178 if dir_h != 'center': 

179 pad_h = cvdraw.swatch((output.width, dh, c), color) 

180 output = staple(output, pad_h, direction=dir_h) 

181 

182 else: 

183 ph0 = (output.width, math.ceil(dh / 2), c) 

184 ph1 = (output.width, math.floor(dh / 2), c) 

185 

186 pad_h0 = cvdraw.swatch(ph0, color) 

187 output = staple(output, pad_h0, direction='above') 

188 

189 if ph1[1] > 0: 

190 pad_h1 = cvdraw.swatch(ph1, color) 

191 output = staple(output, pad_h1, direction='below') 

192 

193 if dw > 0: 

194 if dir_w != 'center': 

195 pad_w = cvdraw.swatch((dw, output.height, c), color) 

196 output = staple(output, pad_w, direction=dir_w) 

197 

198 else: 

199 pw0 = (math.ceil(dw / 2), output.height, c) 

200 pw1 = (math.floor(dw / 2), output.height, c) 

201 

202 pad_w0 = cvdraw.swatch(pw0, color) 

203 output = staple(output, pad_w0, direction='left') 

204 

205 if pw1[0] > 0: 

206 pad_w1 = cvdraw.swatch(pw1, color) 

207 output = staple(output, pad_w1, direction='right') 

208 

209 output = output.to_bit_depth(bit_depth) 

210 return output 

211 

212 

213def staple(image_a, image_b, direction='right', fill_value=0.0): 

214 # type: (Image, Image, str, float) -> Image 

215 ''' 

216 Joins two images along a given direction. 

217 

218 .. image:: images/staple.png 

219 

220 Images must be the same height if stapling along left/right axis. 

221 Images must be the same width if stapling along above/below axis. 

222 

223 Args: 

224 image_a (Image): Image A. 

225 image_b (Image): Image B. 

226 direction (str, optional): Where image b will be placed relative to a. 

227 Options include: left, right, above, below. Default: right. 

228 fill_value (float, optional): Value to fill additional channels with. 

229 Default: 0. 

230 

231 Raises: 

232 ValueError: If illegal direction given. 

233 ValueError: If direction is left/right and image heights are not 

234 equal. 

235 ValueError: If direction is above/below and image widths are not 

236 equal. 

237 

238 Returns: 

239 Image: Stapled Image. 

240 ''' 

241 direction = direction.lower() 

242 dirs = ['left', 'right', 'above', 'below'] 

243 if direction not in dirs: 

244 msg = f'Illegal direction: {direction}. Legal directions: {dirs}.' 

245 raise ValueError(msg) 

246 

247 if direction in ['left', 'right']: 

248 h0 = image_a.height 

249 h1 = image_b.height 

250 if h0 != h1: 

251 msg = f'Image heights must be equal. {h0} != {h1}.' 

252 raise ValueError(msg) 

253 

254 elif direction in ['above', 'below']: 

255 w0 = image_a.width 

256 w1 = image_b.width 

257 if w0 != w1: 

258 msg = f'Image widths must be equal. {w0} != {w1}.' 

259 raise ValueError(msg) 

260 

261 # pad images so number of channels are equal 

262 a = image_a.data 

263 b = image_b.data 

264 

265 # needed for one channel images which make (h, w) arrays 

266 if len(a.shape) < 3: 

267 a = np.expand_dims(a, axis=2) 

268 if len(b.shape) < 3: 

269 b = np.expand_dims(b, axis=2) 

270 

271 ca = image_a.num_channels 

272 cb = image_b.num_channels 

273 if ca != cb: 

274 w, h, _ = image_a.shape 

275 c = abs(ca - cb) 

276 pad = np.ones((h, w, c), dtype=np.float32) * fill_value 

277 if ca > cb: 

278 b = np.concatenate([b, pad], axis=2) 

279 else: 

280 a = np.concatenate([a, pad], axis=2) 

281 

282 if direction == 'above': 

283 data = np.append(b, a, axis=0) 

284 elif direction == 'below': 

285 data = np.append(a, b, axis=0) 

286 elif direction == 'left': 

287 data = np.append(b, a, axis=1) 

288 elif direction == 'right': 

289 data = np.append(a, b, axis=1) 

290 

291 return Image.from_array(data) 

292 

293 

294def cut(image, indices, axis='vertical'): 

295 # (Image, Union[int, list[int]], str) -> Image 

296 ''' 

297 Splits a given image into two images along a vertical or horizontal axis. 

298 

299 .. image:: images/cut.png 

300 

301 Args: 

302 image (Image): Image to be cut. 

303 indices (int or list[int]): Indices of where to cut along cross-axis. 

304 axis (str, optional): Axis to cut along. 

305 Options include: vertical, horizontal. Default: vertical. 

306 

307 Raises: 

308 EnforceError: If image is not an Image instance. 

309 EnforceError: If indices is not an int or list of ints. 

310 EnforceError: If illegal axis is given. 

311 IndexError: If indices contains index that is outside of bounds. 

312 

313 Returns: 

314 tuple[Image]: Two Image instances. 

315 ''' 

316 Enforce(image, 'instance of', Image) 

317 

318 axis = axis.lower() 

319 msg = 'Illegal axis: {a}. Legal axes include: {b}.' 

320 Enforce(axis, 'in', ['vertical', 'horizontal'], message=msg) 

321 

322 # create indices 

323 if isinstance(indices, int): 

324 indices = [indices] 

325 

326 Enforce(indices, 'instance of', list) 

327 enf.enforce_homogenous_type(indices) 

328 Enforce(indices[0], 'instance of', int) # tyep: ignore 

329 

330 indices.append(0) 

331 max_ = image.width 

332 if axis == 'horizontal': 

333 max_ = image.height 

334 indices.append(max_) 

335 indices = sorted(list(set(indices))) 

336 

337 max_i = max(indices) 

338 if max_i > max_: 

339 msg = f'Index out of bounds. {max_i} > {max_}.' 

340 raise IndexError(msg) 

341 

342 min_i = min(indices) 

343 if min_i < 0: 

344 msg = f'Index out of bounds. {min_i} < 0.' 

345 raise IndexError(msg) 

346 # -------------------------------------------------------------------------- 

347 

348 output = [] 

349 for j, i in enumerate(indices): 

350 if j == 0: 

351 continue 

352 prev = indices[j - 1] 

353 if axis == 'vertical': 

354 img = image[prev:i, :, :] 

355 else: 

356 img = image[:, prev:i, :] 

357 output.append(img) 

358 return output 

359 

360 

361def chop(image, channel='a', mode='vertical-horizontal'): 

362 # type: (Image, str, str) -> dict[tuple[int, int], Image] 

363 ''' 

364 Chops up a given image into smaller images that bound single contiguous 

365 objects within a given channel. 

366 

367 .. image:: images/chop_example.png 

368 

369 Chop has the following modes: 

370 

371 .. image:: images/chop_modes.png 

372 

373 Args: 

374 image (Image): Image instance. 

375 channel (str, optional): Channel to chop image by. Default: 'a'. 

376 mode (str, optional): The type and order of cuts to ber performed. 

377 Default: vertical-horizontal. Options include: 

378 

379 * vertical - Make only vertical cuts along the width axis. 

380 * horizontal - Make only horizontal cuts along the height axis. 

381 * vertical-horizontal - Cut along the width axis first and then 

382 the height axis of each resulting segement. 

383 * horizontal-vertical - Cut along the height axis first and then 

384 the width axis of each resulting segement. 

385 

386 Raises: 

387 EnforceError: If image is not an Image or NDArray. 

388 EnforceError: If channel is not in image channels. 

389 EnforceError: If illegal mode given. 

390 

391 Returns: 

392 dict: Dictionary of form (width, height): Image. 

393 ''' 

394 # enforce image 

395 if isinstance(image, np.ndarray): 

396 image = Image.from_array(image) 

397 Enforce(image, 'instance of', Image) 

398 

399 # enforce channel 

400 msg = '{a} is not a valid channel. Channels include: {b}.' 

401 Enforce(channel, 'in', image.channels, message=msg) 

402 

403 # enforce mode 

404 modes = [ 

405 'vertical', 'horizontal', 'vertical-horizontal', 'horizontal-vertical' 

406 ] 

407 msg = '{a} is not a legal mode. Legal modes include: {b}.' 

408 Enforce(mode, 'in', modes, message=msg) 

409 # -------------------------------------------------------------------------- 

410 

411 def get_bounding_boxes(image): 

412 # type: (Image) -> list[list[tuple[int, int]]] 

413 hgt = image.height 

414 img = image.to_bit_depth(BitDepth.UINT8).data 

415 contours = cv2.findContours( 

416 img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE 

417 ) 

418 items = contours[0] if len(contours) == 2 else contours[1] 

419 

420 output = [] 

421 for item in items: 

422 x, y, w, h = cv2.boundingRect(item) 

423 output.append([ 

424 (x, hgt - (y + h)), 

425 (x + w, hgt - y), 

426 ]) 

427 return output 

428 

429 def get_indices(image, axis): 

430 # type: (Image, str) -> list[int] 

431 # find indices of bbox edges across a single axis 

432 bboxes = get_bounding_boxes(image) 

433 indices = set() # type: Any 

434 if axis == 'vertical': 

435 for bbox in bboxes: 

436 indices.add(bbox[0][0]) 

437 indices.add(bbox[1][0]) 

438 else: 

439 for bbox in bboxes: 

440 indices.add(bbox[0][1]) 

441 indices.add(bbox[1][1]) 

442 indices = list(indices) 

443 

444 max_ = image.width if axis == 'vertical' else image.height 

445 indices = filter(lambda x: x not in [0, max_], indices) 

446 indices = sorted(indices) 

447 indices.insert(0, 0) 

448 indices.append(max_) 

449 

450 output = [] 

451 for i in indices: 

452 if i not in output: 

453 output.append(i) 

454 return output 

455 # ------------------------------------------------------------------------ 

456 

457 # get axes 

458 axes = mode.split('-') 

459 a0 = axes[0] 

460 

461 # get indices along first axis 

462 indices = get_indices(image[:, :, channel], a0) 

463 segments = cut(image, indices, axis=a0) 

464 

465 # if only one axis return segments 

466 output = {} 

467 if len(axes) == 1: 

468 if mode == 'vertical': 

469 output = {(x, 0): item for x, item in enumerate(segments)} 

470 else: 

471 yl = len(segments) - 1 

472 output = {(0, yl - y): item for y, item in enumerate(segments)} 

473 return output 

474 

475 # otherwise chop each segement according to its edge detect indices 

476 a1 = axes[1] 

477 xl = len(segments) - 1 

478 for x, segment in enumerate(segments): 

479 indices = get_indices(segment[:, :, channel], a1) 

480 images = cut(segment, indices, axis=a1) 

481 yl = len(images) - 1 

482 for y, image in enumerate(images): 

483 if a0 == 'vertical': 

484 output[(x, yl - y)] = image 

485 else: 

486 output[(y, xl - x)] = image 

487 return output 

488 

489 

490def warp( 

491 image, 

492 angle=0, 

493 translate_x=0, 

494 translate_y=0, 

495 scale=1, 

496 inverse=False, 

497 matrix=None 

498): 

499 # type: (Image, float, float, float, float, bool, OptArray) -> Image 

500 ''' 

501 Warp image by given parameters or warp matrix. 

502 If matrix is given, parameters are ignored. 

503 

504 Args: 

505 image (Image): Image. 

506 angle (float, optional): Rotation in degrees. Default: 0. 

507 translate_x (float, optional): X translation. Default: 0. 

508 translate_y (float, optional): Y translation. Default: 0. 

509 scale (float, optional): Scale factor. Default: 1. 

510 inverse (bool, optional): Inverse transformation. Default: False. 

511 matrix (numpy.ndarray, optional): Warp matrix. Default: None. 

512 

513 Raises: 

514 EnforceError: If image is not an instance of Image. 

515 EnforceError: If matrix is not None or an instance of np.ndarray. 

516 EnforceError: If matrix is not a 3 x 3 matrix. 

517 

518 Returns: 

519 Image: Warped image. 

520 ''' 

521 Enforce(image, 'instance of', Image) 

522 

523 if matrix is None: 

524 matrix = get_warp_matrix(angle, translate_x, translate_y, scale) 

525 Enforce(matrix, 'instance of', np.ndarray) 

526 Enforce(matrix.shape, '==', (3, 3)) 

527 # -------------------------------------------------------------------------- 

528 

529 # convert to float 

530 bit_depth = image.bit_depth 

531 channels = image.channels 

532 array = image.to_bit_depth(BitDepth.FLOAT32).data 

533 flag = cv2.WARP_INVERSE_MAP if inverse else 0 

534 array = cv2.warpPerspective( 

535 array, matrix, dsize=image.width_and_height, flags=flag 

536 ) 

537 

538 # convert to original bit depth 

539 output = Image.from_array(array).to_bit_depth(bit_depth) 

540 

541 # set original channel names 

542 output = output.set_channels(channels) 

543 return output 

544 

545 

546def get_warp_matrix(angle=0, translate_x=0, translate_y=0, scale=1): 

547 # type: (float, float, float, float) -> np.ndarray 

548 ''' 

549 Create 3 x 3 warp matrix. 

550 

551 Args: 

552 angle (float, optional): Rotation in degrees. Default: 0. 

553 translate_x (float, optional): X translation. Default: 0. 

554 translate_y (float, optional): Y translation. Default: 0. 

555 scale (float, optional): Scale factor. Default: 1. 

556 

557 Returns: 

558 ndarray: 3 x 3 warp matrix. 

559 ''' 

560 t = np.radians(angle) 

561 x = translate_x 

562 y = translate_y 

563 s = scale 

564 return np.array([ 

565 [math.cos(t) * s, -math.sin(t) * s, x], 

566 [math.sin(t) * s, math.cos(t) * s, y], 

567 [0, 0, 1] 

568 ])