numpyimage 3.1.2__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.1.2
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
@@ -1,50 +1,24 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- This package contains functions for drawing basic geometric shapes, like
4
- lines and triangles, into 3D numpy arrays (and the code almost works for nD
5
- arrays). opencv has drawline and drawtriangle functions, but those only
6
- operate on 2D image arrays. scipy.ndimage provides many other helpful,
7
- related functions for working with nD image arrays.
8
-
9
- Subpixel indexing conventions:
10
-
11
- 'corner' convention (default): An operation that tries to get or set a pixel
12
- located at (x, y, z) will actually get or set the pixel with index (floor(x),
13
- floor(y), floor(z)). Therefore the pixel at location (x, y, z) represents the
14
- cube of physical locations (a, b, c) such that a is in [x, x+1), b is in [y,
15
- y+1), and c is [z, z+1). This is called the 'corner' convention because
16
- integer locations like (5, 2, 10) point to the corner of a voxel.
17
-
18
- 'center' convention: An operation that tries to get or set a pixel located at
19
- (x, y, z) will actually get or set the pixel with index (round(x), round(y),
20
- round(z)). Therefore the pixel at location (x, y, z) represents the cube of
21
- physical locations (a, b, c) such that a is in [x-0.5, x+0.5), b is in
22
- [y-0.5, y+0.5), and c is in [z-0.5, z+0.5). This is called the 'center'
23
- convention because integer locations like (5, 2, 10) point to the middle of a
24
- voxel. This convention can be selected by passing convention='center' to any
25
- of the functions in this package (once I finish implementing it).
26
-
27
- Functions in this package use non-integer data types to preserve accuracy,
28
- only flooring or rounding (depending on the convention) during get-pixel or
29
- set-pixel operations.
30
-
31
-
32
- LIST OF MAJOR TODOS
33
-
34
- Implement 'center' convention logic for all functions.
35
-
36
- Make the image argument optional for drawpoint, drawline, and drawtriangle,
37
- in which case they should create an image just large enough to hold the drawn
38
- object and then return that image
39
-
40
- Make get_voxels_within_distance be able to take a multidimensional distance
41
- argument, in which case it should return all the voxels within an ellipse
42
- having its radii along each axis specified by the elements of distance. This
43
- is an extension of the current functionality, since currently a sphere is
44
- returned with radius in all dimensions equal to the int/float distance.
3
+ Fnctions for drawing basic geometric shapes, like lines and triangles,
4
+ into 3D numpy arrays (and the code almost works for nD arrays for any n).
5
+ Related tools:
6
+ - opencv has drawline and drawtriangle functions, but those only
7
+ operate on 2D image arrays.
8
+ - scipy.ndimage provides many other helpful, related functions for
9
+ working with nD image arrays.
10
+
11
+
12
+ TODOS:
13
+ - Implement 'center' convention logic for all functions.
14
+ - Make get_voxels_within_distance be able to take a multidimensional distance
15
+ argument, in which case it should return all the voxels within an ellipse
16
+ having its radii along each axis specified by the elements of distance. This
17
+ is an extension of the current functionality, since currently a sphere is
18
+ returned with radius in all dimensions equal to the int/float distance.
45
19
  """
46
20
 
47
-
21
+ from typing import Literal
48
22
  import math
49
23
  import itertools
50
24
 
@@ -56,32 +30,39 @@ from .utils import eq, ifloor, iceil, iround, is_out_of_bounds, remove_out_of_bo
56
30
 
57
31
  # --- Primitive shapes - points, lines, triangles, circles, spheres --- #
58
32
  #TODO out_of_bounds='raise' isn't actually raising a lot of the time. Fix it.
59
- def drawpoint(image, coord, value, thickness=1,
60
- convention='corner', out_of_bounds='ignore'):
33
+ def drawpoint(image, coord, value, thickness=1, **imset_kwargs):
61
34
  """
62
- TODO docstring
35
+ Change an image's value at a point or collection of points.
36
+
37
+ See npimage.graphics.imset's docstring for info on additional keyword arguments:
38
+ - convention: Literal['corner', 'center'] = 'corner'
39
+ - out_of_bounds: Literal['ignore', 'wrap', 'raise'] = 'ignore'
40
+ - add_missing_dims: Literal[None, 'start', 'end'] = None
63
41
  """
64
42
  coords = thicken(coord, thickness)
65
- imset(image, coords, value,
66
- convention=convention, out_of_bounds=out_of_bounds)
43
+ imset(image, coords, value, **imset_kwargs)
67
44
 
68
45
 
69
- # TODO switch to kwargs
70
46
  def drawline(image, pt1, pt2, value, thickness=1,
71
- convention='corner', out_of_bounds='ignore'):
47
+ convention='corner', **imset_kwargs):
72
48
  """
