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.
Files changed (23) hide show
  1. {numpyimage-3.8.2 → numpyimage-3.9.0}/PKG-INFO +1 -1
  2. {numpyimage-3.8.2 → numpyimage-3.9.0}/npimage/imageio.py +8 -4
  3. {numpyimage-3.8.2 → numpyimage-3.9.0}/npimage/vidio.py +139 -5
  4. {numpyimage-3.8.2 → numpyimage-3.9.0}/numpyimage.egg-info/PKG-INFO +1 -1
  5. {numpyimage-3.8.2 → numpyimage-3.9.0}/numpyimage.egg-info/SOURCES.txt +1 -0
  6. {numpyimage-3.8.2 → numpyimage-3.9.0}/pyproject.toml +1 -1
  7. numpyimage-3.9.0/tests/test_orientation.py +51 -0
  8. {numpyimage-3.8.2 → numpyimage-3.9.0}/tests/test_video_streamer.py +185 -0
  9. {numpyimage-3.8.2 → numpyimage-3.9.0}/LICENSE +0 -0
  10. {numpyimage-3.8.2 → numpyimage-3.9.0}/README.md +0 -0
  11. {numpyimage-3.8.2 → numpyimage-3.9.0}/npimage/__init__.py +0 -0
  12. {numpyimage-3.8.2 → numpyimage-3.9.0}/npimage/align.py +0 -0
  13. {numpyimage-3.8.2 → numpyimage-3.9.0}/npimage/graphics.py +0 -0
  14. {numpyimage-3.8.2 → numpyimage-3.9.0}/npimage/nrrd_utils.py +0 -0
  15. {numpyimage-3.8.2 → numpyimage-3.9.0}/npimage/operations.py +0 -0
  16. {numpyimage-3.8.2 → numpyimage-3.9.0}/npimage/utils.py +0 -0
  17. {numpyimage-3.8.2 → numpyimage-3.9.0}/numpyimage.egg-info/dependency_links.txt +0 -0
  18. {numpyimage-3.8.2 → numpyimage-3.9.0}/numpyimage.egg-info/requires.txt +0 -0
  19. {numpyimage-3.8.2 → numpyimage-3.9.0}/numpyimage.egg-info/top_level.txt +0 -0
  20. {numpyimage-3.8.2 → numpyimage-3.9.0}/setup.cfg +0 -0
  21. {numpyimage-3.8.2 → numpyimage-3.9.0}/tests/test_heic.py +0 -0
  22. {numpyimage-3.8.2 → numpyimage-3.9.0}/tests/test_pbm.py +0 -0
  23. {numpyimage-3.8.2 → numpyimage-3.9.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.8.2
3
+ Version: 3.9.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
@@ -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
- data = np.array(Image.open(filename)) # PIL.Image.open returns zyx order
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)) # PIL.Image.open returns zyx order
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) axis.')
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: numpyimage
3
- Version: 3.8.2
3
+ Version: 3.9.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
@@ -15,6 +15,7 @@ numpyimage.egg-info/dependency_links.txt
15
15
  numpyimage.egg-info/requires.txt
16
16
  numpyimage.egg-info/top_level.txt
17
17
  tests/test_heic.py
18
+ tests/test_orientation.py
18
19
  tests/test_pbm.py
19
20
  tests/test_video_streamer.py
20
21
  tests/test_video_writers.py
@@ -4,7 +4,7 @@ build-backend = 'setuptools.build_meta'
4
4
 
5
5
  [project]
6
6
  name = 'numpyimage'
7
- version = '3.8.2'
7
+ version = '3.9.0'
8
8
  description = 'Load, save, & manipulate image files as numpy arrays'
9
9
  readme.file = 'README.md'
10
10
  readme.content-type = 'text/markdown'
@@ -0,0 +1,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