numpyimage 2.1.1__tar.gz → 2.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: 2.1.1
3
+ Version: 2.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: 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
@@ -32,7 +33,7 @@ def load(filename, dim_order='zyx', **kwargs):
32
33
  images, or yxc for multi-channel (RGB/RGBA) 2D images.
33
34
  Set dim_order='xy' if you want to reverse the order of the axes.
34
35
  """
35
- filename = str(filename)
36
+ filename = os.path.expanduser(str(filename))
36
37
  while filename.endswith('/'):
37
38
  filename = filename[:-1]
38
39
  if 'format' in kwargs:
@@ -44,7 +45,7 @@ def load(filename, dim_order='zyx', **kwargs):
44
45
  f' "{filename}". Please specify the file type via'
45
46
  ' the `format` argument, e.g. format="tif"')
46
47
  if extension not in supported_extensions:
47
- raise ValueError(f'File format "{extension}" not supported/recognized.')
48
+ raise ValueError(f'File format of "{filename}" not supported/recognized.')
48
49
 
49
50
  data = None
50
51
  metadata = None
@@ -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']]):
@@ -148,15 +161,15 @@ def save(data,
148
161
  Currently `pixel_size`, `units`, and `compress` are only recognized
149
162
  when saving to .nrrd and .ng files, and ignored otherwise.
150
163
  """
151
- filename = str(filename)
164
+ filename = os.path.expanduser(str(filename))
152
165
  filename = filename.rstrip('/')
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,33 +352,79 @@ 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,
343
- framerate=30, crf=23, compression_speed='medium'):
355
+ def save_video(data, filename, time_axis=0, color_axis=None, overwrite=False,
356
+ dim_order='yx', framerate=30, crf=23, compression_speed='medium'):
344
357
  """
345
- Save a 3D numpy array as a video, with a specified axis as the time axis.
358
+ Save a 3D numpy array of greyscale values OR a 4D numpy array of RGB values as a video
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 (grayscale) or 4D (RGB) 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
+ color_axis : int or None, default None
375
+ If not None, specifies the axis of the color channels (e.g., -1 for last axis, 1 for second axis).
376
+ If None, data is assumed to be grayscale.
377
+
378
+ overwrite : bool, default False
379
+ Whether to overwrite the file if it already exists.
380
+
381
+ dim_order : 'yx' (default) or 'xy'
382
+ The order of the spatial dimensions in the input data.
349
383
  """
350
- if not data.ndim == 3:
351
- raise ValueError('Input data must be a 3D numpy array.')
352
384
  try:
353
385
  import av
354
386
  except ImportError:
355
- raise ImportError('To save videos, you must have PyAV installed. '
356
- 'You can install it with "pip install av".')
357
- data = np.moveaxis(data, time_axis, 0)
358
- if 'xy' in dim_order:
359
- data = data.swapaxes(1, 2)
360
- n_frames = data.shape[0]
387
+ raise ImportError('To save videos, you must have PyAV installed. You can install it with "pip install av".')
388
+
389
+ if color_axis is not None:
390
+ if data.ndim != 4:
391
+ raise ValueError('Input data must be a 4D numpy array for color video.')
392
+ # Move time axis to 0, color axis to -1
393
+ data = np.moveaxis(data, time_axis, 0)
394
+ if color_axis != -1:
395
+ data = np.moveaxis(data, color_axis, -1)
396
+ if 'xy' in dim_order:
397
+ data = data.swapaxes(1, 2)
398
+ n_frames = data.shape[0]
399
+ height, width, channels = data.shape[1:]
400
+ if channels != 3:
401
+ raise ValueError('Color video must have 3 channels (RGB).')
402
+ else:
403
+ if data.ndim != 3:
404
+ raise ValueError('Input data must be a 3D numpy array for grayscale video.')
405
+ data = np.moveaxis(data, time_axis, 0)
406
+ if 'xy' in dim_order:
407
+ data = data.swapaxes(1, 2)
408
+ n_frames = data.shape[0]
409
+ height, width = data.shape[1:]
361
410
 
362
- if filename.split('.')[-1] not in ['mp4', 'mkv', 'avi', 'mov']:
411
+ filename = os.path.expanduser(str(filename))
412
+ if filename.split('.')[-1].lower() not in ['mp4', 'mkv', 'avi', 'mov']:
363
413
  filename += '.mp4'
364
414
  if os.path.exists(filename) and not overwrite:
365
415
  raise FileExistsError(f'File {filename} already exists. '
366
416
  'Set overwrite=True to overwrite.')
