numpyimage 3.7.1__tar.gz → 3.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {numpyimage-3.7.1 → numpyimage-3.8.0}/PKG-INFO +9 -5
- {numpyimage-3.7.1 → numpyimage-3.8.0}/README.md +8 -4
- {numpyimage-3.7.1 → numpyimage-3.8.0}/npimage/graphics.py +22 -12
- {numpyimage-3.7.1 → numpyimage-3.8.0}/npimage/vidio.py +101 -5
- {numpyimage-3.7.1 → numpyimage-3.8.0}/numpyimage.egg-info/PKG-INFO +9 -5
- {numpyimage-3.7.1 → numpyimage-3.8.0}/pyproject.toml +1 -1
- numpyimage-3.8.0/tests/test_video_streamer.py +322 -0
- numpyimage-3.7.1/tests/test_video_streamer.py +0 -71
- {numpyimage-3.7.1 → numpyimage-3.8.0}/LICENSE +0 -0
- {numpyimage-3.7.1 → numpyimage-3.8.0}/npimage/__init__.py +0 -0
- {numpyimage-3.7.1 → numpyimage-3.8.0}/npimage/align.py +0 -0
- {numpyimage-3.7.1 → numpyimage-3.8.0}/npimage/imageio.py +0 -0
- {numpyimage-3.7.1 → numpyimage-3.8.0}/npimage/nrrd_utils.py +0 -0
- {numpyimage-3.7.1 → numpyimage-3.8.0}/npimage/operations.py +0 -0
- {numpyimage-3.7.1 → numpyimage-3.8.0}/npimage/utils.py +0 -0
- {numpyimage-3.7.1 → numpyimage-3.8.0}/numpyimage.egg-info/SOURCES.txt +0 -0
- {numpyimage-3.7.1 → numpyimage-3.8.0}/numpyimage.egg-info/dependency_links.txt +0 -0
- {numpyimage-3.7.1 → numpyimage-3.8.0}/numpyimage.egg-info/requires.txt +0 -0
- {numpyimage-3.7.1 → numpyimage-3.8.0}/numpyimage.egg-info/top_level.txt +0 -0
- {numpyimage-3.7.1 → numpyimage-3.8.0}/setup.cfg +0 -0
- {numpyimage-3.7.1 → numpyimage-3.8.0}/tests/test_heic.py +0 -0
- {numpyimage-3.7.1 → numpyimage-3.8.0}/tests/test_pbm.py +0 -0
- {numpyimage-3.7.1 → numpyimage-3.8.0}/tests/test_video_writers.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: numpyimage
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.8.0
|
|
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: MIT License
|
|
@@ -57,22 +57,23 @@ Requires-Dist: pytest-cov; extra == "dev"
|
|
|
57
57
|
Requires-Dist: psutil; extra == "dev"
|
|
58
58
|
Dynamic: license-file
|
|
59
59
|
|
|
60
|
-
# npimage
|
|
60
|
+
# npimage: Load, save & manipulate numpy arrays representing images & videos
|
|
61
61
|
|
|
62
62
|
[](https://github.com/jasper-tms/npimage/actions/workflows/tests.yml)
|
|
63
63
|
[](https://codecov.io/gh/jasper-tms/npimage)
|
|
64
64
|
[](https://pypi.org/project/numpyimage/)
|
|
65
65
|
[](https://pypi.org/project/numpyimage/)
|
|
66
66
|
[](https://github.com/jasper-tms/npimage/blob/main/LICENSE)
|
|
67
|
+
|
|
67
68
|
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/).)
|
|
68
69
|
|
|
69
|
-
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
|
|
70
|
+
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/4D/+? I wrote some functions in `graphics.py` that do the trick.
|
|
70
71
|
|
|
71
72
|
|
|
72
73
|
### Documentation
|
|
73
74
|
- `imageio.py`: load, save, or show images.
|
|
74
75
|
- `vidio.py`: load or save videos.
|
|
75
|
-
- `graphics.py`: draw points, lines, triangles, circles, or spheres into 2D
|
|
76
|
+
- `graphics.py`: draw points, lines, triangles, circles, or spheres into 2D/3D/+D numpy arrays representing image data.
|
|
76
77
|
- `nrrd_utils.py`: compress or read metadata from `.nrrd` files.
|
|
77
78
|
- `operations.py`: perform operations on images.
|
|
78
79
|
|
|
@@ -86,6 +87,9 @@ As is always the case in python, consider making a virtual environment (using yo
|
|
|
86
87
|
**Option 1:** `pip install` from PyPI:
|
|
87
88
|
|
|
88
89
|
pip install numpyimage
|
|
90
|
+
pip install 'numpyimage[vid]' # If you also want to install the video loading/saving dependencies
|
|
91
|
+
pip install 'numpyimage[align]' # If you also want to install the image alignment dependencies
|
|
92
|
+
pip install 'numpyimage[all]' # If you want to install both video and alignment dependencies
|
|
89
93
|
|
|
90
94
|
(Unfortunately the name `npimage` was already taken on PyPI, so `pip install npimage` will get you a different package.)
|
|
91
95
|
|
|
@@ -98,6 +102,6 @@ As is always the case in python, consider making a virtual environment (using yo
|
|
|
98
102
|
cd ~/repos # Or wherever on your computer you want to download this code to
|
|
99
103
|
git clone https://github.com/jasper-tms/npimage.git
|
|
100
104
|
cd npimage
|
|
101
|
-
pip install .
|
|
105
|
+
pip install '.[all]'
|
|
102
106
|
|
|
103
107
|
**After installing,** you can import this package in python using `import npimage` (not `import numpyimage`!)
|
|
@@ -1,19 +1,20 @@
|
|
|
1
|
-
# npimage
|
|
1
|
+
# npimage: Load, save & manipulate numpy arrays representing images & videos
|
|
2
2
|
|
|
3
3
|
[](https://github.com/jasper-tms/npimage/actions/workflows/tests.yml)
|
|
4
4
|
[](https://codecov.io/gh/jasper-tms/npimage)
|
|
5
5
|
[](https://pypi.org/project/numpyimage/)
|
|
6
6
|
[](https://pypi.org/project/numpyimage/)
|
|
7
7
|
[](https://github.com/jasper-tms/npimage/blob/main/LICENSE)
|
|
8
|
+
|
|
8
9
|
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/).)
|
|
9
10
|
|
|
10
|
-
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
|
|
11
|
+
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/4D/+? I wrote some functions in `graphics.py` that do the trick.
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
### Documentation
|
|
14
15
|
- `imageio.py`: load, save, or show images.
|
|
15
16
|
- `vidio.py`: load or save videos.
|
|
16
|
-
- `graphics.py`: draw points, lines, triangles, circles, or spheres into 2D
|
|
17
|
+
- `graphics.py`: draw points, lines, triangles, circles, or spheres into 2D/3D/+D numpy arrays representing image data.
|
|
17
18
|
- `nrrd_utils.py`: compress or read metadata from `.nrrd` files.
|
|
18
19
|
- `operations.py`: perform operations on images.
|
|
19
20
|
|
|
@@ -27,6 +28,9 @@ As is always the case in python, consider making a virtual environment (using yo
|
|
|
27
28
|
**Option 1:** `pip install` from PyPI:
|
|
28
29
|
|
|
29
30
|
pip install numpyimage
|
|
31
|
+
pip install 'numpyimage[vid]' # If you also want to install the video loading/saving dependencies
|
|
32
|
+
pip install 'numpyimage[align]' # If you also want to install the image alignment dependencies
|
|
33
|
+
pip install 'numpyimage[all]' # If you want to install both video and alignment dependencies
|
|
30
34
|
|
|
31
35
|
(Unfortunately the name `npimage` was already taken on PyPI, so `pip install npimage` will get you a different package.)
|
|
32
36
|
|
|
@@ -39,6 +43,6 @@ As is always the case in python, consider making a virtual environment (using yo
|
|
|
39
43
|
cd ~/repos # Or wherever on your computer you want to download this code to
|
|
40
44
|
git clone https://github.com/jasper-tms/npimage.git
|
|
41
45
|
cd npimage
|
|
42
|
-
pip install .
|
|
46
|
+
pip install '.[all]'
|
|
43
47
|
|
|
44
48
|
**After installing,** you can import this package in python using `import npimage` (not `import numpyimage`!)
|
|
@@ -399,10 +399,14 @@ def imset(image, coords, value,
|
|
|
399
399
|
Parameters
|
|
400
400
|
----------
|
|
401
401
|
image : numpy.ndarray
|
|
402
|
-
The image to set pixel value(s) in.
|
|
402
|
+
The N-dimensional image to set pixel value(s) in.
|
|
403
403
|
|
|
404
404
|
coords : numpy.ndarray or array-like
|
|
405
|
-
The coordinate locations to set values of.
|
|
405
|
+
The coordinate locations to set values of. Must be a 2D array
|
|
406
|
+
of shape (C, M) where C is however many coordinates you want to
|
|
407
|
+
set, and M must equal N (number of dimensions in `image`) if
|
|
408
|
+
`add_missing_dims` is None, or M can be less than N if
|
|
409
|
+
`add_missing_dims` is 'start' or 'end'.
|
|
406
410
|
|
|
407
411
|
value : int, float, or array-like
|
|
408
412
|
The value to set the pixel(s) at the given coordinates to.
|
|
@@ -443,20 +447,26 @@ def imset(image, coords, value,
|
|
|
443
447
|
Refusing to wrap makes more sense in most graphics applications.
|
|
444
448
|
|
|
445
449
|
add_missing_dims : {None, 'start', 'end'}, default None
|
|
446
|
-
If
|
|
447
|
-
|
|
448
|
-
|
|
450
|
+
If `coords` has fewer columns (M) than `image` has dimensions (N),
|
|
451
|
+
this parameter determines whether to add columns of slice(None) to
|
|
452
|
+
the 'start' or 'end' of the coordinate list (or to raise an error
|
|
453
|
+
when set to None).
|
|
449
454
|
This is useful when you want to set all the rows, columns, or
|
|
450
|
-
channels of an image to a particular value.
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
455
|
+
channels of an image to a particular value. See `Examples` below
|
|
456
|
+
|
|
457
|
+
Examples
|
|
458
|
+
--------
|
|
459
|
+
Using `add_missing_dims` to set RGB pixels in a color image. Let's say:
|
|
460
|
+
- You have a (360, 640, 3) RGB pixel array `image`
|
|
461
|
+
- You have 100 pixels specified in a (100, 2) array `coords`
|
|
462
|
+
- You want to make those pixels cyan, i.e. set the values along
|
|
463
|
+
the last axis to (0, 255, 255)
|
|
464
|
+
then since the color axis is the last axis, you can call:
|
|
465
|
+
>>> imset(image, coords, (0, 255, 255), add_missing_dims='end')
|
|
457
466
|
"""
|
|
458
467
|
if not isinstance(coords, np.ndarray):
|
|
459
468
|
coords = np.array(coords)
|
|
469
|
+
coords = coords[~np.isnan(coords).any(axis=1)]
|
|
460
470
|
|
|
461
471
|
if out_of_bounds == 'wrap':
|
|
462
472
|
allow_negative_wrapping = True
|
|
@@ -11,7 +11,8 @@ Function list:
|
|
|
11
11
|
|
|
12
12
|
Class list:
|
|
13
13
|
- VideoStreamer: Provides fast random access to frames in a video file
|
|
14
|
-
via VideoStreamer[frame_number]
|
|
14
|
+
via VideoStreamer[frame_number], or by time in seconds via
|
|
15
|
+
VideoStreamer.t[time_in_seconds].
|
|
15
16
|
- VideoWriter: Allows writing frames one-by-one to a video file via
|
|
16
17
|
VideoWriter.write(image). This can be advantageous compared to save_video
|
|
17
18
|
because you don't ever have to have all the frames in memory at once.
|
|
@@ -22,6 +23,8 @@ from pathlib import Path
|
|
|
22
23
|
import subprocess
|
|
23
24
|
import threading
|
|
24
25
|
import json
|
|
26
|
+
import math
|
|
27
|
+
import bisect
|
|
25
28
|
from fractions import Fraction
|
|
26
29
|
|
|
27
30
|
import numpy as np
|
|
@@ -247,6 +250,7 @@ class VideoStreamer:
|
|
|
247
250
|
|
|
248
251
|
self.index_filename = self.filename.with_suffix(self.filename.suffix + '.index')
|
|
249
252
|
self._build_index(cache_index=cache_index)
|
|
253
|
+
self.t = _VideoStreamerTimeIndexer(self)
|
|
250
254
|
|
|
251
255
|
def _build_index(self, cache_index='auto'):
|
|
252
256
|
if cache_index and self.index_filename.exists():
|
|
@@ -384,9 +388,11 @@ class VideoStreamer:
|
|
|
384
388
|
@property
|
|
385
389
|
def fps(self) -> float:
|
|
386
390
|
"""
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
391
|
+
Frames per second, returned as a float even when the framerate is
|
|
392
|
+
'variable'. For variable-framerate videos this is the average rate,
|
|
393
|
+
equal to `n_frames / duration`, equivalently the number of frame
|
|
394
|
+
intervals divided by the time span between the first and last frames'
|
|
395
|
+
timestamps.
|
|
390
396
|
"""
|
|
391
397
|
if self.framerate == 'variable':
|
|
392
398
|
return float((self.n_frames - 1) / self.time_base
|
|
@@ -402,7 +408,21 @@ class VideoStreamer:
|
|
|
402
408
|
|
|
403
409
|
@property
|
|
404
410
|
def duration(self) -> float:
|
|
405
|
-
|
|
411
|
+
"""
|
|
412
|
+
Total playback duration of the video in seconds, equal to
|
|
413
|
+
`n_frames / fps`.
|
|
414
|
+
|
|
415
|
+
Each frame is treated as occupying one inter-frame interval on
|
|
416
|
+
screen, so the duration extends from the first frame's timestamp to
|
|
417
|
+
one mean inter-frame interval past the last frame's timestamp. For
|
|
418
|
+
constant-framerate videos this is exactly `n_frames * timestep`.
|
|
419
|
+
For variable-framerate videos the last frame's display interval is
|
|
420
|
+
assumed to equal the mean inter-frame interval, which may differ
|
|
421
|
+
slightly from ffprobe's reported duration (ffprobe trusts the
|
|
422
|
+
encoder-written last-frame duration metadata, which is encoder-
|
|
423
|
+
dependent).
|
|
424
|
+
"""
|
|
425
|
+
return float(self.n_frames / self.fps)
|
|
406
426
|
|
|
407
427
|
def frame_number_to_pts(self, frame_number: int) -> int:
|
|
408
428
|
if hasattr(frame_number, '__iter__'):
|
|
@@ -618,6 +638,76 @@ class VideoStreamer:
|
|
|
618
638
|
self.close()
|
|
619
639
|
|
|
620
640
|
|
|
641
|
+
class _VideoStreamerTimeIndexer:
|
|
642
|
+
"""
|
|
643
|
+
Helper exposing time-based indexing on a VideoStreamer via
|
|
644
|
+
``streamer.t[time_in_seconds]``.
|
|
645
|
+
|
|
646
|
+
Returns a 3-tuple ``(image, timestamp, frame_index)`` for the frame
|
|
647
|
+
that is "on screen" at the requested time:
|
|
648
|
+
- ``image`` (np.ndarray): the frame's pixel data
|
|
649
|
+
- ``timestamp`` (float): the frame's actual timestamp in seconds
|
|
650
|
+
- ``frame_index`` (int): the frame's integer index in the video
|
|
651
|
+
|
|
652
|
+
Each frame N is treated as occupying the half-open interval
|
|
653
|
+
``[frame_N_timestamp, frame_{N+1}_timestamp)``, and the last frame
|
|
654
|
+
occupies one mean inter-frame interval past its timestamp. The lookup
|
|
655
|
+
is therefore "largest frame timestamp less than or equal to the
|
|
656
|
+
requested time", with a half-tick eps tolerance so that a request for
|
|
657
|
+
the exact timestamp of frame N reliably returns frame N even when
|
|
658
|
+
floating-point arithmetic has nudged the value slightly below.
|
|
659
|
+
|
|
660
|
+
Valid times span ``[first_frame_timestamp, end_of_playback)``, where
|
|
661
|
+
``end_of_playback = first_frame_timestamp + duration``.
|
|
662
|
+
|
|
663
|
+
Negative times wrap around relative to ``end_of_playback``, mirroring
|
|
664
|
+
how negative integer indices wrap on ``VideoStreamer``: a time of
|
|
665
|
+
``-x`` resolves to ``end_of_playback - x``. Times more negative than
|
|
666
|
+
``-duration`` raise ``IndexError``.
|
|
667
|
+
"""
|
|
668
|
+
def __init__(self, streamer: 'VideoStreamer'):
|
|
669
|
+
self._streamer = streamer
|
|
670
|
+
|
|
671
|
+
def __getitem__(self, time) -> Tuple[np.ndarray, float, int]:
|
|
672
|
+
if not np.issubdtype(type(time), np.number):
|
|
673
|
+
raise TypeError(f'Time index must be a number, got {type(time).__name__}')
|
|
674
|
+
s = self._streamer
|
|
675
|
+
time = float(time)
|
|
676
|
+
first_frame_time = s.frame_number_to_time(0)
|
|
677
|
+
end_of_playback = first_frame_time + s.duration
|
|
678
|
+
if time < 0:
|
|
679
|
+
if time < -s.duration:
|
|
680
|
+
raise IndexError(f'Negative time {time:g} s is more than one'
|
|
681
|
+
f' video duration ({s.duration:g} s) before'
|
|
682
|
+
f' the end of the video.')
|
|
683
|
+
time = time + end_of_playback
|
|
684
|
+
if time >= end_of_playback:
|
|
685
|
+
raise IndexError(f'No frame at time {time:g} s (playback'
|
|
686
|
+
f' ends at {end_of_playback:g} s).')
|
|
687
|
+
if time < first_frame_time:
|
|
688
|
+
raise IndexError(f'Time {time:g} s is before the first frame'
|
|
689
|
+
f' (timestamp {first_frame_time:g} s).')
|
|
690
|
+
# Convert the requested time to a fractional PTS, then add half a
|
|
691
|
+
# time_base tick. The eps shift means a request for the exact
|
|
692
|
+
# timestamp of frame N returns frame N (rather than frame N-1) even
|
|
693
|
+
# if float arithmetic has nudged the converted value below the
|
|
694
|
+
# stored integer PTS.
|
|
695
|
+
target_pts = time / float(s.time_base) + 0.5
|
|
696
|
+
if s._framerate == 'variable':
|
|
697
|
+
# Largest index i with s.frames_pts[i] <= target_pts.
|
|
698
|
+
frame_number = bisect.bisect_right(s.frames_pts, target_pts) - 1
|
|
699
|
+
else:
|
|
700
|
+
offset = target_pts - s.pts0
|
|
701
|
+
frame_number = math.floor(offset / s.pts_delta)
|
|
702
|
+
# The bound checks above guarantee frame_number lands in
|
|
703
|
+
# [0, n_frames - 1] under exact arithmetic. Clamp defensively for
|
|
704
|
+
# the float-roundoff edge at exactly time == end_of_playback - eps.
|
|
705
|
+
frame_number = max(0, min(frame_number, s.n_frames - 1))
|
|
706
|
+
image = s._get_frame(frame_number)
|
|
707
|
+
timestamp = s.frame_number_to_time(frame_number)
|
|
708
|
+
return (image, timestamp, frame_number)
|
|
709
|
+
|
|
710
|
+
|
|
621
711
|
class AVVideoWriter:
|
|
622
712
|
"""
|
|
623
713
|
Create a video writer object for saving frames to a video file.
|
|
@@ -661,6 +751,9 @@ class AVVideoWriter:
|
|
|
661
751
|
raise FileExistsError(f'File {filename} already exists. '
|
|
662
752
|
'Set overwrite=True to overwrite.')
|
|
663
753
|
self.filename = filename
|
|
754
|
+
if not np.issubdtype(type(framerate), np.number):
|
|
755
|
+
raise TypeError('framerate must be a number but got'
|
|
756
|
+
f' type {type(framerate)} instead')
|
|
664
757
|
self._framerate = utils.limit_fraction(framerate)
|
|
665
758
|
self.crf = crf
|
|
666
759
|
self.compression_speed = compression_speed
|
|
@@ -776,6 +869,9 @@ class FFmpegVideoWriter:
|
|
|
776
869
|
raise FileExistsError(f'File {filename} already exists. '
|
|
777
870
|
'Set overwrite=True to overwrite.')
|
|
778
871
|
self.filename = filename
|
|
872
|
+
if not np.issubdtype(type(framerate), np.number):
|
|
873
|
+
raise TypeError('framerate must be a number but got'
|
|
874
|
+
f' type {type(framerate)} instead')
|
|
779
875
|
self._framerate = utils.limit_fraction(framerate)
|
|
780
876
|
self.crf = crf
|
|
781
877
|
self.compression_speed = compression_speed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: numpyimage
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.8.0
|
|
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: MIT License
|
|
@@ -57,22 +57,23 @@ Requires-Dist: pytest-cov; extra == "dev"
|
|
|
57
57
|
Requires-Dist: psutil; extra == "dev"
|
|
58
58
|
Dynamic: license-file
|
|
59
59
|
|
|
60
|
-
# npimage
|
|
60
|
+
# npimage: Load, save & manipulate numpy arrays representing images & videos
|
|
61
61
|
|
|
62
62
|
[](https://github.com/jasper-tms/npimage/actions/workflows/tests.yml)
|
|
63
63
|
[](https://codecov.io/gh/jasper-tms/npimage)
|
|
64
64
|
[](https://pypi.org/project/numpyimage/)
|
|
65
65
|
[](https://pypi.org/project/numpyimage/)
|
|
66
66
|
[](https://github.com/jasper-tms/npimage/blob/main/LICENSE)
|
|
67
|
+
|
|
67
68
|
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/).)
|
|
68
69
|
|
|
69
|
-
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
|
|
70
|
+
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/4D/+? I wrote some functions in `graphics.py` that do the trick.
|
|
70
71
|
|
|
71
72
|
|
|
72
73
|
### Documentation
|
|
73
74
|
- `imageio.py`: load, save, or show images.
|
|
74
75
|
- `vidio.py`: load or save videos.
|
|
75
|
-
- `graphics.py`: draw points, lines, triangles, circles, or spheres into 2D
|
|
76
|
+
- `graphics.py`: draw points, lines, triangles, circles, or spheres into 2D/3D/+D numpy arrays representing image data.
|
|
76
77
|
- `nrrd_utils.py`: compress or read metadata from `.nrrd` files.
|
|
77
78
|
- `operations.py`: perform operations on images.
|
|
78
79
|
|
|
@@ -86,6 +87,9 @@ As is always the case in python, consider making a virtual environment (using yo
|
|
|
86
87
|
**Option 1:** `pip install` from PyPI:
|
|
87
88
|
|
|
88
89
|
pip install numpyimage
|
|
90
|
+
pip install 'numpyimage[vid]' # If you also want to install the video loading/saving dependencies
|
|
91
|
+
pip install 'numpyimage[align]' # If you also want to install the image alignment dependencies
|
|
92
|
+
pip install 'numpyimage[all]' # If you want to install both video and alignment dependencies
|
|
89
93
|
|
|
90
94
|
(Unfortunately the name `npimage` was already taken on PyPI, so `pip install npimage` will get you a different package.)
|
|
91
95
|
|
|
@@ -98,6 +102,6 @@ As is always the case in python, consider making a virtual environment (using yo
|
|
|
98
102
|
cd ~/repos # Or wherever on your computer you want to download this code to
|
|
99
103
|
git clone https://github.com/jasper-tms/npimage.git
|
|
100
104
|
cd npimage
|
|
101
|
-
pip install .
|
|
105
|
+
pip install '.[all]'
|
|
102
106
|
|
|
103
107
|
**After installing,** you can import this package in python using `import npimage` (not `import numpyimage`!)
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for VideoStreamer, particularly handling of HEVC videos with
|
|
3
|
+
negative-PTS priming packets (common in iPhone recordings).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import subprocess
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
import npimage
|
|
13
|
+
|
|
14
|
+
TESTS_DIR = Path(__file__).parent
|
|
15
|
+
SPINNING_MP4 = TESTS_DIR / 'table-tennis-emoji-spinning.mp4'
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def spinning_streamer():
|
|
20
|
+
"""A VideoStreamer over the bundled 8-frame, 4 fps test video."""
|
|
21
|
+
vid = npimage.VideoStreamer(str(SPINNING_MP4))
|
|
22
|
+
yield vid
|
|
23
|
+
vid.close()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.mark.slow
|
|
27
|
+
def test_negative_pts_priming_packet(tmp_path):
|
|
28
|
+
"""
|
|
29
|
+
VideoStreamer correctly handles HEVC videos where the first packet has a
|
|
30
|
+
negative PTS (a "priming" packet used for B-frame prediction that doesn't
|
|
31
|
+
produce a decoded frame).
|
|
32
|
+
|
|
33
|
+
This is common in iPhone HEVC recordings. The bug was that the index was
|
|
34
|
+
built from demuxed packets, which included the priming packet's negative
|
|
35
|
+
PTS. When trying to access frame 0, it would seek to that negative PTS,
|
|
36
|
+
which no decoded frame ever has, causing a VideoSeekError.
|
|
37
|
+
"""
|
|
38
|
+
import av
|
|
39
|
+
|
|
40
|
+
video_path = tmp_path / 'test_negative_pts.mp4'
|
|
41
|
+
|
|
42
|
+
# Create a small HEVC video with a negative-PTS priming packet.
|
|
43
|
+
# -output_ts_offset shifts timestamps so the first keyframe packet gets a
|
|
44
|
+
# negative PTS, mimicking iPhone HEVC behavior.
|
|
45
|
+
result = subprocess.run([
|
|
46
|
+
'ffmpeg', '-y',
|
|
47
|
+
'-f', 'lavfi', '-i', 'color=c=red:size=320x240:rate=30:d=1',
|
|
48
|
+
'-c:v', 'libx265', '-preset', 'ultrafast',
|
|
49
|
+
'-bf', '2', '-x265-params', 'bframes=2',
|
|
50
|
+
'-tag:v', 'hvc1',
|
|
51
|
+
'-output_ts_offset', '-0.033',
|
|
52
|
+
str(video_path),
|
|
53
|
+
], capture_output=True, text=True)
|
|
54
|
+
assert result.returncode == 0, f'ffmpeg failed: {result.stderr}'
|
|
55
|
+
|
|
56
|
+
# Verify the test video actually has the problematic structure: more
|
|
57
|
+
# packets than decoded frames due to the priming packet.
|
|
58
|
+
with av.open(str(video_path)) as container:
|
|
59
|
+
stream = container.streams.video[0]
|
|
60
|
+
pkt_pts = sorted(
|
|
61
|
+
p.pts for p in container.demux(stream) if p.pts is not None
|
|
62
|
+
)
|
|
63
|
+
with av.open(str(video_path)) as container:
|
|
64
|
+
stream = container.streams.video[0]
|
|
65
|
+
frame_pts = [f.pts for f in container.decode(stream)]
|
|
66
|
+
|
|
67
|
+
assert len(pkt_pts) > len(frame_pts), (
|
|
68
|
+
f'Test video should have more packets ({len(pkt_pts)}) than decoded '
|
|
69
|
+
f'frames ({len(frame_pts)}). The test setup may need updating if '
|
|
70
|
+
f'ffmpeg behavior has changed.'
|
|
71
|
+
)
|
|
72
|
+
assert pkt_pts[0] < 0, f'First packet PTS should be negative, got {pkt_pts[0]}'
|
|
73
|
+
|
|
74
|
+
vid = npimage.VideoStreamer(str(video_path))
|
|
75
|
+
|
|
76
|
+
assert vid.n_frames == len(frame_pts)
|
|
77
|
+
|
|
78
|
+
frame = vid[0]
|
|
79
|
+
assert isinstance(frame, np.ndarray)
|
|
80
|
+
assert frame.shape == (240, 320, 3)
|
|
81
|
+
|
|
82
|
+
last_frame = vid[vid.n_frames - 1]
|
|
83
|
+
assert isinstance(last_frame, np.ndarray)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_duration_matches_n_frames_over_fps():
|
|
87
|
+
"""
|
|
88
|
+
`duration` follows the View-B convention: each frame occupies one
|
|
89
|
+
inter-frame interval on screen, so total duration is
|
|
90
|
+
``n_frames / fps`` (equivalent to ``n_frames * timestep`` for CFR).
|
|
91
|
+
|
|
92
|
+
The bundled test video has 8 frames at 4 fps. View A would give
|
|
93
|
+
7 * 0.25 = 1.75 s; View B gives 8 * 0.25 = 2.00 s, matching the
|
|
94
|
+
duration ffprobe reports for the same file.
|
|
95
|
+
"""
|
|
96
|
+
vid = npimage.VideoStreamer(str(SPINNING_MP4))
|
|
97
|
+
try:
|
|
98
|
+
assert vid.n_frames == 8
|
|
99
|
+
assert vid.fps == 4.0
|
|
100
|
+
assert vid.duration == pytest.approx(2.0)
|
|
101
|
+
# Self-consistency: duration, fps, and n_frames form a closed triple.
|
|
102
|
+
assert vid.duration == pytest.approx(vid.n_frames / vid.fps)
|
|
103
|
+
# For CFR specifically, duration should also equal n_frames * timestep.
|
|
104
|
+
assert vid.duration == pytest.approx(vid.n_frames * vid.timestep)
|
|
105
|
+
finally:
|
|
106
|
+
vid.close()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_duration_matches_ffprobe_on_cfr():
|
|
110
|
+
"""
|
|
111
|
+
For constant-framerate videos, our `duration` should agree with
|
|
112
|
+
ffprobe to within a small tolerance.
|
|
113
|
+
"""
|
|
114
|
+
result = subprocess.run([
|
|
115
|
+
'ffprobe', '-v', 'error',
|
|
116
|
+
'-show_entries', 'format=duration',
|
|
117
|
+
'-of', 'default=noprint_wrappers=1:nokey=1',
|
|
118
|
+
str(SPINNING_MP4),
|
|
119
|
+
], capture_output=True, text=True)
|
|
120
|
+
assert result.returncode == 0, f'ffprobe failed: {result.stderr}'
|
|
121
|
+
ffprobe_duration = float(result.stdout.strip())
|
|
122
|
+
|
|
123
|
+
vid = npimage.VideoStreamer(str(SPINNING_MP4))
|
|
124
|
+
try:
|
|
125
|
+
assert vid.duration == pytest.approx(ffprobe_duration, abs=1e-6)
|
|
126
|
+
finally:
|
|
127
|
+
vid.close()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def test_t_indexer_exact_timestamp_returns_that_frame(spinning_streamer):
|
|
131
|
+
"""vid.t[exact_frame_time] returns that frame, not the next one."""
|
|
132
|
+
vid = spinning_streamer
|
|
133
|
+
for i in range(vid.n_frames):
|
|
134
|
+
_, _, idx = vid.t[vid.frame_number_to_time(i)]
|
|
135
|
+
assert idx == i
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def test_t_indexer_returns_3tuple(spinning_streamer):
|
|
139
|
+
"""The return value is a plain (image, timestamp, frame_index) tuple."""
|
|
140
|
+
vid = spinning_streamer
|
|
141
|
+
result = vid.t[0.5]
|
|
142
|
+
assert type(result) is tuple
|
|
143
|
+
assert len(result) == 3
|
|
144
|
+
image, timestamp, idx = result
|
|
145
|
+
assert isinstance(image, np.ndarray)
|
|
146
|
+
assert image.shape == vid.shape[1:]
|
|
147
|
+
assert isinstance(timestamp, float)
|
|
148
|
+
assert isinstance(idx, int)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def test_t_indexer_supports_tuple_unpacking(spinning_streamer):
|
|
152
|
+
"""Callers can unpack the result positionally as (image, time, index)."""
|
|
153
|
+
vid = spinning_streamer
|
|
154
|
+
image, timestamp, idx = vid.t[0.75]
|
|
155
|
+
assert isinstance(image, np.ndarray)
|
|
156
|
+
assert timestamp == pytest.approx(0.75)
|
|
157
|
+
assert idx == 3
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def test_t_indexer_eps_below_timestamp_still_returns_that_frame(spinning_streamer):
|
|
161
|
+
"""A request slightly under a frame's timestamp still returns that frame.
|
|
162
|
+
|
|
163
|
+
This is the float-eps-tolerance behavior: small negative drift from the
|
|
164
|
+
exact timestamp must not cause the indexer to fall back to the previous
|
|
165
|
+
frame. (We only test that direction; small drift above the timestamp is
|
|
166
|
+
correctly resolved to the next frame, see the next test.)
|
|
167
|
+
"""
|
|
168
|
+
vid = spinning_streamer
|
|
169
|
+
t5 = vid.frame_number_to_time(5)
|
|
170
|
+
assert vid.t[t5 - 1e-12][2] == 5
|
|
171
|
+
assert vid.t[t5 - 1e-9][2] == 5
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_t_indexer_just_above_timestamp_stays_on_that_frame(spinning_streamer):
|
|
175
|
+
"""A request just inside a frame's display interval returns that frame.
|
|
176
|
+
|
|
177
|
+
Under View B each frame is on screen during ``[pts_N, pts_{N+1})``, so
|
|
178
|
+
a time 1 ms after frame 5's timestamp is still inside frame 5's
|
|
179
|
+
display interval (which spans 250 ms on this 4 fps video).
|
|
180
|
+
"""
|
|
181
|
+
vid = spinning_streamer
|
|
182
|
+
t5 = vid.frame_number_to_time(5)
|
|
183
|
+
assert vid.t[t5 + 1e-3][2] == 5
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def test_t_indexer_between_frames_returns_earlier_frame(spinning_streamer):
|
|
187
|
+
"""A time strictly between two frames returns the earlier (still-displayed) one."""
|
|
188
|
+
vid = spinning_streamer
|
|
189
|
+
midpoint = (vid.frame_number_to_time(2) + vid.frame_number_to_time(3)) / 2
|
|
190
|
+
assert vid.t[midpoint][2] == 2
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_t_indexer_just_before_next_frame_still_returns_current(spinning_streamer):
|
|
194
|
+
"""A time meaningfully shy of frame N+1's timestamp still returns frame N.
|
|
195
|
+
|
|
196
|
+
This is the "scrub to the deep tail of frame N's display interval"
|
|
197
|
+
case — under View B the next frame hasn't appeared yet. The delta
|
|
198
|
+
must be larger than half a time_base tick or the eps tolerance will
|
|
199
|
+
snap the request up to frame N+1.
|
|
200
|
+
"""
|
|
201
|
+
vid = spinning_streamer
|
|
202
|
+
t6 = vid.frame_number_to_time(6)
|
|
203
|
+
# 1 ms before frame 6 starts (well above half-tick eps) -> still frame 5
|
|
204
|
+
assert vid.t[t6 - 1e-3][2] == 5
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def test_t_indexer_zero_returns_first_frame(spinning_streamer):
|
|
208
|
+
"""Time 0.0 resolves to frame 0."""
|
|
209
|
+
assert spinning_streamer.t[0.0][2] == 0
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def test_t_indexer_negative_time_wraps_relative_to_end(spinning_streamer):
|
|
213
|
+
"""Negative times wrap, mirroring the integer indexer's negative semantics.
|
|
214
|
+
|
|
215
|
+
``vid.t[-x]`` resolves to ``end_of_playback - x``. On the 8-frame,
|
|
216
|
+
4 fps test video (frames at 0.00, 0.25, ..., 1.75; duration 2.00 s)
|
|
217
|
+
a request for -0.25 s lands at absolute time 1.75 — frame 7's
|
|
218
|
+
timestamp, the start of its display interval, so frame 7. -1.0 s
|
|
219
|
+
lands at 1.0 = frame 4. -duration lands at 0.0 = frame 0.
|
|
220
|
+
"""
|
|
221
|
+
vid = spinning_streamer
|
|
222
|
+
assert vid.t[-0.25][2] == 7
|
|
223
|
+
assert vid.t[-1.0][2] == 4
|
|
224
|
+
assert vid.t[-vid.duration][2] == 0
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_t_indexer_negative_time_eps_tolerance(spinning_streamer):
|
|
228
|
+
"""The eps shift also applies after negative-time wrap.
|
|
229
|
+
|
|
230
|
+
A wrapped time that equals an exact frame timestamp should resolve to
|
|
231
|
+
that frame, not the previous one, due to float drift. -0.25 s wraps
|
|
232
|
+
to exactly 1.75 s = frame 7's timestamp.
|
|
233
|
+
"""
|
|
234
|
+
vid = spinning_streamer
|
|
235
|
+
assert vid.t[-0.25][2] == 7
|
|
236
|
+
assert vid.t[-0.25 - 1e-12][2] == 7
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def test_t_indexer_too_negative_raises(spinning_streamer):
|
|
240
|
+
"""Negative times more negative than -duration raise IndexError."""
|
|
241
|
+
vid = spinning_streamer
|
|
242
|
+
with pytest.raises(IndexError):
|
|
243
|
+
vid.t[-vid.duration - 1e-3]
|
|
244
|
+
with pytest.raises(IndexError):
|
|
245
|
+
vid.t[-100.0]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def test_t_indexer_past_last_frame_timestamp_still_returns_last_frame(spinning_streamer):
|
|
249
|
+
"""Times within the last frame's display interval still return it.
|
|
250
|
+
|
|
251
|
+
Under View B the last frame is on screen during
|
|
252
|
+
``[last_frame_timestamp, end_of_playback)``, so a request 1 ms past
|
|
253
|
+
the last frame's timestamp (well inside the 250 ms last interval) is
|
|
254
|
+
still the last frame, not an error.
|
|
255
|
+
"""
|
|
256
|
+
vid = spinning_streamer
|
|
257
|
+
last = vid.frame_number_to_time(vid.n_frames - 1)
|
|
258
|
+
assert vid.t[last + 1e-3][2] == vid.n_frames - 1
|
|
259
|
+
# Just shy of end_of_playback should also still be the last frame.
|
|
260
|
+
assert vid.t[vid.duration - 1e-6][2] == vid.n_frames - 1
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def test_t_indexer_past_end_of_playback_raises(spinning_streamer):
|
|
264
|
+
"""Times at or past end_of_playback raise IndexError."""
|
|
265
|
+
vid = spinning_streamer
|
|
266
|
+
with pytest.raises(IndexError):
|
|
267
|
+
vid.t[vid.duration]
|
|
268
|
+
with pytest.raises(IndexError):
|
|
269
|
+
vid.t[vid.duration + 1e-3]
|
|
270
|
+
with pytest.raises(IndexError):
|
|
271
|
+
vid.t[vid.duration + 100.0]
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def test_t_indexer_image_matches_frame_lookup(spinning_streamer):
|
|
275
|
+
"""The image returned by t[time] is identical to streamer[returned_index]."""
|
|
276
|
+
vid = spinning_streamer
|
|
277
|
+
image, _, idx = vid.t[0.6]
|
|
278
|
+
assert np.array_equal(image, vid[idx])
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def test_t_indexer_rejects_non_numeric(spinning_streamer):
|
|
282
|
+
"""Non-numeric keys raise TypeError rather than failing deeper in."""
|
|
283
|
+
vid = spinning_streamer
|
|
284
|
+
with pytest.raises(TypeError):
|
|
285
|
+
vid.t['0.5']
|
|
286
|
+
with pytest.raises(TypeError):
|
|
287
|
+
vid.t[None]
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def test_t_indexer_variable_framerate_uses_bisect(spinning_streamer):
|
|
291
|
+
"""The variable-framerate code path correctly resolves between frames.
|
|
292
|
+
|
|
293
|
+
The bundled test video is constant-framerate, so we coerce the streamer
|
|
294
|
+
into the variable-framerate branch by swapping in a list of PTS values
|
|
295
|
+
and flipping `_framerate`. This exercises the ``bisect_right`` path in
|
|
296
|
+
``_VideoStreamerTimeIndexer.__getitem__`` against the same View B
|
|
297
|
+
semantics.
|
|
298
|
+
"""
|
|
299
|
+
vid = spinning_streamer
|
|
300
|
+
original_framerate = vid._framerate
|
|
301
|
+
original_frames_pts = vid.frames_pts
|
|
302
|
+
try:
|
|
303
|
+
vid._framerate = 'variable'
|
|
304
|
+
# Materialize the range as a list so bisect operates on real ints.
|
|
305
|
+
vid.frames_pts = list(original_frames_pts)
|
|
306
|
+
|
|
307
|
+
# Exact timestamp -> that frame.
|
|
308
|
+
t4 = vid.frame_number_to_time(4)
|
|
309
|
+
assert vid.t[t4][2] == 4
|
|
310
|
+
# Just below -> still that frame (eps tolerance).
|
|
311
|
+
assert vid.t[t4 - 1e-9][2] == 4
|
|
312
|
+
# Between two frames -> the earlier one (still on screen).
|
|
313
|
+
midpoint = (vid.frame_number_to_time(1) + vid.frame_number_to_time(2)) / 2
|
|
314
|
+
assert vid.t[midpoint][2] == 1
|
|
315
|
+
# Just inside the last frame's display interval -> last frame.
|
|
316
|
+
assert vid.t[vid.duration - 1e-6][2] == vid.n_frames - 1
|
|
317
|
+
# At or past end_of_playback -> IndexError.
|
|
318
|
+
with pytest.raises(IndexError):
|
|
319
|
+
vid.t[vid.duration]
|
|
320
|
+
finally:
|
|
321
|
+
vid._framerate = original_framerate
|
|
322
|
+
vid.frames_pts = original_frames_pts
|
|
@@ -1,71 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Tests for VideoStreamer, particularly handling of HEVC videos with
|
|
3
|
-
negative-PTS priming packets (common in iPhone recordings).
|
|
4
|
-
"""
|
|
5
|
-
|
|
6
|
-
import subprocess
|
|
7
|
-
|
|
8
|
-
import numpy as np
|
|
9
|
-
import pytest
|
|
10
|
-
|
|
11
|
-
import npimage
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
@pytest.mark.slow
|
|
15
|
-
def test_negative_pts_priming_packet(tmp_path):
|
|
16
|
-
"""
|
|
17
|
-
VideoStreamer correctly handles HEVC videos where the first packet has a
|
|
18
|
-
negative PTS (a "priming" packet used for B-frame prediction that doesn't
|
|
19
|
-
produce a decoded frame).
|
|
20
|
-
|
|
21
|
-
This is common in iPhone HEVC recordings. The bug was that the index was
|
|
22
|
-
built from demuxed packets, which included the priming packet's negative
|
|
23
|
-
PTS. When trying to access frame 0, it would seek to that negative PTS,
|
|
24
|
-
which no decoded frame ever has, causing a VideoSeekError.
|
|
25
|
-
"""
|
|
26
|
-
import av
|
|
27
|
-
|
|
28
|
-
video_path = tmp_path / 'test_negative_pts.mp4'
|
|
29
|
-
|
|
30
|
-
# Create a small HEVC video with a negative-PTS priming packet.
|
|
31
|
-
# -output_ts_offset shifts timestamps so the first keyframe packet gets a
|
|
32
|
-
# negative PTS, mimicking iPhone HEVC behavior.
|
|
33
|
-
result = subprocess.run([
|
|
34
|
-
'ffmpeg', '-y',
|
|
35
|
-
'-f', 'lavfi', '-i', 'color=c=red:size=320x240:rate=30:d=1',
|
|
36
|
-
'-c:v', 'libx265', '-preset', 'ultrafast',
|
|
37
|
-
'-bf', '2', '-x265-params', 'bframes=2',
|
|
38
|
-
'-tag:v', 'hvc1',
|
|
39
|
-
'-output_ts_offset', '-0.033',
|
|
40
|
-
str(video_path),
|
|
41
|
-
], capture_output=True, text=True)
|
|
42
|
-
assert result.returncode == 0, f'ffmpeg failed: {result.stderr}'
|
|
43
|
-
|
|
44
|
-
# Verify the test video actually has the problematic structure: more
|
|
45
|
-
# packets than decoded frames due to the priming packet.
|
|
46
|
-
with av.open(str(video_path)) as container:
|
|
47
|
-
stream = container.streams.video[0]
|
|
48
|
-
pkt_pts = sorted(
|
|
49
|
-
p.pts for p in container.demux(stream) if p.pts is not None
|
|
50
|
-
)
|
|
51
|
-
with av.open(str(video_path)) as container:
|
|
52
|
-
stream = container.streams.video[0]
|
|
53
|
-
frame_pts = [f.pts for f in container.decode(stream)]
|
|
54
|
-
|
|
55
|
-
assert len(pkt_pts) > len(frame_pts), (
|
|
56
|
-
f'Test video should have more packets ({len(pkt_pts)}) than decoded '
|
|
57
|
-
f'frames ({len(frame_pts)}). The test setup may need updating if '
|
|
58
|
-
f'ffmpeg behavior has changed.'
|
|
59
|
-
)
|
|
60
|
-
assert pkt_pts[0] < 0, f'First packet PTS should be negative, got {pkt_pts[0]}'
|
|
61
|
-
|
|
62
|
-
vid = npimage.VideoStreamer(str(video_path))
|
|
63
|
-
|
|
64
|
-
assert vid.n_frames == len(frame_pts)
|
|
65
|
-
|
|
66
|
-
frame = vid[0]
|
|
67
|
-
assert isinstance(frame, np.ndarray)
|
|
68
|
-
assert frame.shape == (240, 320, 3)
|
|
69
|
-
|
|
70
|
-
last_frame = vid[vid.n_frames - 1]
|
|
71
|
-
assert isinstance(last_frame, np.ndarray)
|
|
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
|