49
+ Change an image's values along a line from pt1 to pt2.
50
+
73
51
  DDA algorithm described in
74
52
  https://www.tutorialspoint.com/computer_graphics/line_generation_algorithm.htm
75
53
  with some additional logic that allows for non-integer point coordinates.
54
+
55
+ See npimage.graphics.imset's docstring for info on additional keyword arguments:
56
+ - convention: Literal['corner', 'center'] = 'corner'
57
+ - out_of_bounds: Literal['ignore', 'wrap', 'raise'] = 'ignore'
58
+ - add_missing_dims: Literal[None, 'start', 'end'] = None
76
59
  """
77
60
  pt1, pt2, long_axis = _preprocess_polygon_vertices(pt1, pt2)
78
- #print(f'after preprocessing, pt1 is {pt1}, pt2 is {pt2}, and long_axis is {long_axis}')
61
+ imset_kwargs['convention'] = convention
79
62
 
80
63
  # Deal with the start and end points as special - always draw them
81
- drawpoint(image, pt1, value, thickness=thickness,
82
- convention=convention, out_of_bounds=out_of_bounds)
83
- drawpoint(image, pt2, value, thickness=thickness,
84
- convention=convention, out_of_bounds=out_of_bounds)
64
+ drawpoint(image, pt1, value, thickness=thickness, **imset_kwargs)
65
+ drawpoint(image, pt2, value, thickness=thickness, **imset_kwargs)
85
66
 
86
67
  vec_1to2 = pt2 - pt1
87
68
  if eq(vec_1to2[long_axis], 0):
@@ -95,7 +76,6 @@ def drawline(image, pt1, pt2, value, thickness=1,
95
76
  pt2_adjustment = iceil(pt2[long_axis] - 0.5) - 0.5 - pt2[long_axis]
96
77
  pt1 += vec_1to2 * pt1_adjustment / vec_1to2[long_axis]
97
78
  pt2 += vec_1to2 * pt2_adjustment / vec_1to2[long_axis]
98
- #print(f'Adjusted endpoints:\npt1={pt1}\npt2={pt2}')
99
79
  elif convention == 'center':
100
80
  #TODO Test this block more rigorously for corner case performance
101
81
  # Will be marking a pixel every time the line connecting pt1 and
@@ -105,7 +85,6 @@ def drawline(image, pt1, pt2, value, thickness=1,
105
85
  pt2_adjustment = iceil(pt2[long_axis] - 1) - pt2[long_axis]
106
86
  pt1 += vec_1to2 * pt1_adjustment / vec_1to2[long_axis]
107
87
  pt2 += vec_1to2 * pt2_adjustment / vec_1to2[long_axis]
108
- #print(f'Adjusted endpoints:\npt1={pt1}\npt2={pt2}')
109
88
 
110
89
  if pt1[long_axis] > pt2[long_axis]:
111
90
  return # Line is so short that no more points need to be marked
@@ -116,8 +95,7 @@ def drawline(image, pt1, pt2, value, thickness=1,
116
95
  n_steps = iround(n_steps)
117
96
 
118
97
  if n_steps == 0: # pt1 == pt2, so only one point to mark
119
- drawpoint(image, pt1, value, thickness=thickness,
120
- convention=convention, out_of_bounds=out_of_bounds)
98
+ drawpoint(image, pt1, value, thickness=thickness, **imset_kwargs)
121
99
  return
122
100
 
123
101
  pts = np.outer(np.arange(n_steps+1) / n_steps, vec_1to2) + pt1
@@ -125,22 +103,29 @@ def drawline(image, pt1, pt2, value, thickness=1,
125
103
  # slightly (20%) faster than calling drawpoint - despite drawpoint
126
104
  # literally just calling thicken and then imset itself. ???
127
105
  pts = thicken(pts, thickness)
128
- imset(image, pts, value,
129
- convention=convention, out_of_bounds=out_of_bounds)
106
+ imset(image, pts, value, **imset_kwargs)
130
107
 
131
108
 
132
- # TODO switch to kwargs
133
109
  def drawtriangle(image, pt1, pt2, pt3, value, thickness=1,
134
- watertight=True,
135
- fill_value=None, convention='corner', out_of_bounds='ignore'):
110
+ watertight=True, fill_value=None, **imset_kwargs):
111
+ """
112
+ Draw a triangle into an image.
113
+
114
+ If `fill_value` is None, only the three edges of the triangle will be
115
+ drawn. If `fill_value` is a number, the inside of the triangle will be
116
+ drawn with that value. Note that `fill_value` for the inside of the
117
+ triangle can be the same as or different from `value` for the edge lines.
118
+
119
+ See npimage.graphics.imset's docstring for info on additional keyword arguments:
120
+ - convention: Literal['corner', 'center'] = 'corner'
121
+ - out_of_bounds: Literal['ignore', 'wrap', 'raise'] = 'ignore'
122
+ - add_missing_dims: Literal[None, 'start', 'end'] = None
123
+ """
136
124
 
137
125
  if fill_value is None:
138
- drawline(image, pt1, pt2, value, thickness=thickness,
139
- convention=convention, out_of_bounds=out_of_bounds)
140
- drawline(image, pt2, pt3, value, thickness=thickness,
141
- convention=convention, out_of_bounds=out_of_bounds)
142
- drawline(image, pt3, pt1, value, thickness=thickness,
143
- convention=convention, out_of_bounds=out_of_bounds)
126
+ drawline(image, pt1, pt2, value, thickness=thickness, **imset_kwargs)
127
+ drawline(image, pt2, pt3, value, thickness=thickness, **imset_kwargs)
128
+ drawline(image, pt3, pt1, value, thickness=thickness, **imset_kwargs)
144
129
  return
145
130
 
146
131
  pt1, pt2, pt3, long_axis = _preprocess_polygon_vertices(pt1, pt2, pt3)
@@ -166,7 +151,7 @@ def drawtriangle(image, pt1, pt2, pt3, value, thickness=1,
166
151
 
167
152
  try:
168
153
  iter(thickness)
169
- except:
154
+ except TypeError:
170
155
  thickness = np.full(len(image.shape), thickness)
171
156
 
172
157
  def draw_perpendicular_from_triangle_base(basept):
@@ -195,8 +180,7 @@ def drawtriangle(image, pt1, pt2, pt3, value, thickness=1,
195
180
  else:
196
181
  thickness[long_axis - 2] += 1
197
182
 
198
- drawline(image, basept, endpt, fill_value, thickness=thickness,
199
- convention=convention, out_of_bounds=out_of_bounds)
183
+ drawline(image, basept, endpt, fill_value, thickness=thickness, **imset_kwargs)
200
184
 
201
185
  if n_steps == 0:
202
186
  draw_perpendicular_from_triangle_base(pt1_adjusted)
@@ -205,26 +189,27 @@ def drawtriangle(image, pt1, pt2, pt3, value, thickness=1,
205
189
  startpt = pt1_adjusted + (i / n_steps) * vec_1to3_adj
206
190
  draw_perpendicular_from_triangle_base(startpt)
207
191
 
208
- drawline(image, pt1, pt2, value, thickness=thickness,
209
- convention=convention, out_of_bounds=out_of_bounds)
210
- drawline(image, pt2, pt3, value, thickness=thickness,
211
- convention=convention, out_of_bounds=out_of_bounds)
212
- drawline(image, pt3, pt1, value, thickness=thickness,
213
- convention=convention, out_of_bounds=out_of_bounds)
192
+ drawline(image, pt1, pt2, value, thickness=thickness, **imset_kwargs)
193
+ drawline(image, pt2, pt3, value, thickness=thickness, **imset_kwargs)
194
+ drawline(image, pt3, pt1, value, thickness=thickness, **imset_kwargs)
214
195
 
215
196
 
216
197
  def drawcircle(image, center, perpendicular, radius, value,
217
- fill=False, spacing=1, **kwargs):
198
+ fill=False, spacing=1, **imset_kwargs):
218
199
  """
