numpyimage 3.5.3__tar.gz → 3.6.1__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.
Files changed (22) hide show
  1. {numpyimage-3.5.3/numpyimage.egg-info → numpyimage-3.6.1}/PKG-INFO +1 -1
  2. {numpyimage-3.5.3 → numpyimage-3.6.1}/npimage/vidio.py +82 -4
  3. {numpyimage-3.5.3 → numpyimage-3.6.1/numpyimage.egg-info}/PKG-INFO +1 -1
  4. {numpyimage-3.5.3 → numpyimage-3.6.1}/numpyimage.egg-info/SOURCES.txt +1 -0
  5. {numpyimage-3.5.3 → numpyimage-3.6.1}/pyproject.toml +3 -3
  6. numpyimage-3.6.1/tests/test_video_streamer.py +92 -0
  7. {numpyimage-3.5.3 → numpyimage-3.6.1}/LICENSE +0 -0
  8. {numpyimage-3.5.3 → numpyimage-3.6.1}/README.md +0 -0
  9. {numpyimage-3.5.3 → numpyimage-3.6.1}/npimage/__init__.py +0 -0
  10. {numpyimage-3.5.3 → numpyimage-3.6.1}/npimage/align.py +0 -0
  11. {numpyimage-3.5.3 → numpyimage-3.6.1}/npimage/graphics.py +0 -0
  12. {numpyimage-3.5.3 → numpyimage-3.6.1}/npimage/imageio.py +0 -0
  13. {numpyimage-3.5.3 → numpyimage-3.6.1}/npimage/nrrd_utils.py +0 -0
  14. {numpyimage-3.5.3 → numpyimage-3.6.1}/npimage/operations.py +0 -0
  15. {numpyimage-3.5.3 → numpyimage-3.6.1}/npimage/utils.py +0 -0
  16. {numpyimage-3.5.3 → numpyimage-3.6.1}/numpyimage.egg-info/dependency_links.txt +0 -0
  17. {numpyimage-3.5.3 → numpyimage-3.6.1}/numpyimage.egg-info/requires.txt +0 -0
  18. {numpyimage-3.5.3 → numpyimage-3.6.1}/numpyimage.egg-info/top_level.txt +0 -0
  19. {numpyimage-3.5.3 → numpyimage-3.6.1}/setup.cfg +0 -0
  20. {numpyimage-3.5.3 → numpyimage-3.6.1}/tests/test_heic.py +0 -0
  21. {numpyimage-3.5.3 → numpyimage-3.6.1}/tests/test_pbm.py +0 -0
  22. {numpyimage-3.5.3 → numpyimage-3.6.1}/tests/test_video_writers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: numpyimage
3
- Version: 3.5.3
3
+ Version: 3.6.1
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
@@ -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', 'libvpx-vp9', None] = None) -> None:
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: numpyimage
3
- Version: 3.5.3
3
+ Version: 3.6.1
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
@@ -16,4 +16,5 @@ numpyimage.egg-info/requires.txt
16
16
  numpyimage.egg-info/top_level.txt
17
17
  tests/test_heic.py
18
18
  tests/test_pbm.py
19
+ tests/test_video_streamer.py
19
20
  tests/test_video_writers.py
@@ -4,12 +4,12 @@ build-backend = 'setuptools.build_meta'
4
4
 
5
5
  [project]
6
6
  name = 'numpyimage'
7
- version = '3.5.3'
7
+ version = '3.6.1'
8
8
  description = 'Load, save, & manipulate image files as numpy arrays'
9
9
  readme.file = 'README.md'
10
10
  readme.content-type = 'text/markdown'
11
- requires-python = ">=3.6"
12
- license = {file = "LICENSE"}
11
+ requires-python = '>=3.6'
12
+ license = {file = 'LICENSE'}
13
13
  keywords = ['images', 'pixel arrays', 'image formats', 'convert', 'graphics', 'draw simple shapes']
14
14
  authors = [{name = 'Jasper Phelps', email = 'jasper.s.phelps@gmail.com'}]
15
15
  classifiers = [
@@ -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