numpyimage 2.6.1__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.1
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
@@ -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
@@ -134,7 +148,7 @@ class VideoStreamer:
134
148
  self.filename = Path(filename)
135
149
  if not self.filename.exists():
136
150
  raise FileNotFoundError(f"File {filename} not found")
137
- self.index_filename = Path(str(filename).replace('.mp4', '_index.json'))
151
+ self.index_filename = self.filename.parent / (self.filename.stem + '_index.json')
138
152
  self._load_index()
139
153
 
140
154
  self.container = av.open(str(self.filename))
@@ -142,6 +156,10 @@ class VideoStreamer:
142
156
  self.time_base = self.stream.time_base
143
157
  self._shape = None
144
158
  self._first_frame = None
159
+ self._width = None
160
+ self._height = None
161
+ self._ndim = None
162
+ self._dtype = None
145
163
  self._current_frame_number = None
146
164
  self._lock = threading.Lock()
147
165
 
@@ -163,11 +181,14 @@ class VideoStreamer:
163
181
  time_index = [float(frame['pkt_pts_time']) for frame in frames]
164
182
  if len(time_index) == 0:
165
183
  raise RuntimeError("No frames found in video")
184
+
166
185
  self.n_frames = len(time_index)
167
186
  self.t0 = time_index[0]
187
+ self.rotation = _detect_rotation(self.filename)
168
188
  index = {
169
189
  'n_frames': self.n_frames,
170
190
  't0': self.t0,
191
+ 'rotation': self.rotation,
171
192
  }
172
193
 
173
194
  # Determine whether the video is constant or variable framerate
@@ -187,11 +208,12 @@ class VideoStreamer:
187
208
  def _load_index(self):
188
209
  if not self.index_filename.exists():
189
210
  self._build_index()
190
- else:
191
- with open(self.index_filename, 'r') as f:
192
- index = json.load(f)
211
+
212
+ with open(self.index_filename, 'r') as f:
213
+ index = json.load(f)
193
214
  self.n_frames = index['n_frames']
194
215
  self.t0 = index['t0']
216
+ self.rotation = index.get('rotation', None)
195
217
 
196
218
  if not isinstance(index['timestep'], (str, float, int)):
197
219
  raise ValueError('Malformed index: timestep is not a string or number')
@@ -226,7 +248,7 @@ class VideoStreamer:
226
248
  Provides access to random frames as fast as is reasonable when getting
227
249
  frames from compressed video in python.
228
250
  """
229
-
251
+
230
252
  def decode_until(frame_number) -> np.ndarray:
231
253
  """
232
254
  Decode forward from the current frame in the stream until we get
@@ -249,8 +271,15 @@ class VideoStreamer:
249
271
  start, stop, step = frame_number.indices(self.n_frames)
250
272
  return np.array([self._get_frame(i) for i in range(start, stop, step)])
251
273
  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})")
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]")
254
283
 
255
284
  if (self._current_frame_number is None
256
285
  or frame_number <= self._current_frame_number
@@ -263,7 +292,17 @@ class VideoStreamer:
263
292
  self.container.seek(seek_time, any_frame=False,
264
293
  backward=True, stream=self.stream)
265
294
  # Now we decode frames forward until we get to the requested frame
266
- return decode_until(frame_number)
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
267
306
 
268
307
  @property
269
308
  def first_frame(self):
@@ -278,13 +317,29 @@ class VideoStreamer:
278
317
  self._shape = (self.n_frames,) + self.first_frame.shape
279
318
  return self._shape
280
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
+
281
332
  @property
282
333
  def ndim(self):
283
- return len(self.shape)
334
+ if self._ndim is None:
335
+ self._ndim = len(self.shape)
336
+ return self._ndim
284
337
 
285
338
  @property
286
339
  def dtype(self):
287
- return self.first_frame.dtype
340
+ if self._dtype is None:
341
+ self._dtype = self.first_frame.dtype
342
+ return self._dtype
288
343
 
289
344
  def __len__(self):
290
345
  return self.n_frames
@@ -317,7 +372,7 @@ class VideoWriter:
317
372
  ----------
318
373
  filename : str
319
374
  The filename to save the video to.
320
- framerate : int, default 30
375
+ framerate : int or float, default 30
321
376
  The frame rate of the video.
322
377
  crf : int, default 23
323
378
  Constant Rate Factor for encoding quality (lower is better quality).
@@ -337,17 +392,27 @@ class VideoWriter:
337
392
  raise ImportError('Missing optional dependency for video processing,'
338
393
  ' run `pip install av tqdm`')
339
394
  self.av = av
395
+ from fractions import Fraction
340
396
  filename = os.path.expanduser(str(filename))
341
397
  if os.path.exists(filename) and not overwrite:
342
398
  raise FileExistsError(f'File {filename} already exists. '
343
399
  'Set overwrite=True to overwrite.')
344
400
  self.filename = filename
345
- self.framerate = framerate
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')
346
411
  self.crf = crf
347
412
  self.compression_speed = compression_speed
348
413
  self.codec = codec_aliases[codec.lower()]
349
414
  self.container = av.open(filename, mode='w')
350
- self.stream = self.container.add_stream(self.codec, rate=framerate)
415
+ self.stream = self.container.add_stream(self.codec, rate=Fraction(self.framerate))
351
416
  self.stream.pix_fmt = 'yuv420p'
352
417
  self.stream.options = {'crf': str(crf), 'preset': compression_speed}
353
418
  self._closed = False
@@ -358,10 +423,14 @@ class VideoWriter:
358
423
  if not isinstance(frame, self.av.VideoFrame):
359
424
  if frame.ndim == 3 and frame.shape[-1] == 3:
360
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')
361
430
  elif frame.ndim == 2:
362
431
  frame = self.av.VideoFrame.from_ndarray(frame, format='gray')
363
432
  else:
364
- raise ValueError(f'Frame must be (H, W) or (H, W, 3) but was {frame.shape}')
433
+ raise ValueError(f'Frame must be (H, W) (H, W, 3) or (H, W, 4) but was {frame.shape}')
365
434
  if self.stream.width == 0:
366
435
  self.stream.width = frame.width
367
436
  if self.stream.height == 0:
@@ -439,14 +508,14 @@ def save_video(data, filename, time_axis=0, color_axis=None, overwrite=False,
439
508
  ' run `pip install av tqdm`')
440
509
 
441
510
  filename = os.path.expanduser(str(filename))
442
- if filename.split('.')[-1].lower() not in ['mp4', 'mkv', 'avi', 'mov']:
511
+ if filename.split('.')[-1].lower() not in supported_extensions:
443
512
  filename += '.mp4'
444
513
  if os.path.exists(filename) and not overwrite:
445
514
  raise FileExistsError(f'File {filename} already exists. '
446
515
  'Set overwrite=True to overwrite.')
447
516
 
448
517
  if color_axis is None and data.ndim == 4:
449
- color_axis = find_channel_axis(data, possible_channel_lengths=3)
518
+ color_axis = utils.find_channel_axis(data, possible_channel_lengths=3)
450
519
  if color_axis is None:
451
520
  raise ValueError('4D input data must have an RGB (length 3) axis.')
452
521
  if color_axis is not None:
@@ -487,3 +556,70 @@ def save_video(data, filename, time_axis=0, color_axis=None, overwrite=False,
487
556
  for frame_i in tqdm(range(n_frames), total=n_frames,
488
557
  desc='Saving video', disable=not progress_bar):
489
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.1
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
@@ -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.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
File without changes