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.
- {numpyimage-2.1.1 → numpyimage-2.2.0}/PKG-INFO +1 -1
- {numpyimage-2.1.1 → numpyimage-2.2.0}/npimage/__init__.py +3 -0
- numpyimage-2.2.0/npimage/align.py +149 -0
- {numpyimage-2.1.1 → numpyimage-2.2.0}/npimage/core.py +77 -31
- {numpyimage-2.1.1 → numpyimage-2.2.0}/npimage/filetypes/pbm.py +46 -8
- {numpyimage-2.1.1 → numpyimage-2.2.0}/npimage/graphics.py +28 -46
- {numpyimage-2.1.1 → numpyimage-2.2.0}/npimage/operations.py +59 -4
- {numpyimage-2.1.1 → numpyimage-2.2.0}/npimage/utils.py +65 -0
- {numpyimage-2.1.1 → numpyimage-2.2.0}/numpyimage.egg-info/PKG-INFO +1 -1
- {numpyimage-2.1.1 → numpyimage-2.2.0}/numpyimage.egg-info/SOURCES.txt +2 -1
- {numpyimage-2.1.1 → numpyimage-2.2.0}/pyproject.toml +1 -1
- numpyimage-2.2.0/tests/test_pbm.py +61 -0
- numpyimage-2.1.1/npimage/align.py +0 -84
- {numpyimage-2.1.1 → numpyimage-2.2.0}/LICENSE +0 -0
- {numpyimage-2.1.1 → numpyimage-2.2.0}/README.md +0 -0
- {numpyimage-2.1.1 → numpyimage-2.2.0}/npimage/align_scratch.py +0 -0
- {numpyimage-2.1.1 → numpyimage-2.2.0}/npimage/filetypes/__init__.py +0 -0
- {numpyimage-2.1.1 → numpyimage-2.2.0}/npimage/nrrd_utils.py +0 -0
- {numpyimage-2.1.1 → numpyimage-2.2.0}/numpyimage.egg-info/dependency_links.txt +0 -0
- {numpyimage-2.1.1 → numpyimage-2.2.0}/numpyimage.egg-info/requires.txt +0 -0
- {numpyimage-2.1.1 → numpyimage-2.2.0}/numpyimage.egg-info/top_level.txt +0 -0
- {numpyimage-2.1.1 → numpyimage-2.2.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
|
|
@@ -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']]):
|
|
@@ -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
|
-
|
|
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,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',
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
424
|
-
|
|
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
|
-
|
|
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,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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
247
|
-
|
|
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]
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|