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.
Files changed (23) hide show
  1. {numpyimage-3.7.1 → numpyimage-3.8.0}/PKG-INFO +9 -5
  2. {numpyimage-3.7.1 → numpyimage-3.8.0}/README.md +8 -4
  3. {numpyimage-3.7.1 → numpyimage-3.8.0}/npimage/graphics.py +22 -12
  4. {numpyimage-3.7.1 → numpyimage-3.8.0}/npimage/vidio.py +101 -5
  5. {numpyimage-3.7.1 → numpyimage-3.8.0}/numpyimage.egg-info/PKG-INFO +9 -5
  6. {numpyimage-3.7.1 → numpyimage-3.8.0}/pyproject.toml +1 -1
  7. numpyimage-3.8.0/tests/test_video_streamer.py +322 -0
  8. numpyimage-3.7.1/tests/test_video_streamer.py +0 -71
  9. {numpyimage-3.7.1 → numpyimage-3.8.0}/LICENSE +0 -0
  10. {numpyimage-3.7.1 → numpyimage-3.8.0}/npimage/__init__.py +0 -0
  11. {numpyimage-3.7.1 → numpyimage-3.8.0}/npimage/align.py +0 -0
  12. {numpyimage-3.7.1 → numpyimage-3.8.0}/npimage/imageio.py +0 -0
  13. {numpyimage-3.7.1 → numpyimage-3.8.0}/npimage/nrrd_utils.py +0 -0
  14. {numpyimage-3.7.1 → numpyimage-3.8.0}/npimage/operations.py +0 -0
  15. {numpyimage-3.7.1 → numpyimage-3.8.0}/npimage/utils.py +0 -0
  16. {numpyimage-3.7.1 → numpyimage-3.8.0}/numpyimage.egg-info/SOURCES.txt +0 -0
  17. {numpyimage-3.7.1 → numpyimage-3.8.0}/numpyimage.egg-info/dependency_links.txt +0 -0
  18. {numpyimage-3.7.1 → numpyimage-3.8.0}/numpyimage.egg-info/requires.txt +0 -0
  19. {numpyimage-3.7.1 → numpyimage-3.8.0}/numpyimage.egg-info/top_level.txt +0 -0
  20. {numpyimage-3.7.1 → numpyimage-3.8.0}/setup.cfg +0 -0
  21. {numpyimage-3.7.1 → numpyimage-3.8.0}/tests/test_heic.py +0 -0
  22. {numpyimage-3.7.1 → numpyimage-3.8.0}/tests/test_pbm.py +0 -0
  23. {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.7.1
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
  [![Tests](https://github.com/jasper-tms/npimage/actions/workflows/tests.yml/badge.svg)](https://github.com/jasper-tms/npimage/actions/workflows/tests.yml)
63
63
  [![codecov](https://codecov.io/gh/jasper-tms/npimage/branch/main/graph/badge.svg)](https://codecov.io/gh/jasper-tms/npimage)
64
64
  [![PyPI version](https://img.shields.io/pypi/v/numpyimage)](https://pypi.org/project/numpyimage/)
65
65
  [![PyPI downloads](https://img.shields.io/pypi/dm/numpyimage)](https://pypi.org/project/numpyimage/)
66
66
  [![License](https://img.shields.io/github/license/jasper-tms/npimage)](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? 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!)
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 or 3D numpy arrays representing image volumes.
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
  [![Tests](https://github.com/jasper-tms/npimage/actions/workflows/tests.yml/badge.svg)](https://github.com/jasper-tms/npimage/actions/workflows/tests.yml)
4
4
  [![codecov](https://codecov.io/gh/jasper-tms/npimage/branch/main/graph/badge.svg)](https://codecov.io/gh/jasper-tms/npimage)
5
5
  [![PyPI version](https://img.shields.io/pypi/v/numpyimage)](https://pypi.org/project/numpyimage/)
6
6
  [![PyPI downloads](https://img.shields.io/pypi/dm/numpyimage)](https://pypi.org/project/numpyimage/)
7
7
  [![License](https://img.shields.io/github/license/jasper-tms/npimage)](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? 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!)
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 or 3D numpy arrays representing image volumes.
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 the `coords` argument has fewer dimensions than the `image`
447
- argument, this parameter determines how to handle that mismatch.
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. For example:
451
- - You have a (360, 640, 3) RGB pixel array `image`
452
- - You have 100 pixels specified in a (100, 2) array `coords`
453
- - You want to make those pixels cyan, i.e. set the values along
454
- the last axis to (0, 255, 255)
455
- then since the color axis is the last axis, you can call:
456
- >>> imset(image, coords, (0, 255, 255), add_missing_dims='end')
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
- Note that fps always returns a float even if the framerate is 'variable'.
388
- (If framerate is 'variable', fps returns exactly what its name, "frames per second",
389
- implies: the number of frame intervals divided by the video duration in seconds.)
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
- return float((self.frames_pts[-1] - self.frames_pts[0]) * self.time_base)
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.7.1
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
  [![Tests](https://github.com/jasper-tms/npimage/actions/workflows/tests.yml/badge.svg)](https://github.com/jasper-tms/npimage/actions/workflows/tests.yml)
63
63
  [![codecov](https://codecov.io/gh/jasper-tms/npimage/branch/main/graph/badge.svg)](https://codecov.io/gh/jasper-tms/npimage)
64
64
  [![PyPI version](https://img.shields.io/pypi/v/numpyimage)](https://pypi.org/project/numpyimage/)
65
65
  [![PyPI downloads](https://img.shields.io/pypi/dm/numpyimage)](https://pypi.org/project/numpyimage/)
66
66
  [![License](https://img.shields.io/github/license/jasper-tms/npimage)](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? 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!)
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 or 3D numpy arrays representing image volumes.
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`!)
@@ -4,7 +4,7 @@ build-backend = 'setuptools.build_meta'
4
4
 
5
5
  [project]
6
6
  name = 'numpyimage'
7
- version = '3.7.1'
7
+ version = '3.8.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,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