numpyimage 3.5.3__tar.gz → 3.6.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.5.3/numpyimage.egg-info → numpyimage-3.6.0}/PKG-INFO +1 -1
- {numpyimage-3.5.3 → numpyimage-3.6.0}/npimage/vidio.py +82 -4
- {numpyimage-3.5.3 → numpyimage-3.6.0/numpyimage.egg-info}/PKG-INFO +1 -1
- {numpyimage-3.5.3 → numpyimage-3.6.0}/numpyimage.egg-info/SOURCES.txt +1 -0
- {numpyimage-3.5.3 → numpyimage-3.6.0}/pyproject.toml +1 -1
- numpyimage-3.6.0/tests/test_video_streamer.py +92 -0
- {numpyimage-3.5.3 → numpyimage-3.6.0}/LICENSE +0 -0
- {numpyimage-3.5.3 → numpyimage-3.6.0}/README.md +0 -0
- {numpyimage-3.5.3 → numpyimage-3.6.0}/npimage/__init__.py +0 -0
- {numpyimage-3.5.3 → numpyimage-3.6.0}/npimage/align.py +0 -0
- {numpyimage-3.5.3 → numpyimage-3.6.0}/npimage/graphics.py +0 -0
- {numpyimage-3.5.3 → numpyimage-3.6.0}/npimage/imageio.py +0 -0
- {numpyimage-3.5.3 → numpyimage-3.6.0}/npimage/nrrd_utils.py +0 -0
- {numpyimage-3.5.3 → numpyimage-3.6.0}/npimage/operations.py +0 -0
- {numpyimage-3.5.3 → numpyimage-3.6.0}/npimage/utils.py +0 -0
- {numpyimage-3.5.3 → numpyimage-3.6.0}/numpyimage.egg-info/dependency_links.txt +0 -0
- {numpyimage-3.5.3 → numpyimage-3.6.0}/numpyimage.egg-info/requires.txt +0 -0
- {numpyimage-3.5.3 → numpyimage-3.6.0}/numpyimage.egg-info/top_level.txt +0 -0
- {numpyimage-3.5.3 → numpyimage-3.6.0}/setup.cfg +0 -0
- {numpyimage-3.5.3 → numpyimage-3.6.0}/tests/test_heic.py +0 -0
- {numpyimage-3.5.3 → numpyimage-3.6.0}/tests/test_pbm.py +0 -0
- {numpyimage-3.5.3 → numpyimage-3.6.0}/tests/test_video_writers.py +0 -0
|
@@ -55,7 +55,7 @@ extension_default_codecs = {
|
|
|
55
55
|
'webm': 'libvpx-vp9',
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
supported_extensions = ['mp4', 'mkv', 'avi', 'mov', 'webm']
|
|
58
|
+
supported_extensions = ['mp4', 'mkv', 'avi', 'mov', 'webm', 'gif']
|
|
59
59
|
|
|
60
60
|
|
|
61
61
|
def _import_av():
|
|
@@ -92,6 +92,30 @@ def load_video(filename,
|
|
|
92
92
|
framerate : float
|
|
93
93
|
The frame rate of the video in frames per second
|
|
94
94
|
"""
|
|
95
|
+
extension = Path(filename).suffix.lower().lstrip('.')
|
|
96
|
+
if extension == 'gif':
|
|
97
|
+
from PIL import Image
|
|
98
|
+
frames = []
|
|
99
|
+
durations = []
|
|
100
|
+
with Image.open(filename) as img:
|
|
101
|
+
while True:
|
|
102
|
+
frames.append(np.array(img.convert('RGB')))
|
|
103
|
+
durations.append(img.info.get('duration', None))
|
|
104
|
+
try:
|
|
105
|
+
img.seek(img.tell() + 1)
|
|
106
|
+
except EOFError:
|
|
107
|
+
break
|
|
108
|
+
data = np.array(frames)
|
|
109
|
+
if return_framerate:
|
|
110
|
+
if None in durations:
|
|
111
|
+
raise ValueError('Cannot return framerate for GIF because one or'
|
|
112
|
+
' more frames are missing duration metadata')
|
|
113
|
+
if len(durations) == 0:
|
|
114
|
+
return data, None
|
|
115
|
+
framerate = 1000 / np.mean(durations)
|
|
116
|
+
return data, float(framerate)
|
|
117
|
+
return data
|
|
118
|
+
|
|
95
119
|
av = _import_av()
|
|
96
120
|
with av.open(filename) as container:
|
|
97
121
|
stream = container.streams.video[0]
|
|
@@ -133,7 +157,7 @@ def load_video(filename,
|
|
|
133
157
|
|
|
134
158
|
def lazy_load_video(filename) -> Iterator[np.ndarray]:
|
|
135
159
|
"""
|
|
136
|
-
Lazily load video frames as numpy arrays using PyAV
|
|
160
|
+
Lazily load video frames as numpy arrays using PyAV (or using PIL for gifs)
|
|
137
161
|
|
|
138
162
|
This iterator yields images in the order they appear in the video.
|
|
139
163
|
If you want reasonably fast random access to arbitrary frames in
|
|
@@ -149,6 +173,19 @@ def lazy_load_video(filename) -> Iterator[np.ndarray]:
|
|
|
149
173
|
frame : np.ndarray
|
|
150
174
|
Video frame as a numpy array, shape (height, width, colors).
|
|
151
175
|
"""
|
|
176
|
+
extension = Path(filename).suffix.lower().lstrip('.')
|
|
177
|
+
if extension == 'gif':
|
|
178
|
+
from PIL import Image
|
|
179
|
+
with Image.open(filename) as img:
|
|
180
|
+
while True:
|
|
181
|
+
img_array = np.array(img.convert('RGB'))
|
|
182
|
+
yield img_array
|
|
183
|
+
try:
|
|
184
|
+
img.seek(img.tell() + 1)
|
|
185
|
+
except EOFError:
|
|
186
|
+
break
|
|
187
|
+
return
|
|
188
|
+
|
|
152
189
|
av = _import_av()
|
|
153
190
|
rotation = _get_rotation_from_metadata(filename)
|
|
154
191
|
with av.open(Path(filename).expanduser()) as container:
|
|
@@ -185,6 +222,10 @@ class VideoStreamer:
|
|
|
185
222
|
If 'auto', the index is cached only if building it takes
|
|
186
223
|
more than 0.5 seconds, which only happens for ~1+ GB videos.
|
|
187
224
|
"""
|
|
225
|
+
extension = Path(filename).suffix.lower().lstrip('.')
|
|
226
|
+
if extension == 'gif':
|
|
227
|
+
raise NotImplementedError('Streaming from gifs not yet implemented. '
|
|
228
|
+
'Use load_video() or lazy_load_video() instead.')
|
|
188
229
|
self.av = _import_av()
|
|
189
230
|
self.verbose = verbose
|
|
190
231
|
self.filename = Path(filename).expanduser()
|
|
@@ -223,10 +264,11 @@ class VideoStreamer:
|
|
|
223
264
|
|
|
224
265
|
with self.av.open(str(self.filename)) as container:
|
|
225
266
|
stream = container.streams.video[0]
|
|
267
|
+
start_pts = stream.start_time if stream.start_time is not None else 0
|
|
226
268
|
# Access as packets to only read metadata (fast), not pixel data (slow)
|
|
227
269
|
for packet in tqdm(container.demux(stream), total=stream.frames,
|
|
228
270
|
desc='Indexing frames', disable=not self.verbose):
|
|
229
|
-
if packet.pts is not None:
|
|
271
|
+
if packet.pts is not None and packet.pts >= start_pts:
|
|
230
272
|
frames_pts.append(packet.pts)
|
|
231
273
|
|
|
232
274
|
# demux gave us packets in the order they appeared in the file
|
|
@@ -595,6 +637,10 @@ class AVVideoWriter:
|
|
|
595
637
|
overwrite=False):
|
|
596
638
|
self.av = _import_av()
|
|
597
639
|
filename = Path(filename).expanduser()
|
|
640
|
+
extension = filename.suffix.lower().lstrip('.')
|
|
641
|
+
if extension == 'gif':
|
|
642
|
+
raise NotImplementedError('Saving to GIF format not yet implemented. '
|
|
643
|
+
'Use save_video() instead.')
|
|
598
644
|
if filename.exists() and not overwrite:
|
|
599
645
|
raise FileExistsError(f'File {filename} already exists. '
|
|
600
646
|
'Set overwrite=True to overwrite.')
|
|
@@ -856,7 +902,9 @@ VideoWriter = FFmpegVideoWriter
|
|
|
856
902
|
|
|
857
903
|
def save_video(data, filename, time_axis=0, color_axis=None, overwrite=False,
|
|
858
904
|
dim_order='yx', framerate=30, crf=23, compression_speed='medium',
|
|
859
|
-
progress_bar=True, codec: Literal['libx264', 'libx265', 'libvpx',
|
|
905
|
+
progress_bar=True, codec: Literal['libx264', 'libx265', 'libvpx',
|
|
906
|
+
'libvpx-vp9', None] = None
|
|
907
|
+
) -> None:
|
|
860
908
|
"""
|
|
861
909
|
Save a 3D numpy array of greyscale values OR a 4D numpy array of RGB values as a video
|
|
862
910
|
|
|
@@ -954,6 +1002,36 @@ def save_video(data, filename, time_axis=0, color_axis=None, overwrite=False,
|
|
|
954
1002
|
if pad != [[0, 0], [0, 0], [0, 0]]:
|
|
955
1003
|
data = np.pad(data, pad, mode='edge')
|
|
956
1004
|
|
|
1005
|
+
if extension == 'gif':
|
|
1006
|
+
from PIL import Image
|
|
1007
|
+
pil_images = [Image.fromarray(frame) for frame in data]
|
|
1008
|
+
|
|
1009
|
+
# GIF frame delays must be multiples of 10ms, so GIF doesn't
|
|
1010
|
+
# natively suppport framerates like 30 fps which require an
|
|
1011
|
+
# inter-frame delay of 33.33 ms. To support arbitrary
|
|
1012
|
+
# framerates, we copy the solution used in ffmpeg which is to
|
|
1013
|
+
# use variable inter-frame intervals: 30 ms some frames and 40 ms
|
|
1014
|
+
# others to achieve an average interval of 33.33 ms (for the
|
|
1015
|
+
# example of 30 fps). Implementation of alternating delays:
|
|
1016
|
+
ideal_delay_cs = 100 / framerate
|
|
1017
|
+
lo = int(ideal_delay_cs)
|
|
1018
|
+
hi = lo + 1
|
|
1019
|
+
durations_ms = []
|
|
1020
|
+
accumulated = 0.0
|
|
1021
|
+
for _ in range(len(pil_images)):
|
|
1022
|
+
accumulated += ideal_delay_cs
|
|
1023
|
+
if accumulated >= hi:
|
|
1024
|
+
durations_ms.append(hi * 10)
|
|
1025
|
+
accumulated -= hi
|
|
1026
|
+
else:
|
|
1027
|
+
durations_ms.append(lo * 10)
|
|
1028
|
+
accumulated -= lo
|
|
1029
|
+
|
|
1030
|
+
pil_images[0].save(filename, format='GIF', save_all=True,
|
|
1031
|
+
append_images=pil_images[1:],
|
|
1032
|
+
duration=durations_ms, loop=0)
|
|
1033
|
+
return
|
|
1034
|
+
|
|
957
1035
|
with VideoWriter(filename, framerate=framerate, crf=crf,
|
|
958
1036
|
compression_speed=compression_speed, codec=codec,
|
|
959
1037
|
overwrite=overwrite) as writer:
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Tests for VideoStreamer, particularly handling of HEVC videos with
|
|
4
|
+
negative-PTS priming packets (common in iPhone recordings).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
import npimage
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_negative_pts_priming_packet():
|
|
17
|
+
"""
|
|
18
|
+
Test that VideoStreamer correctly handles HEVC videos where the first
|
|
19
|
+
packet has a negative PTS (a "priming" packet used for B-frame prediction
|
|
20
|
+
that doesn't produce a decoded frame).
|
|
21
|
+
|
|
22
|
+
This is common in iPhone HEVC recordings. The bug was that the index was
|
|
23
|
+
built from demuxed packets, which included the priming packet's negative
|
|
24
|
+
PTS. When trying to access frame 0, it would seek to that negative PTS,
|
|
25
|
+
which no decoded frame ever has, causing a VideoSeekError.
|
|
26
|
+
"""
|
|
27
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
28
|
+
video_path = os.path.join(tmpdir, 'test_negative_pts.mp4')
|
|
29
|
+
|
|
30
|
+
# Create a small HEVC video with a negative-PTS priming packet.
|
|
31
|
+
# The -output_ts_offset shifts timestamps so the first keyframe packet
|
|
32
|
+
# gets a 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
|
+
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:
|
|
45
|
+
# more packets than decoded frames due to the priming packet
|
|
46
|
+
import av
|
|
47
|
+
with av.open(video_path) as container:
|
|
48
|
+
stream = container.streams.video[0]
|
|
49
|
+
pkt_pts = sorted(
|
|
50
|
+
p.pts for p in container.demux(stream) if p.pts is not None
|
|
51
|
+
)
|
|
52
|
+
with av.open(video_path) as container:
|
|
53
|
+
stream = container.streams.video[0]
|
|
54
|
+
frame_pts = [f.pts for f in container.decode(stream)]
|
|
55
|
+
|
|
56
|
+
assert len(pkt_pts) > len(frame_pts), (
|
|
57
|
+
f'Test video should have more packets ({len(pkt_pts)}) than '
|
|
58
|
+
f'decoded frames ({len(frame_pts)}). The test setup may need '
|
|
59
|
+
f'updating if ffmpeg behavior has changed.'
|
|
60
|
+
)
|
|
61
|
+
assert pkt_pts[0] < 0, (
|
|
62
|
+
f'First packet PTS should be negative, got {pkt_pts[0]}'
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Now test that VideoStreamer handles this correctly
|
|
66
|
+
vid = npimage.VideoStreamer(video_path)
|
|
67
|
+
|
|
68
|
+
# The number of frames should match decoded frames, not packets
|
|
69
|
+
assert vid.n_frames == len(frame_pts), (
|
|
70
|
+
f'Expected {len(frame_pts)} frames, got {vid.n_frames}'
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
# Accessing frame 0 should work without raising VideoSeekError
|
|
74
|
+
frame = vid[0]
|
|
75
|
+
assert isinstance(frame, np.ndarray)
|
|
76
|
+
assert frame.shape == (240, 320, 3)
|
|
77
|
+
|
|
78
|
+
# Accessing the last frame should also work
|
|
79
|
+
last_frame = vid[vid.n_frames - 1]
|
|
80
|
+
assert isinstance(last_frame, np.ndarray)
|
|
81
|
+
|
|
82
|
+
print(f'PASSED: Loaded {vid.n_frames} frames from video with '
|
|
83
|
+
f'negative-PTS priming packet')
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main():
|
|
87
|
+
test_negative_pts_priming_packet()
|
|
88
|
+
print('\nAll VideoStreamer tests passed.')
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
if __name__ == '__main__':
|
|
92
|
+
main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|