numpyimage 2.6.0__tar.gz → 2.6.2__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: numpyimage
3
- Version: 2.6.0
3
+ Version: 2.6.2
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: GNU GENERAL PUBLIC LICENSE
@@ -701,18 +701,19 @@ Requires-Dist: av; extra == "all"
701
701
  Dynamic: license-file
702
702
 
703
703
  # npimage
704
- Need to load pixel values from image files as numpy arrays, and hate having to remember whether you should use PIL, tifffile, matplotlib, or something else? Hate having to deal with the fact that those libraries all use different function names and syntaxes? Wish you could just provide a filename and get back a numpy array? This library's `core.py` does that, with `array = load(filename)`, `save(array, filename)`, and `show(array)` functions that let you easily handle a number of common image file formats without having to remember library-specific syntax. (Another choice of library to consider for accomplishing similar goals is [imageio](https://pypi.org/project/imageio/), which also supports loading videos through the FFmpeg wrapper library [pyav](https://pypi.org/project/av/).)
704
+ Need to load pixel values from image files as numpy arrays, and hate having to remember whether you should use PIL, tifffile, matplotlib, or something else? Hate having to deal with the fact that those libraries all use different function names and syntaxes? Wish you could just provide a filename and get back a numpy array? This library's `imageio.py` does that, with `array = npimage.load(filename)`, `npimage.save(array, filename)`, and `npimage.show(array)` functions that let you easily handle a number of common image file formats without having to remember library-specific syntax. Additionally, `vidio.py` provides `array = npimage.load_video(filename)` and `npimage.save_video(array, filename)` for videos as well. (Another similar library to consider using is [imageio](https://pypi.org/project/imageio/).)
705
705
 
706
706
  Want to draw simple shapes like lines, triangles, and circles into 3D numpy arrays? Frustrated that the python libraries you can find online like `opencv` and `skimage.draw` work on 2D arrays but not 3D? I wrote some functions in `graphics.py` that do the trick in 3D. (If you know of another library that can do this, please let me know!)
707
707
 
708
708
 
709
709
  ### Documentation
710
- - `core.py`: load, save, or show images.
710
+ - `imageio.py`: load, save, or show images.
711
+ - `vidio.py`: load or save videos.
711
712
  - `graphics.py`: draw points, lines, triangles, circles, or spheres into 2D or 3D numpy arrays representing image volumes.
712
713
  - `nrrd_utils.py`: compress or read metadata from `.nrrd` files.
713
714
  - `operations.py`: perform operations on images.
714
715
 
715
- For now, check each function's docstring for more. A jupyter notebook demonstrating this package's functions will come later.
716
+ Check each function's docstring for more details.
716
717
 
717
718
 
718
719
  ### Installation
@@ -1,16 +1,17 @@
1
1
  # npimage
2
- Need to load pixel values from image files as numpy arrays, and hate having to remember whether you should use PIL, tifffile, matplotlib, or something else? Hate having to deal with the fact that those libraries all use different function names and syntaxes? Wish you could just provide a filename and get back a numpy array? This library's `core.py` does that, with `array = load(filename)`, `save(array, filename)`, and `show(array)` functions that let you easily handle a number of common image file formats without having to remember library-specific syntax. (Another choice of library to consider for accomplishing similar goals is [imageio](https://pypi.org/project/imageio/), which also supports loading videos through the FFmpeg wrapper library [pyav](https://pypi.org/project/av/).)
2
+ Need to load pixel values from image files as numpy arrays, and hate having to remember whether you should use PIL, tifffile, matplotlib, or something else? Hate having to deal with the fact that those libraries all use different function names and syntaxes? Wish you could just provide a filename and get back a numpy array? This library's `imageio.py` does that, with `array = npimage.load(filename)`, `npimage.save(array, filename)`, and `npimage.show(array)` functions that let you easily handle a number of common image file formats without having to remember library-specific syntax. Additionally, `vidio.py` provides `array = npimage.load_video(filename)` and `npimage.save_video(array, filename)` for videos as well. (Another similar library to consider using is [imageio](https://pypi.org/project/imageio/).)
3
3
 
4
4
  Want to draw simple shapes like lines, triangles, and circles into 3D numpy arrays? Frustrated that the python libraries you can find online like `opencv` and `skimage.draw` work on 2D arrays but not 3D? I wrote some functions in `graphics.py` that do the trick in 3D. (If you know of another library that can do this, please let me know!)
5
5
 
6
6
 
7
7
  ### Documentation
8
- - `core.py`: load, save, or show images.
8
+ - `imageio.py`: load, save, or show images.
9
+ - `vidio.py`: load or save videos.
9
10
  - `graphics.py`: draw points, lines, triangles, circles, or spheres into 2D or 3D numpy arrays representing image volumes.
10
11
  - `nrrd_utils.py`: compress or read metadata from `.nrrd` files.
11
12
  - `operations.py`: perform operations on images.
12
13
 
13
- For now, check each function's docstring for more. A jupyter notebook demonstrating this package's functions will come later.
14
+ Check each function's docstring for more details.
14
15
 
15
16
 
16
17
  ### Installation
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env python3
2
2
 
3
- from .core import *
3
+ from .imageio import *
4
+ from .vidio import *
5
+
4
6
  from .operations import *
5
7
  from .align import *
6
8
  from .graphics import *
