phasorpy 0.7__cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl

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