phasorpy 0.2__cp310-cp310-macosx_11_0_arm64.whl → 0.4__cp310-cp310-macosx_11_0_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.
phasorpy/_io.py ADDED
@@ -0,0 +1,2431 @@
1
+ """Read and write time-resolved and hyperspectral image file formats.
2
+
3
+ The ``phasorpy.io`` module provides functions to:
4
+
5
+ - write phasor coordinate images to OME-TIFF and SimFCS file formats:
6
+
7
+ - :py:func:`phasor_to_ometiff`
8
+ - :py:func:`phasor_to_simfcs_referenced`
9
+
10
+ - read phasor coordinates and metadata from specialized file formats:
11
+
12
+ - :py:func:`phasor_from_ometiff` - PhasorPy OME-TIFF
13
+ - :py:func:`phasor_from_ifli` - ISS IFLI
14
+ - :py:func:`phasor_from_lif` - Leica LIF
15
+ - :py:func:`phasor_from_flimlabs_json` - FLIM LABS JSON
16
+ - :py:func:`phasor_from_simfcs_referenced` - SimFCS REF and R64
17
+
18
+ - read time-resolved and hyperspectral signals, as well as metadata from
19
+ many file formats used in bio-imaging:
20
+
21
+ - :py:func:`signal_from_lif` - Leica LIF
22
+ - :py:func:`signal_from_lsm` - Zeiss LSM
23
+ - :py:func:`signal_from_ptu` - PicoQuant PTU
24
+ - :py:func:`signal_from_sdt` - Becker & Hickl SDT
25
+ - :py:func:`signal_from_fbd` - FLIMbox FBD
26
+ - :py:func:`signal_from_flimlabs_json` - FLIM LABS JSON
27
+ - :py:func:`signal_from_imspector_tiff` - ImSpector FLIM TIFF
28
+ - :py:func:`signal_from_flif` - FlimFast FLIF
29
+ - :py:func:`signal_from_b64` - SimFCS B64
30
+ - :py:func:`signal_from_z64` - SimFCS Z64
31
+ - :py:func:`signal_from_bhz` - SimFCS BHZ
32
+ - :py:func:`signal_from_bh` - SimFCS B&H
33
+
34
+ Support for other file formats is being considered:
35
+
36
+ - OME-TIFF
37
+ - Zeiss CZI
38
+ - Nikon ND2
39
+ - Olympus OIB/OIF
40
+ - Olympus OIR
41
+
42
+ The functions are implemented as minimal wrappers around specialized
43
+ third-party file reader libraries, currently
44
+ `tifffile <https://github.com/cgohlke/tifffile>`_,
45
+ `ptufile <https://github.com/cgohlke/ptufile>`_,
46
+ `liffile <https://github.com/cgohlke/liffile>`_,
47
+ `sdtfile <https://github.com/cgohlke/sdtfile>`_, and
48
+ `lfdfiles <https://github.com/cgohlke/lfdfiles>`_.
49
+ For advanced or unsupported use cases, consider using these libraries directly.
50
+
51
+ The signal-reading functions typically have the following signature::
52
+
53
+ signal_from_ext(
54
+ filename: str | PathLike,
55
+ /,
56
+ **kwargs
57
+ ): -> xarray.DataArray
58
+
59
+ where ``ext`` indicates the file format and ``kwargs`` are optional arguments
60
+ passed to the underlying file reader library or used to select which data is
61
+ returned. The returned `xarray.DataArray
62
+ <https://docs.xarray.dev/en/stable/user-guide/data-structures.html>`_
63
+ contains an n-dimensional array with labeled coordinates, dimensions, and
64
+ attributes:
65
+
66
+ - ``data`` or ``values`` (*array_like*)
67
+
68
+ Numpy array or array-like holding the array's values.
69
+
70
+ - ``dims`` (*tuple of str*)
71
+
72
+ :ref:`Axes character codes <axes>` for each dimension in ``data``.
73
+ For example, ``('T', 'C', 'Y', 'X')`` defines the dimension order in a
74
+ 4-dimensional array of a time-series of multi-channel images.
75
+
76
+ - ``coords`` (*dict_like[str, array_like]*)
77
+
78
+ Coordinate arrays labelling each point in the data array.
79
+ The keys are :ref:`axes character codes <axes>`.
80
+ Values are 1-dimensional arrays of numbers or strings.
81
+ For example, ``coords['C']`` could be an array of emission wavelengths.
82
+
83
+ - ``attrs`` (*dict[str, Any]*)
84
+
85
+ Arbitrary metadata such as measurement or calibration parameters required to
86
+ interpret the data values.
87
+ For example, the laser repetition frequency of a time-resolved measurement.
88
+
89
+ .. _axes:
90
+
91
+ Axes character codes from the OME model and tifffile library are used as
92
+ ``dims`` items and ``coords`` keys:
93
+
94
+ - ``'X'`` : width (OME)
95
+ - ``'Y'`` : height (OME)
96
+ - ``'Z'`` : depth (OME)
97
+ - ``'S'`` : sample (color components or phasor coordinates)
98
+ - ``'I'`` : sequence (of images, frames, or planes)
99
+ - ``'T'`` : time (OME)
100
+ - ``'C'`` : channel (OME. Acquisition path or emission wavelength)
101
+ - ``'A'`` : angle (OME)
102
+ - ``'P'`` : phase (OME. In LSM, ``'P'`` maps to position)
103
+ - ``'R'`` : tile (OME. Region, position, or mosaic)
104
+ - ``'H'`` : lifetime histogram (OME)
105
+ - ``'E'`` : lambda (OME. Excitation wavelength)
106
+ - ``'F'`` : frequency (ISS)
107
+ - ``'Q'`` : other (OME. Harmonics in PhasorPy TIFF)
108
+ - ``'L'`` : exposure (FluoView)
109
+ - ``'V'`` : event (FluoView)
110
+ - ``'M'`` : mosaic (LSM 6)
111
+ - ``'J'`` : column (NDTiff)
112
+ - ``'K'`` : row (NDTiff)
113
+
114
+ """
115
+
116
+ from __future__ import annotations
117
+
118
+ __all__ = [
119
+ 'phasor_from_flimlabs_json',
120
+ 'phasor_from_ifli',
121
+ 'phasor_from_lif',
122
+ 'phasor_from_ometiff',
123
+ 'phasor_from_simfcs_referenced',
124
+ 'phasor_to_ometiff',
125
+ 'phasor_to_simfcs_referenced',
126
+ 'signal_from_b64',
127
+ 'signal_from_bh',
128
+ 'signal_from_bhz',
129
+ # 'signal_from_czi',
130
+ 'signal_from_fbd',
131
+ 'signal_from_flif',
132
+ 'signal_from_flimlabs_json',
133
+ 'signal_from_imspector_tiff',
134
+ 'signal_from_lif',
135
+ 'signal_from_lsm',
136
+ # 'signal_from_nd2',
137
+ # 'signal_from_oif',
138
+ # 'signal_from_oir',
139
+ # 'signal_from_ometiff',
140
+ 'signal_from_ptu',
141
+ 'signal_from_sdt',
142
+ 'signal_from_z64',
143
+ '_squeeze_dims',
144
+ ]
145
+
146
+ import logging
147
+ import os
148
+ import re
149
+ import struct
150
+ import zlib
151
+ from typing import TYPE_CHECKING
152
+
153
+ from ._utils import chunk_iter, parse_harmonic
154
+ from .phasor import phasor_from_polar, phasor_to_polar
155
+
156
+ if TYPE_CHECKING:
157
+ from ._typing import (
158
+ Any,
159
+ ArrayLike,
160
+ Container,
161
+ DataArray,
162
+ DTypeLike,
163
+ EllipsisType,
164
+ Literal,
165
+ NDArray,
166
+ PathLike,
167
+ Sequence,
168
+ )
169
+
170
+ import numpy
171
+
172
+ logger = logging.getLogger(__name__)
173
+
174
+
175
+ def phasor_to_ometiff(
176
+ filename: str | PathLike[Any],
177
+ mean: ArrayLike,
178
+ real: ArrayLike,
179
+ imag: ArrayLike,
180
+ /,
181
+ *,
182
+ frequency: float | None = None,
183
+ harmonic: int | Sequence[int] | None = None,
184
+ dims: Sequence[str] | None = None,
185
+ dtype: DTypeLike | None = None,
186
+ description: str | None = None,
187
+ **kwargs: Any,
188
+ ) -> None:
189
+ """Write phasor coordinate images and metadata to OME-TIFF file.
190
+
191
+ The OME-TIFF format is compatible with Bio-Formats and Fiji.
192
+
193
+ By default, write phasor coordinates as single precision floating point
194
+ values to separate image series.
195
+ Write images larger than (1024, 1024) as (256, 256) tiles, datasets
196
+ larger than 2 GB as BigTIFF, and datasets larger than 8 KB zlib-compressed.
197
+
198
+ This file format is experimental and might be incompatible with future
199
+ versions of this library. It is intended for temporarily exchanging
200
+ phasor coordinates with other software, not as a long-term storage
201
+ solution.
202
+
203
+ Parameters
204
+ ----------
205
+ filename : str or Path
206
+ Name of PhasorPy OME-TIFF file to write.
207
+ mean : array_like
208
+ Average intensity image. Write to an image series named 'Phasor mean'.
209
+ real : array_like
210
+ Image of real component of phasor coordinates.
211
+ Multiple harmonics, if any, must be in the first dimension.
212
+ Write to image series named 'Phasor real'.
213
+ imag : array_like
214
+ Image of imaginary component of phasor coordinates.
215
+ Multiple harmonics, if any, must be in the first dimension.
216
+ Write to image series named 'Phasor imag'.
217
+ frequency : float, optional
218
+ Fundamental frequency of time-resolved phasor coordinates.
219
+ Usually in units of MHz.
220
+ Write to image series named 'Phasor frequency'.
221
+ harmonic : int or sequence of int, optional
222
+ Harmonics present in the first dimension of `real` and `imag`, if any.
223
+ Write to image series named 'Phasor harmonic'.
224
+ It is only needed if harmonics are not starting at and increasing by
225
+ one.
226
+ dims : sequence of str, optional
227
+ Character codes for `mean` image dimensions.
228
+ By default, the last dimensions are assumed to be 'TZCYX'.
229
+ If harmonics are present in `real` and `imag`, an "other" (``Q``)
230
+ dimension is prepended to axes for those arrays.
231
+ Refer to the OME-TIFF model for allowed axes and their order.
232
+ dtype : dtype-like, optional
233
+ Floating point data type used to store phasor coordinates.
234
+ The default is ``float32``, which has 6 digits of precision
235
+ and maximizes compatibility with other software.
236
+ description : str, optional
237
+ Plain-text description of dataset. Write as OME dataset description.
238
+ **kwargs
239
+ Additional arguments passed to :py:class:`tifffile.TiffWriter` and
240
+ :py:meth:`tifffile.TiffWriter.write`.
241
+ For example, ``compression=None`` writes image data uncompressed.
242
+
243
+ See Also
244
+ --------
245
+ phasorpy.io.phasor_from_ometiff
246
+
247
+ Notes
248
+ -----
249
+ Scalar or one-dimensional phasor coordinate arrays are written as images.
250
+
251
+ The OME-TIFF format is specified in the
252
+ `OME Data Model and File Formats Documentation
253
+ <https://ome-model.readthedocs.io/>`_.
254
+
255
+ The `6D, 7D and 8D storage
256
+ <https://ome-model.readthedocs.io/en/latest/developers/6d-7d-and-8d-storage.html>`_
257
+ extension is used to store multi-harmonic phasor coordinates.
258
+ The modulo type for the first, harmonic dimension is "other".
259
+
260
+ Examples
261
+ --------
262
+ >>> mean, real, imag = numpy.random.rand(3, 32, 32, 32)
263
+ >>> phasor_to_ometiff(
264
+ ... '_phasorpy.ome.tif', mean, real, imag, dims='ZYX', frequency=80.0
265
+ ... )
266
+
267
+ """
268
+ import tifffile
269
+
270
+ from .version import __version__
271
+
272
+ if dtype is None:
273
+ dtype = numpy.float32
274
+ dtype = numpy.dtype(dtype)
275
+ if dtype.kind != 'f':
276
+ raise ValueError(f'{dtype=} not a floating point type')
277
+
278
+ mean = numpy.asarray(mean, dtype)
279
+ real = numpy.asarray(real, dtype)
280
+ imag = numpy.asarray(imag, dtype)
281
+ datasize = mean.nbytes + real.nbytes + imag.nbytes
282
+
283
+ if real.shape != imag.shape:
284
+ raise ValueError(f'{real.shape=} != {imag.shape=}')
285
+ if mean.shape != real.shape[-mean.ndim :]:
286
+ raise ValueError(f'{mean.shape=} != {real.shape[-mean.ndim:]=}')
287
+ has_harmonic_dim = real.ndim == mean.ndim + 1
288
+ if mean.ndim == real.ndim or real.ndim == 0:
289
+ nharmonic = 1
290
+ else:
291
+ nharmonic = real.shape[0]
292
+
293
+ if mean.ndim < 2:
294
+ # not an image
295
+ mean = mean.reshape(1, -1)
296
+ if has_harmonic_dim:
297
+ real = real.reshape(real.shape[0], 1, -1)
298
+ imag = imag.reshape(imag.shape[0], 1, -1)
299
+ else:
300
+ real = real.reshape(1, -1)
301
+ imag = imag.reshape(1, -1)
302
+
303
+ if harmonic is not None:
304
+ harmonic, _ = parse_harmonic(harmonic)
305
+ if len(harmonic) != nharmonic:
306
+ raise ValueError('invalid harmonic')
307
+
308
+ if frequency is not None:
309
+ frequency_array = numpy.atleast_2d(frequency).astype(numpy.float64)
310
+ if frequency_array.size > 1:
311
+ raise ValueError('frequency must be scalar')
312
+
313
+ axes = 'TZCYX'[-mean.ndim :] if dims is None else ''.join(tuple(dims))
314
+ if len(axes) != mean.ndim:
315
+ raise ValueError(f'{axes=} does not match {mean.ndim=}')
316
+ axes_phasor = axes if mean.ndim == real.ndim else 'Q' + axes
317
+
318
+ if 'photometric' not in kwargs:
319
+ kwargs['photometric'] = 'minisblack'
320
+ if 'compression' not in kwargs and datasize > 8192:
321
+ kwargs['compression'] = 'zlib'
322
+ if 'tile' not in kwargs and 'rowsperstrip' not in kwargs:
323
+ if (
324
+ axes.endswith('YX')
325
+ and mean.shape[-1] > 1024
326
+ and mean.shape[-2] > 1024
327
+ ):
328
+ kwargs['tile'] = (256, 256)
329
+
330
+ mode = kwargs.pop('mode', None)
331
+ bigtiff = kwargs.pop('bigtiff', None)
332
+ if bigtiff is None:
333
+ bigtiff = datasize > 2**31
334
+
335
+ metadata = kwargs.pop('metadata', {})
336
+ if 'Creator' not in metadata:
337
+ metadata['Creator'] = f'PhasorPy {__version__}'
338
+
339
+ dataset = metadata.pop('Dataset', {})
340
+ if 'Name' not in dataset:
341
+ dataset['Name'] = 'Phasor'
342
+ if description:
343
+ dataset['Description'] = description
344
+ metadata['Dataset'] = dataset
345
+
346
+ if has_harmonic_dim:
347
+ metadata['TypeDescription'] = {'Q': 'Phasor harmonics'}
348
+
349
+ with tifffile.TiffWriter(
350
+ filename, bigtiff=bigtiff, mode=mode, ome=True
351
+ ) as tif:
352
+ metadata['Name'] = 'Phasor mean'
353
+ metadata['axes'] = axes
354
+ tif.write(mean, metadata=metadata, **kwargs)
355
+ del metadata['Dataset']
356
+
357
+ metadata['Name'] = 'Phasor real'
358
+ metadata['axes'] = axes_phasor
359
+ tif.write(real, metadata=metadata, **kwargs)
360
+
361
+ metadata['Name'] = 'Phasor imag'
362
+ tif.write(imag, metadata=metadata, **kwargs)
363
+
364
+ if frequency is not None:
365
+ tif.write(frequency_array, metadata={'Name': 'Phasor frequency'})
366
+
367
+ if harmonic is not None:
368
+ tif.write(
369
+ numpy.atleast_2d(harmonic).astype(numpy.uint32),
370
+ metadata={'Name': 'Phasor harmonic'},
371
+ )
372
+
373
+
374
+ def phasor_from_ometiff(
375
+ filename: str | PathLike[Any],
376
+ /,
377
+ *,
378
+ harmonic: int | Sequence[int] | Literal['all'] | str | None = None,
379
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any], dict[str, Any]]:
380
+ """Return phasor coordinates and metadata from PhasorPy OME-TIFF.
381
+
382
+ Parameters
383
+ ----------
384
+ filename : str or Path
385
+ Name of PhasorPy OME-TIFF file to read.
386
+ harmonic : int, sequence of int, or 'all', optional
387
+ Harmonic(s) to return from file.
388
+ If None (default), return the first harmonic stored in the file.
389
+ If `'all'`, return all harmonics as stored in file.
390
+ If a list, the first axes of the returned `real` and `imag` arrays
391
+ contain specified harmonic(s).
392
+ If an integer, the returned `real` and `imag` arrays are single
393
+ harmonic and have the same shape as `mean`.
394
+
395
+ Returns
396
+ -------
397
+ mean : ndarray
398
+ Average intensity image.
399
+ real : ndarray
400
+ Image of real component of phasor coordinates.
401
+ imag : ndarray
402
+ Image of imaginary component of phasor coordinates.
403
+ attrs : dict
404
+ Select metadata:
405
+
406
+ - ``'dims'`` (tuple of str):
407
+ :ref:`Axes codes <axes>` for `mean` image dimensions.
408
+ - ``'harmonic'`` (int or list of int):
409
+ Harmonic(s) present in `real` and `imag`.
410
+ If a scalar, `real` and `imag` are single harmonic and contain no
411
+ harmonic axes.
412
+ If a list, `real` and `imag` contain one or more harmonics in the
413
+ first axis.
414
+ - ``'frequency'`` (float, optional):
415
+ Fundamental frequency of time-resolved phasor coordinates.
416
+ Usually in units of MHz.
417
+ - ``'description'`` (str, optional):
418
+ OME dataset plain-text description.
419
+
420
+ Raises
421
+ ------
422
+ tifffile.TiffFileError
423
+ File is not a TIFF file.
424
+ ValueError
425
+ File is not an OME-TIFF containing phasor coordinates.
426
+ IndexError
427
+ Harmonic is not found in file.
428
+
429
+ See Also
430
+ --------
431
+ phasorpy.io.phasor_to_ometiff
432
+
433
+ Notes
434
+ -----
435
+ Scalar or one-dimensional phasor coordinates stored in the file are
436
+ returned as two-dimensional images (three-dimensional if multiple
437
+ harmonics are present).
438
+
439
+ Examples
440
+ --------
441
+ >>> mean, real, imag = numpy.random.rand(3, 32, 32, 32)
442
+ >>> phasor_to_ometiff(
443
+ ... '_phasorpy.ome.tif', mean, real, imag, dims='ZYX', frequency=80.0
444
+ ... )
445
+ >>> mean, real, imag, attrs = phasor_from_ometiff('_phasorpy.ome.tif')
446
+ >>> mean
447
+ array(...)
448
+ >>> mean.dtype
449
+ dtype('float32')
450
+ >>> mean.shape
451
+ (32, 32, 32)
452
+ >>> attrs['dims']
453
+ ('Z', 'Y', 'X')
454
+ >>> attrs['frequency']
455
+ 80.0
456
+ >>> attrs['harmonic']
457
+ 1
458
+
459
+ """
460
+ import tifffile
461
+
462
+ name = os.path.basename(filename)
463
+
464
+ with tifffile.TiffFile(filename) as tif:
465
+ if (
466
+ not tif.is_ome
467
+ or len(tif.series) < 3
468
+ or tif.series[0].name != 'Phasor mean'
469
+ or tif.series[1].name != 'Phasor real'
470
+ or tif.series[2].name != 'Phasor imag'
471
+ ):
472
+ raise ValueError(
473
+ f'{name!r} is not an OME-TIFF containing phasor images'
474
+ )
475
+
476
+ attrs: dict[str, Any] = {'dims': tuple(tif.series[0].axes)}
477
+
478
+ # TODO: read coords from OME-XML
479
+ ome_xml = tif.ome_metadata
480
+ assert ome_xml is not None
481
+
482
+ # TODO: parse OME-XML
483
+ match = re.search(
484
+ r'><Description>(.*)</Description><',
485
+ ome_xml,
486
+ re.MULTILINE | re.DOTALL,
487
+ )
488
+ if match is not None:
489
+ attrs['description'] = (
490
+ match.group(1)
491
+ .replace('&amp;', '&')
492
+ .replace('&gt;', '>')
493
+ .replace('&lt;', '<')
494
+ )
495
+
496
+ has_harmonic_dim = tif.series[1].ndim > tif.series[0].ndim
497
+ nharmonics = tif.series[1].shape[0] if has_harmonic_dim else 1
498
+ harmonic_max = nharmonics
499
+ for i in (3, 4):
500
+ if len(tif.series) < i + 1:
501
+ break
502
+ series = tif.series[i]
503
+ data = series.asarray().squeeze()
504
+ if series.name == 'Phasor frequency':
505
+ attrs['frequency'] = float(data.item(0))
506
+ elif series.name == 'Phasor harmonic':
507
+ if not has_harmonic_dim and data.size == 1:
508
+ attrs['harmonic'] = int(data.item(0))
509
+ harmonic_max = attrs['harmonic']
510
+ elif has_harmonic_dim and data.size == nharmonics:
511
+ attrs['harmonic'] = data.tolist()
512
+ harmonic_max = max(attrs['harmonic'])
513
+ else:
514
+ logger.warning(
515
+ f'harmonic={data} does not match phasor '
516
+ f'shape={tif.series[1].shape}'
517
+ )
518
+
519
+ if 'harmonic' not in attrs:
520
+ if has_harmonic_dim:
521
+ attrs['harmonic'] = list(range(1, nharmonics + 1))
522
+ else:
523
+ attrs['harmonic'] = 1
524
+ harmonic_stored = attrs['harmonic']
525
+
526
+ mean = tif.series[0].asarray()
527
+ if harmonic is None:
528
+ # first harmonic in file
529
+ if isinstance(harmonic_stored, list):
530
+ attrs['harmonic'] = harmonic_stored[0]
531
+ else:
532
+ attrs['harmonic'] = harmonic_stored
533
+ real = tif.series[1].asarray()
534
+ if has_harmonic_dim:
535
+ real = real[0].copy()
536
+ imag = tif.series[2].asarray()
537
+ if has_harmonic_dim:
538
+ imag = imag[0].copy()
539
+ elif isinstance(harmonic, str) and harmonic == 'all':
540
+ # all harmonics as stored in file
541
+ real = tif.series[1].asarray()
542
+ imag = tif.series[2].asarray()
543
+ else:
544
+ # specified harmonics
545
+ harmonic, keepdims = parse_harmonic(harmonic, harmonic_max)
546
+ try:
547
+ if isinstance(harmonic_stored, list):
548
+ index = [harmonic_stored.index(h) for h in harmonic]
549
+ else:
550
+ index = [[harmonic_stored].index(h) for h in harmonic]
551
+ except ValueError as exc:
552
+ raise IndexError('harmonic not found') from exc
553
+
554
+ if has_harmonic_dim:
555
+ if keepdims:
556
+ attrs['harmonic'] = [harmonic_stored[i] for i in index]
557
+ real = tif.series[1].asarray()[index].copy()
558
+ imag = tif.series[2].asarray()[index].copy()
559
+ else:
560
+ attrs['harmonic'] = harmonic_stored[index[0]]
561
+ real = tif.series[1].asarray()[index[0]].copy()
562
+ imag = tif.series[2].asarray()[index[0]].copy()
563
+ elif keepdims:
564
+ real = tif.series[1].asarray()
565
+ real = real.reshape(1, *real.shape)
566
+ imag = tif.series[2].asarray()
567
+ imag = imag.reshape(1, *imag.shape)
568
+ attrs['harmonic'] = [harmonic_stored]
569
+ else:
570
+ real = tif.series[1].asarray()
571
+ imag = tif.series[2].asarray()
572
+
573
+ if real.shape != imag.shape:
574
+ logger.warning(f'{real.shape=} != {imag.shape=}')
575
+ if real.shape[-mean.ndim :] != mean.shape:
576
+ logger.warning(f'{real.shape[-mean.ndim:]=} != {mean.shape=}')
577
+
578
+ return mean, real, imag, attrs
579
+
580
+
581
+ def phasor_to_simfcs_referenced(
582
+ filename: str | PathLike[Any],
583
+ mean: ArrayLike,
584
+ real: ArrayLike,
585
+ imag: ArrayLike,
586
+ /,
587
+ *,
588
+ size: int | None = None,
589
+ dims: Sequence[str] | None = None,
590
+ ) -> None:
591
+ """Write phasor coordinate images to SimFCS referenced R64 file(s).
592
+
593
+ SimFCS referenced R64 files store square-shaped (commonly 256x256)
594
+ images of the average intensity, and the calibrated phasor coordinates
595
+ (encoded as phase and modulation) of two harmonics as ZIP-compressed,
596
+ single precision floating point arrays.
597
+ The file format does not support any metadata.
598
+
599
+ Images with more than two dimensions or larger than square size are
600
+ chunked to square-sized images and saved to separate files with
601
+ a name pattern, for example, "filename_T099_Y256_X000.r64".
602
+ Images or chunks with less than two dimensions or smaller than square size
603
+ are padded with NaN values.
604
+
605
+ Parameters
606
+ ----------
607
+ filename : str or Path
608
+ Name of SimFCS referenced R64 file to write.
609
+ The file extension must be ``.r64``.
610
+ mean : array_like
611
+ Average intensity image.
612
+ real : array_like
613
+ Image of real component of calibrated phasor coordinates.
614
+ Multiple harmonics, if any, must be in the first dimension.
615
+ Harmonics must be starting at and increasing by one.
616
+ imag : array_like
617
+ Image of imaginary component of calibrated phasor coordinates.
618
+ Multiple harmonics, if any, must be in the first dimension.
619
+ Harmonics must be starting at and increasing by one.
620
+ size : int, optional
621
+ Size of X and Y dimensions of square-sized images stored in file.
622
+ By default, ``size = min(256, max(4, sizey, sizex))``.
623
+ dims : sequence of str, optional
624
+ Character codes for `mean` dimensions used to format file names.
625
+
626
+ See Also
627
+ --------
628
+ phasorpy.io.phasor_from_simfcs_referenced
629
+
630
+ Examples
631
+ --------
632
+ >>> mean, real, imag = numpy.random.rand(3, 32, 32)
633
+ >>> phasor_to_simfcs_referenced('_phasorpy.r64', mean, real, imag)
634
+
635
+ """
636
+ filename, ext = os.path.splitext(filename)
637
+ if ext.lower() != '.r64':
638
+ raise ValueError(f'file extension {ext} != .r64')
639
+
640
+ # TODO: delay conversions to numpy arrays to inner loop
641
+ mean = numpy.asarray(mean, numpy.float32)
642
+ phi, mod = phasor_to_polar(real, imag, dtype=numpy.float32)
643
+ del real
644
+ del imag
645
+ phi = numpy.rad2deg(phi)
646
+
647
+ if phi.shape != mod.shape:
648
+ raise ValueError(f'{phi.shape=} != {mod.shape=}')
649
+ if mean.shape != phi.shape[-mean.ndim :]:
650
+ raise ValueError(f'{mean.shape=} != {phi.shape[-mean.ndim:]=}')
651
+ if phi.ndim == mean.ndim:
652
+ phi = phi.reshape(1, *phi.shape)
653
+ mod = mod.reshape(1, *mod.shape)
654
+ nharmonic = phi.shape[0]
655
+
656
+ if mean.ndim < 2:
657
+ # not an image
658
+ mean = mean.reshape(1, -1)
659
+ phi = phi.reshape(nharmonic, 1, -1)
660
+ mod = mod.reshape(nharmonic, 1, -1)
661
+
662
+ # TODO: investigate actual size and harmonics limits of SimFCS
663
+ sizey, sizex = mean.shape[-2:]
664
+ if size is None:
665
+ size = min(256, max(4, sizey, sizex))
666
+ elif not 4 <= size <= 65535:
667
+ raise ValueError(f'{size=} out of range [4..65535]')
668
+
669
+ harmonics_per_file = 2 # TODO: make this a parameter?
670
+ chunk_shape = tuple(
671
+ [max(harmonics_per_file, 2)] + ([1] * (phi.ndim - 3)) + [size, size]
672
+ )
673
+ multi_file = any(i / j > 1 for i, j in zip(phi.shape, chunk_shape))
674
+
675
+ if dims is not None and len(dims) == phi.ndim - 1:
676
+ dims = tuple(dims)
677
+ dims = ('h' if dims[0].islower() else 'H',) + dims
678
+
679
+ chunk = numpy.empty((size, size), dtype=numpy.float32)
680
+
681
+ def rawdata_append(
682
+ rawdata: list[bytes], a: NDArray[Any] | None = None
683
+ ) -> None:
684
+ if a is None:
685
+ chunk[:] = numpy.nan
686
+ rawdata.append(chunk.tobytes())
687
+ else:
688
+ sizey, sizex = a.shape[-2:]
689
+ if sizey == size and sizex == size:
690
+ rawdata.append(a.tobytes())
691
+ elif sizey <= size and sizex <= size:
692
+ chunk[:sizey, :sizex] = a[..., :sizey, :sizex]
693
+ chunk[sizey:, sizex:] = numpy.nan
694
+ rawdata.append(chunk.tobytes())
695
+ else:
696
+ raise RuntimeError # should not be reached
697
+
698
+ for index, label, _ in chunk_iter(
699
+ phi.shape, chunk_shape, dims, squeeze=False, use_index=True
700
+ ):
701
+ rawdata = [struct.pack('I', size)]
702
+ rawdata_append(rawdata, mean[index[1:]])
703
+ phi_ = phi[index]
704
+ mod_ = mod[index]
705
+ for i in range(phi_.shape[0]):
706
+ rawdata_append(rawdata, phi_[i])
707
+ rawdata_append(rawdata, mod_[i])
708
+ if phi_.shape[0] == 1:
709
+ rawdata_append(rawdata)
710
+ rawdata_append(rawdata)
711
+
712
+ if not multi_file:
713
+ label = ''
714
+ with open(filename + label + ext, 'wb') as fh:
715
+ fh.write(zlib.compress(b''.join(rawdata)))
716
+
717
+
718
+ def phasor_from_simfcs_referenced(
719
+ filename: str | PathLike[Any],
720
+ /,
721
+ *,
722
+ harmonic: int | Sequence[int] | Literal['all'] | str | None = None,
723
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any], dict[str, Any]]:
724
+ """Return phasor coordinates and metadata from SimFCS REF or R64 file.
725
+
726
+ SimFCS referenced REF and R64 files contain phasor coordinate images
727
+ (encoded as phase and modulation) for two harmonics.
728
+ Phasor coordinates from lifetime-resolved signals are calibrated.
729
+
730
+ Parameters
731
+ ----------
732
+ filename : str or Path
733
+ Name of SimFCS REF or R64 file to read.
734
+ harmonic : int or sequence of int, optional
735
+ Harmonic(s) to include in returned phasor coordinates.
736
+ By default, only the first harmonic is returned.
737
+
738
+ Returns
739
+ -------
740
+ mean : ndarray
741
+ Average intensity image.
742
+ real : ndarray
743
+ Image of real component of phasor coordinates.
744
+ Multiple harmonics, if any, are in the first axis.
745
+ imag : ndarray
746
+ Image of imaginary component of phasor coordinates.
747
+ Multiple harmonics, if any, are in the first axis.
748
+ attrs : dict
749
+ Select metadata:
750
+
751
+ - ``'dims'`` (tuple of str):
752
+ :ref:`Axes codes <axes>` for `mean` image dimensions.
753
+
754
+ Raises
755
+ ------
756
+ lfdfiles.LfdfileError
757
+ File is not a SimFCS REF or R64 file.
758
+
759
+ See Also
760
+ --------
761
+ phasorpy.io.phasor_to_simfcs_referenced
762
+
763
+ Examples
764
+ --------
765
+ >>> phasor_to_simfcs_referenced(
766
+ ... '_phasorpy.r64', *numpy.random.rand(3, 32, 32)
767
+ ... )
768
+ >>> mean, real, imag, _ = phasor_from_simfcs_referenced('_phasorpy.r64')
769
+ >>> mean
770
+ array([[...]], dtype=float32)
771
+
772
+ """
773
+ import lfdfiles
774
+
775
+ ext = os.path.splitext(filename)[-1].lower()
776
+ if ext == '.r64':
777
+ with lfdfiles.SimfcsR64(filename) as r64:
778
+ data = r64.asarray()
779
+ elif ext == '.ref':
780
+ with lfdfiles.SimfcsRef(filename) as ref:
781
+ data = ref.asarray()
782
+ else:
783
+ raise ValueError(f'file extension must be .ref or .r64, not {ext!r}')
784
+
785
+ harmonic, keep_harmonic_dim = parse_harmonic(harmonic, data.shape[0] // 2)
786
+
787
+ mean = data[0].copy()
788
+ real = numpy.empty((len(harmonic),) + mean.shape, numpy.float32)
789
+ imag = numpy.empty_like(real)
790
+ for i, h in enumerate(harmonic):
791
+ h = (h - 1) * 2 + 1
792
+ re, im = phasor_from_polar(numpy.deg2rad(data[h]), data[h + 1])
793
+ real[i] = re
794
+ imag[i] = im
795
+ if not keep_harmonic_dim:
796
+ real = real.reshape(mean.shape)
797
+ imag = imag.reshape(mean.shape)
798
+
799
+ return mean, real, imag, {'dims': ('Y', 'X')}
800
+
801
+
802
+ def phasor_from_ifli(
803
+ filename: str | PathLike[Any],
804
+ /,
805
+ *,
806
+ channel: int | None = None,
807
+ harmonic: int | Sequence[int] | Literal['all', 'any'] | str | None = None,
808
+ **kwargs: Any,
809
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any], dict[str, Any]]:
810
+ """Return phasor coordinates and metadata from ISS IFLI file.
811
+
812
+ ISS VistaVision IFLI files contain calibrated phasor coordinates for
813
+ possibly several positions, wavelengths, time points, channels, slices,
814
+ and frequencies from analog or digital frequency-domain fluorescence
815
+ lifetime measurements.
816
+
817
+ Parameters
818
+ ----------
819
+ filename : str or Path
820
+ Name of ISS IFLI file to read.
821
+ channel : int, optional
822
+ If None (default), return all channels, else return specified channel.
823
+ harmonic : int, sequence of int, or 'all', optional
824
+ Harmonic(s) to return from file.
825
+ If None (default), return the first harmonic stored in file.
826
+ If `'all'`, return all harmonics of first frequency stored in file.
827
+ If `'any'`, return all frequencies as stored in file, not necessarily
828
+ harmonics of the first frequency.
829
+ If a list, the first axes of the returned `real` and `imag` arrays
830
+ contain specified harmonic(s).
831
+ If an integer, the returned `real` and `imag` arrays are single
832
+ harmonic and have the same shape as `mean`.
833
+ **kwargs
834
+ Additional arguments passed to :py:meth:`lfdfiles.VistaIfli.asarray`,
835
+ for example ``memmap=True``.
836
+
837
+ Returns
838
+ -------
839
+ mean : ndarray
840
+ Average intensity image.
841
+ May have up to 7 dimensions in ``'RETCZYX'`` order.
842
+ real : ndarray
843
+ Image of real component of phasor coordinates.
844
+ Same shape as `mean`, except it may have a harmonic/frequency
845
+ dimension prepended.
846
+ imag : ndarray
847
+ Image of imaginary component of phasor coordinates.
848
+ Same shape as `real`.
849
+ attrs : dict
850
+ Select metadata:
851
+
852
+ - ``'dims'`` (tuple of str):
853
+ :ref:`Axes codes <axes>` for `mean` image dimensions.
854
+ - ``'harmonic'`` (int or list of int):
855
+ Harmonic(s) present in `real` and `imag`.
856
+ If a scalar, `real` and `imag` are single harmonic and contain no
857
+ harmonic axes.
858
+ If a list, `real` and `imag` contain one or more harmonics in the
859
+ first axis.
860
+ - ``'frequency'`` (float):
861
+ Fundamental frequency of time-resolved phasor coordinates in MHz.
862
+ - ``'ifli_header'`` (dict):
863
+ Metadata from IFLI file header.
864
+
865
+ Raises
866
+ ------
867
+ lfdfiles.LfdFileError
868
+ File is not an ISS IFLI file.
869
+ IndexError
870
+ Harmonic is not found in file.
871
+
872
+ Examples
873
+ --------
874
+ >>> mean, real, imag, attr = phasor_from_ifli(
875
+ ... fetch('frequency_domain.ifli'), harmonic='all'
876
+ ... )
877
+ >>> mean.shape
878
+ (256, 256)
879
+ >>> real.shape
880
+ (4, 256, 256)
881
+ >>> attr['dims']
882
+ ('Y', 'X')
883
+ >>> attr['harmonic']
884
+ [1, 2, 3, 5]
885
+ >>> attr['frequency'] # doctest: +NUMBER
886
+ 80.33
887
+ >>> attr['ifli_header']
888
+ {'Version': 16, ... 'ModFrequency': (...), 'RefLifetime': (2.5,), ...}
889
+
890
+ """
891
+ import lfdfiles
892
+
893
+ with lfdfiles.VistaIfli(filename) as ifli:
894
+ assert ifli.axes is not None
895
+ data = ifli.asarray(**kwargs)
896
+ header = ifli.header
897
+ axes = ifli.axes
898
+
899
+ if channel is not None:
900
+ data = data[:, :, :, channel]
901
+ axes = axes[:3] + axes[4:]
902
+
903
+ shape, dims, _ = _squeeze_dims(data.shape, axes, skip='YXF')
904
+ data = data.reshape(shape)
905
+ data = numpy.moveaxis(data, -2, 0) # move frequency to first axis
906
+ mean = data[..., 0].mean(axis=0) # average frequencies
907
+ real = data[..., 1].copy()
908
+ imag = data[..., 2].copy()
909
+ dims = dims[:-2]
910
+ del data
911
+
912
+ frequencies = header['ModFrequency']
913
+ frequency = frequencies[0]
914
+ harmonic_stored = [
915
+ (
916
+ int(round(f / frequency))
917
+ if (0.99 < f / frequency % 1.0) < 1.01
918
+ else None
919
+ )
920
+ for f in frequencies
921
+ ]
922
+
923
+ index: int | list[int]
924
+ if harmonic is None:
925
+ # return first harmonic in file
926
+ keepdims = False
927
+ harmonic = [1]
928
+ index = [0]
929
+ elif isinstance(harmonic, str) and harmonic in {'all', 'any'}:
930
+ keepdims = True
931
+ if harmonic == 'any':
932
+ # return any frequency
933
+ harmonic = [
934
+ (frequencies[i] / frequency if h is None else h)
935
+ for i, h in enumerate(harmonic_stored)
936
+ ]
937
+ index = list(range(len(harmonic_stored)))
938
+ else:
939
+ # return only harmonics of first frequency
940
+ harmonic = [h for h in harmonic_stored if h is not None]
941
+ index = [i for i, h in enumerate(harmonic_stored) if h is not None]
942
+ else:
943
+ # return specified harmonics
944
+ harmonic, keepdims = parse_harmonic(
945
+ harmonic, max(h for h in harmonic_stored if h is not None)
946
+ )
947
+ try:
948
+ index = [harmonic_stored.index(h) for h in harmonic]
949
+ except ValueError as exc:
950
+ raise IndexError('harmonic not found') from exc
951
+
952
+ real = real[index]
953
+ imag = imag[index]
954
+ if not keepdims:
955
+ real = real[0]
956
+ imag = imag[0]
957
+
958
+ attrs = {
959
+ 'dims': tuple(dims),
960
+ 'harmonic': harmonic,
961
+ 'frequency': frequency * 1e-6,
962
+ 'ifli_header': header,
963
+ }
964
+
965
+ return mean, real, imag, attrs
966
+
967
+
968
+ def phasor_from_lif(
969
+ filename: str | PathLike[Any],
970
+ /,
971
+ series: str | None = None,
972
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any], dict[str, Any]]:
973
+ """Return phasor coordinates and metadata from Leica LIF file.
974
+
975
+ LIF files may contain uncalibrated phasor coordinate images and metadata
976
+ from the analysis of fluorescence lifetime imaging measurements.
977
+
978
+ Parameters
979
+ ----------
980
+ filename : str or Path
981
+ Name of Leica LIF file to read.
982
+ series : str, optional
983
+ Name of image containing phasor coordinates.
984
+
985
+ Returns
986
+ -------
987
+ mean : ndarray
988
+ Average intensity image.
989
+ real : ndarray
990
+ Image of real component of phasor coordinates.
991
+ imag : ndarray
992
+ Image of imaginary component of phasor coordinates.
993
+ attrs : dict
994
+ Select metadata:
995
+
996
+ - ``'dims'`` (tuple of str):
997
+ :ref:`Axes codes <axes>` for `mean` image dimensions.
998
+ - ``'frequency'`` (float):
999
+ Fundamental frequency of time-resolved phasor coordinates in MHz.
1000
+ May not be present in all LIF files.
1001
+ - ``'flim_rawdata'`` (dict):
1002
+ Settings from LIF SingleMoleculeDetection/RawData XML element.
1003
+
1004
+ Raises
1005
+ ------
1006
+ liffile.LifFileError
1007
+ File is not a Leica LIF file.
1008
+ ValueError
1009
+ File does not contain FLIM phasor images and metadata.
1010
+
1011
+ Examples
1012
+ --------
1013
+ >>> mean, real, imag, attrs = phasor_from_lif(fetch('FLIM_testdata.lif'))
1014
+ >>> real.shape
1015
+ (1024, 1024)
1016
+ >>> attrs['dims']
1017
+ ('Y', 'X')
1018
+ >>> attrs['frequency']
1019
+ 19.505
1020
+
1021
+ """
1022
+ # TODO: read harmonic from XML if possible
1023
+ # TODO: get calibration settings from XML metadata or lifetime and/or
1024
+ # phasor plot images
1025
+ import liffile
1026
+
1027
+ if series is None:
1028
+ series = ''
1029
+ else:
1030
+ series = f'.*{series}.*/'
1031
+
1032
+ with liffile.LifFile(filename) as lif:
1033
+ try:
1034
+ image = lif.series[series + 'Phasor Intensity$']
1035
+ dims = image.dims
1036
+ coords = image.coords
1037
+ # meta = image.attrs
1038
+ mean = image.asarray()
1039
+ real = lif.series[series + 'Phasor Real$'].asarray()
1040
+ imag = lif.series[series + 'Phasor Imaginary$'].asarray()
1041
+ # mask = lif.series[series + 'Phasor Mask$'].asarray()
1042
+ except Exception as exc:
1043
+ raise ValueError(
1044
+ f'{lif.filename} does not contain Phasor images'
1045
+ ) from exc
1046
+
1047
+ attrs: dict[str, Any] = {'dims': dims}
1048
+ xml = image.xml_element_smd
1049
+ if xml is not None:
1050
+ frequency = xml.find('.//Dataset/RawData/LaserPulseFrequency')
1051
+ if frequency is not None and frequency.text is not None:
1052
+ attrs['frequency'] = float(frequency.text) * 1e-6
1053
+ attrs['coords'] = coords
1054
+
1055
+ return (
1056
+ mean.astype(numpy.float32),
1057
+ real.astype(numpy.float32),
1058
+ imag.astype(numpy.float32),
1059
+ attrs,
1060
+ )
1061
+
1062
+
1063
+ def phasor_from_flimlabs_json(
1064
+ filename: str | PathLike[Any],
1065
+ /,
1066
+ channel: int | None = None,
1067
+ harmonic: int | Sequence[int] | Literal['all'] | str | None = None,
1068
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any], dict[str, Any]]:
1069
+ """Return phasor coordinates and metadata from FLIM LABS JSON phasor file.
1070
+
1071
+ Some FLIM LABS JSON files contain uncalibrated phasor coordinates
1072
+ (possibly for multiple channels and harmonics) and metadata from
1073
+ digital frequency-domain measurements.
1074
+
1075
+ The real and imaginary parts of the phasor coordinates are zero (not NaN)
1076
+ if the intensity is zero.
1077
+
1078
+ Parameters
1079
+ ----------
1080
+ filename : str or Path
1081
+ Name of FLIM LABS JSON phasor file to read.
1082
+ The file name usually contains the string "_phasor".
1083
+ channel : int, optional
1084
+ If None (default), return all channels, else return specified channel.
1085
+ harmonic : int, sequence of int, or 'all', optional
1086
+ Harmonic(s) to return from file.
1087
+ If None (default), return the first harmonic stored in the file.
1088
+ If `'all'`, return all harmonics as stored in file.
1089
+ If a list, the first axes of the returned `real` and `imag` arrays
1090
+ contain specified harmonic(s).
1091
+ If an integer, the returned `real` and `imag` arrays are single
1092
+ harmonic and have the same shape as `mean`.
1093
+
1094
+ Returns
1095
+ -------
1096
+ mean : ndarray
1097
+ Average intensity image.
1098
+ Zeroed if an intensity image is not present in file.
1099
+ real : ndarray
1100
+ Image of real component of phasor coordinates.
1101
+ imag : ndarray
1102
+ Image of imaginary component of phasor coordinates.
1103
+ attrs : dict
1104
+ Select metadata:
1105
+
1106
+ - ``'dims'`` (tuple of str):
1107
+ :ref:`Axes codes <axes>` for `mean` image dimensions.
1108
+ - ``'harmonic'`` (int):
1109
+ Harmonic of `real` and `imag`.
1110
+ - ``'frequency'`` (float):
1111
+ Fundamental frequency of time-resolved phasor coordinates in MHz.
1112
+ - ``'flimlabs_header'`` (dict):
1113
+ FLIM LABS file header.
1114
+
1115
+ Raises
1116
+ ------
1117
+ ValueError
1118
+ File is not a FLIM LABS JSON file containing phasor coordinates.
1119
+ IndexError
1120
+ Harmonic or channel not found in file.
1121
+
1122
+ See Also
1123
+ --------
1124
+ phasorpy.io.signal_from_flimlabs_json
1125
+
1126
+ Examples
1127
+ --------
1128
+ >>> mean, real, imag, attrs = phasor_from_flimlabs_json(
1129
+ ... fetch('convallaria_2_1737113097_phasor_ch1.json'), harmonic='all'
1130
+ ... )
1131
+ >>> real.shape
1132
+ (4, 256, 256)
1133
+ >>> attrs['dims']
1134
+ ('Y', 'X')
1135
+ >>> attrs['harmonic']
1136
+ [1, 2, 3, 4]
1137
+ >>> attrs['frequency'] # doctest: +NUMBER
1138
+ 79.51
1139
+
1140
+ """
1141
+ import json
1142
+
1143
+ with open(filename, 'rb') as fh:
1144
+ try:
1145
+ data = json.load(fh)
1146
+ except Exception as exc:
1147
+ raise ValueError('not a valid JSON file') from exc
1148
+
1149
+ if (
1150
+ 'header' not in data
1151
+ or 'phasors_data' not in data
1152
+ or 'laser_period_ns' not in data['header']
1153
+ or 'file_id' not in data['header']
1154
+ # or data['header']['file_id'] != [73, 80, 71, 49] # 'IPG1'
1155
+ ):
1156
+ raise ValueError(
1157
+ 'not a FLIM LABS JSON file containing phasor coordinates'
1158
+ )
1159
+
1160
+ header = data['header']
1161
+ phasor_data = data['phasors_data']
1162
+
1163
+ nchannels = len([c for c in header['channels'] if c])
1164
+ if channel is not None and (channel < 0 or channel >= nchannels):
1165
+ raise IndexError(f'{channel=}')
1166
+
1167
+ harmonics = []
1168
+ channels = []
1169
+ for d in phasor_data:
1170
+ h = d['harmonic']
1171
+ if h not in harmonics:
1172
+ harmonics.append(h)
1173
+ c = d['channel']
1174
+ if c not in channels:
1175
+ channels.append(c)
1176
+ harmonics = sorted(harmonics)
1177
+ channels = sorted(channels)
1178
+
1179
+ if len(channels) != nchannels:
1180
+ raise ValueError(f'{len(channels)=} != {nchannels=}')
1181
+
1182
+ if isinstance(harmonic, str) and harmonic == 'all':
1183
+ harmonic = harmonics
1184
+ keep_harmonic_axis = True
1185
+ else:
1186
+ harmonic, keep_harmonic_axis = parse_harmonic(harmonic, harmonics[-1])
1187
+ if any(h not in harmonics for h in harmonic):
1188
+ raise IndexError(f'{harmonic=} not in {harmonics!r}')
1189
+ harmonic_index = {h: i for i, h in enumerate(harmonic)}
1190
+
1191
+ nharmonics = len(harmonic)
1192
+ nchannels = len(channels) if channel is None else 1
1193
+ height = header['image_height']
1194
+ width = header['image_width']
1195
+ dtype = numpy.float32
1196
+
1197
+ shape: tuple[int, ...] = nharmonics, nchannels, height, width
1198
+ axes: str = 'CYX'
1199
+ mean = numpy.zeros(shape[1:], dtype)
1200
+ real = numpy.zeros(shape, dtype)
1201
+ imag = numpy.zeros(shape, dtype)
1202
+
1203
+ for d in phasor_data:
1204
+ h = d['harmonic']
1205
+ if h not in harmonic_index:
1206
+ continue
1207
+ h = harmonic_index[h]
1208
+ c = channels.index(d['channel'])
1209
+ if channel is not None:
1210
+ if c != channel:
1211
+ continue
1212
+ c = 0
1213
+ real[h, c] = numpy.asarray(d['g_data'], dtype)
1214
+ imag[h, c] = numpy.asarray(d['s_data'], dtype)
1215
+
1216
+ if 'intensities_data' in data:
1217
+ from ._phasorpy import _flimlabs_mean
1218
+
1219
+ mean.shape = nchannels, height * width
1220
+ _flimlabs_mean(
1221
+ mean,
1222
+ data['intensities_data'],
1223
+ -1 if channel is None else channel,
1224
+ )
1225
+ mean.shape = shape[1:]
1226
+
1227
+ if nchannels == 1:
1228
+ axes = axes[1:]
1229
+ mean = mean[0]
1230
+ real = real[:, 0]
1231
+ imag = imag[:, 0]
1232
+
1233
+ if not keep_harmonic_axis:
1234
+ real = real[0]
1235
+ imag = imag[0]
1236
+
1237
+ attrs = {
1238
+ 'dims': tuple(axes),
1239
+ 'samples': 256,
1240
+ 'harmonic': harmonic if keep_harmonic_axis else harmonic[0],
1241
+ 'frequency': 1000.0 / header['laser_period_ns'],
1242
+ 'flimlabs_header': header,
1243
+ }
1244
+
1245
+ return mean, real, imag, attrs
1246
+
1247
+
1248
+ def signal_from_flimlabs_json(
1249
+ filename: str | PathLike[Any],
1250
+ /,
1251
+ *,
1252
+ channel: int | None = None,
1253
+ dtype: DTypeLike | None = None,
1254
+ ) -> DataArray:
1255
+ """Return TCSPC histogram and metadata from FLIM LABS JSON imaging file.
1256
+
1257
+ FLIM LABS JSON imaging files contain encoded, multi-channel TCSPC
1258
+ histogram images and metadata from digital frequency-domain measurements.
1259
+
1260
+ Parameters
1261
+ ----------
1262
+ filename : str or Path
1263
+ Name of FLIM LABS JSON imaging file to read.
1264
+ The file name usually contains the string "_imaging" or "_phasor".
1265
+ channel : int, optional
1266
+ If None (default), return all channels, else return specified channel.
1267
+ dtype : dtype-like, optional, default: uint16
1268
+ Unsigned integer type of image histogram array.
1269
+ Increase the bit-depth for high photon counts.
1270
+
1271
+ Returns
1272
+ -------
1273
+ xarray.DataArray
1274
+ TCSPC histogram image stack.
1275
+ A 3 or 4-dimensional array of type `dtype` in dimension order
1276
+ ``'CYXH'``.
1277
+
1278
+ - ``coords['H']``: times of histogram bins in ns.
1279
+ - ``attrs['frequency']``: laser repetition frequency in MHz.
1280
+ - ``attrs['flimlabs_header']``: FLIM LABS file header.
1281
+
1282
+ Raises
1283
+ ------
1284
+ ValueError
1285
+ File is not a FLIM LABS JSON file containing TCSPC histogram.
1286
+ `dtype` is not an unsigned integer.
1287
+ IndexError
1288
+ Channel out of range.
1289
+
1290
+ See Also
1291
+ --------
1292
+ phasorpy.io.phasor_from_flimlabs_json
1293
+
1294
+ Examples
1295
+ --------
1296
+ >>> signal = signal_from_flimlabs_json(
1297
+ ... fetch('convallaria_2_1737113097_phasor_ch1.json')
1298
+ ... )
1299
+ >>> signal.values
1300
+ array(...)
1301
+ >>> signal.shape
1302
+ (256, 256, 256)
1303
+ >>> signal.dims
1304
+ ('Y', 'X', 'H')
1305
+ >>> signal.coords['H'].data
1306
+ array(...)
1307
+ >>> signal.attrs['frequency'] # doctest: +NUMBER
1308
+ 79.51
1309
+
1310
+ """
1311
+ import json
1312
+
1313
+ with open(filename, 'rb') as fh:
1314
+ try:
1315
+ data = json.load(fh)
1316
+ except Exception as exc:
1317
+ raise ValueError('not a valid JSON file') from exc
1318
+
1319
+ if (
1320
+ 'header' not in data
1321
+ or 'laser_period_ns' not in data['header']
1322
+ or 'file_id' not in data['header']
1323
+ or ('data' not in data and 'intensities_data' not in data)
1324
+ ):
1325
+ raise ValueError(
1326
+ 'not a FLIM LABS JSON file containing TCSPC histogram'
1327
+ )
1328
+
1329
+ if dtype is None:
1330
+ dtype = numpy.uint16
1331
+ else:
1332
+ dtype = numpy.dtype(dtype)
1333
+ if dtype.kind != 'u':
1334
+ raise ValueError(f'{dtype=} is not an unsigned integer type')
1335
+
1336
+ header = data['header']
1337
+ nchannels = len([c for c in header['channels'] if c])
1338
+ height = header['image_height']
1339
+ width = header['image_width']
1340
+ frequency = 1000.0 / header['laser_period_ns']
1341
+
1342
+ if channel is not None:
1343
+ if channel >= nchannels or channel < 0:
1344
+ raise IndexError(f'{channel=} not in range(0, {nchannels=})')
1345
+ nchannels = 1
1346
+
1347
+ if 'data' in data:
1348
+ # file_id = [73, 77, 71, 49] # 'IMG1'
1349
+ intensities_data = data['data']
1350
+ else:
1351
+ # file_id = [73, 80, 71, 49] # 'IPG1'
1352
+ intensities_data = data['intensities_data']
1353
+
1354
+ from ._phasorpy import _flimlabs_signal
1355
+
1356
+ signal = numpy.zeros((nchannels, height * width, 256), dtype)
1357
+ _flimlabs_signal(
1358
+ signal,
1359
+ intensities_data,
1360
+ -1 if channel is None else channel,
1361
+ )
1362
+
1363
+ if channel is None and nchannels > 1:
1364
+ signal.shape = (nchannels, height, width, 256)
1365
+ axes = 'CYXH'
1366
+ else:
1367
+ signal.shape = (height, width, 256)
1368
+ axes = 'YXH'
1369
+
1370
+ coords: dict[str, Any] = {}
1371
+ coords['H'] = numpy.linspace(
1372
+ 0.0, header['laser_period_ns'], 256, endpoint=False
1373
+ )
1374
+ if channel is None and nchannels > 1:
1375
+ coords['C'] = numpy.asarray(
1376
+ [i for i, c in enumerate(header['channels']) if c]
1377
+ )
1378
+
1379
+ metadata = _metadata(axes, signal.shape, filename, **coords)
1380
+ attrs = metadata['attrs']
1381
+ attrs['frequency'] = frequency
1382
+ attrs['flimlabs_header'] = header
1383
+
1384
+ from xarray import DataArray
1385
+
1386
+ return DataArray(signal, **metadata)
1387
+
1388
+
1389
+ def signal_from_lif(
1390
+ filename: str | PathLike[Any],
1391
+ /,
1392
+ *,
1393
+ series: int | str | None = None,
1394
+ dim: Literal['λ', 'Λ'] | str = 'λ',
1395
+ ) -> DataArray:
1396
+ """Return hyperspectral image and metadata from Leica LIF file.
1397
+
1398
+ LIF files may contain hyperspectral images and metadata from laser
1399
+ scanning microscopy measurements.
1400
+ Reading of TCSPC histograms from FLIM measurements is not supported
1401
+ because the compression scheme is patent-pending.
1402
+
1403
+ Parameters
1404
+ ----------
1405
+ filename : str or Path
1406
+ Name of Leica LIF file to read.
1407
+ series : str or int, optional
1408
+ Index or regex pattern of image series to return.
1409
+ By default, return the first series containing hyperspectral data.
1410
+ dim : str or None
1411
+ Character code of hyperspectral dimension.
1412
+ Either ``'λ'`` for emission (default) or ``'Λ'`` for excitation.
1413
+
1414
+ Returns
1415
+ -------
1416
+ xarray.DataArray
1417
+ Hyperspectral image data.
1418
+
1419
+ - ``coords['C']``: wavelengths in nm.
1420
+ - ``coords['T']``: time coordinates in s, if any.
1421
+
1422
+ Raises
1423
+ ------
1424
+ liffile.LifFileError
1425
+ File is not a Leica LIF file.
1426
+ ValueError
1427
+ File is not an LSM file or does not contain hyperspectral image.
1428
+
1429
+ Examples
1430
+ --------
1431
+ >>> signal = signal_from_lif('ScanModesExamples.lif') # doctest: +SKIP
1432
+ >>> signal.values # doctest: +SKIP
1433
+ array(...)
1434
+ >>> signal.shape # doctest: +SKIP
1435
+ (9, 128, 128)
1436
+ >>> signal.dims # doctest: +SKIP
1437
+ ('C', 'Y', 'X')
1438
+ >>> signal.coords['C'].data # doctest: +SKIP
1439
+ array([560, 580, 600, ..., 680, 700, 720])
1440
+
1441
+ """
1442
+ import liffile
1443
+
1444
+ with liffile.LifFile(filename) as lif:
1445
+ if series is None:
1446
+ # find series with excitation or emission dimension
1447
+ for image in lif.series:
1448
+ if dim in image.dims:
1449
+ break
1450
+ else:
1451
+ raise ValueError(
1452
+ f'{lif!r} does not contain hyperspectral image'
1453
+ )
1454
+ else:
1455
+ image = lif.series[series]
1456
+
1457
+ if dim not in image.dims or image.sizes[dim] < 4:
1458
+ raise ValueError(f'{image!r} does not contain spectral dimension')
1459
+ if 'C' in image.dims:
1460
+ raise ValueError(
1461
+ 'hyperspectral image must not contain channel axis'
1462
+ )
1463
+
1464
+ data = image.asarray()
1465
+ coords: dict[str, Any] = {
1466
+ ('C' if k == dim else k): (v * 1e9 if k == dim else v)
1467
+ for (k, v) in image.coords.items()
1468
+ }
1469
+ dims = tuple(('C' if d == dim else d) for d in image.dims)
1470
+
1471
+ metadata = _metadata(dims, image.shape, filename, **coords)
1472
+
1473
+ from xarray import DataArray
1474
+
1475
+ return DataArray(data, **metadata)
1476
+
1477
+
1478
+ def signal_from_lsm(
1479
+ filename: str | PathLike[Any],
1480
+ /,
1481
+ ) -> DataArray:
1482
+ """Return hyperspectral image and metadata from Zeiss LSM file.
1483
+
1484
+ LSM files contain multi-dimensional images and metadata from laser
1485
+ scanning microscopy measurements. The file format is based on TIFF.
1486
+
1487
+ Parameters
1488
+ ----------
1489
+ filename : str or Path
1490
+ Name of Zeiss LSM file to read.
1491
+
1492
+ Returns
1493
+ -------
1494
+ xarray.DataArray
1495
+ Hyperspectral image data.
1496
+ Usually, a 3-to-5-dimensional array of type ``uint8`` or ``uint16``.
1497
+
1498
+ - ``coords['C']``: wavelengths in nm.
1499
+ - ``coords['T']``: time coordinates in s, if any.
1500
+
1501
+ Raises
1502
+ ------
1503
+ tifffile.TiffFileError
1504
+ File is not a TIFF file.
1505
+ ValueError
1506
+ File is not an LSM file or does not contain hyperspectral image.
1507
+
1508
+ Examples
1509
+ --------
1510
+ >>> signal = signal_from_lsm(fetch('paramecium.lsm'))
1511
+ >>> signal.values
1512
+ array(...)
1513
+ >>> signal.dtype
1514
+ dtype('uint8')
1515
+ >>> signal.shape
1516
+ (30, 512, 512)
1517
+ >>> signal.dims
1518
+ ('C', 'Y', 'X')
1519
+ >>> signal.coords['C'].data # wavelengths
1520
+ array([423, ..., 713])
1521
+
1522
+ """
1523
+ import tifffile
1524
+
1525
+ with tifffile.TiffFile(filename) as tif:
1526
+ if not tif.is_lsm:
1527
+ raise ValueError(f'{tif.filename} is not an LSM file')
1528
+
1529
+ page = tif.pages.first
1530
+ lsminfo = tif.lsm_metadata
1531
+ channels = page.tags[258].count
1532
+
1533
+ if channels < 4 or lsminfo is None or lsminfo['SpectralScan'] != 1:
1534
+ raise ValueError(
1535
+ f'{tif.filename} does not contain hyperspectral image'
1536
+ )
1537
+
1538
+ # TODO: contribute this to tifffile
1539
+ series = tif.series[0]
1540
+ data = series.asarray()
1541
+ dims = tuple(series.axes)
1542
+ coords = {}
1543
+ # channel wavelengths
1544
+ axis = dims.index('C')
1545
+ wavelengths = lsminfo['ChannelWavelength'].mean(axis=1)
1546
+ if wavelengths.size != data.shape[axis]:
1547
+ raise ValueError(
1548
+ f'{tif.filename} wavelengths do not match channel axis'
1549
+ )
1550
+ # stack may contain non-wavelength frame
1551
+ indices = wavelengths > 0
1552
+ wavelengths = wavelengths[indices]
1553
+ if wavelengths.size < 3:
1554
+ raise ValueError(
1555
+ f'{tif.filename} does not contain hyperspectral image'
1556
+ )
1557
+ wavelengths *= 1e9
1558
+ data = data.take(indices.nonzero()[0], axis=axis)
1559
+ coords['C'] = wavelengths
1560
+ # time stamps
1561
+ if 'T' in dims:
1562
+ coords['T'] = lsminfo['TimeStamps'] - lsminfo['TimeStamps'][0]
1563
+ if coords['T'].size != data.shape[dims.index('T')]:
1564
+ raise ValueError(
1565
+ f'{tif.filename} timestamps do not match time axis'
1566
+ )
1567
+ # spatial coordinates
1568
+ for ax in 'ZYX':
1569
+ if ax in dims:
1570
+ size = data.shape[dims.index(ax)]
1571
+ coords[ax] = numpy.linspace(
1572
+ lsminfo[f'Origin{ax}'],
1573
+ size * lsminfo[f'VoxelSize{ax}'],
1574
+ size,
1575
+ endpoint=False,
1576
+ dtype=numpy.float64,
1577
+ )
1578
+ metadata = _metadata(series.axes, data.shape, filename, **coords)
1579
+
1580
+ from xarray import DataArray
1581
+
1582
+ return DataArray(data, **metadata)
1583
+
1584
+
1585
+ def signal_from_imspector_tiff(
1586
+ filename: str | PathLike[Any],
1587
+ /,
1588
+ ) -> DataArray:
1589
+ """Return FLIM image stack and metadata from ImSpector TIFF file.
1590
+
1591
+ Parameters
1592
+ ----------
1593
+ filename : str or Path
1594
+ Name of ImSpector FLIM TIFF file to read.
1595
+
1596
+ Returns
1597
+ -------
1598
+ xarray.DataArray
1599
+ TCSPC image stack.
1600
+ Usually, a 3-to-5-dimensional array of type ``uint16``.
1601
+
1602
+ - ``coords['H']``: times of histogram bins in ns.
1603
+ - ``attrs['frequency']``: repetition frequency in MHz.
1604
+
1605
+ Raises
1606
+ ------
1607
+ tifffile.TiffFileError
1608
+ File is not a TIFF file.
1609
+ ValueError
1610
+ File is not an ImSpector FLIM TIFF file.
1611
+
1612
+ Examples
1613
+ --------
1614
+ >>> signal = signal_from_imspector_tiff(fetch('Embryo.tif'))
1615
+ >>> signal.values
1616
+ array(...)
1617
+ >>> signal.dtype
1618
+ dtype('uint16')
1619
+ >>> signal.shape
1620
+ (56, 512, 512)
1621
+ >>> signal.dims
1622
+ ('H', 'Y', 'X')
1623
+ >>> signal.coords['H'].data # dtime bins
1624
+ array([0, ..., 12.26])
1625
+ >>> signal.attrs['frequency'] # doctest: +NUMBER
1626
+ 80.109
1627
+
1628
+ """
1629
+ from xml.etree import ElementTree
1630
+
1631
+ import tifffile
1632
+
1633
+ with tifffile.TiffFile(filename) as tif:
1634
+ tags = tif.pages.first.tags
1635
+ omexml = tags.valueof(270, '')
1636
+ make = tags.valueof(271, '')
1637
+
1638
+ if (
1639
+ make != 'ImSpector'
1640
+ or not omexml.startswith('<?xml version')
1641
+ or len(tif.series) != 1
1642
+ or not tif.is_ome
1643
+ ):
1644
+ raise ValueError(f'{tif.filename} is not an ImSpector TIFF file')
1645
+
1646
+ series = tif.series[0]
1647
+ ndim = series.ndim
1648
+ axes = series.axes
1649
+ shape = series.shape
1650
+
1651
+ if ndim < 3 or not axes.endswith('YX'):
1652
+ raise ValueError(
1653
+ f'{tif.filename} is not an ImSpector FLIM TIFF file'
1654
+ )
1655
+
1656
+ data = series.asarray()
1657
+
1658
+ attrs: dict[str, Any] = {}
1659
+ coords = {}
1660
+ physical_size = {}
1661
+
1662
+ root = ElementTree.fromstring(omexml)
1663
+ ns = {
1664
+ '': 'http://www.openmicroscopy.org/Schemas/OME/2008-02',
1665
+ 'ca': 'http://www.openmicroscopy.org/Schemas/CA/2008-02',
1666
+ }
1667
+
1668
+ description = root.find('.//Description', ns)
1669
+ if (
1670
+ description is not None
1671
+ and description.text
1672
+ and description.text != 'not_specified'
1673
+ ):
1674
+ attrs['description'] = description.text
1675
+
1676
+ pixels = root.find('.//Image/Pixels', ns)
1677
+ assert pixels is not None
1678
+ for ax in 'TZYX':
1679
+ attrib = 'TimeIncrement' if ax == 'T' else f'PhysicalSize{ax}'
1680
+ if ax not in axes or attrib not in pixels.attrib:
1681
+ continue
1682
+ size = float(pixels.attrib[attrib]) * shape[axes.index(ax)]
1683
+ physical_size[ax] = size
1684
+ coords[ax] = numpy.linspace(
1685
+ 0.0,
1686
+ size,
1687
+ shape[axes.index(ax)],
1688
+ endpoint=False,
1689
+ dtype=numpy.float64,
1690
+ )
1691
+
1692
+ axes_labels = root.find('.//Image/ca:CustomAttributes/AxesLabels', ns)
1693
+ if (
1694
+ axes_labels is None
1695
+ or 'X' not in axes_labels.attrib
1696
+ or 'TCSPC' not in axes_labels.attrib['X']
1697
+ or 'FirstAxis' not in axes_labels.attrib
1698
+ or 'SecondAxis' not in axes_labels.attrib
1699
+ ):
1700
+ raise ValueError(f'{tif.filename} is not an ImSpector FLIM TIFF file')
1701
+
1702
+ if axes_labels.attrib['FirstAxis'] == 'lifetime' or axes_labels.attrib[
1703
+ 'FirstAxis'
1704
+ ].endswith('TCSPC T'):
1705
+ ax = axes[-3]
1706
+ assert axes_labels.attrib['FirstAxis-Unit'] == 'ns'
1707
+ elif ndim > 3 and (
1708
+ axes_labels.attrib['SecondAxis'] == 'lifetime'
1709
+ or axes_labels.attrib['SecondAxis'].endswith('TCSPC T')
1710
+ ):
1711
+ ax = axes[-4]
1712
+ assert axes_labels.attrib['SecondAxis-Unit'] == 'ns'
1713
+ else:
1714
+ raise ValueError(f'{tif.filename} is not an ImSpector FLIM TIFF file')
1715
+ axes = axes.replace(ax, 'H')
1716
+ coords['H'] = coords[ax]
1717
+ del coords[ax]
1718
+
1719
+ attrs['frequency'] = float(1000.0 / physical_size[ax])
1720
+
1721
+ metadata = _metadata(axes, shape, filename, attrs=attrs, **coords)
1722
+
1723
+ from xarray import DataArray
1724
+
1725
+ return DataArray(data, **metadata)
1726
+
1727
+
1728
+ def signal_from_sdt(
1729
+ filename: str | PathLike[Any],
1730
+ /,
1731
+ *,
1732
+ index: int = 0,
1733
+ ) -> DataArray:
1734
+ """Return time-resolved image and metadata from Becker & Hickl SDT file.
1735
+
1736
+ SDT files contain TCSPC measurement data and instrumentation parameters.
1737
+
1738
+ Parameters
1739
+ ----------
1740
+ filename : str or Path
1741
+ Name of Becker & Hickl SDT file to read.
1742
+ index : int, optional, default: 0
1743
+ Index of dataset to read in case the file contains multiple datasets.
1744
+
1745
+ Returns
1746
+ -------
1747
+ xarray.DataArray
1748
+ Time correlated single photon counting image data with
1749
+ :ref:`axes codes <axes>` ``'YXH'`` and type ``uint16``, ``uint32``,
1750
+ or ``float32``.
1751
+
1752
+ - ``coords['H']``: times of histogram bins in ns.
1753
+ - ``attrs['frequency']``: repetition frequency in MHz.
1754
+
1755
+ Raises
1756
+ ------
1757
+ ValueError
1758
+ File is not a SDT file containing TCSPC histogram.
1759
+
1760
+ Examples
1761
+ --------
1762
+ >>> signal = signal_from_sdt(fetch('tcspc.sdt'))
1763
+ >>> signal.values
1764
+ array(...)
1765
+ >>> signal.dtype
1766
+ dtype('uint16')
1767
+ >>> signal.shape
1768
+ (128, 128, 256)
1769
+ >>> signal.dims
1770
+ ('Y', 'X', 'H')
1771
+ >>> signal.coords['H'].data
1772
+ array([0, ..., 12.45])
1773
+ >>> signal.attrs['frequency'] # doctest: +NUMBER
1774
+ 79.99
1775
+
1776
+ """
1777
+ import sdtfile
1778
+
1779
+ with sdtfile.SdtFile(filename) as sdt:
1780
+ if (
1781
+ 'SPC Setup & Data File' not in sdt.info.id
1782
+ and 'SPC FCS Data File' not in sdt.info.id
1783
+ ):
1784
+ # skip DLL data
1785
+ raise ValueError(
1786
+ f'{os.path.basename(filename)!r} '
1787
+ 'is not an SDT file containing TCSPC data'
1788
+ )
1789
+ # filter block types?
1790
+ # sdtfile.BlockType(sdt.block_headers[index].block_type).contents
1791
+ # == 'PAGE_BLOCK'
1792
+ data = sdt.data[index]
1793
+ times = sdt.times[index] * 1e9
1794
+
1795
+ # TODO: get spatial coordinates from scanner settings?
1796
+ metadata = _metadata('QYXH'[-data.ndim :], data.shape, filename, H=times)
1797
+ metadata['attrs']['frequency'] = 1e3 / float(times[-1] + times[1])
1798
+
1799
+ from xarray import DataArray
1800
+
1801
+ return DataArray(data, **metadata)
1802
+
1803
+
1804
+ def signal_from_ptu(
1805
+ filename: str | PathLike[Any],
1806
+ /,
1807
+ selection: Sequence[int | slice | EllipsisType | None] | None = None,
1808
+ *,
1809
+ trimdims: Sequence[Literal['T', 'C', 'H']] | str | None = None,
1810
+ dtype: DTypeLike | None = None,
1811
+ frame: int | None = None,
1812
+ channel: int | None = None,
1813
+ dtime: int | None = 0,
1814
+ keepdims: bool = True,
1815
+ ) -> DataArray:
1816
+ """Return TCSPC histogram and metadata from PicoQuant PTU T3 mode file.
1817
+
1818
+ PTU files contain time-correlated single photon counting measurement data
1819
+ and instrumentation parameters.
1820
+
1821
+ Parameters
1822
+ ----------
1823
+ filename : str or Path
1824
+ Name of PTU file to read.
1825
+ selection : sequence of index types, optional
1826
+ Indices for all dimensions of image mode files:
1827
+
1828
+ - ``None``: return all items along axis (default).
1829
+ - ``Ellipsis``: return all items along multiple axes.
1830
+ - ``int``: return single item along axis.
1831
+ - ``slice``: return chunk of axis.
1832
+ ``slice.step`` is a binning factor.
1833
+ If ``slice.step=-1``, integrate all items along axis.
1834
+
1835
+ trimdims : str, optional, default: 'TCH'
1836
+ Axes to trim.
1837
+ dtype : dtype-like, optional, default: uint16
1838
+ Unsigned integer type of image histogram array.
1839
+ Increase the bit depth to avoid overflows when integrating.
1840
+ frame : int, optional
1841
+ If < 0, integrate time axis, else return specified frame.
1842
+ Overrides `selection` for axis ``T``.
1843
+ channel : int, optional
1844
+ If < 0, integrate channel axis, else return specified channel.
1845
+ Overrides `selection` for axis ``C``.
1846
+ dtime : int, optional, default: 0
1847
+ Specifies number of bins in image histogram.
1848
+ If 0 (default), return the number of bins in one period.
1849
+ If < 0, integrate delay time axis (image mode only).
1850
+ If > 0, return up to specified bin.
1851
+ Overrides `selection` for axis ``H``.
1852
+ keepdims : bool, optional, default: True
1853
+ If true (default), reduced axes are left as size-one dimension.
1854
+
1855
+ Returns
1856
+ -------
1857
+ xarray.DataArray
1858
+ Decoded TTTR T3 records as up to 5-dimensional image array
1859
+ with :ref:`axes codes <axes>` ``'TYXCH'`` and type specified
1860
+ in ``dtype``:
1861
+
1862
+ - ``coords['H']``: times of histogram bins in ns.
1863
+ - ``attrs['frequency']``: repetition frequency in MHz.
1864
+ - ``attrs['ptu_tags']``: metadata read from PTU file.
1865
+
1866
+ Size-one dimensions are prepended to point mode data to make them
1867
+ broadcastable to image data.
1868
+
1869
+ Raises
1870
+ ------
1871
+ ptufile.PqFileError
1872
+ File is not a PicoQuant PTU file or is corrupted.
1873
+ ValueError
1874
+ File is not a PicoQuant PTU T3 mode file containing TCSPC data.
1875
+
1876
+ Examples
1877
+ --------
1878
+ >>> signal = signal_from_ptu(fetch('hazelnut_FLIM_single_image.ptu'))
1879
+ >>> signal.values
1880
+ array(...)
1881
+ >>> signal.dtype
1882
+ dtype('uint16')
1883
+ >>> signal.shape
1884
+ (5, 256, 256, 1, 132)
1885
+ >>> signal.dims
1886
+ ('T', 'Y', 'X', 'C', 'H')
1887
+ >>> signal.coords['H'].data
1888
+ array([0, ..., 12.7])
1889
+ >>> signal.attrs['frequency'] # doctest: +NUMBER
1890
+ 78.02
1891
+
1892
+ """
1893
+ import ptufile
1894
+ from xarray import DataArray
1895
+
1896
+ with ptufile.PtuFile(filename, trimdims=trimdims) as ptu:
1897
+ if not ptu.is_t3:
1898
+ raise ValueError(f'{ptu.filename!r} is not a T3 mode PTU file')
1899
+ if ptu.is_image:
1900
+ data = ptu.decode_image(
1901
+ selection,
1902
+ dtype=dtype,
1903
+ frame=frame,
1904
+ channel=channel,
1905
+ dtime=dtime,
1906
+ keepdims=keepdims,
1907
+ asxarray=True,
1908
+ )
1909
+ assert isinstance(data, DataArray)
1910
+ elif ptu.measurement_submode == 1:
1911
+ # point mode IRF
1912
+ if dtime == -1:
1913
+ raise ValueError(f'{dtime=} not supported for point mode')
1914
+ data = ptu.decode_histogram(
1915
+ dtype=dtype, dtime=dtime, asxarray=True
1916
+ )
1917
+ assert isinstance(data, DataArray)
1918
+ if channel is not None:
1919
+ if keepdims:
1920
+ data = data[channel : channel + 1]
1921
+ else:
1922
+ data = data[channel]
1923
+ # prepend dimensions as needed to appear image-like
1924
+ data = data.expand_dims(dim={'Y': 1, 'X': 1})
1925
+ if keepdims:
1926
+ data = data.expand_dims(dim={'T': 1})
1927
+ else:
1928
+ raise ValueError(
1929
+ f'{ptu.filename!r} is not a point or image mode PTU file'
1930
+ )
1931
+
1932
+ data.attrs['ptu_tags'] = ptu.tags
1933
+ data.attrs['frequency'] = ptu.frequency * 1e-6 # MHz
1934
+ data.coords['H'] = data.coords['H'] * 1e9
1935
+
1936
+ return data
1937
+
1938
+
1939
+ def signal_from_flif(
1940
+ filename: str | PathLike[Any],
1941
+ /,
1942
+ ) -> DataArray:
1943
+ """Return frequency-domain image and metadata from FlimFast FLIF file.
1944
+
1945
+ FlimFast FLIF files contain camera images and metadata from
1946
+ frequency-domain fluorescence lifetime measurements.
1947
+
1948
+ Parameters
1949
+ ----------
1950
+ filename : str or Path
1951
+ Name of FlimFast FLIF file to read.
1952
+
1953
+ Returns
1954
+ -------
1955
+ xarray.DataArray
1956
+ Frequency-domain phase images with :ref:`axes codes <axes>` ``'THYX'``
1957
+ and type ``uint16``:
1958
+
1959
+ - ``coords['H']``: phases in radians.
1960
+ - ``attrs['frequency']``: repetition frequency in MHz.
1961
+ - ``attrs['ref_phase']``: measured phase of reference.
1962
+ - ``attrs['ref_mod']``: measured modulation of reference.
1963
+ - ``attrs['ref_tauphase']``: lifetime from phase of reference in ns.
1964
+ - ``attrs['ref_taumod']``: lifetime from modulation of reference in ns.
1965
+
1966
+ Raises
1967
+ ------
1968
+ lfdfiles.LfdFileError
1969
+ File is not a FlimFast FLIF file.
1970
+
1971
+ Examples
1972
+ --------
1973
+ >>> signal = signal_from_flif(fetch('flimfast.flif'))
1974
+ >>> signal.values
1975
+ array(...)
1976
+ >>> signal.dtype
1977
+ dtype('uint16')
1978
+ >>> signal.shape
1979
+ (32, 220, 300)
1980
+ >>> signal.dims
1981
+ ('H', 'Y', 'X')
1982
+ >>> signal.coords['H'].data
1983
+ array([0, ..., 6.087], dtype=float32)
1984
+ >>> signal.attrs['frequency'] # doctest: +NUMBER
1985
+ 80.65
1986
+
1987
+ """
1988
+ import lfdfiles
1989
+
1990
+ with lfdfiles.FlimfastFlif(filename) as flif:
1991
+ nphases = int(flif.header.phases)
1992
+ data = flif.asarray()
1993
+ if data.shape[0] < nphases:
1994
+ raise ValueError(f'measured phases {data.shape[0]} < {nphases=}')
1995
+ if data.shape[0] % nphases != 0:
1996
+ data = data[: (data.shape[0] // nphases) * nphases]
1997
+ data = data.reshape(-1, nphases, data.shape[1], data.shape[2])
1998
+ if data.shape[0] == 1:
1999
+ data = data[0]
2000
+ axes = 'HYX'
2001
+ else:
2002
+ axes = 'THYX'
2003
+ # TODO: check if phases are ordered
2004
+ phases = numpy.radians(flif.records['phase'][:nphases])
2005
+ metadata = _metadata(axes, data.shape, H=phases)
2006
+ attrs = metadata['attrs']
2007
+ attrs['frequency'] = float(flif.header.frequency)
2008
+ attrs['ref_phase'] = float(flif.header.measured_phase)
2009
+ attrs['ref_mod'] = float(flif.header.measured_mod)
2010
+ attrs['ref_tauphase'] = float(flif.header.ref_tauphase)
2011
+ attrs['ref_taumod'] = float(flif.header.ref_taumod)
2012
+
2013
+ from xarray import DataArray
2014
+
2015
+ return DataArray(data, **metadata)
2016
+
2017
+
2018
+ def signal_from_fbd(
2019
+ filename: str | PathLike[Any],
2020
+ /,
2021
+ *,
2022
+ frame: int | None = None,
2023
+ channel: int | None = None,
2024
+ keepdims: bool = True,
2025
+ laser_factor: float = -1.0,
2026
+ ) -> DataArray:
2027
+ """Return frequency-domain image and metadata from FLIMbox FBD file.
2028
+
2029
+ FDB files contain encoded data from the FLIMbox device, which can be
2030
+ decoded to photon arrival windows, channels, and global times.
2031
+ The encoding scheme depends on the FLIMbox device's firmware.
2032
+ The FBD file format is undocumented.
2033
+
2034
+ This function may fail to produce expected results when files use unknown
2035
+ firmware, do not contain image scans, settings were recorded incorrectly,
2036
+ scanner and FLIMbox frequencies were out of sync, or scanner settings were
2037
+ changed during acquisition.
2038
+
2039
+ Parameters
2040
+ ----------
2041
+ filename : str or Path
2042
+ Name of FLIMbox FBD file to read.
2043
+ frame : int, optional
2044
+ If None (default), return all frames.
2045
+ If < 0, integrate time axis, else return specified frame.
2046
+ channel : int, optional
2047
+ If None (default), return all channels, else return specified channel.
2048
+ keepdims : bool, optional
2049
+ If true (default), reduced axes are left as size-one dimension.
2050
+ laser_factor : float, optional
2051
+ Factor to correct dwell_time/laser_frequency.
2052
+
2053
+ Returns
2054
+ -------
2055
+ xarray.DataArray
2056
+ Frequency-domain image histogram with :ref:`axes codes <axes>`
2057
+ ``'TCYXH'`` and type ``uint16``:
2058
+
2059
+ - ``coords['H']``: phases in radians.
2060
+ - ``attrs['frequency']``: repetition frequency in MHz.
2061
+
2062
+ Raises
2063
+ ------
2064
+ lfdfiles.LfdFileError
2065
+ File is not a FLIMbox FBD file.
2066
+
2067
+ Examples
2068
+ --------
2069
+ >>> signal = signal_from_fbd(
2070
+ ... fetch('convallaria_000$EI0S.fbd')
2071
+ ... ) # doctest: +SKIP
2072
+ >>> signal.values # doctest: +SKIP
2073
+ array(...)
2074
+ >>> signal.dtype # doctest: +SKIP
2075
+ dtype('uint16')
2076
+ >>> signal.shape # doctest: +SKIP
2077
+ (9, 2, 256, 256, 64)
2078
+ >>> signal.dims # doctest: +SKIP
2079
+ ('T', 'C', 'Y', 'X', 'H')
2080
+ >>> signal.coords['H'].data # doctest: +SKIP
2081
+ array([0, ..., 6.185])
2082
+ >>> signal.attrs['frequency'] # doctest: +SKIP
2083
+ 40.0
2084
+
2085
+ """
2086
+ import lfdfiles
2087
+
2088
+ integrate_frames = 0 if frame is None or frame >= 0 else 1
2089
+
2090
+ with lfdfiles.FlimboxFbd(filename, laser_factor=laser_factor) as fbd:
2091
+ data = fbd.asimage(None, None, integrate_frames=integrate_frames)
2092
+ if integrate_frames:
2093
+ frame = None
2094
+ copy = False
2095
+ axes = 'TCYXH'
2096
+ if channel is None:
2097
+ if not keepdims and data.shape[1] == 1:
2098
+ data = data[:, 0]
2099
+ axes = 'TYXH'
2100
+ else:
2101
+ if channel < 0 or channel >= data.shape[1]:
2102
+ raise IndexError(f'{channel=} out of bounds')
2103
+ if keepdims:
2104
+ data = data[:, channel : channel + 1]
2105
+ else:
2106
+ data = data[:, channel]
2107
+ axes = 'TYXH'
2108
+ copy = True
2109
+ if frame is None:
2110
+ if not keepdims and data.shape[0] == 1:
2111
+ data = data[0]
2112
+ axes = axes[1:]
2113
+ else:
2114
+ if frame < 0 or frame > data.shape[0]:
2115
+ raise IndexError(f'{frame=} out of bounds')
2116
+ if keepdims:
2117
+ data = data[frame : frame + 1]
2118
+ else:
2119
+ data = data[frame]
2120
+ axes = axes[1:]
2121
+ copy = True
2122
+ if copy:
2123
+ data = data.copy()
2124
+ # TODO: return arrival window indices or micro-times as H coords?
2125
+ phases = numpy.linspace(
2126
+ 0.0, numpy.pi * 2, data.shape[-1], endpoint=False
2127
+ )
2128
+ metadata = _metadata(axes, data.shape, H=phases)
2129
+ attrs = metadata['attrs']
2130
+ attrs['frequency'] = fbd.laser_frequency * 1e-6
2131
+
2132
+ from xarray import DataArray
2133
+
2134
+ return DataArray(data, **metadata)
2135
+
2136
+
2137
+ def signal_from_b64(
2138
+ filename: str | PathLike[Any],
2139
+ /,
2140
+ ) -> DataArray:
2141
+ """Return intensity image and metadata from SimFCS B64 file.
2142
+
2143
+ B64 files contain one or more square intensity image(s), a carpet
2144
+ of lines, or a stream of intensity data. B64 files contain no metadata.
2145
+
2146
+ Parameters
2147
+ ----------
2148
+ filename : str or Path
2149
+ Name of SimFCS B64 file to read.
2150
+
2151
+ Returns
2152
+ -------
2153
+ xarray.DataArray
2154
+ Stack of square-sized intensity images of type ``int16``.
2155
+
2156
+ Raises
2157
+ ------
2158
+ lfdfiles.LfdFileError
2159
+ File is not a SimFCS B64 file.
2160
+ ValueError
2161
+ File does not contain an image stack.
2162
+
2163
+ Examples
2164
+ --------
2165
+ >>> signal = signal_from_b64(fetch('simfcs.b64'))
2166
+ >>> signal.values
2167
+ array(...)
2168
+ >>> signal.dtype
2169
+ dtype('int16')
2170
+ >>> signal.shape
2171
+ (22, 1024, 1024)
2172
+ >>> signal.dtype
2173
+ dtype('int16')
2174
+ >>> signal.dims
2175
+ ('I', 'Y', 'X')
2176
+
2177
+ """
2178
+ import lfdfiles
2179
+
2180
+ with lfdfiles.SimfcsB64(filename) as b64:
2181
+ data = b64.asarray()
2182
+ if data.ndim != 3:
2183
+ raise ValueError(
2184
+ f'{os.path.basename(filename)!r} '
2185
+ 'does not contain an image stack'
2186
+ )
2187
+ metadata = _metadata(b64.axes, data.shape, filename)
2188
+
2189
+ from xarray import DataArray
2190
+
2191
+ return DataArray(data, **metadata)
2192
+
2193
+
2194
+ def signal_from_z64(
2195
+ filename: str | PathLike[Any],
2196
+ /,
2197
+ ) -> DataArray:
2198
+ """Return image and metadata from SimFCS Z64 file.
2199
+
2200
+ Z64 files contain stacks of square images such as intensity volumes
2201
+ or time-domain fluorescence lifetime histograms acquired from
2202
+ Becker & Hickl(r) TCSPC cards. Z64 files contain no metadata.
2203
+
2204
+ Parameters
2205
+ ----------
2206
+ filename : str or Path
2207
+ Name of SimFCS Z64 file to read.
2208
+
2209
+ Returns
2210
+ -------
2211
+ xarray.DataArray
2212
+ Single or stack of square-sized images of type ``float32``.
2213
+
2214
+ Raises
2215
+ ------
2216
+ lfdfiles.LfdFileError
2217
+ File is not a SimFCS Z64 file.
2218
+
2219
+ Examples
2220
+ --------
2221
+ >>> signal = signal_from_z64(fetch('simfcs.z64'))
2222
+ >>> signal.values
2223
+ array(...)
2224
+ >>> signal.dtype
2225
+ dtype('float32')
2226
+ >>> signal.shape
2227
+ (256, 256, 256)
2228
+ >>> signal.dims
2229
+ ('Q', 'Y', 'X')
2230
+
2231
+ """
2232
+ import lfdfiles
2233
+
2234
+ with lfdfiles.SimfcsZ64(filename) as z64:
2235
+ data = z64.asarray()
2236
+ metadata = _metadata(z64.axes, data.shape, filename)
2237
+
2238
+ from xarray import DataArray
2239
+
2240
+ return DataArray(data, **metadata)
2241
+
2242
+
2243
+ def signal_from_bh(
2244
+ filename: str | PathLike[Any],
2245
+ /,
2246
+ ) -> DataArray:
2247
+ """Return image and metadata from SimFCS B&H file.
2248
+
2249
+ B&H files contain time-domain fluorescence lifetime histogram data,
2250
+ acquired from Becker & Hickl(r) TCSPC cards, or converted from other
2251
+ data sources. B&H files contain no metadata.
2252
+
2253
+ Parameters
2254
+ ----------
2255
+ filename : str or Path
2256
+ Name of SimFCS B&H file to read.
2257
+
2258
+ Returns
2259
+ -------
2260
+ xarray.DataArray
2261
+ Time-domain fluorescence lifetime histogram with axes ``'HYX'``,
2262
+ shape ``(256, 256, 256)``, and type ``float32``.
2263
+
2264
+ Raises
2265
+ ------
2266
+ lfdfiles.LfdFileError
2267
+ File is not a SimFCS B&H file.
2268
+
2269
+ Examples
2270
+ --------
2271
+ >>> signal = signal_from_bh(fetch('simfcs.b&h'))
2272
+ >>> signal.values
2273
+ array(...)
2274
+ >>> signal.dtype
2275
+ dtype('float32')
2276
+ >>> signal.shape
2277
+ (256, 256, 256)
2278
+ >>> signal.dims
2279
+ ('H', 'Y', 'X')
2280
+
2281
+ """
2282
+ import lfdfiles
2283
+
2284
+ with lfdfiles.SimfcsBh(filename) as bnh:
2285
+ assert bnh.axes is not None
2286
+ data = bnh.asarray()
2287
+ metadata = _metadata(bnh.axes.replace('Q', 'H'), data.shape, filename)
2288
+
2289
+ from xarray import DataArray
2290
+
2291
+ return DataArray(data, **metadata)
2292
+
2293
+
2294
+ def signal_from_bhz(
2295
+ filename: str | PathLike[Any],
2296
+ /,
2297
+ ) -> DataArray:
2298
+ """Return image and metadata from SimFCS BHZ file.
2299
+
2300
+ BHZ files contain time-domain fluorescence lifetime histogram data,
2301
+ acquired from Becker & Hickl(r) TCSPC cards, or converted from other
2302
+ data sources. BHZ files contain no metadata.
2303
+
2304
+ Parameters
2305
+ ----------
2306
+ filename : str or Path
2307
+ Name of SimFCS BHZ file to read.
2308
+
2309
+ Returns
2310
+ -------
2311
+ xarray.DataArray
2312
+ Time-domain fluorescence lifetime histogram with axes ``'HYX'``,
2313
+ shape ``(256, 256, 256)``, and type ``float32``.
2314
+
2315
+ Raises
2316
+ ------
2317
+ lfdfiles.LfdFileError
2318
+ File is not a SimFCS BHZ file.
2319
+
2320
+ Examples
2321
+ --------
2322
+ >>> signal = signal_from_bhz(fetch('simfcs.bhz'))
2323
+ >>> signal.values
2324
+ array(...)
2325
+ >>> signal.dtype
2326
+ dtype('float32')
2327
+ >>> signal.shape
2328
+ (256, 256, 256)
2329
+ >>> signal.dims
2330
+ ('H', 'Y', 'X')
2331
+
2332
+ """
2333
+ import lfdfiles
2334
+
2335
+ with lfdfiles.SimfcsBhz(filename) as bhz:
2336
+ assert bhz.axes is not None
2337
+ data = bhz.asarray()
2338
+ metadata = _metadata(bhz.axes.replace('Q', 'H'), data.shape, filename)
2339
+
2340
+ from xarray import DataArray
2341
+
2342
+ return DataArray(data, **metadata)
2343
+
2344
+
2345
+ def _metadata(
2346
+ dims: Sequence[str] | None,
2347
+ shape: tuple[int, ...],
2348
+ /,
2349
+ name: str | PathLike[Any] | None = None,
2350
+ attrs: dict[str, Any] | None = None,
2351
+ **coords: Any,
2352
+ ) -> dict[str, Any]:
2353
+ """Return xarray-style dims, coords, and attrs in a dict.
2354
+
2355
+ >>> _metadata('SYX', (3, 2, 1), S=['0', '1', '2'])
2356
+ {'dims': ('S', 'Y', 'X'), 'coords': {'S': ['0', '1', '2']}, 'attrs': {}}
2357
+
2358
+ """
2359
+ assert dims is not None
2360
+ dims = tuple(dims)
2361
+ if len(dims) != len(shape):
2362
+ raise ValueError(
2363
+ f'dims do not match shape {len(dims)} != {len(shape)}'
2364
+ )
2365
+ coords = {dim: coords[dim] for dim in dims if dim in coords}
2366
+ if attrs is None:
2367
+ attrs = {}
2368
+ metadata = {'dims': dims, 'coords': coords, 'attrs': attrs}
2369
+ if name:
2370
+ metadata['name'] = os.path.basename(name)
2371
+ return metadata
2372
+
2373
+
2374
+ def _squeeze_dims(
2375
+ shape: Sequence[int],
2376
+ dims: Sequence[str],
2377
+ /,
2378
+ skip: Container[str] = 'XY',
2379
+ ) -> tuple[tuple[int, ...], tuple[str, ...], tuple[bool, ...]]:
2380
+ """Return shape and axes with length-1 dimensions removed.
2381
+
2382
+ Remove unused dimensions unless their axes are listed in `skip`.
2383
+
2384
+ Adapted from the tifffile library.
2385
+
2386
+ Parameters
2387
+ ----------
2388
+ shape : tuple of ints
2389
+ Sequence of dimension sizes.
2390
+ dims : sequence of str
2391
+ Character codes for dimensions in `shape`.
2392
+ skip : container of str, optional
2393
+ Character codes for dimensions whose length-1 dimensions are
2394
+ not removed. The default is 'XY'.
2395
+
2396
+ Returns
2397
+ -------
2398
+ shape : tuple of ints
2399
+ Sequence of dimension sizes with length-1 dimensions removed.
2400
+ dims : tuple of str
2401
+ Character codes for dimensions in output `shape`.
2402
+ squeezed : str
2403
+ Dimensions were kept (True) or removed (False).
2404
+
2405
+ Examples
2406
+ --------
2407
+ >>> _squeeze_dims((5, 1, 2, 1, 1), 'TZYXC')
2408
+ ((5, 2, 1), ('T', 'Y', 'X'), (True, False, True, True, False))
2409
+ >>> _squeeze_dims((1,), ('Q',))
2410
+ ((1,), ('Q',), (True,))
2411
+
2412
+ """
2413
+ if len(shape) != len(dims):
2414
+ raise ValueError(f'{len(shape)=} != {len(dims)=}')
2415
+ if not dims:
2416
+ return tuple(shape), tuple(dims), ()
2417
+ squeezed: list[bool] = []
2418
+ shape_squeezed: list[int] = []
2419
+ dims_squeezed: list[str] = []
2420
+ for size, ax in zip(shape, dims):
2421
+ if size > 1 or ax in skip:
2422
+ squeezed.append(True)
2423
+ shape_squeezed.append(size)
2424
+ dims_squeezed.append(ax)
2425
+ else:
2426
+ squeezed.append(False)
2427
+ if len(shape_squeezed) == 0:
2428
+ squeezed[-1] = True
2429
+ shape_squeezed.append(shape[-1])
2430
+ dims_squeezed.append(dims[-1])
2431
+ return tuple(shape_squeezed), tuple(dims_squeezed), tuple(squeezed)