219
- Draw a circle
220
- See drawpoint for all kwargs.
200
+ Draw a circle into an image.
201
+
202
+ See npimage.graphics.imset's docstring for info on additional keyword arguments:
203
+ - convention: Literal['corner', 'center'] = 'corner'
204
+ - out_of_bounds: Literal['ignore', 'wrap', 'raise'] = 'ignore'
205
+ - add_missing_dims: Literal[None, 'start', 'end'] = None
221
206
  """
222
207
  ndims = 3 # TODO code up logic for determining this from the input
223
208
  return_image = False
224
209
  if image is None and center is None:
225
210
  image = np.zeros((2*iceil(radius) + 1,)*ndims, dtype=np.uint8)
226
211
  center = (radius, radius, radius)
227
- kwargs['convention'] = 'center'
212
+ imset_kwargs['convention'] = 'center'
228
213
  return_image = True
229
214
 
230
215
  norm = lambda v: np.sqrt(sum(v**2))
@@ -241,7 +226,6 @@ def drawcircle(image, center, perpendicular, radius, value,
241
226
  v0 = v0 / norm(v0)
242
227
  v1 = v1 / norm(v1)
243
228
 
244
- half_circumfrence = math.pi*radius
245
229
  num_pts = iceil(math.pi*radius/spacing) + 1
246
230
  angles = np.linspace(0, math.pi, num_pts)
247
231
  half1 = (np.outer(np.cos(angles), v0)
@@ -249,42 +233,45 @@ def drawcircle(image, center, perpendicular, radius, value,
249
233
  half2 = (np.outer(np.cos(angles), v0)
250
234
  - np.outer(np.sin(angles), v1)) * radius + center
251
235
  if fill:
252
- #import npimage # for testing
253
236
  for pt1, pt2 in zip(half1, half2):
254
- drawline(image, pt1, pt2, value, **kwargs)
255
- #print(f'Just drew {pt1}->{pt2}') # for testing
256
- #npimage.imshow(image[0], mode='mpl') # for testing
237
+ drawline(image, pt1, pt2, value, **imset_kwargs)
257
238
  else:
258
- draw = lambda pts: imset(image, pts, value, **kwargs)
259
- draw(half1)
260
- draw(half2)
239
+ imset(image, half1, value, **imset_kwargs)
240
+ imset(image, half2, value, **imset_kwargs)
261
241
 
262
242
  if return_image:
263
243
  return image
264
244
 
265
245
 
266
- def drawsphere(*args, **kwargs):
267
- drawneighborhood(*args, **kwargs)
268
-
269
-
270
- # TODO switch to kwargs
271
- def drawneighborhood(image=None, distance=None, ndims=None, value=1,
272
- center=None, voxel_size=None, metric='euclidian',
273
- out_of_bounds='ignore'):
246
+ def drawsphere(image=None, distance=None, ndims=None, value=1,
247
+ center=None, voxel_size=None, metric='euclidian',
248
+ **imset_kwargs):
274
249
  """
