numpyimage 3.1.2__tar.gz → 3.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-3.1.2/numpyimage.egg-info → numpyimage-3.2.0}/PKG-INFO +1 -1
- {numpyimage-3.1.2 → numpyimage-3.2.0}/npimage/graphics.py +189 -141
- {numpyimage-3.1.2 → numpyimage-3.2.0}/npimage/vidio.py +48 -25
- {numpyimage-3.1.2 → numpyimage-3.2.0/numpyimage.egg-info}/PKG-INFO +1 -1
- {numpyimage-3.1.2 → numpyimage-3.2.0}/pyproject.toml +1 -1
- {numpyimage-3.1.2 → numpyimage-3.2.0}/LICENSE +0 -0
- {numpyimage-3.1.2 → numpyimage-3.2.0}/README.md +0 -0
- {numpyimage-3.1.2 → numpyimage-3.2.0}/npimage/__init__.py +0 -0
- {numpyimage-3.1.2 → numpyimage-3.2.0}/npimage/align.py +0 -0
- {numpyimage-3.1.2 → numpyimage-3.2.0}/npimage/imageio.py +0 -0
- {numpyimage-3.1.2 → numpyimage-3.2.0}/npimage/nrrd_utils.py +0 -0
- {numpyimage-3.1.2 → numpyimage-3.2.0}/npimage/operations.py +0 -0
- {numpyimage-3.1.2 → numpyimage-3.2.0}/npimage/utils.py +0 -0
- {numpyimage-3.1.2 → numpyimage-3.2.0}/numpyimage.egg-info/SOURCES.txt +0 -0
- {numpyimage-3.1.2 → numpyimage-3.2.0}/numpyimage.egg-info/dependency_links.txt +0 -0
- {numpyimage-3.1.2 → numpyimage-3.2.0}/numpyimage.egg-info/requires.txt +0 -0
- {numpyimage-3.1.2 → numpyimage-3.2.0}/numpyimage.egg-info/top_level.txt +0 -0
- {numpyimage-3.1.2 → numpyimage-3.2.0}/setup.cfg +0 -0
- {numpyimage-3.1.2 → numpyimage-3.2.0}/tests/test_heic.py +0 -0
- {numpyimage-3.1.2 → numpyimage-3.2.0}/tests/test_pbm.py +0 -0
- {numpyimage-3.1.2 → numpyimage-3.2.0}/tests/test_video_writers.py +0 -0
|
@@ -1,50 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
(x, y, z) will actually get or set the pixel with index (round(x), round(y),
|
|
20
|
-
round(z)). Therefore the pixel at location (x, y, z) represents the cube of
|
|
21
|
-
physical locations (a, b, c) such that a is in [x-0.5, x+0.5), b is in
|
|
22
|
-
[y-0.5, y+0.5), and c is in [z-0.5, z+0.5). This is called the 'center'
|
|
23
|
-
convention because integer locations like (5, 2, 10) point to the middle of a
|
|
24
|
-
voxel. This convention can be selected by passing convention='center' to any
|
|
25
|
-
of the functions in this package (once I finish implementing it).
|
|
26
|
-
|
|
27
|
-
Functions in this package use non-integer data types to preserve accuracy,
|
|
28
|
-
only flooring or rounding (depending on the convention) during get-pixel or
|
|
29
|
-
set-pixel operations.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
LIST OF MAJOR TODOS
|
|
33
|
-
|
|
34
|
-
Implement 'center' convention logic for all functions.
|
|
35
|
-
|
|
36
|
-
Make the image argument optional for drawpoint, drawline, and drawtriangle,
|
|
37
|
-
in which case they should create an image just large enough to hold the drawn
|
|
38
|
-
object and then return that image
|
|
39
|
-
|
|
40
|
-
Make get_voxels_within_distance be able to take a multidimensional distance
|
|
41
|
-
argument, in which case it should return all the voxels within an ellipse
|
|
42
|
-
having its radii along each axis specified by the elements of distance. This
|
|
43
|
-
is an extension of the current functionality, since currently a sphere is
|
|
44
|
-
returned with radius in all dimensions equal to the int/float distance.
|
|
3
|
+
Fnctions for drawing basic geometric shapes, like lines and triangles,
|
|
4
|
+
into 3D numpy arrays (and the code almost works for nD arrays for any n).
|
|
5
|
+
Related tools:
|
|
6
|
+
- opencv has drawline and drawtriangle functions, but those only
|
|
7
|
+
operate on 2D image arrays.
|
|
8
|
+
- scipy.ndimage provides many other helpful, related functions for
|
|
9
|
+
working with nD image arrays.
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
TODOS:
|
|
13
|
+
- Implement 'center' convention logic for all functions.
|
|
14
|
+
- Make get_voxels_within_distance be able to take a multidimensional distance
|
|
15
|
+
argument, in which case it should return all the voxels within an ellipse
|
|
16
|
+
having its radii along each axis specified by the elements of distance. This
|
|
17
|
+
is an extension of the current functionality, since currently a sphere is
|
|
18
|
+
returned with radius in all dimensions equal to the int/float distance.
|
|
45
19
|
"""
|
|
46
20
|
|
|
47
|
-
|
|
21
|
+
from typing import Literal
|
|
48
22
|
import math
|
|
49
23
|
import itertools
|
|
50
24
|
|
|
@@ -56,32 +30,39 @@ from .utils import eq, ifloor, iceil, iround, is_out_of_bounds, remove_out_of_bo
|
|
|
56
30
|
|
|
57
31
|
# --- Primitive shapes - points, lines, triangles, circles, spheres --- #
|
|
58
32
|
#TODO out_of_bounds='raise' isn't actually raising a lot of the time. Fix it.
|
|
59
|
-
def drawpoint(image, coord, value, thickness=1,
|
|
60
|
-
convention='corner', out_of_bounds='ignore'):
|
|
33
|
+
def drawpoint(image, coord, value, thickness=1, **imset_kwargs):
|
|
61
34
|
"""
|
|
62
|
-
|
|
35
|
+
Change an image's value at a point or collection of points.
|
|
36
|
+
|
|
37
|
+
See npimage.graphics.imset's docstring for info on additional keyword arguments:
|
|
38
|
+
- convention: Literal['corner', 'center'] = 'corner'
|
|
39
|
+
- out_of_bounds: Literal['ignore', 'wrap', 'raise'] = 'ignore'
|
|
40
|
+
- add_missing_dims: Literal[None, 'start', 'end'] = None
|
|
63
41
|
"""
|
|
64
42
|
coords = thicken(coord, thickness)
|
|
65
|
-
imset(image, coords, value,
|
|
66
|
-
convention=convention, out_of_bounds=out_of_bounds)
|
|
43
|
+
imset(image, coords, value, **imset_kwargs)
|
|
67
44
|
|
|
68
45
|
|
|
69
|
-
# TODO switch to kwargs
|
|
70
46
|
def drawline(image, pt1, pt2, value, thickness=1,
|
|
71
|
-
convention='corner',
|
|
47
|
+
convention='corner', **imset_kwargs):
|
|
72
48
|
"""
|
|
49
|
+
Change an image's values along a line from pt1 to pt2.
|
|
50
|
+
|
|
73
51
|
DDA algorithm described in
|
|
74
52
|
https://www.tutorialspoint.com/computer_graphics/line_generation_algorithm.htm
|
|
75
53
|
with some additional logic that allows for non-integer point coordinates.
|
|
54
|
+
|
|
55
|
+
See npimage.graphics.imset's docstring for info on additional keyword arguments:
|
|
56
|
+
- convention: Literal['corner', 'center'] = 'corner'
|
|
57
|
+
- out_of_bounds: Literal['ignore', 'wrap', 'raise'] = 'ignore'
|
|
58
|
+
- add_missing_dims: Literal[None, 'start', 'end'] = None
|
|
76
59
|
"""
|
|
77
60
|
pt1, pt2, long_axis = _preprocess_polygon_vertices(pt1, pt2)
|
|
78
|
-
|
|
61
|
+
imset_kwargs['convention'] = convention
|
|
79
62
|
|
|
80
63
|
# Deal with the start and end points as special - always draw them
|
|
81
|
-
drawpoint(image, pt1, value, thickness=thickness,
|
|
82
|
-
|
|
83
|
-
drawpoint(image, pt2, value, thickness=thickness,
|
|
84
|
-
convention=convention, out_of_bounds=out_of_bounds)
|
|
64
|
+
drawpoint(image, pt1, value, thickness=thickness, **imset_kwargs)
|
|
65
|
+
drawpoint(image, pt2, value, thickness=thickness, **imset_kwargs)
|
|
85
66
|
|
|
86
67
|
vec_1to2 = pt2 - pt1
|
|
87
68
|
if eq(vec_1to2[long_axis], 0):
|
|
@@ -95,7 +76,6 @@ def drawline(image, pt1, pt2, value, thickness=1,
|
|
|
95
76
|
pt2_adjustment = iceil(pt2[long_axis] - 0.5) - 0.5 - pt2[long_axis]
|
|
96
77
|
pt1 += vec_1to2 * pt1_adjustment / vec_1to2[long_axis]
|
|
97
78
|
pt2 += vec_1to2 * pt2_adjustment / vec_1to2[long_axis]
|
|
98
|
-
#print(f'Adjusted endpoints:\npt1={pt1}\npt2={pt2}')
|
|
99
79
|
elif convention == 'center':
|
|
100
80
|
#TODO Test this block more rigorously for corner case performance
|
|
101
81
|
# Will be marking a pixel every time the line connecting pt1 and
|
|
@@ -105,7 +85,6 @@ def drawline(image, pt1, pt2, value, thickness=1,
|
|
|
105
85
|
pt2_adjustment = iceil(pt2[long_axis] - 1) - pt2[long_axis]
|
|
106
86
|
pt1 += vec_1to2 * pt1_adjustment / vec_1to2[long_axis]
|
|
107
87
|
pt2 += vec_1to2 * pt2_adjustment / vec_1to2[long_axis]
|
|
108
|
-
#print(f'Adjusted endpoints:\npt1={pt1}\npt2={pt2}')
|
|
109
88
|
|
|
110
89
|
if pt1[long_axis] > pt2[long_axis]:
|
|
111
90
|
return # Line is so short that no more points need to be marked
|
|
@@ -116,8 +95,7 @@ def drawline(image, pt1, pt2, value, thickness=1,
|
|
|
116
95
|
n_steps = iround(n_steps)
|
|
117
96
|
|
|
118
97
|
if n_steps == 0: # pt1 == pt2, so only one point to mark
|
|
119
|
-
drawpoint(image, pt1, value, thickness=thickness,
|
|
120
|
-
convention=convention, out_of_bounds=out_of_bounds)
|
|
98
|
+
drawpoint(image, pt1, value, thickness=thickness, **imset_kwargs)
|
|
121
99
|
return
|
|
122
100
|
|
|
123
101
|
pts = np.outer(np.arange(n_steps+1) / n_steps, vec_1to2) + pt1
|
|
@@ -125,22 +103,29 @@ def drawline(image, pt1, pt2, value, thickness=1,
|
|
|
125
103
|
# slightly (20%) faster than calling drawpoint - despite drawpoint
|
|
126
104
|
# literally just calling thicken and then imset itself. ???
|
|
127
105
|
pts = thicken(pts, thickness)
|
|
128
|
-
imset(image, pts, value,
|
|
129
|
-
convention=convention, out_of_bounds=out_of_bounds)
|
|
106
|
+
imset(image, pts, value, **imset_kwargs)
|
|
130
107
|
|
|
131
108
|
|
|
132
|
-
# TODO switch to kwargs
|
|
133
109
|
def drawtriangle(image, pt1, pt2, pt3, value, thickness=1,
|
|
134
|
-
watertight=True,
|
|
135
|
-
|
|
110
|
+
watertight=True, fill_value=None, **imset_kwargs):
|
|
111
|
+
"""
|
|
112
|
+
Draw a triangle into an image.
|
|
113
|
+
|
|
114
|
+
If `fill_value` is None, only the three edges of the triangle will be
|
|
115
|
+
drawn. If `fill_value` is a number, the inside of the triangle will be
|
|
116
|
+
drawn with that value. Note that `fill_value` for the inside of the
|
|
117
|
+
triangle can be the same as or different from `value` for the edge lines.
|
|
118
|
+
|
|
119
|
+
See npimage.graphics.imset's docstring for info on additional keyword arguments:
|
|
120
|
+
- convention: Literal['corner', 'center'] = 'corner'
|
|
121
|
+
- out_of_bounds: Literal['ignore', 'wrap', 'raise'] = 'ignore'
|
|
122
|
+
- add_missing_dims: Literal[None, 'start', 'end'] = None
|
|
123
|
+
"""
|
|
136
124
|
|
|
137
125
|
if fill_value is None:
|
|
138
|
-
drawline(image, pt1, pt2, value, thickness=thickness,
|
|
139
|
-
|
|
140
|
-
drawline(image,
|
|
141
|
-
convention=convention, out_of_bounds=out_of_bounds)
|
|
142
|
-
drawline(image, pt3, pt1, value, thickness=thickness,
|
|
143
|
-
convention=convention, out_of_bounds=out_of_bounds)
|
|
126
|
+
drawline(image, pt1, pt2, value, thickness=thickness, **imset_kwargs)
|
|
127
|
+
drawline(image, pt2, pt3, value, thickness=thickness, **imset_kwargs)
|
|
128
|
+
drawline(image, pt3, pt1, value, thickness=thickness, **imset_kwargs)
|
|
144
129
|
return
|
|
145
130
|
|
|
146
131
|
pt1, pt2, pt3, long_axis = _preprocess_polygon_vertices(pt1, pt2, pt3)
|
|
@@ -166,7 +151,7 @@ def drawtriangle(image, pt1, pt2, pt3, value, thickness=1,
|
|
|
166
151
|
|
|
167
152
|
try:
|
|
168
153
|
iter(thickness)
|
|
169
|
-
except:
|
|
154
|
+
except TypeError:
|
|
170
155
|
thickness = np.full(len(image.shape), thickness)
|
|
171
156
|
|
|
172
157
|
def draw_perpendicular_from_triangle_base(basept):
|
|
@@ -195,8 +180,7 @@ def drawtriangle(image, pt1, pt2, pt3, value, thickness=1,
|
|
|
195
180
|
else:
|
|
196
181
|
thickness[long_axis - 2] += 1
|
|
197
182
|
|
|
198
|
-
drawline(image, basept, endpt, fill_value, thickness=thickness,
|
|
199
|
-
convention=convention, out_of_bounds=out_of_bounds)
|
|
183
|
+
drawline(image, basept, endpt, fill_value, thickness=thickness, **imset_kwargs)
|
|
200
184
|
|
|
201
185
|
if n_steps == 0:
|
|
202
186
|
draw_perpendicular_from_triangle_base(pt1_adjusted)
|
|
@@ -205,26 +189,27 @@ def drawtriangle(image, pt1, pt2, pt3, value, thickness=1,
|
|
|
205
189
|
startpt = pt1_adjusted + (i / n_steps) * vec_1to3_adj
|
|
206
190
|
draw_perpendicular_from_triangle_base(startpt)
|
|
207
191
|
|
|
208
|
-
drawline(image, pt1, pt2, value, thickness=thickness,
|
|
209
|
-
|
|
210
|
-
drawline(image,
|
|
211
|
-
convention=convention, out_of_bounds=out_of_bounds)
|
|
212
|
-
drawline(image, pt3, pt1, value, thickness=thickness,
|
|
213
|
-
convention=convention, out_of_bounds=out_of_bounds)
|
|
192
|
+
drawline(image, pt1, pt2, value, thickness=thickness, **imset_kwargs)
|
|
193
|
+
drawline(image, pt2, pt3, value, thickness=thickness, **imset_kwargs)
|
|
194
|
+
drawline(image, pt3, pt1, value, thickness=thickness, **imset_kwargs)
|
|
214
195
|
|
|
215
196
|
|
|
216
197
|
def drawcircle(image, center, perpendicular, radius, value,
|
|
217
|
-
fill=False, spacing=1, **
|
|
198
|
+
fill=False, spacing=1, **imset_kwargs):
|
|
218
199
|
"""
|
|
219
|
-
Draw a circle
|
|
220
|
-
|
|
200
|
+
Draw a circle into an image.
|
|
201
|
+
|
|
202
|
+
See npimage.graphics.imset's docstring for info on additional keyword arguments:
|
|
203
|
+
- convention: Literal['corner', 'center'] = 'corner'
|
|
204
|
+
- out_of_bounds: Literal['ignore', 'wrap', 'raise'] = 'ignore'
|
|
205
|
+
- add_missing_dims: Literal[None, 'start', 'end'] = None
|
|
221
206
|
"""
|
|
222
207
|
ndims = 3 # TODO code up logic for determining this from the input
|
|
223
208
|
return_image = False
|
|
224
209
|
if image is None and center is None:
|
|
225
210
|
image = np.zeros((2*iceil(radius) + 1,)*ndims, dtype=np.uint8)
|
|
226
211
|
center = (radius, radius, radius)
|
|
227
|
-
|
|
212
|
+
imset_kwargs['convention'] = 'center'
|
|
228
213
|
return_image = True
|
|
229
214
|
|
|
230
215
|
norm = lambda v: np.sqrt(sum(v**2))
|
|
@@ -241,7 +226,6 @@ def drawcircle(image, center, perpendicular, radius, value,
|
|
|
241
226
|
v0 = v0 / norm(v0)
|
|
242
227
|
v1 = v1 / norm(v1)
|
|
243
228
|
|
|
244
|
-
half_circumfrence = math.pi*radius
|
|
245
229
|
num_pts = iceil(math.pi*radius/spacing) + 1
|
|
246
230
|
angles = np.linspace(0, math.pi, num_pts)
|
|
247
231
|
half1 = (np.outer(np.cos(angles), v0)
|
|
@@ -249,42 +233,45 @@ def drawcircle(image, center, perpendicular, radius, value,
|
|
|
249
233
|
half2 = (np.outer(np.cos(angles), v0)
|
|
250
234
|
- np.outer(np.sin(angles), v1)) * radius + center
|
|
251
235
|
if fill:
|
|
252
|
-
#import npimage # for testing
|
|
253
236
|
for pt1, pt2 in zip(half1, half2):
|
|
254
|
-
drawline(image, pt1, pt2, value, **
|
|
255
|
-
#print(f'Just drew {pt1}->{pt2}') # for testing
|
|
256
|
-
#npimage.imshow(image[0], mode='mpl') # for testing
|
|
237
|
+
drawline(image, pt1, pt2, value, **imset_kwargs)
|
|
257
238
|
else:
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
draw(half2)
|
|
239
|
+
imset(image, half1, value, **imset_kwargs)
|
|
240
|
+
imset(image, half2, value, **imset_kwargs)
|
|
261
241
|
|
|
262
242
|
if return_image:
|
|
263
243
|
return image
|
|
264
244
|
|
|
265
245
|
|
|
266
|
-
def drawsphere(
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
# TODO switch to kwargs
|
|
271
|
-
def drawneighborhood(image=None, distance=None, ndims=None, value=1,
|
|
272
|
-
center=None, voxel_size=None, metric='euclidian',
|
|
273
|
-
out_of_bounds='ignore'):
|
|
246
|
+
def drawsphere(image=None, distance=None, ndims=None, value=1,
|
|
247
|
+
center=None, voxel_size=None, metric='euclidian',
|
|
248
|
+
**imset_kwargs):
|
|
274
249
|
"""
|
|
275
250
|
Draw a value into all pixels within a certain distance of a location.
|
|
276
|
-
|
|
277
|
-
|
|
251
|
+
|
|
252
|
+
drawsphere is a more general version of drawpoint. These are equivalent:
|
|
253
|
+
>>> im = np.full((240, 240), 255, dtype=np.uint8)
|
|
254
|
+
>>> npimage.drawsphere(im, distance=24, value=0, center=(120, 120), metric='chebyshev')
|
|
255
|
+
>>> npimage.drawpoint(im, (120, 120), 128, thickness=49)
|
|
256
|
+
|
|
257
|
+
which both draw boxes. But drawsphere with `metric='euclidian'` actually
|
|
258
|
+
draws spheres.
|
|
259
|
+
|
|
278
260
|
If you want an image made for you with size just large enough to fit the
|
|
279
|
-
requested
|
|
261
|
+
requested sphere, leave `image=None`.
|
|
262
|
+
|
|
263
|
+
See npimage.graphics.imset's docstring for info on additional keyword arguments:
|
|
264
|
+
- convention: Literal['corner', 'center'] = 'corner'
|
|
265
|
+
- out_of_bounds: Literal['ignore', 'wrap', 'raise'] = 'ignore'
|
|
266
|
+
- add_missing_dims: Literal[None, 'start', 'end'] = None
|
|
280
267
|
"""
|
|
281
268
|
if ndims is None:
|
|
282
269
|
try:
|
|
283
270
|
ndims = len(distance)
|
|
284
|
-
except:
|
|
271
|
+
except TypeError:
|
|
285
272
|
try:
|
|
286
273
|
ndims = len(image.shape)
|
|
287
|
-
except:
|
|
274
|
+
except TypeError:
|
|
288
275
|
if voxel_size is not None:
|
|
289
276
|
ndims = len(voxel_size)
|
|
290
277
|
elif center is not None:
|
|
@@ -292,7 +279,7 @@ def drawneighborhood(image=None, distance=None, ndims=None, value=1,
|
|
|
292
279
|
else:
|
|
293
280
|
raise Exception('Specify the dimensionality you want by'
|
|
294
281
|
"passing 'ndims='")
|
|
295
|
-
|
|
282
|
+
|
|
296
283
|
if distance is None and image is None:
|
|
297
284
|
raise Exception('Must specify distance or image.')
|
|
298
285
|
|
|
@@ -334,17 +321,17 @@ def drawneighborhood(image=None, distance=None, ndims=None, value=1,
|
|
|
334
321
|
else:
|
|
335
322
|
image = np.zeros(ifloor(center + distance + 1))
|
|
336
323
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
#print(f'image.shape={image.shape}')
|
|
340
|
-
|
|
324
|
+
if imset_kwargs.get('add_missing_dims', None) is not None:
|
|
325
|
+
ndims = None
|
|
341
326
|
voxels = get_voxels_within_distance(distance, center=center, ndims=ndims,
|
|
342
327
|
voxel_size=voxel_size, metric=metric)
|
|
343
|
-
imset(image, voxels, value,
|
|
328
|
+
imset(image, voxels, value, **imset_kwargs)
|
|
344
329
|
|
|
345
330
|
if return_image:
|
|
346
331
|
return image
|
|
347
332
|
|
|
333
|
+
drawneighborhood = drawsphere # Function name alias
|
|
334
|
+
|
|
348
335
|
|
|
349
336
|
# --- Low-level wrappers for getting and setting values from arrays --- #
|
|
350
337
|
def imget(image, coords, convention='corner',
|
|
@@ -369,7 +356,8 @@ def imget(image, coords, convention='corner',
|
|
|
369
356
|
elif out_of_bounds != 'raise':
|
|
370
357
|
raise ValueError("out_of_bounds must be 'ignore', 'raise', or"
|
|
371
358
|
f"'wrap' but was {out_of_bounds}.")
|
|
372
|
-
coords = remove_out_of_bounds(coords, image.shape,
|
|
359
|
+
coords = remove_out_of_bounds(coords, image.shape,
|
|
360
|
+
allow_negative_wrapping, convention)
|
|
373
361
|
if n_original > coords.shape[0]:
|
|
374
362
|
n_out_of_bounds = n_original - coords.shape[0]
|
|
375
363
|
if out_of_bounds == 'raise':
|
|
@@ -387,15 +375,72 @@ def imget(image, coords, convention='corner',
|
|
|
387
375
|
return image[tuple(iround(coords).T)]
|
|
388
376
|
|
|
389
377
|
|
|
390
|
-
def imset(image, coords, value,
|
|
378
|
+
def imset(image, coords, value,
|
|
379
|
+
convention: Literal['corner', 'center'] = 'corner',
|
|
380
|
+
out_of_bounds: Literal['ignore', 'wrap', 'raise'] = 'ignore',
|
|
381
|
+
add_missing_dims: Literal[None, 'start', 'end'] = None):
|
|
391
382
|
"""
|
|
392
383
|
Given a numpy array, set a particular coordinate or set of coordinates to
|
|
393
384
|
have a given value.
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
385
|
+
|
|
386
|
+
Parameters
|
|
387
|
+
----------
|
|
388
|
+
image : numpy.ndarray
|
|
389
|
+
The image to set pixel value(s) in.
|
|
390
|
+
|
|
391
|
+
coords : numpy.ndarray or array-like
|
|
392
|
+
The coordinate locations to set values of.
|
|
393
|
+
|
|
394
|
+
value : int, float, or array-like
|
|
395
|
+
The value to set the pixel(s) at the given coordinates to.
|
|
396
|
+
|
|
397
|
+
convention : {'corner', 'center'}, default 'corner'
|
|
398
|
+
Whether to truncate ('corner') or round ('center') fractional
|
|
399
|
+
values in the `coords` argument.
|
|
400
|
+
|
|
401
|
+
'corner' (default):
|
|
402
|
+
In this mode, coordinate (5, 2) and coordinate (4.99, 2) refer
|
|
403
|
+
to different pixels because pixel borders are at integer values.
|
|
404
|
+
Implementation: Coordinates (a, b, c) are converted to integer
|
|
405
|
+
pixel indices via (floor(a), floor(b), floor(c)).
|
|
406
|
+
Therefore the pixel at integer index (x, y, z) will be addressed
|
|
407
|
+
by (a, b, c) when a is in [x, x+1), b is in [y, y+1), and c is
|
|
408
|
+
[z, z+1). This is called the 'corner' convention because integer
|
|
409
|
+
coordinates like (5, 2, 10) point to the upper-left corner of a
|
|
410
|
+
pixel. In this case, any tiny decrease from an integer value will
|
|
411
|
+
end up pointing to a different pixel in the image.
|
|
412
|
+
|
|
413
|
+
'center':
|
|
414
|
+
In this mode, coordinate (5, 2) and coordinate (4.99, 2) refer
|
|
415
|
+
to the same pixel because pixel borders are at half-integer
|
|
416
|
+
values, so (4.51, 2) and (4.49, 2) refer to different pixels.
|
|
417
|
+
Implementation: Coordinates (a, b, c) will be converted to integer
|
|
418
|
+
pixel indices via (round(a), round(b), round(c)). Therefore the
|
|
419
|
+
pixel at integer index (x, y, z) will be addressed by (a, b, c)
|
|
420
|
+
when a is in [x-0.5, x+0.5), b is in [y-0.5, y+0.5), and c is
|
|
421
|
+
[z-0.5, z+0.5). This is called the 'center' convention because
|
|
422
|
+
integer coordinates like (5, 2, 10) point to the center of a
|
|
423
|
+
pixel, so increasing or decreasing any of the coordinate values by
|
|
424
|
+
up to 0.5 will still point to the same pixel in the image.
|
|
425
|
+
|
|
426
|
+
out_of_bounds : {'ignore', 'wrap', 'raise'}, default 'ignore'
|
|
427
|
+
By default, coordinates with negative indices are ignored, i.e. this
|
|
428
|
+
function won't wrap negative indexes around to access the end of the
|
|
429
|
+
array, despite numpy arrays normally wrapping negative indices.
|
|
430
|
+
Refusing to wrap makes more sense in most graphics applications.
|
|
431
|
+
|
|
432
|
+
add_missing_dims : {None, 'start', 'end'}, default None
|
|
433
|
+
If the `coords` argument has fewer dimensions than the `image`
|
|
434
|
+
argument, this parameter determines how to handle that mismatch.
|
|
435
|
+
|
|
436
|
+
This is useful when you want to set all the rows, columns, or
|
|
437
|
+
channels of an image to a particular value. For example:
|
|
438
|
+
- You have a (360, 640, 3) RGB pixel array `image`
|
|
439
|
+
- You have 100 pixels specified in a (100, 2) array `coords`
|
|
440
|
+
- You want to make those pixels cyan, i.e. set the values along
|
|
441
|
+
the last axis to (0, 255, 255)
|
|
442
|
+
then since the color axis is the last axis, you can call:
|
|
443
|
+
>>> imset(image, coords, (0, 255, 255), add_missing_dims='end')
|
|
399
444
|
"""
|
|
400
445
|
if not isinstance(coords, np.ndarray):
|
|
401
446
|
coords = np.array(coords)
|
|
@@ -404,7 +449,15 @@ def imset(image, coords, value, convention='corner', out_of_bounds='ignore'):
|
|
|
404
449
|
allow_negative_wrapping = True
|
|
405
450
|
else:
|
|
406
451
|
allow_negative_wrapping = False
|
|
407
|
-
|
|
452
|
+
|
|
453
|
+
bounds = image.shape
|
|
454
|
+
if add_missing_dims == 'start':
|
|
455
|
+
while len(bounds) > coords.shape[1]:
|
|
456
|
+
bounds = bounds[1:]
|
|
457
|
+
elif add_missing_dims == 'end':
|
|
458
|
+
while len(bounds) > coords.shape[1]:
|
|
459
|
+
bounds = bounds[:-1]
|
|
460
|
+
is_oob = is_out_of_bounds(coords, bounds, allow_negative_wrapping, convention)
|
|
408
461
|
if is_oob.any():
|
|
409
462
|
if out_of_bounds in ['ignore', 'wrap']:
|
|
410
463
|
coords = coords[~is_oob]
|
|
@@ -416,9 +469,18 @@ def imset(image, coords, value, convention='corner', out_of_bounds='ignore'):
|
|
|
416
469
|
f"'wrap' but was {out_of_bounds}.")
|
|
417
470
|
|
|
418
471
|
if convention == 'corner':
|
|
419
|
-
|
|
472
|
+
slicer = tuple(ifloor(coords).T)
|
|
420
473
|
else: # convention == 'center'
|
|
421
|
-
|
|
474
|
+
slicer = tuple(iround(coords).T)
|
|
475
|
+
|
|
476
|
+
if add_missing_dims == 'start':
|
|
477
|
+
while len(slicer) < image.ndim:
|
|
478
|
+
slicer = (slice(None),) + slicer
|
|
479
|
+
elif add_missing_dims == 'end':
|
|
480
|
+
while len(slicer) < image.ndim:
|
|
481
|
+
slicer = slicer + (slice(None),)
|
|
482
|
+
|
|
483
|
+
image[slicer] = value
|
|
422
484
|
|
|
423
485
|
|
|
424
486
|
# --- Drawing shapes composed of many primitives, --- #
|
|
@@ -491,18 +553,8 @@ def _preprocess_polygon_vertices(*pts):
|
|
|
491
553
|
|
|
492
554
|
pts = list(pts) # Make mutable
|
|
493
555
|
for i, pt in enumerate(pts):
|
|
494
|
-
# This block tried to guess whether the user wants center or corner
|
|
495
|
-
# convention, but now that the functions in this module explicitly include
|
|
496
|
-
# convention as an argument, it's no longer needed
|
|
497
|
-
# if all(isint(pt)):
|
|
498
|
-
# pts[i] = np.array(pts[i]) + 0.5
|
|
499
|
-
# print(f'Adjusted {i+1}{suffixes.get(i+1, "th")} point to {pts[i]}'\
|
|
500
|
-
# ' so it represents the middle of its pixel')
|
|
501
|
-
# else:
|
|
502
556
|
pts[i] = np.array(pts[i]).astype(np.float64)
|
|
503
557
|
|
|
504
|
-
# assert pts[i].shape == (n_dims,)
|
|
505
|
-
|
|
506
558
|
longest_axis = None
|
|
507
559
|
longest_axis_len = -1
|
|
508
560
|
for pt1_idx in range(len(pts)):
|
|
@@ -544,7 +596,7 @@ def thicken(pts, thickness=1, no_duplicates=True):
|
|
|
544
596
|
|
|
545
597
|
try:
|
|
546
598
|
iter(thickness)
|
|
547
|
-
except:
|
|
599
|
+
except TypeError:
|
|
548
600
|
thickness = [thickness] * pts.shape[1]
|
|
549
601
|
|
|
550
602
|
# TODO make an option for doing spherical thickening using
|
|
@@ -634,14 +686,16 @@ def floodfill(image, seed, fill_value, fill_diagonally=False,
|
|
|
634
686
|
This implementation is very slow and needs improvement
|
|
635
687
|
"""
|
|
636
688
|
seed_value = image[seed]
|
|
637
|
-
if verbose:
|
|
689
|
+
if verbose:
|
|
690
|
+
print(f'seed_value={seed_value}')
|
|
638
691
|
if fill_diagonally:
|
|
639
692
|
neighbors = get_voxels_within_distance(1, ndims=len(image.shape),
|
|
640
693
|
metric='chebyshev')
|
|
641
694
|
else:
|
|
642
695
|
neighbors = get_voxels_within_distance(1, ndims=len(image.shape))
|
|
643
696
|
|
|
644
|
-
if verbose:
|
|
697
|
+
if verbose:
|
|
698
|
+
print(f'neighbors={neighbors}')
|
|
645
699
|
neighbors = neighbors[~(neighbors == 0).all(axis=1)]
|
|
646
700
|
fill_mask = np.zeros(image.shape, dtype=bool)
|
|
647
701
|
|
|
@@ -652,12 +706,10 @@ def floodfill(image, seed, fill_value, fill_diagonally=False,
|
|
|
652
706
|
#TODO learn cython????
|
|
653
707
|
|
|
654
708
|
def fill(wavefront, iteration):
|
|
655
|
-
if verbose:
|
|
656
|
-
|
|
657
|
-
#fill wavefront
|
|
709
|
+
if verbose:
|
|
710
|
+
print(f'iteration={iteration}, len(wavefront)={len(wavefront)}')
|
|
658
711
|
imset(fill_mask, wavefront, True)
|
|
659
712
|
imset(image, wavefront, fill_value)
|
|
660
|
-
#print(f'image:\n{image}')
|
|
661
713
|
|
|
662
714
|
#add valid neighbors
|
|
663
715
|
new_wavefront = []
|
|
@@ -706,7 +758,6 @@ def to_physical_coordinates(voxel_coordinates, voxel_size, offset=0):
|
|
|
706
758
|
(in units of microns per voxel) and offset (indicating the physical
|
|
707
759
|
coordinates of the origin voxel, in microns)
|
|
708
760
|
"""
|
|
709
|
-
#TODO test this.
|
|
710
761
|
return voxel_coordinates * voxel_size + offset
|
|
711
762
|
|
|
712
763
|
|
|
@@ -719,7 +770,4 @@ def to_voxel_coordinates(physical_coordinates, voxel_size, offset=0):
|
|
|
719
770
|
accuracy, and downstream functions will need to convert to int if these are
|
|
720
771
|
to be used to index an array.
|
|
721
772
|
"""
|
|
722
|
-
#print(f'physical_coordinates={physical_coordinates}')
|
|
723
|
-
#print(f'offset={offset}')
|
|
724
|
-
#print(f'voxel_size={voxel_size}')
|
|
725
773
|
return (physical_coordinates - offset) / voxel_size
|
|
@@ -19,7 +19,6 @@ Class list:
|
|
|
19
19
|
|
|
20
20
|
from typing import Union, Tuple, Iterator, Literal
|
|
21
21
|
from pathlib import Path
|
|
22
|
-
import os
|
|
23
22
|
import subprocess
|
|
24
23
|
import threading
|
|
25
24
|
import json
|
|
@@ -138,13 +137,17 @@ def lazy_load_video(filename) -> Iterator[np.ndarray]:
|
|
|
138
137
|
except ImportError:
|
|
139
138
|
raise ImportError('Missing optional dependency for video processing,'
|
|
140
139
|
' run `pip install av tqdm`')
|
|
141
|
-
with av.open(filename) as container:
|
|
140
|
+
with av.open(Path(filename).expanduser()) as container:
|
|
142
141
|
stream = container.streams.video[0]
|
|
143
142
|
for frame in container.decode(stream):
|
|
144
143
|
img = frame.to_ndarray(format='rgb24')
|
|
145
144
|
yield img
|
|
146
145
|
|
|
147
146
|
|
|
147
|
+
class VideoSeekError(RuntimeError):
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
|
|
148
151
|
class VideoStreamer:
|
|
149
152
|
def __init__(self, filename, verbose: bool = False):
|
|
150
153
|
try:
|
|
@@ -154,7 +157,7 @@ class VideoStreamer:
|
|
|
154
157
|
raise ImportError('Missing optional dependency for video processing,'
|
|
155
158
|
' run `pip install av tqdm`')
|
|
156
159
|
self.verbose = verbose
|
|
157
|
-
self.filename = Path(filename)
|
|
160
|
+
self.filename = Path(filename).expanduser()
|
|
158
161
|
if not self.filename.exists():
|
|
159
162
|
raise FileNotFoundError(f'File {filename} not found')
|
|
160
163
|
|
|
@@ -378,11 +381,14 @@ class VideoStreamer:
|
|
|
378
381
|
return frame
|
|
379
382
|
if frame.pts > target_pts:
|
|
380
383
|
self._current_frame_number = self.pts_to_frame_number(frame.pts)
|
|
381
|
-
raise
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
384
|
+
raise VideoSeekError(f'Frame with PTS {target_pts} not found after'
|
|
385
|
+
f' seeking – current frame PTS: {frame.pts}')
|
|
386
|
+
if type(self.verbose) is int and self.verbose: # Set verbose=1 to use
|
|
387
|
+
print(f'Passing frame {self.pts_to_frame_number(frame.pts)} (PTS {frame.pts})'
|
|
388
|
+
f' while decoding to frame {frame_number} (PTS {target_pts})')
|
|
389
|
+
raise VideoSeekError('Hit end of video before finding frame {frame_number} (PTS '
|
|
390
|
+
f'{target_pts}). Last seen frame was {self._current_frame_number}'
|
|
391
|
+
f' (PTS {self.frame_number_to_pts(self._current_frame_number)}')
|
|
386
392
|
|
|
387
393
|
if isinstance(frame_number, slice): # Support slicing
|
|
388
394
|
start, stop, step = frame_number.indices(self.n_frames)
|
|
@@ -392,17 +398,39 @@ class VideoStreamer:
|
|
|
392
398
|
if (self._current_frame_number is None
|
|
393
399
|
or frame_number <= self._current_frame_number
|
|
394
400
|
or frame_number > self._current_frame_number + 100):
|
|
395
|
-
|
|
401
|
+
# We seek to a few frames before the requested frame because
|
|
402
|
+
# seeking has the undesirable behavior of sometimes landing
|
|
403
|
+
# at a keyframe just after the requested frame, if a keyframe
|
|
404
|
+
# exists one or two frames after the requested frame.
|
|
405
|
+
seek_to_frame = max(0, frame_number - 3)
|
|
406
|
+
seek_to_pts = self.frame_number_to_pts(seek_to_frame)
|
|
396
407
|
if self.verbose:
|
|
397
|
-
print(f'
|
|
398
|
-
|
|
408
|
+
print(f'Frame {frame_number} requested: Seeking to'
|
|
409
|
+
f' frame {seek_to_frame} (PTS {seek_to_pts})')
|
|
410
|
+
# The seek call actually seeks to the closest keyframe before
|
|
399
411
|
# target_pts, because it's not possible to seek directly to
|
|
400
412
|
# non-keyframes due to video files being compressed.
|
|
401
|
-
self.container.seek(
|
|
413
|
+
self.container.seek(seek_to_pts, any_frame=False,
|
|
402
414
|
backward=True, stream=self.stream)
|
|
403
415
|
self._frame_iterator = self.container.decode(self.stream)
|
|
404
|
-
|
|
405
|
-
|
|
416
|
+
try:
|
|
417
|
+
# Now we decode frames forward until we get to the requested frame
|
|
418
|
+
image = decode_until(frame_number)
|
|
419
|
+
except VideoSeekError as e:
|
|
420
|
+
# If we fail on the first attempt, try seeking back
|
|
421
|
+
# 30 frames (instead of 3) then decoding forward again.
|
|
422
|
+
seek_to_frame = max(0, frame_number - 30)
|
|
423
|
+
seek_to_pts = self.frame_number_to_pts(seek_to_frame)
|
|
424
|
+
if self.verbose:
|
|
425
|
+
print(f'[WARNING] {e}')
|
|
426
|
+
print(f'[RETRY] Frame {frame_number} requested: Seeking'
|
|
427
|
+
f' to frame {seek_to_frame} (PTS {seek_to_pts})')
|
|
428
|
+
self.container.seek(seek_to_pts, stream=self.stream)
|
|
429
|
+
self._frame_iterator = self.container.decode(self.stream)
|
|
430
|
+
# If this one fails too, we let its exception raise.
|
|
431
|
+
# I haven't seen this ever fail, but who knows.
|
|
432
|
+
image = decode_until(frame_number)
|
|
433
|
+
|
|
406
434
|
if self.rotation not in [None, '0', 0]:
|
|
407
435
|
image = np.rot90(image, k=-int(self.rotation) // 90)
|
|
408
436
|
return image
|
|
@@ -517,8 +545,8 @@ class AVVideoWriter:
|
|
|
517
545
|
raise ImportError('Missing optional dependency for video processing,'
|
|
518
546
|
' run `pip install av tqdm`')
|
|
519
547
|
self.av = av
|
|
520
|
-
filename =
|
|
521
|
-
if
|
|
548
|
+
filename = Path(filename).expanduser()
|
|
549
|
+
if filename.exists() and not overwrite:
|
|
522
550
|
raise FileExistsError(f'File {filename} already exists. '
|
|
523
551
|
'Set overwrite=True to overwrite.')
|
|
524
552
|
self.filename = filename
|
|
@@ -622,8 +650,8 @@ class FFmpegVideoWriter:
|
|
|
622
650
|
def __init__(self, filename, framerate=30, crf=23, compression_speed='medium',
|
|
623
651
|
codec: Literal['libx264', 'libx265'] = 'libx264',
|
|
624
652
|
overwrite=False):
|
|
625
|
-
filename =
|
|
626
|
-
if
|
|
653
|
+
filename = Path(filename).expanduser()
|
|
654
|
+
if filename.exists() and not overwrite:
|
|
627
655
|
raise FileExistsError(f'File {filename} already exists. '
|
|
628
656
|
'Set overwrite=True to overwrite.')
|
|
629
657
|
self.filename = filename
|
|
@@ -632,11 +660,6 @@ class FFmpegVideoWriter:
|
|
|
632
660
|
self.compression_speed = compression_speed
|
|
633
661
|
self.codec = codec_aliases[codec.lower()]
|
|
634
662
|
|
|
635
|
-
# Check for existing file
|
|
636
|
-
if os.path.exists(self.filename) and not overwrite:
|
|
637
|
-
raise FileExistsError(f'File {self.filename} already exists. '
|
|
638
|
-
'Set overwrite=True to overwrite.')
|
|
639
|
-
|
|
640
663
|
# Initialize process state
|
|
641
664
|
self._process = None
|
|
642
665
|
self._stdin = None
|
|
@@ -821,10 +844,10 @@ def save_video(data, filename, time_axis=0, color_axis=None, overwrite=False,
|
|
|
821
844
|
raise ImportError('Missing optional dependency for video processing,'
|
|
822
845
|
' run `pip install av tqdm`')
|
|
823
846
|
|
|
824
|
-
filename =
|
|
847
|
+
filename = Path(filename).expanduser()
|
|
825
848
|
if filename.split('.')[-1].lower() not in supported_extensions:
|
|
826
849
|
filename += '.mp4'
|
|
827
|
-
if
|
|
850
|
+
if filename.exists() and not overwrite:
|
|
828
851
|
raise FileExistsError(f'File {filename} already exists. '
|
|
829
852
|
'Set overwrite=True to overwrite.')
|
|
830
853
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|