phasorpy 0.4__cp311-cp311-win_arm64.whl → 0.6__cp311-cp311-win_arm64.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.
@@ -0,0 +1,445 @@
1
+ """Read and write OME-TIFF file format."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ # 'signal_from_ometiff',
7
+ 'phasor_from_ometiff',
8
+ 'phasor_to_ometiff',
9
+ ]
10
+
11
+ import os
12
+ import re
13
+ from typing import TYPE_CHECKING
14
+
15
+ from .._utils import parse_harmonic
16
+ from ..utils import logger
17
+
18
+ if TYPE_CHECKING:
19
+ from .._typing import (
20
+ Any,
21
+ ArrayLike,
22
+ DTypeLike,
23
+ Literal,
24
+ NDArray,
25
+ PathLike,
26
+ Sequence,
27
+ )
28
+
29
+ import numpy
30
+
31
+ from .. import __version__
32
+
33
+
34
+ def phasor_to_ometiff(
35
+ filename: str | PathLike[Any],
36
+ mean: ArrayLike,
37
+ real: ArrayLike,
38
+ imag: ArrayLike,
39
+ /,
40
+ *,
41
+ frequency: float | None = None,
42
+ harmonic: int | Sequence[int] | None = None,
43
+ dims: Sequence[str] | None = None,
44
+ dtype: DTypeLike | None = None,
45
+ description: str | None = None,
46
+ **kwargs: Any,
47
+ ) -> None:
48
+ """Write phasor coordinate images and metadata to OME-TIFF file.
49
+
50
+ The OME-TIFF format is compatible with Bio-Formats and Fiji.
51
+
52
+ By default, write phasor coordinates as single precision floating point
53
+ values to separate image series.
54
+ Write images larger than (1024, 1024) pixels as (256, 256) tiles, datasets
55
+ larger than 2 GB as BigTIFF, and datasets larger than 8 KB using
56
+ zlib compression.
57
+
58
+ This file format is experimental and might be incompatible with future
59
+ versions of this library. It is intended for temporarily exchanging
60
+ phasor coordinates with other software, not as a long-term storage
61
+ solution.
62
+
63
+ Parameters
64
+ ----------
65
+ filename : str or Path
66
+ Name of PhasorPy OME-TIFF file to write.
67
+ mean : array_like
68
+ Average intensity image. Write to an image series named 'Phasor mean'.
69
+ real : array_like
70
+ Image of real component of phasor coordinates.
71
+ Multiple harmonics, if any, must be in the first dimension.
72
+ Write to image series named 'Phasor real'.
73
+ imag : array_like
74
+ Image of imaginary component of phasor coordinates.
75
+ Multiple harmonics, if any, must be in the first dimension.
76
+ Write to image series named 'Phasor imag'.
77
+ frequency : float, optional
78
+ Fundamental frequency of time-resolved phasor coordinates.
79
+ Usually in units of MHz.
80
+ Write to image series named 'Phasor frequency'.
81
+ harmonic : int or sequence of int, optional
82
+ Harmonics present in the first dimension of `real` and `imag`, if any.
83
+ Write to image series named 'Phasor harmonic'.
84
+ It is only needed if harmonics are not starting at and increasing by
85
+ one.
86
+ dims : sequence of str, optional
87
+ Character codes for `mean` image dimensions.
88
+ By default, the last dimensions are assumed to be 'TZCYX'.
89
+ If harmonics are present in `real` and `imag`, an "other" (``Q``)
90
+ dimension is prepended to axes for those arrays.
91
+ Refer to the OME-TIFF model for allowed axes and their order.
92
+ dtype : dtype-like, optional
93
+ Floating point data type used to store phasor coordinates.
94
+ The default is ``float32``, which has 6 digits of precision
95
+ and maximizes compatibility with other software.
96
+ description : str, optional
97
+ Plain-text description of dataset. Write as OME dataset description.
98
+ **kwargs
99
+ Additional arguments passed to :py:class:`tifffile.TiffWriter` and
100
+ :py:meth:`tifffile.TiffWriter.write`.
101
+ For example, ``compression=None`` writes image data uncompressed.
102
+
103
+ See Also
104
+ --------
105
+ phasorpy.io.phasor_from_ometiff
106
+
107
+ Notes
108
+ -----
109
+ Scalar or one-dimensional phasor coordinate arrays are written as images.
110
+
111
+ The OME-TIFF format is specified in the
112
+ `OME Data Model and File Formats Documentation
113
+ <https://ome-model.readthedocs.io/>`_.
114
+
115
+ The `6D, 7D and 8D storage
116
+ <https://ome-model.readthedocs.io/en/latest/developers/6d-7d-and-8d-storage.html>`_
117
+ extension is used to store multi-harmonic phasor coordinates.
118
+ The modulo type for the first, harmonic dimension is "other".
119
+
120
+ The implementation is based on the
121
+ `tifffile <https://github.com/cgohlke/tifffile/>`__ library.
122
+
123
+ Examples
124
+ --------
125
+ >>> mean, real, imag = numpy.random.rand(3, 32, 32, 32)
126
+ >>> phasor_to_ometiff(
127
+ ... '_phasorpy.ome.tif', mean, real, imag, dims='ZYX', frequency=80.0
128
+ ... )
129
+
130
+ """
131
+ import tifffile
132
+
133
+ if dtype is None:
134
+ dtype = numpy.float32
135
+ dtype = numpy.dtype(dtype)
136
+ if dtype.kind != 'f':
137
+ raise ValueError(f'{dtype=} not a floating point type')
138
+
139
+ mean = numpy.asarray(mean, dtype)
140
+ real = numpy.asarray(real, dtype)
141
+ imag = numpy.asarray(imag, dtype)
142
+ datasize = mean.nbytes + real.nbytes + imag.nbytes
143
+
144
+ if real.shape != imag.shape:
145
+ raise ValueError(f'{real.shape=} != {imag.shape=}')
146
+ if mean.shape != real.shape[-mean.ndim :]:
147
+ raise ValueError(f'{mean.shape=} != {real.shape[-mean.ndim:]=}')
148
+ has_harmonic_dim = real.ndim == mean.ndim + 1
149
+ if mean.ndim == real.ndim or real.ndim == 0:
150
+ nharmonic = 1
151
+ else:
152
+ nharmonic = real.shape[0]
153
+
154
+ if mean.ndim < 2:
155
+ # not an image
156
+ mean = mean.reshape(1, -1)
157
+ if has_harmonic_dim:
158
+ real = real.reshape(real.shape[0], 1, -1)
159
+ imag = imag.reshape(imag.shape[0], 1, -1)
160
+ else:
161
+ real = real.reshape(1, -1)
162
+ imag = imag.reshape(1, -1)
163
+
164
+ if harmonic is not None:
165
+ harmonic, _ = parse_harmonic(harmonic)
166
+ if len(harmonic) != nharmonic:
167
+ raise ValueError('invalid harmonic')
168
+
169
+ if frequency is not None:
170
+ frequency_array = numpy.atleast_2d(frequency).astype(numpy.float64)
171
+ if frequency_array.size > 1:
172
+ raise ValueError('frequency must be scalar')
173
+
174
+ axes = 'TZCYX'[-mean.ndim :] if dims is None else ''.join(tuple(dims))
175
+ if len(axes) != mean.ndim:
176
+ raise ValueError(f'{axes=} does not match {mean.ndim=}')
177
+ axes_phasor = axes if mean.ndim == real.ndim else 'Q' + axes
178
+
179
+ if 'photometric' not in kwargs:
180
+ kwargs['photometric'] = 'minisblack'
181
+ if 'compression' not in kwargs and datasize > 8192:
182
+ kwargs['compression'] = 'zlib'
183
+ if 'tile' not in kwargs and 'rowsperstrip' not in kwargs:
184
+ if (
185
+ axes.endswith('YX')
186
+ and mean.shape[-1] > 1024
187
+ and mean.shape[-2] > 1024
188
+ ):
189
+ kwargs['tile'] = (256, 256)
190
+
191
+ mode = kwargs.pop('mode', None)
192
+ bigtiff = kwargs.pop('bigtiff', None)
193
+ if bigtiff is None:
194
+ bigtiff = datasize > 2**31
195
+
196
+ metadata = kwargs.pop('metadata', {})
197
+ if 'Creator' not in metadata:
198
+ metadata['Creator'] = f'PhasorPy {__version__}'
199
+
200
+ dataset = metadata.pop('Dataset', {})
201
+ if 'Name' not in dataset:
202
+ dataset['Name'] = 'Phasor'
203
+ if description:
204
+ dataset['Description'] = description
205
+ metadata['Dataset'] = dataset
206
+
207
+ if has_harmonic_dim:
208
+ metadata['TypeDescription'] = {'Q': 'Phasor harmonics'}
209
+
210
+ with tifffile.TiffWriter(
211
+ filename, bigtiff=bigtiff, mode=mode, ome=True
212
+ ) as tif:
213
+ metadata['Name'] = 'Phasor mean'
214
+ metadata['axes'] = axes
215
+ tif.write(mean, metadata=metadata, **kwargs)
216
+ del metadata['Dataset']
217
+
218
+ metadata['Name'] = 'Phasor real'
219
+ metadata['axes'] = axes_phasor
220
+ tif.write(real, metadata=metadata, **kwargs)
221
+
222
+ metadata['Name'] = 'Phasor imag'
223
+ tif.write(imag, metadata=metadata, **kwargs)
224
+
225
+ if frequency is not None:
226
+ tif.write(frequency_array, metadata={'Name': 'Phasor frequency'})
227
+
228
+ if harmonic is not None:
229
+ tif.write(
230
+ numpy.atleast_2d(harmonic).astype(numpy.uint32),
231
+ metadata={'Name': 'Phasor harmonic'},
232
+ )
233
+
234
+
235
+ def phasor_from_ometiff(
236
+ filename: str | PathLike[Any],
237
+ /,
238
+ *,
239
+ harmonic: int | Sequence[int] | Literal['all'] | str | None = None,
240
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any], dict[str, Any]]:
241
+ """Return phasor coordinates and metadata from PhasorPy OME-TIFF file.
242
+
243
+ PhasorPy OME-TIFF files contain phasor mean intensity, real and imaginary
244
+ components, along with frequency and harmonic information.
245
+
246
+ Parameters
247
+ ----------
248
+ filename : str or Path
249
+ Name of PhasorPy OME-TIFF file to read.
250
+ harmonic : int, sequence of int, or 'all', optional
251
+ Harmonic(s) to return from file.
252
+ If None (default), return the first harmonic stored in the file.
253
+ If `'all'`, return all harmonics as stored in file.
254
+ If a list, the first axes of the returned `real` and `imag` arrays
255
+ contain specified harmonic(s).
256
+ If an integer, the returned `real` and `imag` arrays are single
257
+ harmonic and have the same shape as `mean`.
258
+
259
+ Returns
260
+ -------
261
+ mean : ndarray
262
+ Average intensity image.
263
+ real : ndarray
264
+ Image of real component of phasor coordinates.
265
+ imag : ndarray
266
+ Image of imaginary component of phasor coordinates.
267
+ attrs : dict
268
+ Select metadata:
269
+
270
+ - ``'dims'`` (tuple of str):
271
+ :ref:`Axes codes <axes>` for `mean` image dimensions.
272
+ - ``'harmonic'`` (int or list of int):
273
+ Harmonic(s) present in `real` and `imag`.
274
+ If a scalar, `real` and `imag` are single harmonic and contain no
275
+ harmonic axes.
276
+ If a list, `real` and `imag` contain one or more harmonics in the
277
+ first axis.
278
+ - ``'frequency'`` (float, optional):
279
+ Fundamental frequency of time-resolved phasor coordinates.
280
+ Usually in units of MHz.
281
+ - ``'description'`` (str, optional):
282
+ OME dataset plain-text description.
283
+
284
+ Raises
285
+ ------
286
+ tifffile.TiffFileError
287
+ File is not a TIFF file.
288
+ ValueError
289
+ File is not an OME-TIFF containing phasor coordinates.
290
+ IndexError
291
+ Harmonic is not found in file.
292
+
293
+ See Also
294
+ --------
295
+ phasorpy.io.phasor_to_ometiff
296
+
297
+ Notes
298
+ -----
299
+ Scalar or one-dimensional phasor coordinates stored in the file are
300
+ returned as two-dimensional images (three-dimensional if multiple
301
+ harmonics are present).
302
+
303
+ The implementation is based on the
304
+ `tifffile <https://github.com/cgohlke/tifffile/>`__ library.
305
+
306
+ Examples
307
+ --------
308
+ >>> mean, real, imag = numpy.random.rand(3, 32, 32, 32)
309
+ >>> phasor_to_ometiff(
310
+ ... '_phasorpy.ome.tif', mean, real, imag, dims='ZYX', frequency=80.0
311
+ ... )
312
+ >>> mean, real, imag, attrs = phasor_from_ometiff('_phasorpy.ome.tif')
313
+ >>> mean
314
+ array(...)
315
+ >>> mean.dtype
316
+ dtype('float32')
317
+ >>> mean.shape
318
+ (32, 32, 32)
319
+ >>> attrs['dims']
320
+ ('Z', 'Y', 'X')
321
+ >>> attrs['frequency']
322
+ 80.0
323
+ >>> attrs['harmonic']
324
+ 1
325
+
326
+ """
327
+ import tifffile
328
+
329
+ name = os.path.basename(filename)
330
+
331
+ with tifffile.TiffFile(filename) as tif:
332
+ if (
333
+ not tif.is_ome
334
+ or len(tif.series) < 3
335
+ or tif.series[0].name != 'Phasor mean'
336
+ or tif.series[1].name != 'Phasor real'
337
+ or tif.series[2].name != 'Phasor imag'
338
+ ):
339
+ raise ValueError(
340
+ f'{name!r} is not an OME-TIFF containing phasor images'
341
+ )
342
+
343
+ attrs: dict[str, Any] = {'dims': tuple(tif.series[0].axes)}
344
+
345
+ # TODO: read coords from OME-XML
346
+ ome_xml = tif.ome_metadata
347
+ assert ome_xml is not None
348
+
349
+ # TODO: parse OME-XML
350
+ match = re.search(
351
+ r'><Description>(.*)</Description><',
352
+ ome_xml,
353
+ re.MULTILINE | re.DOTALL,
354
+ )
355
+ if match is not None:
356
+ attrs['description'] = (
357
+ match.group(1)
358
+ .replace('&amp;', '&')
359
+ .replace('&gt;', '>')
360
+ .replace('&lt;', '<')
361
+ )
362
+
363
+ has_harmonic_dim = tif.series[1].ndim > tif.series[0].ndim
364
+ nharmonics = tif.series[1].shape[0] if has_harmonic_dim else 1
365
+ harmonic_max = nharmonics
366
+ for i in (3, 4):
367
+ if len(tif.series) < i + 1:
368
+ break
369
+ series = tif.series[i]
370
+ data = series.asarray().squeeze()
371
+ if series.name == 'Phasor frequency':
372
+ attrs['frequency'] = float(data.item(0))
373
+ elif series.name == 'Phasor harmonic':
374
+ if not has_harmonic_dim and data.size == 1:
375
+ attrs['harmonic'] = int(data.item(0))
376
+ harmonic_max = attrs['harmonic']
377
+ elif has_harmonic_dim and data.size == nharmonics:
378
+ attrs['harmonic'] = data.tolist()
379
+ harmonic_max = max(attrs['harmonic'])
380
+ else:
381
+ logger().warning(
382
+ f'harmonic={data} does not match phasor '
383
+ f'shape={tif.series[1].shape}'
384
+ )
385
+
386
+ if 'harmonic' not in attrs:
387
+ if has_harmonic_dim:
388
+ attrs['harmonic'] = list(range(1, nharmonics + 1))
389
+ else:
390
+ attrs['harmonic'] = 1
391
+ harmonic_stored = attrs['harmonic']
392
+
393
+ mean = tif.series[0].asarray()
394
+ if harmonic is None:
395
+ # first harmonic in file
396
+ if isinstance(harmonic_stored, list):
397
+ attrs['harmonic'] = harmonic_stored[0]
398
+ else:
399
+ attrs['harmonic'] = harmonic_stored
400
+ real = tif.series[1].asarray()
401
+ if has_harmonic_dim:
402
+ real = real[0].copy()
403
+ imag = tif.series[2].asarray()
404
+ if has_harmonic_dim:
405
+ imag = imag[0].copy()
406
+ elif isinstance(harmonic, str) and harmonic == 'all':
407
+ # all harmonics as stored in file
408
+ real = tif.series[1].asarray()
409
+ imag = tif.series[2].asarray()
410
+ else:
411
+ # specified harmonics
412
+ harmonic, keepdims = parse_harmonic(harmonic, harmonic_max)
413
+ try:
414
+ if isinstance(harmonic_stored, list):
415
+ index = [harmonic_stored.index(h) for h in harmonic]
416
+ else:
417
+ index = [[harmonic_stored].index(h) for h in harmonic]
418
+ except ValueError as exc:
419
+ raise IndexError('harmonic not found') from exc
420
+
421
+ if has_harmonic_dim:
422
+ if keepdims:
423
+ attrs['harmonic'] = [harmonic_stored[i] for i in index]
424
+ real = tif.series[1].asarray()[index].copy()
425
+ imag = tif.series[2].asarray()[index].copy()
426
+ else:
427
+ attrs['harmonic'] = harmonic_stored[index[0]]
428
+ real = tif.series[1].asarray()[index[0]].copy()
429
+ imag = tif.series[2].asarray()[index[0]].copy()
430
+ elif keepdims:
431
+ real = tif.series[1].asarray()
432
+ real = real.reshape(1, *real.shape)
433
+ imag = tif.series[2].asarray()
434
+ imag = imag.reshape(1, *imag.shape)
435
+ attrs['harmonic'] = [harmonic_stored]
436
+ else:
437
+ real = tif.series[1].asarray()
438
+ imag = tif.series[2].asarray()
439
+
440
+ if real.shape != imag.shape:
441
+ logger().warning(f'{real.shape=} != {imag.shape=}')
442
+ if real.shape[-mean.ndim :] != mean.shape:
443
+ logger().warning(f'{real.shape[-mean.ndim:]=} != {mean.shape=}')
444
+
445
+ return mean, real, imag, attrs