275
250
  Draw a value into all pixels within a certain distance of a location.
276
- This is almost the same thing as drawpoint, but drawpoint's thickness and
277
- drawneighborhood's distance work differently.
251
+
252
+ drawsphere is a more general version of drawpoint. These are equivalent:
253
+ >>> im = np.full((240, 240), 255, dtype=np.uint8)
254
+ >>> npimage.drawsphere(im, distance=24, value=0, center=(120, 120), metric='chebyshev')
255
+ >>> npimage.drawpoint(im, (120, 120), 128, thickness=49)
256
+
257
+ which both draw boxes. But drawsphere with `metric='euclidian'` actually
258
+ draws spheres.
259
+
278
260
  If you want an image made for you with size just large enough to fit the
279
- requested neighborhood, omit the image argument.
261
+ requested sphere, leave `image=None`.
262
+
263
+ See npimage.graphics.imset's docstring for info on additional keyword arguments:
264
+ - convention: Literal['corner', 'center'] = 'corner'
265
+ - out_of_bounds: Literal['ignore', 'wrap', 'raise'] = 'ignore'
266
+ - add_missing_dims: Literal[None, 'start', 'end'] = None
280
267
  """
281
268
  if ndims is None:
282
269
  try:
283
270
  ndims = len(distance)
284
- except:
271
+ except TypeError:
285
272
  try:
286
273
  ndims = len(image.shape)
287
- except:
274
+ except TypeError:
288
275
  if voxel_size is not None:
289
276
  ndims = len(voxel_size)
290
277
  elif center is not None:
@@ -292,7 +279,7 @@ def drawneighborhood(image=None, distance=None, ndims=None, value=1,
292
279
  else:
293
280
  raise Exception('Specify the dimensionality you want by'
294
281
  "passing 'ndims='")
295
-
282
+
296
283
  if distance is None and image is None:
297
284
  raise Exception('Must specify distance or image.')
298
285
 
@@ -334,17 +321,17 @@ def drawneighborhood(image=None, distance=None, ndims=None, value=1,
334
321
  else:
335
322
  image = np.zeros(ifloor(center + distance + 1))
336
323
 
337
- #print(f'center={center}')
338
- #print(f'distance={distance}')
339
- #print(f'image.shape={image.shape}')
340
-
324
+ if imset_kwargs.get('add_missing_dims', None) is not None:
325
+ ndims = None
341
326
  voxels = get_voxels_within_distance(distance, center=center, ndims=ndims,
342
327
  voxel_size=voxel_size, metric=metric)
343
- imset(image, voxels, value, out_of_bounds=out_of_bounds)
328
+ imset(image, voxels, value, **imset_kwargs)
344
329
 
345
330
  if return_image:
346
331
  return image
347
332
 
333
+ drawneighborhood = drawsphere # Function name alias
334
+
348
335
 
349
336
  # --- Low-level wrappers for getting and setting values from arrays --- #
350
337
  def imget(image, coords, convention='corner',
@@ -369,7 +356,8 @@ def imget(image, coords, convention='corner',
369
356
  elif out_of_bounds != 'raise':
370
357
  raise ValueError("out_of_bounds must be 'ignore', 'raise', or"
371
358
  f"'wrap' but was {out_of_bounds}.")
372
- coords = remove_out_of_bounds(coords, image.shape, allow_negative_wrapping, convention)
359
+ coords = remove_out_of_bounds(coords, image.shape,
360
+ allow_negative_wrapping, convention)
373
361
  if n_original > coords.shape[0]:
374
362
  n_out_of_bounds = n_original - coords.shape[0]
375
363
  if out_of_bounds == 'raise':
@@ -387,15 +375,72 @@ def imget(image, coords, convention='corner',
387
375
  return image[tuple(iround(coords).T)]
388
376
 
389
377
 
390
- def imset(image, coords, value, convention='corner', out_of_bounds='ignore'):
378
+ def imset(image, coords, value,
379
+ convention: Literal['corner', 'center'] = 'corner',
380
+ out_of_bounds: Literal['ignore', 'wrap', 'raise'] = 'ignore',
381
+ add_missing_dims: Literal[None, 'start', 'end'] = None):
391
382
  """
