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.
- {numpyimage-3.9.0 → numpyimage-3.10.0}/PKG-INFO +1 -1
- {numpyimage-3.9.0 → numpyimage-3.10.0}/npimage/vidio.py +476 -66
- {numpyimage-3.9.0 → numpyimage-3.10.0}/numpyimage.egg-info/PKG-INFO +1 -1
- {numpyimage-3.9.0 → numpyimage-3.10.0}/pyproject.toml +1 -1
- {numpyimage-3.9.0 → numpyimage-3.10.0}/tests/test_video_streamer.py +288 -1
- {numpyimage-3.9.0 → numpyimage-3.10.0}/LICENSE +0 -0
- {numpyimage-3.9.0 → numpyimage-3.10.0}/README.md +0 -0
- {numpyimage-3.9.0 → numpyimage-3.10.0}/npimage/__init__.py +0 -0
- {numpyimage-3.9.0 → numpyimage-3.10.0}/npimage/align.py +0 -0
- {numpyimage-3.9.0 → numpyimage-3.10.0}/npimage/graphics.py +0 -0
- {numpyimage-3.9.0 → numpyimage-3.10.0}/npimage/imageio.py +0 -0
- {numpyimage-3.9.0 → numpyimage-3.10.0}/npimage/nrrd_utils.py +0 -0
- {numpyimage-3.9.0 → numpyimage-3.10.0}/npimage/operations.py +0 -0
- {numpyimage-3.9.0 → numpyimage-3.10.0}/npimage/utils.py +0 -0
- {numpyimage-3.9.0 → numpyimage-3.10.0}/numpyimage.egg-info/SOURCES.txt +0 -0
- {numpyimage-3.9.0 → numpyimage-3.10.0}/numpyimage.egg-info/dependency_links.txt +0 -0
- {numpyimage-3.9.0 → numpyimage-3.10.0}/numpyimage.egg-info/requires.txt +0 -0
- {numpyimage-3.9.0 → numpyimage-3.10.0}/numpyimage.egg-info/top_level.txt +0 -0
- {numpyimage-3.9.0 → numpyimage-3.10.0}/setup.cfg +0 -0
- {numpyimage-3.9.0 → numpyimage-3.10.0}/tests/test_heic.py +0 -0
- {numpyimage-3.9.0 → numpyimage-3.10.0}/tests/test_orientation.py +0 -0
- {numpyimage-3.9.0 → numpyimage-3.10.0}/tests/test_pbm.py +0 -0
- {numpyimage-3.9.0 → numpyimage-3.10.0}/tests/test_video_writers.py +0 -0
|
@@ -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
|
|
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
|
-
|
|
445
|
-
|
|
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.
|
|
448
|
-
self._framerate =
|
|
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
|
-
|
|
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
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
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.
|
|
@@ -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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|