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
« 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
4import math
6from lunchbox.enforce import Enforce
7import cv2
8import numpy as np
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# ------------------------------------------------------------------------------
18def reformat(image, width, height):
19 # type: (Image, float, float) -> Image
20 '''
21 Reformat given image by given width and height factors.
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.
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.
32 Returns:
33 Image: Reformatted image.
34 '''
35 Enforce(image, 'instance of', Image)
36 # --------------------------------------------------------------------------
38 source_bit_depth = image.bit_depth
39 image = image.to_bit_depth(BitDepth.FLOAT32)
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)
48 tmp = cv2.resize(image.data, (x, y))
49 output = Image.from_array(tmp).to_bit_depth(source_bit_depth)
50 return output
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.
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.
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.
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 # --------------------------------------------------------------------------
83 height_offset *= -1
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
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)
96 cw = int(cw)
97 ch = int(ch)
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)
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)
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)
121 return image[w0:w1, h0:h1]
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.
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.
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
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)
151 w, h, c = image.shape
152 dw = shape[0] - w
153 dh = shape[1] - h
154 c = max(c, shape[2])
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 # --------------------------------------------------------------------------
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
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 # --------------------------------------------------------------------------
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)
182 else:
183 ph0 = (output.width, math.ceil(dh / 2), c)
184 ph1 = (output.width, math.floor(dh / 2), c)
186 pad_h0 = cvdraw.swatch(ph0, color)
187 output = staple(output, pad_h0, direction='above')
189 if ph1[1] > 0:
190 pad_h1 = cvdraw.swatch(ph1, color)
191 output = staple(output, pad_h1, direction='below')
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)
198 else:
199 pw0 = (math.ceil(dw / 2), output.height, c)
200 pw1 = (math.floor(dw / 2), output.height, c)
202 pad_w0 = cvdraw.swatch(pw0, color)
203 output = staple(output, pad_w0, direction='left')
205 if pw1[0] > 0:
206 pad_w1 = cvdraw.swatch(pw1, color)
207 output = staple(output, pad_w1, direction='right')
209 output = output.to_bit_depth(bit_depth)
210 return output
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.
218 .. image:: images/staple.png
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.
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.
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.
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)
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)
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)
261 # pad images so number of channels are equal
262 a = image_a.data
263 b = image_b.data
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)
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)
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)
291 return Image.from_array(data)
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.
299 .. image:: images/cut.png
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.
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.
313 Returns:
314 tuple[Image]: Two Image instances.
315 '''
316 Enforce(image, 'instance of', Image)
318 axis = axis.lower()
319 msg = 'Illegal axis: {a}. Legal axes include: {b}.'
320 Enforce(axis, 'in', ['vertical', 'horizontal'], message=msg)
322 # create indices
323 if isinstance(indices, int):
324 indices = [indices]
326 Enforce(indices, 'instance of', list)
327 enf.enforce_homogenous_type(indices)
328 Enforce(indices[0], 'instance of', int) # tyep: ignore
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)))
337 max_i = max(indices)
338 if max_i > max_:
339 msg = f'Index out of bounds. {max_i} > {max_}.'
340 raise IndexError(msg)
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 # --------------------------------------------------------------------------
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
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.
367 .. image:: images/chop_example.png
369 Chop has the following modes:
371 .. image:: images/chop_modes.png
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:
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.
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.
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)
399 # enforce channel
400 msg = '{a} is not a valid channel. Channels include: {b}.'
401 Enforce(channel, 'in', image.channels, message=msg)
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 # --------------------------------------------------------------------------
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]
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
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)
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_)
450 output = []
451 for i in indices:
452 if i not in output:
453 output.append(i)
454 return output
455 # ------------------------------------------------------------------------
457 # get axes
458 axes = mode.split('-')
459 a0 = axes[0]
461 # get indices along first axis
462 indices = get_indices(image[:, :, channel], a0)
463 segments = cut(image, indices, axis=a0)
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
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
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.
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.
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.
518 Returns:
519 Image: Warped image.
520 '''
521 Enforce(image, 'instance of', Image)
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 # --------------------------------------------------------------------------
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 )
538 # convert to original bit depth
539 output = Image.from_array(array).to_bit_depth(bit_depth)
541 # set original channel names
542 output = output.set_channels(channels)
543 return output
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.
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.
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 ])