numpyimage 3.2.0__tar.gz → 3.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: numpyimage
3
- Version: 3.2.0
3
+ Version: 3.3.0
4
4
  Summary: Load, save, & manipulate image files as numpy arrays
5
5
  Author-email: Jasper Phelps <jasper.s.phelps@gmail.com>
6
6
  License: MIT License
@@ -239,6 +239,7 @@ def save(data,
239
239
  try:
240
240
  iter(pixel_size)
241
241
  if len(pixel_size) == data.ndim - 1 and channel_axis is not None:
242
+ pixel_size = list(pixel_size)
242
243
  pixel_size.insert(channel_axis, np.nan)
243
244
  except TypeError:
244
245
  if channel_axis is not None:
@@ -12,11 +12,11 @@ Function list:
12
12
  """
13
13
 
14
14
  from typing import Iterable, Literal, Union, Optional, Tuple, List
15
- import gc
16
15
 
17
16
  import numpy as np
17
+ from tqdm import tqdm
18
18
 
19
- from .utils import iround, eq, isint
19
+ from .utils import iround, eq, isint, find_channel_axis
20
20
 
21
21
 
22
22
  def squeeze_dtype(image: np.ndarray, minimum_bits=1):
@@ -233,29 +233,50 @@ def downsample(image: np.ndarray,
233
233
  image : np.ndarray
234
234
  The image to downsample.
235
235
 
236
- factor : int or iterable of ints
236
+ factor : int or iterable of ints, default 2
237
237
  An iterable with length matching the number of axes in the image,
238
238
  specifying a downsampling factor along each axis.
239
239
  If factor is provided as an int, that int will be used for each axis.
240
- If the image is rgb or rgba (that is, the final axis has length 3 or
241
- 4), it is not necessary to specify a factor for that axis and so the
242
- 'factor' iterable can be one element shorter than the number of axes in
243
- the image.
240
+ If the image has a channel axis (RGB/RGBA), it is not necessary to
241
+ specify a factor for that axis and so the 'factor' iterable can be
242
+ one element shorter than the number of axes in the image.
244
243
 
245
- keep_input_dtype : bool
244
+ keep_input_dtype : bool, default True
246
245
  If True, the output image will have the same dtype as the input image.
247
246
  If False, the output image will have dtype float64 to keep full precision.
247
+
248
+ Returns
249
+ -------
250
+ np.ndarray
251
+ The downsampled image.
252
+
253
+ Examples
254
+ --------
255
+ Downsample a shape (2, 4) array using default settings of factor=2, keep_input_dtype=True
256
+
257
+ >>> downsample(np.array([[1, 2, 3, 4],
258
+ ... [5, 6, 7, 8]]))
259
+ array([[4, 6]])
260
+
261
+ Note that the output dtype is kept as int, which loses some precision. Compare to:
262
+
263
+ >>> downsample(np.array([[1, 2, 3, 4],
264
+ ... [5, 6, 7, 8]]),
265
+ ... keep_input_dtype=False)
266
+ array([[3.5, 5.5]])
248
267
  """
268
+ channel_axis = find_channel_axis(image)
249
269
  if isinstance(factor, int):
250
- if image.shape[-1] in [3, 4]:
270
+ if channel_axis is not None:
251
271
  # If RGB/RGBA image, don't downsample the colors axis
252
272
  factor = (factor,) * (len(image.shape) - 1)
253
273
  else:
254
274
  factor = (factor,) * len(image.shape)
255
- if len(factor) == len(image.shape) - 1 and image.shape[-1] in [3, 4]:
275
+ if len(factor) == len(image.shape) - 1 and channel_axis is not None:
256
276
  if verbose:
257
- print('RGB/RGBA image detected - not downsampling last axis.')
258
- factor = (*factor, 1)
277
+ print('RGB/RGBA image detected - not downsampling channel axis.')
278
+ factor = list(factor)
279
+ factor.insert(channel_axis, 1)
259
280
  if any([f > l > 1 for f, l in zip(factor, image.shape)]):
260
281
  raise ValueError('Downsampling factor must be <= image size along each axis')
261
282
 
@@ -306,10 +327,10 @@ def offset(image: np.ndarray,
306
327
  distance: Union[float, Iterable[float]],
307
328
  axis: int = None,
308
329
  expand_bounds: bool = False,
309
- edge_mode: Literal['extend', 'wrap',
310
- 'reflect', 'constant'] = 'extend',
311
- edge_fill_value=0,
312
- fill_transparent=False) -> np.ndarray:
330
+ fill_empty_with: float = 0,
331
+ keep_input_dtype: bool = True,
332
+ fill_transparent: bool = False,
333
+ verbose: bool = False) -> np.ndarray:
313
334
  """
314
335
  Offset an image by a given distance.
315
336
 
@@ -321,7 +342,7 @@ def offset(image: np.ndarray,
321
342
 
322
343
  If edge_mode is set to 'constant', the pixels no longer occupied by the
323
344
  original image as a result of the offset will be filled in with
324
- 'edge_fill_value'.
345
+ 'fill_empty_value'.
325
346
 
326
347
  See also scipy.ndimage.shift, which performs a very similar operation
327
348
  """