367
- container = av.open(filename, mode='w')
417
+ extension = filename.split('.')[-1].lower()
418
+ if extension == 'mp4':
419
+ pad = [[0, 0], [0, 0], [0, 0]]
420
+ if height % 2 != 0:
421
+ pad[1][1] = 1
422
+ if width % 2 != 0:
423
+ pad[2][1] = 1
424
+ if pad != [[0, 0], [0, 0], [0, 0]]:
425
+ data = np.pad(data, pad, mode='edge')
368
426
 
427
+ container = av.open(filename, mode='w')
369
428
  stream = container.add_stream('libx264', rate=framerate)
370
429
  stream.pix_fmt = 'yuv420p'
371
430
  stream.options = {'crf': str(crf), 'preset': compression_speed}
@@ -373,7 +432,10 @@ def save_video(data, filename, overwrite=False, dim_order='yx', time_axis=0,
373
432
  stream.width = data.shape[2]
374
433
 
375
434
  for frame_i in range(n_frames):
376
- frame = av.VideoFrame.from_ndarray(data[frame_i], format='gray')
435
+ if color_axis is not None:
436
+ frame = av.VideoFrame.from_ndarray(data[frame_i], format='rgb24')
437
+ else:
438
+ frame = av.VideoFrame.from_ndarray(data[frame_i], format='gray')
377
439
  for packet in stream.encode(frame):
378
440
  container.mux(packet)
379
441
 
@@ -385,8 +447,10 @@ def save_video(data, filename, overwrite=False, dim_order='yx', time_axis=0,
385
447
 
386
448
  def show(data,
387
449
  dim_order='yx',
388
- mode='PIL',
450
+ data_type: Literal['image', 'segmentation'] = 'image',
451
+ mode: Literal['PIL', 'mpl'] = 'PIL',
389
452
  convert_to_8bit=True,
453
+ channel_axis='guess',
390
454
  **kwargs):
391
455
  """
392
456
  Display a numpy array of pixel values as an image. Supported types:
@@ -410,6 +474,9 @@ def show(data,
410
474
  if os.path.exists(data):
411
475
  data = load(data)
412
476
 
477
+ if data_type == 'segmentation':
478
+ data = utils.assign_random_colors(data, seed=kwargs.get('seed', None))
479
+
413
480
  if (not data.ndim == 2) and not (data.ndim == 3 and find_channel_axis(data) is not None):
414
481
  m = ('Data must have shape (y, x) for grayscale, '
415
482
  '(y, x, 3) for RGB, or (y, x, 4) for RGBA but had '
@@ -420,8 +487,9 @@ def show(data,
420
487
 
421
488
  if 'xy' in dim_order:
422
489
  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:
490
+ if channel_axis == 'guess':
491
+ channel_axis = find_channel_axis(data)
492
+ if utils.isint(channel_axis) and channel_axis != -1:
425
493
  data = np.moveaxis(data, find_channel_axis(data), -1)
426
494
 
427
495
  if convert_to_8bit and data.dtype != np.uint8:
@@ -480,6 +548,7 @@ def find_channel_axis(data, expected_channel_axis=[0, -1]):
480
548
  Note that returning 0 means the channel axis was found and is the first
481
549
  axis, so be careful not to do a test like `if find_channel_axis(data):`
482
550
  because 0 will evaluate to False even though the data has a channel axis.
551
+ Instead write `if find_channel_axis(data) is not None:`
483
552
  """
484
553
  if isinstance(expected_channel_axis, int):
485
554
  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,39 @@ import numpy as np
19
19
  from .utils import iround, eq, isint
20
20
 
21
21
 
22
+ def squeeze_dtype(image: np.ndarray, minimum_bits=1):
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
+ if minimum_bits > 64:
29
+ raise ValueError('minimum_bits must be <= 64')
30
+
31
+ im_has_fractions = not np.all(image == np.floor(image))
32
+ if im_has_fractions:
33
+ candidates = [np.float16, np.float32, np.float64]
34
+ # TODO some logic
35
+ print('WARNING: squeeze_dtype not fully implemented on float values,'
36
+ ' returning original data unchanged.')
37
+ return image
38
+ elif image.min() < 0:
39
+ candidates = [np.int8, np.int16, np.int32, np.int64]
40
+ elif image.max() <= 1 and minimum_bits <= 1:
41
+ return image.astype(bool, copy=False)
42
+ else:
43
+ candidates = [np.uint8, np.uint16, np.uint32, np.uint64]
44
+
45
+ image_max = image.max()
46
+ if image_max <= 1 and minimum_bits <= 1:
47
+ return image.astype(bool, copy=False)
48
+ for dtype in candidates:
49
+ info = np.iinfo(dtype)
50
+ if image_max <= info.max and info.bits >= minimum_bits:
51
+ return image.astype(dtype, copy=False)
52
+ raise ValueError('Image has values larger than the maximum representable value')
53
+
54
+
22
55
  def to_8bit(image: np.ndarray, **kwargs) -> np.ndarray:
23
56
  """
24
57
  Convert an image to 8-bit.
@@ -39,7 +72,8 @@ def to_16bit(image: np.ndarray, **kwargs) -> np.ndarray:
39
72
 
40
73
  def cast(image: np.ndarray,
41
74
  output_dtype: Union[str, np.dtype],
42
- maximize_contrast=True,
75
+ maximize_contrast: bool = False,
76
+ round_before_cast_to_int: bool = True,
43
77
  bottom_percentile=0.05,
44
78
  top_percentile=99.95,
45
79
  bottom_value=None,
@@ -101,11 +135,15 @@ def cast(image: np.ndarray,
101
135
  raise TypeError(f'Unsupported dtype: {output_dtype}')
102
136
 
103
137
  if not maximize_contrast:
138
+ if (round_before_cast_to_int
139
+ and np.issubdtype(output_dtype, np.integer)
140
+ and not np.issubdtype(image.dtype, np.integer)):
141
+ image = image.round()
104
142
  return np.clip(
105
143
  image,
106
144
  info.min,
107
145
  info.max
108
- ).astype(output_dtype)
146
+ ).astype(output_dtype, copy=False)
109
147
 
110
148
  if bottom_value is None or top_value is None:
111
149
  percentiles = np.percentile(image, [bottom_percentile, top_percentile])
@@ -184,7 +222,9 @@ def adjust_brightness(image: np.ndarray,
184
222
 
185
223
  def downsample(image: np.ndarray,
186
224
  factor: Union[int, Iterable[int]] = 2,
187
- keep_input_dtype=True) -> np.ndarray:
225
+ method: Literal['mean', 'median', 'max', 'min'] = 'mean',
226
+ keep_input_dtype=True,
227
+ verbose=False) -> np.ndarray:
188
228
  """
189
229
  Downsample an image by a given factor along each axis.
190
230
 
@@ -213,7 +253,8 @@ def downsample(image: np.ndarray,
213
253
  else:
214
254
  factor = (factor,) * len(image.shape)
215
255
  if len(factor) == len(image.shape) - 1 and image.shape[-1] in [3, 4]:
216
- print('RGB/RGBA image detected - not downsampling last axis.')
256
+ if verbose:
257
+ print('RGB/RGBA image detected - not downsampling last axis.')
217
258
  factor = (*factor, 1)
218
259
  if any([f > l > 1 for f, l in zip(factor, image.shape)]):
219
260
  raise ValueError('Downsampling factor must be <= image size along each axis')
@@ -243,8 +284,14 @@ def downsample(image: np.ndarray,
243
284
  temp_shape.extend([1, 1])
244
285
  else:
245
286
  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)
287
+ axes_to_collapse = tuple(range(1, len(temp_shape), 2))
288
+ collapse_functions = {'mean': np.mean, 'max': np.max,
289
+ 'min': np.min, 'median': np.median}
290
+ try:
291
+ collapse_function = collapse_functions[method]
292
+ except KeyError:
293
+ raise ValueError(f'method must be one of {collapse_functions.keys()}')
294
+ image_downsampled = collapse_function(image.reshape(temp_shape), axis=axes_to_collapse)
248
295
 
249
296
  if keep_input_dtype:
250
297
  if np.issubdtype(image.dtype, np.integer):
@@ -533,3 +580,18 @@ def remove_bleedthrough(im, contaminated_slice, source_slice,
533
580
  """
534
581
  #TODO implement ICA and use it to separate the two independent sources
535
582
  raise NotImplementedError
583
+
584
+
585
+ def assign_random_colors(data: np.ndarray,
586
+ seed: Optional[int] = None,
587
+ verbose: bool = False,
588
+ ) -> np.ndarray:
589
+ rng = np.random.default_rng(seed)
590
+ if verbose:
591
+ print('Finding unique IDs...')
592
+ unique_ids, inverse = np.unique(data, return_inverse=True)
593
+ if verbose:
594
+ print('Assigning random colors...')
595
+ colors = rng.integers(0, 255, size=(len(unique_ids), 3), dtype=np.uint8)
596
+ data_colored = colors[inverse].reshape(data.shape + (3,))
597
+ 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.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: GNU GENERAL PUBLIC LICENSE
@@ -3,7 +3,6 @@ README.md
3
3
  pyproject.toml
4
4
  npimage/__init__.py
5
5
  npimage/align.py
6
- npimage/align_scratch.py
7
6
  npimage/core.py
8
7
  npimage/graphics.py
9
8
  npimage/nrrd_utils.py
@@ -15,4 +14,6 @@ numpyimage.egg-info/PKG-INFO
15
14
  numpyimage.egg-info/SOURCES.txt
16
15
  numpyimage.egg-info/dependency_links.txt
17
16
  numpyimage.egg-info/requires.txt
18
- numpyimage.egg-info/top_level.txt
17
+ numpyimage.egg-info/top_level.txt
18
+ tests/test_pbm.py
19
+ tests/tests.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.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'
@@ -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()
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env python3
2
+
3
+ import numpy as np
4
+ import fire
5
+
6
+ import npimage
7
+ import npimage.operations
8
+
9
+ test_im = np.zeros((8, 8), dtype=np.uint8)
10
+ test_im[0:1, 0:2] = 255
11
+ test_im[1:3, 2:4] = 255
12
+ test_im[3:7, 3:7] = 255
13
+
14
+
15
+ def mplshow(*args):
16
+ npimage.show(*args, mode='matplotlib')
17
+
18
+
19
+ def test_offset():
20
+ mplshow(test_im)
21
+ mplshow(npimage.operations.offset(test_im, (0.5, 0)))
22
+ mplshow(npimage.operations.offset(test_im, (0, 0.5)))
23
+ mplshow(npimage.operations.offset(test_im, (0.5, 0.5)))
24
+ mplshow(npimage.operations.offset(test_im, (1, 1)))
25
+
26
+
27
+ def test_paste():
28
+ offsets = [('++', [100, 50]),
29
+ ('+-', [100, -50]),
30
+ ('-+', [-100, 50]),
31
+ ('--', [-100, -50])]
32
+ for offset in offsets:
33
+ im = npimage.load('firefox-logo.png', dim_order='xy')
34
+ npimage.operations.paste(im, im, offset[1])
35
+ npimage.save(im, 'firefox-logo_paste' + offset[0] + '.png', dim_order='xy')
36
+
37
+
38
+ if __name__ == '__main__':
39
+ fire.Fire()
@@ -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
@@ -1,50 +0,0 @@
1
-
2
-
3
-
4
- def align(im1, im2, num_divisions=4):
5
- """
6
- Return a translated version of im1 that is aligned with im2.
7
-
8
- To align the images, this function divides im1 into a grid of
9
- num_divisions x num_divisions regions. For each region, we find the
10
- region in im2 that is most similar to it. After removing outliers
11
- for which the similarity score is too low, we calculate the average
12
- translation between the two images. We then shift im1 by this
13
- average translation.
14
- """
15
- # The code below is entirely GitHub copilot generated and needs to
16
- # be looked at, finished, tested, etc
17
-
18
- # Divide the image into a grid of num_divisions x num_divisions regions
19
- height, width = im1.shape
20
- region_height = height // num_divisions
21
- region_width = width // num_divisions
22
-
23
- # Initialize lists to store the translations for each region
24
- translations = []
25
-
26
- # For each region in im1, find the most similar region in im2
27
- for i in range(num_divisions):
28
- for j in range(num_divisions):
29
- # Define the region in im1
30
- top_left = (i * region_height, j * region_width)
31
- bottom_right = ((i + 1) * region_height, (j + 1) * region_width)
32
- region1 = im1[top_left[0]:bottom_right[0], top_left[1]:bottom_right[1]]
33
-
34
- # Define the search region in im2
35
- search_bbox = (slice(max(0, top_left[0] - region_height),
36
- min(height, bottom_right[0] + region_height)),
37
- slice(max(0, top_left[1] - region_width),
38
- min(width, bottom_right[1] + region_width)))
39
-
40
- # Find the most similar region in im2
41
- top_left2, score = find_landmark(im2, region1, search_bbox=search_bbox)
42
-
43
- # Add the translation to the list of translations
44
- translations.append((top_left[0] - top_left2[0], top_left[1] - top_left2[1]))
45
-
46
- # Remove outliers from the list of translations
47
- translations = np.array(translations)
48
- mean_translations = np.mean(translations)
49
- # Shift im1 by the average translation
50
- aligned_im1 = np.roll(im1, mean_translations, axis=(0, 1))
File without changes
File without changes
File without changes