numpyimage 3.5.2__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.
Files changed (22) hide show
  1. {numpyimage-3.5.2/numpyimage.egg-info → numpyimage-3.6.0}/PKG-INFO +1 -1
  2. {numpyimage-3.5.2 → numpyimage-3.6.0}/npimage/graphics.py +14 -7
  3. {numpyimage-3.5.2 → numpyimage-3.6.0}/npimage/vidio.py +133 -21
  4. {numpyimage-3.5.2 → numpyimage-3.6.0/numpyimage.egg-info}/PKG-INFO +1 -1
  5. {numpyimage-3.5.2 → numpyimage-3.6.0}/numpyimage.egg-info/SOURCES.txt +1 -0
  6. {numpyimage-3.5.2 → numpyimage-3.6.0}/pyproject.toml +1 -1
  7. numpyimage-3.6.0/tests/test_video_streamer.py +92 -0
  8. {numpyimage-3.5.2 → numpyimage-3.6.0}/LICENSE +0 -0
  9. {numpyimage-3.5.2 → numpyimage-3.6.0}/README.md +0 -0
  10. {numpyimage-3.5.2 → numpyimage-3.6.0}/npimage/__init__.py +0 -0
  11. {numpyimage-3.5.2 → numpyimage-3.6.0}/npimage/align.py +0 -0
  12. {numpyimage-3.5.2 → numpyimage-3.6.0}/npimage/imageio.py +0 -0
  13. {numpyimage-3.5.2 → numpyimage-3.6.0}/npimage/nrrd_utils.py +0 -0
  14. {numpyimage-3.5.2 → numpyimage-3.6.0}/npimage/operations.py +0 -0
  15. {numpyimage-3.5.2 → numpyimage-3.6.0}/npimage/utils.py +0 -0
  16. {numpyimage-3.5.2 → numpyimage-3.6.0}/numpyimage.egg-info/dependency_links.txt +0 -0
  17. {numpyimage-3.5.2 → numpyimage-3.6.0}/numpyimage.egg-info/requires.txt +0 -0
  18. {numpyimage-3.5.2 → numpyimage-3.6.0}/numpyimage.egg-info/top_level.txt +0 -0
  19. {numpyimage-3.5.2 → numpyimage-3.6.0}/setup.cfg +0 -0
  20. {numpyimage-3.5.2 → numpyimage-3.6.0}/tests/test_heic.py +0 -0
  21. {numpyimage-3.5.2 → numpyimage-3.6.0}/tests/test_pbm.py +0 -0
  22. {numpyimage-3.5.2 → numpyimage-3.6.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.5.2
3
+ Version: 3.6.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
@@ -637,13 +637,20 @@ def get_voxels_within_distance(distance, center=None, ndims=None,
637
637
  """
638
638
  Generates a list of voxel coordinates within a given distance of a given
639
639
  center point.
640
- center, tuple : Voxel coordinate to be used as the center.
641
- Defaults to the origin.
642
- distance, float : How far away from the center point to include.
643
- metric, string : How to calculate distance. Options are:
644
- euclidian (default)
645
- manhattan (sum of coordinates)
646
- chebyshev (max of coordinates)
640
+
641
+ Parameters
642
+ ----------
643
+ center : tuple
644
+ Voxel coordinate to be used as the center. Defaults to the origin.
645
+
646
+ distance : float
647
+ How far away from the center point to include.
648
+
649
+ metric : string, default 'euclidian'
650
+ How to calculate distance. Options are:
651
+ - euclidian (default)
652
+ - manhattan (sum of coordinates)
653
+ - chebyshev (max of coordinates)
647
654
  """
648
655
  assert metric in ['euclidian', 'manhattan', 'chebyshev']
649
656
 
@@ -40,9 +40,22 @@ codec_aliases = {
40
40
  'hev1': 'libx265',
41
41
  'h265': 'libx265',
42
42
  'H.265': 'libx265',
43
+ 'libvpx': 'libvpx',
44
+ 'vp8': 'libvpx',
45
+ 'libvpx-vp9': 'libvpx-vp9',
46
+ 'vp9': 'libvpx-vp9',
43
47
  }
44
48
 
45
- supported_extensions = ['mp4', 'mkv', 'avi', 'mov', 'webm']
49
+ # Default codec for each container format
50
+ extension_default_codecs = {
51
+ 'mp4': 'libx264',
52
+ 'mkv': 'libx264',
53
+ 'avi': 'libx264',
54
+ 'mov': 'libx264',
55
+ 'webm': 'libvpx-vp9',
56
+ }
57
+
58
+ supported_extensions = ['mp4', 'mkv', 'avi', 'mov', 'webm', 'gif']
46
59
 
47
60
 
48
61
  def _import_av():
@@ -79,6 +92,30 @@ def load_video(filename,
79
92
  framerate : float
80
93
  The frame rate of the video in frames per second
81
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
+
82
119
  av = _import_av()
83
120
  with av.open(filename) as container:
84
121
  stream = container.streams.video[0]
@@ -120,7 +157,7 @@ def load_video(filename,
120
157
 
121
158
  def lazy_load_video(filename) -> Iterator[np.ndarray]:
122
159
  """
123
- Lazily load video frames as numpy arrays using PyAV.
160
+ Lazily load video frames as numpy arrays using PyAV (or using PIL for gifs)
124
161
 
125
162
  This iterator yields images in the order they appear in the video.
126
163
  If you want reasonably fast random access to arbitrary frames in
@@ -136,6 +173,19 @@ def lazy_load_video(filename) -> Iterator[np.ndarray]:
136
173
  frame : np.ndarray
137
174
  Video frame as a numpy array, shape (height, width, colors).
138
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
+
139
189
  av = _import_av()
140
190
  rotation = _get_rotation_from_metadata(filename)
141
191
  with av.open(Path(filename).expanduser()) as container:
@@ -172,6 +222,10 @@ class VideoStreamer:
172
222
  If 'auto', the index is cached only if building it takes
173
223
  more than 0.5 seconds, which only happens for ~1+ GB videos.
174
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.')
175
229
  self.av = _import_av()
176
230
  self.verbose = verbose
177
231
  self.filename = Path(filename).expanduser()
@@ -210,10 +264,11 @@ class VideoStreamer:
210
264
 
211
265
  with self.av.open(str(self.filename)) as container:
212
266
  stream = container.streams.video[0]
267
+ start_pts = stream.start_time if stream.start_time is not None else 0
213
268
  # Access as packets to only read metadata (fast), not pixel data (slow)
214
269
  for packet in tqdm(container.demux(stream), total=stream.frames,
215
270
  desc='Indexing frames', disable=not self.verbose):
216
- if packet.pts is not None:
271
+ if packet.pts is not None and packet.pts >= start_pts:
217
272
  frames_pts.append(packet.pts)
218
273
 
219
274
  # demux gave us packets in the order they appeared in the file
@@ -256,7 +311,7 @@ class VideoStreamer:
256
311
  pts_deltas = np.diff(frames_pts) if len(frames_pts) > 1 else None
257
312
  if pts_deltas is not None and (pts_deltas == pts_deltas[0]).all():
258
313
  # The video is constant framerate
259
- self.pts_delta = pts_deltas[0]
314
+ self.pts_delta = int(pts_deltas[0])
260
315
  self._framerate = 1 / (self.pts_delta * self.time_base)
261
316
  if self._framerate.denominator == 1:
262
317
  self._framerate = self._framerate.numerator
@@ -572,15 +627,20 @@ class AVVideoWriter:
572
627
  compression_speed : str, default 'medium'
573
628
  Compression speed preset: 'ultrafast', 'superfast', 'veryfast',
574
629
  'faster', 'fast', 'medium', 'slow', 'slower', 'veryslow'.
575
- codec : Literal['libx264', 'libx265'], default 'libx264'
576
- The video codec to use for encoding. Can be any of a number of aliases for
577
- these two codecs, including avc1/h264 vs hevc/hvc1/hev1/h265.
630
+ codec : str or None, default None
631
+ The video codec to use for encoding. If None, automatically chosen based
632
+ on the file extension (e.g. libx264 for .mp4, libvpx-vp9 for .webm).
633
+ Accepts aliases like h264, h265, vp8, vp9, etc.
578
634
  """
579
635
  def __init__(self, filename, framerate=30, crf=23, compression_speed='medium',
580
- codec: Literal['libx264', 'libx265'] = 'libx264',
636
+ codec: Literal['libx264', 'libx265', 'libvpx', 'libvpx-vp9', None] = None,
581
637
  overwrite=False):
582
638
  self.av = _import_av()
583
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.')
584
644
  if filename.exists() and not overwrite:
585
645
  raise FileExistsError(f'File {filename} already exists. '
586
646
  'Set overwrite=True to overwrite.')
@@ -588,11 +648,20 @@ class AVVideoWriter:
588
648
  self._framerate = utils.limit_fraction(framerate)
589
649
  self.crf = crf
590
650
  self.compression_speed = compression_speed
591
- self.codec = codec_aliases[codec.lower()]
651
+
652
+ if codec is None:
653
+ ext = filename.suffix.lower().lstrip('.')
654
+ self.codec = extension_default_codecs.get(ext, 'libx264')
655
+ else:
656
+ self.codec = codec_aliases[codec.lower()]
657
+
592
658
  self.container = self.av.open(filename, mode='w')
593
659
  self.stream = self.container.add_stream(self.codec, rate=self._framerate)
594
660
  self.stream.pix_fmt = 'yuv420p'
595
- self.stream.options = {'crf': str(crf), 'preset': compression_speed}
661
+ if self.codec in ('libvpx', 'libvpx-vp9'):
662
+ self.stream.options = {'crf': str(crf), 'b:v': '0'}
663
+ else:
664
+ self.stream.options = {'crf': str(crf), 'preset': compression_speed}
596
665
  self._closed = False
597
666
  self.stream.width = 0
598
667
  self.stream.height = 0
@@ -676,14 +745,15 @@ class FFmpegVideoWriter:
676
745
  compression_speed : str, default 'medium'
677
746
  Compression speed preset: 'ultrafast', 'superfast', 'veryfast',
678
747
  'faster', 'fast', 'medium', 'slow', 'slower', 'veryslow'.
679
- codec : Literal['libx264', 'libx265'], default 'libx264'
680
- The video codec to use for encoding. Can be any of a number of aliases for
681
- these two codecs, including avc1/h264 vs hevc/hvc1/hev1/h265.
748
+ codec : str or None, default None
749
+ The video codec to use for encoding. If None, automatically chosen based
750
+ on the file extension (e.g. libx264 for .mp4, libvpx-vp9 for .webm).
751
+ Accepts aliases like h264, h265, vp8, vp9, etc.
682
752
  overwrite : bool, default False
683
753
  Whether to overwrite the file if it already exists.
684
754
  """
685
755
  def __init__(self, filename, framerate=30, crf=23, compression_speed='medium',
686
- codec: Literal['libx264', 'libx265'] = 'libx264',
756
+ codec: Literal['libx264', 'libx265', 'libvpx', 'libvpx-vp9', None] = None,
687
757
  overwrite=False):
688
758
  filename = Path(filename).expanduser()
689
759
  if filename.exists() and not overwrite:
@@ -693,7 +763,12 @@ class FFmpegVideoWriter:
693
763
  self._framerate = utils.limit_fraction(framerate)
694
764
  self.crf = crf
695
765
  self.compression_speed = compression_speed
696
- self.codec = codec_aliases[codec.lower()]
766
+
767
+ if codec is None:
768
+ ext = filename.suffix.lower().lstrip('.')
769
+ self.codec = extension_default_codecs.get(ext, 'libx264')
770
+ else:
771
+ self.codec = codec_aliases[codec.lower()]
697
772
 
698
773
  # Initialize process state
699
774
  self._process = None
@@ -725,9 +800,13 @@ class FFmpegVideoWriter:
725
800
  '-c:v', self.codec,
726
801
  '-pix_fmt', 'yuv420p', # Output pixel format
727
802
  '-crf', str(self.crf),
728
- '-preset', self.compression_speed,
729
- self.filename
730
803
  ]
804
+ if self.codec in ('libvpx', 'libvpx-vp9'):
805
+ # VP8/VP9 need -b:v 0 for constant quality (CRF) mode
806
+ command += ['-b:v', '0']
807
+ else:
808
+ command += ['-preset', self.compression_speed]
809
+ command.append(str(self.filename))
731
810
 
732
811
  # Start FFmpeg process
733
812
  self._process = subprocess.Popen(
@@ -823,7 +902,9 @@ VideoWriter = FFmpegVideoWriter
823
902
 
824
903
  def save_video(data, filename, time_axis=0, color_axis=None, overwrite=False,
825
904
  dim_order='yx', framerate=30, crf=23, compression_speed='medium',
826
- progress_bar=True, codec: Literal['libx264', 'libx265'] = 'libx264') -> None:
905
+ progress_bar=True, codec: Literal['libx264', 'libx265', 'libvpx',
906
+ 'libvpx-vp9', None] = None
907
+ ) -> None:
827
908
  """
828
909
  Save a 3D numpy array of greyscale values OR a 4D numpy array of RGB values as a video
829
910
 
@@ -869,9 +950,10 @@ def save_video(data, filename, time_axis=0, color_axis=None, overwrite=False,
869
950
  progress_bar : bool, default True
870
951
  If True, display a progress bar.
871
952
 
872
- codec : Literal['libx264', 'libx265'], default 'libx264'
873
- The video codec to use for encoding. Can be any of a number of aliases for
874
- these two codecs, including avc1/h264 vs hevc/hvc1/hev1/h265.
953
+ codec : str or None, default None
954
+ The video codec to use for encoding. If None, automatically chosen based
955
+ on the file extension (e.g. libx264 for .mp4, libvpx-vp9 for .webm).
956
+ Accepts aliases like h264, h265, vp8, vp9, etc.
875
957
  """
876
958
 
877
959
  filename = str(filename)
@@ -920,6 +1002,36 @@ def save_video(data, filename, time_axis=0, color_axis=None, overwrite=False,
920
1002
  if pad != [[0, 0], [0, 0], [0, 0]]:
921
1003
  data = np.pad(data, pad, mode='edge')
922
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
+
923
1035
  with VideoWriter(filename, framerate=framerate, crf=crf,
924
1036
  compression_speed=compression_speed, codec=codec,
925
1037
  overwrite=overwrite) as writer:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: numpyimage
3
- Version: 3.5.2
3
+ Version: 3.6.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
@@ -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,7 +4,7 @@ build-backend = 'setuptools.build_meta'
4
4
 
5
5
  [project]
6
6
  name = 'numpyimage'
7
- version = '3.5.2'
7
+ version = '3.6.0'
8
8
  description = 'Load, save, & manipulate image files as numpy arrays'
9
9
  readme.file = 'README.md'
10
10
  readme.content-type = 'text/markdown'
@@ -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