numpyimage 3.9.0__tar.gz → 3.10.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.9.0 → numpyimage-3.10.0}/PKG-INFO +1 -1
  2. {numpyimage-3.9.0 → numpyimage-3.10.0}/npimage/vidio.py +476 -66
  3. {numpyimage-3.9.0 → numpyimage-3.10.0}/numpyimage.egg-info/PKG-INFO +1 -1
  4. {numpyimage-3.9.0 → numpyimage-3.10.0}/pyproject.toml +1 -1
  5. {numpyimage-3.9.0 → numpyimage-3.10.0}/tests/test_video_streamer.py +288 -1
  6. {numpyimage-3.9.0 → numpyimage-3.10.0}/LICENSE +0 -0
  7. {numpyimage-3.9.0 → numpyimage-3.10.0}/README.md +0 -0
  8. {numpyimage-3.9.0 → numpyimage-3.10.0}/npimage/__init__.py +0 -0
  9. {numpyimage-3.9.0 → numpyimage-3.10.0}/npimage/align.py +0 -0
  10. {numpyimage-3.9.0 → numpyimage-3.10.0}/npimage/graphics.py +0 -0
  11. {numpyimage-3.9.0 → numpyimage-3.10.0}/npimage/imageio.py +0 -0
  12. {numpyimage-3.9.0 → numpyimage-3.10.0}/npimage/nrrd_utils.py +0 -0
  13. {numpyimage-3.9.0 → numpyimage-3.10.0}/npimage/operations.py +0 -0
  14. {numpyimage-3.9.0 → numpyimage-3.10.0}/npimage/utils.py +0 -0
  15. {numpyimage-3.9.0 → numpyimage-3.10.0}/numpyimage.egg-info/SOURCES.txt +0 -0
  16. {numpyimage-3.9.0 → numpyimage-3.10.0}/numpyimage.egg-info/dependency_links.txt +0 -0
  17. {numpyimage-3.9.0 → numpyimage-3.10.0}/numpyimage.egg-info/requires.txt +0 -0
  18. {numpyimage-3.9.0 → numpyimage-3.10.0}/numpyimage.egg-info/top_level.txt +0 -0
  19. {numpyimage-3.9.0 → numpyimage-3.10.0}/setup.cfg +0 -0
  20. {numpyimage-3.9.0 → numpyimage-3.10.0}/tests/test_heic.py +0 -0
  21. {numpyimage-3.9.0 → numpyimage-3.10.0}/tests/test_orientation.py +0 -0
  22. {numpyimage-3.9.0 → numpyimage-3.10.0}/tests/test_pbm.py +0 -0
  23. {numpyimage-3.9.0 → numpyimage-3.10.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.9.0
3
+ Version: 3.10.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
@@ -13,6 +13,9 @@ Class list:
13
13
  - VideoStreamer: Provides fast random access to frames in a video file
14
14
  via VideoStreamer[frame_number], or by time in seconds via
15
15
  VideoStreamer.t[time_in_seconds].
16
+ - ConstantFrameratePTS: The frame timestamps of a constant-framerate video,
17
+ stored as a formula instead of as one timestamp per frame. This is what
18
+ VideoStreamer.frames_pts holds for a constant-framerate video.
16
19
  - VideoWriter: Allows writing frames one-by-one to a video file via
17
20
  VideoWriter.write(image). This can be advantageous compared to save_video
18
21
  because you don't ever have to have all the frames in memory at once.
@@ -26,7 +29,6 @@ import shutil
26
29
  import sys
27
30
  import threading
28
31
  import json
29
- import math
30
32
  import re
31
33
  import bisect
32
34
  from fractions import Fraction
@@ -252,6 +254,340 @@ _cache_size_units = {
252
254
  'gb': 1024 ** 3,
253
255
  }
254
256
 
257
+ # Bumped whenever the on-disk .index format changes in a way that makes
258
+ # previously written index files unusable or obsolete. Index files without
259
+ # this key, or with an older value, are discarded and rebuilt.
260
+ _index_format_version = 2
261
+
262
+ # Framerates that real videos are actually shot at, including the awkward
263
+ # 1000/1001 ("NTSC") rates. A short clip's timestamps often can't distinguish
264
+ # the true framerate from some nearby fraction that happens to reproduce them
265
+ # just as exactly, so when we have to work the framerate out from the
266
+ # timestamps we check these first and report one of them if it fits. That way
267
+ # a 29.97 fps video is reported as 30000/1001 rather than as whatever odd
268
+ # fraction happens to land on the same timestamps.
269
+ _common_framerates = (
270
+ Fraction(24000, 1001), Fraction(24), Fraction(25),
271
+ Fraction(30000, 1001), Fraction(30), Fraction(48), Fraction(50),
272
+ Fraction(60000, 1001), Fraction(60), Fraction(100),
273
+ Fraction(120000, 1001), Fraction(120), Fraction(240),
274
+ )
275
+
276
+
277
+ def _round_half_away_from_zero(value: Fraction) -> int:
278
+ """
279
+ Round a `Fraction` to the nearest integer, breaking ties away from zero.
280
+
281
+ ffmpeg rounds this way (its `AV_ROUND_NEAR_INF` mode) when it converts a
282
+ frame's ideal presentation time into an integer timestamp in the
283
+ container's time base, so this is the convention that must be used to
284
+ reproduce the timestamps ffmpeg wrote. Python's built-in `round()` breaks
285
+ ties toward the nearest even integer instead, which disagrees whenever a
286
+ frame's ideal time lands exactly halfway between two ticks: a 16 fps webm
287
+ stores its second frame at tick 63, whereas `round(Fraction(125, 2))`
288
+ gives 62.
289
+
290
+ Parameters
291
+ ----------
292
+ value : Fraction
293
+ The value to round.
294
+
295
+ Returns
296
+ -------
297
+ int
298
+ `value` rounded to the nearest integer, with exact halves rounded
299
+ away from zero.
300
+ """
301
+ numerator, denominator = value.numerator, value.denominator
302
+ if numerator >= 0:
303
+ return (2 * numerator + denominator) // (2 * denominator)
304
+ return -((-2 * numerator + denominator) // (2 * denominator))
305
+
306
+
307
+ class ConstantFrameratePTS:
308
+ """
309
+ The presentation timestamps (PTS) of a constant-framerate video, stored
310
+ as a formula rather than as an explicit list of values.
311
+
312
+ Frame `i` of a constant-framerate video is shown `i / framerate` seconds
313
+ after the first frame, but a container can only store a timestamp as a
314
+ whole number of ticks of its `time_base`, so the PTS actually written to
315
+ the file is that ideal time rounded to the nearest tick:
316
+
317
+ pts[i] = pts0 + round(i / framerate / time_base)
318
+
319
+ When one frame spans a whole number of ticks the rounding does nothing and
320
+ consecutive PTS values come out evenly spaced. This is the case for a
321
+ typical mp4, whose `time_base` is chosen to divide evenly by the framerate
322
+ (at 30 fps with a time_base of 1/15360, every frame is exactly 512 ticks).
323
+
324
+ When one frame does not span a whole number of ticks, the rounding makes
325
+ consecutive PTS values differ by a tick here and there even though the
326
+ video is perfectly constant-framerate. This is unavoidable for webm and
327
+ other Matroska files, whose `time_base` is 1/1000: at 30 fps a frame lasts
328
+ 100/3 ticks, so the stored timestamps go 0, 33, 67, 100, 133, ... and the
329
+ gaps between them alternate between 33 and 34. Such a video is still
330
+ constant-framerate in every sense that matters here, because the timestamps
331
+ are still fully described by the formula above.
332
+
333
+ This class represents both cases exactly. It behaves like an immutable
334
+ sequence of the video's PTS values, so it can be indexed, sliced,
335
+ iterated, and searched just like the plain list of PTS values that
336
+ `VideoStreamer` uses for a variable-framerate video. Unlike that list it
337
+ stores only four numbers no matter how long the video is, and it can
338
+ convert a PTS back to a frame number by arithmetic instead of by scanning.
339
+ """
340
+ def __init__(self,
341
+ pts0: int,
342
+ framerate: Union[int, Fraction],
343
+ time_base: Fraction,
344
+ n_frames: int):
345
+ """
346
+ Parameters
347
+ ----------
348
+ pts0 : int
349
+ The PTS of the first frame.
350
+
351
+ framerate : int or Fraction
352
+ Frames per second.
353
+
354
+ time_base : Fraction
355
+ The duration of one PTS tick, in seconds.
356
+
357
+ n_frames : int
358
+ The number of frames in the video.
359
+ """
360
+ self.pts0 = int(pts0)
361
+ self.framerate = Fraction(framerate)
362
+ self.time_base = Fraction(time_base)
363
+ self.n_frames = int(n_frames)
364
+ if self.framerate <= 0:
365
+ raise ValueError(f'framerate must be positive, got {framerate}')
366
+ if self.n_frames < 0:
367
+ raise ValueError(f'n_frames must not be negative, got {n_frames}')
368
+ # The number of ticks one frame lasts. Not necessarily a whole number,
369
+ # which is the entire reason this class exists.
370
+ self.ticks_per_frame = 1 / (self.framerate * self.time_base)
371
+
372
+ def _pts_at(self, frame_number: int) -> int:
373
+ """
374
+ Evaluate the PTS formula at a single frame number, which must already
375
+ be normalized to lie in `range(self.n_frames)`.
376
+ """
377
+ return self.pts0 + _round_half_away_from_zero(frame_number
378
+ * self.ticks_per_frame)
379
+
380
+ def as_array(self) -> np.ndarray:
381
+ """
382
+ Evaluate the PTS formula at every frame number at once.
383
+
384
+ Returns
385
+ -------
386
+ np.ndarray
387
+ The video's PTS values, as an array of `self.n_frames` integers.
388
+ """
389
+ numerator = self.ticks_per_frame.numerator
390
+ denominator = self.ticks_per_frame.denominator
391
+ # The vectorized form of _round_half_away_from_zero(i * ticks_per_frame),
392
+ # relying on i, numerator, and denominator all being positive. numpy
393
+ # integers wrap around silently on overflow instead of raising, so fall
394
+ # back to (slower) arbitrary-precision python ints if int64 can't hold
395
+ # the largest intermediate value this would compute.
396
+ largest_intermediate = 2 * max(self.n_frames - 1, 0) * numerator + denominator
397
+ if largest_intermediate > np.iinfo(np.int64).max:
398
+ return np.array([self._pts_at(i) for i in range(self.n_frames)],
399
+ dtype=object)
400
+ frame_numbers = np.arange(self.n_frames, dtype=np.int64)
401
+ return self.pts0 + ((2 * frame_numbers * numerator + denominator)
402
+ // (2 * denominator))
403
+
404
+ def reproduces(self, frames_pts: Union[list, np.ndarray]) -> bool:
405
+ """
406
+ Check whether this formula reproduces a list of PTS values exactly.
407
+
408
+ This is the test that decides whether a video counts as constant
409
+ framerate. It is an exact test, not an approximate one: every single
410
+ timestamp must come out bit-for-bit identical, and the timestamps must
411
+ be strictly increasing (so that each PTS maps back to exactly one
412
+ frame number).
413
+
414
+ Parameters
415
+ ----------
416
+ frames_pts : list or np.ndarray
417
+ The PTS values read out of the video file, in presentation order.
418
+
419
+ Returns
420
+ -------
421
+ bool
422
+ True if `pts[i] == pts0 + round(i / framerate / time_base)` holds
423
+ for every frame, and the values strictly increase.
424
+ """
425
+ if len(frames_pts) != self.n_frames:
426
+ return False
427
+ reconstructed = self.as_array()
428
+ if not np.array_equal(reconstructed, np.asarray(frames_pts)):
429
+ return False
430
+ return bool(self.n_frames < 2 or (np.diff(reconstructed) > 0).all())
431
+
432
+ def __len__(self) -> int:
433
+ return self.n_frames
434
+
435
+ def __getitem__(self, key) -> Union[int, list]:
436
+ if isinstance(key, slice):
437
+ return [self._pts_at(i) for i in range(*key.indices(self.n_frames))]
438
+ if not np.issubdtype(type(key), np.integer):
439
+ raise TypeError(f'Frame number must be an int or slice, got '
440
+ f'{type(key).__name__}')
441
+ frame_number = int(key)
442
+ if frame_number < 0:
443
+ frame_number += self.n_frames
444
+ if not 0 <= frame_number < self.n_frames:
445
+ raise IndexError(f'Frame number {key} is out of range for a video '
446
+ f'with {self.n_frames} frames.')
447
+ return self._pts_at(frame_number)
448
+
449
+ def __iter__(self) -> Iterator[int]:
450
+ return (self._pts_at(i) for i in range(self.n_frames))
451
+
452
+ def index(self, pts: int) -> int:
453
+ """
454
+ Find the frame number whose PTS is `pts`, in constant time.
455
+
456
+ Inverting the PTS formula gives a frame number that is off by at most
457
+ one from the true one (the rounding in the formula moves a timestamp by
458
+ less than a tick, and a frame lasts at least a tick in any video whose
459
+ timestamps strictly increase), so it is enough to check the estimate
460
+ and its two neighbors.
461
+
462
+ Parameters
463
+ ----------
464
+ pts : int
465
+ The PTS to look up.
466
+
467
+ Returns
468
+ -------
469
+ int
470
+ The frame number with this PTS.
471
+
472
+ Raises
473
+ ------
474
+ ValueError
475
+ If no frame has this PTS.
476
+ """
477
+ pts = int(pts)
478
+ estimate = int((pts - self.pts0) / self.ticks_per_frame)
479
+ for frame_number in (estimate - 1, estimate, estimate + 1):
480
+ if 0 <= frame_number < self.n_frames and self._pts_at(frame_number) == pts:
481
+ return frame_number
482
+ raise ValueError(f'PTS {pts} is not in this video.')
483
+
484
+ def __contains__(self, pts) -> bool:
485
+ try:
486
+ self.index(pts)
487
+ except (ValueError, TypeError):
488
+ return False
489
+ return True
490
+
491
+ def __eq__(self, other) -> bool:
492
+ if isinstance(other, ConstantFrameratePTS):
493
+ return (self.pts0 == other.pts0
494
+ and self.framerate == other.framerate
495
+ and self.time_base == other.time_base
496
+ and self.n_frames == other.n_frames)
497
+ if isinstance(other, (list, tuple)):
498
+ return list(self) == list(other)
499
+ return NotImplemented
500
+
501
+ def __repr__(self) -> str:
502
+ return (f'{type(self).__name__}(pts0={self.pts0}, '
503
+ f'framerate={self.framerate}, time_base={self.time_base}, '
504
+ f'n_frames={self.n_frames})')
505
+
506
+
507
+ def _detect_constant_framerate(frames_pts: list,
508
+ time_base: Fraction,
509
+ declared_framerate=None
510
+ ) -> Optional[ConstantFrameratePTS]:
511
+ """
512
+ Determine whether a video's PTS values are exactly described by a constant
513
+ framerate, and if so, by which one.
514
+
515
+ A video counts as constant framerate when some framerate `r` satisfies
516
+ `pts[i] == pts0 + round(i / r / time_base)` for every frame (see
517
+ `ConstantFrameratePTS`). Rather than solving for `r`, this tries a short
518
+ list of plausible candidates and verifies each one against every timestamp
519
+ in the video, so a candidate is only ever accepted on the strength of
520
+ exactly reproducing the data. A framerate the container declares but does
521
+ not honor is therefore rejected, as is a framerate derived here that turns
522
+ out not to fit.
523
+
524
+ Parameters
525
+ ----------
526
+ frames_pts : list of int
527
+ The video's PTS values, in presentation order.
528
+
529
+ time_base : Fraction
530
+ The duration of one PTS tick, in seconds.
531
+
532
+ declared_framerate : int, Fraction, or None, default None
533
+ The framerate the container claims, if it claims one. Used only as a
534
+ candidate to be verified, never trusted on its own.
535
+
536
+ Returns
537
+ -------
538
+ ConstantFrameratePTS or None
539
+ A formula that exactly reproduces `frames_pts`, or None if the video
540
+ is genuinely variable framerate.
541
+ """
542
+ n_frames = len(frames_pts)
543
+ if n_frames == 0:
544
+ return None
545
+ if n_frames == 1:
546
+ # A single frame is trivially consistent with any framerate. Pick the
547
+ # declared one if there is one so that `framerate` is still reported.
548
+ framerate = Fraction(declared_framerate) if declared_framerate else Fraction(1)
549
+ return ConstantFrameratePTS(frames_pts[0], framerate, time_base, 1)
550
+
551
+ candidates = []
552
+ if declared_framerate:
553
+ candidates.append(Fraction(declared_framerate))
554
+
555
+ # The framerate implied by the gap between the first two frames. This is
556
+ # the exact answer whenever a frame spans a whole number of ticks, which
557
+ # covers the evenly-spaced case that mp4 files fall into.
558
+ first_gap = frames_pts[1] - frames_pts[0]
559
+ if first_gap > 0:
560
+ candidates.append(1 / (first_gap * time_base))
561
+
562
+ # The average framerate across the whole video, which for a constant
563
+ # framerate video lands within a rounding error of the true rate. Snapping
564
+ # it to a nearby standard framerate, and failing that to a nearby simple
565
+ # fraction, recovers the true rate. Standard framerates are tried first
566
+ # because a short video's timestamps can be reproduced exactly by more than
567
+ # one framerate, and in that case the standard one is the right answer to
568
+ # report.
569
+ span = frames_pts[-1] - frames_pts[0]
570
+ if span > 0:
571
+ average = Fraction(n_frames - 1) / (span * time_base)
572
+ nearby = sorted((framerate for framerate in _common_framerates
573
+ if abs(framerate - average) < average / 1000),
574
+ key=lambda framerate: abs(framerate - average))
575
+ candidates.extend(nearby)
576
+ for largest_denominator in (1, 1001, 100000):
577
+ candidates.append(average.limit_denominator(largest_denominator))
578
+
579
+ checked = set()
580
+ for framerate in candidates:
581
+ framerate = Fraction(framerate)
582
+ if framerate <= 0 or framerate in checked:
583
+ continue
584
+ checked.add(framerate)
585
+ candidate = ConstantFrameratePTS(frames_pts[0], framerate,
586
+ time_base, n_frames)
587
+ if candidate.reproduces(frames_pts):
588
+ return candidate
589
+ return None
590
+
255
591
 
256
592
  def _parse_cache_size(cache_size: Optional[Union[int, str]]
257
593
  ) -> Tuple[Optional[int], Optional[int]]:
@@ -318,6 +654,8 @@ class VideoStreamer:
318
654
  next to the video file for faster loading next time.
319
655
  If 'auto', the index is cached only if building it takes
320
656
  more than 0.5 seconds, which only happens for ~1+ GB videos.
657
+ An index file written by an older version of npimage is
658
+ discarded and rebuilt.
321
659
 
322
660
  cache_size : int, str, or None, default '256MB'
323
661
  Sets the size of an in-memory cache of decoded frames. The cache
@@ -382,8 +720,8 @@ class VideoStreamer:
382
720
  self.t = _VideoStreamerTimeIndexer(self)
383
721
 
384
722
  def _build_index(self, cache_index='auto'):
385
- if cache_index and self.index_filename.exists():
386
- return self._load_index()
723
+ if cache_index and self.index_filename.exists() and self._load_index():
724
+ return
387
725
  if self.verbose:
388
726
  print('Building frame timestamp index for fast random frame access...')
389
727
 
@@ -438,37 +776,38 @@ class VideoStreamer:
438
776
  self.n_frames = len(frames_pts)
439
777
  self.pts0 = frames_pts[0]
440
778
  self.rotation = _get_rotation_from_metadata(self.filename)
441
- index = {}
442
-
443
- # Determine whether the video is constant or variable framerate
444
- pts_deltas = np.diff(frames_pts) if len(frames_pts) > 1 else None
445
- if pts_deltas is not None and (pts_deltas == pts_deltas[0]).all():
779
+ index = {'index_format_version': _index_format_version}
780
+
781
+ # Determine whether the video is constant or variable framerate. The
782
+ # video counts as constant framerate if some single framerate exactly
783
+ # reproduces every one of its timestamps, which is a weaker condition
784
+ # than the timestamps being evenly spaced (see ConstantFrameratePTS)
785
+ # but still an exact one.
786
+ declared_framerate = getattr(self.stream, 'average_rate', None)
787
+ constant_framerate_pts = _detect_constant_framerate(
788
+ frames_pts, self.time_base, declared_framerate)
789
+ if constant_framerate_pts is not None:
446
790
  # The video is constant framerate
447
- self.pts_delta = int(pts_deltas[0])
448
- self._framerate = 1 / (self.pts_delta * self.time_base)
791
+ self.frames_pts = constant_framerate_pts
792
+ self._framerate = constant_framerate_pts.framerate
449
793
  if self._framerate.denominator == 1:
450
794
  self._framerate = self._framerate.numerator
451
795
  index['framerate'] = self._framerate
452
796
  else:
453
797
  index['framerate'] = {'numerator': self._framerate.numerator,
454
798
  'denominator': self._framerate.denominator}
455
- self.frames_pts = range(self.pts0,
456
- self.pts0 + self.pts_delta * self.n_frames,
457
- self.pts_delta)
799
+ index['pts0'] = self.pts0
458
800
  else:
459
801
  # The video is variable framerate
460
802
  self._framerate = 'variable'
461
803
  index['framerate'] = 'variable'
462
804
  self.frames_pts = frames_pts
805
+ index['frames_pts'] = frames_pts
463
806
 
464
807
  index['n_frames'] = self.n_frames
465
808
  index['rotation'] = self.rotation
466
809
  index['time_base'] = {'numerator': self.time_base.numerator,
467
810
  'denominator': self.time_base.denominator}
468
- if self._framerate == 'variable':
469
- index['frames_pts'] = frames_pts
470
- else:
471
- index['pts0'] = self.pts0
472
811
 
473
812
  if cache_index is True or (cache_index == 'auto'
474
813
  and time.time() - start_time > 0.5):
@@ -477,37 +816,111 @@ class VideoStreamer:
477
816
  if self.verbose:
478
817
  print(f'Cached index at "{self.index_filename}"')
479
818
 
480
- def _load_index(self):
819
+ def _load_index(self) -> bool:
820
+ """
821
+ Load a previously cached frame timestamp index.
822
+
823
+ Returns
824
+ -------
825
+ bool
826
+ True if the index was loaded. False if it was written by an older
827
+ version of npimage or is unreadable, in which case the caller
828
+ should rebuild it from the video file.
829
+ """
481
830
  if self.verbose:
482
831
  print(f'Loading frame timestamp index from "{self.index_filename}"')
483
832
 
484
- with open(self.index_filename, 'r') as f:
485
- index = json.load(f)
486
- self.n_frames = index['n_frames']
487
- self.rotation = index.get('rotation', None)
833
+ try:
834
+ with open(self.index_filename, 'r') as f:
835
+ index = json.load(f)
836
+ if index.get('index_format_version') != _index_format_version:
837
+ if self.verbose:
838
+ print(f'Index at "{self.index_filename}" was written in an'
839
+ ' outdated format. Rebuilding it.')
840
+ return False
841
+ self.n_frames = index['n_frames']
842
+ self.rotation = index.get('rotation', None)
843
+ time_base = index['time_base']
844
+ self.time_base = Fraction(time_base['numerator'],
845
+ time_base['denominator'])
846
+
847
+ if index['framerate'] == 'variable':
848
+ self._framerate = 'variable'
849
+ self.frames_pts = index['frames_pts']
850
+ self.pts0 = self.frames_pts[0]
851
+ else:
852
+ self.pts0 = index['pts0']
853
+ if isinstance(index['framerate'], dict):
854
+ self._framerate = Fraction(index['framerate']['numerator'],
855
+ index['framerate']['denominator'])
856
+ else:
857
+ self._framerate = index['framerate']
858
+ self.frames_pts = ConstantFrameratePTS(self.pts0, self._framerate,
859
+ self.time_base, self.n_frames)
860
+ except (json.JSONDecodeError, KeyError, IndexError, TypeError, ValueError) as e:
861
+ if self.verbose:
862
+ print(f'Could not read index at "{self.index_filename}" ({e}).'
863
+ ' Rebuilding it.')
864
+ return False
865
+ return True
488
866
 
489
- # Load time_base and pts_values
490
- time_base_data = index['time_base']
491
- self.time_base = Fraction(time_base_data['numerator'], time_base_data['denominator'])
867
+ @property
868
+ def ticks_per_frame(self) -> Fraction:
869
+ """
870
+ How long one frame lasts, in PTS ticks, as an exact `Fraction`.
871
+
872
+ This is not necessarily a whole number: a 30 fps webm has a time_base
873
+ of 1/1000, so each of its frames lasts 100/3 ticks. The PTS actually
874
+ stored for a frame is this ideal spacing rounded to a whole number of
875
+ ticks, so use `frame_number_to_pts()` to get a frame's real PTS rather
876
+ than multiplying by this.
877
+
878
+ Raises
879
+ ------
880
+ AttributeError
881
+ If the video is variable framerate, in which case its frames have
882
+ no single duration.
883
+ """
884
+ if self._framerate == 'variable':
885
+ raise AttributeError('A variable-framerate video has no single '
886
+ 'ticks_per_frame. Use frame_number_to_pts() '
887
+ 'instead.')
888
+ return self.frames_pts.ticks_per_frame
492
889
 
493
- if index['framerate'] == 'variable':
494
- self._framerate = 'variable'
495
- self.frames_pts = index['frames_pts']
496
- else:
497
- self.pts0 = index['pts0']
498
- if np.issubdtype(type(index['framerate']), np.integer):
499
- self._framerate = index['framerate']
500
- else:
501
- self._framerate = Fraction(index['framerate']['numerator'],
502
- index['framerate']['denominator'])
503
- self.pts_delta = 1 / self.time_base / self._framerate
504
- if self.pts_delta.denominator != 1:
505
- raise ValueError('pts_delta does not appear to be an integer. This is'
506
- ' unexpected and may indicate a malformed index.')
507
- self.pts_delta = self.pts_delta.numerator
508
- self.frames_pts = range(self.pts0,
509
- self.pts0 + self.pts_delta * self.n_frames,
510
- self.pts_delta)
890
+ @property
891
+ def pts_delta(self) -> int:
892
+ """
893
+ The whole number of PTS ticks between consecutive frames.
894
+
895
+ This only exists for a video whose frames each last a whole number of
896
+ ticks, which is the case when the container's time_base divides evenly
897
+ by the framerate (as a typical mp4's does). Such a video's PTS values
898
+ are evenly spaced, so `pts0 + frame_number * pts_delta` is a valid way
899
+ to compute them.
900
+
901
+ It deliberately does not exist for a video whose frames do not last a
902
+ whole number of ticks, such as any 30 fps webm (whose time_base is
903
+ 1/1000, giving 100/3 ticks per frame). Such a video's PTS values are
904
+ not evenly spaced, so there is no correct integer to return here, and
905
+ computing timestamps by repeatedly adding one would drift away from the
906
+ real timestamps. Use `frame_number_to_pts()`, which is exact for every
907
+ video, or `ticks_per_frame` for the exact fractional frame duration.
908
+
909
+ Raises
910
+ ------
911
+ AttributeError
912
+ If the video is variable framerate, or if its frames do not last a
913
+ whole number of ticks.
914
+ """
915
+ ticks_per_frame = self.ticks_per_frame
916
+ if ticks_per_frame.denominator != 1:
917
+ raise AttributeError(
918
+ f'The frames of this video each last {ticks_per_frame} PTS '
919
+ f'ticks, which is not a whole number, so its PTS values are '
920
+ f'not evenly spaced and it has no integer pts_delta. Use '
921
+ f'frame_number_to_pts() to get a frame\'s exact PTS, or '
922
+ f'ticks_per_frame for the exact frame duration.')
923
+ return ticks_per_frame.numerator
511
924
 
512
925
  @property
513
926
  def framerate(self) -> Union[float, Literal['variable']]:
@@ -558,10 +971,7 @@ class VideoStreamer:
558
971
  if hasattr(frame_number, '__iter__'):
559
972
  return [self.frame_number_to_pts(n) for n in frame_number]
560
973
  frame_number = self._normalize_frame_number(frame_number)
561
- if self._framerate == 'variable':
562
- return self.frames_pts[frame_number]
563
- else:
564
- return int(frame_number) * self.pts_delta + self.pts0
974
+ return int(self.frames_pts[frame_number])
565
975
 
566
976
  def frame_number_to_time(self, frame_number: int) -> float:
567
977
  if hasattr(frame_number, '__iter__'):
@@ -571,20 +981,20 @@ class VideoStreamer:
571
981
  def pts_to_frame_number(self, pts: int) -> int:
572
982
  if hasattr(pts, '__iter__'):
573
983
  return [self.pts_to_frame_number(p) for p in pts]
574
- if self._framerate == 'variable':
575
- if pts not in self.frames_pts:
576
- raise ValueError(f'PTS {pts} not in video index.')
984
+ first_pts, last_pts = self.frames_pts[0], self.frames_pts[-1]
985
+ if pts < first_pts:
986
+ raise ValueError(f'PTS {pts} is before the start of the'
987
+ f' video (PTS {first_pts}).')
988
+ if pts > last_pts:
989
+ raise ValueError(f'PTS {pts} is after the end of the'
990
+ f' video (PTS {last_pts}).')
991
+ try:
992
+ # O(1) for a constant-framerate video, O(n_frames) for a
993
+ # variable-framerate one, whose PTS values are a plain list.
577
994
  return self.frames_pts.index(pts)
578
- else:
579
- if pts < self.pts0:
580
- raise ValueError(f'PTS {pts} is before the start of the'
581
- f' video (PTS {self.pts0}).')
582
- if pts > self.pts_delta * (self.n_frames - 1) + self.pts0:
583
- raise ValueError(f'PTS {pts} is after the end of the video (PTS '
584
- f'{self.pts_delta * (self.n_frames - 1) + self.pts0}).')
585
- if (pts - self.pts0) % self.pts_delta != 0:
586
- raise ValueError(f'PTS {pts} is between frames for this video.')
587
- return (pts - self.pts0) // self.pts_delta
995
+ except ValueError:
996
+ raise ValueError(f'PTS {pts} is between frames for this'
997
+ ' video.') from None
588
998
 
589
999
  def __getitem__(self, key) -> np.ndarray:
590
1000
  if np.issubdtype(type(key), np.integer):
@@ -860,12 +1270,12 @@ class _VideoStreamerTimeIndexer:
860
1270
  # if float arithmetic has nudged the converted value below the
861
1271
  # stored integer PTS.
862
1272
  target_pts = time / float(s.time_base) + 0.5
863
- if s._framerate == 'variable':
864
- # Largest index i with s.frames_pts[i] <= target_pts.
865
- frame_number = bisect.bisect_right(s.frames_pts, target_pts) - 1
866
- else:
867
- offset = target_pts - s.pts0
868
- frame_number = math.floor(offset / s.pts_delta)
1273
+ # Largest index i with s.frames_pts[i] <= target_pts. PTS values
1274
+ # increase with frame number whether s.frames_pts is the plain list of
1275
+ # a variable-framerate video or the ConstantFrameratePTS formula of a
1276
+ # constant-framerate one, and bisect only needs indexing and a length,
1277
+ # so the same search works for both.
1278
+ frame_number = bisect.bisect_right(s.frames_pts, target_pts) - 1
869
1279
  # The bound checks above guarantee frame_number lands in
870
1280
  # [0, n_frames - 1] under exact arithmetic. Clamp defensively for
871
1281
  # the float-roundoff edge at exactly time == end_of_playback - eps.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: numpyimage
3
- Version: 3.9.0
3
+ Version: 3.10.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
@@ -4,7 +4,7 @@ build-backend = 'setuptools.build_meta'
4
4
 
5
5
  [project]
6
6
  name = 'numpyimage'
7
- version = '3.9.0'
7
+ version = '3.10.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'
@@ -3,14 +3,17 @@ Tests for VideoStreamer, particularly handling of HEVC videos with
3
3
  negative-PTS priming packets (common in iPhone recordings).
4
4
  """
5
5
 
6
+ import json
6
7
  import subprocess
8
+ from fractions import Fraction
7
9
  from pathlib import Path
8
10
 
9
11
  import numpy as np
10
12
  import pytest
11
13
 
12
14
  import npimage
13
- from npimage.vidio import _parse_cache_size
15
+ from npimage.vidio import (ConstantFrameratePTS, _detect_constant_framerate,
16
+ _parse_cache_size, _round_half_away_from_zero)
14
17
 
15
18
  TESTS_DIR = Path(__file__).parent
16
19
  SPINNING_MP4 = TESTS_DIR / 'table-tennis-emoji-spinning.mp4'
@@ -505,3 +508,287 @@ def test_cache_hit_serves_without_redecoding():
505
508
  finally:
506
509
  reference.close()
507
510
  vid.close()
511
+
512
+
513
+ # ----------------------------------------------------------------------------
514
+ # Constant framerate detection with a time base that can't represent the
515
+ # framerate exactly (every webm, whose time_base is 1/1000)
516
+ # ----------------------------------------------------------------------------
517
+
518
+ def make_webm(path, framerate, duration=2, extra_arguments=None):
519
+ """
520
+ Write a small webm at a given framerate and return its path.
521
+
522
+ webm/Matroska always uses a time_base of 1/1000, so at most framerates a
523
+ frame does not last a whole number of ticks and the stored PTS values come
524
+ out unevenly spaced even though the video is constant framerate.
525
+ """
526
+ command = ['ffmpeg', '-y',
527
+ '-f', 'lavfi',
528
+ '-i', f'testsrc=size=64x48:rate={framerate}',
529
+ '-t', str(duration)]
530
+ command += extra_arguments or []
531
+ command += ['-c:v', 'libvpx', '-b:v', '100k',
532
+ '-cpu-used', '8', '-deadline', 'realtime',
533
+ str(path)]
534
+ result = subprocess.run(command, capture_output=True, text=True)
535
+ assert result.returncode == 0, f'ffmpeg failed: {result.stderr}'
536
+ return path
537
+
538
+
539
+ def demuxed_pts(path):
540
+ """The PTS values stored in a video file, read straight out of it."""
541
+ import av
542
+
543
+ with av.open(str(path)) as container:
544
+ stream = container.streams.video[0]
545
+ return sorted(packet.pts for packet in container.demux(stream)
546
+ if packet.pts is not None)
547
+
548
+
549
+ def test_round_half_away_from_zero():
550
+ """
551
+ We must round the way ffmpeg does (AV_ROUND_NEAR_INF), not the way python's
552
+ round() does (half to even), or we fail to reproduce the PTS of any video
553
+ whose frames land exactly halfway between two ticks.
554
+ """
555
+ assert _round_half_away_from_zero(Fraction(125, 2)) == 63 # round() gives 62
556
+ assert _round_half_away_from_zero(Fraction(375, 2)) == 188
557
+ assert _round_half_away_from_zero(Fraction(1, 2)) == 1 # round() gives 0
558
+ assert _round_half_away_from_zero(Fraction(-1, 2)) == -1
559
+ assert _round_half_away_from_zero(Fraction(100, 3)) == 33
560
+ assert _round_half_away_from_zero(Fraction(200, 3)) == 67
561
+ assert _round_half_away_from_zero(Fraction(62)) == 62
562
+
563
+
564
+ def test_constant_framerate_pts_models_webm_style_rounding():
565
+ """
566
+ 30 fps in a 1/1000 time base: frames last 100/3 ticks, so the stored PTS
567
+ are the ideal times rounded to whole ticks and the gaps between them
568
+ alternate between 33 and 34.
569
+ """
570
+ pts = ConstantFrameratePTS(pts0=0, framerate=30,
571
+ time_base=Fraction(1, 1000), n_frames=9)
572
+ assert list(pts) == [0, 33, 67, 100, 133, 167, 200, 233, 267]
573
+ assert sorted(set(np.diff(list(pts)))) == [33, 34]
574
+ assert len(pts) == 9
575
+ assert pts[0] == 0 and pts[4] == 133
576
+ assert pts[-1] == 267 # negative indices count back from the end
577
+ assert pts[2:5] == [67, 100, 133]
578
+ with pytest.raises(IndexError):
579
+ pts[9]
580
+
581
+
582
+ def test_constant_framerate_pts_is_exact_for_evenly_spaced_video():
583
+ """
584
+ A time base that divides evenly by the framerate (typical of mp4) makes the
585
+ rounding a no-op, so the same formula still describes it.
586
+ """
587
+ pts = ConstantFrameratePTS(pts0=1024, framerate=30,
588
+ time_base=Fraction(1, 15360), n_frames=5)
589
+ assert list(pts) == [1024, 1536, 2048, 2560, 3072] # 512 ticks per frame
590
+ assert pts.ticks_per_frame == 512
591
+
592
+
593
+ def test_constant_framerate_pts_index_is_exact():
594
+ """PTS -> frame number must invert the rounded formula exactly."""
595
+ pts = ConstantFrameratePTS(pts0=0, framerate=30,
596
+ time_base=Fraction(1, 1000), n_frames=300)
597
+ for frame_number, value in enumerate(pts):
598
+ assert pts.index(value) == frame_number
599
+ assert value in pts
600
+ # A PTS that falls between two frames belongs to no frame.
601
+ assert 34 not in pts
602
+ with pytest.raises(ValueError):
603
+ pts.index(34)
604
+
605
+
606
+ def test_detect_constant_framerate_accepts_quantized_grid():
607
+ """The rounded 30 fps grid is recognized as constant framerate."""
608
+ frames_pts = [0, 33, 67, 100, 133, 167, 200, 233, 267, 300]
609
+ detected = _detect_constant_framerate(frames_pts, Fraction(1, 1000))
610
+ assert detected is not None
611
+ assert detected.framerate == 30
612
+ assert list(detected) == frames_pts
613
+
614
+
615
+ def test_detect_constant_framerate_rejects_genuinely_variable():
616
+ """Timestamps that no single framerate reproduces stay variable."""
617
+ frames_pts = [0, 33, 100, 133, 233, 267] # frames dropped partway through
618
+ assert _detect_constant_framerate(frames_pts, Fraction(1, 1000)) is None
619
+
620
+
621
+ def test_detect_constant_framerate_rejects_wrong_declared_framerate():
622
+ """
623
+ A framerate the container claims but does not honor is rejected: the
624
+ declared rate is only ever a candidate, and it has to reproduce every
625
+ timestamp to be accepted.
626
+ """
627
+ frames_pts = [0, 33, 100, 133, 233, 267]
628
+ assert _detect_constant_framerate(frames_pts, Fraction(1, 1000),
629
+ declared_framerate=30) is None
630
+
631
+
632
+ def test_detect_constant_framerate_without_declared_framerate():
633
+ """The framerate is recovered from the timestamps alone."""
634
+ frames_pts = list(ConstantFrameratePTS(0, Fraction(30000, 1001),
635
+ Fraction(1, 1000), 200))
636
+ detected = _detect_constant_framerate(frames_pts, Fraction(1, 1000),
637
+ declared_framerate=None)
638
+ assert detected is not None
639
+ assert detected.framerate == Fraction(30000, 1001)
640
+
641
+
642
+ @pytest.mark.slow
643
+ @pytest.mark.parametrize('framerate', [30, 16, 24, 60])
644
+ def test_webm_is_constant_framerate(tmp_path, framerate):
645
+ """
646
+ A webm at a framerate its 1/1000 time base cannot represent exactly is
647
+ still reported as constant framerate, and the formula we store reproduces
648
+ every timestamp in the file bit for bit.
649
+
650
+ 16 fps is the interesting case: its frames last exactly 62.5 ticks, so
651
+ every other frame lands on an exact half tick and only ffmpeg's rounding
652
+ convention reproduces it.
653
+ """
654
+ path = make_webm(tmp_path / f'{framerate}fps.webm', framerate)
655
+ vid = npimage.VideoStreamer(str(path), cache_index=False)
656
+ try:
657
+ assert vid.framerate == float(framerate)
658
+ assert isinstance(vid.frames_pts, ConstantFrameratePTS)
659
+ expected = demuxed_pts(path)
660
+ modeled = [vid.frame_number_to_pts(i) for i in range(vid.n_frames)]
661
+ assert modeled == expected
662
+ # And every PTS maps back to the frame it came from.
663
+ assert [vid.pts_to_frame_number(pts) for pts in expected] \
664
+ == list(range(vid.n_frames))
665
+ finally:
666
+ vid.close()
667
+
668
+
669
+ def test_pts_delta_is_an_int_for_evenly_spaced_video(spinning_streamer):
670
+ """
671
+ A video whose frames last a whole number of ticks keeps the plain integer
672
+ pts_delta that callers may already be relying on.
673
+ """
674
+ vid = spinning_streamer
675
+ assert isinstance(vid.pts_delta, int)
676
+ assert vid.ticks_per_frame == vid.pts_delta
677
+ assert vid.frame_number_to_pts(3) == vid.pts0 + 3 * vid.pts_delta
678
+
679
+
680
+ @pytest.mark.slow
681
+ def test_pts_delta_refuses_to_exist_for_unevenly_spaced_video(tmp_path):
682
+ """
683
+ A 30 fps webm's frames last 100/3 ticks, so its PTS are not evenly spaced
684
+ and no integer pts_delta is correct. Returning one anyway would invite
685
+ callers to compute timestamps as pts0 + i * pts_delta, which would drift
686
+ (0, 33, 66, 99, ... instead of 0, 33, 67, 100, ...), so we raise instead.
687
+ """
688
+ path = make_webm(tmp_path / 'delta.webm', 30, duration=2)
689
+ vid = npimage.VideoStreamer(str(path), cache_index=False)
690
+ try:
691
+ assert vid.ticks_per_frame == Fraction(100, 3)
692
+ with pytest.raises(AttributeError, match='not a whole number'):
693
+ vid.pts_delta
694
+ # The exact route is always available, and it does not drift.
695
+ assert [vid.frame_number_to_pts(i) for i in range(4)] == [0, 33, 67, 100]
696
+ finally:
697
+ vid.close()
698
+
699
+
700
+ @pytest.mark.slow
701
+ def test_webm_random_access_returns_correct_frames(tmp_path):
702
+ """
703
+ The frames handed back by random access on a webm are the same pixels a
704
+ plain sequential decode produces. This is what the PTS bookkeeping is for:
705
+ if the modeled PTS were off by even one tick, seeking would land on the
706
+ wrong frame.
707
+ """
708
+ import av
709
+
710
+ path = make_webm(tmp_path / 'access.webm', 30, duration=4)
711
+ ground_truth = []
712
+ with av.open(str(path)) as container:
713
+ stream = container.streams.video[0]
714
+ for frame in container.decode(stream):
715
+ ground_truth.append(frame.to_ndarray(format='rgb24'))
716
+
717
+ # Caching off, and a jumbled access order, so that every read really goes
718
+ # through the seek-and-decode-forward path.
719
+ vid = npimage.VideoStreamer(str(path), cache_index=False, cache_size=None)
720
+ try:
721
+ assert vid.n_frames == len(ground_truth)
722
+ frame_numbers = list(range(vid.n_frames))
723
+ np.random.default_rng(0).shuffle(frame_numbers)
724
+ for frame_number in frame_numbers:
725
+ assert np.array_equal(vid[frame_number], ground_truth[frame_number]), \
726
+ f'Frame {frame_number} did not match a sequential decode'
727
+ finally:
728
+ vid.close()
729
+
730
+
731
+ @pytest.mark.slow
732
+ def test_webm_with_dropped_frames_is_variable_framerate(tmp_path):
733
+ """A webm whose frames really are unevenly spaced stays variable."""
734
+ path = make_webm(tmp_path / 'variable.webm', 30, duration=4,
735
+ extra_arguments=['-vf', "select='not(mod(n,3))+not(mod(n,7))'",
736
+ '-vsync', 'vfr'])
737
+ vid = npimage.VideoStreamer(str(path), cache_index=False)
738
+ try:
739
+ assert vid.framerate == 'variable'
740
+ assert isinstance(vid.frames_pts, list)
741
+ assert [vid.frame_number_to_pts(i) for i in range(vid.n_frames)] \
742
+ == demuxed_pts(path)
743
+ finally:
744
+ vid.close()
745
+
746
+
747
+ @pytest.mark.slow
748
+ def test_webm_index_round_trips_through_cache_file(tmp_path):
749
+ """
750
+ The cached .index stores the framerate formula rather than one timestamp
751
+ per frame, and reloading it rebuilds the identical PTS values.
752
+ """
753
+ path = make_webm(tmp_path / 'cached.webm', 30, duration=4)
754
+ vid = npimage.VideoStreamer(str(path), cache_index=True)
755
+ expected_pts = list(vid.frames_pts)
756
+ vid.close()
757
+
758
+ index_file = Path(str(path) + '.index')
759
+ assert index_file.exists()
760
+ index = json.loads(index_file.read_text())
761
+ assert index['framerate'] == 30
762
+ assert 'frames_pts' not in index # the whole point: no per-frame timestamps
763
+
764
+ reloaded = npimage.VideoStreamer(str(path), cache_index=True)
765
+ try:
766
+ assert isinstance(reloaded.frames_pts, ConstantFrameratePTS)
767
+ assert list(reloaded.frames_pts) == expected_pts
768
+ assert reloaded.framerate == 30.0
769
+ finally:
770
+ reloaded.close()
771
+
772
+
773
+ @pytest.mark.slow
774
+ def test_stale_index_file_is_rebuilt(tmp_path):
775
+ """
776
+ An index written by an older npimage (which would have called this webm
777
+ variable framerate) is discarded and rebuilt rather than trusted.
778
+ """
779
+ path = make_webm(tmp_path / 'stale.webm', 30, duration=2)
780
+ index_file = Path(str(path) + '.index')
781
+ index_file.write_text(json.dumps({
782
+ 'framerate': 'variable',
783
+ 'n_frames': 3,
784
+ 'frames_pts': [0, 33, 67],
785
+ 'rotation': None,
786
+ 'time_base': {'numerator': 1, 'denominator': 1000},
787
+ }))
788
+
789
+ vid = npimage.VideoStreamer(str(path), cache_index=True)
790
+ try:
791
+ assert vid.n_frames == len(demuxed_pts(path)) != 3
792
+ assert vid.framerate == 30.0
793
+ finally:
794
+ vid.close()
File without changes
File without changes
File without changes
File without changes
File without changes