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