phasorpy 0.4__cp311-cp311-win_amd64.whl → 0.6__cp311-cp311-win_amd64.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/_other.py ADDED
@@ -0,0 +1,782 @@
1
+ """Read other file formats."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ # 'signal_from_czi',
7
+ 'signal_from_flif',
8
+ 'phasor_from_ifli',
9
+ 'signal_from_imspector_tiff',
10
+ 'signal_from_lsm',
11
+ 'signal_from_ptu',
12
+ 'signal_from_sdt',
13
+ ]
14
+
15
+ import os
16
+ from typing import TYPE_CHECKING
17
+ from xml.etree import ElementTree
18
+
19
+ from .._utils import parse_harmonic, squeeze_dims, xarray_metadata
20
+
21
+ if TYPE_CHECKING:
22
+ from .._typing import (
23
+ Any,
24
+ DataArray,
25
+ DTypeLike,
26
+ Literal,
27
+ NDArray,
28
+ PathLike,
29
+ Sequence,
30
+ EllipsisType,
31
+ )
32
+
33
+ import numpy
34
+
35
+
36
+ def signal_from_sdt(
37
+ filename: str | PathLike[Any],
38
+ /,
39
+ *,
40
+ index: int = 0,
41
+ ) -> DataArray:
42
+ """Return TCSPC histogram and metadata from Becker & Hickl SDT file.
43
+
44
+ SDT files contain TCSPC measurement data and instrumentation parameters.
45
+
46
+ Parameters
47
+ ----------
48
+ filename : str or Path
49
+ Name of Becker & Hickl SDT file to read.
50
+ index : int, optional, default: 0
51
+ Index of dataset to read in case the file contains multiple datasets.
52
+
53
+ Returns
54
+ -------
55
+ xarray.DataArray
56
+ TCSPC histogram with :ref:`axes codes <axes>` ``'QCYXH'`` and
57
+ type ``uint16``, ``uint32``, or ``float32``.
58
+ Dimensions ``'Q'`` and ``'C'`` are optional detector channels.
59
+
60
+ - ``coords['H']``: delay-times of histogram bins in ns.
61
+ - ``attrs['frequency']``: repetition frequency in MHz.
62
+
63
+ Raises
64
+ ------
65
+ ValueError
66
+ File is not a SDT file containing TCSPC histogram.
67
+
68
+ Notes
69
+ -----
70
+ The implementation is based on the
71
+ `sdtfile <https://github.com/cgohlke/sdtfile/>`__ library.
72
+
73
+ Examples
74
+ --------
75
+ >>> signal = signal_from_sdt(fetch('tcspc.sdt'))
76
+ >>> signal.values
77
+ array(...)
78
+ >>> signal.dtype
79
+ dtype('uint16')
80
+ >>> signal.shape
81
+ (128, 128, 256)
82
+ >>> signal.dims
83
+ ('Y', 'X', 'H')
84
+ >>> signal.coords['H'].data
85
+ array([0, ..., 12.45])
86
+ >>> signal.attrs['frequency'] # doctest: +NUMBER
87
+ 79.99
88
+
89
+ """
90
+ import sdtfile
91
+
92
+ with sdtfile.SdtFile(filename) as sdt:
93
+ if (
94
+ 'SPC Setup & Data File' not in sdt.info.id
95
+ and 'SPC FCS Data File' not in sdt.info.id
96
+ ):
97
+ # skip DLL data
98
+ raise ValueError(
99
+ f'{os.path.basename(filename)!r} '
100
+ 'is not an SDT file containing TCSPC data'
101
+ )
102
+ # filter block types?
103
+ # sdtfile.BlockType(sdt.block_headers[index].block_type).contents
104
+ # == 'PAGE_BLOCK'
105
+ data = sdt.data[index]
106
+ times = sdt.times[index] * 1e9
107
+
108
+ # TODO: get spatial coordinates from scanner settings?
109
+ metadata = xarray_metadata(
110
+ 'QCYXH'[-data.ndim :], data.shape, filename, H=times
111
+ )
112
+ metadata['attrs']['frequency'] = 1e3 / float(times[-1] + times[1])
113
+
114
+ from xarray import DataArray
115
+
116
+ return DataArray(data, **metadata)
117
+
118
+
119
+ def signal_from_ptu(
120
+ filename: str | PathLike[Any],
121
+ /,
122
+ selection: Sequence[int | slice | EllipsisType | None] | None = None,
123
+ *,
124
+ trimdims: Sequence[Literal['T', 'C', 'H']] | str | None = None,
125
+ dtype: DTypeLike | None = None,
126
+ frame: int | None = None,
127
+ channel: int | None = 0,
128
+ dtime: int | None = 0,
129
+ keepdims: bool = False,
130
+ ) -> DataArray:
131
+ """Return TCSPC histogram and metadata from PicoQuant PTU T3 mode file.
132
+
133
+ PTU files contain TCSPC measurement data and instrumentation parameters,
134
+ which are decoded to a multi-dimensional TCSPC histogram.
135
+
136
+ Parameters
137
+ ----------
138
+ filename : str or Path
139
+ Name of PTU file to read.
140
+ selection : sequence of index types, optional
141
+ Indices for all dimensions of image mode files:
142
+
143
+ - ``None``: return all items along axis (default).
144
+ - ``Ellipsis``: return all items along multiple axes.
145
+ - ``int``: return single item along axis.
146
+ - ``slice``: return chunk of axis.
147
+ ``slice.step`` is a binning factor.
148
+ If ``slice.step=-1``, integrate all items along axis.
149
+
150
+ trimdims : str, optional, default: 'TCH'
151
+ Axes to trim.
152
+ dtype : dtype-like, optional, default: uint16
153
+ Unsigned integer type of TCSPC histogram.
154
+ Increase the bit depth to avoid overflows when integrating.
155
+ frame : int, optional
156
+ If < 0, integrate time axis, else return specified frame.
157
+ Overrides `selection` for axis ``T``.
158
+ channel : int, optional
159
+ Index of channel to return.
160
+ By default, return the first channel.
161
+ If < 0, integrate channel axis.
162
+ Overrides `selection` for axis ``C``.
163
+ dtime : int, optional, default: 0
164
+ Specifies number of bins in TCSPC histogram.
165
+ If 0 (default), return the number of bins in one period.
166
+ If < 0, integrate delay-time axis (image mode only).
167
+ If > 0, return up to specified bin.
168
+ Overrides `selection` for axis ``H``.
169
+ keepdims : bool, optional, default: False
170
+ If true, return reduced axes as size-one dimensions.
171
+
172
+ Returns
173
+ -------
174
+ xarray.DataArray
175
+ TCSPC histogram with :ref:`axes codes <axes>` ``'TYXCH'`` and
176
+ type specified in ``dtype``:
177
+
178
+ - ``coords['H']``: delay-times of histogram bins in ns.
179
+ - ``attrs['frequency']``: repetition frequency in MHz.
180
+ - ``attrs['ptu_tags']``: metadata read from PTU file.
181
+
182
+ Size-one dimensions are prepended to point mode data to make them
183
+ broadcastable to image data.
184
+
185
+ Raises
186
+ ------
187
+ ptufile.PqFileError
188
+ File is not a PicoQuant PTU file or is corrupted.
189
+ ValueError
190
+ File is not a PicoQuant PTU T3 mode file containing TCSPC data.
191
+
192
+ Notes
193
+ -----
194
+ The implementation is based on the
195
+ `ptufile <https://github.com/cgohlke/ptufile/>`__ library.
196
+
197
+ Examples
198
+ --------
199
+ >>> signal = signal_from_ptu(fetch('hazelnut_FLIM_single_image.ptu'))
200
+ >>> signal.values
201
+ array(...)
202
+ >>> signal.dtype
203
+ dtype('uint16')
204
+ >>> signal.shape
205
+ (5, 256, 256, 132)
206
+ >>> signal.dims
207
+ ('T', 'Y', 'X', 'H')
208
+ >>> signal.coords['H'].data
209
+ array([0, ..., 12.7])
210
+ >>> signal.attrs['frequency'] # doctest: +NUMBER
211
+ 78.02
212
+
213
+ """
214
+ import ptufile
215
+ from xarray import DataArray
216
+
217
+ with ptufile.PtuFile(filename, trimdims=trimdims) as ptu:
218
+ if not ptu.is_t3:
219
+ raise ValueError(f'{ptu.filename!r} is not a T3 mode PTU file')
220
+ if ptu.is_image:
221
+ data = ptu.decode_image(
222
+ selection,
223
+ dtype=dtype,
224
+ frame=frame,
225
+ channel=channel,
226
+ dtime=dtime,
227
+ keepdims=keepdims,
228
+ asxarray=True,
229
+ )
230
+ assert isinstance(data, DataArray)
231
+ elif ptu.measurement_submode == 1:
232
+ # point mode IRF
233
+ if dtime == -1:
234
+ raise ValueError(f'{dtime=} not supported for point mode')
235
+ data = ptu.decode_histogram(
236
+ dtype=dtype, dtime=dtime, asxarray=True
237
+ )
238
+ assert isinstance(data, DataArray)
239
+ if channel is not None:
240
+ if keepdims:
241
+ data = data[channel : channel + 1]
242
+ else:
243
+ data = data[channel]
244
+ # prepend dimensions as needed to appear image-like
245
+ data = data.expand_dims(dim={'Y': 1, 'X': 1})
246
+ if keepdims:
247
+ data = data.expand_dims(dim={'T': 1})
248
+ else:
249
+ raise ValueError(
250
+ f'{ptu.filename!r} is not a point or image mode PTU file'
251
+ )
252
+
253
+ data.attrs['ptu_tags'] = ptu.tags
254
+ data.attrs['frequency'] = ptu.frequency * 1e-6 # MHz
255
+ data.coords['H'] = data.coords['H'] * 1e9
256
+
257
+ return data
258
+
259
+
260
+ def signal_from_lsm(
261
+ filename: str | PathLike[Any],
262
+ /,
263
+ ) -> DataArray:
264
+ """Return hyperspectral image and metadata from Zeiss LSM file.
265
+
266
+ LSM files contain multi-dimensional images and metadata from laser
267
+ scanning microscopy measurements. The file format is based on TIFF.
268
+
269
+ Parameters
270
+ ----------
271
+ filename : str or Path
272
+ Name of Zeiss LSM file to read.
273
+
274
+ Returns
275
+ -------
276
+ xarray.DataArray
277
+ Hyperspectral image data.
278
+ Usually, a 3-to-5-dimensional array of type ``uint8`` or ``uint16``.
279
+
280
+ - ``coords['C']``: wavelengths in nm.
281
+ - ``coords['T']``: time coordinates in s, if any.
282
+
283
+ Raises
284
+ ------
285
+ tifffile.TiffFileError
286
+ File is not a TIFF file.
287
+ ValueError
288
+ File is not an LSM file or does not contain hyperspectral image.
289
+
290
+ Notes
291
+ -----
292
+ The implementation is based on the
293
+ `tifffile <https://github.com/cgohlke/tifffile/>`__ library.
294
+
295
+ Examples
296
+ --------
297
+ >>> signal = signal_from_lsm(fetch('paramecium.lsm'))
298
+ >>> signal.values
299
+ array(...)
300
+ >>> signal.dtype
301
+ dtype('uint8')
302
+ >>> signal.shape
303
+ (30, 512, 512)
304
+ >>> signal.dims
305
+ ('C', 'Y', 'X')
306
+ >>> signal.coords['C'].data # wavelengths
307
+ array([423, ..., 713])
308
+
309
+ """
310
+ import tifffile
311
+
312
+ with tifffile.TiffFile(filename) as tif:
313
+ if not tif.is_lsm:
314
+ raise ValueError(f'{tif.filename!r} is not an LSM file')
315
+
316
+ page = tif.pages.first
317
+ lsminfo = tif.lsm_metadata
318
+ channels = page.tags[258].count
319
+
320
+ if channels < 4 or lsminfo is None or lsminfo['SpectralScan'] != 1:
321
+ raise ValueError(
322
+ f'{tif.filename!r} does not contain hyperspectral image'
323
+ )
324
+
325
+ # TODO: contribute this to tifffile
326
+ series = tif.series[0]
327
+ data = series.asarray()
328
+ dims = tuple(series.axes)
329
+ coords = {}
330
+ # channel wavelengths
331
+ axis = dims.index('C')
332
+ wavelengths = lsminfo['ChannelWavelength'].mean(axis=1)
333
+ if wavelengths.size != data.shape[axis]:
334
+ raise ValueError(
335
+ f'{tif.filename!r} wavelengths do not match channel axis'
336
+ )
337
+ # stack may contain non-wavelength frame
338
+ indices = wavelengths > 0
339
+ wavelengths = wavelengths[indices]
340
+ if wavelengths.size < 3:
341
+ raise ValueError(
342
+ f'{tif.filename!r} does not contain hyperspectral image'
343
+ )
344
+ wavelengths *= 1e9
345
+ data = data.take(indices.nonzero()[0], axis=axis)
346
+ coords['C'] = wavelengths
347
+ # time stamps
348
+ if 'T' in dims:
349
+ coords['T'] = lsminfo['TimeStamps'] - lsminfo['TimeStamps'][0]
350
+ if coords['T'].size != data.shape[dims.index('T')]:
351
+ raise ValueError(
352
+ f'{tif.filename!r} timestamps do not match time axis'
353
+ )
354
+ # spatial coordinates
355
+ for ax in 'ZYX':
356
+ if ax in dims:
357
+ size = data.shape[dims.index(ax)]
358
+ coords[ax] = numpy.linspace(
359
+ lsminfo[f'Origin{ax}'],
360
+ size * lsminfo[f'VoxelSize{ax}'],
361
+ size,
362
+ endpoint=False,
363
+ dtype=numpy.float64,
364
+ )
365
+ metadata = xarray_metadata(series.axes, data.shape, filename, **coords)
366
+
367
+ from xarray import DataArray
368
+
369
+ return DataArray(data, **metadata)
370
+
371
+
372
+ def signal_from_imspector_tiff(
373
+ filename: str | PathLike[Any],
374
+ /,
375
+ ) -> DataArray:
376
+ """Return TCSPC histogram and metadata from ImSpector TIFF file.
377
+
378
+ Parameters
379
+ ----------
380
+ filename : str or Path
381
+ Name of ImSpector FLIM TIFF file to read.
382
+
383
+ Returns
384
+ -------
385
+ xarray.DataArray
386
+ TCSPC histogram with :ref:`axes codes <axes>` ``'HTZYX'`` and
387
+ type ``uint16``.
388
+
389
+ - ``coords['H']``: delay-times of histogram bins in ns.
390
+ - ``attrs['frequency']``: repetition frequency in MHz.
391
+
392
+ Raises
393
+ ------
394
+ tifffile.TiffFileError
395
+ File is not a TIFF file.
396
+ ValueError
397
+ File is not an ImSpector FLIM TIFF file.
398
+
399
+ Notes
400
+ -----
401
+ The implementation is based on the
402
+ `tifffile <https://github.com/cgohlke/tifffile/>`__ library.
403
+
404
+ Examples
405
+ --------
406
+ >>> signal = signal_from_imspector_tiff(fetch('Embryo.tif'))
407
+ >>> signal.values
408
+ array(...)
409
+ >>> signal.dtype
410
+ dtype('uint16')
411
+ >>> signal.shape
412
+ (56, 512, 512)
413
+ >>> signal.dims
414
+ ('H', 'Y', 'X')
415
+ >>> signal.coords['H'].data # dtime bins
416
+ array([0, ..., 12.26])
417
+ >>> signal.attrs['frequency'] # doctest: +NUMBER
418
+ 80.109
419
+
420
+ """
421
+ import tifffile
422
+
423
+ with tifffile.TiffFile(filename) as tif:
424
+ tags = tif.pages.first.tags
425
+ omexml = tags.valueof(270, '')
426
+ make = tags.valueof(271, '')
427
+
428
+ if (
429
+ make != 'ImSpector'
430
+ or not omexml.startswith('<?xml version')
431
+ or len(tif.series) != 1
432
+ or not tif.is_ome
433
+ ):
434
+ raise ValueError(f'{tif.filename!r} is not an ImSpector TIFF file')
435
+
436
+ series = tif.series[0]
437
+ ndim = series.ndim
438
+ axes = series.axes
439
+ shape = series.shape
440
+
441
+ if ndim < 3 or not axes.endswith('YX'):
442
+ raise ValueError(
443
+ f'{tif.filename!r} is not an ImSpector FLIM TIFF file'
444
+ )
445
+
446
+ data = series.asarray()
447
+
448
+ attrs: dict[str, Any] = {}
449
+ coords = {}
450
+ physical_size = {}
451
+
452
+ root = ElementTree.fromstring(omexml)
453
+ ns = {
454
+ '': 'http://www.openmicroscopy.org/Schemas/OME/2008-02',
455
+ 'ca': 'http://www.openmicroscopy.org/Schemas/CA/2008-02',
456
+ }
457
+
458
+ description = root.find('.//Description', ns)
459
+ if (
460
+ description is not None
461
+ and description.text
462
+ and description.text != 'not_specified'
463
+ ):
464
+ attrs['description'] = description.text
465
+
466
+ pixels = root.find('.//Image/Pixels', ns)
467
+ assert pixels is not None
468
+ for ax in 'TZYX':
469
+ attrib = 'TimeIncrement' if ax == 'T' else f'PhysicalSize{ax}'
470
+ if ax not in axes or attrib not in pixels.attrib:
471
+ continue
472
+ size = float(pixels.attrib[attrib]) * shape[axes.index(ax)]
473
+ physical_size[ax] = size
474
+ coords[ax] = numpy.linspace(
475
+ 0.0,
476
+ size,
477
+ shape[axes.index(ax)],
478
+ endpoint=False,
479
+ dtype=numpy.float64,
480
+ )
481
+
482
+ axes_labels = root.find('.//Image/ca:CustomAttributes/AxesLabels', ns)
483
+ if (
484
+ axes_labels is None
485
+ or 'X' not in axes_labels.attrib
486
+ or 'TCSPC' not in axes_labels.attrib['X']
487
+ or 'FirstAxis' not in axes_labels.attrib
488
+ or 'SecondAxis' not in axes_labels.attrib
489
+ ):
490
+ raise ValueError(
491
+ f'{tif.filename!r} is not an ImSpector FLIM TIFF file'
492
+ )
493
+
494
+ if axes_labels.attrib['FirstAxis'] == 'lifetime' or axes_labels.attrib[
495
+ 'FirstAxis'
496
+ ].endswith('TCSPC T'):
497
+ ax = axes[-3]
498
+ assert axes_labels.attrib['FirstAxis-Unit'] == 'ns'
499
+ elif ndim > 3 and (
500
+ axes_labels.attrib['SecondAxis'] == 'lifetime'
501
+ or axes_labels.attrib['SecondAxis'].endswith('TCSPC T')
502
+ ):
503
+ ax = axes[-4]
504
+ assert axes_labels.attrib['SecondAxis-Unit'] == 'ns'
505
+ else:
506
+ raise ValueError(
507
+ f'{tif.filename!r} is not an ImSpector FLIM TIFF file'
508
+ )
509
+ axes = axes.replace(ax, 'H')
510
+ coords['H'] = coords[ax]
511
+ del coords[ax]
512
+
513
+ attrs['frequency'] = float(1000.0 / physical_size[ax])
514
+
515
+ metadata = xarray_metadata(axes, shape, filename, attrs=attrs, **coords)
516
+
517
+ from xarray import DataArray
518
+
519
+ return DataArray(data, **metadata)
520
+
521
+
522
+ def phasor_from_ifli(
523
+ filename: str | PathLike[Any],
524
+ /,
525
+ *,
526
+ channel: int | None = 0,
527
+ harmonic: int | Sequence[int] | Literal['all', 'any'] | str | None = None,
528
+ **kwargs: Any,
529
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any], dict[str, Any]]:
530
+ """Return phasor coordinates and metadata from ISS IFLI file.
531
+
532
+ ISS VistaVision IFLI files contain calibrated phasor coordinates for
533
+ possibly several positions, wavelengths, time points, channels, slices,
534
+ and frequencies from analog or digital frequency-domain fluorescence
535
+ lifetime measurements.
536
+
537
+ Parameters
538
+ ----------
539
+ filename : str or Path
540
+ Name of ISS IFLI file to read.
541
+ channel : int, optional
542
+ Index of channel to return.
543
+ By default, return the first channel.
544
+ If None, return all channels.
545
+ harmonic : int, sequence of int, or 'all', optional
546
+ Harmonic(s) to return from file.
547
+ If None (default), return the first harmonic stored in file.
548
+ If `'all'`, return all harmonics of first frequency stored in file.
549
+ If `'any'`, return all frequencies as stored in file, not necessarily
550
+ harmonics of the first frequency.
551
+ If a list, the first axes of the returned `real` and `imag` arrays
552
+ contain specified harmonic(s).
553
+ If an integer, the returned `real` and `imag` arrays are single
554
+ harmonic and have the same shape as `mean`.
555
+ **kwargs
556
+ Additional arguments passed to :py:meth:`lfdfiles.VistaIfli.asarray`,
557
+ for example ``memmap=True``.
558
+
559
+ Returns
560
+ -------
561
+ mean : ndarray
562
+ Average intensity image.
563
+ May have up to 7 dimensions in ``'RETCZYX'`` order.
564
+ real : ndarray
565
+ Image of real component of phasor coordinates.
566
+ Same shape as `mean`, except it may have a harmonic/frequency
567
+ dimension prepended.
568
+ imag : ndarray
569
+ Image of imaginary component of phasor coordinates.
570
+ Same shape as `real`.
571
+ attrs : dict
572
+ Select metadata:
573
+
574
+ - ``'dims'`` (tuple of str):
575
+ :ref:`Axes codes <axes>` for `mean` image dimensions.
576
+ - ``'harmonic'`` (int or list of int):
577
+ Harmonic(s) present in `real` and `imag`.
578
+ If a scalar, `real` and `imag` are single harmonic and contain no
579
+ harmonic axes.
580
+ If a list, `real` and `imag` contain one or more harmonics in the
581
+ first axis.
582
+ - ``'frequency'`` (float):
583
+ Fundamental frequency of time-resolved phasor coordinates in MHz.
584
+ - ``'samples'`` (int):
585
+ Number of samples per frequency.
586
+ - ``'ifli_header'`` (dict):
587
+ Metadata from IFLI file header.
588
+
589
+ Raises
590
+ ------
591
+ lfdfiles.LfdFileError
592
+ File is not an ISS IFLI file.
593
+ IndexError
594
+ Harmonic is not found in file.
595
+
596
+ Notes
597
+ -----
598
+ The implementation is based on the
599
+ `lfdfiles <https://github.com/cgohlke/lfdfiles/>`__ library.
600
+
601
+ Examples
602
+ --------
603
+ >>> mean, real, imag, attr = phasor_from_ifli(
604
+ ... fetch('frequency_domain.ifli'), harmonic='all'
605
+ ... )
606
+ >>> mean.shape
607
+ (256, 256)
608
+ >>> real.shape
609
+ (4, 256, 256)
610
+ >>> attr['dims']
611
+ ('Y', 'X')
612
+ >>> attr['harmonic']
613
+ [1, 2, 3, 5]
614
+ >>> attr['frequency'] # doctest: +NUMBER
615
+ 80.33
616
+ >>> attr['samples']
617
+ 64
618
+ >>> attr['ifli_header']
619
+ {'Version': 16, ... 'ModFrequency': (...), 'RefLifetime': (2.5,), ...}
620
+
621
+ """
622
+ import lfdfiles
623
+
624
+ with lfdfiles.VistaIfli(filename) as ifli:
625
+ assert ifli.axes is not None
626
+ data = ifli.asarray(**kwargs)
627
+ header = ifli.header
628
+ axes = ifli.axes
629
+
630
+ if channel is not None:
631
+ data = data[:, :, :, channel]
632
+ axes = axes[:3] + axes[4:]
633
+
634
+ shape, dims, _ = squeeze_dims(data.shape, axes, skip='YXF')
635
+ data = data.reshape(shape)
636
+ data = numpy.moveaxis(data, -2, 0) # move frequency to first axis
637
+ mean = data[..., 0].mean(axis=0) # average frequencies
638
+ real = data[..., 1].copy()
639
+ imag = data[..., 2].copy()
640
+ dims = dims[:-2]
641
+ del data
642
+
643
+ samples = header['HistogramResolution']
644
+ frequencies = header['ModFrequency']
645
+ frequency = frequencies[0]
646
+ harmonic_stored = [
647
+ (
648
+ int(round(f / frequency))
649
+ if (0.99 < f / frequency % 1.0) < 1.01
650
+ else None
651
+ )
652
+ for f in frequencies
653
+ ]
654
+
655
+ index: int | list[int]
656
+ if harmonic is None:
657
+ # return first harmonic in file
658
+ keepdims = False
659
+ harmonic = [1]
660
+ index = [0]
661
+ elif isinstance(harmonic, str) and harmonic in {'all', 'any'}:
662
+ keepdims = True
663
+ if harmonic == 'any':
664
+ # return any frequency
665
+ harmonic = [
666
+ (frequencies[i] / frequency if h is None else h)
667
+ for i, h in enumerate(harmonic_stored)
668
+ ]
669
+ index = list(range(len(harmonic_stored)))
670
+ else:
671
+ # return only harmonics of first frequency
672
+ harmonic = [h for h in harmonic_stored if h is not None]
673
+ index = [i for i, h in enumerate(harmonic_stored) if h is not None]
674
+ else:
675
+ # return specified harmonics
676
+ harmonic, keepdims = parse_harmonic(
677
+ harmonic, max(h for h in harmonic_stored if h is not None)
678
+ )
679
+ try:
680
+ index = [harmonic_stored.index(h) for h in harmonic]
681
+ except ValueError as exc:
682
+ raise IndexError('harmonic not found') from exc
683
+
684
+ real = real[index]
685
+ imag = imag[index]
686
+ if not keepdims:
687
+ real = real[0]
688
+ imag = imag[0]
689
+
690
+ attrs = {
691
+ 'dims': tuple(dims),
692
+ 'harmonic': harmonic,
693
+ 'frequency': frequency * 1e-6,
694
+ 'samples': samples,
695
+ 'ifli_header': header,
696
+ }
697
+
698
+ return mean, real, imag, attrs
699
+
700
+
701
+ def signal_from_flif(
702
+ filename: str | PathLike[Any],
703
+ /,
704
+ ) -> DataArray:
705
+ """Return phase images and metadata from FlimFast FLIF file.
706
+
707
+ FlimFast FLIF files contain phase images and metadata from full-field,
708
+ frequency-domain fluorescence lifetime measurements.
709
+
710
+ Parameters
711
+ ----------
712
+ filename : str or Path
713
+ Name of FlimFast FLIF file to read.
714
+
715
+ Returns
716
+ -------
717
+ xarray.DataArray
718
+ Phase images with :ref:`axes codes <axes>` ``'THYX'`` and
719
+ type ``uint16``:
720
+
721
+ - ``coords['H']``: phases in radians.
722
+ - ``attrs['frequency']``: repetition frequency in MHz.
723
+ - ``attrs['ref_phase']``: measured phase of reference.
724
+ - ``attrs['ref_mod']``: measured modulation of reference.
725
+ - ``attrs['ref_tauphase']``: lifetime from phase of reference in ns.
726
+ - ``attrs['ref_taumod']``: lifetime from modulation of reference in ns.
727
+
728
+ Raises
729
+ ------
730
+ lfdfiles.LfdFileError
731
+ File is not a FlimFast FLIF file.
732
+
733
+ Notes
734
+ -----
735
+ The implementation is based on the
736
+ `lfdfiles <https://github.com/cgohlke/lfdfiles/>`__ library.
737
+
738
+ Examples
739
+ --------
740
+ >>> signal = signal_from_flif(fetch('flimfast.flif'))
741
+ >>> signal.values
742
+ array(...)
743
+ >>> signal.dtype
744
+ dtype('uint16')
745
+ >>> signal.shape
746
+ (32, 220, 300)
747
+ >>> signal.dims
748
+ ('H', 'Y', 'X')
749
+ >>> signal.coords['H'].data
750
+ array([0, ..., 6.087], dtype=float32)
751
+ >>> signal.attrs['frequency'] # doctest: +NUMBER
752
+ 80.65
753
+
754
+ """
755
+ import lfdfiles
756
+
757
+ with lfdfiles.FlimfastFlif(filename) as flif:
758
+ nphases = int(flif.header.phases)
759
+ data = flif.asarray()
760
+ if data.shape[0] < nphases:
761
+ raise ValueError(f'measured phases {data.shape[0]} < {nphases=}')
762
+ if data.shape[0] % nphases != 0:
763
+ data = data[: (data.shape[0] // nphases) * nphases]
764
+ data = data.reshape(-1, nphases, data.shape[1], data.shape[2])
765
+ if data.shape[0] == 1:
766
+ data = data[0]
767
+ axes = 'HYX'
768
+ else:
769
+ axes = 'THYX'
770
+ # TODO: check if phases are ordered
771
+ phases = numpy.radians(flif.records['phase'][:nphases])
772
+ metadata = xarray_metadata(axes, data.shape, H=phases)
773
+ attrs = metadata['attrs']
774
+ attrs['frequency'] = float(flif.header.frequency)
775
+ attrs['ref_phase'] = float(flif.header.measured_phase)
776
+ attrs['ref_mod'] = float(flif.header.measured_mod)
777
+ attrs['ref_tauphase'] = float(flif.header.ref_tauphase)
778
+ attrs['ref_taumod'] = float(flif.header.ref_taumod)
779
+
780
+ from xarray import DataArray
781
+
782
+ return DataArray(data, **metadata)