numpyimage 2.6.1__tar.gz → 2.6.3__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.3}/PKG-INFO +1 -1
- {numpyimage-2.6.1 → numpyimage-2.6.3}/npimage/imageio.py +8 -60
- {numpyimage-2.6.1 → numpyimage-2.6.3}/npimage/utils.py +55 -0
- {numpyimage-2.6.1 → numpyimage-2.6.3}/npimage/vidio.py +163 -22
- {numpyimage-2.6.1 → numpyimage-2.6.3}/numpyimage.egg-info/PKG-INFO +1 -1
- {numpyimage-2.6.1 → numpyimage-2.6.3}/pyproject.toml +1 -1
- {numpyimage-2.6.1 → numpyimage-2.6.3}/LICENSE +0 -0
- {numpyimage-2.6.1 → numpyimage-2.6.3}/README.md +0 -0
- {numpyimage-2.6.1 → numpyimage-2.6.3}/npimage/__init__.py +0 -0
- {numpyimage-2.6.1 → numpyimage-2.6.3}/npimage/align.py +0 -0
- {numpyimage-2.6.1 → numpyimage-2.6.3}/npimage/graphics.py +0 -0
- {numpyimage-2.6.1 → numpyimage-2.6.3}/npimage/nrrd_utils.py +0 -0
- {numpyimage-2.6.1 → numpyimage-2.6.3}/npimage/operations.py +0 -0
- {numpyimage-2.6.1 → numpyimage-2.6.3}/numpyimage.egg-info/SOURCES.txt +0 -0
- {numpyimage-2.6.1 → numpyimage-2.6.3}/numpyimage.egg-info/dependency_links.txt +0 -0
- {numpyimage-2.6.1 → numpyimage-2.6.3}/numpyimage.egg-info/requires.txt +0 -0
- {numpyimage-2.6.1 → numpyimage-2.6.3}/numpyimage.egg-info/top_level.txt +0 -0
- {numpyimage-2.6.1 → numpyimage-2.6.3}/setup.cfg +0 -0
- {numpyimage-2.6.1 → numpyimage-2.6.3}/tests/test_heic.py +0 -0
- {numpyimage-2.6.1 → numpyimage-2.6.3}/tests/test_pbm.py +0 -0
- {numpyimage-2.6.1 → numpyimage-2.6.3}/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
|
|
@@ -82,12 +96,17 @@ def load_video(filename,
|
|
|
82
96
|
data = np.empty((num_frames, *first_img.shape), dtype=first_img.dtype)
|
|
83
97
|
# Then fill it up frame by frame
|
|
84
98
|
data[0] = first_img
|
|
99
|
+
container.seek(0, stream=stream)
|
|
85
100
|
for i, frame in tqdm(enumerate(frame_iter), total=num_frames,
|
|
86
101
|
desc='Loading video', disable=not progress_bar):
|
|
87
|
-
if i == 0:
|
|
88
|
-
continue
|
|
89
102
|
img = frame.to_ndarray(format='rgb24')
|
|
103
|
+
if i == 0 and not np.array_equal(img, first_img):
|
|
104
|
+
raise RuntimeError('PyAV seek failed. Please report how this happened'
|
|
105
|
+
' at github.com/jasper-tms/npimage/issues')
|
|
90
106
|
data[i] = img
|
|
107
|
+
if (data[-1] == 0).all():
|
|
108
|
+
print('WARNING: Last frame of video is all zeros, this may'
|
|
109
|
+
' indicate an error in video loading unless you expected this.')
|
|
91
110
|
if return_framerate:
|
|
92
111
|
return data, float(stream.average_rate)
|
|
93
112
|
else:
|
|
@@ -134,7 +153,7 @@ class VideoStreamer:
|
|
|
134
153
|
self.filename = Path(filename)
|
|
135
154
|
if not self.filename.exists():
|
|
136
155
|
raise FileNotFoundError(f"File {filename} not found")
|
|
137
|
-
self.index_filename =
|
|
156
|
+
self.index_filename = self.filename.parent / (self.filename.stem + '_index.json')
|
|
138
157
|
self._load_index()
|
|
139
158
|
|
|
140
159
|
self.container = av.open(str(self.filename))
|
|
@@ -142,6 +161,10 @@ class VideoStreamer:
|
|
|
142
161
|
self.time_base = self.stream.time_base
|
|
143
162
|
self._shape = None
|
|
144
163
|
self._first_frame = None
|
|
164
|
+
self._width = None
|
|
165
|
+
self._height = None
|
|
166
|
+
self._ndim = None
|
|
167
|
+
self._dtype = None
|
|
145
168
|
self._current_frame_number = None
|
|
146
169
|
self._lock = threading.Lock()
|
|
147
170
|
|
|
@@ -163,11 +186,14 @@ class VideoStreamer:
|
|
|
163
186
|
time_index = [float(frame['pkt_pts_time']) for frame in frames]
|
|
164
187
|
if len(time_index) == 0:
|
|
165
188
|
raise RuntimeError("No frames found in video")
|
|
189
|
+
|
|
166
190
|
self.n_frames = len(time_index)
|
|
167
191
|
self.t0 = time_index[0]
|
|
192
|
+
self.rotation = _detect_rotation(self.filename)
|
|
168
193
|
index = {
|
|
169
194
|
'n_frames': self.n_frames,
|
|
170
195
|
't0': self.t0,
|
|
196
|
+
'rotation': self.rotation,
|
|
171
197
|
}
|
|
172
198
|
|
|
173
199
|
# Determine whether the video is constant or variable framerate
|
|
@@ -187,11 +213,12 @@ class VideoStreamer:
|
|
|
187
213
|
def _load_index(self):
|
|
188
214
|
if not self.index_filename.exists():
|
|
189
215
|
self._build_index()
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
216
|
+
|
|
217
|
+
with open(self.index_filename, 'r') as f:
|
|
218
|
+
index = json.load(f)
|
|
193
219
|
self.n_frames = index['n_frames']
|
|
194
220
|
self.t0 = index['t0']
|
|
221
|
+
self.rotation = index.get('rotation', None)
|
|
195
222
|
|
|
196
223
|
if not isinstance(index['timestep'], (str, float, int)):
|
|
197
224
|
raise ValueError('Malformed index: timestep is not a string or number')
|
|
@@ -226,7 +253,7 @@ class VideoStreamer:
|
|
|
226
253
|
Provides access to random frames as fast as is reasonable when getting
|
|
227
254
|
frames from compressed video in python.
|
|
228
255
|
"""
|
|
229
|
-
|
|
256
|
+
|
|
230
257
|
def decode_until(frame_number) -> np.ndarray:
|
|
231
258
|
"""
|
|
232
259
|
Decode forward from the current frame in the stream until we get
|
|
@@ -249,8 +276,15 @@ class VideoStreamer:
|
|
|
249
276
|
start, stop, step = frame_number.indices(self.n_frames)
|
|
250
277
|
return np.array([self._get_frame(i) for i in range(start, stop, step)])
|
|
251
278
|
with self._lock:
|
|
252
|
-
|
|
253
|
-
|
|
279
|
+
# Support negative indexing
|
|
280
|
+
if frame_number < 0 and frame_number + self.n_frames >= 0:
|
|
281
|
+
frame_number += self.n_frames
|
|
282
|
+
elif frame_number >= self.n_frames:
|
|
283
|
+
raise IndexError(f"Frame {frame_number} out of range:"
|
|
284
|
+
f" [0, {self.n_frames-1}]")
|
|
285
|
+
elif frame_number < 0:
|
|
286
|
+
raise IndexError(f"Negative frame {frame_number} out of"
|
|
287
|
+
f" range: [-{self.n_frames}, -1]")
|
|
254
288
|
|
|
255
289
|
if (self._current_frame_number is None
|
|
256
290
|
or frame_number <= self._current_frame_number
|
|
@@ -263,7 +297,17 @@ class VideoStreamer:
|
|
|
263
297
|
self.container.seek(seek_time, any_frame=False,
|
|
264
298
|
backward=True, stream=self.stream)
|
|
265
299
|
# Now we decode frames forward until we get to the requested frame
|
|
266
|
-
return decode_until(frame_number)
|
|
300
|
+
return _rotate(decode_until(frame_number), self.rotation)
|
|
301
|
+
|
|
302
|
+
@property
|
|
303
|
+
def average_timestep(self):
|
|
304
|
+
"""
|
|
305
|
+
Returns the average time step between frames.
|
|
306
|
+
"""
|
|
307
|
+
if self.timestep == 'variable':
|
|
308
|
+
return (self.time_index[-1] - self.time_index[0]) / (self.n_frames - 1)
|
|
309
|
+
else:
|
|
310
|
+
return self.timestep
|
|
267
311
|
|
|
268
312
|
@property
|
|
269
313
|
def first_frame(self):
|
|
@@ -278,13 +322,29 @@ class VideoStreamer:
|
|
|
278
322
|
self._shape = (self.n_frames,) + self.first_frame.shape
|
|
279
323
|
return self._shape
|
|
280
324
|
|
|
325
|
+
@property
|
|
326
|
+
def width(self):
|
|
327
|
+
if self._width is None:
|
|
328
|
+
self._width = self.first_frame.shape[1]
|
|
329
|
+
return self._width
|
|
330
|
+
|
|
331
|
+
@property
|
|
332
|
+
def height(self):
|
|
333
|
+
if self._height is None:
|
|
334
|
+
self._height = self.first_frame.shape[0]
|
|
335
|
+
return self._height
|
|
336
|
+
|
|
281
337
|
@property
|
|
282
338
|
def ndim(self):
|
|
283
|
-
|
|
339
|
+
if self._ndim is None:
|
|
340
|
+
self._ndim = len(self.shape)
|
|
341
|
+
return self._ndim
|
|
284
342
|
|
|
285
343
|
@property
|
|
286
344
|
def dtype(self):
|
|
287
|
-
|
|
345
|
+
if self._dtype is None:
|
|
346
|
+
self._dtype = self.first_frame.dtype
|
|
347
|
+
return self._dtype
|
|
288
348
|
|
|
289
349
|
def __len__(self):
|
|
290
350
|
return self.n_frames
|
|
@@ -317,7 +377,7 @@ class VideoWriter:
|
|
|
317
377
|
----------
|
|
318
378
|
filename : str
|
|
319
379
|
The filename to save the video to.
|
|
320
|
-
framerate : int, default 30
|
|
380
|
+
framerate : int or float, default 30
|
|
321
381
|
The frame rate of the video.
|
|
322
382
|
crf : int, default 23
|
|
323
383
|
Constant Rate Factor for encoding quality (lower is better quality).
|
|
@@ -337,17 +397,27 @@ class VideoWriter:
|
|
|
337
397
|
raise ImportError('Missing optional dependency for video processing,'
|
|
338
398
|
' run `pip install av tqdm`')
|
|
339
399
|
self.av = av
|
|
400
|
+
from fractions import Fraction
|
|
340
401
|
filename = os.path.expanduser(str(filename))
|
|
341
402
|
if os.path.exists(filename) and not overwrite:
|
|
342
403
|
raise FileExistsError(f'File {filename} already exists. '
|
|
343
404
|
'Set overwrite=True to overwrite.')
|
|
344
405
|
self.filename = filename
|
|
345
|
-
self.framerate = framerate
|
|
406
|
+
self.framerate = str(framerate) # str instead of float to avoid precision issues
|
|
407
|
+
while Fraction(self.framerate).denominator >= 2**32 or Fraction(self.framerate).numerator >= 2**32:
|
|
408
|
+
# If framerate has too many decimals to be expressed as a
|
|
409
|
+
# ratio of 32-bit ints, which is required by ffmpeg, crop
|
|
410
|
+
# off one decimal point of precision until it is expressable
|
|
411
|
+
self.framerate = self.framerate[:-1]
|
|
412
|
+
if self.framerate[-1] == '.':
|
|
413
|
+
self.framerate = self.framerate[:-1]
|
|
414
|
+
if len(self.framerate) == 0:
|
|
415
|
+
raise RuntimeError('Error occurred handling framerate argument')
|
|
346
416
|
self.crf = crf
|
|
347
417
|
self.compression_speed = compression_speed
|
|
348
418
|
self.codec = codec_aliases[codec.lower()]
|
|
349
419
|
self.container = av.open(filename, mode='w')
|
|
350
|
-
self.stream = self.container.add_stream(self.codec, rate=framerate)
|
|
420
|
+
self.stream = self.container.add_stream(self.codec, rate=Fraction(self.framerate))
|
|
351
421
|
self.stream.pix_fmt = 'yuv420p'
|
|
352
422
|
self.stream.options = {'crf': str(crf), 'preset': compression_speed}
|
|
353
423
|
self._closed = False
|
|
@@ -358,10 +428,14 @@ class VideoWriter:
|
|
|
358
428
|
if not isinstance(frame, self.av.VideoFrame):
|
|
359
429
|
if frame.ndim == 3 and frame.shape[-1] == 3:
|
|
360
430
|
frame = self.av.VideoFrame.from_ndarray(frame, format='rgb24')
|
|
431
|
+
elif frame.ndim == 3 and frame.shape[-1] == 4:
|
|
432
|
+
# While some video codecs support an alpha channel, most don't,
|
|
433
|
+
# so for now we're just going to ignore the alpha channel
|
|
434
|
+
frame = self.av.VideoFrame.from_ndarray(frame[..., :3], format='rgb24')
|
|
361
435
|
elif frame.ndim == 2:
|
|
362
436
|
frame = self.av.VideoFrame.from_ndarray(frame, format='gray')
|
|
363
437
|
else:
|
|
364
|
-
raise ValueError(f'Frame must be (H, W) or (H, W,
|
|
438
|
+
raise ValueError(f'Frame must be (H, W) (H, W, 3) or (H, W, 4) but was {frame.shape}')
|
|
365
439
|
if self.stream.width == 0:
|
|
366
440
|
self.stream.width = frame.width
|
|
367
441
|
if self.stream.height == 0:
|
|
@@ -439,14 +513,14 @@ def save_video(data, filename, time_axis=0, color_axis=None, overwrite=False,
|
|
|
439
513
|
' run `pip install av tqdm`')
|
|
440
514
|
|
|
441
515
|
filename = os.path.expanduser(str(filename))
|
|
442
|
-
if filename.split('.')[-1].lower() not in
|
|
516
|
+
if filename.split('.')[-1].lower() not in supported_extensions:
|
|
443
517
|
filename += '.mp4'
|
|
444
518
|
if os.path.exists(filename) and not overwrite:
|
|
445
519
|
raise FileExistsError(f'File {filename} already exists. '
|
|
446
520
|
'Set overwrite=True to overwrite.')
|
|
447
521
|
|
|
448
522
|
if color_axis is None and data.ndim == 4:
|
|
449
|
-
color_axis = find_channel_axis(data, possible_channel_lengths=3)
|
|
523
|
+
color_axis = utils.find_channel_axis(data, possible_channel_lengths=3)
|
|
450
524
|
if color_axis is None:
|
|
451
525
|
raise ValueError('4D input data must have an RGB (length 3) axis.')
|
|
452
526
|
if color_axis is not None:
|
|
@@ -487,3 +561,70 @@ def save_video(data, filename, time_axis=0, color_axis=None, overwrite=False,
|
|
|
487
561
|
for frame_i in tqdm(range(n_frames), total=n_frames,
|
|
488
562
|
desc='Saving video', disable=not progress_bar):
|
|
489
563
|
writer.write(data[frame_i])
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _detect_rotation(filename):
|
|
567
|
+
"""
|
|
568
|
+
Detect rotation metadata from the video file using ffprobe.
|
|
569
|
+
Returns the rotation angle as a string (e.g., '90', '180', '270'), or None if not present.
|
|
570
|
+
"""
|
|
571
|
+
import subprocess
|
|
572
|
+
import json
|
|
573
|
+
cmd = [
|
|
574
|
+
'ffprobe',
|
|
575
|
+
'-v', 'quiet',
|
|
576
|
+
'-select_streams', 'v:0',
|
|
577
|
+
'-show_entries', 'stream_tags=rotate',
|
|
578
|
+
'-of', 'json',
|
|
579
|
+
str(filename)
|
|
580
|
+
]
|
|
581
|
+
result = subprocess.run(cmd, stdout=subprocess.PIPE,
|
|
582
|
+
stderr=subprocess.PIPE, text=True)
|
|
583
|
+
if result.returncode != 0:
|
|
584
|
+
return None
|
|
585
|
+
try:
|
|
586
|
+
data = json.loads(result.stdout)
|
|
587
|
+
streams = data.get('streams', [])
|
|
588
|
+
if streams and 'tags' in streams[0]:
|
|
589
|
+
rotate_tag = streams[0]['tags'].get('rotate')
|
|
590
|
+
if rotate_tag:
|
|
591
|
+
return rotate_tag
|
|
592
|
+
except (json.JSONDecodeError, KeyError, ValueError):
|
|
593
|
+
pass
|
|
594
|
+
return None
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _rotate(data: np.ndarray, rotation: Union[str, int, None] = 0) -> np.ndarray:
|
|
598
|
+
"""
|
|
599
|
+
Apply clockwise rotation to a numpy array
|
|
600
|
+
|
|
601
|
+
Parameters
|
|
602
|
+
----------
|
|
603
|
+
data : np.ndarray
|
|
604
|
+
The numpy array to rotate.
|
|
605
|
+
rotation : str, int, or None, default 0
|
|
606
|
+
The rotation angle in degrees, as a string or integer.
|
|
607
|
+
Valid values are 0, 90, 180, 270, or 360.
|
|
608
|
+
If None, 0, or 360, no rotation is applied.
|
|
609
|
+
|
|
610
|
+
TODO find an actual video file with rotation_tag of 90 or 270 and check that
|
|
611
|
+
the rotation is applied in the right direction. If it's opposite the correct
|
|
612
|
+
direction, remove axes=(1, 0) from the np.rot90 calls in this function.
|
|
613
|
+
"""
|
|
614
|
+
if rotation is None:
|
|
615
|
+
return data
|
|
616
|
+
try:
|
|
617
|
+
rotation = int(rotation)
|
|
618
|
+
except (ValueError, TypeError):
|
|
619
|
+
raise ValueError(f"Invalid rotation value: {rotation}")
|
|
620
|
+
|
|
621
|
+
if rotation in [0, 360]:
|
|
622
|
+
return data
|
|
623
|
+
if rotation == 90:
|
|
624
|
+
return np.rot90(data, k=1, axes=(1, 0))
|
|
625
|
+
elif rotation == 180:
|
|
626
|
+
return np.rot90(data, k=2, axes=(1, 0))
|
|
627
|
+
elif rotation == 270:
|
|
628
|
+
return np.rot90(data, k=3, axes=(1, 0))
|
|
629
|
+
else:
|
|
630
|
+
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
|