numpyimage 3.8.2__tar.gz → 3.9.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.8.2 → numpyimage-3.9.0}/PKG-INFO +1 -1
- {numpyimage-3.8.2 → numpyimage-3.9.0}/npimage/imageio.py +8 -4
- {numpyimage-3.8.2 → numpyimage-3.9.0}/npimage/vidio.py +139 -5
- {numpyimage-3.8.2 → numpyimage-3.9.0}/numpyimage.egg-info/PKG-INFO +1 -1
- {numpyimage-3.8.2 → numpyimage-3.9.0}/numpyimage.egg-info/SOURCES.txt +1 -0
- {numpyimage-3.8.2 → numpyimage-3.9.0}/pyproject.toml +1 -1
- numpyimage-3.9.0/tests/test_orientation.py +51 -0
- {numpyimage-3.8.2 → numpyimage-3.9.0}/tests/test_video_streamer.py +185 -0
- {numpyimage-3.8.2 → numpyimage-3.9.0}/LICENSE +0 -0
- {numpyimage-3.8.2 → numpyimage-3.9.0}/README.md +0 -0
- {numpyimage-3.8.2 → numpyimage-3.9.0}/npimage/__init__.py +0 -0
- {numpyimage-3.8.2 → numpyimage-3.9.0}/npimage/align.py +0 -0
- {numpyimage-3.8.2 → numpyimage-3.9.0}/npimage/graphics.py +0 -0
- {numpyimage-3.8.2 → numpyimage-3.9.0}/npimage/nrrd_utils.py +0 -0
- {numpyimage-3.8.2 → numpyimage-3.9.0}/npimage/operations.py +0 -0
- {numpyimage-3.8.2 → numpyimage-3.9.0}/npimage/utils.py +0 -0
- {numpyimage-3.8.2 → numpyimage-3.9.0}/numpyimage.egg-info/dependency_links.txt +0 -0
- {numpyimage-3.8.2 → numpyimage-3.9.0}/numpyimage.egg-info/requires.txt +0 -0
- {numpyimage-3.8.2 → numpyimage-3.9.0}/numpyimage.egg-info/top_level.txt +0 -0
- {numpyimage-3.8.2 → numpyimage-3.9.0}/setup.cfg +0 -0
- {numpyimage-3.8.2 → numpyimage-3.9.0}/tests/test_heic.py +0 -0
- {numpyimage-3.8.2 → numpyimage-3.9.0}/tests/test_pbm.py +0 -0
- {numpyimage-3.8.2 → numpyimage-3.9.0}/tests/test_video_writers.py +0 -0
|
@@ -52,13 +52,17 @@ def load(filename, dim_order='zyx', **kwargs) -> Union[np.ndarray, Tuple[np.ndar
|
|
|
52
52
|
metadata = None
|
|
53
53
|
|
|
54
54
|
if extension in ['jpg', 'jpeg', 'png']:
|
|
55
|
-
from PIL import Image
|
|
56
|
-
|
|
55
|
+
from PIL import Image, ImageOps
|
|
56
|
+
# PIL.Image.open returns the raw stored pixels and does not apply the
|
|
57
|
+
# EXIF Orientation tag, so photos taken in a rotated orientation would
|
|
58
|
+
# load sideways or upside-down. ImageOps.exif_transpose applies the tag
|
|
59
|
+
# (and is a no-op when no Orientation tag is present).
|
|
60
|
+
data = np.array(ImageOps.exif_transpose(Image.open(filename)))
|
|
57
61
|
|
|
58
62
|
if extension == 'heic':
|
|
59
63
|
_ensure_heif_opener_registered()
|
|
60
|
-
from PIL import Image
|
|
61
|
-
data = np.array(Image.open(filename))
|
|
64
|
+
from PIL import Image, ImageOps
|
|
65
|
+
data = np.array(ImageOps.exif_transpose(Image.open(filename)))
|
|
62
66
|
|
|
63
67
|
if extension in ['tif', 'tiff']:
|
|
64
68
|
import tifffile
|
|
@@ -18,14 +18,16 @@ Class list:
|
|
|
18
18
|
because you don't ever have to have all the frames in memory at once.
|
|
19
19
|
"""
|
|
20
20
|
|
|
21
|
-
from typing import Union, Tuple, Iterator, Literal
|
|
21
|
+
from typing import Union, Tuple, Iterator, Literal, Optional
|
|
22
22
|
from pathlib import Path
|
|
23
|
+
from collections import OrderedDict
|
|
23
24
|
import subprocess
|
|
24
25
|
import shutil
|
|
25
26
|
import sys
|
|
26
27
|
import threading
|
|
27
28
|
import json
|
|
28
29
|
import math
|
|
30
|
+
import re
|
|
29
31
|
import bisect
|
|
30
32
|
from fractions import Fraction
|
|
31
33
|
|
|
@@ -243,11 +245,64 @@ class VideoSeekError(RuntimeError):
|
|
|
243
245
|
pass
|
|
244
246
|
|
|
245
247
|
|
|
248
|
+
_cache_size_units = {
|
|
249
|
+
'b': 1,
|
|
250
|
+
'kb': 1024,
|
|
251
|
+
'mb': 1024 ** 2,
|
|
252
|
+
'gb': 1024 ** 3,
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _parse_cache_size(cache_size: Optional[Union[int, str]]
|
|
257
|
+
) -> Tuple[Optional[int], Optional[int]]:
|
|
258
|
+
"""
|
|
259
|
+
Interpret the `cache_size` argument of `VideoStreamer` and return a
|
|
260
|
+
`(max_frames, max_bytes)` tuple in which exactly one element is set when
|
|
261
|
+
caching is enabled, or `(None, None)` when caching is disabled.
|
|
262
|
+
|
|
263
|
+
See the `VideoStreamer` initializer docstring for the accepted formats.
|
|
264
|
+
"""
|
|
265
|
+
if cache_size is None:
|
|
266
|
+
return None, None
|
|
267
|
+
if isinstance(cache_size, bool):
|
|
268
|
+
raise TypeError(f'cache_size must be an int, str, or None, not a bool '
|
|
269
|
+
f'(got {cache_size})')
|
|
270
|
+
if isinstance(cache_size, (int, np.integer)):
|
|
271
|
+
if cache_size < 0:
|
|
272
|
+
raise ValueError(f'cache_size must be non-negative, got {cache_size}')
|
|
273
|
+
if cache_size == 0:
|
|
274
|
+
return None, None
|
|
275
|
+
return int(cache_size), None
|
|
276
|
+
if isinstance(cache_size, str):
|
|
277
|
+
match = re.fullmatch(r'\s*([0-9]*\.?[0-9]+)\s*([a-zA-Z]*)\s*', cache_size)
|
|
278
|
+
if match is None:
|
|
279
|
+
raise ValueError(f'Could not interpret cache_size string "{cache_size}". '
|
|
280
|
+
"Expected something like '64', '256MB', or '1.5GB'.")
|
|
281
|
+
number, unit = match.group(1), match.group(2).lower()
|
|
282
|
+
if unit == '':
|
|
283
|
+
# A bare number means a frame count, which must be a whole number.
|
|
284
|
+
value = float(number)
|
|
285
|
+
if not value.is_integer():
|
|
286
|
+
raise ValueError(f'A cache_size given as a number of frames must be '
|
|
287
|
+
f'a whole number, got "{cache_size}"')
|
|
288
|
+
return (int(value), None) if value > 0 else (None, None)
|
|
289
|
+
# Accept binary-prefix spellings like 'MiB' as aliases for 'MB'.
|
|
290
|
+
unit = unit.replace('ib', 'b')
|
|
291
|
+
if unit not in _cache_size_units:
|
|
292
|
+
raise ValueError(f'Unrecognized cache_size unit "{match.group(2)}". '
|
|
293
|
+
f'Recognized units are B, KB, MB, and GB.')
|
|
294
|
+
num_bytes = int(float(number) * _cache_size_units[unit])
|
|
295
|
+
return (None, num_bytes) if num_bytes > 0 else (None, None)
|
|
296
|
+
raise TypeError(f'cache_size must be an int, str, or None, got '
|
|
297
|
+
f'{type(cache_size).__name__}')
|
|
298
|
+
|
|
299
|
+
|
|
246
300
|
class VideoStreamer:
|
|
247
301
|
def __init__(self,
|
|
248
302
|
filename: str,
|
|
249
303
|
verbose: bool = False,
|
|
250
|
-
cache_index: Literal['auto', True, False] = 'auto'
|
|
304
|
+
cache_index: Literal['auto', True, False] = 'auto',
|
|
305
|
+
cache_size: Optional[Union[int, str]] = '256MB'):
|
|
251
306
|
"""
|
|
252
307
|
Parameters
|
|
253
308
|
----------
|
|
@@ -263,6 +318,32 @@ class VideoStreamer:
|
|
|
263
318
|
next to the video file for faster loading next time.
|
|
264
319
|
If 'auto', the index is cached only if building it takes
|
|
265
320
|
more than 0.5 seconds, which only happens for ~1+ GB videos.
|
|
321
|
+
|
|
322
|
+
cache_size : int, str, or None, default '256MB'
|
|
323
|
+
Sets the size of an in-memory cache of decoded frames. The cache
|
|
324
|
+
makes a three-way tradeoff: in exchange for holding up to
|
|
325
|
+
`cache_size` worth of decoded frames in memory, reading a frame
|
|
326
|
+
that is already cached is dramatically faster (~20x in benchmarks
|
|
327
|
+
on a 1080p video) than decoding it, while reading a not-yet-cached
|
|
328
|
+
frame is only marginally slower (~2%, the cost of copying the
|
|
329
|
+
decoded frame before returning it). This is a large net win
|
|
330
|
+
whenever you revisit frames, for example when scrubbing back and
|
|
331
|
+
forth through a video, which is why caching is on by default. The
|
|
332
|
+
least recently used frames are evicted once the cache is full.
|
|
333
|
+
(The ~20x and ~2% figures are approximate and vary with the video.)
|
|
334
|
+
|
|
335
|
+
- None or 0 disables caching.
|
|
336
|
+
- An int sets the maximum number of frames to cache, e.g.
|
|
337
|
+
`cache_size=64`.
|
|
338
|
+
- A string sets a maximum memory budget, e.g. `cache_size='256MB'`
|
|
339
|
+
or `cache_size='1.5GB'`. Recognized units are B, KB, MB, and GB
|
|
340
|
+
(interpreted as powers of 1024, so '1MB' means 1024*1024 bytes).
|
|
341
|
+
A bare numeric string like '64' is treated as a frame count.
|
|
342
|
+
|
|
343
|
+
Frames returned by indexing are always independent, writeable
|
|
344
|
+
arrays, exactly as when caching is disabled: the cache holds its own
|
|
345
|
+
private copy of each frame, so modifying a returned frame never
|
|
346
|
+
affects the cache or any other returned frame.
|
|
266
347
|
"""
|
|
267
348
|
extension = Path(filename).suffix.lower().lstrip('.')
|
|
268
349
|
if extension == 'gif':
|
|
@@ -287,6 +368,15 @@ class VideoStreamer:
|
|
|
287
368
|
self._current_frame_number = None
|
|
288
369
|
self._lock = threading.Lock()
|
|
289
370
|
|
|
371
|
+
# In-memory frame cache (see the cache_size docstring above). Exactly
|
|
372
|
+
# one of _cache_max_frames / _cache_max_bytes is set when caching is on.
|
|
373
|
+
self._cache_max_frames, self._cache_max_bytes = _parse_cache_size(cache_size)
|
|
374
|
+
if self._cache_max_frames is None and self._cache_max_bytes is None:
|
|
375
|
+
self._cache = None
|
|
376
|
+
else:
|
|
377
|
+
self._cache = OrderedDict()
|
|
378
|
+
self._cache_bytes = 0
|
|
379
|
+
|
|
290
380
|
self.index_filename = self.filename.with_suffix(self.filename.suffix + '.index')
|
|
291
381
|
self._build_index(cache_index=cache_index)
|
|
292
382
|
self.t = _VideoStreamerTimeIndexer(self)
|
|
@@ -560,6 +650,12 @@ class VideoStreamer:
|
|
|
560
650
|
|
|
561
651
|
with self._lock:
|
|
562
652
|
frame_number = self._normalize_frame_number(frame_number)
|
|
653
|
+
if self._cache is not None and frame_number in self._cache:
|
|
654
|
+
# Cache hit: mark as most recently used and hand back an
|
|
655
|
+
# independent, writeable copy so callers can't corrupt the
|
|
656
|
+
# cached frame by modifying what they receive.
|
|
657
|
+
self._cache.move_to_end(frame_number)
|
|
658
|
+
return self._cache[frame_number].copy()
|
|
563
659
|
if (self._current_frame_number is None
|
|
564
660
|
or frame_number <= self._current_frame_number
|
|
565
661
|
or frame_number > self._current_frame_number + 100):
|
|
@@ -598,8 +694,39 @@ class VideoStreamer:
|
|
|
598
694
|
|
|
599
695
|
if self.rotation not in [None, '0', 0]:
|
|
600
696
|
image = np.rot90(image, k=-int(self.rotation) // 90)
|
|
697
|
+
if self._cache is not None:
|
|
698
|
+
self._cache_store(frame_number, image)
|
|
699
|
+
# Return a copy, keeping the cached frame as a private canonical
|
|
700
|
+
# copy that callers can never corrupt (see the hit path above).
|
|
701
|
+
return image.copy()
|
|
601
702
|
return image
|
|
602
703
|
|
|
704
|
+
def _cache_store(self, frame_number: int, image: np.ndarray) -> None:
|
|
705
|
+
"""
|
|
706
|
+
Insert a decoded frame into the cache as the most recently used entry,
|
|
707
|
+
then evict least recently used entries until the cache is within its
|
|
708
|
+
configured size limit.
|
|
709
|
+
"""
|
|
710
|
+
self._cache[frame_number] = image
|
|
711
|
+
self._cache.move_to_end(frame_number)
|
|
712
|
+
if self._cache_max_bytes is not None:
|
|
713
|
+
self._cache_bytes += image.nbytes
|
|
714
|
+
while self._cache_bytes > self._cache_max_bytes and len(self._cache) > 1:
|
|
715
|
+
_, evicted = self._cache.popitem(last=False)
|
|
716
|
+
self._cache_bytes -= evicted.nbytes
|
|
717
|
+
else:
|
|
718
|
+
while len(self._cache) > self._cache_max_frames:
|
|
719
|
+
self._cache.popitem(last=False)
|
|
720
|
+
|
|
721
|
+
def clear_cache(self) -> None:
|
|
722
|
+
"""
|
|
723
|
+
Empty the in-memory frame cache. Has no effect if caching is disabled.
|
|
724
|
+
"""
|
|
725
|
+
with self._lock:
|
|
726
|
+
if self._cache is not None:
|
|
727
|
+
self._cache.clear()
|
|
728
|
+
self._cache_bytes = 0
|
|
729
|
+
|
|
603
730
|
def _normalize_frame_number(self, frame_number: int) -> int:
|
|
604
731
|
"""
|
|
605
732
|
Support negative indexing by converting negative frame numbers to
|
|
@@ -1116,7 +1243,8 @@ def save_video(data, filename, time_axis=0, color_axis=None, overwrite=False,
|
|
|
1116
1243
|
color_axis : int or None, default None
|
|
1117
1244
|
If not None, specifies the axis of the color channels (e.g., -1 for last axis,
|
|
1118
1245
|
1 for second axis).
|
|
1119
|
-
If None, data must be 3D (greyscale) or 4D with one length 3 axis (RGB)
|
|
1246
|
+
If None, data must be 3D (greyscale) or 4D with one length 3 axis (RGB)
|
|
1247
|
+
or length 4 axis (RGBA, whose alpha channel will be dropped).
|
|
1120
1248
|
|
|
1121
1249
|
overwrite : bool, default False
|
|
1122
1250
|
Whether to overwrite the file if it already exists.
|
|
@@ -1158,9 +1286,10 @@ def save_video(data, filename, time_axis=0, color_axis=None, overwrite=False,
|
|
|
1158
1286
|
if not isinstance(data, np.ndarray):
|
|
1159
1287
|
data = np.array(data)
|
|
1160
1288
|
if color_axis is None and data.ndim == 4:
|
|
1161
|
-
color_axis = utils.find_channel_axis(data, possible_channel_lengths=3)
|
|
1289
|
+
color_axis = utils.find_channel_axis(data, possible_channel_lengths=[3, 4])
|
|
1162
1290
|
if color_axis is None:
|
|
1163
|
-
raise ValueError('4D input data must have an RGB (length 3)
|
|
1291
|
+
raise ValueError('4D input data must have an RGB (length 3) or '
|
|
1292
|
+
'RGBA (length 4) axis.')
|
|
1164
1293
|
if color_axis is not None:
|
|
1165
1294
|
if data.ndim != 4:
|
|
1166
1295
|
raise ValueError('Input data must be 4D when color_axis is specified.')
|
|
@@ -1172,6 +1301,11 @@ def save_video(data, filename, time_axis=0, color_axis=None, overwrite=False,
|
|
|
1172
1301
|
data = data.swapaxes(1, 2)
|
|
1173
1302
|
n_frames = data.shape[0]
|
|
1174
1303
|
height, width, channels = data.shape[1:]
|
|
1304
|
+
if channels == 4:
|
|
1305
|
+
# While some video codecs support an alpha channel, most don't,
|
|
1306
|
+
# so we drop it and keep just the RGB channels
|
|
1307
|
+
data = data[..., :3]
|
|
1308
|
+
channels = 3
|
|
1175
1309
|
if channels != 3:
|
|
1176
1310
|
raise ValueError(f'Color video must have 3 channels (RGB) but had {channels}.')
|
|
1177
1311
|
else:
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Tests that load() applies the EXIF orientation tag for photo formats."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pytest
|
|
5
|
+
from PIL import Image, ImageOps
|
|
6
|
+
|
|
7
|
+
import npimage
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _save_with_orientation(array, path, orientation):
|
|
11
|
+
"""
|
|
12
|
+
Save `array` as an image at `path`, stamping the given EXIF Orientation
|
|
13
|
+
value (e.g. 6 = Rotate 90 CW) without rotating the stored pixels, so that
|
|
14
|
+
only a reader honoring the tag will display it upright.
|
|
15
|
+
"""
|
|
16
|
+
image = Image.fromarray(array)
|
|
17
|
+
exif = image.getexif()
|
|
18
|
+
exif[0x0112] = orientation # 0x0112 is the EXIF Orientation tag
|
|
19
|
+
image.save(str(path), exif=exif)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.mark.parametrize('extension', ['jpg', 'png'])
|
|
23
|
+
def test_load_applies_rotate_90(tmp_path, extension):
|
|
24
|
+
"""
|
|
25
|
+
A photo whose pixels are stored landscape but tagged Rotate 90 CW loads
|
|
26
|
+
with its width and height swapped (i.e. the rotation is applied).
|
|
27
|
+
"""
|
|
28
|
+
height, width = 20, 40
|
|
29
|
+
array = np.zeros((height, width, 3), dtype=np.uint8)
|
|
30
|
+
array[:, :width // 2] = [255, 0, 0] # left half red, so rotation is visible
|
|
31
|
+
path = tmp_path / f'rotated.{extension}'
|
|
32
|
+
_save_with_orientation(array, path, orientation=6) # Rotate 90 CW
|
|
33
|
+
|
|
34
|
+
loaded = npimage.load(str(path))
|
|
35
|
+
# Applying Rotate 90 CW swaps the axes, so a (height, width) image
|
|
36
|
+
# becomes (width, height).
|
|
37
|
+
assert loaded.shape[:2] == (width, height)
|
|
38
|
+
# And it matches PIL's orientation-applied result exactly.
|
|
39
|
+
truth = np.array(ImageOps.exif_transpose(Image.open(str(path))))
|
|
40
|
+
assert np.array_equal(loaded, truth)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_load_without_orientation_is_unchanged(tmp_path):
|
|
44
|
+
"""An image with no Orientation tag loads with its pixels untouched."""
|
|
45
|
+
array = np.random.randint(0, 256, (20, 40, 3), dtype=np.uint8)
|
|
46
|
+
path = tmp_path / 'plain.png'
|
|
47
|
+
Image.fromarray(array).save(str(path))
|
|
48
|
+
|
|
49
|
+
loaded = npimage.load(str(path))
|
|
50
|
+
assert loaded.shape == array.shape
|
|
51
|
+
assert np.array_equal(loaded, array)
|
|
@@ -10,6 +10,7 @@ import numpy as np
|
|
|
10
10
|
import pytest
|
|
11
11
|
|
|
12
12
|
import npimage
|
|
13
|
+
from npimage.vidio import _parse_cache_size
|
|
13
14
|
|
|
14
15
|
TESTS_DIR = Path(__file__).parent
|
|
15
16
|
SPINNING_MP4 = TESTS_DIR / 'table-tennis-emoji-spinning.mp4'
|
|
@@ -320,3 +321,187 @@ def test_t_indexer_variable_framerate_uses_bisect(spinning_streamer):
|
|
|
320
321
|
finally:
|
|
321
322
|
vid._framerate = original_framerate
|
|
322
323
|
vid.frames_pts = original_frames_pts
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# ----------------------------------------------------------------------------
|
|
327
|
+
# Frame caching
|
|
328
|
+
# ----------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
def test_parse_cache_size_disabled():
|
|
331
|
+
"""None and 0 (in either int or string form) disable caching."""
|
|
332
|
+
assert _parse_cache_size(None) == (None, None)
|
|
333
|
+
assert _parse_cache_size(0) == (None, None)
|
|
334
|
+
assert _parse_cache_size('0') == (None, None)
|
|
335
|
+
assert _parse_cache_size('0MB') == (None, None)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def test_parse_cache_size_frame_counts():
|
|
339
|
+
"""Bare ints and bare numeric strings are interpreted as frame counts."""
|
|
340
|
+
assert _parse_cache_size(64) == (64, None)
|
|
341
|
+
assert _parse_cache_size('64') == (64, None)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def test_parse_cache_size_byte_budgets():
|
|
345
|
+
"""Strings with units are interpreted as memory budgets (powers of 1024)."""
|
|
346
|
+
assert _parse_cache_size('1B') == (None, 1)
|
|
347
|
+
assert _parse_cache_size('1KB') == (None, 1024)
|
|
348
|
+
assert _parse_cache_size('256MB') == (None, 256 * 1024 ** 2)
|
|
349
|
+
assert _parse_cache_size('1.5GB') == (None, int(1.5 * 1024 ** 3))
|
|
350
|
+
# Binary-prefix spellings are accepted as aliases.
|
|
351
|
+
assert _parse_cache_size('1MiB') == (None, 1024 ** 2)
|
|
352
|
+
# Whitespace and case are tolerated.
|
|
353
|
+
assert _parse_cache_size(' 2 gb ') == (None, 2 * 1024 ** 3)
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def test_parse_cache_size_invalid():
|
|
357
|
+
"""Bad units, non-whole frame counts, bools, and negatives all raise."""
|
|
358
|
+
with pytest.raises(ValueError):
|
|
359
|
+
_parse_cache_size('abc')
|
|
360
|
+
with pytest.raises(ValueError):
|
|
361
|
+
_parse_cache_size('10TB')
|
|
362
|
+
with pytest.raises(ValueError):
|
|
363
|
+
_parse_cache_size('1.5') # fractional frame count
|
|
364
|
+
with pytest.raises(ValueError):
|
|
365
|
+
_parse_cache_size(-5)
|
|
366
|
+
with pytest.raises(TypeError):
|
|
367
|
+
_parse_cache_size(True)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def test_cache_returns_correct_frames():
|
|
371
|
+
"""Cached frames are pixel-identical to uncached ones, even out of order."""
|
|
372
|
+
reference = npimage.VideoStreamer(str(SPINNING_MP4), cache_size=None)
|
|
373
|
+
cached = npimage.VideoStreamer(str(SPINNING_MP4), cache_size=4)
|
|
374
|
+
try:
|
|
375
|
+
for i in [0, 5, 2, 5, 0, 7, 3, 7, 1]:
|
|
376
|
+
assert np.array_equal(reference[i], cached[i]), f'mismatch at frame {i}'
|
|
377
|
+
finally:
|
|
378
|
+
reference.close()
|
|
379
|
+
cached.close()
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def test_cache_disabled_returns_writeable_frames():
|
|
383
|
+
"""With caching off, returned frames are writeable (unchanged behavior)."""
|
|
384
|
+
vid = npimage.VideoStreamer(str(SPINNING_MP4), cache_size=None)
|
|
385
|
+
try:
|
|
386
|
+
assert vid._cache is None
|
|
387
|
+
assert vid[0].flags.writeable
|
|
388
|
+
finally:
|
|
389
|
+
vid.close()
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def test_cache_enabled_returns_writeable_independent_frames():
|
|
393
|
+
"""
|
|
394
|
+
With caching on, returned frames are still writeable, and mutating a
|
|
395
|
+
returned frame must not corrupt the cached copy handed to later callers.
|
|
396
|
+
|
|
397
|
+
This exercises both the cache-miss return path (the first read of a frame)
|
|
398
|
+
and the cache-hit return path (subsequent reads): mutating the frame
|
|
399
|
+
returned from either must leave the cache untouched.
|
|
400
|
+
"""
|
|
401
|
+
vid = npimage.VideoStreamer(str(SPINNING_MP4), cache_size=4)
|
|
402
|
+
try:
|
|
403
|
+
first = vid[0] # cache miss: this return is freshly decoded
|
|
404
|
+
assert first.flags.writeable
|
|
405
|
+
original = first.copy()
|
|
406
|
+
# Guard against a vacuous test: the frame must have content to clobber.
|
|
407
|
+
assert original.any()
|
|
408
|
+
|
|
409
|
+
first[:] = 0 # mutate the miss-path frame in place
|
|
410
|
+
second = vid[0] # cache hit: must be the pristine frame
|
|
411
|
+
assert np.array_equal(second, original), (
|
|
412
|
+
'Mutating the frame returned on a cache miss corrupted the cache.'
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
second[:] = 0 # now mutate the hit-path frame in place
|
|
416
|
+
third = vid[0] # another cache hit: still pristine
|
|
417
|
+
assert np.array_equal(third, original), (
|
|
418
|
+
'Mutating the frame returned on a cache hit corrupted the cache.'
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
# Every read handed back an independent array object.
|
|
422
|
+
assert first is not second
|
|
423
|
+
assert second is not third
|
|
424
|
+
assert first is not third
|
|
425
|
+
finally:
|
|
426
|
+
vid.close()
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def test_cache_lru_eviction_by_frame_count():
|
|
430
|
+
"""The least recently used frames are evicted once the cache is full."""
|
|
431
|
+
vid = npimage.VideoStreamer(str(SPINNING_MP4), cache_size=4)
|
|
432
|
+
try:
|
|
433
|
+
for i in range(5): # fill past capacity: 0 should be evicted
|
|
434
|
+
_ = vid[i]
|
|
435
|
+
assert list(vid._cache.keys()) == [1, 2, 3, 4]
|
|
436
|
+
# Touch frame 1 (most recently used), then add two more.
|
|
437
|
+
_ = vid[1]
|
|
438
|
+
_ = vid[5]
|
|
439
|
+
_ = vid[6]
|
|
440
|
+
# 2 and 3 (oldest untouched) evicted; 1 retained because it was reused.
|
|
441
|
+
assert 1 in vid._cache
|
|
442
|
+
assert 2 not in vid._cache
|
|
443
|
+
assert 3 not in vid._cache
|
|
444
|
+
finally:
|
|
445
|
+
vid.close()
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def test_cache_respects_byte_budget():
|
|
449
|
+
"""A memory-budgeted cache never holds more bytes than allowed."""
|
|
450
|
+
reference = npimage.VideoStreamer(str(SPINNING_MP4), cache_size=None)
|
|
451
|
+
frame_bytes = reference[0].nbytes
|
|
452
|
+
# Budget for exactly two frames.
|
|
453
|
+
vid = npimage.VideoStreamer(str(SPINNING_MP4), cache_size=f'{2 * frame_bytes}B')
|
|
454
|
+
try:
|
|
455
|
+
for i in range(5):
|
|
456
|
+
_ = vid[i]
|
|
457
|
+
assert len(vid._cache) == 2
|
|
458
|
+
assert vid._cache_bytes <= 2 * frame_bytes
|
|
459
|
+
finally:
|
|
460
|
+
reference.close()
|
|
461
|
+
vid.close()
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def test_clear_cache():
|
|
465
|
+
"""clear_cache empties the cache and resets the byte tally."""
|
|
466
|
+
vid = npimage.VideoStreamer(str(SPINNING_MP4), cache_size='64MB')
|
|
467
|
+
try:
|
|
468
|
+
for i in range(4):
|
|
469
|
+
_ = vid[i]
|
|
470
|
+
assert len(vid._cache) > 0
|
|
471
|
+
vid.clear_cache()
|
|
472
|
+
assert len(vid._cache) == 0
|
|
473
|
+
assert vid._cache_bytes == 0
|
|
474
|
+
# The streamer still works after clearing.
|
|
475
|
+
assert np.array_equal(vid[0], vid[0])
|
|
476
|
+
finally:
|
|
477
|
+
vid.close()
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def test_cache_hit_serves_without_redecoding():
|
|
481
|
+
"""
|
|
482
|
+
The core promise of the cache: re-reading a cached frame returns it
|
|
483
|
+
straight from memory without decoding it again.
|
|
484
|
+
|
|
485
|
+
We verify this without mocking by watching `_current_frame_number`, which
|
|
486
|
+
tracks the decoder's position. Decoding a frame advances it to that frame,
|
|
487
|
+
whereas a cache hit returns before touching the stream and so leaves it
|
|
488
|
+
unchanged. We also confirm the cached frame is still pixel-correct.
|
|
489
|
+
"""
|
|
490
|
+
reference = npimage.VideoStreamer(str(SPINNING_MP4), cache_size=None)
|
|
491
|
+
vid = npimage.VideoStreamer(str(SPINNING_MP4), cache_size=8)
|
|
492
|
+
try:
|
|
493
|
+
_ = vid[5] # cache miss: decodes frame 5
|
|
494
|
+
_ = vid[2] # cache miss: decodes frame 2
|
|
495
|
+
assert vid._current_frame_number == 2 # decoder is now parked at 2
|
|
496
|
+
|
|
497
|
+
frame5 = vid[5] # cache hit: must NOT decode
|
|
498
|
+
assert vid._current_frame_number == 2, (
|
|
499
|
+
'Re-reading a cached frame moved the decoder, so it was decoded '
|
|
500
|
+
'again instead of served from the cache.'
|
|
501
|
+
)
|
|
502
|
+
assert 5 in vid._cache
|
|
503
|
+
# The frame served from the cache is identical to a fresh decode.
|
|
504
|
+
assert np.array_equal(frame5, reference[5])
|
|
505
|
+
finally:
|
|
506
|
+
reference.close()
|
|
507
|
+
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
|