numpyimage 2.1.1__tar.gz → 2.2.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: 2.1.1
3
+ Version: 2.2.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: GNU GENERAL PUBLIC LICENSE
@@ -1,4 +1,7 @@
1
+ #!/usr/bin/env python3
2
+
1
3
  from .core import *
2
4
  from .operations import *
3
5
  from .align import *
4
6
  from .graphics import *
7
+ from .utils import *
@@ -0,0 +1,149 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Functions for aligning images.
4
+ """
5
+ from typing import Optional, Tuple, Literal
6
+
7
+ import numpy as np
8
+ import cv2 as cv
9
+
10
+
11
+ def find_landmark(image: np.ndarray,
12
+ landmark: np.ndarray,
13
+ search_bbox: Optional[Tuple[Tuple[int, int], Tuple[int, int]]] = None,
14
+ metric: Literal[cv.TM_SQDIFF, cv.TM_SQDIFF_NORMED,
15
+ cv.TM_CCORR, cv.TM_CCORR_NORMED,
16
+ cv.TM_CCOEFF, cv.TM_CCOEFF_NORMED] = cv.TM_CCOEFF_NORMED,
17
+ subpixel_accuracy: bool = True
18
+ ) -> Tuple[Tuple[int, int], float]:
19
+ """
20
+ Find the region in an image that most resembles a particular landmark.
21
+
22
+ Implementation leverages OpenCV functions, following tutorial at
23
+ https://docs.opencv.org/4.x/d4/dc6/tutorial_py_template_matching.html
24
+
25
+ Parameters
26
+ ----------
27
+ image : np.ndarray
28
+ The image in which to search for the landmark.
29
+
30
+ landmark : np.ndarray
31
+ The landmark to search for in the image.
32
+
33
+ search_bbox : ([axis1min, axis1max], [axis2min, axis2max])
34
+ The bounding box in which to search for the landmark. If you know
35
+ the landmark you're searching for is in a subset of the image then
36
+ specifying a search region will save time.
37
+ If None, the entire image will be searched. If not None, search_bbox
38
+ should be a list/tuple with two elements. Each element should be either
39
+ a slice object or a list/tuple of two integers that specify the min and
40
+ max values of the range.
41
+ For example, search_bbox=(slice(0, 100), slice(0, 200)) or
42
+ search_box=([0, 100], [0, 200]) would search the top-left
43
+ 100x200 region of the image.
44
+
45
+ metric : cv.TemplateMatchModes, optional
46
+ The metric to use to compare the landmark to the image. Options are
47
+ cv.TM_SQDIFF, cv.TM_SQDIFF_NORMED, cv.TM_CCORR, cv.TM_CCORR_NORMED,
48
+ cv.TM_CCOEFF, cv.TM_CCOEFF_NORMED. Default is cv.TM_CCOEFF_NORMED.
49
+
50
+ Returns
51
+ -------
52
+ (top_left, match_score) : tuple
53
+ top_left : tuple
54
+ The top-left corner of the bounding box in the image that
55
+ is most similar to the landmark.
56
+ score : float
57
+ The score of the match, from 0 to 1. A score of 1 indicates
58
+ a perfect match.
59
+ """
60
+ if search_bbox is not None:
61
+ if all(isinstance(el, slice) for el in search_bbox):
62
+ img_to_search = image[search_bbox[0], search_bbox[1]]
63
+ search_offset = (search_bbox[0].start, search_bbox[1].start)
64
+ elif all(isinstance(el, (list, tuple)) and len(el) == 2
65
+ for el in search_bbox):
66
+ img_to_search = image[search_bbox[0][0]:search_bbox[0][1],
67
+ search_bbox[1][0]:search_bbox[1][1]]
68
+ search_offset = (search_bbox[0][0], search_bbox[1][0])
69
+ else:
70
+ raise ValueError(f"Invalid search_bbox: {search_bbox}. Must "
71
+ "be two slices or two (min, max) pairs.")
72
+ else:
73
+ img_to_search = image
74
+ search_offset = (0, 0)
75
+
76
+ # Perform template matching
77
+ scores = cv.matchTemplate(img_to_search, landmark, metric)
78
+
79
+ min_val, max_val, min_loc, max_loc = cv.minMaxLoc(scores)
80
+ if metric in [cv.TM_SQDIFF, cv.TM_SQDIFF_NORMED]:
81
+ top_left = min_loc[::-1] # Includes a flip from (x, y) to (y, x)
82
+ match_score = 1 - min_val
83
+ else:
84
+ top_left = max_loc[::-1] # Includes a flip from (x, y) to (y, x)
85
+ match_score = max_val
86
+
87
+ if not subpixel_accuracy:
88
+ top_left = (top_left[0] + search_offset[0],
89
+ top_left[1] + search_offset[1])
90
+ return top_left, match_score
91
+
92
+ # Subpixel accuracy: Fit a quadratic to a patch around the best score
93
+ patch_size = 5
94
+ patch_top_left = (max(0, top_left[0] - patch_size//2),
95
+ max(0, top_left[1] - patch_size//2))
96
+ patch_bottom_right = (min(scores.shape[0], top_left[0] + patch_size//2),
97
+ min(scores.shape[1], top_left[1] + patch_size//2))
98
+ patch = scores[patch_top_left[0]:patch_bottom_right[0],
99
+ patch_top_left[1]:patch_bottom_right[1]]
100
+
101
+ def eval_quadratic(coeffs, x, y):
102
+ """
103
+ Evaluate a quadratic surface at a given point.
104
+
105
+ The quadratic surface is defined as:
106
+ quadratic(x, y) = a*x**2 + b*y**2 + c*x*y + d*x + e*y + f
107
+ """
108
+ a, b, c, d, e, f = coeffs
109
+ return a*x**2 + b*y**2 + c*x*y + d*x + e*y + f
110
+
111
+ def quadratic_interpolate_peak(patch):
112
+ """
113
+ Fit a 2D quadratic surface to a matrix of values, and find
114
+ the peak position and value of the surface.
115
+
116
+ Returns
117
+ -------
118
+ tuple containing
119
+ - The coordinate of the peak value of the quadratic fit to the patch
120
+ - The peak value
121
+ """
122
+ A = [[i**2, j**2, i*j, i, j, 1] for i, j in np.ndindex(patch.shape)]
123
+ z = [patch[i, j] for i, j in np.ndindex(patch.shape)]
124
+ A = np.array(A)
125
+ z = np.array(z)
126
+
127
+ coeffs, *_ = np.linalg.lstsq(A, z, rcond=None)
128
+ a, b, c, d, e, f = coeffs
129
+
130
+ # Find peak by solving gradient = 0:
131
+ # df/di = 2a i + c j + d = 0
132
+ # df/dj = 2b j + c i + e = 0
133
+ A_grad = np.array([[2*a, c],
134
+ [c, 2*b]])
135
+ b_grad = -np.array([d, e])
136
+ try:
137
+ peak_loc = np.linalg.solve(A_grad, b_grad)
138
+ except np.linalg.LinAlgError:
139
+ peak_loc = np.array([np.nan, np.nan]) # singular matrix, fallback
140
+
141
+ peak_value = eval_quadratic(coeffs, peak_loc[0], peak_loc[1])
142
+ return peak_loc, peak_value
143
+
144
+ patch_peak, peak_value = quadratic_interpolate_peak(patch)
145
+ image_peak = patch_peak + patch_top_left + search_offset
146
+ image_peak = (round(image_peak[0], ndigits=2),
147
+ round(image_peak[1], ndigits=2))
148
+
149
+ return image_peak, peak_value
@@ -9,6 +9,7 @@ Function list:
9
9
  - show(np_array) -> Displays a numpy array of pixel values as an image
10
10
  """
