numpyimage 2.6.1__tar.gz → 2.6.2__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.6.1 → numpyimage-2.6.2}/PKG-INFO +1 -1
- {numpyimage-2.6.1 → numpyimage-2.6.2}/npimage/imageio.py +8 -60
- {numpyimage-2.6.1 → numpyimage-2.6.2}/npimage/utils.py +55 -0
- {numpyimage-2.6.1 → numpyimage-2.6.2}/npimage/vidio.py +156 -20
- {numpyimage-2.6.1 → numpyimage-2.6.2}/numpyimage.egg-info/PKG-INFO +1 -1
- {numpyimage-2.6.1 → numpyimage-2.6.2}/pyproject.toml +1 -1
- {numpyimage-2.6.1 → numpyimage-2.6.2}/LICENSE +0 -0
- {numpyimage-2.6.1 → numpyimage-2.6.2}/README.md +0 -0
- {numpyimage-2.6.1 → numpyimage-2.6.2}/npimage/__init__.py +0 -0
- {numpyimage-2.6.1 → numpyimage-2.6.2}/npimage/align.py +0 -0
- {numpyimage-2.6.1 → numpyimage-2.6.2}/npimage/graphics.py +0 -0
- {numpyimage-2.6.1 → numpyimage-2.6.2}/npimage/nrrd_utils.py +0 -0
- {numpyimage-2.6.1 → numpyimage-2.6.2}/npimage/operations.py +0 -0
- {numpyimage-2.6.1 → numpyimage-2.6.2}/numpyimage.egg-info/SOURCES.txt +0 -0
- {numpyimage-2.6.1 → numpyimage-2.6.2}/numpyimage.egg-info/dependency_links.txt +0 -0
- {numpyimage-2.6.1 → numpyimage-2.6.2}/numpyimage.egg-info/requires.txt +0 -0
- {numpyimage-2.6.1 → numpyimage-2.6.2}/numpyimage.egg-info/top_level.txt +0 -0
- {numpyimage-2.6.1 → numpyimage-2.6.2}/setup.cfg +0 -0
- {numpyimage-2.6.1 → numpyimage-2.6.2}/tests/test_heic.py +0 -0
- {numpyimage-2.6.1 → numpyimage-2.6.2}/tests/test_pbm.py +0 -0
- {numpyimage-2.6.1 → numpyimage-2.6.2}/tests/test_video.py +0 -0
|
@@ -15,7 +15,7 @@ from builtins import open as builtin_open
|
|
|
15
15
|
|
|
16
16
|
import numpy as np
|
|
17
17
|
|
|
18
|
-
from . import operations, utils
|
|
18
|
+
from . import vidio, operations, utils
|
|
19
19
|
|
|
20
20
|
supported_extensions = [
|
|
21
21
|
'tif', 'tiff', 'jpg', 'jpeg', 'png', 'pbm',
|
|
@@ -43,7 +43,9 @@ def load(filename, dim_order='zyx', **kwargs) -> Union[np.ndarray, Tuple[np.ndar
|
|
|
43
43
|
raise ValueError('Could not determine file format from filename'
|
|
44
44
|
f' "{filename}". Please specify the file type via'
|
|
45
45
|
' the `format` argument, e.g. format="tif"')
|
|
46
|
-
if extension
|
|
46
|
+
if extension in vidio.supported_extensions:
|
|
47
|
+
return vidio.VideoStreamer(filename)
|
|
48
|
+
elif extension not in supported_extensions:
|
|
47
49
|
raise ValueError(f'File format of "{filename}" not supported/recognized.')
|
|
48
50
|
|
|
49
51
|
data = None
|
|
@@ -178,7 +180,7 @@ def save(data,
|
|
|
178
180
|
'.nrrd. Whether or not compression occurs now will depend on '
|
|
179
181
|
'the format you are saving to.')
|
|
180
182
|
|
|
181
|
-
channel_axis = find_channel_axis(data)
|
|
183
|
+
channel_axis = utils.find_channel_axis(data)
|
|
182
184
|
if 'xy' in dim_order:
|
|
183
185
|
data = data.T
|
|
184
186
|
if hasattr(pixel_size, '__iter__') and not isinstance(pixel_size, str):
|
|
@@ -392,7 +394,7 @@ def show(data,
|
|
|
392
394
|
if data_type == 'segmentation':
|
|
393
395
|
data = utils.assign_random_colors(data, seed=kwargs.get('seed', None))
|
|
394
396
|
|
|
395
|
-
if (not data.ndim == 2) and not (data.ndim == 3 and find_channel_axis(data) is not None):
|
|
397
|
+
if (not data.ndim == 2) and not (data.ndim == 3 and utils.find_channel_axis(data) is not None):
|
|
396
398
|
m = ('Data must have shape (y, x) for grayscale, '
|
|
397
399
|
'(y, x, 3) for RGB, or (y, x, 4) for RGBA but had '
|
|
398
400
|
f'shape {data.shape}')
|
|
@@ -403,9 +405,9 @@ def show(data,
|
|
|
403
405
|
if 'xy' in dim_order:
|
|
404
406
|
data = data.T
|
|
405
407
|
if channel_axis == 'guess':
|
|
406
|
-
channel_axis = find_channel_axis(data)
|
|
408
|
+
channel_axis = utils.find_channel_axis(data)
|
|
407
409
|
if utils.isint(channel_axis) and channel_axis != -1:
|
|
408
|
-
data = np.moveaxis(data, find_channel_axis(data), -1)
|
|
410
|
+
data = np.moveaxis(data, utils.find_channel_axis(data), -1)
|
|
409
411
|
|
|
410
412
|
if convert_to_8bit and data.dtype != np.uint8:
|
|
411
413
|
data = operations.to_8bit(data)
|
|
@@ -430,60 +432,6 @@ def show(data,
|
|
|
430
432
|
imshow = show # Function name alias
|
|
431
433
|
|
|
432
434
|
|
|
433
|
-
def find_channel_axis(data,
|
|
434
|
-
possible_channel_axes=[-1, 0],
|
|
435
|
-
possible_channel_lengths=[2, 3, 4]) -> Union[int, None]:
|
|
436
|
-
"""
|
|
437
|
-
If the given numpy array has a shape suggesting that it has a
|
|
438
|
-
channel (color) axis (that is, any axis with length 2 (2-color),
|
|
439
|
-
3 (RGB), or 4 (RGBA)), return the index of that axis.
|
|
440
|
-
|
|
441
|
-
Parameters
|
|
442
|
-
----------
|
|
443
|
-
data : numpy.ndarray
|
|
444
|
-
The numpy array to check for a channel axis.
|
|
445
|
-
|
|
446
|
-
possible_channel_axes : int or list of int, default [0, -1]
|
|
447
|
-
If None, any axis having length 2, 3, or 4 will be considered
|
|
448
|
-
a channel axis.
|
|
449
|
-
If an int, only that axis index will be checked.
|
|
450
|
-
If a list of ints, all axes with those indices will be checked.
|
|
451
|
-
The default value of [-1, 0] checks the last and first axes, which is
|
|
452
|
-
almost always where a channel axis will be found.
|
|
453
|
-
|
|
454
|
-
possible_channel_lengths : int or list of int, default [2, 3, 4]
|
|
455
|
-
If an int, only that length will be considered a channel axis.
|
|
456
|
-
If a list of ints, an axis with any of those lengths will be considered
|
|
457
|
-
a channel axis.
|
|
458
|
-
|
|
459
|
-
Returns
|
|
460
|
-
-------
|
|
461
|
-
int or None
|
|
462
|
-
The index of the channel axis, or None if no channel axis was found.
|
|
463
|
-
|
|
464
|
-
If possible_channel_axes is given, the returned value will be one of
|
|
465
|
-
the possible_channel_axes values, or None if no channel axis was found.
|
|
466
|
-
|
|
467
|
-
If possible_channel_axes is None, the returned value will be between
|
|
468
|
-
0 and data.ndim - 1, inclusive, or None if no channel axis was found.
|
|
469
|
-
|
|
470
|
-
Note that returning 0 means the channel axis was found and is the first
|
|
471
|
-
axis, so be careful not to do a test like `if find_channel_axis(data):`
|
|
472
|
-
because 0 will evaluate to False even though the data has a channel axis.
|
|
473
|
-
Instead write `if find_channel_axis(data) is not None:`
|
|
474
|
-
"""
|
|
475
|
-
if isinstance(possible_channel_axes, int):
|
|
476
|
-
possible_channel_axes = [possible_channel_axes]
|
|
477
|
-
if possible_channel_axes is None:
|
|
478
|
-
possible_channel_axes = range(data.ndim)
|
|
479
|
-
if isinstance(possible_channel_lengths, int):
|
|
480
|
-
possible_channel_lengths = [possible_channel_lengths]
|
|
481
|
-
for axis in possible_channel_axes:
|
|
482
|
-
if data.shape[axis] in possible_channel_lengths:
|
|
483
|
-
return axis
|
|
484
|
-
return None
|
|
485
|
-
|
|
486
|
-
|
|
487
435
|
# Flag to track if HEIF opener has been registered
|
|
488
436
|
_heif_opener_registered = False
|
|
489
437
|
def _ensure_heif_opener_registered() -> None:
|
|
@@ -11,6 +11,7 @@ Function list:
|
|
|
11
11
|
- transpose_metadata (reverse the order of all per-axis metadata values)
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
+
from typing import Union
|
|
14
15
|
from collections import OrderedDict
|
|
15
16
|
|
|
16
17
|
import numpy as np
|
|
@@ -56,6 +57,60 @@ def isint(n):
|
|
|
56
57
|
return isinstance(n, int) or np.issubdtype(type(n), np.integer)
|
|
57
58
|
|
|
58
59
|
|
|
60
|
+
def find_channel_axis(data,
|
|
61
|
+
possible_channel_axes=[-1, 0],
|
|
62
|
+
possible_channel_lengths=[2, 3, 4]) -> Union[int, None]:
|
|
63
|
+
"""
|
|
64
|
+
If the given numpy array has a shape suggesting that it has a
|
|
65
|
+
channel (color) axis (that is, any axis with length 2 (2-color),
|
|
66
|
+
3 (RGB), or 4 (RGBA)), return the index of that axis.
|
|
67
|
+
|
|
68
|
+
Parameters
|
|
69
|
+
----------
|
|
70
|
+
data : numpy.ndarray
|
|
71
|
+
The numpy array to check for a channel axis.
|
|
72
|
+
|
|
73
|
+
possible_channel_axes : int or list of int, default [0, -1]
|
|
74
|
+
If None, any axis having length 2, 3, or 4 will be considered
|
|
75
|
+
a channel axis.
|
|
76
|
+
If an int, only that axis index will be checked.
|
|
77
|
+
If a list of ints, all axes with those indices will be checked.
|
|
78
|
+
The default value of [-1, 0] checks the last and first axes, which is
|
|
79
|
+
almost always where a channel axis will be found.
|
|
80
|
+
|
|
81
|
+
possible_channel_lengths : int or list of int, default [2, 3, 4]
|
|
82
|
+
If an int, only that length will be considered a channel axis.
|
|
83
|
+
If a list of ints, an axis with any of those lengths will be considered
|
|
84
|
+
a channel axis.
|
|
85
|
+
|
|
86
|
+
Returns
|
|
87
|
+
-------
|
|
88
|
+
int or None
|
|
89
|
+
The index of the channel axis, or None if no channel axis was found.
|
|
90
|
+
|
|
91
|
+
If possible_channel_axes is given, the returned value will be one of
|
|
92
|
+
the possible_channel_axes values, or None if no channel axis was found.
|
|
93
|
+
|
|
94
|
+
If possible_channel_axes is None, the returned value will be between
|
|
95
|
+
0 and data.ndim - 1, inclusive, or None if no channel axis was found.
|
|
96
|
+
|
|
97
|
+
Note that returning 0 means the channel axis was found and is the first
|
|
98
|
+
axis, so be careful not to do a test like `if find_channel_axis(data):`
|
|
99
|
+
because 0 will evaluate to False even though the data has a channel axis.
|
|
100
|
+
Instead write `if find_channel_axis(data) is not None:`
|
|
101
|
+
"""
|
|
102
|
+
if isinstance(possible_channel_axes, int):
|
|
103
|
+
possible_channel_axes = [possible_channel_axes]
|
|
104
|
+
if possible_channel_axes is None:
|
|
105
|
+
possible_channel_axes = range(data.ndim)
|
|
106
|
+
if isinstance(possible_channel_lengths, int):
|
|
107
|
+
possible_channel_lengths = [possible_channel_lengths]
|
|
108
|
+
for axis in possible_channel_axes:
|
|
109
|
+
if data.shape[axis] in possible_channel_lengths:
|
|
110
|
+
return axis
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
59
114
|
def transpose_metadata(metadata: dict or OrderedDict,
|
|
60
115
|
inplace: bool = True) -> dict or OrderedDict or None:
|
|
61
116
|
"""
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
Functions for reading
|
|
3
|
+
Functions for reading and writing videos.
|
|
4
4
|
|
|
5
5
|
Function list:
|
|
6
6
|
- load_video(filename) -> np.ndarray
|
|
7
7
|
- lazy_load_video(filename) -> Iterator[np.ndarray]
|
|
8
8
|
- save_video(data, filename) -> Saves a 3D numpy array as a video
|
|
9
9
|
|
|
10
|
+
Class list:
|
|
11
|
+
- VideoStreamer: Provides fast random access to frames in a video file
|
|
12
|
+
via VideoStreamer[frame_number]
|
|
13
|
+
- VideoWriter: Allows writing frames one-by-one to a video file via
|
|
14
|
+
VideoWriter.write(image). This can be advantageous compared to save_video
|
|
15
|
+
when you don't want to ever have to have all the frames in memory at once.
|
|
16
|
+
|
|
10
17
|
"""
|
|
11
18
|
|
|
12
19
|
from typing import Union, Tuple, Iterator, Literal
|
|
@@ -18,7 +25,7 @@ import json
|
|
|
18
25
|
|
|
19
26
|
import numpy as np
|
|
20
27
|
|
|
21
|
-
from .
|
|
28
|
+
from . import utils
|
|
22
29
|
|
|
23
30
|
codec_aliases = {
|
|
24
31
|
"libx264": "libx264",
|
|
@@ -33,6 +40,8 @@ codec_aliases = {
|
|
|
33
40
|
"H.265": "libx265",
|
|
34
41
|
}
|
|
35
42
|
|
|
43
|
+
supported_extensions = ['mp4', 'mkv', 'avi', 'mov']
|
|
44
|
+
|
|
36
45
|
|
|
37
46
|
def load_video(filename,
|
|
38
47
|
return_framerate=False,
|
|
@@ -51,8 +60,13 @@ def load_video(filename,
|
|
|
51
60
|
|
|
52
61
|
Returns
|
|
53
62
|
-------
|
|
54
|
-
|
|
55
|
-
|
|
63
|
+
If return_framerate is False:
|
|
64
|
+
data : numpy.ndarray
|
|
65
|
+
The video frames as a numpy array, shape (num_frames, height, width, colors)
|
|
66
|
+
If return_framerate is True:
|
|
67
|
+
(data, framerate) : tuple, where data is as above and:
|
|
68
|
+
framerate : float
|
|
69
|
+
The frame rate of the video in frames per second
|
|
56
70
|
"""
|
|
57
71
|
try:
|
|
58
72
|
import av
|
|
@@ -134,7 +148,7 @@ class VideoStreamer:
|
|
|
134
148
|
self.filename = Path(filename)
|
|
135
149
|
if not self.filename.exists():
|
|
136
150
|
raise FileNotFoundError(f"File {filename} not found")
|
|
137
|
-
self.index_filename =
|
|
151
|
+
self.index_filename = self.filename.parent / (self.filename.stem + '_index.json')
|
|
138
152
|
self._load_index()
|
|
139
153
|
|
|
140
154
|
self.container = av.open(str(self.filename))
|
|
@@ -142,6 +156,10 @@ class VideoStreamer:
|
|
|
142
156
|
self.time_base = self.stream.time_base
|
|
143
157
|
self._shape = None
|
|
144
158
|
self._first_frame = None
|
|
159
|
+
self._width = None
|
|
160
|
+
self._height = None
|
|
161
|
+
self._ndim = None
|
|
162
|
+
self._dtype = None
|
|
145
163
|
self._current_frame_number = None
|
|
146
164
|
self._lock = threading.Lock()
|
|
147
165
|
|
|
@@ -163,11 +181,14 @@ class VideoStreamer:
|
|
|
163
181
|
time_index = [float(frame['pkt_pts_time']) for frame in frames]
|
|
164
182
|
if len(time_index) == 0:
|
|
165
183
|
raise RuntimeError("No frames found in video")
|
|
184
|
+
|
|
166
185
|
self.n_frames = len(time_index)
|
|
167
186
|
self.t0 = time_index[0]
|
|
187
|
+
self.rotation = _detect_rotation(self.filename)
|
|
168
188
|
index = {
|
|
169
189
|
'n_frames': self.n_frames,
|
|
170
190
|
't0': self.t0,
|
|
191
|
+
'rotation': self.rotation,
|
|
171
192
|
}
|
|
172
193
|
|
|
173
194
|
# Determine whether the video is constant or variable framerate
|
|
@@ -187,11 +208,12 @@ class VideoStreamer:
|
|
|
187
208
|
def _load_index(self):
|
|
188
209
|
if not self.index_filename.exists():
|
|
189
210
|
self._build_index()
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
211
|
+
|
|
212
|
+
with open(self.index_filename, 'r') as f:
|
|
213
|
+
index = json.load(f)
|
|
193
214
|
self.n_frames = index['n_frames']
|
|
194
215
|
self.t0 = index['t0']
|
|
216
|
+
self.rotation = index.get('rotation', None)
|
|
195
217
|
|
|
196
218
|
if not isinstance(index['timestep'], (str, float, int)):
|
|
197
219
|
raise ValueError('Malformed index: timestep is not a string or number')
|
|
@@ -226,7 +248,7 @@ class VideoStreamer:
|
|
|
226
248
|
Provides access to random frames as fast as is reasonable when getting
|
|
227
249
|
frames from compressed video in python.
|
|
228
250
|
"""
|
|
229
|
-
|
|
251
|
+
|
|
230
252
|
def decode_until(frame_number) -> np.ndarray:
|
|
231
253
|
"""
|
|
232
254
|
Decode forward from the current frame in the stream until we get
|
|
@@ -249,8 +271,15 @@ class VideoStreamer:
|
|
|
249
271
|
start, stop, step = frame_number.indices(self.n_frames)
|
|
250
272
|
return np.array([self._get_frame(i) for i in range(start, stop, step)])
|
|
251
273
|
with self._lock:
|
|
252
|
-
|
|
253
|
-
|
|
274
|
+
# Support negative indexing
|
|
275
|
+
if frame_number < 0 and frame_number + self.n_frames >= 0:
|
|
276
|
+
frame_number += self.n_frames
|
|
277
|
+
elif frame_number >= self.n_frames:
|
|
278
|
+
raise IndexError(f"Frame {frame_number} out of range:"
|
|
279
|
+
f" [0, {self.n_frames-1}]")
|
|
280
|
+
elif frame_number < 0:
|
|
281
|
+
raise IndexError(f"Negative frame {frame_number} out of"
|
|
282
|
+
f" range: [-{self.n_frames}, -1]")
|
|
254
283
|
|
|
255
284
|
if (self._current_frame_number is None
|
|
256
285
|
or frame_number <= self._current_frame_number
|
|
@@ -263,7 +292,17 @@ class VideoStreamer:
|
|
|
263
292
|
self.container.seek(seek_time, any_frame=False,
|
|
264
293
|
backward=True, stream=self.stream)
|
|
265
294
|
# Now we decode frames forward until we get to the requested frame
|
|
266
|
-
return decode_until(frame_number)
|
|
295
|
+
return _rotate(decode_until(frame_number), self.rotation)
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def average_timestep(self):
|
|
299
|
+
"""
|
|
300
|
+
Returns the average time step between frames.
|
|
301
|
+
"""
|
|
302
|
+
if self.timestep == 'variable':
|
|
303
|
+
return (self.time_index[-1] - self.time_index[0]) / (self.n_frames - 1)
|
|
304
|
+
else:
|
|
305
|
+
return self.timestep
|
|
267
306
|
|
|
268
307
|
@property
|
|
269
308
|
def first_frame(self):
|
|
@@ -278,13 +317,29 @@ class VideoStreamer:
|
|
|
278
317
|
self._shape = (self.n_frames,) + self.first_frame.shape
|
|
279
318
|
return self._shape
|
|
280
319
|
|
|
320
|
+
@property
|
|
321
|
+
def width(self):
|
|
322
|
+
if self._width is None:
|
|
323
|
+
self._width = self.first_frame.shape[1]
|
|
324
|
+
return self._width
|
|
325
|
+
|
|
326
|
+
@property
|
|
327
|
+
def height(self):
|
|
328
|
+
if self._height is None:
|
|
329
|
+
self._height = self.first_frame.shape[0]
|
|
330
|
+
return self._height
|
|
331
|
+
|
|
281
332
|
@property
|
|
282
333
|
def ndim(self):
|
|
283
|
-
|
|
334
|
+
if self._ndim is None:
|
|
335
|
+
self._ndim = len(self.shape)
|
|
336
|
+
return self._ndim
|
|
284
337
|
|
|
285
338
|
@property
|
|
286
339
|
def dtype(self):
|
|
287
|
-
|
|
340
|
+
if self._dtype is None:
|
|
341
|
+
self._dtype = self.first_frame.dtype
|
|
342
|
+
return self._dtype
|
|
288
343
|
|
|
289
344
|
def __len__(self):
|
|
290
345
|
return self.n_frames
|
|
@@ -317,7 +372,7 @@ class VideoWriter:
|
|
|
317
372
|
----------
|
|
318
373
|
filename : str
|
|
319
374
|
The filename to save the video to.
|
|
320
|
-
framerate : int, default 30
|
|
375
|
+
framerate : int or float, default 30
|
|
321
376
|
The frame rate of the video.
|
|
322
377
|
crf : int, default 23
|
|
323
378
|
Constant Rate Factor for encoding quality (lower is better quality).
|
|
@@ -337,17 +392,27 @@ class VideoWriter:
|
|
|
337
392
|
raise ImportError('Missing optional dependency for video processing,'
|
|
338
393
|
' run `pip install av tqdm`')
|
|
339
394
|
self.av = av
|
|
395
|
+
from fractions import Fraction
|
|
340
396
|
filename = os.path.expanduser(str(filename))
|
|
341
397
|
if os.path.exists(filename) and not overwrite:
|
|
342
398
|
raise FileExistsError(f'File {filename} already exists. '
|
|
343
399
|
'Set overwrite=True to overwrite.')
|
|
344
400
|
self.filename = filename
|
|
345
|
-
self.framerate = framerate
|
|
401
|
+
self.framerate = str(framerate) # str instead of float to avoid precision issues
|
|
402
|
+
while Fraction(self.framerate).denominator >= 2**32 or Fraction(self.framerate).numerator >= 2**32:
|
|
403
|
+
# If framerate has too many decimals to be expressed as a
|
|
404
|
+
# ratio of 32-bit ints, which is required by ffmpeg, crop
|
|
405
|
+
# off one decimal point of precision until it is expressable
|
|
406
|
+
self.framerate = self.framerate[:-1]
|
|
407
|
+
if self.framerate[-1] == '.':
|
|
408
|
+
self.framerate = self.framerate[:-1]
|
|
409
|
+
if len(self.framerate) == 0:
|
|
410
|
+
raise RuntimeError('Error occurred handling framerate argument')
|
|
346
411
|
self.crf = crf
|
|
347
412
|
self.compression_speed = compression_speed
|
|
348
413
|
self.codec = codec_aliases[codec.lower()]
|
|
349
414
|
self.container = av.open(filename, mode='w')
|
|
350
|
-
self.stream = self.container.add_stream(self.codec, rate=framerate)
|
|
415
|
+
self.stream = self.container.add_stream(self.codec, rate=Fraction(self.framerate))
|
|
351
416
|
self.stream.pix_fmt = 'yuv420p'
|
|
352
417
|
self.stream.options = {'crf': str(crf), 'preset': compression_speed}
|
|
353
418
|
self._closed = False
|
|
@@ -358,10 +423,14 @@ class VideoWriter:
|
|
|
358
423
|
if not isinstance(frame, self.av.VideoFrame):
|
|
359
424
|
if frame.ndim == 3 and frame.shape[-1] == 3:
|
|
360
425
|
frame = self.av.VideoFrame.from_ndarray(frame, format='rgb24')
|
|
426
|
+
elif frame.ndim == 3 and frame.shape[-1] == 4:
|
|
427
|
+
# While some video codecs support an alpha channel, most don't,
|
|
428
|
+
# so for now we're just going to ignore the alpha channel
|
|
429
|
+
frame = self.av.VideoFrame.from_ndarray(frame[..., :3], format='rgb24')
|
|
361
430
|
elif frame.ndim == 2:
|
|
362
431
|
frame = self.av.VideoFrame.from_ndarray(frame, format='gray')
|
|
363
432
|
else:
|
|
364
|
-
raise ValueError(f'Frame must be (H, W) or (H, W,
|
|
433
|
+
raise ValueError(f'Frame must be (H, W) (H, W, 3) or (H, W, 4) but was {frame.shape}')
|
|
365
434
|
if self.stream.width == 0:
|
|
366
435
|
self.stream.width = frame.width
|
|
367
436
|
if self.stream.height == 0:
|
|
@@ -439,14 +508,14 @@ def save_video(data, filename, time_axis=0, color_axis=None, overwrite=False,
|
|
|
439
508
|
' run `pip install av tqdm`')
|
|
440
509
|
|
|
441
510
|
filename = os.path.expanduser(str(filename))
|
|
442
|
-
if filename.split('.')[-1].lower() not in
|
|
511
|
+
if filename.split('.')[-1].lower() not in supported_extensions:
|
|
443
512
|
filename += '.mp4'
|
|
444
513
|
if os.path.exists(filename) and not overwrite:
|
|
445
514
|
raise FileExistsError(f'File {filename} already exists. '
|
|
446
515
|
'Set overwrite=True to overwrite.')
|
|
447
516
|
|
|
448
517
|
if color_axis is None and data.ndim == 4:
|
|
449
|
-
color_axis = find_channel_axis(data, possible_channel_lengths=3)
|
|
518
|
+
color_axis = utils.find_channel_axis(data, possible_channel_lengths=3)
|
|
450
519
|
if color_axis is None:
|
|
451
520
|
raise ValueError('4D input data must have an RGB (length 3) axis.')
|
|
452
521
|
if color_axis is not None:
|
|
@@ -487,3 +556,70 @@ def save_video(data, filename, time_axis=0, color_axis=None, overwrite=False,
|
|
|
487
556
|
for frame_i in tqdm(range(n_frames), total=n_frames,
|
|
488
557
|
desc='Saving video', disable=not progress_bar):
|
|
489
558
|
writer.write(data[frame_i])
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _detect_rotation(filename):
|
|
562
|
+
"""
|
|
563
|
+
Detect rotation metadata from the video file using ffprobe.
|
|
564
|
+
Returns the rotation angle as a string (e.g., '90', '180', '270'), or None if not present.
|
|
565
|
+
"""
|
|
566
|
+
import subprocess
|
|
567
|
+
import json
|
|
568
|
+
cmd = [
|
|
569
|
+
'ffprobe',
|
|
570
|
+
'-v', 'quiet',
|
|
571
|
+
'-select_streams', 'v:0',
|
|
572
|
+
'-show_entries', 'stream_tags=rotate',
|
|
573
|
+
'-of', 'json',
|
|
574
|
+
str(filename)
|
|
575
|
+
]
|
|
576
|
+
result = subprocess.run(cmd, stdout=subprocess.PIPE,
|
|
577
|
+
stderr=subprocess.PIPE, text=True)
|
|
578
|
+
if result.returncode != 0:
|
|
579
|
+
return None
|
|
580
|
+
try:
|
|
581
|
+
data = json.loads(result.stdout)
|
|
582
|
+
streams = data.get('streams', [])
|
|
583
|
+
if streams and 'tags' in streams[0]:
|
|
584
|
+
rotate_tag = streams[0]['tags'].get('rotate')
|
|
585
|
+
if rotate_tag:
|
|
586
|
+
return rotate_tag
|
|
587
|
+
except (json.JSONDecodeError, KeyError, ValueError):
|
|
588
|
+
pass
|
|
589
|
+
return None
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _rotate(data: np.ndarray, rotation: Union[str, int, None] = 0) -> np.ndarray:
|
|
593
|
+
"""
|
|
594
|
+
Apply clockwise rotation to a numpy array
|
|
595
|
+
|
|
596
|
+
Parameters
|
|
597
|
+
----------
|
|
598
|
+
data : np.ndarray
|
|
599
|
+
The numpy array to rotate.
|
|
600
|
+
rotation : str, int, or None, default 0
|
|
601
|
+
The rotation angle in degrees, as a string or integer.
|
|
602
|
+
Valid values are 0, 90, 180, 270, or 360.
|
|
603
|
+
If None, 0, or 360, no rotation is applied.
|
|
604
|
+
|
|
605
|
+
TODO find an actual video file with rotation_tag of 90 or 270 and check that
|
|
606
|
+
the rotation is applied in the right direction. If it's opposite the correct
|
|
607
|
+
direction, remove axes=(1, 0) from the np.rot90 calls in this function.
|
|
608
|
+
"""
|
|
609
|
+
if rotation is None:
|
|
610
|
+
return data
|
|
611
|
+
try:
|
|
612
|
+
rotation = int(rotation)
|
|
613
|
+
except (ValueError, TypeError):
|
|
614
|
+
raise ValueError(f"Invalid rotation value: {rotation}")
|
|
615
|
+
|
|
616
|
+
if rotation in [0, 360]:
|
|
617
|
+
return data
|
|
618
|
+
if rotation == 90:
|
|
619
|
+
return np.rot90(data, k=1, axes=(1, 0))
|
|
620
|
+
elif rotation == 180:
|
|
621
|
+
return np.rot90(data, k=2, axes=(1, 0))
|
|
622
|
+
elif rotation == 270:
|
|
623
|
+
return np.rot90(data, k=3, axes=(1, 0))
|
|
624
|
+
else:
|
|
625
|
+
raise ValueError(f"Invalid rotation value: {rotation}")
|
|
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
|