numpyimage 1.2.0__tar.gz → 2.0.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.1
2
2
  Name: numpyimage
3
- Version: 1.2.0
3
+ Version: 2.0.0
4
4
  Summary: Load, save, and manipulate image files as numpy arrays
5
5
  Home-page: https://github.com/jasper-tms/npimage
6
6
  Author: Jasper Phelps
@@ -0,0 +1,4 @@
1
+ from .core import *
2
+ from .operations import *
3
+ from .align import *
4
+ from .graphics import *
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Functions for aligning images.
4
+ """
5
+
6
+ import numpy as np
7
+ import cv2 as cv
8
+
9
+
10
+ def find_landmark(image: np.ndarray,
11
+ landmark: np.ndarray,
12
+ search_bbox=None,
13
+ metric=cv.TM_CCOEFF_NORMED):
14
+ """
15
+ Find the region in an image that most resembles a particular landmark.
16
+
17
+ Implementation leverages OpenCV functions, following tutorial at
18
+ https://docs.opencv.org/4.x/d4/dc6/tutorial_py_template_matching.html
19
+
20
+ Parameters
21
+ ----------
22
+ image : np.ndarray
23
+ The image in which to search for the landmark.
24
+
25
+ landmark : np.ndarray
26
+ The landmark to search for in the image.
27
+
28
+ search_bbox : list/tuple, optional
29
+ The bounding box in which to search for the landmark. If you know
30
+ the landmark you're searching for is in a subset of the image then
31
+ specifying a search region will save time.
32
+ If None, the entire image will be searched. If not None, search_bbox
33
+ should be a list/tuple with two elements. Each element should be either
34
+ a slice object or a list/tuple of two integers that specify the min and
35
+ max values of the range.
36
+ For example, search_bbox=(slice(0, 100), slice(0, 200)) or
37
+ search_box=([0, 100], [0, 200]) would search the top-left
38
+ 100x200 region of the image.
39
+
40
+ metric : cv.TemplateMatchModes, optional
41
+ The metric to use to compare the landmark to the image. Options are
42
+ cv.TM_SQDIFF, cv.TM_SQDIFF_NORMED, cv.TM_CCORR, cv.TM_CCORR_NORMED,
43
+ cv.TM_CCOEFF, cv.TM_CCOEFF_NORMED. Default is cv.TM_CCOEFF_NORMED.
44
+
45
+ Returns
46
+ -------
47
+ (top_left, match_score) : tuple
48
+ top_left : tuple
49
+ The top-left corner of the bounding box in the image that
50
+ is most similar to the landmark.
51
+ score : float
52
+ The score of the match, from 0 to 1. A score of 1 indicates
53
+ a perfect match.
54
+ """
55
+ if search_bbox is not None:
56
+ if all(isinstance(el, slice) for el in search_bbox):
57
+ img_to_search = image[search_bbox[0], search_bbox[1]]
58
+ offset = (search_bbox[0].start, search_bbox[1].start)
59
+ elif all(isinstance(el, (list, tuple)) and len(el) == 2
60
+ for el in search_bbox):
61
+ img_to_search = image[search_bbox[0][0]:search_bbox[0][1],
62
+ search_bbox[1][0]:search_bbox[1][1]]
63
+ offset = (search_bbox[0][0], search_bbox[1][0])
64
+ else:
65
+ raise ValueError(f"Invalid search_bbox: {search_bbox}. Must "
66
+ "be two slices or two (min, max) pairs.")
67
+ else:
68
+ img_to_search = image
69
+ offset = (0, 0)
70
+
71
+ # Perform template matching
72
+ scores = cv.matchTemplate(img_to_search, landmark, metric)
73
+
74
+ # TODO implement subpixel max_loc finding.
75
+ # Perhaps fitting a gaussian peak?
76
+ min_val, max_val, min_loc, max_loc = cv.minMaxLoc(scores)
77
+ if metric in [cv.TM_SQDIFF, cv.TM_SQDIFF_NORMED]:
78
+ top_left = min_loc[::-1] # Includes a flip from (x, y) to (y, x)
79
+ match_score = 1 - min_val
80
+ else:
81
+ top_left = max_loc[::-1] # Includes a flip from (x, y) to (y, x)
82
+ match_score = max_val
83
+ top_left = (top_left[0] + offset[0], top_left[1] + offset[1])
84
+ return top_left, match_score
@@ -0,0 +1,493 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Functions for reading, writing, and showing images.
4
+
5
+ Function list:
6
+ - load(filename) -> numpy.ndarray
7
+ - save(data, filename) -> Saves a numpy array as an nD image file
8
+ - save_video(data, filename) -> Saves a 3D numpy array as a video
9
+ - show(np_array) -> Displays a numpy array of pixel values as an image
10
+ """
11
+
12
+ import os
13
+ import glob
14
+ from builtins import open as builtin_open
15
+
16
+ import numpy as np
17
+
18
+ from . import operations, utils
19
+
20
+ supported_extensions = [
21
+ 'tif', 'tiff', 'jpg', 'jpeg', 'png', 'pbm',
22
+ 'nrrd', 'zarr', 'raw', 'vol', 'ng'
23
+ ]
24
+
25
+
26
+ def load(filename, dim_order='zyx', **kwargs):
27
+ """
28
+ Open a 2D or 3D image file and return its pixel values as a numpy array.
29
+
30
+ As is typical for Python, the returned array will by default have
31
+ dimensions ordered as zyx for 3D image volumes, yx for 1-channel 2D
32
+ images, or yxc for multi-channel (RGB/RGBA) 2D images.
33
+ Set dim_order='xy' if you want to reverse the order of the axes.
34
+ """
35
+ filename = str(filename)
36
+ while filename.endswith('/'):
37
+ filename = filename[:-1]
38
+ if 'format' in kwargs:
39
+ extension = kwargs['format'].lower()
40
+ elif '.' in filename:
41
+ extension = filename.split('.')[-1].lower()
42
+ else:
43
+ raise ValueError('Could not determine file format from filename'
44
+ f' "{filename}". Please specify the file type via'
45
+ ' the `format` argument, e.g. format="tif"')
46
+ if extension not in supported_extensions:
47
+ raise ValueError(f'File format "{extension}" not supported/recognized.')
48
+
49
+ data = None
50
+
51
+ if extension in ['jpg', 'jpeg', 'png']:
52
+ from PIL import Image
53
+ data = np.array(Image.open(filename)) #PIL.Image.open returns zyx order
54
+
55
+ if extension in ['tif', 'tiff']:
56
+ import tifffile
57
+ data = tifffile.imread(filename) #tifffile.imread returns zyx order
58
+
59
+ if extension == 'pbm':
60
+ from .filetypes import pbm
61
+ data = pbm.load(filename) # pbm.load returns zyx
62
+
63
+ if extension == 'nrrd':
64
+ import nrrd # pip install pynrrd
65
+ # Specify C-style ordering to get zyx ordering from nrrd.read
66
+ # https://pynrrd.readthedocs.io/en/stable/background/index-ordering.html
67
+ data, metadata = nrrd.read(filename, index_order='C')
68
+ # Metadata is in Fortran order, so flip it to C order
69
+ utils.transpose_metadata(metadata, inplace=True)
70
+
71
+ if extension in ['raw', 'vol']:
72
+ dtype = kwargs.get('dtype', np.uint8)
73
+ with builtin_open(filename, 'rb') as f:
74
+ data = np.fromfile(f, dtype=dtype)
75
+ if 'shape' in kwargs:
76
+ data = data.reshape(*kwargs['shape'])
77
+
78
+ if extension == 'zarr':
79
+ if 'dataset' not in kwargs:
80
+ dataset = filename + '/'
81
+ while (not os.path.exists(os.path.join(dataset, '.zarray')) and
82
+ len(glob.glob(dataset + '*/')) == 1):
83
+ dataset = glob.glob(dataset + '*/')[0]
84
+
85
+ if os.path.exists(os.path.join(dataset, '.zarray')):
86
+ dataset = dataset.replace(filename + '/', '')[:-1]
87
+ print('Found exactly one dataset in this zarr:'
88
+ ' Opening {dataset}')
89
+ elif len(glob.glob(dataset + '*/')) == 0:
90
+ raise Exception('No valid datasets containing a .zarray file'
91
+ f' found within {filename}')
92
+ else:
93
+ raise Exception(f'Multiple datasets found within {filename}.'
94
+ ' Pass a "dataset=" argument to specify the'
95
+ ' one you want.')
96
+
97
+ try:
98
+ import daisy
99
+ except:
100
+ daisy = None
101
+
102
+ if daisy:
103
+ data = daisy.open_ds(filename, dataset)[:] #TODO check zyx/xyz order. Pretty sure daisy zarrs are zyx
104
+ else:
105
+ raise NotImplementedError
106
+ import zarr
107
+ data = zarr.open(TODO) #TODO check zyx/xyz order
108
+
109
+ if 'xy' in dim_order:
110
+ data = data.T
111
+ if extension == 'nrrd': # TODO check if other formats need this
112
+ utils.transpose_metadata(metadata, inplace=True)
113
+
114
+ if any([kwargs.get(key, False) for key in
115
+ ['metadata', 'get_metadata', 'return_metadata']]):
116
+ try:
117
+ return data, metadata
118
+ except:
119
+ print('WARNING: Metadata requested but not found.'
120
+ ' Returning image only.')
121
+ return data
122
+ else:
123
+ return data
124
+
125
+ open = load # Function name alias
126
+ read = load # Function name alias
127
+ imread = load # Function name alias
128
+
129
+
130
+ def save(data,
131
+ filename,
132
+ overwrite=False,
133
+ dim_order='zyx',
134
+ pixel_size=None,
135
+ units=None,
136
+ compress=None,
137
+ compression_level=3,
138
+ metadata=None):
139
+ """
140
+ Save a numpy array to file with a file type specified by the
141
+ filename extension.
142
+
143
+ As is typical for Python, the input array is assumed to have
144
+ dimensions ordered as zyx for 3D images, yx for 1-channel 2D
145
+ images, or yxc for multi-channel 2D images.
146
+ Set dim_order='xy' if your array is a 3D image in in xyz order,
147
+ a 1-channel 2D image in xy order, or a multi-channel 2D image
148
+ in xyc order.
149
+
150
+ Currently `pixel_size`, `units`, and `compress` are only recognized
151
+ when saving to .nrrd and .ng files, and ignored otherwise.
152
+ """
153
+ filename = str(filename)
154
+ filename = filename.rstrip('/')
155
+ if os.path.exists(filename) and not overwrite:
156
+ raise FileExistsError(f'File {filename} already exists. '
157
+ 'Set overwrite=True to overwrite.')
158
+ extension = filename.split('.')[-1]
159
+ assert extension in supported_extensions, f'Filetype {extension} not supported'
160
+
161
+ if compress and extension not in ['nrrd', 'ng']:
162
+ print('WARNING: compress argument is ignored because not saving as '
163
+ '.nrrd. Whether or not compression occurs now will depend on '
164
+ 'the format you are saving to.')
165
+
166
+ channel_axis = find_channel_axis(data)
167
+ if 'xy' in dim_order:
168
+ data = data.T
169
+ if hasattr(pixel_size, '__iter__') and not isinstance(pixel_size, str):
170
+ pixel_size = pixel_size[::-1]
171
+ if hasattr(units, '__iter__') and not isinstance(units, str):
172
+ units = units[::-1]
173
+ utils.transpose_metadata(metadata, inplace=True)
174
+ # The spatial axes and associated metadata are now in zyx order.
175
+
176
+ if extension in ['tif', 'tiff']:
177
+ import tifffile
178
+ tifffile.imsave(filename, data=data)
179
+
180
+ if extension in ['jpg', 'jpeg', 'png']:
181
+ # imagej only writes 8-bit jpgs, with nasty clipping if you try to save
182
+ # a 16 or 32 bit image as jpg. PIL probably does too. TODO investigate,
183
+ # and print warning if user tries to save a 16- or more bit array as jpg.
184
+ from PIL import Image # pip install pillow
185
+ Image.fromarray(data).save(filename)
186
+
187
+ if extension == 'pbm':
188
+ from .filetypes import pbm
189
+ pbm.save(data, filename, comments=metadata)
190
+
191
+ if extension == 'nrrd':
192
+ import nrrd # pip install pynrrd
193
+ if metadata is None:
194
+ metadata = {}
195
+ else:
196
+ metadata = metadata.copy()
197
+ custom_field_map = {}
198
+
199
+ if compress is True:
200
+ metadata.update({'encoding': 'gzip'})
201
+ if compress is False or 'encoding' not in metadata:
202
+ metadata.update({'encoding': 'raw'})
203
+
204
+ if isinstance(metadata.get('space directions', None), str):
205
+ custom_field_map['space directions'] = 'string'
206
+ if pixel_size is not None:
207
+ if 'space directions' in metadata:
208
+ raise ValueError('Cannot specify both "space directions" in'
209
+ ' metadata and "pixel_size" as an argument.')
210
+
211
+ try:
212
+ iter(pixel_size)
213
+ if len(pixel_size) == data.ndim - 1 and channel_axis is not None:
214
+ pixel_size.insert(channel_axis, np.nan)
215
+ except TypeError:
216
+ if channel_axis is not None:
217
+ pixel_size = [pixel_size] * (data.ndim - 1)
218
+ pixel_size.insert(channel_axis, np.nan)
219
+ else:
220
+ pixel_size = [pixel_size] * data.ndim
221
+ if len(pixel_size) != data.ndim:
222
+ raise ValueError(f'pixel_size has length {len(pixel_size)},'
223
+ ' but data has {data.ndim} dimensions.')
224
+
225
+ pixel_size_not_none = [size for size in pixel_size
226
+ if size is not None and size != np.nan]
227
+ space_directions = np.diag(pixel_size_not_none).astype(float)
228
+ # nrrd.format_optional_matrix() expects an entire row of
229
+ # np.nan for non-spatial dimensions, so insert row(s) for that.
230
+ none_indices = [i for i, size in enumerate(pixel_size)
231
+ if size is None or size == np.nan]
232
+ space_directions = np.insert(space_directions, none_indices, np.nan, axis=0)
233
+ metadata.update({'space directions': space_directions})
234
+
235
+ if 'space dimension' not in metadata and 'space' not in metadata:
236
+ # If the number of spatial dimensions is not specified, assume
237
+ # it's the number of dimensions in the data array, minus 1 if
238
+ # a channel axis is present.
239
+ if find_channel_axis(data) is not None:
240
+ metadata.update({'space dimension': data.ndim - 1})
241
+ else:
242
+ metadata.update({'space dimension': data.ndim})
243
+ if units is not None:
244
+ if hasattr(units, '__iter__') and not isinstance(units, str):
245
+ units = list(units)
246
+ elif 'space dimension' in metadata:
247
+ units = [units] * metadata['space dimension']
248
+ else:
249
+ units = [units] * data.ndim
250
+ metadata.update({'space units': units})
251
+
252
+ # From https://pynrrd.readthedocs.io/en/stable/background/index-ordering.html
253
+ # "C-order is the index order used in Python and many Python libraries
254
+ # (e.g. NumPy, scikit-image, PIL, OpenCV). pynrrd recommends using
255
+ # C-order indexing to be consistent with the Python community. However,
256
+ # as of this time, the default indexing [in the nrrd.write command] is
257
+ # Fortran-order to maintain backwards compatibility."
258
+ # "All header fields are specified in Fortran order, per the NRRD
259
+ # specification, regardless of the index order. For example, a
260
+ # C-ordered array with shape (60, 800, 600) would have a sizes field
261
+ # of (600, 800, 60)."
262
+ #
263
+ # We expect users of this save function to pass in metadata that has
264
+ # header fields ordered in the same order as their data, so in addition
265
+ # to effectively flipping the order of the data's axes by specifying
266
+ # index_order='C' to the nrrd.write command below, we also need to flip
267
+ # the order of any per-axis metadata fields.
268
+ utils.transpose_metadata(metadata, inplace=True)
269
+ nrrd.write(filename,
270
+ data,
271
+ header=metadata,
272
+ custom_field_map=custom_field_map,
273
+ compression_level=compression_level,
274
+ index_order='C')
275
+
276
+ if extension in ['raw', 'vol']:
277
+ raise NotImplementedError
278
+
279
+ if extension == 'zarr':
280
+ raise NotImplementedError
281
+
282
+ if extension == 'ng':
283
+ from cloudvolume import CloudVolume
284
+ from cloudvolume.exceptions import InfoUnavailableError
285
+
286
+ # CloudVolume expects data in Fortran order
287
+ data = data.T
288
+
289
+ resolution = 1
290
+ if pixel_size is not None:
291
+ resolution = pixel_size
292
+ try:
293
+ iter(resolution)
294
+ except TypeError:
295
+ resolution = [resolution] * data.ndim
296
+ if compress is True or compress in ['lossy', 'jpeg', 'jpg']:
297
+ encoding = 'jpeg'
298
+ gzip = False
299
+ elif compress is None or compress in ['lossless', 'gzip']:
300
+ if compress is None:
301
+ print('Using gzip compression by default for .ng format.')
302
+ encoding = 'raw'
303
+ gzip = True
304
+ elif compress is False:
305
+ encoding = 'raw'
306
+ gzip = False
307
+ else:
308
+ raise ValueError('For .ng format, compress must be '
309
+ 'True/"lossy"/"jpeg"/"jpg", "lossless"/"gzip",'
310
+ f' or False, but was "{compress}"')
311
+
312
+ if not any(filename.startswith(prefix)
313
+ for prefix in ['file://', 'gs://', 's3://']):
314
+ filename = 'file://' + filename
315
+
316
+ try:
317
+ vol = CloudVolume(filename, compress=gzip)
318
+ if (vol.shape[:data.ndim] != data.shape
319
+ or any(s != 1 for s in vol.shape[data.ndim:])):
320
+ raise ValueError('Dataset already exists at the specified path,'
321
+ ' but its shape does not match the given data.')
322
+ except InfoUnavailableError:
323
+ info = CloudVolume.create_new_info(
324
+ num_channels=1,
325
+ layer_type='image',
326
+ data_type=data.dtype,
327
+ encoding=encoding,
328
+ resolution=resolution,
329
+ voxel_offset=[0, 0, 0],
330
+ chunk_size=[64, 64, 64],
331
+ volume_size=data.shape
332
+ )
333
+
334
+ vol = CloudVolume(filename, info=info, compress=gzip)
335
+ vol.commit_info()
336
+
337
+ vol[:] = data
338
+
339
+
340
+ write = save # Function name alias
341
+ to_file = save # Function name alias
342
+
343
+
344
+ def save_video(data, filename, overwrite=False, dim_order='yx', time_axis=0,
345
+ framerate=30, crf=23, compression_speed='medium'):
346
+ """
347
+ Save a 3D numpy array as a video, with a specified axis as the time axis.
348
+
349
+ Follows the PyAV cookbook section on generating video from numpy arrays:
350
+ https://pyav.basswood-io.com/docs/develop/cookbook/numpy.html#generating-video
351
+ """
352
+ if not data.ndim == 3:
353
+ raise ValueError('Input data must be a 3D numpy array.')
354
+ try:
355
+ import av
356
+ except ImportError:
357
+ raise ImportError('To save videos, you must have PyAV installed. '
358
+ 'You can install it with "pip install av".')
359
+ data = np.moveaxis(data, time_axis, 0)
360
+ if 'xy' in dim_order:
361
+ data = data.swapaxes(1, 2)
362
+ n_frames = data.shape[0]
363
+
364
+ if filename.split('.')[-1] not in ['mp4', 'mkv', 'avi', 'mov']:
365
+ filename += '.mp4'
366
+ if os.path.exists(filename) and not overwrite:
367
+ raise FileExistsError(f'File {filename} already exists. '
368
+ 'Set overwrite=True to overwrite.')
369
+ container = av.open(filename, mode='w')
370
+
371
+ stream = container.add_stream('libx264', rate=framerate)
372
+ stream.pix_fmt = 'yuv420p'
373
+ stream.options = {'crf': str(crf), 'preset': compression_speed}
374
+ stream.height = data.shape[1]
375
+ stream.width = data.shape[2]
376
+
377
+ for frame_i in range(n_frames):
378
+ frame = av.VideoFrame.from_ndarray(data[frame_i], format='gray')
379
+ for packet in stream.encode(frame):
380
+ container.mux(packet)
381
+
382
+ for packet in stream.encode():
383
+ container.mux(packet)
384
+
385
+ container.close()
386
+
387
+
388
+ def show(data,
389
+ dim_order='yx',
390
+ mode='PIL',
391
+ convert_to_8bit=True,
392
+ **kwargs):
393
+ """
394
+ Display a numpy array of pixel values as an image. Supported types:
395
+ 1-channel (grayscale) : data.shape must be (y, x)
396
+ 3-channel (RGB) : data.shape must be (y, x, 3)
397
+ 4-channel (RGBA) : data.shape must be (y, x, 4)
398
+
399
+ If `dim_order` is set to 'xy' (instead of the default 'yx'), then
400
+ swap the y and x above.
401
+
402
+ Images will be shown using either `PIL.Image.fromarray(data).show()`
403
+ or `matplotlib.pyplot.imshow(data)` depending on 'mode'. Uses PIL by
404
+ default. Set mode='mpl' to use matplotlib.
405
+
406
+ kwargs get passed along to Image.fromarray or pyplot.imshow,
407
+ except a few options get parsed by this function:
408
+ colorbar=True : Display a color bar (mpl only)
409
+ TODO more options
410
+ """
411
+ if isinstance(data, str):
412
+ if os.path.exists(data):
413
+ data = load(data)
414
+
415
+ if (not data.ndim == 2) and not (data.ndim == 3 and find_channel_axis(data) is not None):
416
+ m = ('Data must have shape (y, x) for grayscale, '
417
+ '(y, x, 3) for RGB, or (y, x, 4) for RGBA but had '
418
+ f'shape {data.shape}')
419
+ if 'xy' in dim_order:
420
+ m = m.replace('y, x', 'x, y')
421
+ raise ValueError(m)
422
+
423
+ if 'xy' in dim_order:
424
+ data = data.T
425
+ channel_axis = find_channel_axis(data, expected_channel_axis=-1)
426
+ if channel_axis is not None and channel_axis != -1:
427
+ data = np.moveaxis(data, find_channel_axis(data), -1)
428
+
429
+ if convert_to_8bit and data.dtype != np.uint8:
430
+ data = operations.to_8bit(data)
431
+
432
+ colorbar = False
433
+ if 'colorbar' in kwargs:
434
+ if kwargs['colorbar']:
435
+ colorbar = True
436
+ kwargs.pop('colorbar')
437
+
438
+ if mode.lower() in ['pil', 'pillow']:
439
+ from PIL import Image # pip install pillow
440
+ Image.fromarray(data, **kwargs).show()
441
+
442
+ elif mode.lower() in ['mpl', 'matplotlib', 'pyplot']:
443
+ import matplotlib.pyplot as plt # pip install matplotlib
444
+ plt.imshow(data, **kwargs)
445
+ if colorbar:
446
+ plt.colorbar()
447
+ plt.show()
448
+
449
+ imshow = show # Function name alias
450
+
451
+
452
+ def find_channel_axis(data, expected_channel_axis=[0, -1]):
453
+ """
454
+ If the given numpy array has a shape suggesting that it has a
455
+ channel (color) axis (that is, any axis with length 2 (2-color),
456
+ 3 (RGB), or 4 (RGBA)), return the index of that axis.
457
+
458
+ Parameters
459
+ ----------
460
+ data : numpy.ndarray
461
+ The numpy array to check for a channel axis.
462
+
463
+ expected_channel_axis : int or list of int, default [0, -1]
464
+ If None, any axis having length 2, 3, or 4 will be considered
465
+ a channel axis.
466
+ If an int, only that axis index will be checked.
467
+ If a list of ints, all axes with those indices will be checked.
468
+ The default value of [0, -1] checks the first and last axes, which is
469
+ almost always where a channel axis will be found.
470
+
471
+ Returns
472
+ -------
473
+ int or None
474
+ The index of the channel axis, or None if no channel axis was found.
475
+
476
+ If expected_channel_axis is given, the returned value will be one of
477
+ the expected_channel_axis values, or None if no channel axis was found.
478
+
479
+ If expected_channel_axis is None, the returned value will be between
480
+ 0 and data.ndim - 1, inclusive, or None if no channel axis was found.
481
+
482
+ Note that returning 0 means the channel axis was found and is the first
483
+ axis, so be careful not to do a test like `if find_channel_axis(data):`
484
+ because 0 will evaluate to False even though the data has a channel axis.
485
+ """
486
+ if isinstance(expected_channel_axis, int):
487
+ expected_channel_axis = [expected_channel_axis]
488
+ if expected_channel_axis is None:
489
+ expected_channel_axis = range(data.ndim)
490
+ for axis in expected_channel_axis:
491
+ if data.shape[axis] in [2, 3, 4]:
492
+ return axis
493
+ return None
@@ -27,7 +27,6 @@ def load(filename):
27
27
  data = data.reshape((h, w)).view(bool)
28
28
 
29
29
  return data
30
-
31
30
 
32
31
 
33
32
  def save(data, filename, comments=None):