@@ -333,19 +354,21 @@ def offset(image: np.ndarray,
333
354
  ' did a mix of those two.')
334
355
  except TypeError:
335
356
  distance_iter = [0] * len(image.shape)
357
+ if len(distance_iter) == 1 and axis is None:
358
+ axis = 0
336
359
  if axis is None:
337
360
  raise ValueError('Must specify axis when giving distance as a'
338
361
  ' single number.')
339
362
  distance_iter[axis] = distance
340
363
  distance = distance_iter
341
- if edge_mode not in ['extend', 'wrap', 'reflect', 'constant']:
342
- raise ValueError("edge_mode must be one of 'extend', 'wrap', 'reflect', or 'constant'")
343
- if edge_mode in ['wrap', 'reflect']:
344
- raise NotImplementedError("edge_mode '{}' not yet implemented".format(edge_mode))
345
364
 
346
- if len(image.shape) == len(distance) + 1 and image.shape[-1] in [1, 3, 4]:
347
- # Specify no offset along the channels axis, if not specified by user
348
- distance = (*distance, 0)
365
+ if len(image.shape) == len(distance) + 1:
366
+ channel_axis = find_channel_axis(image)
367
+ if channel_axis is not None:
368
+ # Specify no offset along the channels axis, if not specified by user
369
+ distance = list(distance)
370
+ distance.insert(channel_axis, 0)
371
+ distance = tuple(distance)
349
372
 
350
373
  if len(image.shape) != len(distance):
351
374
  m = (f'distance must have length {len(image.shape)} to specify an'
@@ -353,29 +376,41 @@ def offset(image: np.ndarray,
353
376
  f' {len(distance)}')
354
377
  raise ValueError(m)
355
378
 
356
- if not expand_bounds:
357
- new_image = np.full_like(image, edge_fill_value)
358
- else:
359
- new_shape = np.array(image.shape) + np.array([int(max(0, d)) for d in distance])
360
- new_image = np.full(new_shape, edge_fill_value, dtype=image.dtype)
361
-
362
- if image.shape[-1] == 4 and not fill_transparent:
363
- # If rgba, set alpha channel value to max
364
- # The line below means new_image[:, :, :, ..., :, -1] = 255
365
- new_image[tuple([slice(None, None)] * (len(image.shape)-1) + [-1])] = 255
366
-
367
- distance_int = [int(x) for x in distance]
368
-
369
- source_range = [slice(max(0, -d), min(s, s-d)) for d, s in zip(distance_int, new_image.shape)]
370
- target_range = [slice(max(0, d), min(s, s+d)) for d, s in zip(distance_int, new_image.shape)]
379
+ new_dtype = (image.dtype if keep_input_dtype or all(eq(d, int(d)) for d in distance)
380
+ else np.float64)
381
+ distance_int = [int(d) for d in distance]
382
+ new_shape = (image.shape if not expand_bounds else
383
+ np.array(image.shape) + np.array([int(max(0, d)) for d in distance]))
384
+ new_image = np.full(new_shape, fill_empty_with, dtype=new_dtype)
385
+
386
+ if fill_transparent:
387
+ raise NotImplementedError('fill_transparent not yet implemented')
388
+ # Previous implementation of fill_transparent:
389
+ #if image.shape[-1] == 4 and not fill_transparent:
390
+ # # If rgba, set alpha channel value to max
391
+ # # The line below means new_image[:, :, :, ..., :, -1] = 255
392
+ # new_image[tuple([slice(None, None)] * (len(image.shape)-1) + [-1])] = 255
393
+
394
+ source_range = [slice(max(0, -d), min(s, s-d))
395
+ for d, s in zip(distance_int, new_image.shape)]
396
+ target_range = [slice(max(0, d), min(s, s+d))
397
+ for d, s in zip(distance_int, new_image.shape)]
371
398
 
399
+ if verbose:
400
+ print('Performing integer offset...')
401
+ # This is the line that does the main operation
372
402
  new_image[tuple(target_range)] = image[tuple(source_range)]
373
403
 
374
404
  for i, d in enumerate(distance):
405
+ progress_msg = f'Performing subpixel offset along axis {i}' if verbose else None
375
406
  if not eq(d, int(d)):
376
407
  _offset_subpixel(new_image, d - int(d), i,
377
- edge_fill_value=edge_fill_value,
378
- fill_transparent=fill_transparent, inplace=True)
408
+ edge_mode='constant',
409
+ constant_edge_value=fill_empty_with,
410
+ keep_input_dtype=True,
411
+ fill_transparent=fill_transparent,
412
+ inplace=True,
413
+ progress_msg=progress_msg)
379
414
 