392
383
  Given a numpy array, set a particular coordinate or set of coordinates to
393
384
  have a given value.
394
- By default, coordinates with negative indices are ignored, i.e. this
395
- function won't wrap negative indexes around to access the end of the array,
396
- despite numpy arrays normally wrapping negative indices. Refusing to wrap
397
- makes more sense in most graphics applications.
398
- out_of_bounds, string : 'ignore' (default), 'wrap', or 'raise'
385
+
386
+ Parameters
387
+ ----------
388
+ image : numpy.ndarray
389
+ The image to set pixel value(s) in.
390
+
391
+ coords : numpy.ndarray or array-like
392
+ The coordinate locations to set values of.
393
+
394
+ value : int, float, or array-like
395
+ The value to set the pixel(s) at the given coordinates to.
396
+
397
+ convention : {'corner', 'center'}, default 'corner'
398
+ Whether to truncate ('corner') or round ('center') fractional
399
+ values in the `coords` argument.
400
+
401
+ 'corner' (default):
402
+ In this mode, coordinate (5, 2) and coordinate (4.99, 2) refer
403
+ to different pixels because pixel borders are at integer values.
404
+ Implementation: Coordinates (a, b, c) are converted to integer
405
+ pixel indices via (floor(a), floor(b), floor(c)).
406
+ Therefore the pixel at integer index (x, y, z) will be addressed
407
+ by (a, b, c) when a is in [x, x+1), b is in [y, y+1), and c is
408
+ [z, z+1). This is called the 'corner' convention because integer
409
+ coordinates like (5, 2, 10) point to the upper-left corner of a
410
+ pixel. In this case, any tiny decrease from an integer value will
411
+ end up pointing to a different pixel in the image.
412
+
413
+ 'center':
414
+ In this mode, coordinate (5, 2) and coordinate (4.99, 2) refer
415
+ to the same pixel because pixel borders are at half-integer
416
+ values, so (4.51, 2) and (4.49, 2) refer to different pixels.
417
+ Implementation: Coordinates (a, b, c) will be converted to integer
418
+ pixel indices via (round(a), round(b), round(c)). Therefore the
419
+ pixel at integer index (x, y, z) will be addressed by (a, b, c)
420
+ when a is in [x-0.5, x+0.5), b is in [y-0.5, y+0.5), and c is
421
+ [z-0.5, z+0.5). This is called the 'center' convention because
422
+ integer coordinates like (5, 2, 10) point to the center of a
423
+ pixel, so increasing or decreasing any of the coordinate values by
424
+ up to 0.5 will still point to the same pixel in the image.
425
+
426
+ out_of_bounds : {'ignore', 'wrap', 'raise'}, default 'ignore'
427
+ By default, coordinates with negative indices are ignored, i.e. this
428
+ function won't wrap negative indexes around to access the end of the
429
+ array, despite numpy arrays normally wrapping negative indices.
430
+ Refusing to wrap makes more sense in most graphics applications.
431
+
432
+ add_missing_dims : {None, 'start', 'end'}, default None
433
+ If the `coords` argument has fewer dimensions than the `image`
434
+ argument, this parameter determines how to handle that mismatch.
435
+
436
+ This is useful when you want to set all the rows, columns, or
437
+ channels of an image to a particular value. For example:
438
+ - You have a (360, 640, 3) RGB pixel array `image`
439
+ - You have 100 pixels specified in a (100, 2) array `coords`
440
+ - You want to make those pixels cyan, i.e. set the values along
441
+ the last axis to (0, 255, 255)
442
+ then since the color axis is the last axis, you can call:
443
+ >>> imset(image, coords, (0, 255, 255), add_missing_dims='end')
399
444
  """
400
445
  if not isinstance(coords, np.ndarray):
401
446
  coords = np.array(coords)
@@ -404,7 +449,15 @@ def imset(image, coords, value, convention='corner', out_of_bounds='ignore'):
404
449
  allow_negative_wrapping = True
405
450
  else:
406
451
  allow_negative_wrapping = False
407
- is_oob = is_out_of_bounds(coords, image.shape, allow_negative_wrapping, convention)
452
+
453
+ bounds = image.shape
454
+ if add_missing_dims == 'start':
455
+ while len(bounds) > coords.shape[1]:
456
+ bounds = bounds[1:]
457
+ elif add_missing_dims == 'end':
458
+ while len(bounds) > coords.shape[1]:
459
+ bounds = bounds[:-1]
460
+ is_oob = is_out_of_bounds(coords, bounds, allow_negative_wrapping, convention)
408
461
  if is_oob.any():
409
462
  if out_of_bounds in ['ignore', 'wrap']:
410
463
  coords = coords[~is_oob]
@@ -416,9 +469,18 @@ def imset(image, coords, value, convention='corner', out_of_bounds='ignore'):
416
469
  f"'wrap' but was {out_of_bounds}.")
417
470
 
418
471
  if convention == 'corner':
419
- image[tuple(ifloor(coords).T)] = value
472
+ slicer = tuple(ifloor(coords).T)
420
473
  else: # convention == 'center'
421
- image[tuple(iround(coords).T)] = value
474
+ slicer = tuple(iround(coords).T)
475
+
476
+ if add_missing_dims == 'start':
477
+ while len(slicer) < image.ndim:
478
+ slicer = (slice(None),) + slicer
479
+ elif add_missing_dims == 'end':
480
+ while len(slicer) < image.ndim:
481
+ slicer = slicer + (slice(None),)
482
+
483
+ image[slicer] = value
422
484
 
423
485
 
424
486
  # --- Drawing shapes composed of many primitives, --- #
@@ -491,18 +553,8 @@ def _preprocess_polygon_vertices(*pts):
491
553
 
492
554
  pts = list(pts) # Make mutable
493
555
  for i, pt in enumerate(pts):
494
- # This block tried to guess whether the user wants center or corner
495
- # convention, but now that the functions in this module explicitly include
496
- # convention as an argument, it's no longer needed
497
- # if all(isint(pt)):
498
- # pts[i] = np.array(pts[i]) + 0.5
499
- # print(f'Adjusted {i+1}{suffixes.get(i+1, "th")} point to {pts[i]}'\
500
- # ' so it represents the middle of its pixel')
501
- # else:
502
556
  pts[i] = np.array(pts[i]).astype(np.float64)
503
557
 
504
- # assert pts[i].shape == (n_dims,)
505
-
506
558
  longest_axis = None
507
559
  longest_axis_len = -1
508
560
  for pt1_idx in range(len(pts)):
@@ -544,7 +596,7 @@ def thicken(pts, thickness=1, no_duplicates=True):
544
596
 
545
597
  try:
546
598
  iter(thickness)
547
- except:
599
+ except TypeError:
548
600
  thickness = [thickness] * pts.shape[1]
549
601
 
550
602
  # TODO make an option for doing spherical thickening using
@@ -634,14 +686,16 @@ def floodfill(image, seed, fill_value, fill_diagonally=False,
634
686
  This implementation is very slow and needs improvement
635
687
  """
