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.
- {numpyimage-2.1.1 → numpyimage-2.3.0}/PKG-INFO +1 -1
- {numpyimage-2.1.1 → numpyimage-2.3.0}/npimage/__init__.py +3 -0
- numpyimage-2.3.0/npimage/align.py +149 -0
- {numpyimage-2.1.1 → numpyimage-2.3.0}/npimage/core.py +114 -45
- {numpyimage-2.1.1 → numpyimage-2.3.0}/npimage/filetypes/pbm.py +46 -8
- {numpyimage-2.1.1 → numpyimage-2.3.0}/npimage/graphics.py +28 -46
- {numpyimage-2.1.1 → numpyimage-2.3.0}/npimage/operations.py +68 -6
- {numpyimage-2.1.1 → numpyimage-2.3.0}/npimage/utils.py +65 -0
- {numpyimage-2.1.1 → numpyimage-2.3.0}/numpyimage.egg-info/PKG-INFO +1 -1
- {numpyimage-2.1.1 → numpyimage-2.3.0}/numpyimage.egg-info/SOURCES.txt +3 -2
- {numpyimage-2.1.1 → numpyimage-2.3.0}/pyproject.toml +1 -1
- numpyimage-2.3.0/tests/test_pbm.py +61 -0
- numpyimage-2.3.0/tests/tests.py +39 -0
- numpyimage-2.1.1/npimage/align.py +0 -84
- numpyimage-2.1.1/npimage/align_scratch.py +0 -50
- {numpyimage-2.1.1 → numpyimage-2.3.0}/LICENSE +0 -0
- {numpyimage-2.1.1 → numpyimage-2.3.0}/README.md +0 -0
- {numpyimage-2.1.1 → numpyimage-2.3.0}/npimage/filetypes/__init__.py +0 -0
- {numpyimage-2.1.1 → numpyimage-2.3.0}/npimage/nrrd_utils.py +0 -0
- {numpyimage-2.1.1 → numpyimage-2.3.0}/numpyimage.egg-info/dependency_links.txt +0 -0
- {numpyimage-2.1.1 → numpyimage-2.3.0}/numpyimage.egg-info/requires.txt +0 -0
- {numpyimage-2.1.1 → numpyimage-2.3.0}/numpyimage.egg-info/top_level.txt +0 -0
- {numpyimage-2.1.1 → numpyimage-2.3.0}/setup.cfg +0 -0
|
@@ -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 "{
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
113
|
-
utils.transpose_metadata(metadata, inplace=
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
424
|
-
|
|
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
|
-
|
|
23
|
-
w = int(
|
|
24
|
-
h = int(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
if
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
if
|
|
375
|
-
|
|
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
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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[
|
|
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) <=
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
247
|
-
|
|
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]
|
|
@@ -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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|