380
415
  return new_image
381
416
 
@@ -385,9 +420,11 @@ def _offset_subpixel(image: np.ndarray,
385
420
  axis: int,
386
421
  edge_mode: Literal['extend', 'wrap',
387
422
  'reflect', 'constant'] = 'extend',
388
- edge_fill_value=0,
389
- fill_transparent=False,
390
- inplace=False):
423
+ constant_edge_value: Optional[float] = None,
424
+ keep_input_dtype: bool = True,
425
+ fill_transparent: bool = False,
426
+ inplace: bool = False,
427
+ progress_msg: Optional[str] = None):
391
428
  """
392
429
  Offset an image by a fraction of a pixel along a single specified axis.
393
430
 
@@ -402,61 +439,80 @@ def _offset_subpixel(image: np.ndarray,
402
439
  The pixels no longer occupied by the original image as a result of the
403
440
  offset will be filled in with 'edge_fill_value'.
404
441
  """
405
- assert -1 < distance and distance < 1
406
-
407
- one_pix_offset = [0] * len(image.shape)
408
- one_pix_offset[axis] = 1 if distance >= 0 else -1
409
- image_1pix_shifted = offset(image, one_pix_offset,
410
- edge_fill_value=edge_fill_value,
411
- fill_transparent=fill_transparent)
412
- distance = abs(distance)
413
-
414
- if np.issubdtype(image.dtype, np.integer):
415
- # If the input array is an integer type, we should avoid creating a
416
- # float version of the array during calculations, because float arrays
417
- # can take up an unacceptable amount of memory. (e.g. If I write lazy
418
- # code that ends up multiplying a uint8 array by a fractional value
419
- # like 0.5, a float64 array is created which takes up 8x the amount of
420
- # memory as the source array. And we need to make two of these!).
421
- # Instead, we will do a trick of increasing the bit-depth of the source
422
- # array by 1 byte (or a few bytes, since numpy only works with bit
423
- # depths that are a power of 2, e.g. uint24 isn't a thing), use that
424
- # additional range to keep some accuracy during the weighted average
425
- # calculation, then cast back to the original dtype.
426
-
427
- upcast_dtype = np.dtype(f'{image.dtype.kind}{image.dtype.itemsize * 2}')
428
- image_upcast = image.astype(upcast_dtype)
429
- image_1pix_shifted_upcast = image_1pix_shifted.astype(upcast_dtype)
430
-
431
- # We'll use the extra bit of precision to enable us to use integer
432
- # weights from 0 to 255 instead of float weights from 0.0 to 1.0
433
- image_weight = int(256 * (1 - distance))
434
- image_1pix_shifted_weight = int(256 * distance)
435
- # Adding 127 before dividing by 256 means we round to the nearest
436
- # integer instead of truncating (which we'd get without the 127)
437
- image_subpix_shifted = (
438
- (image_upcast * image_weight)
439
- + (image_1pix_shifted_upcast * image_1pix_shifted_weight)
440
- + 127
441
- ) // 256
442
- del image_upcast, image_1pix_shifted_upcast, image_1pix_shifted
443
- gc.collect()
444
-
445
- # Now cast back to the original dtype
446
- image_subpix_shifted = image_subpix_shifted.astype(image.dtype)
447
-
448
- elif np.issubdtype(image.dtype, np.floating):
449
- image_subpix_shifted = image * (1 - distance) + image_1pix_shifted * distance
450
-
451
- if inplace:
452
- image[:] = image_subpix_shifted
453
- else:
454
- return image_subpix_shifted
442
+ if inplace and not keep_input_dtype:
443
+ raise ValueError("inplace=True doesn't make sense with keep_input_dtype=False")
444
+ if distance < -1 or distance > 1:
445
+ raise ValueError('subpixel offset distance must be between -1 and 1')
446
+ if abs(distance) < 1e-6:
447
+ return image if inplace else image.copy()
448
+ if edge_mode not in ['extend', 'wrap', 'reflect', 'constant']:
449
+ raise ValueError('edge_mode must be one of "extend", "wrap",'
450
+ ' "reflect", or "constant"')
451
+ if fill_transparent:
452
+ raise NotImplementedError('fill_transparent not yet implemented')
453
+
454
+ if not inplace:
455
+ if keep_input_dtype:
456
+ image = image.copy()
457
+ else:
458
+ image = image.astype('float64')
459
+
460
+ abs_distance = abs(distance)
461
+ sign = 1 if distance > 0 else -1
462
+ axis_size = image.shape[axis]
463
+ slicer: List[Union[slice, int]] = [slice(None)] * image.ndim
464
+
465
+ # Handle last slice which attempts to pull data from out of bounds
466
+ final_index = 0 if sign > 0 else -1
467
+ slicer[axis] = final_index
468
+ final_slice = tuple(slicer)
469
+
470
+ if edge_mode == 'extend':
471
+ edge_data = image[final_slice].copy()
472
+ elif edge_mode == 'wrap':
473
+ slicer[axis] -= sign
474
+ edge_data = image[tuple(slicer)].copy()
475
+ elif edge_mode == 'reflect':
476
+ slicer[axis] += sign
477
+ edge_data = image[tuple(slicer)].copy()
478
+ elif edge_mode == 'constant':
479
+ if constant_edge_value is None:
480
+ raise ValueError('constant_edge_value must be provided when'
481
+ ' edge_mode is "constant"')
482
+ edge_data = constant_edge_value
483
+
484
+ loop_range = range(axis_size - 1, 0, -1) if sign > 0 else range(0, axis_size - 1, 1)
485
+
486
+ for i in tqdm(loop_range, desc=progress_msg, disable=not bool(progress_msg)):
487
+ slicer[axis] = i
488
+ current_slice = tuple(slicer)
489
+ slicer[axis] = i - sign
490
+ adjacent_slice = tuple(slicer)
491
+
492
+ new_values = (
493
+ (1 - abs_distance) * image[current_slice]
494
+ + abs_distance * image[adjacent_slice]
495
+ )
496
+ if keep_input_dtype and np.issubdtype(image.dtype, np.integer):
497
+ new_values = iround(new_values, output_dtype=image.dtype)
498
+ image[current_slice] = new_values
499
+
500
+ image[final_slice] = (
501
+ (1 - abs_distance) * image[final_slice]
502
+ + abs_distance * edge_data
503
+ )
504
+
505
+ if not inplace:
506
+ return image
455
507
 
