phasorpy 0.7__cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl

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.
phasorpy/_utils.py ADDED
@@ -0,0 +1,786 @@
1
+ """Private auxiliary and convenience functions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ 'chunk_iter',
7
+ 'dilate_coordinates',
8
+ 'init_module',
9
+ 'kwargs_notnone',
10
+ 'parse_harmonic',
11
+ 'parse_kwargs',
12
+ 'parse_signal_axis',
13
+ 'parse_skip_axis',
14
+ 'phasor_from_polar_scalar',
15
+ 'phasor_to_polar_scalar',
16
+ 'scale_matrix',
17
+ 'sort_coordinates',
18
+ 'squeeze_dims',
19
+ 'update_kwargs',
20
+ 'xarray_metadata',
21
+ ]
22
+
23
+ import math
24
+ import numbers
25
+ import os
26
+ import sys
27
+ from collections.abc import Sequence
28
+ from typing import TYPE_CHECKING
29
+
30
+ if TYPE_CHECKING:
31
+ from ._typing import (
32
+ Any,
33
+ ArrayLike,
34
+ Literal,
35
+ NDArray,
36
+ Iterator,
37
+ Container,
38
+ PathLike,
39
+ )
40
+
41
+ import numpy
42
+
43
+
44
+ def parse_kwargs(
45
+ kwargs: dict[str, Any],
46
+ /,
47
+ *keys: str,
48
+ _del: bool = True,
49
+ **keyvalues: Any,
50
+ ) -> dict[str, Any]:
51
+ """Return dict with keys from keys|keyvals and values from kwargs|keyvals.
52
+
53
+ If `_del` is true (default), existing keys are deleted from `kwargs`.
54
+
55
+ Parameters
56
+ ----------
57
+ kwargs : dict
58
+ Source dictionary to extract keys from.
59
+ *keys : str
60
+ Keys to extract from kwargs if present.
61
+ _del : bool, default: True
62
+ If True, remove extracted keys from kwargs.
63
+ **keyvalues : Any
64
+ Key-value pairs. If key exists in kwargs, use kwargs value,
65
+ otherwise use provided default value.
66
+
67
+ Returns
68
+ -------
69
+ dict
70
+ Dictionary containing extracted keys and values.
71
+
72
+ >>> kwargs = {'one': 1, 'two': 2, 'four': 4}
73
+ >>> kwargs2 = parse_kwargs(kwargs, 'two', 'three', four=None, five=5)
74
+ >>> kwargs == {'one': 1}
75
+ True
76
+ >>> kwargs2 == {'two': 2, 'four': 4, 'five': 5}
77
+ True
78
+
79
+ """
80
+ result = {}
81
+ for key in keys:
82
+ if key in kwargs:
83
+ result[key] = kwargs[key]
84
+ if _del:
85
+ del kwargs[key]
86
+ for key, value in keyvalues.items():
87
+ if key in kwargs:
88
+ result[key] = kwargs[key]
89
+ if _del:
90
+ del kwargs[key]
91
+ else:
92
+ result[key] = value
93
+ return result
94
+
95
+
96
+ def update_kwargs(kwargs: dict[str, Any], /, **keyvalues: Any) -> None:
97
+ """Update dict with keys and values if keys do not already exist.
98
+
99
+ >>> kwargs = {'one': 1}
100
+ >>> update_kwargs(kwargs, one=None, two=2)
101
+ >>> kwargs == {'one': 1, 'two': 2}
102
+ True
103
+
104
+ """
105
+ for key, value in keyvalues.items():
106
+ if key not in kwargs:
107
+ kwargs[key] = value
108
+
109
+
110
+ def kwargs_notnone(**kwargs: Any) -> dict[str, Any]:
111
+ """Return dict of kwargs which values are not None.
112
+
113
+ >>> kwargs_notnone(one=1, none=None)
114
+ {'one': 1}
115
+
116
+ """
117
+ return dict(item for item in kwargs.items() if item[1] is not None)
118
+
119
+
120
+ def scale_matrix(factor: float, origin: Sequence[float]) -> NDArray[Any]:
121
+ """Return matrix to scale homogeneous coordinates by factor around origin.
122
+
123
+ Parameters
124
+ ----------
125
+ factor : float
126
+ Scale factor.
127
+ origin : (float, float)
128
+ Coordinates of point around which to scale.
129
+
130
+ Returns
131
+ -------
132
+ matrix : ndarray
133
+ A 3x3 homogeneous transformation matrix.
134
+
135
+ Examples
136
+ --------
137
+ >>> scale_matrix(1.1, [0.0, 0.5])
138
+ array([[1.1, 0, -0],
139
+ [0, 1.1, -0.05],
140
+ [0, 0, 1]])
141
+
142
+ """
143
+ mat = numpy.diag((factor, factor, 1.0))
144
+ mat[:2, 2] = origin[:2]
145
+ mat[:2, 2] *= 1.0 - factor
146
+ return mat
147
+
148
+
149
+ def sort_coordinates(
150
+ real: ArrayLike,
151
+ imag: ArrayLike,
152
+ /,
153
+ origin: ArrayLike | None = None,
154
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any]]:
155
+ """Return cartesian coordinates sorted counterclockwise around origin.
156
+
157
+ Parameters
158
+ ----------
159
+ real, imag : array_like
160
+ Coordinates to be sorted.
161
+ origin : array_like, optional
162
+ Coordinates around which to sort by angle.
163
+ By default, sort around the mean of `real` and `imag`.
164
+
165
+ Returns
166
+ -------
167
+ real : ndarray
168
+ Sorted real coordinates.
169
+ imag : ndarray
170
+ Sorted imaginary coordinates.
171
+ indices : ndarray
172
+ Indices used to reorder coordinates.
173
+ Use ``indices.argsort()`` to get original order.
174
+
175
+ Examples
176
+ --------
177
+ >>> sort_coordinates([0, 1, 2, 3], [0, 1, -1, 0])
178
+ (array([2, 3, 1, 0]), array([-1, 0, 1, 0]), array([2, 3, 1, 0]...))
179
+
180
+ """
181
+ x, y = numpy.atleast_1d(real, imag)
182
+ if x.ndim != 1 or x.shape != y.shape:
183
+ raise ValueError(f'invalid {x.shape=} or {y.shape=}')
184
+ if x.size < 3:
185
+ return x, y, numpy.arange(x.size)
186
+ if origin is None:
187
+ ox, oy = x.mean(), y.mean()
188
+ else:
189
+ origin = numpy.asarray(origin, dtype=numpy.float64)
190
+ ox = origin[0]
191
+ oy = origin[1]
192
+ indices = numpy.argsort(numpy.arctan2(y - oy, x - ox))
193
+ return x[indices], y[indices], indices
194
+
195
+
196
+ def dilate_coordinates(
197
+ real: ArrayLike,
198
+ imag: ArrayLike,
199
+ offset: float,
200
+ /,
201
+ ) -> tuple[NDArray[Any], NDArray[Any]]:
202
+ """Return dilated coordinates.
203
+
204
+ Parameters
205
+ ----------
206
+ real, imag : array_like
207
+ Coordinates of convex hull, sorted by angle.
208
+ offset : float
209
+ Amount by which to dilate coordinates.
210
+
211
+ Returns
212
+ -------
213
+ real : ndarray
214
+ Dilated real coordinates.
215
+ imag : ndarray
216
+ Dilated imaginary coordinates.
217
+
218
+ Examples
219
+ --------
220
+ >>> dilate_coordinates([2, 3, 1, 0], [-1, 0, 1, 0], 0.05)
221
+ (array([2.022, 3.05, 0.9776, -0.05]), array([-1.045, 0, 1.045, 0]))
222
+
223
+ """
224
+ x = numpy.asanyarray(real, dtype=numpy.float64)
225
+ y = numpy.asanyarray(imag, dtype=numpy.float64)
226
+ if x.ndim != 1 or x.shape != y.shape or x.size < 1:
227
+ raise ValueError(f'invalid {x.shape=} or {y.shape=}')
228
+ if x.size > 1:
229
+ dx = numpy.diff(numpy.diff(x, prepend=x[-1], append=x[0]))
230
+ dy = numpy.diff(numpy.diff(y, prepend=y[-1], append=y[0]))
231
+ else:
232
+ # TODO: this assumes coordinate on universal semicircle
233
+ dx = numpy.diff(x, append=0.5)
234
+ dy = numpy.diff(y, append=0.0)
235
+ s = numpy.hypot(dx, dy)
236
+ dx /= s
237
+ dx *= -offset
238
+ dx += x
239
+ dy /= s
240
+ dy *= -offset
241
+ dy += y
242
+ return dx, dy
243
+
244
+
245
+ def phasor_to_polar_scalar(
246
+ real: float,
247
+ imag: float,
248
+ /,
249
+ *,
250
+ degree: bool = False,
251
+ percent: bool = False,
252
+ ) -> tuple[float, float]:
253
+ """Return polar from scalar phasor coordinates.
254
+
255
+ Parameters
256
+ ----------
257
+ real : float
258
+ Real component of phasor coordinate.
259
+ imag : float
260
+ Imaginary component of phasor coordinate.
261
+ degree : bool, optional
262
+ If true, return phase in degrees instead of radians.
263
+ percent : bool, optional
264
+ If true, return modulation as percentage instead of fraction.
265
+
266
+ Returns
267
+ -------
268
+ phase : float
269
+ Phase angle in radians (or degrees if degree=True).
270
+ modulation : float
271
+ Modulation depth as fraction (or percentage if percent=True).
272
+
273
+ Examples
274
+ --------
275
+ >>> phasor_to_polar_scalar(0.0, 1.0, degree=True, percent=True)
276
+ (90.0, 100.0)
277
+
278
+ """
279
+ phi = math.atan2(imag, real)
280
+ mod = math.hypot(imag, real)
281
+ if degree:
282
+ phi = math.degrees(phi)
283
+ if percent:
284
+ mod *= 100.0
285
+ return phi, mod
286
+
287
+
288
+ def phasor_from_polar_scalar(
289
+ phase: float,
290
+ modulation: float,
291
+ /,
292
+ *,
293
+ degree: bool = False,
294
+ percent: bool = False,
295
+ ) -> tuple[float, float]:
296
+ """Return phasor from scalar polar coordinates.
297
+
298
+ Parameters
299
+ ----------
300
+ phase : float
301
+ Phase angle in radians (or degrees if degree=True).
302
+ modulation : float
303
+ Modulation depth as fraction (or percentage if percent=True).
304
+ degree : bool, optional
305
+ If true, phase is in degrees instead of radians.
306
+ percent : bool, optional
307
+ If true, modulation is as percentage instead of fraction.
308
+
309
+ Returns
310
+ -------
311
+ real : float
312
+ Real component of phasor coordinate.
313
+ imag : float
314
+ Imaginary component of phasor coordinate.
315
+
316
+ Examples
317
+ --------
318
+ >>> phasor_from_polar_scalar(0.0, 100.0, degree=True, percent=True)
319
+ (1.0, 0.0)
320
+
321
+ """
322
+ if degree:
323
+ phase = math.radians(phase)
324
+ if percent:
325
+ modulation /= 100.0
326
+ real = modulation * math.cos(phase)
327
+ imag = modulation * math.sin(phase)
328
+ return real, imag
329
+
330
+
331
+ def parse_signal_axis(
332
+ signal: ArrayLike,
333
+ /,
334
+ axis: int | str | None = None,
335
+ ) -> tuple[int, str]:
336
+ """Return axis over which phasor coordinates are computed.
337
+
338
+ The axis parameter is not validated against the signal shape.
339
+
340
+ Parameters
341
+ ----------
342
+ signal : array_like
343
+ Signal array.
344
+ Axis names are used if it has a `dims` attribute.
345
+ axis : int, str, or None, default: None
346
+ Axis over which to compute phasor coordinates.
347
+ If None, automatically selects 'H' or 'C' axis if available,
348
+ otherwise uses the last axis (-1).
349
+ If int, specifies axis index.
350
+ If str, specifies axis name (requires `signal.dims`).
351
+
352
+ Returns
353
+ -------
354
+ axis : int
355
+ Index of axis over which phasor coordinates are computed.
356
+ axis_label : str
357
+ Label of axis from `signal.dims` if available, empty string otherwise.
358
+
359
+ Raises
360
+ ------
361
+ ValueError
362
+ If axis string is not found in signal.dims.
363
+ If axis string is provided but signal has no dims attribute.
364
+
365
+ Examples
366
+ --------
367
+ >>> parse_signal_axis([])
368
+ (-1, '')
369
+ >>> parse_signal_axis([], 1)
370
+ (1, '')
371
+ >>> class DataArray:
372
+ ... dims = ('C', 'H', 'Y', 'X')
373
+ ...
374
+ >>> parse_signal_axis(DataArray())
375
+ (1, 'H')
376
+ >>> parse_signal_axis(DataArray(), 'C')
377
+ (0, 'C')
378
+ >>> parse_signal_axis(DataArray(), 1)
379
+ (1, 'H')
380
+
381
+ """
382
+ if hasattr(signal, 'dims'):
383
+ assert isinstance(signal.dims, tuple)
384
+ if axis is None:
385
+ for ax in 'HC':
386
+ if ax in signal.dims:
387
+ return signal.dims.index(ax), ax
388
+ return -1, signal.dims[-1]
389
+ if isinstance(axis, int):
390
+ return axis, signal.dims[axis]
391
+ if axis in signal.dims:
392
+ return signal.dims.index(axis), axis
393
+ raise ValueError(f'{axis=} not found in {signal.dims}')
394
+ if axis is None:
395
+ return -1, ''
396
+ if isinstance(axis, int):
397
+ return axis, ''
398
+ raise ValueError(f'{axis=} not valid for {type(signal)=}')
399
+
400
+
401
+ def parse_skip_axis(
402
+ skip_axis: int | Sequence[int] | None,
403
+ /,
404
+ ndim: int,
405
+ prepend_axis: bool = False,
406
+ ) -> tuple[tuple[int, ...], tuple[int, ...]]:
407
+ """Return axes to skip and not to skip.
408
+
409
+ This helper function is used to validate and parse `skip_axis`
410
+ parameters.
411
+
412
+ Parameters
413
+ ----------
414
+ skip_axis : int or sequence of int, optional
415
+ Axes to skip. If None, no axes are skipped.
416
+ ndim : int
417
+ Dimensionality of array in which to skip axes.
418
+ prepend_axis : bool, optional
419
+ Prepend one dimension and include in `skip_axis`.
420
+
421
+ Returns
422
+ -------
423
+ skip_axis : tuple of int
424
+ Ordered, positive values of `skip_axis`.
425
+ other_axis : tuple of int
426
+ Axes indices not included in `skip_axis`.
427
+
428
+ Raises
429
+ ------
430
+ ValueError
431
+ If ndim is negative.
432
+ IndexError
433
+ If any `skip_axis` value is out of bounds of `ndim`.
434
+
435
+ Examples
436
+ --------
437
+ >>> parse_skip_axis([1, -2], 5)
438
+ ((1, 3), (0, 2, 4))
439
+
440
+ >>> parse_skip_axis([1, -2], 5, True)
441
+ ((0, 2, 4), (1, 3, 5))
442
+
443
+ """
444
+ if ndim < 0:
445
+ raise ValueError(f'invalid {ndim=}')
446
+ if skip_axis is None:
447
+ if prepend_axis:
448
+ return (0,), tuple(range(1, ndim + 1))
449
+ return (), tuple(range(ndim))
450
+ if not isinstance(skip_axis, Sequence):
451
+ skip_axis = (skip_axis,)
452
+ if any(i >= ndim or i < -ndim for i in skip_axis):
453
+ raise IndexError(f'skip_axis={skip_axis} out of range for {ndim=}')
454
+ skip_axis = sorted(int(i % ndim) for i in skip_axis)
455
+ if prepend_axis:
456
+ skip_axis = [0] + [i + 1 for i in skip_axis]
457
+ ndim += 1
458
+ other_axis = tuple(i for i in range(ndim) if i not in skip_axis)
459
+ return tuple(skip_axis), other_axis
460
+
461
+
462
+ def parse_harmonic(
463
+ harmonic: int | Sequence[int] | Literal['all'] | str | None,
464
+ harmonic_max: int | None = None,
465
+ /,
466
+ ) -> tuple[list[int], bool]:
467
+ """Return parsed harmonic parameter.
468
+
469
+ This function performs common, but not necessarily all, verifications
470
+ of user-provided `harmonic` parameter.
471
+
472
+ Parameters
473
+ ----------
474
+ harmonic : int, sequence of int, 'all', or None
475
+ Harmonic parameter to parse.
476
+ harmonic_max : int, optional
477
+ Maximum value allowed in `harmonic`. Must be one or greater.
478
+ To verify against known number of signal samples,
479
+ pass ``samples // 2``.
480
+ If `harmonic='all'`, a range of harmonics from one to `harmonic_max`
481
+ (included) is returned.
482
+
483
+ Returns
484
+ -------
485
+ harmonic : list of int
486
+ Parsed list of harmonics.
487
+ has_harmonic_axis : bool
488
+ False if `harmonic` input parameter is a scalar integer.
489
+
490
+ Raises
491
+ ------
492
+ IndexError
493
+ Any element is out of range `[1, harmonic_max]`.
494
+ ValueError
495
+ Elements are not unique.
496
+ Harmonic is empty.
497
+ String input is not 'all'.
498
+ `harmonic_max` is smaller than 1.
499
+ TypeError
500
+ Any element is not an integer.
501
+ `harmonic` is `'all'` and `harmonic_max` is None.
502
+
503
+ """
504
+ if harmonic_max is not None and harmonic_max < 1:
505
+ raise ValueError(f'{harmonic_max=} < 1')
506
+
507
+ if harmonic is None:
508
+ return [1], False
509
+
510
+ if isinstance(harmonic, (int, numbers.Integral)):
511
+ if harmonic < 1 or (
512
+ harmonic_max is not None and harmonic > harmonic_max
513
+ ):
514
+ raise IndexError(f'{harmonic=} out of range [1, {harmonic_max}]')
515
+ return [int(harmonic)], False
516
+
517
+ if isinstance(harmonic, str):
518
+ if harmonic == 'all':
519
+ if harmonic_max is None:
520
+ raise TypeError(
521
+ f'maximum harmonic must be specified for {harmonic=!r}'
522
+ )
523
+ return list(range(1, harmonic_max + 1)), True
524
+ raise ValueError(f'{harmonic=!r} is not a valid harmonic')
525
+
526
+ h = numpy.atleast_1d(harmonic)
527
+ if h.size == 0:
528
+ raise ValueError(f'{harmonic=} is empty')
529
+ if h.dtype.kind not in 'iu' or h.ndim != 1:
530
+ raise TypeError(f'{harmonic=} element not an integer')
531
+ if numpy.any(h < 1):
532
+ raise IndexError(f'{harmonic=} element < 1')
533
+ if harmonic_max is not None and numpy.any(h > harmonic_max):
534
+ raise IndexError(f'{harmonic=} element > {harmonic_max}]')
535
+ if numpy.unique(h).size != h.size:
536
+ raise ValueError(f'{harmonic=} elements must be unique')
537
+ return [int(i) for i in harmonic], True
538
+
539
+
540
+ def chunk_iter(
541
+ shape: tuple[int, ...],
542
+ chunk_shape: tuple[int, ...],
543
+ /,
544
+ dims: Sequence[str] | None = None,
545
+ *,
546
+ pattern: str | None = None,
547
+ squeeze: bool = False,
548
+ use_index: bool = False,
549
+ ) -> Iterator[tuple[tuple[int | slice, ...], str, bool]]:
550
+ """Yield indices and labels of chunks from ndarray's shape.
551
+
552
+ Parameters
553
+ ----------
554
+ shape : tuple of int
555
+ Shape of C-order ndarray to chunk.
556
+ chunk_shape : tuple of int
557
+ Shape of chunks in the most significant dimensions.
558
+ dims : sequence of str, optional
559
+ Labels for each axis in shape if `pattern` is None.
560
+ pattern : str, optional
561
+ String to format chunk indices.
562
+ If None, use ``_[{dims[index]}{chunk_index[index]}]`` for each axis.
563
+ squeeze : bool, optional
564
+ If true, do not include length-1 chunked dimensions in label
565
+ unless dimensions are part of `chunk_shape`.
566
+ Applies only if `pattern` is None.
567
+ use_index : bool, optional
568
+ If true, use indices of chunks in `shape` instead of chunk indices to
569
+ format pattern.
570
+
571
+ Yields
572
+ ------
573
+ index : tuple of int or slice
574
+ Indices of chunk in ndarray.
575
+ label : str
576
+ Pattern formatted with chunk indices.
577
+ cropped : bool
578
+ True if chunk exceeds any border of ndarray.
579
+ Indexing ndarray with `index` will yield a slice smaller than
580
+ `chunk_shape`.
581
+
582
+ Examples
583
+ --------
584
+
585
+ >>> list(chunk_iter((2, 2), (2,), pattern='Y{}'))
586
+ [((0, slice(0, 2, 1)), 'Y0', False), ((1, slice(0, 2, 1)), 'Y1', False)]
587
+
588
+ Chunk a four-dimensional image stack into 2x2 sized image tiles:
589
+
590
+ >>> stack = numpy.zeros((2, 3, 4, 5))
591
+ >>> for index, label, cropped in chunk_iter(stack.shape, (2, 2)):
592
+ ... chunk = stack[index]
593
+ ...
594
+
595
+ """
596
+ ndim = len(shape)
597
+
598
+ sep = '_'
599
+ if dims is None:
600
+ dims = sep * ndim
601
+ sep = ''
602
+ elif ndim != len(dims):
603
+ raise ValueError(f'{len(shape)=} != {len(dims)=}')
604
+
605
+ if pattern is not None:
606
+ try:
607
+ pattern.format(*shape)
608
+ except Exception as exc:
609
+ raise ValueError('pattern cannot be formatted') from exc
610
+
611
+ # number of high dimensions not included in chaunk_shape
612
+ hdim = ndim - len(chunk_shape)
613
+ if hdim < 0:
614
+ raise ValueError(f'{len(shape)=} < {len(chunk_shape)=}')
615
+ if hdim > 0:
616
+ # prepend length-1 dimensions
617
+ chunk_shape = ((1,) * hdim) + chunk_shape
618
+
619
+ chunked_shape = []
620
+ pattern_list = []
621
+ for i, (size, chunk_size, ax) in enumerate(zip(shape, chunk_shape, dims)):
622
+ if size <= 0:
623
+ raise ValueError('shape must contain positive sizes')
624
+ if chunk_size <= 0:
625
+ raise ValueError('chunk_shape must contain positive sizes')
626
+ div, mod = divmod(size, chunk_size)
627
+ chunked_shape.append(div + 1 if mod else div)
628
+
629
+ if not squeeze or chunked_shape[-1] > 1:
630
+ if use_index:
631
+ digits = int(math.log10(size)) + 1
632
+ else:
633
+ digits = int(math.log10(chunked_shape[-1])) + 1
634
+ pattern_list.append(f'{sep}{ax}{{{i}:0{digits}d}}')
635
+
636
+ if pattern is None:
637
+ pattern = ''.join(pattern_list)
638
+
639
+ chunk_index: tuple[int, ...]
640
+ for chunk_index in numpy.ndindex(tuple(chunked_shape)):
641
+ index: tuple[int | slice, ...] = tuple(
642
+ (
643
+ chunk_index[i]
644
+ if i < hdim
645
+ else slice(
646
+ chunk_index[i] * chunk_shape[i],
647
+ (chunk_index[i] + 1) * chunk_shape[i],
648
+ 1,
649
+ )
650
+ )
651
+ for i in range(ndim)
652
+ )
653
+ if use_index:
654
+ format_index = tuple(
655
+ chunk_index[i] * chunk_shape[i] for i in range(ndim)
656
+ )
657
+ else:
658
+ format_index = chunk_index
659
+ yield (
660
+ index,
661
+ pattern.format(*format_index),
662
+ any(
663
+ (chunk_index[i] + 1) * chunk_shape[i] > shape[i]
664
+ for i in range(ndim)
665
+ ),
666
+ )
667
+
668
+
669
+ def init_module(globs: dict[str, Any], /) -> None:
670
+ """Add names in module to ``__all__`` and set ``__module__`` attributes.
671
+
672
+ Parameters
673
+ ----------
674
+ globs : dict
675
+ Module namespace to modify.
676
+
677
+ Examples
678
+ --------
679
+ >>> init_module(globals())
680
+
681
+ """
682
+ names = globs['__all__']
683
+ module_name = globs['__name__']
684
+ module = sys.modules[module_name]
685
+ for name in dir(module):
686
+ if name.startswith('_') or name in {
687
+ 'annotations',
688
+ 'init_module',
689
+ 'utils', # TODO: where does this come from?
690
+ }:
691
+ continue
692
+ names.append(name)
693
+ obj = getattr(module, name)
694
+ if hasattr(obj, '__module__'):
695
+ obj.__module__ = module_name
696
+ globs['__all__'] = sorted(set(names))
697
+
698
+
699
+ def xarray_metadata(
700
+ dims: Sequence[str] | None,
701
+ shape: tuple[int, ...],
702
+ /,
703
+ name: str | PathLike[Any] | None = None,
704
+ attrs: dict[str, Any] | None = None,
705
+ **coords: Any,
706
+ ) -> dict[str, Any]:
707
+ """Return xarray-style dims, coords, and attrs in a dict.
708
+
709
+ >>> xarray_metadata('SYX', (3, 2, 1), S=['0', '1', '2'])
710
+ {'dims': ('S', 'Y', 'X'), 'coords': {'S': ['0', '1', '2']}, 'attrs': {}}
711
+
712
+ """
713
+ assert dims is not None
714
+ dims = tuple(dims)
715
+ if len(dims) != len(shape):
716
+ raise ValueError(
717
+ f'dims do not match shape {len(dims)} != {len(shape)}'
718
+ )
719
+ coords = {dim: coords[dim] for dim in dims if dim in coords}
720
+ if attrs is None:
721
+ attrs = {}
722
+ metadata = {'dims': dims, 'coords': coords, 'attrs': attrs}
723
+ if name:
724
+ metadata['name'] = os.path.basename(name)
725
+ return metadata
726
+
727
+
728
+ def squeeze_dims(
729
+ shape: Sequence[int],
730
+ dims: Sequence[str],
731
+ /,
732
+ skip: Container[str] = 'XY',
733
+ ) -> tuple[tuple[int, ...], tuple[str, ...], tuple[bool, ...]]:
734
+ """Return shape and axes with length-1 dimensions removed.
735
+
736
+ Remove unused dimensions unless their axes are listed in the `skip`
737
+ parameter.
738
+
739
+ Adapted from the tifffile library.
740
+
741
+ Parameters
742
+ ----------
743
+ shape : tuple of ints
744
+ Sequence of dimension sizes.
745
+ dims : sequence of str
746
+ Character codes for dimensions in `shape`.
747
+ skip : container of str, optional
748
+ Character codes for dimensions whose length-1 dimensions are
749
+ not removed. The default is 'XY'.
750
+
751
+ Returns
752
+ -------
753
+ shape : tuple of ints
754
+ Sequence of dimension sizes with length-1 dimensions removed.
755
+ dims : tuple of str
756
+ Character codes for dimensions in output `shape`.
757
+ squeezed : str
758
+ Dimensions were kept (True) or removed (False).
759
+
760
+ Examples
761
+ --------
762
+ >>> squeeze_dims((5, 1, 2, 1, 1), 'TZYXC')
763
+ ((5, 2, 1), ('T', 'Y', 'X'), (True, False, True, True, False))
764
+ >>> squeeze_dims((1,), ('Q',))
765
+ ((1,), ('Q',), (True,))
766
+
767
+ """
768
+ if len(shape) != len(dims):
769
+ raise ValueError(f'{len(shape)=} != {len(dims)=}')
770
+ if not dims:
771
+ return tuple(shape), tuple(dims), ()
772
+ squeezed: list[bool] = []
773
+ shape_squeezed: list[int] = []
774
+ dims_squeezed: list[str] = []
775
+ for size, ax in zip(shape, dims):
776
+ if size > 1 or ax in skip:
777
+ squeezed.append(True)
778
+ shape_squeezed.append(size)
779
+ dims_squeezed.append(ax)
780
+ else:
781
+ squeezed.append(False)
782
+ if len(shape_squeezed) == 0:
783
+ squeezed[-1] = True
784
+ shape_squeezed.append(shape[-1])
785
+ dims_squeezed.append(dims[-1])
786
+ return tuple(shape_squeezed), tuple(dims_squeezed), tuple(squeezed)