636
688
  seed_value = image[seed]
637
- if verbose: print(f'seed_value={seed_value}')
689
+ if verbose:
690
+ print(f'seed_value={seed_value}')
638
691
  if fill_diagonally:
639
692
  neighbors = get_voxels_within_distance(1, ndims=len(image.shape),
640
693
  metric='chebyshev')
641
694
  else:
642
695
  neighbors = get_voxels_within_distance(1, ndims=len(image.shape))
643
696
 
644
- if verbose: print(f'neighbors={neighbors}')
697
+ if verbose:
698
+ print(f'neighbors={neighbors}')
645
699
  neighbors = neighbors[~(neighbors == 0).all(axis=1)]
646
700
  fill_mask = np.zeros(image.shape, dtype=bool)
647
701
 
@@ -652,12 +706,10 @@ def floodfill(image, seed, fill_value, fill_diagonally=False,
652
706
  #TODO learn cython????
653
707
 
654
708
  def fill(wavefront, iteration):
655
- if verbose: print(f'iteration={iteration}, len(wavefront)={len(wavefront)}')
656
- #print(wavefront)
657
- #fill wavefront
709
+ if verbose:
710
+ print(f'iteration={iteration}, len(wavefront)={len(wavefront)}')
658
711
  imset(fill_mask, wavefront, True)
659
712
  imset(image, wavefront, fill_value)
660
- #print(f'image:\n{image}')
661
713
 
662
714
  #add valid neighbors
663
715
  new_wavefront = []
@@ -706,7 +758,6 @@ def to_physical_coordinates(voxel_coordinates, voxel_size, offset=0):
706
758
  (in units of microns per voxel) and offset (indicating the physical
707
759
  coordinates of the origin voxel, in microns)
708
760
  """
709
- #TODO test this.
710
761
  return voxel_coordinates * voxel_size + offset
711
762
 
712
763
 
@@ -719,7 +770,4 @@ def to_voxel_coordinates(physical_coordinates, voxel_size, offset=0):
719
770
  accuracy, and downstream functions will need to convert to int if these are
720
771
  to be used to index an array.
721
772
  """
722
- #print(f'physical_coordinates={physical_coordinates}')
723
- #print(f'offset={offset}')
724
- #print(f'voxel_size={voxel_size}')
725
773
  return (physical_coordinates - offset) / voxel_size
@@ -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
 
@@ -19,7 +19,6 @@ Class list:
19
19
 
20
20
  from typing import Union, Tuple, Iterator, Literal
21
21
  from pathlib import Path
22
- import os
23
22
  import subprocess
24
23
  import threading
25
24
  import json
@@ -138,13 +137,17 @@ def lazy_load_video(filename) -> Iterator[np.ndarray]:
138
137
  except ImportError:
139
138
  raise ImportError('Missing optional dependency for video processing,'
140
139
  ' run `pip install av tqdm`')
141
- with av.open(filename) as container:
140
+ with av.open(Path(filename).expanduser()) as container:
142
141
  stream = container.streams.video[0]
143
142
  for frame in container.decode(stream):
144
143
  img = frame.to_ndarray(format='rgb24')
145
144
  yield img
146
145
 
147
146
 
147
+ class VideoSeekError(RuntimeError):
148
+ pass
149
+
150
+
148
151
  class VideoStreamer:
149
152
  def __init__(self, filename, verbose: bool = False):
150
153
  try:
@@ -154,7 +157,7 @@ class VideoStreamer:
154
157
  raise ImportError('Missing optional dependency for video processing,'
155
158
  ' run `pip install av tqdm`')
156
159
  self.verbose = verbose
157
- self.filename = Path(filename)
160
+ self.filename = Path(filename).expanduser()
158
161
  if not self.filename.exists():
159
162
  raise FileNotFoundError(f'File {filename} not found')
160
163
 
@@ -378,11 +381,14 @@ class VideoStreamer:
378
381
  return frame
379
382
  if frame.pts > target_pts:
380
383
  self._current_frame_number = self.pts_to_frame_number(frame.pts)
381
- raise RuntimeError(f'Frame with PTS {target_pts} not found after'
382
- f' seeking – current frame PTS: {frame.pts}')
383
- raise RuntimeError('Hit end of video before finding frame {frame_number} (PTS '
384
- f'{target_pts}). Last seen frame was {self._current_frame_number}'
385
- f' (PTS {self.frame_number_to_pts(self._current_frame_number)}')
384
+ raise VideoSeekError(f'Frame with PTS {target_pts} not found after'
385
+ f' seeking – current frame PTS: {frame.pts}')
386
+ if type(self.verbose) is int and self.verbose: # Set verbose=1 to use
387
+ print(f'Passing frame {self.pts_to_frame_number(frame.pts)} (PTS {frame.pts})'
388
+ f' while decoding to frame {frame_number} (PTS {target_pts})')
389
+ raise VideoSeekError('Hit end of video before finding frame {frame_number} (PTS '
390
+ f'{target_pts}). Last seen frame was {self._current_frame_number}'
391
+ f' (PTS {self.frame_number_to_pts(self._current_frame_number)}')
386
392
 
387
393
  if isinstance(frame_number, slice): # Support slicing
388
394
  start, stop, step = frame_number.indices(self.n_frames)
@@ -392,17 +398,39 @@ class VideoStreamer:
392
398
  if (self._current_frame_number is None
393
399
  or frame_number <= self._current_frame_number
394
400
  or frame_number > self._current_frame_number + 100):
395
- target_pts = self.frame_number_to_pts(frame_number)
401
+ # We seek to a few frames before the requested frame because
402
+ # seeking has the undesirable behavior of sometimes landing
403
+ # at a keyframe just after the requested frame, if a keyframe
404
+ # exists one or two frames after the requested frame.
405
+ seek_to_frame = max(0, frame_number - 3)
406
+ seek_to_pts = self.frame_number_to_pts(seek_to_frame)
396
407
  if self.verbose:
397
- print(f'Seeking to frame {frame_number} (PTS {target_pts})')
398
- # The following actually seeks to the closest keyframe before
408
+ print(f'Frame {frame_number} requested: Seeking to'
409
+ f' frame {seek_to_frame} (PTS {seek_to_pts})')
410
+ # The seek call actually seeks to the closest keyframe before
399
411
  # target_pts, because it's not possible to seek directly to
400
412
  # non-keyframes due to video files being compressed.
401
- self.container.seek(target_pts, any_frame=False,
413
+ self.container.seek(seek_to_pts, any_frame=False,
402
414
  backward=True, stream=self.stream)
403
415
  self._frame_iterator = self.container.decode(self.stream)
404
- # Now we decode frames forward until we get to the requested frame
405
- image = decode_until(frame_number)
416
+ try:
417
+ # Now we decode frames forward until we get to the requested frame
418
+ image = decode_until(frame_number)
419
+ except VideoSeekError as e:
420
+ # If we fail on the first attempt, try seeking back
421
+ # 30 frames (instead of 3) then decoding forward again.
422
+ seek_to_frame = max(0, frame_number - 30)
423
+ seek_to_pts = self.frame_number_to_pts(seek_to_frame)
424
+ if self.verbose:
425
+ print(f'[WARNING] {e}')
426
+ print(f'[RETRY] Frame {frame_number} requested: Seeking'
427
+ f' to frame {seek_to_frame} (PTS {seek_to_pts})')
428
+ self.container.seek(seek_to_pts, stream=self.stream)
429
+ self._frame_iterator = self.container.decode(self.stream)
430
+ # If this one fails too, we let its exception raise.
431
+ # I haven't seen this ever fail, but who knows.
432
+ image = decode_until(frame_number)
433
+
406
434
  if self.rotation not in [None, '0', 0]:
407
435
  image = np.rot90(image, k=-int(self.rotation) // 90)
408
436
  return image
@@ -517,8 +545,8 @@ class AVVideoWriter:
517
545
  raise ImportError('Missing optional dependency for video processing,'
518
546
  ' run `pip install av tqdm`')
519
547
  self.av = av
520
- filename = os.path.expanduser(str(filename))
521
- if os.path.exists(filename) and not overwrite:
548
+ filename = Path(filename).expanduser()
549
+ if filename.exists() and not overwrite:
522
550
  raise FileExistsError(f'File {filename} already exists. '
523
551
  'Set overwrite=True to overwrite.')
524
552
  self.filename = filename
@@ -622,8 +650,8 @@ class FFmpegVideoWriter:
622
650
  def __init__(self, filename, framerate=30, crf=23, compression_speed='medium',
623
651
  codec: Literal['libx264', 'libx265'] = 'libx264',
624
652
  overwrite=False):
625
- filename = os.path.expanduser(str(filename))
626
- if os.path.exists(filename) and not overwrite:
653
+ filename = Path(filename).expanduser()
654
+ if filename.exists() and not overwrite:
627
655
  raise FileExistsError(f'File {filename} already exists. '
628
656
  'Set overwrite=True to overwrite.')
629
657
  self.filename = filename
@@ -632,11 +660,6 @@ class FFmpegVideoWriter:
632
660
  self.compression_speed = compression_speed
633
661
  self.codec = codec_aliases[codec.lower()]
634
662
 
635
- # Check for existing file
636
- if os.path.exists(self.filename) and not overwrite:
637
- raise FileExistsError(f'File {self.filename} already exists. '
638
- 'Set overwrite=True to overwrite.')
639
-
640
663
  # Initialize process state
641
664
  self._process = None
642
665
  self._stdin = None
@@ -821,10 +844,11 @@ def save_video(data, filename, time_axis=0, color_axis=None, overwrite=False,
821
844
  raise ImportError('Missing optional dependency for video processing,'
822
845
  ' run `pip install av tqdm`')
823
846
 
824
- filename = os.path.expanduser(str(filename))
847
+ filename = str(filename)
825
848
  if filename.split('.')[-1].lower() not in supported_extensions:
826
849
  filename += '.mp4'
827
- if os.path.exists(filename) and not overwrite:
850
+ filename = Path(filename).expanduser()
851
+ if filename.exists() and not overwrite:
828
852
  raise FileExistsError(f'File {filename} already exists. '
829
853
  'Set overwrite=True to overwrite.')
830
854
 
@@ -856,7 +880,7 @@ def save_video(data, filename, time_axis=0, color_axis=None, overwrite=False,
856
880
  n_frames = data.shape[0]
857
881
  height, width = data.shape[1:]
858
882
 
859
- extension = filename.split('.')[-1].lower()
883
+ extension = filename.suffix.lower().lstrip('.')
860
884
  if extension == 'mp4':
861
885
  pad = [[0, 0], [0, 0], [0, 0]]
862
886
  if height % 2 != 0:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: numpyimage
3
- Version: 3.1.2
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.1.2'
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