numpyimage 2.6.0__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.0 → numpyimage-2.6.2}/PKG-INFO +5 -4
- {numpyimage-2.6.0 → numpyimage-2.6.2}/README.md +4 -3
- {numpyimage-2.6.0 → numpyimage-2.6.2}/npimage/__init__.py +3 -1
- {numpyimage-2.6.0 → numpyimage-2.6.2}/npimage/graphics.py +1 -1
- numpyimage-2.6.0/npimage/core.py → numpyimage-2.6.2/npimage/imageio.py +9 -342
- {numpyimage-2.6.0 → numpyimage-2.6.2}/npimage/utils.py +55 -0
- numpyimage-2.6.2/npimage/vidio.py +625 -0
- {numpyimage-2.6.0 → numpyimage-2.6.2}/numpyimage.egg-info/PKG-INFO +5 -4
- {numpyimage-2.6.0 → numpyimage-2.6.2}/numpyimage.egg-info/SOURCES.txt +2 -1
- {numpyimage-2.6.0 → numpyimage-2.6.2}/pyproject.toml +1 -1
- {numpyimage-2.6.0 → numpyimage-2.6.2}/LICENSE +0 -0
- {numpyimage-2.6.0 → numpyimage-2.6.2}/npimage/align.py +0 -0
- {numpyimage-2.6.0 → numpyimage-2.6.2}/npimage/nrrd_utils.py +0 -0
- {numpyimage-2.6.0 → numpyimage-2.6.2}/npimage/operations.py +0 -0
- {numpyimage-2.6.0 → numpyimage-2.6.2}/numpyimage.egg-info/dependency_links.txt +0 -0
- {numpyimage-2.6.0 → numpyimage-2.6.2}/numpyimage.egg-info/requires.txt +0 -0
- {numpyimage-2.6.0 → numpyimage-2.6.2}/numpyimage.egg-info/top_level.txt +0 -0
- {numpyimage-2.6.0 → numpyimage-2.6.2}/setup.cfg +0 -0
- {numpyimage-2.6.0 → numpyimage-2.6.2}/tests/test_heic.py +0 -0
- {numpyimage-2.6.0 → numpyimage-2.6.2}/tests/test_pbm.py +0 -0
- {numpyimage-2.6.0 → numpyimage-2.6.2}/tests/test_video.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: numpyimage
|
|
3
|
-
Version: 2.6.
|
|
3
|
+
Version: 2.6.2
|
|
4
4
|
Summary: Load, save, & manipulate image files as numpy arrays
|
|
5
5
|
Author-email: Jasper Phelps <jasper.s.phelps@gmail.com>
|
|
6
6
|
License: GNU GENERAL PUBLIC LICENSE
|
|
@@ -701,18 +701,19 @@ Requires-Dist: av; extra == "all"
|
|
|
701
701
|
Dynamic: license-file
|
|
702
702
|
|
|
703
703
|
# npimage
|
|
704
|
-
Need to load pixel values from image files as numpy arrays, and hate having to remember whether you should use PIL, tifffile, matplotlib, or something else? Hate having to deal with the fact that those libraries all use different function names and syntaxes? Wish you could just provide a filename and get back a numpy array? This library's `
|
|
704
|
+
Need to load pixel values from image files as numpy arrays, and hate having to remember whether you should use PIL, tifffile, matplotlib, or something else? Hate having to deal with the fact that those libraries all use different function names and syntaxes? Wish you could just provide a filename and get back a numpy array? This library's `imageio.py` does that, with `array = npimage.load(filename)`, `npimage.save(array, filename)`, and `npimage.show(array)` functions that let you easily handle a number of common image file formats without having to remember library-specific syntax. Additionally, `vidio.py` provides `array = npimage.load_video(filename)` and `npimage.save_video(array, filename)` for videos as well. (Another similar library to consider using is [imageio](https://pypi.org/project/imageio/).)
|
|
705
705
|
|
|
706
706
|
Want to draw simple shapes like lines, triangles, and circles into 3D numpy arrays? Frustrated that the python libraries you can find online like `opencv` and `skimage.draw` work on 2D arrays but not 3D? I wrote some functions in `graphics.py` that do the trick in 3D. (If you know of another library that can do this, please let me know!)
|
|
707
707
|
|
|
708
708
|
|
|
709
709
|
### Documentation
|
|
710
|
-
- `
|
|
710
|
+
- `imageio.py`: load, save, or show images.
|
|
711
|
+
- `vidio.py`: load or save videos.
|
|
711
712
|
- `graphics.py`: draw points, lines, triangles, circles, or spheres into 2D or 3D numpy arrays representing image volumes.
|
|
712
713
|
- `nrrd_utils.py`: compress or read metadata from `.nrrd` files.
|
|
713
714
|
- `operations.py`: perform operations on images.
|
|
714
715
|
|
|
715
|
-
|
|
716
|
+
Check each function's docstring for more details.
|
|
716
717
|
|
|
717
718
|
|
|
718
719
|
### Installation
|
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
# npimage
|
|
2
|
-
Need to load pixel values from image files as numpy arrays, and hate having to remember whether you should use PIL, tifffile, matplotlib, or something else? Hate having to deal with the fact that those libraries all use different function names and syntaxes? Wish you could just provide a filename and get back a numpy array? This library's `
|
|
2
|
+
Need to load pixel values from image files as numpy arrays, and hate having to remember whether you should use PIL, tifffile, matplotlib, or something else? Hate having to deal with the fact that those libraries all use different function names and syntaxes? Wish you could just provide a filename and get back a numpy array? This library's `imageio.py` does that, with `array = npimage.load(filename)`, `npimage.save(array, filename)`, and `npimage.show(array)` functions that let you easily handle a number of common image file formats without having to remember library-specific syntax. Additionally, `vidio.py` provides `array = npimage.load_video(filename)` and `npimage.save_video(array, filename)` for videos as well. (Another similar library to consider using is [imageio](https://pypi.org/project/imageio/).)
|
|
3
3
|
|
|
4
4
|
Want to draw simple shapes like lines, triangles, and circles into 3D numpy arrays? Frustrated that the python libraries you can find online like `opencv` and `skimage.draw` work on 2D arrays but not 3D? I wrote some functions in `graphics.py` that do the trick in 3D. (If you know of another library that can do this, please let me know!)
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
### Documentation
|
|
8
|
-
- `
|
|
8
|
+
- `imageio.py`: load, save, or show images.
|
|
9
|
+
- `vidio.py`: load or save videos.
|
|
9
10
|
- `graphics.py`: draw points, lines, triangles, circles, or spheres into 2D or 3D numpy arrays representing image volumes.
|
|
10
11
|
- `nrrd_utils.py`: compress or read metadata from `.nrrd` files.
|
|
11
12
|
- `operations.py`: perform operations on images.
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
Check each function's docstring for more details.
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
### Installation
|
|
@@ -686,7 +686,7 @@ def floodfill(image, seed, fill_value, fill_diagonally=False,
|
|
|
686
686
|
|
|
687
687
|
if debug_frequency is not None and iteration % debug_frequency == 0:
|
|
688
688
|
print(f'Writing iteration{iteration}.nrrd')
|
|
689
|
-
from .
|
|
689
|
+
from .imageio import save
|
|
690
690
|
save(image, f'iteration{iteration}.nrrd')
|
|
691
691
|
|
|
692
692
|
#recurseifneighbors
|
|
@@ -5,18 +5,17 @@ Functions for reading, writing, and showing images.
|
|
|
5
5
|
Function list:
|
|
6
6
|
- load(filename) -> numpy.ndarray
|
|
7
7
|
- save(data, filename) -> Saves a numpy array as an nD image file
|
|
8
|
-
- save_video(data, filename) -> Saves a 3D numpy array as a video
|
|
9
8
|
- show(np_array) -> Displays a numpy array of pixel values as an image
|
|
10
9
|
"""
|
|
11
10
|
|
|
12
|
-
from typing import Literal, Union, Tuple
|
|
11
|
+
from typing import Literal, Union, Tuple
|
|
13
12
|
import os
|
|
14
13
|
import glob
|
|
15
14
|
from builtins import open as builtin_open
|
|
16
15
|
|
|
17
16
|
import numpy as np
|
|
18
17
|
|
|
19
|
-
from . import operations, utils
|
|
18
|
+
from . import vidio, operations, utils
|
|
20
19
|
|
|
21
20
|
supported_extensions = [
|
|
22
21
|
'tif', 'tiff', 'jpg', 'jpeg', 'png', 'pbm',
|
|
@@ -44,7 +43,9 @@ def load(filename, dim_order='zyx', **kwargs) -> Union[np.ndarray, Tuple[np.ndar
|
|
|
44
43
|
raise ValueError('Could not determine file format from filename'
|
|
45
44
|
f' "{filename}". Please specify the file type via'
|
|
46
45
|
' the `format` argument, e.g. format="tif"')
|
|
47
|
-
if extension
|
|
46
|
+
if extension in vidio.supported_extensions:
|
|
47
|
+
return vidio.VideoStreamer(filename)
|
|
48
|
+
elif extension not in supported_extensions:
|
|
48
49
|
raise ValueError(f'File format of "{filename}" not supported/recognized.')
|
|
49
50
|
|
|
50
51
|
data = None
|
|
@@ -179,7 +180,7 @@ def save(data,
|
|
|
179
180
|
'.nrrd. Whether or not compression occurs now will depend on '
|
|
180
181
|
'the format you are saving to.')
|
|
181
182
|
|
|
182
|
-
channel_axis = find_channel_axis(data)
|
|
183
|
+
channel_axis = utils.find_channel_axis(data)
|
|
183
184
|
if 'xy' in dim_order:
|
|
184
185
|
data = data.T
|
|
185
186
|
if hasattr(pixel_size, '__iter__') and not isinstance(pixel_size, str):
|
|
@@ -361,286 +362,6 @@ write = save # Function name alias
|
|
|
361
362
|
to_file = save # Function name alias
|
|
362
363
|
|
|
363
364
|
|
|
364
|
-
def load_video(filename,
|
|
365
|
-
return_framerate=False,
|
|
366
|
-
progress_bar=True) -> Union[np.ndarray, Tuple[np.ndarray, float]]:
|
|
367
|
-
"""
|
|
368
|
-
Load all images in a video file as a numpy array.
|
|
369
|
-
|
|
370
|
-
Parameters
|
|
371
|
-
----------
|
|
372
|
-
filename : str
|
|
373
|
-
Path to the video file
|
|
374
|
-
return_framerate : bool, default False
|
|
375
|
-
If True, return the frame rate of the video
|
|
376
|
-
progress_bar : bool, default True
|
|
377
|
-
If True, display a progress bar
|
|
378
|
-
|
|
379
|
-
Returns
|
|
380
|
-
-------
|
|
381
|
-
data : numpy.ndarray
|
|
382
|
-
The video frames as a numpy array, shape (num_frames, height, width, colors)
|
|
383
|
-
"""
|
|
384
|
-
try:
|
|
385
|
-
import av
|
|
386
|
-
from tqdm import tqdm
|
|
387
|
-
except ImportError:
|
|
388
|
-
raise ImportError('Missing optional dependency for video processing,'
|
|
389
|
-
' run `pip install av tqdm`')
|
|
390
|
-
|
|
391
|
-
container = av.open(filename)
|
|
392
|
-
stream = container.streams.video[0]
|
|
393
|
-
num_frames = stream.frames
|
|
394
|
-
if not num_frames or num_frames == 0:
|
|
395
|
-
# If we don't know the number of frames, we can't preallocate, so
|
|
396
|
-
# it's hard to do better than the following approach which temporarily
|
|
397
|
-
# uses double the amount of RAM compared to the preallocated approach.
|
|
398
|
-
data = np.array(list(lazy_load_video(filename)))
|
|
399
|
-
if return_framerate:
|
|
400
|
-
return data, float(stream.average_rate)
|
|
401
|
-
else:
|
|
402
|
-
return data
|
|
403
|
-
else:
|
|
404
|
-
# Load first image to get shape and dtype
|
|
405
|
-
frame_iter = container.decode(stream)
|
|
406
|
-
first_frame = next(frame_iter)
|
|
407
|
-
first_img = first_frame.to_ndarray(format='rgb24')
|
|
408
|
-
# Preallocate memory for the entire array
|
|
409
|
-
data = np.empty((num_frames, *first_img.shape), dtype=first_img.dtype)
|
|
410
|
-
# Then fill it up frame by frame
|
|
411
|
-
data[0] = first_img
|
|
412
|
-
for i, frame in tqdm(enumerate(frame_iter), total=num_frames,
|
|
413
|
-
desc='Loading video', disable=not progress_bar):
|
|
414
|
-
if i == 0:
|
|
415
|
-
continue
|
|
416
|
-
img = frame.to_ndarray(format='rgb24')
|
|
417
|
-
data[i] = img
|
|
418
|
-
if return_framerate:
|
|
419
|
-
return data, float(stream.average_rate)
|
|
420
|
-
else:
|
|
421
|
-
return data
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
def lazy_load_video(filename) -> Iterator[np.ndarray]:
|
|
425
|
-
"""
|
|
426
|
-
Lazily load video frames as numpy arrays using PyAV.
|
|
427
|
-
|
|
428
|
-
Parameters
|
|
429
|
-
----------
|
|
430
|
-
filename : str
|
|
431
|
-
Path to the video file.
|
|
432
|
-
|
|
433
|
-
Yields
|
|
434
|
-
------
|
|
435
|
-
frame : np.ndarray
|
|
436
|
-
Video frame as a numpy array, shape (height, width, colors).
|
|
437
|
-
"""
|
|
438
|
-
try:
|
|
439
|
-
import av
|
|
440
|
-
except ImportError:
|
|
441
|
-
raise ImportError('Missing optional dependency for video processing,'
|
|
442
|
-
' run `pip install av tqdm`')
|
|
443
|
-
container = av.open(filename)
|
|
444
|
-
stream = container.streams.video[0]
|
|
445
|
-
for frame in container.decode(stream):
|
|
446
|
-
img = frame.to_ndarray(format='rgb24')
|
|
447
|
-
yield img
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
codec_aliases = {
|
|
451
|
-
"libx264": "libx264",
|
|
452
|
-
"avc1": "libx264",
|
|
453
|
-
"h264": "libx264",
|
|
454
|
-
"H.264": "libx264",
|
|
455
|
-
"libx265": "libx265",
|
|
456
|
-
"hevc": "libx265",
|
|
457
|
-
"hvc1": "libx265",
|
|
458
|
-
"hev1": "libx265",
|
|
459
|
-
"h265": "libx265",
|
|
460
|
-
"H.265": "libx265",
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
class VideoWriter:
|
|
465
|
-
"""
|
|
466
|
-
Create a video writer object for saving frames to a video file.
|
|
467
|
-
|
|
468
|
-
Parameters
|
|
469
|
-
----------
|
|
470
|
-
filename : str
|
|
471
|
-
The filename to save the video to.
|
|
472
|
-
framerate : int, default 30
|
|
473
|
-
The frame rate of the video.
|
|
474
|
-
crf : int, default 23
|
|
475
|
-
Constant Rate Factor for encoding quality (lower is better quality).
|
|
476
|
-
compression_speed : str, default 'medium'
|
|
477
|
-
Compression speed preset: 'ultrafast', 'superfast', 'veryfast',
|
|
478
|
-
'faster', 'fast', 'medium', 'slow', 'slower', 'veryslow'.
|
|
479
|
-
codec : Literal['libx264', 'libx265'], default 'libx264'
|
|
480
|
-
The video codec to use for encoding. Can be any of a number of aliases for
|
|
481
|
-
these two codecs, including avc1/h264 vs hevc/hvc1/hev1/h265.
|
|
482
|
-
"""
|
|
483
|
-
def __init__(self, filename, framerate=30, crf=23, compression_speed='medium',
|
|
484
|
-
codec: Literal['libx264', 'libx265'] = 'libx264',
|
|
485
|
-
overwrite=False):
|
|
486
|
-
try:
|
|
487
|
-
import av
|
|
488
|
-
except ImportError:
|
|
489
|
-
raise ImportError('Missing optional dependency for video processing,'
|
|
490
|
-
' run `pip install av tqdm`')
|
|
491
|
-
self.av = av
|
|
492
|
-
filename = os.path.expanduser(str(filename))
|
|
493
|
-
if os.path.exists(filename) and not overwrite:
|
|
494
|
-
raise FileExistsError(f'File {filename} already exists. '
|
|
495
|
-
'Set overwrite=True to overwrite.')
|
|
496
|
-
self.filename = filename
|
|
497
|
-
self.framerate = framerate
|
|
498
|
-
self.crf = crf
|
|
499
|
-
self.compression_speed = compression_speed
|
|
500
|
-
self.codec = codec_aliases[codec.lower()]
|
|
501
|
-
self.container = av.open(filename, mode='w')
|
|
502
|
-
self.stream = self.container.add_stream(self.codec, rate=framerate)
|
|
503
|
-
self.stream.pix_fmt = 'yuv420p'
|
|
504
|
-
self.stream.options = {'crf': str(crf), 'preset': compression_speed}
|
|
505
|
-
self._closed = False
|
|
506
|
-
self.stream.width = 0
|
|
507
|
-
self.stream.height = 0
|
|
508
|
-
|
|
509
|
-
def write(self, frame):
|
|
510
|
-
if not isinstance(frame, self.av.VideoFrame):
|
|
511
|
-
if frame.ndim == 3 and frame.shape[-1] == 3:
|
|
512
|
-
frame = self.av.VideoFrame.from_ndarray(frame, format='rgb24')
|
|
513
|
-
elif frame.ndim == 2:
|
|
514
|
-
frame = self.av.VideoFrame.from_ndarray(frame, format='gray')
|
|
515
|
-
else:
|
|
516
|
-
raise ValueError(f'Frame must be (H, W) or (H, W, 3) but was {frame.shape}')
|
|
517
|
-
if self.stream.width == 0:
|
|
518
|
-
self.stream.width = frame.width
|
|
519
|
-
if self.stream.height == 0:
|
|
520
|
-
self.stream.height = frame.height
|
|
521
|
-
for packet in self.stream.encode(frame):
|
|
522
|
-
self.container.mux(packet)
|
|
523
|
-
|
|
524
|
-
def __enter__(self):
|
|
525
|
-
return self
|
|
526
|
-
|
|
527
|
-
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
528
|
-
# Flush stream
|
|
529
|
-
for packet in self.stream.encode():
|
|
530
|
-
self.container.mux(packet)
|
|
531
|
-
self.container.close()
|
|
532
|
-
self._closed = True
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
def save_video(data, filename, time_axis=0, color_axis=None, overwrite=False,
|
|
536
|
-
dim_order='yx', framerate=30, crf=23, compression_speed='medium',
|
|
537
|
-
progress_bar=True, codec: Literal['libx264', 'libx265'] = 'libx264') -> None:
|
|
538
|
-
"""
|
|
539
|
-
Save a 3D numpy array of greyscale values OR a 4D numpy array of RGB values as a video
|
|
540
|
-
|
|
541
|
-
Follows the PyAV cookbook section on generating video from numpy arrays:
|
|
542
|
-
https://pyav.basswood-io.com/docs/develop/cookbook/numpy.html#generating-video
|
|
543
|
-
|
|
544
|
-
Parameters
|
|
545
|
-
----------
|
|
546
|
-
data : numpy.ndarray or list of filenames
|
|
547
|
-
A 3D (grayscale) or 4D (RGB) numpy array of pixel values.
|
|
548
|
-
|
|
549
|
-
filename : str
|
|
550
|
-
The filename to save the video to.
|
|
551
|
-
|
|
552
|
-
time_axis : int, default 0
|
|
553
|
-
The axis of the data array that will be played as time in the video.
|
|
554
|
-
|
|
555
|
-
color_axis : int or None, default None
|
|
556
|
-
If not None, specifies the axis of the color channels (e.g., -1 for last axis,
|
|
557
|
-
1 for second axis).
|
|
558
|
-
If None, data must be 3D (greyscale) or 4D with one length 3 axis (RGB).
|
|
559
|
-
|
|
560
|
-
overwrite : bool, default False
|
|
561
|
-
Whether to overwrite the file if it already exists.
|
|
562
|
-
|
|
563
|
-
dim_order : 'yx' (default) or 'xy'
|
|
564
|
-
The order of the spatial dimensions in the input data.
|
|
565
|
-
|
|
566
|
-
framerate : int, default 30
|
|
567
|
-
The frame rate of the video.
|
|
568
|
-
|
|
569
|
-
crf : int, default 23
|
|
570
|
-
Constant Rate Factor that specifies amount of lossiness allowed in compression.
|
|
571
|
-
Lower values produce better quality, larger videos. crf=17 has no human-visible
|
|
572
|
-
compression artifacts. File size approximately doubles/halves each time you
|
|
573
|
-
add/subtract 6 from crf, so crf=17 produces files about twice as large as
|
|
574
|
-
the default crf=23.
|
|
575
|
-
|
|
576
|
-
compression_speed : str, default 'medium'
|
|
577
|
-
Compression speed preset: 'ultrafast', 'superfast', 'veryfast',
|
|
578
|
-
'faster', 'fast', 'medium', 'slow', 'slower', 'veryslow'.
|
|
579
|
-
|
|
580
|
-
progress_bar : bool, default True
|
|
581
|
-
If True, display a progress bar.
|
|
582
|
-
|
|
583
|
-
codec : Literal['libx264', 'libx265'], default 'libx264'
|
|
584
|
-
The video codec to use for encoding. Can be any of a number of aliases for
|
|
585
|
-
these two codecs, including avc1/h264 vs hevc/hvc1/hev1/h265.
|
|
586
|
-
"""
|
|
587
|
-
try:
|
|
588
|
-
from tqdm import tqdm
|
|
589
|
-
except ImportError:
|
|
590
|
-
raise ImportError('Missing optional dependency for video processing,'
|
|
591
|
-
' run `pip install av tqdm`')
|
|
592
|
-
|
|
593
|
-
filename = os.path.expanduser(str(filename))
|
|
594
|
-
if filename.split('.')[-1].lower() not in ['mp4', 'mkv', 'avi', 'mov']:
|
|
595
|
-
filename += '.mp4'
|
|
596
|
-
if os.path.exists(filename) and not overwrite:
|
|
597
|
-
raise FileExistsError(f'File {filename} already exists. '
|
|
598
|
-
'Set overwrite=True to overwrite.')
|
|
599
|
-
|
|
600
|
-
if color_axis is None and data.ndim == 4:
|
|
601
|
-
color_axis = find_channel_axis(data, possible_channel_lengths=3)
|
|
602
|
-
if color_axis is None:
|
|
603
|
-
raise ValueError('4D input data must have an RGB (length 3) axis.')
|
|
604
|
-
if color_axis is not None:
|
|
605
|
-
if data.ndim != 4:
|
|
606
|
-
raise ValueError('Input data must be 4D when color_axis is specified.')
|
|
607
|
-
# Move time axis to 0, color axis to -1
|
|
608
|
-
data = np.moveaxis(data, time_axis, 0)
|
|
609
|
-
if color_axis != -1:
|
|
610
|
-
data = np.moveaxis(data, color_axis, -1)
|
|
611
|
-
if 'xy' in dim_order:
|
|
612
|
-
data = data.swapaxes(1, 2)
|
|
613
|
-
n_frames = data.shape[0]
|
|
614
|
-
height, width, channels = data.shape[1:]
|
|
615
|
-
if channels != 3:
|
|
616
|
-
raise ValueError(f'Color video must have 3 channels (RGB) but had {channels}.')
|
|
617
|
-
else:
|
|
618
|
-
if data.ndim != 3:
|
|
619
|
-
raise ValueError('Input data must be 3D when color_axis is not specified.')
|
|
620
|
-
data = np.moveaxis(data, time_axis, 0)
|
|
621
|
-
if 'xy' in dim_order:
|
|
622
|
-
data = data.swapaxes(1, 2)
|
|
623
|
-
n_frames = data.shape[0]
|
|
624
|
-
height, width = data.shape[1:]
|
|
625
|
-
|
|
626
|
-
extension = filename.split('.')[-1].lower()
|
|
627
|
-
if extension == 'mp4':
|
|
628
|
-
pad = [[0, 0], [0, 0], [0, 0]]
|
|
629
|
-
if height % 2 != 0:
|
|
630
|
-
pad[1][1] = 1
|
|
631
|
-
if width % 2 != 0:
|
|
632
|
-
pad[2][1] = 1
|
|
633
|
-
if pad != [[0, 0], [0, 0], [0, 0]]:
|
|
634
|
-
data = np.pad(data, pad, mode='edge')
|
|
635
|
-
|
|
636
|
-
with VideoWriter(filename, framerate=framerate, crf=crf,
|
|
637
|
-
compression_speed=compression_speed, codec=codec,
|
|
638
|
-
overwrite=overwrite) as writer:
|
|
639
|
-
for frame_i in tqdm(range(n_frames), total=n_frames,
|
|
640
|
-
desc='Saving video', disable=not progress_bar):
|
|
641
|
-
writer.write(data[frame_i])
|
|
642
|
-
|
|
643
|
-
|
|
644
365
|
def show(data,
|
|
645
366
|
dim_order='yx',
|
|
646
367
|
data_type: Literal['image', 'segmentation'] = 'image',
|
|
@@ -673,7 +394,7 @@ def show(data,
|
|
|
673
394
|
if data_type == 'segmentation':
|
|
674
395
|
data = utils.assign_random_colors(data, seed=kwargs.get('seed', None))
|
|
675
396
|
|
|
676
|
-
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):
|
|
677
398
|
m = ('Data must have shape (y, x) for grayscale, '
|
|
678
399
|
'(y, x, 3) for RGB, or (y, x, 4) for RGBA but had '
|
|
679
400
|
f'shape {data.shape}')
|
|
@@ -684,9 +405,9 @@ def show(data,
|
|
|
684
405
|
if 'xy' in dim_order:
|
|
685
406
|
data = data.T
|
|
686
407
|
if channel_axis == 'guess':
|
|
687
|
-
channel_axis = find_channel_axis(data)
|
|
408
|
+
channel_axis = utils.find_channel_axis(data)
|
|
688
409
|
if utils.isint(channel_axis) and channel_axis != -1:
|
|
689
|
-
data = np.moveaxis(data, find_channel_axis(data), -1)
|
|
410
|
+
data = np.moveaxis(data, utils.find_channel_axis(data), -1)
|
|
690
411
|
|
|
691
412
|
if convert_to_8bit and data.dtype != np.uint8:
|
|
692
413
|
data = operations.to_8bit(data)
|
|
@@ -711,60 +432,6 @@ def show(data,
|
|
|
711
432
|
imshow = show # Function name alias
|
|
712
433
|
|
|
713
434
|
|
|
714
|
-
def find_channel_axis(data,
|
|
715
|
-
possible_channel_axes=[-1, 0],
|
|
716
|
-
possible_channel_lengths=[2, 3, 4]) -> Union[int, None]:
|
|
717
|
-
"""
|
|
718
|
-
If the given numpy array has a shape suggesting that it has a
|
|
719
|
-
channel (color) axis (that is, any axis with length 2 (2-color),
|
|
720
|
-
3 (RGB), or 4 (RGBA)), return the index of that axis.
|
|
721
|
-
|
|
722
|
-
Parameters
|
|
723
|
-
----------
|
|
724
|
-
data : numpy.ndarray
|
|
725
|
-
The numpy array to check for a channel axis.
|
|
726
|
-
|
|
727
|
-
possible_channel_axes : int or list of int, default [0, -1]
|
|
728
|
-
If None, any axis having length 2, 3, or 4 will be considered
|
|
729
|
-
a channel axis.
|
|
730
|
-
If an int, only that axis index will be checked.
|
|
731
|
-
If a list of ints, all axes with those indices will be checked.
|
|
732
|
-
The default value of [-1, 0] checks the last and first axes, which is
|
|
733
|
-
almost always where a channel axis will be found.
|
|
734
|
-
|
|
735
|
-
possible_channel_lengths : int or list of int, default [2, 3, 4]
|
|
736
|
-
If an int, only that length will be considered a channel axis.
|
|
737
|
-
If a list of ints, an axis with any of those lengths will be considered
|
|
738
|
-
a channel axis.
|
|
739
|
-
|
|
740
|
-
Returns
|
|
741
|
-
-------
|
|
742
|
-
int or None
|
|
743
|
-
The index of the channel axis, or None if no channel axis was found.
|
|
744
|
-
|
|
745
|
-
If possible_channel_axes is given, the returned value will be one of
|
|
746
|
-
the possible_channel_axes values, or None if no channel axis was found.
|
|
747
|
-
|
|
748
|
-
If possible_channel_axes is None, the returned value will be between
|
|
749
|
-
0 and data.ndim - 1, inclusive, or None if no channel axis was found.
|
|
750
|
-
|
|
751
|
-
Note that returning 0 means the channel axis was found and is the first
|
|
752
|
-
axis, so be careful not to do a test like `if find_channel_axis(data):`
|
|
753
|
-
because 0 will evaluate to False even though the data has a channel axis.
|
|
754
|
-
Instead write `if find_channel_axis(data) is not None:`
|
|
755
|
-
"""
|
|
756
|
-
if isinstance(possible_channel_axes, int):
|
|
757
|
-
possible_channel_axes = [possible_channel_axes]
|
|
758
|
-
if possible_channel_axes is None:
|
|
759
|
-
possible_channel_axes = range(data.ndim)
|
|
760
|
-
if isinstance(possible_channel_lengths, int):
|
|
761
|
-
possible_channel_lengths = [possible_channel_lengths]
|
|
762
|
-
for axis in possible_channel_axes:
|
|
763
|
-
if data.shape[axis] in possible_channel_lengths:
|
|
764
|
-
return axis
|
|
765
|
-
return None
|
|
766
|
-
|
|
767
|
-
|
|
768
435
|
# Flag to track if HEIF opener has been registered
|
|
769
436
|
_heif_opener_registered = False
|
|
770
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
|
"""
|
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Functions for reading and writing videos.
|
|
4
|
+
|
|
5
|
+
Function list:
|
|
6
|
+
- load_video(filename) -> np.ndarray
|
|
7
|
+
- lazy_load_video(filename) -> Iterator[np.ndarray]
|
|
8
|
+
- save_video(data, filename) -> Saves a 3D numpy array as a video
|
|
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
|
+
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from typing import Union, Tuple, Iterator, Literal
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
import os
|
|
22
|
+
import subprocess
|
|
23
|
+
import threading
|
|
24
|
+
import json
|
|
25
|
+
|
|
26
|
+
import numpy as np
|
|
27
|
+
|
|
28
|
+
from . import utils
|
|
29
|
+
|
|
30
|
+
codec_aliases = {
|
|
31
|
+
"libx264": "libx264",
|
|
32
|
+
"avc1": "libx264",
|
|
33
|
+
"h264": "libx264",
|
|
34
|
+
"H.264": "libx264",
|
|
35
|
+
"libx265": "libx265",
|
|
36
|
+
"hevc": "libx265",
|
|
37
|
+
"hvc1": "libx265",
|
|
38
|
+
"hev1": "libx265",
|
|
39
|
+
"h265": "libx265",
|
|
40
|
+
"H.265": "libx265",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
supported_extensions = ['mp4', 'mkv', 'avi', 'mov']
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_video(filename,
|
|
47
|
+
return_framerate=False,
|
|
48
|
+
progress_bar=True) -> Union[np.ndarray, Tuple[np.ndarray, float]]:
|
|
49
|
+
"""
|
|
50
|
+
Load all images in a video file as a numpy array.
|
|
51
|
+
|
|
52
|
+
Parameters
|
|
53
|
+
----------
|
|
54
|
+
filename : str
|
|
55
|
+
Path to the video file
|
|
56
|
+
return_framerate : bool, default False
|
|
57
|
+
If True, return the frame rate of the video
|
|
58
|
+
progress_bar : bool, default True
|
|
59
|
+
If True, display a progress bar
|
|
60
|
+
|
|
61
|
+
Returns
|
|
62
|
+
-------
|
|
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
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
import av
|
|
73
|
+
from tqdm import tqdm
|
|
74
|
+
except ImportError:
|
|
75
|
+
raise ImportError('Missing optional dependency for video processing,'
|
|
76
|
+
' run `pip install av tqdm`')
|
|
77
|
+
|
|
78
|
+
container = av.open(filename)
|
|
79
|
+
stream = container.streams.video[0]
|
|
80
|
+
num_frames = stream.frames
|
|
81
|
+
if not num_frames or num_frames == 0:
|
|
82
|
+
# If we don't know the number of frames, we can't preallocate, so
|
|
83
|
+
# it's hard to do better than the following approach which temporarily
|
|
84
|
+
# uses double the amount of RAM compared to the preallocated approach.
|
|
85
|
+
data = np.array(list(lazy_load_video(filename)))
|
|
86
|
+
if return_framerate:
|
|
87
|
+
return data, float(stream.average_rate)
|
|
88
|
+
else:
|
|
89
|
+
return data
|
|
90
|
+
else:
|
|
91
|
+
# Load first image to get shape and dtype
|
|
92
|
+
frame_iter = container.decode(stream)
|
|
93
|
+
first_frame = next(frame_iter)
|
|
94
|
+
first_img = first_frame.to_ndarray(format='rgb24')
|
|
95
|
+
# Preallocate memory for the entire array
|
|
96
|
+
data = np.empty((num_frames, *first_img.shape), dtype=first_img.dtype)
|
|
97
|
+
# Then fill it up frame by frame
|
|
98
|
+
data[0] = first_img
|
|
99
|
+
for i, frame in tqdm(enumerate(frame_iter), total=num_frames,
|
|
100
|
+
desc='Loading video', disable=not progress_bar):
|
|
101
|
+
if i == 0:
|
|
102
|
+
continue
|
|
103
|
+
img = frame.to_ndarray(format='rgb24')
|
|
104
|
+
data[i] = img
|
|
105
|
+
if return_framerate:
|
|
106
|
+
return data, float(stream.average_rate)
|
|
107
|
+
else:
|
|
108
|
+
return data
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def lazy_load_video(filename) -> Iterator[np.ndarray]:
|
|
112
|
+
"""
|
|
113
|
+
Lazily load video frames as numpy arrays using PyAV.
|
|
114
|
+
|
|
115
|
+
This iterator yields images in the order they appear in the video.
|
|
116
|
+
If you want reasonably fast random access to arbitrary frames in
|
|
117
|
+
a video, use the VideoStreamer class instead.
|
|
118
|
+
|
|
119
|
+
Parameters
|
|
120
|
+
----------
|
|
121
|
+
filename : str
|
|
122
|
+
Path to the video file.
|
|
123
|
+
|
|
124
|
+
Yields
|
|
125
|
+
------
|
|
126
|
+
frame : np.ndarray
|
|
127
|
+
Video frame as a numpy array, shape (height, width, colors).
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
import av
|
|
131
|
+
except ImportError:
|
|
132
|
+
raise ImportError('Missing optional dependency for video processing,'
|
|
133
|
+
' run `pip install av tqdm`')
|
|
134
|
+
container = av.open(filename)
|
|
135
|
+
stream = container.streams.video[0]
|
|
136
|
+
for frame in container.decode(stream):
|
|
137
|
+
img = frame.to_ndarray(format='rgb24')
|
|
138
|
+
yield img
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class VideoStreamer:
|
|
142
|
+
def __init__(self, filename):
|
|
143
|
+
try:
|
|
144
|
+
import av
|
|
145
|
+
except ImportError:
|
|
146
|
+
raise ImportError('Missing optional dependency for video processing,'
|
|
147
|
+
' run `pip install av tqdm`')
|
|
148
|
+
self.filename = Path(filename)
|
|
149
|
+
if not self.filename.exists():
|
|
150
|
+
raise FileNotFoundError(f"File {filename} not found")
|
|
151
|
+
self.index_filename = self.filename.parent / (self.filename.stem + '_index.json')
|
|
152
|
+
self._load_index()
|
|
153
|
+
|
|
154
|
+
self.container = av.open(str(self.filename))
|
|
155
|
+
self.stream = self.container.streams.video[0]
|
|
156
|
+
self.time_base = self.stream.time_base
|
|
157
|
+
self._shape = None
|
|
158
|
+
self._first_frame = None
|
|
159
|
+
self._width = None
|
|
160
|
+
self._height = None
|
|
161
|
+
self._ndim = None
|
|
162
|
+
self._dtype = None
|
|
163
|
+
self._current_frame_number = None
|
|
164
|
+
self._lock = threading.Lock()
|
|
165
|
+
|
|
166
|
+
def _build_index(self):
|
|
167
|
+
print("Building index for fast random frame access...")
|
|
168
|
+
cmd = [
|
|
169
|
+
'ffprobe',
|
|
170
|
+
'-select_streams', 'v:0',
|
|
171
|
+
'-show_frames',
|
|
172
|
+
'-show_entries', 'frame=pkt_pos,pkt_pts_time,coded_picture_number',
|
|
173
|
+
'-of', 'json',
|
|
174
|
+
self.filename
|
|
175
|
+
]
|
|
176
|
+
result = subprocess.run(cmd, stdout=subprocess.PIPE,
|
|
177
|
+
stderr=subprocess.PIPE, text=True)
|
|
178
|
+
if result.returncode != 0:
|
|
179
|
+
raise RuntimeError(f"ffprobe failed: {result.stderr}")
|
|
180
|
+
frames = json.loads(result.stdout).get('frames', [])
|
|
181
|
+
time_index = [float(frame['pkt_pts_time']) for frame in frames]
|
|
182
|
+
if len(time_index) == 0:
|
|
183
|
+
raise RuntimeError("No frames found in video")
|
|
184
|
+
|
|
185
|
+
self.n_frames = len(time_index)
|
|
186
|
+
self.t0 = time_index[0]
|
|
187
|
+
self.rotation = _detect_rotation(self.filename)
|
|
188
|
+
index = {
|
|
189
|
+
'n_frames': self.n_frames,
|
|
190
|
+
't0': self.t0,
|
|
191
|
+
'rotation': self.rotation,
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
# Determine whether the video is constant or variable framerate
|
|
195
|
+
time_deltas = np.diff(time_index) if len(time_index) > 1 else None
|
|
196
|
+
if time_deltas is not None and np.allclose(time_deltas, time_deltas[0], atol=1e-6):
|
|
197
|
+
self.timestep = float(time_deltas[0]) # The video is constant framerate
|
|
198
|
+
index['timestep'] = self.timestep
|
|
199
|
+
else:
|
|
200
|
+
index.update({
|
|
201
|
+
'timestep': 'variable', # The video is variable framerate
|
|
202
|
+
'time_index': time_index,
|
|
203
|
+
})
|
|
204
|
+
with open(self.index_filename, 'w') as f:
|
|
205
|
+
json.dump(index, f)
|
|
206
|
+
print(f"Saved index to {self.index_filename} so this slow step is not needed again.")
|
|
207
|
+
|
|
208
|
+
def _load_index(self):
|
|
209
|
+
if not self.index_filename.exists():
|
|
210
|
+
self._build_index()
|
|
211
|
+
|
|
212
|
+
with open(self.index_filename, 'r') as f:
|
|
213
|
+
index = json.load(f)
|
|
214
|
+
self.n_frames = index['n_frames']
|
|
215
|
+
self.t0 = index['t0']
|
|
216
|
+
self.rotation = index.get('rotation', None)
|
|
217
|
+
|
|
218
|
+
if not isinstance(index['timestep'], (str, float, int)):
|
|
219
|
+
raise ValueError('Malformed index: timestep is not a string or number')
|
|
220
|
+
|
|
221
|
+
if index['timestep'] == 'variable':
|
|
222
|
+
self.timestep = 'variable'
|
|
223
|
+
self.time_index = index['time_index']
|
|
224
|
+
else:
|
|
225
|
+
self.timestep = float(index['timestep'])
|
|
226
|
+
if self.timestep <= 0:
|
|
227
|
+
raise ValueError('Malformed index: timestep is not positive')
|
|
228
|
+
|
|
229
|
+
def frame_to_time(self, frame_number):
|
|
230
|
+
if self.timestep == 'variable':
|
|
231
|
+
return self.time_index[frame_number]
|
|
232
|
+
else:
|
|
233
|
+
return self.timestep * frame_number + self.t0
|
|
234
|
+
|
|
235
|
+
def __getitem__(self, key):
|
|
236
|
+
if isinstance(key, tuple):
|
|
237
|
+
frame_idx = key[0]
|
|
238
|
+
frame = self._get_frame(frame_idx)
|
|
239
|
+
if len(key) == 1:
|
|
240
|
+
return frame
|
|
241
|
+
else:
|
|
242
|
+
return frame[key[1:]]
|
|
243
|
+
else:
|
|
244
|
+
return self._get_frame(key)
|
|
245
|
+
|
|
246
|
+
def _get_frame(self, frame_number):
|
|
247
|
+
"""
|
|
248
|
+
Provides access to random frames as fast as is reasonable when getting
|
|
249
|
+
frames from compressed video in python.
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
def decode_until(frame_number) -> np.ndarray:
|
|
253
|
+
"""
|
|
254
|
+
Decode forward from the current frame in the stream until we get
|
|
255
|
+
to the requested frame number.
|
|
256
|
+
"""
|
|
257
|
+
target_time = self.frame_to_time(frame_number)
|
|
258
|
+
for frame in self.container.decode(self.stream):
|
|
259
|
+
frame_time = float(frame.pts * self.time_base)
|
|
260
|
+
if abs(frame_time - target_time) < 1e-6:
|
|
261
|
+
frame = frame.to_ndarray(format='rgb24')
|
|
262
|
+
self._current_frame_number = frame_number
|
|
263
|
+
return frame
|
|
264
|
+
if frame_time > target_time:
|
|
265
|
+
raise RuntimeError(f"Frame with time {target_time} not found after"
|
|
266
|
+
f" seeking – current frame time: {frame_time}")
|
|
267
|
+
raise RuntimeError(f"Frame with time {target_time} not found after"
|
|
268
|
+
f" seeking – current frame time: {frame_time}")
|
|
269
|
+
|
|
270
|
+
if isinstance(frame_number, slice): # Support slicing
|
|
271
|
+
start, stop, step = frame_number.indices(self.n_frames)
|
|
272
|
+
return np.array([self._get_frame(i) for i in range(start, stop, step)])
|
|
273
|
+
with self._lock:
|
|
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]")
|
|
283
|
+
|
|
284
|
+
if (self._current_frame_number is None
|
|
285
|
+
or frame_number <= self._current_frame_number
|
|
286
|
+
or frame_number > self._current_frame_number + 100):
|
|
287
|
+
target_time = self.frame_to_time(frame_number)
|
|
288
|
+
seek_time = int(target_time / float(self.time_base))
|
|
289
|
+
# The following actually seeks to the closest keyframe before
|
|
290
|
+
# seek_time, because it's not possible to seek directly to
|
|
291
|
+
# non-keyframes due to video files being compressed.
|
|
292
|
+
self.container.seek(seek_time, any_frame=False,
|
|
293
|
+
backward=True, stream=self.stream)
|
|
294
|
+
# Now we decode frames forward until we get to the requested frame
|
|
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
|
|
306
|
+
|
|
307
|
+
@property
|
|
308
|
+
def first_frame(self):
|
|
309
|
+
if self._first_frame is None:
|
|
310
|
+
self._first_frame = self[0]
|
|
311
|
+
return self._first_frame
|
|
312
|
+
|
|
313
|
+
@property
|
|
314
|
+
def shape(self):
|
|
315
|
+
# (num_frames, height, width, channels)
|
|
316
|
+
if self._shape is None:
|
|
317
|
+
self._shape = (self.n_frames,) + self.first_frame.shape
|
|
318
|
+
return self._shape
|
|
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
|
+
|
|
332
|
+
@property
|
|
333
|
+
def ndim(self):
|
|
334
|
+
if self._ndim is None:
|
|
335
|
+
self._ndim = len(self.shape)
|
|
336
|
+
return self._ndim
|
|
337
|
+
|
|
338
|
+
@property
|
|
339
|
+
def dtype(self):
|
|
340
|
+
if self._dtype is None:
|
|
341
|
+
self._dtype = self.first_frame.dtype
|
|
342
|
+
return self._dtype
|
|
343
|
+
|
|
344
|
+
def __len__(self):
|
|
345
|
+
return self.n_frames
|
|
346
|
+
|
|
347
|
+
def close(self):
|
|
348
|
+
self.container.close()
|
|
349
|
+
|
|
350
|
+
def __del__(self):
|
|
351
|
+
try:
|
|
352
|
+
self.close()
|
|
353
|
+
except Exception:
|
|
354
|
+
pass
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
class VideoWriter:
|
|
358
|
+
"""
|
|
359
|
+
Create a video writer object for saving frames to a video file.
|
|
360
|
+
|
|
361
|
+
Example usage:
|
|
362
|
+
>>> with VideoWriter('output.mp4', framerate=30) as writer:
|
|
363
|
+
>>> for i in range(n_frames):
|
|
364
|
+
>>> frame = do_something_to_build_an_image(i)
|
|
365
|
+
>>> writer.write(frame)
|
|
366
|
+
|
|
367
|
+
This allows you to write a bunch of frames to a video file without
|
|
368
|
+
ever needing to store all the frames in memory at once. If you have all
|
|
369
|
+
your frames in memory already, you could use save_video(data, filename)
|
|
370
|
+
|
|
371
|
+
Parameters
|
|
372
|
+
----------
|
|
373
|
+
filename : str
|
|
374
|
+
The filename to save the video to.
|
|
375
|
+
framerate : int or float, default 30
|
|
376
|
+
The frame rate of the video.
|
|
377
|
+
crf : int, default 23
|
|
378
|
+
Constant Rate Factor for encoding quality (lower is better quality).
|
|
379
|
+
compression_speed : str, default 'medium'
|
|
380
|
+
Compression speed preset: 'ultrafast', 'superfast', 'veryfast',
|
|
381
|
+
'faster', 'fast', 'medium', 'slow', 'slower', 'veryslow'.
|
|
382
|
+
codec : Literal['libx264', 'libx265'], default 'libx264'
|
|
383
|
+
The video codec to use for encoding. Can be any of a number of aliases for
|
|
384
|
+
these two codecs, including avc1/h264 vs hevc/hvc1/hev1/h265.
|
|
385
|
+
"""
|
|
386
|
+
def __init__(self, filename, framerate=30, crf=23, compression_speed='medium',
|
|
387
|
+
codec: Literal['libx264', 'libx265'] = 'libx264',
|
|
388
|
+
overwrite=False):
|
|
389
|
+
try:
|
|
390
|
+
import av
|
|
391
|
+
except ImportError:
|
|
392
|
+
raise ImportError('Missing optional dependency for video processing,'
|
|
393
|
+
' run `pip install av tqdm`')
|
|
394
|
+
self.av = av
|
|
395
|
+
from fractions import Fraction
|
|
396
|
+
filename = os.path.expanduser(str(filename))
|
|
397
|
+
if os.path.exists(filename) and not overwrite:
|
|
398
|
+
raise FileExistsError(f'File {filename} already exists. '
|
|
399
|
+
'Set overwrite=True to overwrite.')
|
|
400
|
+
self.filename = filename
|
|
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')
|
|
411
|
+
self.crf = crf
|
|
412
|
+
self.compression_speed = compression_speed
|
|
413
|
+
self.codec = codec_aliases[codec.lower()]
|
|
414
|
+
self.container = av.open(filename, mode='w')
|
|
415
|
+
self.stream = self.container.add_stream(self.codec, rate=Fraction(self.framerate))
|
|
416
|
+
self.stream.pix_fmt = 'yuv420p'
|
|
417
|
+
self.stream.options = {'crf': str(crf), 'preset': compression_speed}
|
|
418
|
+
self._closed = False
|
|
419
|
+
self.stream.width = 0
|
|
420
|
+
self.stream.height = 0
|
|
421
|
+
|
|
422
|
+
def write(self, frame):
|
|
423
|
+
if not isinstance(frame, self.av.VideoFrame):
|
|
424
|
+
if frame.ndim == 3 and frame.shape[-1] == 3:
|
|
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')
|
|
430
|
+
elif frame.ndim == 2:
|
|
431
|
+
frame = self.av.VideoFrame.from_ndarray(frame, format='gray')
|
|
432
|
+
else:
|
|
433
|
+
raise ValueError(f'Frame must be (H, W) (H, W, 3) or (H, W, 4) but was {frame.shape}')
|
|
434
|
+
if self.stream.width == 0:
|
|
435
|
+
self.stream.width = frame.width
|
|
436
|
+
if self.stream.height == 0:
|
|
437
|
+
self.stream.height = frame.height
|
|
438
|
+
for packet in self.stream.encode(frame):
|
|
439
|
+
self.container.mux(packet)
|
|
440
|
+
|
|
441
|
+
def __enter__(self):
|
|
442
|
+
return self
|
|
443
|
+
|
|
444
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
445
|
+
# Flush stream
|
|
446
|
+
for packet in self.stream.encode():
|
|
447
|
+
self.container.mux(packet)
|
|
448
|
+
self.container.close()
|
|
449
|
+
self._closed = True
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def save_video(data, filename, time_axis=0, color_axis=None, overwrite=False,
|
|
453
|
+
dim_order='yx', framerate=30, crf=23, compression_speed='medium',
|
|
454
|
+
progress_bar=True, codec: Literal['libx264', 'libx265'] = 'libx264') -> None:
|
|
455
|
+
"""
|
|
456
|
+
Save a 3D numpy array of greyscale values OR a 4D numpy array of RGB values as a video
|
|
457
|
+
|
|
458
|
+
Follows the PyAV cookbook section on generating video from numpy arrays:
|
|
459
|
+
https://pyav.basswood-io.com/docs/develop/cookbook/numpy.html#generating-video
|
|
460
|
+
|
|
461
|
+
Parameters
|
|
462
|
+
----------
|
|
463
|
+
data : numpy.ndarray or list of filenames
|
|
464
|
+
A 3D (grayscale) or 4D (RGB) numpy array of pixel values.
|
|
465
|
+
|
|
466
|
+
filename : str
|
|
467
|
+
The filename to save the video to.
|
|
468
|
+
|
|
469
|
+
time_axis : int, default 0
|
|
470
|
+
The axis of the data array that will be played as time in the video.
|
|
471
|
+
|
|
472
|
+
color_axis : int or None, default None
|
|
473
|
+
If not None, specifies the axis of the color channels (e.g., -1 for last axis,
|
|
474
|
+
1 for second axis).
|
|
475
|
+
If None, data must be 3D (greyscale) or 4D with one length 3 axis (RGB).
|
|
476
|
+
|
|
477
|
+
overwrite : bool, default False
|
|
478
|
+
Whether to overwrite the file if it already exists.
|
|
479
|
+
|
|
480
|
+
dim_order : 'yx' (default) or 'xy'
|
|
481
|
+
The order of the spatial dimensions in the input data.
|
|
482
|
+
|
|
483
|
+
framerate : int, default 30
|
|
484
|
+
The frame rate of the video.
|
|
485
|
+
|
|
486
|
+
crf : int, default 23
|
|
487
|
+
Constant Rate Factor that specifies amount of lossiness allowed in compression.
|
|
488
|
+
Lower values produce better quality, larger videos. crf=17 has no human-visible
|
|
489
|
+
compression artifacts. File size approximately doubles/halves each time you
|
|
490
|
+
add/subtract 6 from crf, so crf=17 produces files about twice as large as
|
|
491
|
+
the default crf=23.
|
|
492
|
+
|
|
493
|
+
compression_speed : str, default 'medium'
|
|
494
|
+
Compression speed preset: 'ultrafast', 'superfast', 'veryfast',
|
|
495
|
+
'faster', 'fast', 'medium', 'slow', 'slower', 'veryslow'.
|
|
496
|
+
|
|
497
|
+
progress_bar : bool, default True
|
|
498
|
+
If True, display a progress bar.
|
|
499
|
+
|
|
500
|
+
codec : Literal['libx264', 'libx265'], default 'libx264'
|
|
501
|
+
The video codec to use for encoding. Can be any of a number of aliases for
|
|
502
|
+
these two codecs, including avc1/h264 vs hevc/hvc1/hev1/h265.
|
|
503
|
+
"""
|
|
504
|
+
try:
|
|
505
|
+
from tqdm import tqdm
|
|
506
|
+
except ImportError:
|
|
507
|
+
raise ImportError('Missing optional dependency for video processing,'
|
|
508
|
+
' run `pip install av tqdm`')
|
|
509
|
+
|
|
510
|
+
filename = os.path.expanduser(str(filename))
|
|
511
|
+
if filename.split('.')[-1].lower() not in supported_extensions:
|
|
512
|
+
filename += '.mp4'
|
|
513
|
+
if os.path.exists(filename) and not overwrite:
|
|
514
|
+
raise FileExistsError(f'File {filename} already exists. '
|
|
515
|
+
'Set overwrite=True to overwrite.')
|
|
516
|
+
|
|
517
|
+
if color_axis is None and data.ndim == 4:
|
|
518
|
+
color_axis = utils.find_channel_axis(data, possible_channel_lengths=3)
|
|
519
|
+
if color_axis is None:
|
|
520
|
+
raise ValueError('4D input data must have an RGB (length 3) axis.')
|
|
521
|
+
if color_axis is not None:
|
|
522
|
+
if data.ndim != 4:
|
|
523
|
+
raise ValueError('Input data must be 4D when color_axis is specified.')
|
|
524
|
+
# Move time axis to 0, color axis to -1
|
|
525
|
+
data = np.moveaxis(data, time_axis, 0)
|
|
526
|
+
if color_axis != -1:
|
|
527
|
+
data = np.moveaxis(data, color_axis, -1)
|
|
528
|
+
if 'xy' in dim_order:
|
|
529
|
+
data = data.swapaxes(1, 2)
|
|
530
|
+
n_frames = data.shape[0]
|
|
531
|
+
height, width, channels = data.shape[1:]
|
|
532
|
+
if channels != 3:
|
|
533
|
+
raise ValueError(f'Color video must have 3 channels (RGB) but had {channels}.')
|
|
534
|
+
else:
|
|
535
|
+
if data.ndim != 3:
|
|
536
|
+
raise ValueError('Input data must be 3D when color_axis is not specified.')
|
|
537
|
+
data = np.moveaxis(data, time_axis, 0)
|
|
538
|
+
if 'xy' in dim_order:
|
|
539
|
+
data = data.swapaxes(1, 2)
|
|
540
|
+
n_frames = data.shape[0]
|
|
541
|
+
height, width = data.shape[1:]
|
|
542
|
+
|
|
543
|
+
extension = filename.split('.')[-1].lower()
|
|
544
|
+
if extension == 'mp4':
|
|
545
|
+
pad = [[0, 0], [0, 0], [0, 0]]
|
|
546
|
+
if height % 2 != 0:
|
|
547
|
+
pad[1][1] = 1
|
|
548
|
+
if width % 2 != 0:
|
|
549
|
+
pad[2][1] = 1
|
|
550
|
+
if pad != [[0, 0], [0, 0], [0, 0]]:
|
|
551
|
+
data = np.pad(data, pad, mode='edge')
|
|
552
|
+
|
|
553
|
+
with VideoWriter(filename, framerate=framerate, crf=crf,
|
|
554
|
+
compression_speed=compression_speed, codec=codec,
|
|
555
|
+
overwrite=overwrite) as writer:
|
|
556
|
+
for frame_i in tqdm(range(n_frames), total=n_frames,
|
|
557
|
+
desc='Saving video', disable=not progress_bar):
|
|
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}")
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: numpyimage
|
|
3
|
-
Version: 2.6.
|
|
3
|
+
Version: 2.6.2
|
|
4
4
|
Summary: Load, save, & manipulate image files as numpy arrays
|
|
5
5
|
Author-email: Jasper Phelps <jasper.s.phelps@gmail.com>
|
|
6
6
|
License: GNU GENERAL PUBLIC LICENSE
|
|
@@ -701,18 +701,19 @@ Requires-Dist: av; extra == "all"
|
|
|
701
701
|
Dynamic: license-file
|
|
702
702
|
|
|
703
703
|
# npimage
|
|
704
|
-
Need to load pixel values from image files as numpy arrays, and hate having to remember whether you should use PIL, tifffile, matplotlib, or something else? Hate having to deal with the fact that those libraries all use different function names and syntaxes? Wish you could just provide a filename and get back a numpy array? This library's `
|
|
704
|
+
Need to load pixel values from image files as numpy arrays, and hate having to remember whether you should use PIL, tifffile, matplotlib, or something else? Hate having to deal with the fact that those libraries all use different function names and syntaxes? Wish you could just provide a filename and get back a numpy array? This library's `imageio.py` does that, with `array = npimage.load(filename)`, `npimage.save(array, filename)`, and `npimage.show(array)` functions that let you easily handle a number of common image file formats without having to remember library-specific syntax. Additionally, `vidio.py` provides `array = npimage.load_video(filename)` and `npimage.save_video(array, filename)` for videos as well. (Another similar library to consider using is [imageio](https://pypi.org/project/imageio/).)
|
|
705
705
|
|
|
706
706
|
Want to draw simple shapes like lines, triangles, and circles into 3D numpy arrays? Frustrated that the python libraries you can find online like `opencv` and `skimage.draw` work on 2D arrays but not 3D? I wrote some functions in `graphics.py` that do the trick in 3D. (If you know of another library that can do this, please let me know!)
|
|
707
707
|
|
|
708
708
|
|
|
709
709
|
### Documentation
|
|
710
|
-
- `
|
|
710
|
+
- `imageio.py`: load, save, or show images.
|
|
711
|
+
- `vidio.py`: load or save videos.
|
|
711
712
|
- `graphics.py`: draw points, lines, triangles, circles, or spheres into 2D or 3D numpy arrays representing image volumes.
|
|
712
713
|
- `nrrd_utils.py`: compress or read metadata from `.nrrd` files.
|
|
713
714
|
- `operations.py`: perform operations on images.
|
|
714
715
|
|
|
715
|
-
|
|
716
|
+
Check each function's docstring for more details.
|
|
716
717
|
|
|
717
718
|
|
|
718
719
|
### Installation
|
|
@@ -3,11 +3,12 @@ README.md
|
|
|
3
3
|
pyproject.toml
|
|
4
4
|
npimage/__init__.py
|
|
5
5
|
npimage/align.py
|
|
6
|
-
npimage/core.py
|
|
7
6
|
npimage/graphics.py
|
|
7
|
+
npimage/imageio.py
|
|
8
8
|
npimage/nrrd_utils.py
|
|
9
9
|
npimage/operations.py
|
|
10
10
|
npimage/utils.py
|
|
11
|
+
npimage/vidio.py
|
|
11
12
|
numpyimage.egg-info/PKG-INFO
|
|
12
13
|
numpyimage.egg-info/SOURCES.txt
|
|
13
14
|
numpyimage.egg-info/dependency_links.txt
|
|
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
|