phasorpy 0.4__cp311-cp311-win_arm64.whl → 0.6__cp311-cp311-win_arm64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
phasorpy/io/_simfcs.py ADDED
@@ -0,0 +1,627 @@
1
+ """Read and write SimFCS file formats."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ 'phasor_from_simfcs_referenced',
7
+ 'phasor_to_simfcs_referenced',
8
+ 'signal_from_b64',
9
+ 'signal_from_bh',
10
+ 'signal_from_bhz',
11
+ 'signal_from_fbd',
12
+ 'signal_from_z64',
13
+ ]
14
+
15
+ import os
16
+ import struct
17
+ import zlib
18
+ from typing import TYPE_CHECKING
19
+
20
+ from .._utils import chunk_iter, parse_harmonic, xarray_metadata
21
+ from ..phasor import phasor_from_polar, phasor_to_polar
22
+
23
+ if TYPE_CHECKING:
24
+ from .._typing import (
25
+ Any,
26
+ ArrayLike,
27
+ DataArray,
28
+ Literal,
29
+ NDArray,
30
+ PathLike,
31
+ Sequence,
32
+ )
33
+
34
+ import numpy
35
+
36
+
37
+ def phasor_to_simfcs_referenced(
38
+ filename: str | PathLike[Any],
39
+ mean: ArrayLike,
40
+ real: ArrayLike,
41
+ imag: ArrayLike,
42
+ /,
43
+ *,
44
+ size: int | None = None,
45
+ dims: Sequence[str] | None = None,
46
+ ) -> None:
47
+ """Write phasor coordinate images to SimFCS referenced R64 file(s).
48
+
49
+ SimFCS referenced R64 files store square-shaped (commonly 256x256)
50
+ images of the average intensity, and the calibrated phasor coordinates
51
+ (encoded as phase and modulation) of two harmonics as ZIP-compressed,
52
+ single precision floating point arrays.
53
+ The file format does not support any metadata.
54
+
55
+ Images with more than two dimensions or larger than square size are
56
+ chunked to square-sized images and saved to separate files with
57
+ a name pattern, for example, "filename_T099_Y256_X000.r64".
58
+ Images or chunks with less than two dimensions or smaller than square size
59
+ are padded with NaN values.
60
+
61
+ Parameters
62
+ ----------
63
+ filename : str or Path
64
+ Name of SimFCS referenced R64 file to write.
65
+ The file extension must be ``.r64``.
66
+ mean : array_like
67
+ Average intensity image.
68
+ real : array_like
69
+ Image of real component of calibrated phasor coordinates.
70
+ Multiple harmonics, if any, must be in the first dimension.
71
+ Harmonics must be starting at and increasing by one.
72
+ imag : array_like
73
+ Image of imaginary component of calibrated phasor coordinates.
74
+ Multiple harmonics, if any, must be in the first dimension.
75
+ Harmonics must be starting at and increasing by one.
76
+ size : int, optional
77
+ Size of X and Y dimensions of square-sized images stored in file.
78
+ By default, ``size = min(256, max(4, sizey, sizex))``.
79
+ dims : sequence of str, optional
80
+ Character codes for `mean` dimensions used to format file names.
81
+
82
+ See Also
83
+ --------
84
+ phasorpy.io.phasor_from_simfcs_referenced
85
+
86
+ Examples
87
+ --------
88
+ >>> mean, real, imag = numpy.random.rand(3, 32, 32)
89
+ >>> phasor_to_simfcs_referenced('_phasorpy.r64', mean, real, imag)
90
+
91
+ """
92
+ filename, ext = os.path.splitext(filename)
93
+ if ext.lower() != '.r64':
94
+ raise ValueError(f'file extension {ext} != .r64')
95
+
96
+ # TODO: delay conversions to numpy arrays to inner loop
97
+ mean = numpy.asarray(mean, numpy.float32)
98
+ phi, mod = phasor_to_polar(real, imag, dtype=numpy.float32)
99
+ del real
100
+ del imag
101
+ phi = numpy.rad2deg(phi)
102
+
103
+ if phi.shape != mod.shape:
104
+ raise ValueError(f'{phi.shape=} != {mod.shape=}')
105
+ if mean.shape != phi.shape[-mean.ndim :]:
106
+ raise ValueError(f'{mean.shape=} != {phi.shape[-mean.ndim:]=}')
107
+ if phi.ndim == mean.ndim:
108
+ phi = phi.reshape(1, *phi.shape)
109
+ mod = mod.reshape(1, *mod.shape)
110
+ nharmonic = phi.shape[0]
111
+
112
+ if mean.ndim < 2:
113
+ # not an image
114
+ mean = mean.reshape(1, -1)
115
+ phi = phi.reshape(nharmonic, 1, -1)
116
+ mod = mod.reshape(nharmonic, 1, -1)
117
+
118
+ # TODO: investigate actual size and harmonics limits of SimFCS
119
+ sizey, sizex = mean.shape[-2:]
120
+ if size is None:
121
+ size = min(256, max(4, sizey, sizex))
122
+ elif not 4 <= size <= 65535:
123
+ raise ValueError(f'{size=} out of range [4, 65535]')
124
+
125
+ harmonics_per_file = 2 # TODO: make this a parameter?
126
+ chunk_shape = tuple(
127
+ [max(harmonics_per_file, 2)] + ([1] * (phi.ndim - 3)) + [size, size]
128
+ )
129
+ multi_file = any(i / j > 1 for i, j in zip(phi.shape, chunk_shape))
130
+
131
+ if dims is not None and len(dims) == phi.ndim - 1:
132
+ dims = tuple(dims)
133
+ dims = ('h' if dims[0].islower() else 'H',) + dims
134
+
135
+ chunk = numpy.empty((size, size), dtype=numpy.float32)
136
+
137
+ def rawdata_append(
138
+ rawdata: list[bytes], a: NDArray[Any] | None = None
139
+ ) -> None:
140
+ if a is None:
141
+ chunk[:] = numpy.nan
142
+ rawdata.append(chunk.tobytes())
143
+ else:
144
+ sizey, sizex = a.shape[-2:]
145
+ if sizey == size and sizex == size:
146
+ rawdata.append(a.tobytes())
147
+ elif sizey <= size and sizex <= size:
148
+ chunk[:sizey, :sizex] = a[..., :sizey, :sizex]
149
+ chunk[:sizey, sizex:] = numpy.nan
150
+ chunk[sizey:, :] = numpy.nan
151
+ rawdata.append(chunk.tobytes())
152
+ else:
153
+ raise RuntimeError # should not be reached
154
+
155
+ for index, label, _ in chunk_iter(
156
+ phi.shape, chunk_shape, dims, squeeze=False, use_index=True
157
+ ):
158
+ rawdata = [struct.pack('I', size)]
159
+ rawdata_append(rawdata, mean[index[1:]])
160
+ phi_ = phi[index]
161
+ mod_ = mod[index]
162
+ for i in range(phi_.shape[0]):
163
+ rawdata_append(rawdata, phi_[i])
164
+ rawdata_append(rawdata, mod_[i])
165
+ if phi_.shape[0] == 1:
166
+ rawdata_append(rawdata)
167
+ rawdata_append(rawdata)
168
+
169
+ if not multi_file:
170
+ label = ''
171
+ with open(filename + label + ext, 'wb') as fh:
172
+ fh.write(zlib.compress(b''.join(rawdata)))
173
+
174
+
175
+ def phasor_from_simfcs_referenced(
176
+ filename: str | PathLike[Any],
177
+ /,
178
+ *,
179
+ harmonic: int | Sequence[int] | Literal['all'] | str | None = None,
180
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any], dict[str, Any]]:
181
+ """Return phasor coordinates and metadata from SimFCS REF or R64 file.
182
+
183
+ SimFCS referenced REF and R64 files contain phasor coordinate images
184
+ (encoded as phase and modulation) for two harmonics.
185
+ Phasor coordinates from lifetime-resolved signals are calibrated.
186
+
187
+ Parameters
188
+ ----------
189
+ filename : str or Path
190
+ Name of SimFCS REF or R64 file to read.
191
+ harmonic : int or sequence of int, optional
192
+ Harmonic(s) to include in returned phasor coordinates.
193
+ By default, only the first harmonic is returned.
194
+
195
+ Returns
196
+ -------
197
+ mean : ndarray
198
+ Average intensity image.
199
+ real : ndarray
200
+ Image of real component of phasor coordinates.
201
+ Multiple harmonics, if any, are in the first axis.
202
+ imag : ndarray
203
+ Image of imaginary component of phasor coordinates.
204
+ Multiple harmonics, if any, are in the first axis.
205
+ attrs : dict
206
+ Select metadata:
207
+
208
+ - ``'dims'`` (tuple of str):
209
+ :ref:`Axes codes <axes>` for `mean` image dimensions.
210
+
211
+ Raises
212
+ ------
213
+ lfdfiles.LfdfileError
214
+ File is not a SimFCS REF or R64 file.
215
+
216
+ See Also
217
+ --------
218
+ phasorpy.io.phasor_to_simfcs_referenced
219
+
220
+ Notes
221
+ -----
222
+ The implementation is based on the
223
+ `lfdfiles <https://github.com/cgohlke/lfdfiles/>`__ library.
224
+
225
+ Examples
226
+ --------
227
+ >>> phasor_to_simfcs_referenced(
228
+ ... '_phasorpy.r64', *numpy.random.rand(3, 32, 32)
229
+ ... )
230
+ >>> mean, real, imag, _ = phasor_from_simfcs_referenced('_phasorpy.r64')
231
+ >>> mean
232
+ array([[...]], dtype=float32)
233
+
234
+ """
235
+ import lfdfiles
236
+
237
+ ext = os.path.splitext(filename)[-1].lower()
238
+ if ext == '.r64':
239
+ with lfdfiles.SimfcsR64(filename) as r64:
240
+ data = r64.asarray()
241
+ elif ext == '.ref':
242
+ with lfdfiles.SimfcsRef(filename) as ref:
243
+ data = ref.asarray()
244
+ else:
245
+ raise ValueError(f'file extension must be .ref or .r64, not {ext!r}')
246
+
247
+ harmonic, keep_harmonic_dim = parse_harmonic(harmonic, data.shape[0] // 2)
248
+
249
+ mean = data[0].copy()
250
+ real = numpy.empty((len(harmonic),) + mean.shape, numpy.float32)
251
+ imag = numpy.empty_like(real)
252
+ for i, h in enumerate(harmonic):
253
+ h = (h - 1) * 2 + 1
254
+ re, im = phasor_from_polar(numpy.deg2rad(data[h]), data[h + 1])
255
+ real[i] = re
256
+ imag[i] = im
257
+ if not keep_harmonic_dim:
258
+ real = real.reshape(mean.shape)
259
+ imag = imag.reshape(mean.shape)
260
+
261
+ return mean, real, imag, {'dims': ('Y', 'X')}
262
+
263
+
264
+ def signal_from_fbd(
265
+ filename: str | PathLike[Any],
266
+ /,
267
+ *,
268
+ frame: int | None = None,
269
+ channel: int | None = 0,
270
+ keepdims: bool = False,
271
+ laser_factor: float = -1.0,
272
+ ) -> DataArray:
273
+ """Return phase histogram and metadata from FLIMbox FBD file.
274
+
275
+ FDB files contain encoded cross-correlation phase histograms from
276
+ digital frequency-domain measurements using a FLIMbox device.
277
+ The encoding scheme depends on the FLIMbox device's firmware.
278
+ The FBD file format is undocumented.
279
+
280
+ This function may fail to produce expected results when files use unknown
281
+ firmware, do not contain image scans, settings were recorded incorrectly,
282
+ scanner and FLIMbox frequencies were out of sync, or scanner settings were
283
+ changed during acquisition.
284
+
285
+ Parameters
286
+ ----------
287
+ filename : str or Path
288
+ Name of FLIMbox FBD file to read.
289
+ frame : int, optional
290
+ If None (default), return all frames.
291
+ If < 0, integrate time axis, else return specified frame.
292
+ channel : int, optional
293
+ Index of channel to return.
294
+ By default, return the first channel.
295
+ If None, return all channels.
296
+ keepdims : bool, optional, default: False
297
+ If true, return reduced axes as size-one dimensions.
298
+ laser_factor : float, optional
299
+ Factor to correct dwell_time/laser_frequency.
300
+
301
+ Returns
302
+ -------
303
+ xarray.DataArray
304
+ Phase histogram with :ref:`axes codes <axes>` ``'TCYXH'`` and
305
+ type ``uint16``:
306
+
307
+ - ``coords['H']``: cross-correlation phases in radians.
308
+ - ``attrs['frequency']``: repetition frequency in MHz.
309
+ - ``attrs['harmonic']``: harmonic contained in phase histogram.
310
+ - ``attrs['flimbox_header']``: FBD binary header, if any.
311
+ - ``attrs['flimbox_firmware']``: FLIMbox firmware settings, if any.
312
+ - ``attrs['flimbox_settings']``: Settings from FBS XML, if any.
313
+
314
+ Raises
315
+ ------
316
+ lfdfiles.LfdFileError
317
+ File is not a FLIMbox FBD file.
318
+
319
+ Notes
320
+ -----
321
+ The implementation is based on the
322
+ `lfdfiles <https://github.com/cgohlke/lfdfiles/>`__ library.
323
+
324
+ Examples
325
+ --------
326
+ >>> signal = signal_from_fbd(
327
+ ... fetch('convallaria_000$EI0S.fbd')
328
+ ... ) # doctest: +SKIP
329
+ >>> signal.values # doctest: +SKIP
330
+ array(...)
331
+ >>> signal.dtype # doctest: +SKIP
332
+ dtype('uint16')
333
+ >>> signal.shape # doctest: +SKIP
334
+ (9, 256, 256, 64)
335
+ >>> signal.dims # doctest: +SKIP
336
+ ('T', 'Y', 'X', 'H')
337
+ >>> signal.coords['H'].data # doctest: +SKIP
338
+ array([0, ..., 6.185])
339
+ >>> signal.attrs['frequency'] # doctest: +SKIP
340
+ 40.0
341
+
342
+ """
343
+ import lfdfiles
344
+
345
+ integrate_frames = 0 if frame is None or frame >= 0 else 1
346
+
347
+ with lfdfiles.FlimboxFbd(filename, laser_factor=laser_factor) as fbd:
348
+ data = fbd.asimage(None, None, integrate_frames=integrate_frames)
349
+ if integrate_frames:
350
+ frame = None
351
+ copy = False
352
+ axes = 'TCYXH'
353
+ if channel is None:
354
+ if not keepdims and data.shape[1] == 1:
355
+ data = data[:, 0]
356
+ axes = 'TYXH'
357
+ else:
358
+ if channel < 0 or channel >= data.shape[1]:
359
+ raise IndexError(f'{channel=} out of bounds')
360
+ if keepdims:
361
+ data = data[:, channel : channel + 1]
362
+ else:
363
+ data = data[:, channel]
364
+ axes = 'TYXH'
365
+ copy = True
366
+ if frame is None:
367
+ if not keepdims and data.shape[0] == 1:
368
+ data = data[0]
369
+ axes = axes[1:]
370
+ else:
371
+ if frame < 0 or frame >= data.shape[0]:
372
+ raise IndexError(f'{frame=} out of bounds')
373
+ if keepdims:
374
+ data = data[frame : frame + 1]
375
+ else:
376
+ data = data[frame]
377
+ axes = axes[1:]
378
+ copy = True
379
+ if copy:
380
+ data = data.copy()
381
+ # TODO: return arrival window indices or micro-times as H coords?
382
+ phases = numpy.linspace(
383
+ 0.0, numpy.pi * 2, data.shape[-1], endpoint=False
384
+ )
385
+ metadata = xarray_metadata(axes, data.shape, H=phases)
386
+ attrs = metadata['attrs']
387
+ attrs['frequency'] = fbd.laser_frequency * 1e-6
388
+ attrs['harmonic'] = fbd.harmonics
389
+ if fbd.header is not None:
390
+ attrs['flimbox_header'] = fbd.header
391
+ if fbd.fbf is not None:
392
+ attrs['flimbox_firmware'] = fbd.fbf
393
+ if fbd.fbs is not None:
394
+ attrs['flimbox_settings'] = fbd.fbs
395
+
396
+ from xarray import DataArray
397
+
398
+ return DataArray(data, **metadata)
399
+
400
+
401
+ def signal_from_b64(
402
+ filename: str | PathLike[Any],
403
+ /,
404
+ ) -> DataArray:
405
+ """Return intensity image and metadata from SimFCS B64 file.
406
+
407
+ B64 files contain one or more square intensity image(s), a carpet
408
+ of lines, or a stream of intensity data. B64 files contain no metadata.
409
+
410
+ Parameters
411
+ ----------
412
+ filename : str or Path
413
+ Name of SimFCS B64 file to read.
414
+
415
+ Returns
416
+ -------
417
+ xarray.DataArray
418
+ Intensity image of type ``int16``.
419
+
420
+ Raises
421
+ ------
422
+ lfdfiles.LfdFileError
423
+ File is not a SimFCS B64 file.
424
+ ValueError
425
+ File does not contain an image stack.
426
+
427
+ Notes
428
+ -----
429
+ The implementation is based on the
430
+ `lfdfiles <https://github.com/cgohlke/lfdfiles/>`__ library.
431
+
432
+ Examples
433
+ --------
434
+ >>> signal = signal_from_b64(fetch('simfcs.b64'))
435
+ >>> signal.values
436
+ array(...)
437
+ >>> signal.dtype
438
+ dtype('int16')
439
+ >>> signal.shape
440
+ (22, 1024, 1024)
441
+ >>> signal.dtype
442
+ dtype('int16')
443
+ >>> signal.dims
444
+ ('I', 'Y', 'X')
445
+
446
+ """
447
+ import lfdfiles
448
+
449
+ with lfdfiles.SimfcsB64(filename) as b64:
450
+ data = b64.asarray()
451
+ if data.ndim != 3:
452
+ raise ValueError(
453
+ f'{os.path.basename(filename)!r} '
454
+ 'does not contain an image stack'
455
+ )
456
+ metadata = xarray_metadata(b64.axes, data.shape, filename)
457
+
458
+ from xarray import DataArray
459
+
460
+ return DataArray(data, **metadata)
461
+
462
+
463
+ def signal_from_z64(
464
+ filename: str | PathLike[Any],
465
+ /,
466
+ ) -> DataArray:
467
+ """Return image stack and metadata from SimFCS Z64 file.
468
+
469
+ Z64 files commonly contain stacks of square images, such as intensity
470
+ volumes or TCSPC histograms. Z64 files contain no metadata.
471
+
472
+ Parameters
473
+ ----------
474
+ filename : str or Path
475
+ Name of SimFCS Z64 file to read.
476
+
477
+ Returns
478
+ -------
479
+ xarray.DataArray
480
+ Image stack of type ``float32``.
481
+
482
+ Raises
483
+ ------
484
+ lfdfiles.LfdFileError
485
+ File is not a SimFCS Z64 file.
486
+
487
+ Notes
488
+ -----
489
+ The implementation is based on the
490
+ `lfdfiles <https://github.com/cgohlke/lfdfiles/>`__ library.
491
+
492
+ Examples
493
+ --------
494
+ >>> signal = signal_from_z64(fetch('simfcs.z64'))
495
+ >>> signal.values
496
+ array(...)
497
+ >>> signal.dtype
498
+ dtype('float32')
499
+ >>> signal.shape
500
+ (256, 256, 256)
501
+ >>> signal.dims
502
+ ('Q', 'Y', 'X')
503
+
504
+ """
505
+ import lfdfiles
506
+
507
+ with lfdfiles.SimfcsZ64(filename) as z64:
508
+ data = z64.asarray()
509
+ metadata = xarray_metadata(z64.axes, data.shape, filename)
510
+
511
+ from xarray import DataArray
512
+
513
+ return DataArray(data, **metadata)
514
+
515
+
516
+ def signal_from_bh(
517
+ filename: str | PathLike[Any],
518
+ /,
519
+ ) -> DataArray:
520
+ """Return TCSPC histogram and metadata from SimFCS B&H file.
521
+
522
+ B&H files contain TCSPC histograms acquired from Becker & Hickl
523
+ cards, or converted from other data sources. B&H files contain no metadata.
524
+
525
+ Parameters
526
+ ----------
527
+ filename : str or Path
528
+ Name of SimFCS B&H file to read.
529
+
530
+ Returns
531
+ -------
532
+ xarray.DataArray
533
+ TCSPC histogram with ref:`axes codes <axes>` ``'HYX'``,
534
+ shape ``(256, 256, 256)``, and type ``float32``.
535
+
536
+ Raises
537
+ ------
538
+ lfdfiles.LfdFileError
539
+ File is not a SimFCS B&H file.
540
+
541
+ Notes
542
+ -----
543
+ The implementation is based on the
544
+ `lfdfiles <https://github.com/cgohlke/lfdfiles/>`__ library.
545
+
546
+ Examples
547
+ --------
548
+ >>> signal = signal_from_bh(fetch('simfcs.b&h'))
549
+ >>> signal.values
550
+ array(...)
551
+ >>> signal.dtype
552
+ dtype('float32')
553
+ >>> signal.shape
554
+ (256, 256, 256)
555
+ >>> signal.dims
556
+ ('H', 'Y', 'X')
557
+
558
+ """
559
+ import lfdfiles
560
+
561
+ with lfdfiles.SimfcsBh(filename) as bnh:
562
+ assert bnh.axes is not None
563
+ data = bnh.asarray()
564
+ metadata = xarray_metadata(
565
+ bnh.axes.replace('Q', 'H'), data.shape, filename
566
+ )
567
+
568
+ from xarray import DataArray
569
+
570
+ return DataArray(data, **metadata)
571
+
572
+
573
+ def signal_from_bhz(
574
+ filename: str | PathLike[Any],
575
+ /,
576
+ ) -> DataArray:
577
+ """Return TCSPC histogram and metadata from SimFCS BHZ file.
578
+
579
+ BHZ files contain TCSPC histograms acquired from Becker & Hickl
580
+ cards, or converted from other data sources. BHZ files contain no metadata.
581
+
582
+ Parameters
583
+ ----------
584
+ filename : str or Path
585
+ Name of SimFCS BHZ file to read.
586
+
587
+ Returns
588
+ -------
589
+ xarray.DataArray
590
+ TCSPC histogram with ref:`axes codes <axes>` ``'HYX'``,
591
+ shape ``(256, 256, 256)``, and type ``float32``.
592
+
593
+ Raises
594
+ ------
595
+ lfdfiles.LfdFileError
596
+ File is not a SimFCS BHZ file.
597
+
598
+ Notes
599
+ -----
600
+ The implementation is based on the
601
+ `lfdfiles <https://github.com/cgohlke/lfdfiles/>`__ library.
602
+
603
+ Examples
604
+ --------
605
+ >>> signal = signal_from_bhz(fetch('simfcs.bhz'))
606
+ >>> signal.values
607
+ array(...)
608
+ >>> signal.dtype
609
+ dtype('float32')
610
+ >>> signal.shape
611
+ (256, 256, 256)
612
+ >>> signal.dims
613
+ ('H', 'Y', 'X')
614
+
615
+ """
616
+ import lfdfiles
617
+
618
+ with lfdfiles.SimfcsBhz(filename) as bhz:
619
+ assert bhz.axes is not None
620
+ data = bhz.asarray()
621
+ metadata = xarray_metadata(
622
+ bhz.axes.replace('Q', 'H'), data.shape, filename
623
+ )
624
+
625
+ from xarray import DataArray
626
+
627
+ return DataArray(data, **metadata)