456
508
 
457
509
  def paste(image: np.ndarray,
458
510
  target: np.ndarray,
459
- offset: Iterable[float]):
511
+ offset: Iterable[float],
512
+ subpixel_edge_mode: Literal['extend', 'wrap',
513
+ 'reflect', 'constant'] = 'extend',
514
+ constant_edge_value: Optional[float] = None,
515
+ verbose: bool = False):
460
516
  """
461
517
  Paste an image onto another image at a given offset.
462
518
 
@@ -468,12 +524,20 @@ def paste(image: np.ndarray,
468
524
  except TypeError:
469
525
  offset = [offset] * len(image.shape)
470
526
  if len(offset) != image.ndim:
471
- raise ValueError('The length of the offset must match the number of dimensions in the image.')
527
+ raise ValueError('The length of the offset must match the number of '
528
+ 'dimensions in the image.')
472
529
  offset_int = [int(x) for x in offset]
473
530
  offset_subpixel = [x - int(x) for x in offset]
531
+ if any(not eq(o, 0) for o in offset_subpixel):
532
+ image = image.copy()
474
533
  for i, offset in enumerate(offset_subpixel):
534
+ progress_msg = f'Performing subpixel offset along axis {i}' if verbose else None
475
535
  if not eq(offset, 0):
476
- _offset_subpixel(image, offset, i, inplace=True)
536
+ _offset_subpixel(image, offset, i,
537
+ edge_mode=subpixel_edge_mode,
538
+ constant_edge_value=constant_edge_value,
539
+ inplace=True,
540
+ progress_msg=progress_msg)
477
541
 
478
542
  source_range = [slice(max(0, -o), min(s, t-o))
479
543
  for o, s, t in zip(offset_int, image.shape, target.shape)]
@@ -60,30 +60,46 @@ def isint(n):
60
60
 
61
61
  def find_channel_axis(data,
62
62
  possible_channel_axes=[-1, 0],
63
- possible_channel_lengths=[2, 3, 4]) -> Union[int, None]:
63
+ possible_channel_lengths=[2, 3, 4],
64
+ minimum_image_size=(32, 32)) -> Union[int, None]:
64
65
  """