@@ -686,7 +686,7 @@ def floodfill(image, seed, fill_value, fill_diagonally=False,
686
686
 
687
687
  if debug_frequency is not None and iteration % debug_frequency == 0:
688
688
  print(f'Writing iteration{iteration}.nrrd')
689
- from .core import save
689
+ from .imageio import save
690
690
  save(image, f'iteration{iteration}.nrrd')
691
691
 
692
692
  #recurseifneighbors
@@ -5,18 +5,17 @@ Functions for reading, writing, and showing images.
5
5
  Function list:
6
6
  - load(filename) -> numpy.ndarray
7
7
  - save(data, filename) -> Saves a numpy array as an nD image file
8
- - save_video(data, filename) -> Saves a 3D numpy array as a video
9
8
  - show(np_array) -> Displays a numpy array of pixel values as an image
10
9
  """
11
10
 
12
- from typing import Literal, Union, Tuple, Iterator
11
+ from typing import Literal, Union, Tuple
13
12
  import os
14
13
  import glob
15
14
  from builtins import open as builtin_open
16
15
 
17
16
  import numpy as np
18
17
 
19
- from . import operations, utils
18
+ from . import vidio, operations, utils
20
19
 
21
20
  supported_extensions = [
22
21
  'tif', 'tiff', 'jpg', 'jpeg', 'png', 'pbm',
@@ -44,7 +43,9 @@ def load(filename, dim_order='zyx', **kwargs) -> Union[np.ndarray, Tuple[np.ndar
44
43
  raise ValueError('Could not determine file format from filename'
45
44
  f' "{filename}". Please specify the file type via'
46
45
  ' the `format` argument, e.g. format="tif"')
47
- if extension not in supported_extensions:
46
+ if extension in vidio.supported_extensions:
47
+ return vidio.VideoStreamer(filename)
48
+ elif extension not in supported_extensions:
48
49
  raise ValueError(f'File format of "{filename}" not supported/recognized.')
49
50
 
50
51
  data = None
@@ -179,7 +180,7 @@ def save(data,
179
180
  '.nrrd. Whether or not compression occurs now will depend on '
180
181
  'the format you are saving to.')
181
182
 
182
- channel_axis = find_channel_axis(data)
183
+ channel_axis = utils.find_channel_axis(data)
183
184
  if 'xy' in dim_order:
184
185
  data = data.T
185
186
  if hasattr(pixel_size, '__iter__') and not isinstance(pixel_size, str):
@@ -361,286 +362,6 @@ write = save # Function name alias
361
362
  to_file = save # Function name alias
362
363
 
363
364
 
364
- def load_video(filename,
365
- return_framerate=False,
366
- progress_bar=True) -> Union[np.ndarray, Tuple[np.ndarray, float]]:
367
- """
368
- Load all images in a video file as a numpy array.
369
-
370
- Parameters
371
- ----------
372
- filename : str
373
- Path to the video file
374
- return_framerate : bool, default False
375
- If True, return the frame rate of the video
376
- progress_bar : bool, default True
377
- If True, display a progress bar
378
-
379
- Returns
380
- -------
381
- data : numpy.ndarray
382
- The video frames as a numpy array, shape (num_frames, height, width, colors)
383
- """
384
- try:
385
- import av
386
- from tqdm import tqdm
387
- except ImportError:
388
- raise ImportError('Missing optional dependency for video processing,'
389
- ' run `pip install av tqdm`')
390
-
391
- container = av.open(filename)
392
- stream = container.streams.video[0]
393
- num_frames = stream.frames
394
- if not num_frames or num_frames == 0:
395
- # If we don't know the number of frames, we can't preallocate, so
396
- # it's hard to do better than the following approach which temporarily
397
- # uses double the amount of RAM compared to the preallocated approach.
398
- data = np.array(list(lazy_load_video(filename)))
399
- if return_framerate:
400
- return data, float(stream.average_rate)
401
- else:
402
- return data
403
- else:
404
- # Load first image to get shape and dtype
405
- frame_iter = container.decode(stream)
406
- first_frame = next(frame_iter)
407
- first_img = first_frame.to_ndarray(format='rgb24')
408
- # Preallocate memory for the entire array
409
- data = np.empty((num_frames, *first_img.shape), dtype=first_img.dtype)
410
- # Then fill it up frame by frame
411
- data[0] = first_img
412
- for i, frame in tqdm(enumerate(frame_iter), total=num_frames,
413
- desc='Loading video', disable=not progress_bar):
414
- if i == 0:
415
- continue
416
- img = frame.to_ndarray(format='rgb24')
417
- data[i] = img
418
- if return_framerate:
419
- return data, float(stream.average_rate)
420
- else:
421
- return data
422
-
423
-
424
- def lazy_load_video(filename) -> Iterator[np.ndarray]:
425
- """
426
- Lazily load video frames as numpy arrays using PyAV.
427
-
428
- Parameters
429
- ----------
430
- filename : str
431
- Path to the video file.
432
-
433
- Yields
434
- ------
435
- frame : np.ndarray
436
- Video frame as a numpy array, shape (height, width, colors).
437
- """
438
- try:
439
- import av
440
- except ImportError:
441
- raise ImportError('Missing optional dependency for video processing,'
442
- ' run `pip install av tqdm`')
443
- container = av.open(filename)
444
- stream = container.streams.video[0]
445
- for frame in container.decode(stream):
446
- img = frame.to_ndarray(format='rgb24')
447
- yield img
448
-
449
-
450
- codec_aliases = {
451
- "libx264": "libx264",
452
- "avc1": "libx264",
453
- "h264": "libx264",
454
- "H.264": "libx264",
455
- "libx265": "libx265",
456
- "hevc": "libx265",
457
- "hvc1": "libx265",
458
- "hev1": "libx265",
459
- "h265": "libx265",
460
- "H.265": "libx265",
461
- }
462
-
463
-
464
- class VideoWriter:
465
- """
466
- Create a video writer object for saving frames to a video file.
467
-
468
- Parameters
469
- ----------
470
- filename : str
471
- The filename to save the video to.
472
- framerate : int, default 30
473
- The frame rate of the video.
474
- crf : int, default 23
475
- Constant Rate Factor for encoding quality (lower is better quality).
476
- compression_speed : str, default 'medium'
477
- Compression speed preset: 'ultrafast', 'superfast', 'veryfast',
478
- 'faster', 'fast', 'medium', 'slow', 'slower', 'veryslow'.
479
- codec : Literal['libx264', 'libx265'], default 'libx264'
480
- The video codec to use for encoding. Can be any of a number of aliases for
481
- these two codecs, including avc1/h264 vs hevc/hvc1/hev1/h265.
482
- """
483
- def __init__(self, filename, framerate=30, crf=23, compression_speed='medium',
484
- codec: Literal['libx264', 'libx265'] = 'libx264',
485
- overwrite=False):
486
- try:
487
- import av
488
- except ImportError:
489
- raise ImportError('Missing optional dependency for video processing,'
490
- ' run `pip install av tqdm`')
491
- self.av = av
492
- filename = os.path.expanduser(str(filename))
493
- if os.path.exists(filename) and not overwrite:
494
- raise FileExistsError(f'File {filename} already exists. '
495
- 'Set overwrite=True to overwrite.')
496
- self.filename = filename
497
- self.framerate = framerate
498
- self.crf = crf
499
- self.compression_speed = compression_speed
500
- self.codec = codec_aliases[codec.lower()]
501
- self.container = av.open(filename, mode='w')
502
- self.stream = self.container.add_stream(self.codec, rate=framerate)
503
- self.stream.pix_fmt = 'yuv420p'
504
- self.stream.options = {'crf': str(crf), 'preset': compression_speed}
505
- self._closed = False
506
- self.stream.width = 0
507
- self.stream.height = 0
508
-
509
- def write(self, frame):
510
- if not isinstance(frame, self.av.VideoFrame):
511
- if frame.ndim == 3 and frame.shape[-1] == 3:
512
- frame = self.av.VideoFrame.from_ndarray(frame, format='rgb24')
513
- elif frame.ndim == 2:
514
- frame = self.av.VideoFrame.from_ndarray(frame, format='gray')
515
- else:
516
- raise ValueError(f'Frame must be (H, W) or (H, W, 3) but was {frame.shape}')
517
- if self.stream.width == 0:
518
- self.stream.width = frame.width
519
- if self.stream.height == 0:
520
- self.stream.height = frame.height
521
- for packet in self.stream.encode(frame):
522
- self.container.mux(packet)
523
-
524
- def __enter__(self):
525
- return self
526
-
527
- def __exit__(self, exc_type, exc_val, exc_tb):
528
- # Flush stream
529
- for packet in self.stream.encode():
530
- self.container.mux(packet)
531
- self.container.close()
532
- self._closed = True
533
-
534
-
535
- def save_video(data, filename, time_axis=0, color_axis=None, overwrite=False,
536
- dim_order='yx', framerate=30, crf=23, compression_speed='medium',
537
- progress_bar=True, codec: Literal['libx264', 'libx265'] = 'libx264') -> None:
538
- """
539
- Save a 3D numpy array of greyscale values OR a 4D numpy array of RGB values as a video
540
-
541
- Follows the PyAV cookbook section on generating video from numpy arrays:
542
- https://pyav.basswood-io.com/docs/develop/cookbook/numpy.html#generating-video
543
-
544
- Parameters
545
- ----------
546
- data : numpy.ndarray or list of filenames
547
- A 3D (grayscale) or 4D (RGB) numpy array of pixel values.
548
-
549
- filename : str
550
- The filename to save the video to.
551
-
552
- time_axis : int, default 0
553
- The axis of the data array that will be played as time in the video.
554
-
555
- color_axis : int or None, default None
556
- If not None, specifies the axis of the color channels (e.g., -1 for last axis,
557
- 1 for second axis).
558
- If None, data must be 3D (greyscale) or 4D with one length 3 axis (RGB).
559
-
560
- overwrite : bool, default False
561
- Whether to overwrite the file if it already exists.
562
-
563
- dim_order : 'yx' (default) or 'xy'
564
- The order of the spatial dimensions in the input data.
565
-
566
- framerate : int, default 30
567
- The frame rate of the video.
568
-
569
- crf : int, default 23
570
- Constant Rate Factor that specifies amount of lossiness allowed in compression.
571
- Lower values produce better quality, larger videos. crf=17 has no human-visible
572
- compression artifacts. File size approximately doubles/halves each time you
573
- add/subtract 6 from crf, so crf=17 produces files about twice as large as
574
- the default crf=23.
575
-
576
- compression_speed : str, default 'medium'
577
- Compression speed preset: 'ultrafast', 'superfast', 'veryfast',
578
- 'faster', 'fast', 'medium', 'slow', 'slower', 'veryslow'.
579
-
580
- progress_bar : bool, default True
581
- If True, display a progress bar.
582
-
583
- codec : Literal['libx264', 'libx265'], default 'libx264'
584
- The video codec to use for encoding. Can be any of a number of aliases for
585
- these two codecs, including avc1/h264 vs hevc/hvc1/hev1/h265.
586
- """
587
- try:
588
- from tqdm import tqdm
589
- except ImportError:
590
- raise ImportError('Missing optional dependency for video processing,'
591
- ' run `pip install av tqdm`')
592
-
593
- filename = os.path.expanduser(str(filename))
594
- if filename.split('.')[-1].lower() not in ['mp4', 'mkv', 'avi', 'mov']:
595
- filename += '.mp4'
596
- if os.path.exists(filename) and not overwrite:
597
- raise FileExistsError(f'File {filename} already exists. '
598
- 'Set overwrite=True to overwrite.')
599
-
600
- if color_axis is None and data.ndim == 4:
601
- color_axis = find_channel_axis(data, possible_channel_lengths=3)
602
- if color_axis is None:
603
- raise ValueError('4D input data must have an RGB (length 3) axis.')
604
- if color_axis is not None:
605
- if data.ndim != 4:
606
- raise ValueError('Input data must be 4D when color_axis is specified.')
607
- # Move time axis to 0, color axis to -1
608
- data = np.moveaxis(data, time_axis, 0)
609
- if color_axis != -1:
610
- data = np.moveaxis(data, color_axis, -1)
611
- if 'xy' in dim_order:
612
- data = data.swapaxes(1, 2)
613
- n_frames = data.shape[0]
614
- height, width, channels = data.shape[1:]
615
- if channels != 3:
616
- raise ValueError(f'Color video must have 3 channels (RGB) but had {channels}.')
617
- else:
618
- if data.ndim != 3:
619
- raise ValueError('Input data must be 3D when color_axis is not specified.')
620
- data = np.moveaxis(data, time_axis, 0)
621
- if 'xy' in dim_order:
622
- data = data.swapaxes(1, 2)
623
- n_frames = data.shape[0]
624
- height, width = data.shape[1:]
625
-
626
- extension = filename.split('.')[-1].lower()
627
- if extension == 'mp4':
628
- pad = [[0, 0], [0, 0], [0, 0]]
629
- if height % 2 != 0:
630
- pad[1][1] = 1
631
- if width % 2 != 0:
632
- pad[2][1] = 1
633
- if pad != [[0, 0], [0, 0], [0, 0]]:
634
- data = np.pad(data, pad, mode='edge')
635
-
636
- with VideoWriter(filename, framerate=framerate, crf=crf,
637
- compression_speed=compression_speed, codec=codec,
638
- overwrite=overwrite) as writer:
639
- for frame_i in tqdm(range(n_frames), total=n_frames,
640
- desc='Saving video', disable=not progress_bar):
641
- writer.write(data[frame_i])
642
-
643
-
644
365
  def show(data,
645
366
  dim_order='yx',
646
367
  data_type: Literal['image', 'segmentation'] = 'image',
@@ -673,7 +394,7 @@ def show(data,
673
394
  if data_type == 'segmentation':
674
395
  data = utils.assign_random_colors(data, seed=kwargs.get('seed', None))
675
396
 
676
- if (not data.ndim == 2) and not (data.ndim == 3 and find_channel_axis(data) is not None):
397
+ if (not data.ndim == 2) and not (data.ndim == 3 and utils.find_channel_axis(data) is not None):
677
398
  m = ('Data must have shape (y, x) for grayscale, '
678
399
  '(y, x, 3) for RGB, or (y, x, 4) for RGBA but had '
679
400
  f'shape {data.shape}')
@@ -684,9 +405,9 @@ def show(data,
684
405
  if 'xy' in dim_order:
685
406
  data = data.T
686
407
  if channel_axis == 'guess':
687
- channel_axis = find_channel_axis(data)
408
+ channel_axis = utils.find_channel_axis(data)
688
409
  if utils.isint(channel_axis) and channel_axis != -1:
689
- data = np.moveaxis(data, find_channel_axis(data), -1)
410
+ data = np.moveaxis(data, utils.find_channel_axis(data), -1)
690
411
 
691
412
  if convert_to_8bit and data.dtype != np.uint8:
692
413
  data = operations.to_8bit(data)
@@ -711,60 +432,6 @@ def show(data,
711
432
  imshow = show # Function name alias
712
433
 
713
434
 
714
- def find_channel_axis(data,
715
- possible_channel_axes=[-1, 0],
716
- possible_channel_lengths=[2, 3, 4]) -> Union[int, None]:
717
- """
718
- If the given numpy array has a shape suggesting that it has a
719
- channel (color) axis (that is, any axis with length 2 (2-color),
720
- 3 (RGB), or 4 (RGBA)), return the index of that axis.
721
-
722
- Parameters
723
- ----------
724
- data : numpy.ndarray
725
- The numpy array to check for a channel axis.
726
-
727
- possible_channel_axes : int or list of int, default [0, -1]
728
- If None, any axis having length 2, 3, or 4 will be considered
729
- a channel axis.
730
- If an int, only that axis index will be checked.
731
- If a list of ints, all axes with those indices will be checked.
732
- The default value of [-1, 0] checks the last and first axes, which is
733
- almost always where a channel axis will be found.
734
-
735
- possible_channel_lengths : int or list of int, default [2, 3, 4]
736
- If an int, only that length will be considered a channel axis.
737
- If a list of ints, an axis with any of those lengths will be considered
738
- a channel axis.
739
-
740
- Returns
741
- -------
742
- int or None
743
- The index of the channel axis, or None if no channel axis was found.
744
-
745
- If possible_channel_axes is given, the returned value will be one of
746
- the possible_channel_axes values, or None if no channel axis was found.
747
-
748
- If possible_channel_axes is None, the returned value will be between
749
- 0 and data.ndim - 1, inclusive, or None if no channel axis was found.
750
-
751
- Note that returning 0 means the channel axis was found and is the first
752
- axis, so be careful not to do a test like `if find_channel_axis(data):`
753
- because 0 will evaluate to False even though the data has a channel axis.
754
- Instead write `if find_channel_axis(data) is not None:`
755
- """
756
- if isinstance(possible_channel_axes, int):
757
- possible_channel_axes = [possible_channel_axes]
758
- if possible_channel_axes is None:
759
- possible_channel_axes = range(data.ndim)
760
- if isinstance(possible_channel_lengths, int):
761
- possible_channel_lengths = [possible_channel_lengths]
762
- for axis in possible_channel_axes:
763
- if data.shape[axis] in possible_channel_lengths:
764
- return axis
765
- return None
766
-
767
-
768
435
  # Flag to track if HEIF opener has been registered
769
436
  _heif_opener_registered = False
770
437
  def _ensure_heif_opener_registered() -> None:
@@ -11,6 +11,7 @@ Function list:
11
11
  - transpose_metadata (reverse the order of all per-axis metadata values)
12
12
  """
13
13
 
14
+ from typing import Union
14
15
  from collections import OrderedDict
15
16
 
16
17
  import numpy as np
@@ -56,6 +57,60 @@ def isint(n):
56
57
  return isinstance(n, int) or np.issubdtype(type(n), np.integer)
57
58
 
58
59
 
60
+ def find_channel_axis(data,
61
+ possible_channel_axes=[-1, 0],
62
+ possible_channel_lengths=[2, 3, 4]) -> Union[int, None]:
63
+ """
64
+ If the given numpy array has a shape suggesting that it has a
65
+ channel (color) axis (that is, any axis with length 2 (2-color),
66
+ 3 (RGB), or 4 (RGBA)), return the index of that axis.
67
+
68
+ Parameters
69
+ ----------
70
+ data : numpy.ndarray
71
+ The numpy array to check for a channel axis.
72
+
73
+ possible_channel_axes : int or list of int, default [0, -1]
74
+ If None, any axis having length 2, 3, or 4 will be considered
75
+ a channel axis.
76
+ If an int, only that axis index will be checked.
77
+ If a list of ints, all axes with those indices will be checked.
78
+ The default value of [-1, 0] checks the last and first axes, which is
79
+ almost always where a channel axis will be found.
80
+
81
+ possible_channel_lengths : int or list of int, default [2, 3, 4]
82
+ If an int, only that length will be considered a channel axis.
83
+ If a list of ints, an axis with any of those lengths will be considered
84
+ a channel axis.
85
+
86
+ Returns
87
+ -------
88
+ int or None
89
+ The index of the channel axis, or None if no channel axis was found.
90
+
91
+ If possible_channel_axes is given, the returned value will be one of
92
+ the possible_channel_axes values, or None if no channel axis was found.
93
+
94
+ If possible_channel_axes is None, the returned value will be between
95
+ 0 and data.ndim - 1, inclusive, or None if no channel axis was found.
96
+
97
+ Note that returning 0 means the channel axis was found and is the first
98
+ axis, so be careful not to do a test like `if find_channel_axis(data):`
99
+ because 0 will evaluate to False even though the data has a channel axis.
100
+ Instead write `if find_channel_axis(data) is not None:`
101
+ """
102
+ if isinstance(possible_channel_axes, int):
103
+ possible_channel_axes = [possible_channel_axes]
104
+ if possible_channel_axes is None:
105
+ possible_channel_axes = range(data.ndim)
106
+ if isinstance(possible_channel_lengths, int):
107
+ possible_channel_lengths = [possible_channel_lengths]
108
+ for axis in possible_channel_axes:
109
+ if data.shape[axis] in possible_channel_lengths:
110
+ return axis
111
+ return None
112
+
113
+
59
114
  def transpose_metadata(metadata: dict or OrderedDict,
60
115
  inplace: bool = True) -> dict or OrderedDict or None:
61
116
  """
@@ -0,0 +1,625 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Functions for reading and writing videos.
4
+
5
+ Function list:
6
+ - load_video(filename) -> np.ndarray
7
+ - lazy_load_video(filename) -> Iterator[np.ndarray]
8
+ - save_video(data, filename) -> Saves a 3D numpy array as a video
9
+
10
+ Class list:
11
+ - VideoStreamer: Provides fast random access to frames in a video file
12
+ via VideoStreamer[frame_number]
13
+ - VideoWriter: Allows writing frames one-by-one to a video file via
14
+ VideoWriter.write(image). This can be advantageous compared to save_video
15
+ when you don't want to ever have to have all the frames in memory at once.
16
+
17
+ """
18
+
19
+ from typing import Union, Tuple, Iterator, Literal
20
+ from pathlib import Path
21
+ import os
22
+ import subprocess
23
+ import threading
24
+ import json
25
+
26
+ import numpy as np
27
+
28
+ from . import utils
29
+
30
+ codec_aliases = {
31
+ "libx264": "libx264",
32
+ "avc1": "libx264",
33
+ "h264": "libx264",
34
+ "H.264": "libx264",
35
+ "libx265": "libx265",
36
+ "hevc": "libx265",
37
+ "hvc1": "libx265",
38
+ "hev1": "libx265",
39
+ "h265": "libx265",
40
+ "H.265": "libx265",
41
+ }
42
+
43
+ supported_extensions = ['mp4', 'mkv', 'avi', 'mov']
44
+
45
+
46
+ def load_video(filename,
47
+ return_framerate=False,
48
+ progress_bar=True) -> Union[np.ndarray, Tuple[np.ndarray, float]]:
49
+ """
50
+ Load all images in a video file as a numpy array.
51
+
52
+ Parameters
53
+ ----------
54
+ filename : str
55
+ Path to the video file
56
+ return_framerate : bool, default False
57
+ If True, return the frame rate of the video
58
+ progress_bar : bool, default True
59
+ If True, display a progress bar
60
+
61
+ Returns
62
+ -------
63
+ If return_framerate is False:
64
+ data : numpy.ndarray
65
+ The video frames as a numpy array, shape (num_frames, height, width, colors)
66
+ If return_framerate is True:
67
+ (data, framerate) : tuple, where data is as above and:
68
+ framerate : float
69
+ The frame rate of the video in frames per second
70
+ """
71
+ try:
72
+ import av
73
+ from tqdm import tqdm
74
+ except ImportError:
75
+ raise ImportError('Missing optional dependency for video processing,'
76
+ ' run `pip install av tqdm`')
77
+
78
+ container = av.open(filename)
79
+ stream = container.streams.video[0]
80
+ num_frames = stream.frames
81
+ if not num_frames or num_frames == 0:
82
+ # If we don't know the number of frames, we can't preallocate, so
83
+ # it's hard to do better than the following approach which temporarily
84
+ # uses double the amount of RAM compared to the preallocated approach.
85
+ data = np.array(list(lazy_load_video(filename)))
86
+ if return_framerate:
87
+ return data, float(stream.average_rate)
88
+ else:
89
+ return data
90
+ else:
91
+ # Load first image to get shape and dtype
92
+ frame_iter = container.decode(stream)
93
+ first_frame = next(frame_iter)
94
+ first_img = first_frame.to_ndarray(format='rgb24')
95
+ # Preallocate memory for the entire array
96
+ data = np.empty((num_frames, *first_img.shape), dtype=first_img.dtype)
97
+ # Then fill it up frame by frame
98
+ data[0] = first_img
99
+ for i, frame in tqdm(enumerate(frame_iter), total=num_frames,
100
+ desc='Loading video', disable=not progress_bar):
101
+ if i == 0:
102
+ continue
103
+ img = frame.to_ndarray(format='rgb24')
104
+ data[i] = img
105
+ if return_framerate:
106
+ return data, float(stream.average_rate)
107
+ else:
108
+ return data
109
+
110
+
111
+ def lazy_load_video(filename) -> Iterator[np.ndarray]:
112
+ """
113
+ Lazily load video frames as numpy arrays using PyAV.
114
+
115
+ This iterator yields images in the order they appear in the video.
116
+ If you want reasonably fast random access to arbitrary frames in
117
+ a video, use the VideoStreamer class instead.
118
+
119
+ Parameters
120
+ ----------
121
+ filename : str
122
+ Path to the video file.
123
+
124
+ Yields
125
+ ------
126
+ frame : np.ndarray
127
+ Video frame as a numpy array, shape (height, width, colors).
128
+ """
129
+ try:
130
+ import av
131
+ except ImportError:
132
+ raise ImportError('Missing optional dependency for video processing,'
133
+ ' run `pip install av tqdm`')
134
+ container = av.open(filename)
135
+ stream = container.streams.video[0]
136
+ for frame in container.decode(stream):
137
+ img = frame.to_ndarray(format='rgb24')
138
+ yield img
139
+
140
+
141
+ class VideoStreamer:
142
+ def __init__(self, filename):
143
+ try:
144
+ import av
145
+ except ImportError:
146
+ raise ImportError('Missing optional dependency for video processing,'
147
+ ' run `pip install av tqdm`')
148
+ self.filename = Path(filename)
149
+ if not self.filename.exists():
150
+ raise FileNotFoundError(f"File {filename} not found")
151
+ self.index_filename = self.filename.parent / (self.filename.stem + '_index.json')
152
+ self._load_index()
153
+
154
+ self.container = av.open(str(self.filename))
155
+ self.stream = self.container.streams.video[0]
156
+ self.time_base = self.stream.time_base
157
+ self._shape = None
158
+ self._first_frame = None
159
+ self._width = None
160
+ self._height = None
161
+ self._ndim = None
162
+ self._dtype = None
163
+ self._current_frame_number = None
164
+ self._lock = threading.Lock()
165
+
166
+ def _build_index(self):
167
+ print("Building index for fast random frame access...")
168
+ cmd = [
169
+ 'ffprobe',
170
+ '-select_streams', 'v:0',
171
+ '-show_frames',
172
+ '-show_entries', 'frame=pkt_pos,pkt_pts_time,coded_picture_number',
173
+ '-of', 'json',
174
+ self.filename
175
+ ]
176
+ result = subprocess.run(cmd, stdout=subprocess.PIPE,
177
+ stderr=subprocess.PIPE, text=True)
178
+ if result.returncode != 0:
179
+ raise RuntimeError(f"ffprobe failed: {result.stderr}")
180
+ frames = json.loads(result.stdout).get('frames', [])
181
+ time_index = [float(frame['pkt_pts_time']) for frame in frames]
182
+ if len(time_index) == 0:
183
+ raise RuntimeError("No frames found in video")
184
+
185
+ self.n_frames = len(time_index)
186
+ self.t0 = time_index[0]
187
+ self.rotation = _detect_rotation(self.filename)
188
+ index = {
189
+ 'n_frames': self.n_frames,
190
+ 't0': self.t0,
191
+ 'rotation': self.rotation,
192
+ }
193
+
194
+ # Determine whether the video is constant or variable framerate
195
+ time_deltas = np.diff(time_index) if len(time_index) > 1 else None
196
+ if time_deltas is not None and np.allclose(time_deltas, time_deltas[0], atol=1e-6):
197
+ self.timestep = float(time_deltas[0]) # The video is constant framerate
198
+ index['timestep'] = self.timestep
199
+ else:
200
+ index.update({
201
+ 'timestep': 'variable', # The video is variable framerate
202
+ 'time_index': time_index,
203
+ })
204
+ with open(self.index_filename, 'w') as f:
205
+ json.dump(index, f)
206
+ print(f"Saved index to {self.index_filename} so this slow step is not needed again.")
207
+
208
+ def _load_index(self):
209
+ if not self.index_filename.exists():
210
+ self._build_index()
211
+
212
+ with open(self.index_filename, 'r') as f:
213
+ index = json.load(f)
214
+ self.n_frames = index['n_frames']
215
+ self.t0 = index['t0']
216
+ self.rotation = index.get('rotation', None)
217
+
218
+ if not isinstance(index['timestep'], (str, float, int)):
219
+ raise ValueError('Malformed index: timestep is not a string or number')
220
+
221
+ if index['timestep'] == 'variable':
222
+ self.timestep = 'variable'
223
+ self.time_index = index['time_index']
224
+ else:
225
+ self.timestep = float(index['timestep'])
226
+ if self.timestep <= 0:
227
+ raise ValueError('Malformed index: timestep is not positive')
228
+
229
+ def frame_to_time(self, frame_number):
230
+ if self.timestep == 'variable':
231
+ return self.time_index[frame_number]
232
+ else:
233
+ return self.timestep * frame_number + self.t0
234
+
235
+ def __getitem__(self, key):
236
+ if isinstance(key, tuple):
237
+ frame_idx = key[0]
238
+ frame = self._get_frame(frame_idx)
239
+ if len(key) == 1:
240
+ return frame
241
+ else:
242
+ return frame[key[1:]]
243
+ else:
244
+ return self._get_frame(key)
245
+
246
+ def _get_frame(self, frame_number):
247
+ """
248
+ Provides access to random frames as fast as is reasonable when getting
249
+ frames from compressed video in python.
250
+ """
251
+
252
+ def decode_until(frame_number) -> np.ndarray:
253
+ """
254
+ Decode forward from the current frame in the stream until we get
255
+ to the requested frame number.
256
+ """
257
+ target_time = self.frame_to_time(frame_number)
258
+ for frame in self.container.decode(self.stream):
259
+ frame_time = float(frame.pts * self.time_base)
260
+ if abs(frame_time - target_time) < 1e-6:
261
+ frame = frame.to_ndarray(format='rgb24')
262
+ self._current_frame_number = frame_number
263
+ return frame
264
+ if frame_time > target_time:
265
+ raise RuntimeError(f"Frame with time {target_time} not found after"
266
+ f" seeking – current frame time: {frame_time}")
267
+ raise RuntimeError(f"Frame with time {target_time} not found after"
268
+ f" seeking – current frame time: {frame_time}")
269
+
270
+ if isinstance(frame_number, slice): # Support slicing
271
+ start, stop, step = frame_number.indices(self.n_frames)
272
+ return np.array([self._get_frame(i) for i in range(start, stop, step)])
273
+ with self._lock:
274
+ # Support negative indexing
275
+ if frame_number < 0 and frame_number + self.n_frames >= 0:
276
+ frame_number += self.n_frames
277
+ elif frame_number >= self.n_frames:
278
+ raise IndexError(f"Frame {frame_number} out of range:"
279
+ f" [0, {self.n_frames-1}]")
280
+ elif frame_number < 0:
281
+ raise IndexError(f"Negative frame {frame_number} out of"
282
+ f" range: [-{self.n_frames}, -1]")
283
+
284
+ if (self._current_frame_number is None
285
+ or frame_number <= self._current_frame_number
286
+ or frame_number > self._current_frame_number + 100):
287
+ target_time = self.frame_to_time(frame_number)
288
+ seek_time = int(target_time / float(self.time_base))
289
+ # The following actually seeks to the closest keyframe before
290
+ # seek_time, because it's not possible to seek directly to
291
+ # non-keyframes due to video files being compressed.
292
+ self.container.seek(seek_time, any_frame=False,
293
+ backward=True, stream=self.stream)
294
+ # Now we decode frames forward until we get to the requested frame
295
+ return _rotate(decode_until(frame_number), self.rotation)
296
+
297
+ @property
298
+ def average_timestep(self):
299
+ """
300
+ Returns the average time step between frames.
301
+ """
302
+ if self.timestep == 'variable':
303
+ return (self.time_index[-1] - self.time_index[0]) / (self.n_frames - 1)
304
+ else:
305
+ return self.timestep
306
+
307
+ @property
308
+ def first_frame(self):
309
+ if self._first_frame is None:
310
+ self._first_frame = self[0]
311
+ return self._first_frame
312
+
313
+ @property
314
+ def shape(self):
315
+ # (num_frames, height, width, channels)
316
+ if self._shape is None:
317
+ self._shape = (self.n_frames,) + self.first_frame.shape
318
+ return self._shape
319
+
320
+ @property
321
+ def width(self):
322
+ if self._width is None:
323
+ self._width = self.first_frame.shape[1]
324
+ return self._width
325
+
326
+ @property
327
+ def height(self):
328
+ if self._height is None:
329
+ self._height = self.first_frame.shape[0]
330
+ return self._height
331
+
332
+ @property
333
+ def ndim(self):
334
+ if self._ndim is None:
335
+ self._ndim = len(self.shape)
336
+ return self._ndim
337
+
338
+ @property
339
+ def dtype(self):
340
+ if self._dtype is None:
341
+ self._dtype = self.first_frame.dtype
342
+ return self._dtype
343
+
344
+ def __len__(self):
345
+ return self.n_frames
346
+
347
+ def close(self):
348
+ self.container.close()
349
+
350
+ def __del__(self):
351
+ try:
352
+ self.close()
353
+ except Exception:
354
+ pass
355
+
356
+
357
+ class VideoWriter:
358
+ """
359
+ Create a video writer object for saving frames to a video file.
360
+
361
+ Example usage:
362
+ >>> with VideoWriter('output.mp4', framerate=30) as writer:
363
+ >>> for i in range(n_frames):
364
+ >>> frame = do_something_to_build_an_image(i)
365
+ >>> writer.write(frame)
366
+
367
+ This allows you to write a bunch of frames to a video file without
368
+ ever needing to store all the frames in memory at once. If you have all
369
+ your frames in memory already, you could use save_video(data, filename)
370
+
371
+ Parameters
372
+ ----------
373
+ filename : str
374
+ The filename to save the video to.
375
+ framerate : int or float, default 30
376
+ The frame rate of the video.
377
+ crf : int, default 23
378
+ Constant Rate Factor for encoding quality (lower is better quality).
379
+ compression_speed : str, default 'medium'
380
+ Compression speed preset: 'ultrafast', 'superfast', 'veryfast',
381
+ 'faster', 'fast', 'medium', 'slow', 'slower', 'veryslow'.
382
+ codec : Literal['libx264', 'libx265'], default 'libx264'
383
+ The video codec to use for encoding. Can be any of a number of aliases for
384
+ these two codecs, including avc1/h264 vs hevc/hvc1/hev1/h265.
385
+ """
386
+ def __init__(self, filename, framerate=30, crf=23, compression_speed='medium',
387
+ codec: Literal['libx264', 'libx265'] = 'libx264',
388
+ overwrite=False):
389
+ try:
390
+ import av
391
+ except ImportError:
392
+ raise ImportError('Missing optional dependency for video processing,'
393
+ ' run `pip install av tqdm`')
394
+ self.av = av
395
+ from fractions import Fraction
396
+ filename = os.path.expanduser(str(filename))
397
+ if os.path.exists(filename) and not overwrite:
398
+ raise FileExistsError(f'File {filename} already exists. '
399
+ 'Set overwrite=True to overwrite.')
400
+ self.filename = filename
401
+ self.framerate = str(framerate) # str instead of float to avoid precision issues
402
+ while Fraction(self.framerate).denominator >= 2**32 or Fraction(self.framerate).numerator >= 2**32:
403
+ # If framerate has too many decimals to be expressed as a
404
+ # ratio of 32-bit ints, which is required by ffmpeg, crop
405
+ # off one decimal point of precision until it is expressable
406
+ self.framerate = self.framerate[:-1]
407
+ if self.framerate[-1] == '.':
408
+ self.framerate = self.framerate[:-1]
409
+ if len(self.framerate) == 0:
410
+ raise RuntimeError('Error occurred handling framerate argument')
411
+ self.crf = crf
412
+ self.compression_speed = compression_speed
413
+ self.codec = codec_aliases[codec.lower()]
414
+ self.container = av.open(filename, mode='w')
415
+ self.stream = self.container.add_stream(self.codec, rate=Fraction(self.framerate))
416
+ self.stream.pix_fmt = 'yuv420p'
417
+ self.stream.options = {'crf': str(crf), 'preset': compression_speed}
418
+ self._closed = False
419
+ self.stream.width = 0
420
+ self.stream.height = 0
421
+
422
+ def write(self, frame):
423
+ if not isinstance(frame, self.av.VideoFrame):
424
+ if frame.ndim == 3 and frame.shape[-1] == 3:
425
+ frame = self.av.VideoFrame.from_ndarray(frame, format='rgb24')
426
+ elif frame.ndim == 3 and frame.shape[-1] == 4:
427
+ # While some video codecs support an alpha channel, most don't,
428
+ # so for now we're just going to ignore the alpha channel
429
+ frame = self.av.VideoFrame.from_ndarray(frame[..., :3], format='rgb24')
430
+ elif frame.ndim == 2:
431
+ frame = self.av.VideoFrame.from_ndarray(frame, format='gray')
432
+ else:
433
+ raise ValueError(f'Frame must be (H, W) (H, W, 3) or (H, W, 4) but was {frame.shape}')
434
+ if self.stream.width == 0:
435
+ self.stream.width = frame.width
436
+ if self.stream.height == 0:
437
+ self.stream.height = frame.height
438
+ for packet in self.stream.encode(frame):
439
+ self.container.mux(packet)
440
+
441
+ def __enter__(self):
442
+ return self
443
+
444
+ def __exit__(self, exc_type, exc_val, exc_tb):
445
+ # Flush stream
446
+ for packet in self.stream.encode():
447
+ self.container.mux(packet)
448
+ self.container.close()
449
+ self._closed = True
450
+
451
+
452
+ def save_video(data, filename, time_axis=0, color_axis=None, overwrite=False,
453
+ dim_order='yx', framerate=30, crf=23, compression_speed='medium',
454
+ progress_bar=True, codec: Literal['libx264', 'libx265'] = 'libx264') -> None:
455
+ """
456
+ Save a 3D numpy array of greyscale values OR a 4D numpy array of RGB values as a video
457
+
458
+ Follows the PyAV cookbook section on generating video from numpy arrays:
459
+ https://pyav.basswood-io.com/docs/develop/cookbook/numpy.html#generating-video
460
+
461
+ Parameters
462
+ ----------
463
+ data : numpy.ndarray or list of filenames
464
+ A 3D (grayscale) or 4D (RGB) numpy array of pixel values.
465
+
466
+ filename : str
467
+ The filename to save the video to.
468
+
469
+ time_axis : int, default 0
470
+ The axis of the data array that will be played as time in the video.
471
+
472
+ color_axis : int or None, default None
473
+ If not None, specifies the axis of the color channels (e.g., -1 for last axis,
474
+ 1 for second axis).
475
+ If None, data must be 3D (greyscale) or 4D with one length 3 axis (RGB).
476
+
477
+ overwrite : bool, default False
478
+ Whether to overwrite the file if it already exists.
479
+
480
+ dim_order : 'yx' (default) or 'xy'
481
+ The order of the spatial dimensions in the input data.
482
+
483
+ framerate : int, default 30
484
+ The frame rate of the video.
485
+
486
+ crf : int, default 23
487
+ Constant Rate Factor that specifies amount of lossiness allowed in compression.
488
+ Lower values produce better quality, larger videos. crf=17 has no human-visible
489
+ compression artifacts. File size approximately doubles/halves each time you
490
+ add/subtract 6 from crf, so crf=17 produces files about twice as large as
491
+ the default crf=23.
492
+
493
+ compression_speed : str, default 'medium'
494
+ Compression speed preset: 'ultrafast', 'superfast', 'veryfast',
495
+ 'faster', 'fast', 'medium', 'slow', 'slower', 'veryslow'.
496
+
497
+ progress_bar : bool, default True
498
+ If True, display a progress bar.
499
+
500
+ codec : Literal['libx264', 'libx265'], default 'libx264'
501
+ The video codec to use for encoding. Can be any of a number of aliases for
502
+ these two codecs, including avc1/h264 vs hevc/hvc1/hev1/h265.
503
+ """
504
+ try:
505
+ from tqdm import tqdm
506
+ except ImportError:
507
+ raise ImportError('Missing optional dependency for video processing,'
508
+ ' run `pip install av tqdm`')
509
+
510
+ filename = os.path.expanduser(str(filename))
511
+ if filename.split('.')[-1].lower() not in supported_extensions:
512
+ filename += '.mp4'
513
+ if os.path.exists(filename) and not overwrite:
514
+ raise FileExistsError(f'File {filename} already exists. '
515
+ 'Set overwrite=True to overwrite.')
516
+
517
+ if color_axis is None and data.ndim == 4:
518
+ color_axis = utils.find_channel_axis(data, possible_channel_lengths=3)
519
+ if color_axis is None:
520
+ raise ValueError('4D input data must have an RGB (length 3) axis.')
521
+ if color_axis is not None:
522
+ if data.ndim != 4:
523
+ raise ValueError('Input data must be 4D when color_axis is specified.')
524
+ # Move time axis to 0, color axis to -1
525
+ data = np.moveaxis(data, time_axis, 0)
526
+ if color_axis != -1:
527
+ data = np.moveaxis(data, color_axis, -1)
528
+ if 'xy' in dim_order:
529
+ data = data.swapaxes(1, 2)
530
+ n_frames = data.shape[0]
531
+ height, width, channels = data.shape[1:]
532
+ if channels != 3:
533
+ raise ValueError(f'Color video must have 3 channels (RGB) but had {channels}.')
534
+ else:
535
+ if data.ndim != 3:
536
+ raise ValueError('Input data must be 3D when color_axis is not specified.')
537
+ data = np.moveaxis(data, time_axis, 0)
538
+ if 'xy' in dim_order:
539
+ data = data.swapaxes(1, 2)
540
+ n_frames = data.shape[0]
541
+ height, width = data.shape[1:]
542
+
543
+ extension = filename.split('.')[-1].lower()
544
+ if extension == 'mp4':
545
+ pad = [[0, 0], [0, 0], [0, 0]]
546
+ if height % 2 != 0:
547
+ pad[1][1] = 1
548
+ if width % 2 != 0:
549
+ pad[2][1] = 1
550
+ if pad != [[0, 0], [0, 0], [0, 0]]:
551
+ data = np.pad(data, pad, mode='edge')
552
+
553
+ with VideoWriter(filename, framerate=framerate, crf=crf,
554
+ compression_speed=compression_speed, codec=codec,
555
+ overwrite=overwrite) as writer:
556
+ for frame_i in tqdm(range(n_frames), total=n_frames,
557
+ desc='Saving video', disable=not progress_bar):
558
+ writer.write(data[frame_i])
559
+
560
+
561
+ def _detect_rotation(filename):
562
+ """
563
+ Detect rotation metadata from the video file using ffprobe.
564
+ Returns the rotation angle as a string (e.g., '90', '180', '270'), or None if not present.
565
+ """
566
+ import subprocess
567
+ import json
568
+ cmd = [
569
+ 'ffprobe',
570
+ '-v', 'quiet',
571
+ '-select_streams', 'v:0',
572
+ '-show_entries', 'stream_tags=rotate',
573
+ '-of', 'json',
574
+ str(filename)
575
+ ]
576
+ result = subprocess.run(cmd, stdout=subprocess.PIPE,
577
+ stderr=subprocess.PIPE, text=True)
578
+ if result.returncode != 0:
579
+ return None
580
+ try:
581
+ data = json.loads(result.stdout)
582
+ streams = data.get('streams', [])
583
+ if streams and 'tags' in streams[0]:
584
+ rotate_tag = streams[0]['tags'].get('rotate')
585
+ if rotate_tag:
586
+ return rotate_tag
587
+ except (json.JSONDecodeError, KeyError, ValueError):
588
+ pass
589
+ return None
590
+
591
+
592
+ def _rotate(data: np.ndarray, rotation: Union[str, int, None] = 0) -> np.ndarray:
593
+ """
594
+ Apply clockwise rotation to a numpy array
595
+
596
+ Parameters
597
+ ----------
598
+ data : np.ndarray
599
+ The numpy array to rotate.
600
+ rotation : str, int, or None, default 0
601
+ The rotation angle in degrees, as a string or integer.
602
+ Valid values are 0, 90, 180, 270, or 360.
603
+ If None, 0, or 360, no rotation is applied.
604
+
605
+ TODO find an actual video file with rotation_tag of 90 or 270 and check that
606
+ the rotation is applied in the right direction. If it's opposite the correct
607
+ direction, remove axes=(1, 0) from the np.rot90 calls in this function.
608
+ """
609
+ if rotation is None:
610
+ return data
611
+ try:
612
+ rotation = int(rotation)
613
+ except (ValueError, TypeError):
614
+ raise ValueError(f"Invalid rotation value: {rotation}")
615
+
616
+ if rotation in [0, 360]:
617
+ return data
618
+ if rotation == 90:
619
+ return np.rot90(data, k=1, axes=(1, 0))
620
+ elif rotation == 180:
621
+ return np.rot90(data, k=2, axes=(1, 0))
622
+ elif rotation == 270:
623
+ return np.rot90(data, k=3, axes=(1, 0))
624
+ else:
625
+ raise ValueError(f"Invalid rotation value: {rotation}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: numpyimage
3
- Version: 2.6.0
3
+ Version: 2.6.2
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: GNU GENERAL PUBLIC LICENSE
@@ -701,18 +701,19 @@ Requires-Dist: av; extra == "all"
701
701
  Dynamic: license-file
702
702
 
703
703
  # npimage
704
- Need to load pixel values from image files as numpy arrays, and hate having to remember whether you should use PIL, tifffile, matplotlib, or something else? Hate having to deal with the fact that those libraries all use different function names and syntaxes? Wish you could just provide a filename and get back a numpy array? This library's `core.py` does that, with `array = load(filename)`, `save(array, filename)`, and `show(array)` functions that let you easily handle a number of common image file formats without having to remember library-specific syntax. (Another choice of library to consider for accomplishing similar goals is [imageio](https://pypi.org/project/imageio/), which also supports loading videos through the FFmpeg wrapper library [pyav](https://pypi.org/project/av/).)
704
+ Need to load pixel values from image files as numpy arrays, and hate having to remember whether you should use PIL, tifffile, matplotlib, or something else? Hate having to deal with the fact that those libraries all use different function names and syntaxes? Wish you could just provide a filename and get back a numpy array? This library's `imageio.py` does that, with `array = npimage.load(filename)`, `npimage.save(array, filename)`, and `npimage.show(array)` functions that let you easily handle a number of common image file formats without having to remember library-specific syntax. Additionally, `vidio.py` provides `array = npimage.load_video(filename)` and `npimage.save_video(array, filename)` for videos as well. (Another similar library to consider using is [imageio](https://pypi.org/project/imageio/).)
705
705
 
706
706
  Want to draw simple shapes like lines, triangles, and circles into 3D numpy arrays? Frustrated that the python libraries you can find online like `opencv` and `skimage.draw` work on 2D arrays but not 3D? I wrote some functions in `graphics.py` that do the trick in 3D. (If you know of another library that can do this, please let me know!)
707
707
 
708
708
 
709
709
  ### Documentation
710
- - `core.py`: load, save, or show images.
710
+ - `imageio.py`: load, save, or show images.
711
+ - `vidio.py`: load or save videos.
711
712
  - `graphics.py`: draw points, lines, triangles, circles, or spheres into 2D or 3D numpy arrays representing image volumes.
712
713
  - `nrrd_utils.py`: compress or read metadata from `.nrrd` files.
713
714
  - `operations.py`: perform operations on images.
714
715
 
715
- For now, check each function's docstring for more. A jupyter notebook demonstrating this package's functions will come later.
716
+ Check each function's docstring for more details.
716
717
 
717
718
 
718
719
  ### Installation
@@ -3,11 +3,12 @@ README.md
3
3
  pyproject.toml
4
4
  npimage/__init__.py
5
5
  npimage/align.py
6
- npimage/core.py
7
6
  npimage/graphics.py
7
+ npimage/imageio.py
8
8
  npimage/nrrd_utils.py
9
9
  npimage/operations.py
10
10
  npimage/utils.py
11
+ npimage/vidio.py
11
12
  numpyimage.egg-info/PKG-INFO
12
13
  numpyimage.egg-info/SOURCES.txt
13
14
  numpyimage.egg-info/dependency_links.txt
@@ -4,7 +4,7 @@ build-backend = 'setuptools.build_meta'
4
4
 
5
5
  [project]
6
6
  name = 'numpyimage'
7
- version = '2.6.0'
7
+ version = '2.6.2'
8
8
  description = 'Load, save, & manipulate image files as numpy arrays'
9
9
  readme.file = 'README.md'
10
10
  readme.content-type = 'text/markdown'
File without changes
File without changes
File without changes
File without changes