11
11
 
12
+ from typing import Literal
12
13
  import os
13
14
  import glob
14
15
  from builtins import open as builtin_open
@@ -76,7 +77,24 @@ def load(filename, dim_order='zyx', **kwargs):
76
77
  if 'shape' in kwargs:
77
78
  data = data.reshape(*kwargs['shape'])
78
79
 
80
+ if extension == 'ng':
81
+ from cloudvolume import CloudVolume
82
+ if '://' not in filename:
83
+ filename = 'file://' + filename
84
+ vol = CloudVolume(filename)
85
+ data = np.array(vol[:]).squeeze()
86
+ # CloudVolume is unusual in returning data in Fortran order,
87
+ # so we transpose xyz -> zyx
88
+ data = data.T
89
+ metadata = utils.transpose_metadata(vol.info, inplace=False)
90
+
79
91
  if extension == 'zarr':
92
+ if '.ome.zarr' in filename:
93
+ import ome_zarr.io.parse_url
94
+ import ome_zarr.reader.Reader
95
+ raise NotImplementedError
96
+ return 'something something'
97
+
80
98
  if 'dataset' not in kwargs:
81
99
  dataset = filename + '/'
82
100
  while (not os.path.exists(os.path.join(dataset, '.zarray')) and
@@ -94,23 +112,18 @@ def load(filename, dim_order='zyx', **kwargs):
94
112
  raise Exception(f'Multiple datasets found within {filename}.'
95
113
  ' Pass a "dataset=" argument to specify the'
96
114
  ' one you want.')
115
+ raise NotImplementedError('Reading Zarr format not yet implemented.')
116
+ #import zarr
117
+ #data = zarr.open(TODO) #Finish this and be sure to check zyx/xyz order
97
118
 
98
- try:
99
- import daisy
100
- except:
101
- daisy = None
102
-
103
- if daisy:
104
- data = daisy.open_ds(filename, dataset)[:] #TODO check zyx/xyz order. Pretty sure daisy zarrs are zyx
105
- else:
106
- raise NotImplementedError
107
- import zarr
108
- data = zarr.open(TODO) #TODO check zyx/xyz order
119
+ if data is None:
120
+ raise ValueError(f'Could not read file {filename}. '
121
+ 'Please check the file format and try again.')
109
122
 
110
123
  if 'xy' in dim_order:
111
124
  data = data.T
112
- if extension == 'nrrd': # TODO check if other formats need this
113
- utils.transpose_metadata(metadata, inplace=True)
125
+ if extension in ['nrrd', 'ng']:
126
+ metadata = utils.transpose_metadata(metadata, inplace=False)
114
127
 
115
128
  if any([kwargs.get(key, False) for key in
116
129
  ['metadata', 'get_metadata', 'return_metadata']]):
@@ -153,10 +166,10 @@ def save(data,
153
166
  if os.path.exists(filename) and not overwrite:
154
167
  raise FileExistsError(f'File {filename} already exists. '
155
168
  'Set overwrite=True to overwrite.')
156
- extension = filename.split('.')[-1]
169
+ extension = filename.split('.')[-1].lower()
157
170
  assert extension in supported_extensions, f'Filetype {extension} not supported'
158
171
 
159
- if compress and extension not in ['nrrd', 'ng']:
172
+ if compress is not None and extension not in ['nrrd', 'ng']:
160
173
  print('WARNING: compress argument is ignored because not saving as '
161
174
  '.nrrd. Whether or not compression occurs now will depend on '
162
175
  'the format you are saving to.')
@@ -194,9 +207,15 @@ def save(data,
194
207
  metadata = metadata.copy()
195
208
  custom_field_map = {}
196
209
 
197
- if compress is True:
210
+ if compress is True or compress in ['lossless', 'gzip']:
198
211
  metadata.update({'encoding': 'gzip'})
199
- if compress is False or 'encoding' not in metadata:
212
+ elif compress is False:
213
+ metadata.update({'encoding': 'raw'})
214
+ elif compress is not None:
215
+ raise ValueError('For .nrrd format, compress must be '
216
+ 'True/"lossless"/"gzip", or False,'
217
+ f' but was "{compress}"')
218
+ if 'encoding' not in metadata:
200
219
  metadata.update({'encoding': 'raw'})
201
220
 
202
221
  if isinstance(metadata.get('space directions', None), str):
@@ -218,7 +237,7 @@ def save(data,
218
237
  pixel_size = [pixel_size] * data.ndim
219
238
  if len(pixel_size) != data.ndim:
220
239
  raise ValueError(f'pixel_size has length {len(pixel_size)},'
221
- ' but data has {data.ndim} dimensions.')
240
+ f' but data has {data.ndim} dimensions.')
222
241
 
223
242
  pixel_size_not_none = [size for size in pixel_size
224
243
  if size is not None and size != np.nan]
@@ -231,13 +250,7 @@ def save(data,
231
250
  metadata.update({'space directions': space_directions})
232
251
 
233
252
  if 'space dimension' not in metadata and 'space' not in metadata:
234
- # If the number of spatial dimensions is not specified, assume
235
- # it's the number of dimensions in the data array, minus 1 if
236
- # a channel axis is present.
237
- if find_channel_axis(data) is not None:
238
- metadata.update({'space dimension': data.ndim - 1})
239
- else:
240
- metadata.update({'space dimension': data.ndim})
253
+ metadata.update({'space dimension': data.ndim})
241
254
  if units is not None:
242
255
  if hasattr(units, '__iter__') and not isinstance(units, str):
243
256
  units = list(units)
@@ -339,13 +352,30 @@ write = save # Function name alias
339
352
  to_file = save # Function name alias
340
353
 
341
354
 
342
- def save_video(data, filename, overwrite=False, dim_order='yx', time_axis=0,
355
+ def save_video(data, filename, time_axis=0, overwrite=False, dim_order='yx',
343
356
  framerate=30, crf=23, compression_speed='medium'):
344
357
  """
345
358
  Save a 3D numpy array as a video, with a specified axis as the time axis.
346
359
 
347
360
  Follows the PyAV cookbook section on generating video from numpy arrays:
348
361
  https://pyav.basswood-io.com/docs/develop/cookbook/numpy.html#generating-video
362
+
363
+ Parameters
364
+ ----------
365
+ data : numpy.ndarray or list of filenames
366
+ A 3D numpy array of pixel values.
367
+
368
+ filename : str
369
+ The filename to save the video to.
370
+
371
+ time_axis : int, default 0
372
+ The axis of the data array that will be played as time in the video.
373
+
374
+ overwrite : bool, default False
375
+ Whether to overwrite the file if it already exists.
376
+
377
+ dim_order : 'yx' (default) or 'xy'
378
+ The order of the spatial dimensions in the input data.
349
379
  """
350
380
  if not data.ndim == 3:
351
381
  raise ValueError('Input data must be a 3D numpy array.')
@@ -359,13 +389,22 @@ def save_video(data, filename, overwrite=False, dim_order='yx', time_axis=0,
359
389
  data = data.swapaxes(1, 2)
360
390
  n_frames = data.shape[0]
361
391
 
362
- if filename.split('.')[-1] not in ['mp4', 'mkv', 'avi', 'mov']:
392
+ if filename.split('.')[-1].lower() not in ['mp4', 'mkv', 'avi', 'mov']:
363
393
  filename += '.mp4'
364
394
  if os.path.exists(filename) and not overwrite:
365
395
  raise FileExistsError(f'File {filename} already exists. '
366
396
  'Set overwrite=True to overwrite.')
367
- container = av.open(filename, mode='w')
397
+ extension = filename.split('.')[-1].lower()
398
+ if extension == 'mp4':
399
+ pad = [[0, 0], [0, 0], [0, 0]]
400
+ if data.shape[1] % 2 != 0:
401
+ pad[1][1] = 1
402
+ if data.shape[2] % 2 != 0:
403
+ pad[2][1] = 1
404
+ if pad != [[0, 0], [0, 0], [0, 0]]:
405
+ data = np.pad(data, pad, mode='edge')
368
406
 
407
+ container = av.open(filename, mode='w')
369
408
  stream = container.add_stream('libx264', rate=framerate)
370
409
  stream.pix_fmt = 'yuv420p'
371
410
  stream.options = {'crf': str(crf), 'preset': compression_speed}
@@ -385,8 +424,10 @@ def save_video(data, filename, overwrite=False, dim_order='yx', time_axis=0,
385
424
 
386
425
  def show(data,
387
426
  dim_order='yx',
388
- mode='PIL',
427
+ data_type: Literal['image', 'segmentation'] = 'image',
428
+ mode: Literal['PIL', 'mpl'] = 'PIL',
389
429
  convert_to_8bit=True,
430
+ channel_axis='guess',
390
431
  **kwargs):
391
432
  """
392
433
  Display a numpy array of pixel values as an image. Supported types:
@@ -410,6 +451,9 @@ def show(data,
410
451
  if os.path.exists(data):
411
452
  data = load(data)
412
453
 
454
+ if data_type == 'segmentation':
455
+ data = utils.assign_random_colors(data, seed=kwargs.get('seed', None))
456
+
413
457
  if (not data.ndim == 2) and not (data.ndim == 3 and find_channel_axis(data) is not None):
414
458
  m = ('Data must have shape (y, x) for grayscale, '
415
459
  '(y, x, 3) for RGB, or (y, x, 4) for RGBA but had '
@@ -420,8 +464,9 @@ def show(data,
420
464
 
421
465
  if 'xy' in dim_order:
422
466
  data = data.T
423
- channel_axis = find_channel_axis(data, expected_channel_axis=-1)
424
- if channel_axis is not None and channel_axis != -1:
467
+ if channel_axis == 'guess':
468
+ channel_axis = find_channel_axis(data)
469
+ if utils.isint(channel_axis) and channel_axis != -1:
425
470
  data = np.moveaxis(data, find_channel_axis(data), -1)
426
471
 
427
472
  if convert_to_8bit and data.dtype != np.uint8:
@@ -480,6 +525,7 @@ def find_channel_axis(data, expected_channel_axis=[0, -1]):
480
525
  Note that returning 0 means the channel axis was found and is the first
481
526
  axis, so be careful not to do a test like `if find_channel_axis(data):`
482
527
  because 0 will evaluate to False even though the data has a channel axis.
528
+ Instead write `if find_channel_axis(data) is not None:`
483
529
  """
484
530
  if isinstance(expected_channel_axis, int):
485
531
  expected_channel_axis = [expected_channel_axis]
@@ -19,14 +19,18 @@ def load(filename):
19
19
  while line.startswith(b'#'): # Print and skip comments
20
20
  print(line.decode().strip('\n'))
21
21
  line = f.readline()
22
- w, h = line.decode().strip().split()
23
- w = int(w)
24
- h = int(h)
25
- data = f.readline()
26
- data = np.unpackbits(np.frombuffer(data, dtype=np.uint8))
27
- data = data.reshape((h, w)).view(bool)
22
+ line = line.decode().strip().split()
23
+ w = int(line[0])
24
+ h = int(line[1])
28
25
 
29
- return data
26
+ # Calculate the number of bytes per row (padded to the next byte boundary)
27
+ row_bytes = (w + 7) // 8
28
+ data = np.frombuffer(f.read(row_bytes * h), dtype=np.uint8)
29
+
30
+ # Unpack bits and reshape, then slice to remove padding bits
31
+ data = np.unpackbits(data).reshape((h, row_bytes * 8))[:, :w]
32
+
33
+ return data.astype(bool)
30
34
 
31
35
 
32
36
  def save(data, filename, comments=None):
@@ -62,4 +66,38 @@ def save(data, filename, comments=None):
62
66
  f.write(b'\n')
63
67
 
64
68
  # Data
65
- f.write(np.packbits(data).tobytes())
69
+ # Pad each row to the next byte boundary
70
+ row_bytes = (data.shape[1] + 7) // 8
71
+ padded_data = np.zeros((data.shape[0], row_bytes * 8), dtype=bool)
72
+ padded_data[:, :data.shape[1]] = data
73
+ f.write(np.packbits(padded_data).tobytes())
74
+
75
+
76
+ def predict_file_size(data: np.ndarray) -> int:
77
+ """
78
+ Predict the file size of a PBM file given the image data.
79
+
80
+ Parameters
81
+ ----------
82
+ data : np.ndarray
83
+ The image data as a numpy array.
84
+
85
+ Returns
86
+ -------
87
+ int
88
+ The predicted file size in bytes.
89
+ """
90
+ if not isinstance(data, np.ndarray):
91
+ raise TypeError('data must be a np.ndarray but was {}'.format(type(data)))
92
+
93
+ # Header: 'P4\n' (3 bytes)
94
+ header_size = 3
95
+
96
+ # Dimensions: '<width> <height>\n'
97
+ dimensions_size = len(f"{data.shape[1]} {data.shape[0]}\n")
98
+
99
+ # Data: Each row is padded to the next byte boundary
100
+ row_bytes = (data.shape[1] + 7) // 8
101
+ data_size = row_bytes * data.shape[0]
102
+
103
+ return header_size + dimensions_size + data_size
@@ -51,7 +51,7 @@ import itertools
51
51
  import numpy as np
52
52
  np.set_printoptions(suppress=True)
53
53
 
54
- from .utils import eq, ifloor, iceil, iround
54
+ from .utils import eq, ifloor, iceil, iround, is_out_of_bounds, remove_out_of_bounds
55
55
 
56
56
 
57
57
  # --- Primitive shapes - points, lines, triangles, circles, spheres --- #
@@ -358,40 +358,28 @@ def imget(image, coords, convention='corner',
358
358
  makes more sense in most graphics applications.
359
359
  out_of_bounds, string : 'ignore' (default), 'wrap', or 'raise'
360
360
  """
361
- assert convention in ['corner', 'center']
362
361
  if not isinstance(coords, np.ndarray):
363
362
  coords = np.array(coords)
364
- ax = len(coords.shape) - 1
365
-
366
- if convention == 'corner':
367
- is_negative = coords < 0
368
- exceeds_dimensions = coords >= image.shape
369
- elif convention == 'center':
370
- is_negative = coords < -0.5
371
- exceeds_dimensions = coords + 0.5 >= image.shape
372
-
373
- is_out_of_bounds = np.logical_or(is_negative, exceeds_dimensions)
374
- if (is_out_of_bounds).any():
375
- def warn():
363
+ n_original = coords.shape[0]
364
+
365
+ if out_of_bounds == 'wrap':
366
+ allow_negative_wrapping = True
367
+ elif out_of_bounds == 'ignore':
368
+ allow_negative_wrapping = False
369
+ elif out_of_bounds != 'raise':
370
+ raise ValueError("out_of_bounds must be 'ignore', 'raise', or"
371
+ f"'wrap' but was {out_of_bounds}.")
372
+ coords = remove_out_of_bounds(coords, image.shape, allow_negative_wrapping, convention)
373
+ if n_original > coords.shape[0]:
374
+ n_out_of_bounds = n_original - coords.shape[0]
375
+ if out_of_bounds == 'raise':
376
+ raise IndexError(f'{n_out_of_bounds}/{n_original} coordinates'
377
+ 'are out of bounds.')
378
+ if verbose:
376
379
  print('WARNING: Some requested coordinates are out of bounds.'
377
380
  ' The returned list of values will be shorter than the'
378
381
  ' request list, and therefore the returned values will not'
379
382
  ' match 1-to-1 with the requested coordinates.')
380
- if out_of_bounds == 'ignore':
381
- coords = coords[~is_out_of_bounds.any(axis=ax)]
382
- if verbose:
383
- print(f'verbose={verbose}')
384
- warn()
385
- elif out_of_bounds == 'wrap':
386
- coords = coords[~exceeds_dimensions.any(axis=ax)]
387
- if verbose:
388
- warn()
389
- elif out_of_bounds == 'raise':
390
- raise IndexError('Some coordinates out of bounds:\n'
391
- f'{coords[is_out_of_bounds]}')
392
- else:
393
- raise ValueError("out_of_bounds must be 'ignore', 'raise', or"
394
- f"'wrap' but was {out_of_bounds}.")
395
383
 
396
384
  if convention == 'corner':
397
385
  return image[tuple(ifloor(coords).T)]
@@ -409,26 +397,20 @@ def imset(image, coords, value, convention='corner', out_of_bounds='ignore'):
409
397
  makes more sense in most graphics applications.
410
398
  out_of_bounds, string : 'ignore' (default), 'wrap', or 'raise'
411
399
  """
412
- assert convention in ['corner', 'center']
413
400
  if not isinstance(coords, np.ndarray):
414
401
  coords = np.array(coords)
415
402
 
416
- if convention == 'corner':
417
- is_negative = coords < 0
418
- exceeds_dimensions = coords >= image.shape
419
- else: # convention == 'center'
420
- is_negative = coords < -0.5
421
- exceeds_dimensions = coords + 0.5 >= image.shape
422
-
423
- is_out_of_bounds = np.logical_or(is_negative, exceeds_dimensions)
424
- if (is_out_of_bounds).any():
425
- if out_of_bounds == 'ignore':
426
- coords = coords[~is_out_of_bounds.any(axis=1)]
427
- elif out_of_bounds == 'wrap':
428
- coords = coords[~exceeds_dimensions.any(axis=1)]
403
+ if out_of_bounds == 'wrap':
404
+ allow_negative_wrapping = True
405
+ else:
406
+ allow_negative_wrapping = False
407
+ is_oob = is_out_of_bounds(coords, image.shape, allow_negative_wrapping, convention)
408
+ if is_oob.any():
409
+ if out_of_bounds in ['ignore', 'wrap']:
410
+ coords = coords[~is_oob]
429
411
  elif out_of_bounds == 'raise':
430
412
  raise IndexError('Some coordinates out of bounds:\n'
431
- f'{coords[is_out_of_bounds]}')
413
+ f'{coords[is_oob]}')
432
414
  else:
433
415
  raise ValueError("out_of_bounds must be 'ignore', 'raise', or"
434
416
  f"'wrap' but was {out_of_bounds}.")
@@ -642,8 +624,8 @@ def get_voxels_within_distance(distance, center=None, ndims=None,
642
624
  }
643
625
  d = norms[metric]
644
626
 
645
- candidate_pts_scaled = candidate_pts * voxel_size
646
- return candidate_pts[d(candidate_pts_scaled) <= distance] + center
627
+ candidate_pts_scaled = candidate_pts * voxel_size / distance
628
+ return candidate_pts[d(candidate_pts_scaled) <= 1] + center
647
629
 
648
630
 
649
631
  def floodfill(image, seed, fill_value, fill_diagonally=False,
@@ -19,6 +19,37 @@ import numpy as np
19
19
  from .utils import iround, eq, isint
20
20
 
21
21
 
22
+ def squeeze_dtype(image: np.ndarray):
23
+ """
24
+ Cast a numpy array to the smallest possible dtype without losing information.
25
+ """
26
+ if not isinstance(image, np.ndarray):
27
+ raise TypeError('image must be a numpy array')
28
+
29
+ im_has_fractions = not np.all(image == np.floor(image))
30
+ if im_has_fractions:
31
+ candidates = [np.float16, np.float32, np.float64]
32
+ # TODO some logic
33
+ print('WARNING: squeeze_dtype not fully implemented on float values,'
34
+ ' returning original data unchanged.')
35
+ return image
36
+ elif image.min() < 0:
37
+ candidates = [np.int8, np.int16, np.int32, np.int64]
38
+ elif image.max() <= 1:
39
+ return image.astype(bool, copy=False)
40
+ else:
41
+ candidates = [np.uint8, np.uint16, np.uint32, np.uint64]
42
+
43
+ image_max = image.max()
44
+ if image_max <= 1:
45
+ return image.astype(bool, copy=False)
46
+ for dtype in candidates:
47
+ info = np.iinfo(dtype)
48
+ if image_max <= info.max:
49
+ return image.astype(dtype, copy=False)
50
+ raise ValueError('Image has values larger than the maximum representable value')
51
+
52
+
22
53
  def to_8bit(image: np.ndarray, **kwargs) -> np.ndarray:
23
54
  """
24
55
  Convert an image to 8-bit.
@@ -184,7 +215,9 @@ def adjust_brightness(image: np.ndarray,
184
215
 
185
216
  def downsample(image: np.ndarray,
186
217
  factor: Union[int, Iterable[int]] = 2,
187
- keep_input_dtype=True) -> np.ndarray:
218
+ method: Literal['mean', 'median', 'max', 'min'] = 'mean',
219
+ keep_input_dtype=True,
220
+ verbose=False) -> np.ndarray:
188
221
  """
189
222
  Downsample an image by a given factor along each axis.
190
223
 
@@ -213,7 +246,8 @@ def downsample(image: np.ndarray,
213
246
  else:
214
247
  factor = (factor,) * len(image.shape)
215
248
  if len(factor) == len(image.shape) - 1 and image.shape[-1] in [3, 4]:
216
- print('RGB/RGBA image detected - not downsampling last axis.')
249
+ if verbose:
250
+ print('RGB/RGBA image detected - not downsampling last axis.')
217
251
  factor = (*factor, 1)
218
252
  if any([f > l > 1 for f, l in zip(factor, image.shape)]):
219
253
  raise ValueError('Downsampling factor must be <= image size along each axis')
@@ -243,8 +277,14 @@ def downsample(image: np.ndarray,
243
277
  temp_shape.extend([1, 1])
244
278
  else:
245
279
  temp_shape.extend([l // f, f])
246
- axes_to_avg = tuple(range(1, len(temp_shape), 2))
247
- image_downsampled = image.reshape(temp_shape).mean(axis=axes_to_avg)
280
+ axes_to_collapse = tuple(range(1, len(temp_shape), 2))
281
+ collapse_functions = {'mean': np.mean, 'max': np.max,
282
+ 'min': np.min, 'median': np.median}
283
+ try:
284
+ collapse_function = collapse_functions[method]
285
+ except KeyError:
286
+ raise ValueError(f'method must be one of {collapse_functions.keys()}')
287
+ image_downsampled = collapse_function(image.reshape(temp_shape), axis=axes_to_collapse)
248
288
 
249
289
  if keep_input_dtype:
250
290
  if np.issubdtype(image.dtype, np.integer):
@@ -533,3 +573,18 @@ def remove_bleedthrough(im, contaminated_slice, source_slice,
533
573
  """
534
574
  #TODO implement ICA and use it to separate the two independent sources
535
575
  raise NotImplementedError
576
+
577
+
578
+ def assign_random_colors(data: np.ndarray,
579
+ seed: Optional[int] = None,
580
+ verbose: bool = False,
581
+ ) -> np.ndarray:
582
+ rng = np.random.default_rng(seed)
583
+ if verbose:
584
+ print('Finding unique IDs...')
585
+ unique_ids, inverse = np.unique(data, return_inverse=True)
586
+ if verbose:
587
+ print('Assigning random colors...')
588
+ colors = rng.integers(0, 255, size=(len(unique_ids), 3), dtype=np.uint8)
589
+ data_colored = colors[inverse].reshape(data.shape + (3,))
590
+ return data_colored
@@ -75,6 +75,9 @@ def transpose_metadata(metadata: dict or OrderedDict,
75
75
  for key, value in metadata.items():
76
76
  if isinstance(value, str) or not hasattr(value, '__iter__'):
77
77
  continue
78
+ if key == 'scales':
79
+ value = [transpose_metadata(scale, inplace=False)
80
+ for scale in value]
78
81
  if isinstance(value, np.ndarray):
79
82
  value = np.flip(value)
80
83
  else:
@@ -82,3 +85,65 @@ def transpose_metadata(metadata: dict or OrderedDict,
82
85
  metadata[key] = value
83
86
  if not inplace:
84
87
  return metadata
88
+
89
+
90
+ def is_out_of_bounds(coords, shape, allow_negative_wrapping=False, convention='corner'):
91
+ """
92
+ Check if coordinates are out of the bounds of a given shape.
93
+
94
+ Parameters
95
+ ----------
96
+ coords: Coordinates to check (can be a list or numpy array).
97
+
98
+ shape: Shape of the volume (tuple or list).
99
+
100
+ allow_negative_wrapping
101
+ If False, all negative coordinates are considered out of bounds
102
+ If True, negative coordinates with absolute value less than the shape
103
+ are considered in bounds (in which case they can be used as indices
104
+ to a list or np.ndarray and they will wrap around).
105
+
106
+ convention: 'corner' or 'center' to specify whether the coordinate
107
+ 0 refers to the top-left corner of the first pixel (in which case
108
+ -0.1 is out of bounds) or the center of the first pixel (in which
109
+ case -0.1 is in bounds, down to -0.5 being the last in-bounds value).
110
+
111
+ Returns
112
+ -------
113
+ Boolean array indicating whether each coordinate is within bounds.
114
+ """
115
+ if convention not in ['corner', 'center']:
116
+ raise ValueError("Convention must be 'corner' or 'center'.")
117
+ if not isinstance(coords, np.ndarray):
118
+ coords = np.array(coords)
119
+ if coords.ndim == 1 and coords.shape[0] == len(shape):
120
+ return is_out_of_bounds(coords[np.newaxis, :], shape,
121
+ allow_negative_wrapping, convention)[0]
122
+ if coords.ndim != 2 or coords.shape[1] != len(shape):
123
+ raise ValueError(f'Coordinates must be a Nx{len(shape)} array, '
124
+ f'but got shape {coords.shape}.')
125
+
126
+ upper_limit = shape
127
+ if allow_negative_wrapping:
128
+ lower_limit = tuple(-i for i in shape)
129
+ else:
130
+ lower_limit = 0
131
+
132
+ if convention == 'center':
133
+ lower_limit = np.array(lower_limit) - 0.5
134
+ upper_limit = np.array(upper_limit) - 0.5
135
+
136
+ underflows = coords < lower_limit
137
+ overflows = coords >= upper_limit
138
+
139
+ return np.logical_or(underflows, overflows).any(axis=-1)
140
+
141
+
142
+ def is_in_bounds(*args, **kwargs):
143
+ return np.logical_not(is_out_of_bounds(*args, **kwargs))
144
+
145
+
146
+ def remove_out_of_bounds(coords, shape, allow_negative_wrapping=False,
147
+ convention='corner'):
148
+ in_bounds = is_in_bounds(coords, shape, allow_negative_wrapping, convention)
149
+ return coords[in_bounds]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: numpyimage
3
- Version: 2.1.1
3
+ Version: 2.2.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: GNU GENERAL PUBLIC LICENSE
@@ -15,4 +15,5 @@ numpyimage.egg-info/PKG-INFO
15
15
  numpyimage.egg-info/SOURCES.txt
16
16
  numpyimage.egg-info/dependency_links.txt
17
17
  numpyimage.egg-info/requires.txt
18
- numpyimage.egg-info/top_level.txt
18
+ numpyimage.egg-info/top_level.txt
19
+ tests/test_pbm.py
@@ -4,7 +4,7 @@ build-backend = 'setuptools.build_meta'
4
4
 
5
5
  [project]
6
6
  name = 'numpyimage'
7
- version = '2.1.1'
7
+ version = '2.2.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'
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import os
4
+
5
+ import numpy as np
6
+
7
+ import npimage
8
+
9
+
10
+ def test_pbm_load_save():
11
+ test_cases = [
12
+ {
13
+ "data": np.array([
14
+ [1, 0, 1, 0, 1],
15
+ [0, 1, 0, 1, 0],
16
+ [1, 1, 1, 0, 0]
17
+ ], dtype=bool),
18
+ "filename": "test_arbitrary_width.pbm",
19
+ "description": "PBM read/write, arbitrary width"
20
+ },
21
+ {
22
+ "data": np.array([
23
+ [1, 0, 1, 0, 1, 0, 1, 0],
24
+ [0, 1, 0, 1, 0, 1, 0, 1],
25
+ [1, 1, 1, 1, 0, 0, 0, 0],
26
+ [0, 0, 0, 0, 1, 1, 1, 1]
27
+ ], dtype=bool),
28
+ "filename": "test_multiple_of_8.pbm",
29
+ "description": "PBM read/write, width a multiple of 8"
30
+ }
31
+ ]
32
+
33
+ for case in test_cases:
34
+ test_data = case["data"]
35
+ test_filename = case["filename"]
36
+ description = case["description"]
37
+
38
+ try:
39
+ # Save the test data to a PBM file
40
+ npimage.save(test_data, test_filename)
41
+
42
+ # Verify the file size
43
+ expected_file_size = npimage.filetypes.pbm.predict_file_size(test_data)
44
+ actual_file_size = os.path.getsize(test_filename)
45
+ assert actual_file_size == expected_file_size, f"File size mismatch: expected {expected_file_size}, got {actual_file_size}"
46
+
47
+ # Load the data back from the PBM file
48
+ loaded_data = npimage.load(test_filename)
49
+
50
+ # Assert that the loaded data matches the original data
51
+ assert np.array_equal(test_data, loaded_data), f"Loaded data does not match original data for {description}"
52
+
53
+ print(f"Test passed: {description} works correctly.")
54
+ finally:
55
+ # Clean up the test file
56
+ if os.path.exists(test_filename):
57
+ os.remove(test_filename)
58
+
59
+
60
+ if __name__ == '__main__':
61
+ test_pbm_load_save()
@@ -1,84 +0,0 @@
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
File without changes
File without changes
File without changes