65
66
  If the given numpy array has a shape suggesting that it has a
66
- channel (color) axis (that is, any axis with length 2 (2-color),
67
+ channel (color) axis (that is, an axis with length 2 (2-color),
67
68
  3 (RGB), or 4 (RGBA)), return the index of that axis.
68
69
 
70
+ With the default parameters, the smallest array shapes that will be
71
+ recognized as having a channel axis are (2, 32, 32) and (32, 32, 2).
72
+ Note that shapes (32, 2) and (32, 16, 2) will NOT be recognized due
73
+ to the minimum_image_size parameter. If you have arrays that look
74
+ like this and you want to recognize them as having a channel axis,
75
+ you can adjust the minimum_image_size parameter accordingly.
76
+
69
77
  Parameters
70
78
  ----------
71
79
  data : numpy.ndarray
72
80
  The numpy array to check for a channel axis.
73
81
 
74
- possible_channel_axes : int or list of int, default [0, -1]
75
- If None, any axis having length 2, 3, or 4 will be considered
76
- a channel axis.
82
+ possible_channel_axes : int or list of int, default [-1, 0]
83
+ If None, any axis having length in possible_channel_lengths will
84
+ be considered a channel axis.
77
85
  If an int, only that axis index will be checked.
78
- If a list of ints, all axes with those indices will be checked.
79
- The default value of [-1, 0] checks the last and first axes, which is
80
- almost always where a channel axis will be found.
86
+ If a list of ints, all axes with those indices will be checked,
87
+ and the first one that satisfies the other criteria will be returned.
88
+ The default value of [-1, 0] checks the last and first axes (in that
89
+ order), which is almost always where a channel axis will be found.
81
90
 
82
91
  possible_channel_lengths : int or list of int, default [2, 3, 4]
83
92
  If an int, only that length will be considered a channel axis.
84
93
  If a list of ints, an axis with any of those lengths will be considered
85
94
  a channel axis.
86
95
 
96
+ minimum_image_size : tuple of int, default (32, 32)
97
+ In addition to having an axis with a length in possible_channel_lengths,
98
+ the data must also have other axes with at least these lengths in order
99
+ to be considered to have a channel axis. This prevents small arrays
100
+ with shapes like (3, 3, 3) or (128, 3) from being misinterpreted
101
+ as having a channel axis when they are probably not intended as such.
102
+
87
103
  Returns
88
104
  -------
89
105
  int or None
@@ -99,16 +115,46 @@ def find_channel_axis(data,
99
115
  axis, so be careful not to do a test like `if find_channel_axis(data):`
100
116
  because 0 will evaluate to False even though the data has a channel axis.
101
117
  Instead write `if find_channel_axis(data) is not None:`
118
+
119
+ Examples
120
+ --------
121
+ >>> npimage.find_channel_axis(np.empty((3, 1024, 1024)))
122
+ 0
123
+ >>> npimage.find_channel_axis(np.empty((1024, 1024, 3)))
124
+ 2
125
+ >>> npimage.find_channel_axis(np.empty((1024, 3, 1024)))
126
+ None # Due to possible_channel_axes=[-1, 0] not being met
127
+ >>> npimage.find_channel_axis(np.empty((256, 128, 128)))
128
+ None # Due to possible_channel_lengths=[2, 3, 4] not being met
129
+ >>> npimage.find_channel_axis(np.empty((1024, 4)))
130
+ None # Due to minimum_image_size=(32, 32) not being met
131
+ >>> npimage.find_channel_axis(np.empty((30, 30, 4)))
132
+ None # Due to minimum_image_size=(32, 32) not being met
133
+ >>> npimage.find_channel_axis(np.empty((30, 4)), minimum_image_size=0)
134
+ 1
102
135
  """
103
136
  if isinstance(possible_channel_axes, int):
104
137
  possible_channel_axes = [possible_channel_axes]
105
138
  if possible_channel_axes is None:
106
139
  possible_channel_axes = range(data.ndim)
140
+
107
141
  if isinstance(possible_channel_lengths, int):
108
142
  possible_channel_lengths = [possible_channel_lengths]
143
+
144
+ if isinstance(minimum_image_size, int):
145
+ minimum_image_size = (minimum_image_size,)
146
+
109
147
  for axis in possible_channel_axes:
110
- if data.shape[axis] in possible_channel_lengths:
111
- return axis
148
+ if data.shape[axis] not in possible_channel_lengths:
149
+ continue
150
+ other_axis_lengths = [data.shape[i] for i in range(data.ndim) if i != axis]
151
+ if len(other_axis_lengths) < len(minimum_image_size):
152
+ continue
153
+ other_axis_lengths = sorted(other_axis_lengths, reverse=True)
154
+ other_axis_lengths = other_axis_lengths[:len(minimum_image_size)]
155
+ if any(i < j for i, j in zip(other_axis_lengths, minimum_image_size)):
156
+ continue
157
+ return axis % data.ndim
112
158
  return None
113
159
 
114
160
 
@@ -844,9 +844,10 @@ def save_video(data, filename, time_axis=0, color_axis=None, overwrite=False,
844
844
  raise ImportError('Missing optional dependency for video processing,'
845
845
  ' run `pip install av tqdm`')
846
846
 
847
- filename = Path(filename).expanduser()
847
+ filename = str(filename)
848
848
  if filename.split('.')[-1].lower() not in supported_extensions:
849
849
  filename += '.mp4'
850
+ filename = Path(filename).expanduser()
850
851
  if filename.exists() and not overwrite:
851
852
  raise FileExistsError(f'File {filename} already exists. '
852
853
  'Set overwrite=True to overwrite.')
@@ -879,7 +880,7 @@ def save_video(data, filename, time_axis=0, color_axis=None, overwrite=False,
879
880
  n_frames = data.shape[0]
880
881
  height, width = data.shape[1:]
881
882
 
882
- extension = filename.split('.')[-1].lower()
883
+ extension = filename.suffix.lower().lstrip('.')
883
884
  if extension == 'mp4':
884
885
  pad = [[0, 0], [0, 0], [0, 0]]
885
886
  if height % 2 != 0:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: numpyimage
3
- Version: 3.2.0
3
+ Version: 3.3.0
4
4
  Summary: Load, save, & manipulate image files as numpy arrays
5
5
  Author-email: Jasper Phelps <jasper.s.phelps@gmail.com>
6
6
  License: MIT License
@@ -4,7 +4,7 @@ build-backend = 'setuptools.build_meta'
4
4
 
5
5
  [project]
6
6
  name = 'numpyimage'
7
- version = '3.2.0'
7
+ version = '3.3.0'
8
8
  description = 'Load, save, & manipulate image files as numpy arrays'
9
9
  readme.file = 'README.md'
10
10
  readme.content-type = 'text/markdown'
File without changes
File without changes
File without changes
File without changes
File without changes