numpyimage 2.6.1__tar.gz → 2.6.3__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.1
3
+ Version: 2.6.3
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
@@ -15,7 +15,7 @@ from builtins import open as builtin_open
15
15
 
16
16
  import numpy as np
17
17
 
18
- from . import operations, utils
18
+ from . import vidio, operations, utils
19
19
 
20
20
  supported_extensions = [
21
21
  'tif', 'tiff', 'jpg', 'jpeg', 'png', 'pbm',
@@ -43,7 +43,9 @@ def load(filename, dim_order='zyx', **kwargs) -> Union[np.ndarray, Tuple[np.ndar
43
43
  raise ValueError('Could not determine file format from filename'
44
44
  f' "{filename}". Please specify the file type via'
45
45
  ' the `format` argument, e.g. format="tif"')
46
- 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:
47
49
  raise ValueError(f'File format of "{filename}" not supported/recognized.')
48
50
 
49
51
  data = None
@@ -178,7 +180,7 @@ def save(data,
178
180
  '.nrrd. Whether or not compression occurs now will depend on '
179
181
  'the format you are saving to.')
180
182
 
181
- channel_axis = find_channel_axis(data)
183
+ channel_axis = utils.find_channel_axis(data)
182
184
  if 'xy' in dim_order:
183
185
  data = data.T
184
186
  if hasattr(pixel_size, '__iter__') and not isinstance(pixel_size, str):
@@ -392,7 +394,7 @@ def show(data,
392
394
  if data_type == 'segmentation':
393
395
  data = utils.assign_random_colors(data, seed=kwargs.get('seed', None))
394
396
 
395
- 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):
396
398
  m = ('Data must have shape (y, x) for grayscale, '
397
399
  '(y, x, 3) for RGB, or (y, x, 4) for RGBA but had '
398
400
  f'shape {data.shape}')
@@ -403,9 +405,9 @@ def show(data,
403
405
  if 'xy' in dim_order:
404
406
  data = data.T
405
407
  if channel_axis == 'guess':
406
- channel_axis = find_channel_axis(data)
408
+ channel_axis = utils.find_channel_axis(data)
407
409
  if utils.isint(channel_axis) and channel_axis != -1:
408
- data = np.moveaxis(data, find_channel_axis(data), -1)
410
+ data = np.moveaxis(data, utils.find_channel_axis(data), -1)
409
411
 
410
412
  if convert_to_8bit and data.dtype != np.uint8:
411
413
  data = operations.to_8bit(data)
@@ -430,60 +432,6 @@ def show(data,
430
432
  imshow = show # Function name alias
431
433
 
432
434
 
433
- def find_channel_axis(data,
434
- possible_channel_axes=[-1, 0],
435
- possible_channel_lengths=[2, 3, 4]) -> Union[int, None]:
436
- """
437
- If the given numpy array has a shape suggesting that it has a
438
- channel (color) axis (that is, any axis with length 2 (2-color),
439
- 3 (RGB), or 4 (RGBA)), return the index of that axis.
440
-
441
- Parameters
442
- ----------
443
- data : numpy.ndarray
444
- The numpy array to check for a channel axis.
445
-
446
- possible_channel_axes : int or list of int, default [0, -1]
447
- If None, any axis having length 2, 3, or 4 will be considered
448
- a channel axis.
449
- If an int, only that axis index will be checked.
450
- If a list of ints, all axes with those indices will be checked.
451
- The default value of [-1, 0] checks the last and first axes, which is
452
- almost always where a channel axis will be found.
453
-
454
- possible_channel_lengths : int or list of int, default [2, 3, 4]
455
- If an int, only that length will be considered a channel axis.
456
- If a list of ints, an axis with any of those lengths will be considered
457
- a channel axis.
458
-
459
- Returns
460
- -------
461
- int or None
462
- The index of the channel axis, or None if no channel axis was found.
463
-
464
- If possible_channel_axes is given, the returned value will be one of
465
- the possible_channel_axes values, or None if no channel axis was found.
466
-
467
- If possible_channel_axes is None, the returned value will be between
468
- 0 and data.ndim - 1, inclusive, or None if no channel axis was found.
469
-
470
- Note that returning 0 means the channel axis was found and is the first
471
- axis, so be careful not to do a test like `if find_channel_axis(data):`
472
- because 0 will evaluate to False even though the data has a channel axis.
473
- Instead write `if find_channel_axis(data) is not None:`
474
- """
475
- if isinstance(possible_channel_axes, int):
476
- possible_channel_axes = [possible_channel_axes]
477
- if possible_channel_axes is None:
478
- possible_channel_axes = range(data.ndim)
479
- if isinstance(possible_channel_lengths, int):
480
- possible_channel_lengths = [possible_channel_lengths]
481
- for axis in possible_channel_axes:
482
- if data.shape[axis] in possible_channel_lengths:
483
- return axis
484
- return None
485
-
486
-
487
435
  # Flag to track if HEIF opener has been registered
488
436
  _heif_opener_registered = False
489
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
  """
@@ -1,12 +1,19 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- Functions for reading, writing, and showing images.
3
+ Functions for reading and writing videos.
4
4
 
5
5
  Function list:
6
6
  - load_video(filename) -> np.ndarray
7
7
  - lazy_load_video(filename) -> Iterator[np.ndarray]
8
8
  - save_video(data, filename) -> Saves a 3D numpy array as a video
9
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
+
10
17
  """
11
18
 
12
19
  from typing import Union, Tuple, Iterator, Literal
@@ -18,7 +25,7 @@ import json
18
25
 
19
26
  import numpy as np
20
27
 
21
- from .imageio import find_channel_axis
28
+ from . import utils
22
29
 
23
30
  codec_aliases = {
24
31
  "libx264": "libx264",
@@ -33,6 +40,8 @@ codec_aliases = {
33
40
  "H.265": "libx265",
34
41
  }
35
42
 
43
+ supported_extensions = ['mp4', 'mkv', 'avi', 'mov']
44
+
36
45
 
37
46
  def load_video(filename,
38
47
  return_framerate=False,
@@ -51,8 +60,13 @@ def load_video(filename,
51
60
 
52
61
  Returns
53
62
  -------
54
- data : numpy.ndarray
55
- The video frames as a numpy array, shape (num_frames, height, width, colors)
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
56
70
  """
57
71
  try:
58
72
  import av
@@ -82,12 +96,17 @@ def load_video(filename,
82
96
  data = np.empty((num_frames, *first_img.shape), dtype=first_img.dtype)
83
97
  # Then fill it up frame by frame
84
98
  data[0] = first_img
99
+ container.seek(0, stream=stream)
85
100
  for i, frame in tqdm(enumerate(frame_iter), total=num_frames,
86
101
  desc='Loading video', disable=not progress_bar):
87
- if i == 0:
88
- continue
89
102
  img = frame.to_ndarray(format='rgb24')
103
+ if i == 0 and not np.array_equal(img, first_img):
104
+ raise RuntimeError('PyAV seek failed. Please report how this happened'
105
+ ' at github.com/jasper-tms/npimage/issues')
90
106
  data[i] = img
107
+ if (data[-1] == 0).all():
108
+ print('WARNING: Last frame of video is all zeros, this may'
109
+ ' indicate an error in video loading unless you expected this.')
91
110
  if return_framerate:
92
111
  return data, float(stream.average_rate)
93
112
  else:
@@ -134,7 +153,7 @@ class VideoStreamer:
134
153
  self.filename = Path(filename)
135
154
  if not self.filename.exists():
136
155
  raise FileNotFoundError(f"File {filename} not found")
137
- self.index_filename = Path(str(filename).replace('.mp4', '_index.json'))
156
+ self.index_filename = self.filename.parent / (self.filename.stem + '_index.json')
138
157
  self._load_index()
139
158
 
140
159
  self.container = av.open(str(self.filename))
@@ -142,6 +161,10 @@ class VideoStreamer:
142
161
  self.time_base = self.stream.time_base
143
162
  self._shape = None
144
163
  self._first_frame = None
164
+ self._width = None
165
+ self._height = None
166
+ self._ndim = None
167
+ self._dtype = None
145
168
  self._current_frame_number = None
146
169
  self._lock = threading.Lock()
147
170
 
@@ -163,11 +186,14 @@ class VideoStreamer:
163
186
  time_index = [float(frame['pkt_pts_time']) for frame in frames]
164
187
  if len(time_index) == 0:
165
188
  raise RuntimeError("No frames found in video")
189
+
166
190
  self.n_frames = len(time_index)
167
191
  self.t0 = time_index[0]
192
+ self.rotation = _detect_rotation(self.filename)
168
193
  index = {
169
194
  'n_frames': self.n_frames,
170
195
  't0': self.t0,
196
+ 'rotation': self.rotation,
171
197
  }
172
198
 
173
199
  # Determine whether the video is constant or variable framerate
@@ -187,11 +213,12 @@ class VideoStreamer:
187
213
  def _load_index(self):
188
214
  if not self.index_filename.exists():
189
215
  self._build_index()
190
- else:
191
- with open(self.index_filename, 'r') as f:
192
- index = json.load(f)
216
+
217
+ with open(self.index_filename, 'r') as f:
218
+ index = json.load(f)
193
219
  self.n_frames = index['n_frames']
194
220
  self.t0 = index['t0']
221
+ self.rotation = index.get('rotation', None)
195
222
 
196
223
  if not isinstance(index['timestep'], (str, float, int)):
197
224
  raise ValueError('Malformed index: timestep is not a string or number')
@@ -226,7 +253,7 @@ class VideoStreamer:
226
253
  Provides access to random frames as fast as is reasonable when getting
227
254
  frames from compressed video in python.
228
255
  """
229
-
256
+
230
257
  def decode_until(frame_number) -> np.ndarray:
231
258
  """
232
259
  Decode forward from the current frame in the stream until we get
@@ -249,8 +276,15 @@ class VideoStreamer:
249
276
  start, stop, step = frame_number.indices(self.n_frames)
250
277
  return np.array([self._get_frame(i) for i in range(start, stop, step)])
251
278
  with self._lock:
252
- if frame_number < 0 or frame_number >= self.n_frames:
253
- raise IndexError(f"Frame {frame_number} out of range: [0, {self.n_frames})")
279
+ # Support negative indexing
280
+ if frame_number < 0 and frame_number + self.n_frames >= 0:
281
+ frame_number += self.n_frames
282
+ elif frame_number >= self.n_frames:
283
+ raise IndexError(f"Frame {frame_number} out of range:"
284
+ f" [0, {self.n_frames-1}]")
285
+ elif frame_number < 0:
286
+ raise IndexError(f"Negative frame {frame_number} out of"
287
+ f" range: [-{self.n_frames}, -1]")
254
288
 
255
289
  if (self._current_frame_number is None
256
290
  or frame_number <= self._current_frame_number
@@ -263,7 +297,17 @@ class VideoStreamer:
263
297
  self.container.seek(seek_time, any_frame=False,
264
298
  backward=True, stream=self.stream)
265
299
  # Now we decode frames forward until we get to the requested frame
266
- return decode_until(frame_number)
300
+ return _rotate(decode_until(frame_number), self.rotation)
301
+
302
+ @property
303
+ def average_timestep(self):
304
+ """
305
+ Returns the average time step between frames.
306
+ """
307
+ if self.timestep == 'variable':
308
+ return (self.time_index[-1] - self.time_index[0]) / (self.n_frames - 1)
309
+ else:
310
+ return self.timestep
267
311
 
268
312
  @property
269
313
  def first_frame(self):
@@ -278,13 +322,29 @@ class VideoStreamer:
278
322
  self._shape = (self.n_frames,) + self.first_frame.shape
279
323
  return self._shape
280
324
 
325
+ @property
326
+ def width(self):
327
+ if self._width is None:
328
+ self._width = self.first_frame.shape[1]
329
+ return self._width
330
+
331
+ @property
332
+ def height(self):
333
+ if self._height is None:
334
+ self._height = self.first_frame.shape[0]
335
+ return self._height
336
+
281
337
  @property
282
338
  def ndim(self):
283
- return len(self.shape)
339
+ if self._ndim is None:
340
+ self._ndim = len(self.shape)
341
+ return self._ndim
284
342
 
285
343
  @property
286
344
  def dtype(self):
287
- return self.first_frame.dtype
345
+ if self._dtype is None:
346
+ self._dtype = self.first_frame.dtype
347
+ return self._dtype
288
348
 
289
349
  def __len__(self):
290
350
  return self.n_frames
@@ -317,7 +377,7 @@ class VideoWriter:
317
377
  ----------
318
378
  filename : str
319
379
  The filename to save the video to.
320
- framerate : int, default 30
380
+ framerate : int or float, default 30
321
381
  The frame rate of the video.
322
382
  crf : int, default 23
323
383
  Constant Rate Factor for encoding quality (lower is better quality).
@@ -337,17 +397,27 @@ class VideoWriter:
337
397
  raise ImportError('Missing optional dependency for video processing,'
338
398
  ' run `pip install av tqdm`')
339
399
  self.av = av
400
+ from fractions import Fraction
340
401
  filename = os.path.expanduser(str(filename))
341
402
  if os.path.exists(filename) and not overwrite:
342
403
  raise FileExistsError(f'File {filename} already exists. '
343
404
  'Set overwrite=True to overwrite.')
344
405
  self.filename = filename
345
- self.framerate = framerate
406
+ self.framerate = str(framerate) # str instead of float to avoid precision issues
407
+ while Fraction(self.framerate).denominator >= 2**32 or Fraction(self.framerate).numerator >= 2**32:
408
+ # If framerate has too many decimals to be expressed as a
409
+ # ratio of 32-bit ints, which is required by ffmpeg, crop
410
+ # off one decimal point of precision until it is expressable
411
+ self.framerate = self.framerate[:-1]
412
+ if self.framerate[-1] == '.':
413
+ self.framerate = self.framerate[:-1]
414
+ if len(self.framerate) == 0:
415
+ raise RuntimeError('Error occurred handling framerate argument')
346
416
  self.crf = crf
347
417
  self.compression_speed = compression_speed
348
418
  self.codec = codec_aliases[codec.lower()]
349
419
  self.container = av.open(filename, mode='w')
350
- self.stream = self.container.add_stream(self.codec, rate=framerate)
420
+ self.stream = self.container.add_stream(self.codec, rate=Fraction(self.framerate))
351
421
  self.stream.pix_fmt = 'yuv420p'
352
422
  self.stream.options = {'crf': str(crf), 'preset': compression_speed}
353
423
  self._closed = False
@@ -358,10 +428,14 @@ class VideoWriter:
358
428
  if not isinstance(frame, self.av.VideoFrame):
359
429
  if frame.ndim == 3 and frame.shape[-1] == 3:
360
430
  frame = self.av.VideoFrame.from_ndarray(frame, format='rgb24')
431
+ elif frame.ndim == 3 and frame.shape[-1] == 4:
432
+ # While some video codecs support an alpha channel, most don't,
433
+ # so for now we're just going to ignore the alpha channel
434
+ frame = self.av.VideoFrame.from_ndarray(frame[..., :3], format='rgb24')
361
435
  elif frame.ndim == 2:
362
436
  frame = self.av.VideoFrame.from_ndarray(frame, format='gray')
363
437
  else:
364
- raise ValueError(f'Frame must be (H, W) or (H, W, 3) but was {frame.shape}')
438
+ raise ValueError(f'Frame must be (H, W) (H, W, 3) or (H, W, 4) but was {frame.shape}')
365
439
  if self.stream.width == 0:
366
440
  self.stream.width = frame.width
367
441
  if self.stream.height == 0:
@@ -439,14 +513,14 @@ def save_video(data, filename, time_axis=0, color_axis=None, overwrite=False,
439
513
  ' run `pip install av tqdm`')
440
514
 
441
515
  filename = os.path.expanduser(str(filename))
442
- if filename.split('.')[-1].lower() not in ['mp4', 'mkv', 'avi', 'mov']:
516
+ if filename.split('.')[-1].lower() not in supported_extensions:
443
517
  filename += '.mp4'
444
518
  if os.path.exists(filename) and not overwrite:
445
519
  raise FileExistsError(f'File {filename} already exists. '
446
520
  'Set overwrite=True to overwrite.')
447
521
 
448
522
  if color_axis is None and data.ndim == 4:
449
- color_axis = find_channel_axis(data, possible_channel_lengths=3)
523
+ color_axis = utils.find_channel_axis(data, possible_channel_lengths=3)
450
524
  if color_axis is None:
451
525
  raise ValueError('4D input data must have an RGB (length 3) axis.')
452
526
  if color_axis is not None:
@@ -487,3 +561,70 @@ def save_video(data, filename, time_axis=0, color_axis=None, overwrite=False,
487
561
  for frame_i in tqdm(range(n_frames), total=n_frames,
488
562
  desc='Saving video', disable=not progress_bar):
489
563
  writer.write(data[frame_i])
564
+
565
+
566
+ def _detect_rotation(filename):
567
+ """
568
+ Detect rotation metadata from the video file using ffprobe.
569
+ Returns the rotation angle as a string (e.g., '90', '180', '270'), or None if not present.
570
+ """
571
+ import subprocess
572
+ import json
573
+ cmd = [
574
+ 'ffprobe',
575
+ '-v', 'quiet',
576
+ '-select_streams', 'v:0',
577
+ '-show_entries', 'stream_tags=rotate',
578
+ '-of', 'json',
579
+ str(filename)
580
+ ]
581
+ result = subprocess.run(cmd, stdout=subprocess.PIPE,
582
+ stderr=subprocess.PIPE, text=True)
583
+ if result.returncode != 0:
584
+ return None
585
+ try:
586
+ data = json.loads(result.stdout)
587
+ streams = data.get('streams', [])
588
+ if streams and 'tags' in streams[0]:
589
+ rotate_tag = streams[0]['tags'].get('rotate')
590
+ if rotate_tag:
591
+ return rotate_tag
592
+ except (json.JSONDecodeError, KeyError, ValueError):
593
+ pass
594
+ return None
595
+
596
+
597
+ def _rotate(data: np.ndarray, rotation: Union[str, int, None] = 0) -> np.ndarray:
598
+ """
599
+ Apply clockwise rotation to a numpy array
600
+
601
+ Parameters
602
+ ----------
603
+ data : np.ndarray
604
+ The numpy array to rotate.
605
+ rotation : str, int, or None, default 0
606
+ The rotation angle in degrees, as a string or integer.
607
+ Valid values are 0, 90, 180, 270, or 360.
608
+ If None, 0, or 360, no rotation is applied.
609
+
610
+ TODO find an actual video file with rotation_tag of 90 or 270 and check that
611
+ the rotation is applied in the right direction. If it's opposite the correct
612
+ direction, remove axes=(1, 0) from the np.rot90 calls in this function.
613
+ """
614
+ if rotation is None:
615
+ return data
616
+ try:
617
+ rotation = int(rotation)
618
+ except (ValueError, TypeError):
619
+ raise ValueError(f"Invalid rotation value: {rotation}")
620
+
621
+ if rotation in [0, 360]:
622
+ return data
623
+ if rotation == 90:
624
+ return np.rot90(data, k=1, axes=(1, 0))
625
+ elif rotation == 180:
626
+ return np.rot90(data, k=2, axes=(1, 0))
627
+ elif rotation == 270:
628
+ return np.rot90(data, k=3, axes=(1, 0))
629
+ else:
630
+ 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.1
3
+ Version: 2.6.3
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
@@ -4,7 +4,7 @@ build-backend = 'setuptools.build_meta'
4
4
 
5
5
  [project]
6
6
  name = 'numpyimage'
7
- version = '2.6.1'
7
+ version = '2.6.3'
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
File without changes