phasorpy 0.7__cp314-cp314-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.
@@ -0,0 +1,360 @@
1
+ """Read FLIM LABS file formats."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ['phasor_from_flimlabs_json', 'signal_from_flimlabs_json']
6
+
7
+ import json
8
+ from typing import TYPE_CHECKING
9
+
10
+ from .._utils import parse_harmonic, xarray_metadata
11
+
12
+ if TYPE_CHECKING:
13
+ from .._typing import (
14
+ Any,
15
+ DataArray,
16
+ DTypeLike,
17
+ Literal,
18
+ NDArray,
19
+ PathLike,
20
+ Sequence,
21
+ )
22
+
23
+ import numpy
24
+
25
+
26
+ def phasor_from_flimlabs_json(
27
+ filename: str | PathLike[Any],
28
+ /,
29
+ channel: int | None = 0,
30
+ harmonic: int | Sequence[int] | Literal['all'] | str | None = None,
31
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any], dict[str, Any]]:
32
+ """Return phasor coordinates and metadata from FLIM LABS JSON phasor file.
33
+
34
+ FLIM LABS JSON files may contain calibrated phasor coordinates
35
+ (possibly for multiple channels and harmonics) and metadata from
36
+ digital frequency-domain measurements.
37
+
38
+ Parameters
39
+ ----------
40
+ filename : str or Path
41
+ Name of FLIM LABS JSON phasor file to read.
42
+ The file name usually contains the string "_phasor".
43
+ channel : int, optional
44
+ Index of channel to return.
45
+ By default, return the first channel.
46
+ If None, return all channels.
47
+ harmonic : int, sequence of int, or 'all', optional
48
+ Harmonic(s) to return from file.
49
+ If None (default), return the first harmonic stored in the file.
50
+ If `'all'`, return all harmonics as stored in file.
51
+ If a list, the first axes of the returned `real` and `imag` arrays
52
+ contain specified harmonic(s).
53
+ If an integer, the returned `real` and `imag` arrays are single
54
+ harmonic and have the same shape as `mean`.
55
+
56
+ Returns
57
+ -------
58
+ mean : ndarray
59
+ Average intensity image.
60
+ Zeroed if an intensity image is not present in file.
61
+ real : ndarray
62
+ Image of real component of phasor coordinates.
63
+ imag : ndarray
64
+ Image of imaginary component of phasor coordinates.
65
+ attrs : dict
66
+ Select metadata:
67
+
68
+ - ``'dims'`` (tuple of str):
69
+ :ref:`Axes codes <axes>` for `mean` image dimensions.
70
+ - ``'samples'`` (int):
71
+ Number of time bins (always 256).
72
+ - ``'harmonic'`` (int or list of int):
73
+ Harmonic(s) of `real` and `imag`.
74
+ Single int if one harmonic, list if multiple harmonics.
75
+ - ``'frequency'`` (float):
76
+ Laser repetition frequency in MHz.
77
+ - ``'flimlabs_header'`` (dict):
78
+ FLIM LABS file header.
79
+
80
+ Raises
81
+ ------
82
+ ValueError
83
+ File is not a FLIM LABS JSON file containing phasor coordinates.
84
+ IndexError
85
+ Harmonic or channel not found in file.
86
+
87
+ See Also
88
+ --------
89
+ phasorpy.io.signal_from_flimlabs_json
90
+
91
+ Examples
92
+ --------
93
+ >>> mean, real, imag, attrs = phasor_from_flimlabs_json(
94
+ ... fetch('Convallaria_m2_1740751781_phasor_ch1.json'), harmonic='all'
95
+ ... )
96
+ >>> real.shape
97
+ (3, 256, 256)
98
+ >>> attrs['dims']
99
+ ('Y', 'X')
100
+ >>> attrs['harmonic']
101
+ [1, 2, 3]
102
+ >>> attrs['frequency'] # doctest: +NUMBER
103
+ 40.00
104
+
105
+ """
106
+ with open(filename, 'rb') as fh:
107
+ try:
108
+ data = json.load(fh)
109
+ except Exception as exc:
110
+ raise ValueError('not a valid JSON file') from exc
111
+
112
+ if (
113
+ 'header' not in data
114
+ or 'phasors_data' not in data
115
+ or 'laser_period_ns' not in data['header']
116
+ or 'file_id' not in data['header']
117
+ # or data['header']['file_id'] != [73, 80, 71, 49] # 'IPG1'
118
+ ):
119
+ raise ValueError(
120
+ 'not a FLIM LABS JSON file containing phasor coordinates'
121
+ )
122
+
123
+ header = data['header']
124
+ phasor_data = data['phasors_data']
125
+
126
+ harmonics = []
127
+ channels = [] # 1-based
128
+ for d in phasor_data:
129
+ h = d['harmonic']
130
+ if h not in harmonics:
131
+ harmonics.append(h)
132
+ c = d['channel']
133
+ if c not in channels:
134
+ channels.append(c)
135
+ harmonics = sorted(harmonics)
136
+ channels = sorted(channels)
137
+
138
+ if channel is not None:
139
+ if channel + 1 not in channels:
140
+ raise IndexError(f'{channel=}')
141
+ channel += 1 # 1-based index
142
+
143
+ if isinstance(harmonic, str) and harmonic == 'all':
144
+ harmonic = harmonics
145
+ keep_harmonic_axis = True
146
+ else:
147
+ harmonic, keep_harmonic_axis = parse_harmonic(harmonic, harmonics[-1])
148
+ if any(h not in harmonics for h in harmonic):
149
+ raise IndexError(f'{harmonic=} not in {harmonics!r}')
150
+ harmonic_index = {h: i for i, h in enumerate(harmonic)}
151
+
152
+ nharmonics = len(harmonic)
153
+ nchannels = len(channels) if channel is None else 1
154
+ height = header['image_height']
155
+ width = header['image_width']
156
+ dtype = numpy.float32
157
+
158
+ shape: tuple[int, ...] = nharmonics, nchannels, height, width
159
+ axes: str = 'CYX'
160
+ mean = numpy.zeros(shape[1:], dtype)
161
+ real = numpy.zeros(shape, dtype)
162
+ imag = numpy.zeros(shape, dtype)
163
+
164
+ for d in phasor_data:
165
+ h = d['harmonic']
166
+ if h not in harmonic_index:
167
+ continue
168
+ h = harmonic_index[h]
169
+ if channel is not None:
170
+ if d['channel'] != channel:
171
+ continue
172
+ c = 0
173
+ else:
174
+ c = channels.index(d['channel'])
175
+
176
+ real[h, c] = numpy.asarray(d['g_data'], dtype)
177
+ imag[h, c] = numpy.asarray(d['s_data'], dtype)
178
+
179
+ if 'intensities_data' in data:
180
+ from .._phasorpy import _flimlabs_mean
181
+
182
+ mean.shape = nchannels, height * width
183
+ _flimlabs_mean(
184
+ mean,
185
+ data['intensities_data'],
186
+ -1 if channel is None else channels.index(channel),
187
+ )
188
+ mean.shape = shape[1:]
189
+ # JSON cannot store NaN values
190
+ nan_mask = mean == 0
191
+ real[:, nan_mask] = numpy.nan
192
+ imag[:, nan_mask] = numpy.nan
193
+ del nan_mask
194
+
195
+ if nchannels == 1:
196
+ axes = axes[1:]
197
+ mean = mean[0]
198
+ real = real[:, 0]
199
+ imag = imag[:, 0]
200
+
201
+ if not keep_harmonic_axis:
202
+ real = real[0]
203
+ imag = imag[0]
204
+
205
+ attrs = {
206
+ 'dims': tuple(axes),
207
+ 'samples': 256,
208
+ 'harmonic': harmonic if keep_harmonic_axis else harmonic[0],
209
+ 'frequency': 1000.0 / header['laser_period_ns'],
210
+ 'flimlabs_header': header,
211
+ }
212
+
213
+ return mean, real, imag, attrs
214
+
215
+
216
+ def signal_from_flimlabs_json(
217
+ filename: str | PathLike[Any],
218
+ /,
219
+ *,
220
+ channel: int | None = 0,
221
+ dtype: DTypeLike | None = None,
222
+ ) -> DataArray:
223
+ """Return TCSPC histogram and metadata from FLIM LABS JSON imaging file.
224
+
225
+ FLIM LABS JSON imaging files contain encoded, multi-channel TCSPC
226
+ histograms and metadata from digital frequency-domain measurements.
227
+
228
+ Parameters
229
+ ----------
230
+ filename : str or Path
231
+ Name of FLIM LABS JSON imaging file to read.
232
+ The file name usually contains the string "_imaging" or "_phasor".
233
+ channel : int, optional
234
+ Index of channel to return.
235
+ By default, return the first channel.
236
+ If None, return all channels.
237
+ dtype : dtype_like, optional, default: uint16
238
+ Unsigned integer type of TCSPC histogram.
239
+ Increase the bit-depth for high photon counts.
240
+
241
+ Returns
242
+ -------
243
+ xarray.DataArray
244
+ TCSPC histogram with :ref:`axes codes <axes>` depending on
245
+ `channel` parameter:
246
+
247
+ - Single channel: axes ``'YXH'``
248
+ - Multiple channels: axes ``'CYXH'``
249
+
250
+ Type specified by ``dtype`` parameter.
251
+
252
+ - ``coords['H']``: delay-times of histogram bins in ns.
253
+ - ``coords['C']``: channel indices (if multiple channels).
254
+ - ``attrs['frequency']``: laser repetition frequency in MHz.
255
+ - ``attrs['flimlabs_header']``: FLIM LABS file header.
256
+
257
+ Raises
258
+ ------
259
+ ValueError
260
+ If file is not a valid JSON file.
261
+ If file is not a FLIM LABS JSON file containing TCSPC histogram.
262
+ If `dtype` is not an unsigned integer type.
263
+ IndexError
264
+ If `channel` is out of range.
265
+
266
+ See Also
267
+ --------
268
+ phasorpy.io.phasor_from_flimlabs_json
269
+
270
+ Examples
271
+ --------
272
+ >>> signal = signal_from_flimlabs_json(
273
+ ... fetch('Convallaria_m2_1740751781_phasor_ch1.json')
274
+ ... )
275
+ >>> signal.values
276
+ array(...)
277
+ >>> signal.shape
278
+ (256, 256, 256)
279
+ >>> signal.dims
280
+ ('Y', 'X', 'H')
281
+ >>> signal.coords['H'].data
282
+ array(...)
283
+ >>> signal.attrs['frequency'] # doctest: +NUMBER
284
+ 40.0
285
+
286
+ """
287
+ with open(filename, 'rb') as fh:
288
+ try:
289
+ data = json.load(fh)
290
+ except Exception as exc:
291
+ raise ValueError('not a valid JSON file') from exc
292
+
293
+ if (
294
+ 'header' not in data
295
+ or 'laser_period_ns' not in data['header']
296
+ or 'file_id' not in data['header']
297
+ or ('data' not in data and 'intensities_data' not in data)
298
+ ):
299
+ raise ValueError(
300
+ 'not a FLIM LABS JSON file containing TCSPC histogram'
301
+ )
302
+
303
+ if dtype is None:
304
+ dtype = numpy.uint16
305
+ else:
306
+ dtype = numpy.dtype(dtype)
307
+ if dtype.kind != 'u':
308
+ raise ValueError(f'{dtype=} is not an unsigned integer type')
309
+
310
+ header = data['header']
311
+ nchannels = len([c for c in header['channels'] if c])
312
+ height = header['image_height']
313
+ width = header['image_width']
314
+ frequency = 1000.0 / header['laser_period_ns']
315
+
316
+ if channel is not None:
317
+ if channel >= nchannels or channel < 0:
318
+ raise IndexError(f'{channel=} out of range[0, {nchannels=}]')
319
+ nchannels = 1
320
+
321
+ if 'data' in data:
322
+ # file_id = [73, 77, 71, 49] # 'IMG1'
323
+ intensities_data = data['data']
324
+ else:
325
+ # file_id = [73, 80, 71, 49] # 'IPG1'
326
+ intensities_data = data['intensities_data']
327
+
328
+ from .._phasorpy import _flimlabs_signal
329
+
330
+ signal = numpy.zeros((nchannels, height * width, 256), dtype)
331
+ _flimlabs_signal(
332
+ signal,
333
+ intensities_data,
334
+ -1 if channel is None else channel,
335
+ )
336
+
337
+ if channel is None and nchannels > 1:
338
+ signal.shape = (nchannels, height, width, 256)
339
+ axes = 'CYXH'
340
+ else:
341
+ signal.shape = (height, width, 256)
342
+ axes = 'YXH'
343
+
344
+ coords: dict[str, Any] = {}
345
+ coords['H'] = numpy.linspace(
346
+ 0.0, header['laser_period_ns'], 256, endpoint=False
347
+ )
348
+ if channel is None and nchannels > 1:
349
+ coords['C'] = numpy.asarray(
350
+ [i for i, c in enumerate(header['channels']) if c]
351
+ )
352
+
353
+ metadata = xarray_metadata(axes, signal.shape, filename, **coords)
354
+ attrs = metadata['attrs']
355
+ attrs['frequency'] = frequency
356
+ attrs['flimlabs_header'] = header
357
+
358
+ from xarray import DataArray
359
+
360
+ return DataArray(signal, **metadata)
phasorpy/io/_leica.py ADDED
@@ -0,0 +1,331 @@
1
+ """Read Leica image file formats."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __all__ = ['lifetime_from_lif', 'phasor_from_lif', 'signal_from_lif']
6
+
7
+ from typing import TYPE_CHECKING
8
+
9
+ from .._utils import xarray_metadata
10
+
11
+ if TYPE_CHECKING:
12
+ from .._typing import Any, DataArray, Literal, NDArray, PathLike
13
+
14
+ import numpy
15
+
16
+
17
+ def phasor_from_lif(
18
+ filename: str | PathLike[Any],
19
+ /,
20
+ image: str | None = None,
21
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any], dict[str, Any]]:
22
+ """Return phasor coordinates and metadata from Leica image file.
23
+
24
+ Leica image files may contain uncalibrated phasor coordinate images and
25
+ metadata from the analysis of FLIM measurements.
26
+
27
+ Parameters
28
+ ----------
29
+ filename : str or Path
30
+ Name of Leica image file to read.
31
+ image : str, optional
32
+ Name of parent image containing phasor coordinates.
33
+
34
+ Returns
35
+ -------
36
+ mean : ndarray
37
+ Average intensity image.
38
+ real : ndarray
39
+ Image of real component of phasor coordinates.
40
+ imag : ndarray
41
+ Image of imaginary component of phasor coordinates.
42
+ attrs : dict
43
+ Select metadata:
44
+
45
+ - ``'dims'`` (tuple of str):
46
+ :ref:`Axes codes <axes>` for `mean` image dimensions.
47
+ - ``'frequency'`` (float):
48
+ Fundamental frequency of time-resolved phasor coordinates in MHz.
49
+ May not be present in all files.
50
+ - ``'flim_rawdata'`` (dict):
51
+ Settings from SingleMoleculeDetection/RawData XML element.
52
+ - ``'flim_phasor_channels'`` (list of dict):
53
+ Settings from SingleMoleculeDetection/.../PhasorData/Channels XML
54
+ elements.
55
+
56
+ Raises
57
+ ------
58
+ liffile.LifFileError
59
+ File is not a Leica image file.
60
+ ValueError
61
+ File or `image` do not contain phasor coordinates and metadata.
62
+
63
+ Notes
64
+ -----
65
+ The implementation is based on the
66
+ `liffile <https://github.com/cgohlke/liffile/>`__ library.
67
+
68
+ Examples
69
+ --------
70
+ >>> mean, real, imag, attrs = phasor_from_lif(fetch('FLIM_testdata.lif'))
71
+ >>> real.shape
72
+ (1024, 1024)
73
+ >>> attrs['dims']
74
+ ('Y', 'X')
75
+ >>> attrs['frequency']
76
+ 19.505
77
+
78
+ """
79
+ # TODO: read harmonic from XML if possible
80
+ # TODO: get calibration settings from XML metadata, lifetime, or
81
+ # phasor plot images
82
+ import liffile
83
+
84
+ image = '' if image is None else f'.*{image}.*/'
85
+ samples = 1
86
+
87
+ with liffile.LifFile(filename) as lif:
88
+ try:
89
+ im = lif.images[image + 'Phasor Intensity$']
90
+ dims = im.dims
91
+ coords = im.coords
92
+ # meta = image.attrs
93
+ mean = im.asarray().astype(numpy.float32)
94
+ real = lif.images[image + 'Phasor Real$'].asarray()
95
+ imag = lif.images[image + 'Phasor Imaginary$'].asarray()
96
+ # mask = lif.images[image + 'Phasor Mask$'].asarray()
97
+ except Exception as exc:
98
+ raise ValueError(
99
+ f'{lif.filename!r} does not contain Phasor images'
100
+ ) from exc
101
+
102
+ attrs: dict[str, Any] = {'dims': dims, 'coords': coords}
103
+ flim = im.parent_image
104
+ if flim is not None and isinstance(flim, liffile.LifFlimImage):
105
+ xml = flim.parent.xml_element
106
+ frequency = xml.find('.//Dataset/RawData/LaserPulseFrequency')
107
+ if frequency is not None and frequency.text is not None:
108
+ attrs['frequency'] = float(frequency.text) * 1e-6
109
+ clock_period = xml.find('.//Dataset/RawData/ClockPeriod')
110
+ if clock_period is not None and clock_period.text is not None:
111
+ tmp = float(clock_period.text) * float(frequency.text)
112
+ samples = int(round(1.0 / tmp))
113
+ attrs['samples'] = samples
114
+ channels = []
115
+ for channel in xml.findall(
116
+ './/Dataset/FlimData/PhasorData/Channels'
117
+ ):
118
+ ch = liffile.xml2dict(channel)['Channels']
119
+ ch.pop('PhasorPlotShapes', None)
120
+ channels.append(ch)
121
+ attrs['flim_phasor_channels'] = channels
122
+ attrs['flim_rawdata'] = flim.attrs.get('RawData', {})
123
+
124
+ if samples > 1:
125
+ mean /= samples
126
+ return (
127
+ mean,
128
+ real.astype(numpy.float32),
129
+ imag.astype(numpy.float32),
130
+ attrs,
131
+ )
132
+
133
+
134
+ def lifetime_from_lif(
135
+ filename: str | PathLike[Any],
136
+ /,
137
+ image: str | None = None,
138
+ ) -> tuple[NDArray[Any], NDArray[Any], NDArray[Any], dict[str, Any]]:
139
+ """Return lifetime image and metadata from Leica image file.
140
+
141
+ Leica image files may contain fluorescence lifetime images and metadata
142
+ from the analysis of FLIM measurements.
143
+ The lifetimes are average photon arrival times ("Fast FLIM") according to
144
+ the LAS X FLIM/FCS documentation.
145
+
146
+ Parameters
147
+ ----------
148
+ filename : str or Path
149
+ Name of Leica image file to read.
150
+ image : str, optional
151
+ Name of parent image containing lifetime image.
152
+
153
+ Returns
154
+ -------
155
+ lifetime : ndarray
156
+ Fast FLIM lifetime image in ns.
157
+ intensity : ndarray
158
+ Fluorescence intensity image.
159
+ stddev : ndarray
160
+ Standard deviation of fluorescence lifetimes in ns.
161
+ attrs : dict
162
+ Select metadata:
163
+
164
+ - ``'dims'`` (tuple of str):
165
+ :ref:`Axes codes <axes>` for `intensity` image dimensions.
166
+ - ``'frequency'`` (float):
167
+ Fundamental frequency of lifetimes in MHz.
168
+ May not be present in all files.
169
+ - ``'samples'`` (int):
170
+ Number of bins in TCSPC histogram. May not be present in all files.
171
+ - ``'flim_rawdata'`` (dict):
172
+ Settings from SingleMoleculeDetection/RawData XML element.
173
+
174
+ Raises
175
+ ------
176
+ liffile.LifFileError
177
+ File is not a Leica image file.
178
+ ValueError
179
+ File or `image` does not contain lifetime coordinates and metadata.
180
+
181
+ Notes
182
+ -----
183
+ The implementation is based on the
184
+ `liffile <https://github.com/cgohlke/liffile/>`__ library.
185
+
186
+ Examples
187
+ --------
188
+ >>> lifetime, intensity, stddev, attrs = lifetime_from_lif(
189
+ ... fetch('FLIM_testdata.lif')
190
+ ... )
191
+ >>> lifetime.shape
192
+ (1024, 1024)
193
+ >>> attrs['dims']
194
+ ('Y', 'X')
195
+ >>> attrs['frequency']
196
+ 19.505
197
+
198
+ """
199
+ import liffile
200
+
201
+ image = '' if image is None else f'.*{image}.*/'
202
+
203
+ with liffile.LifFile(filename) as lif:
204
+ try:
205
+ im = lif.images[image + 'Intensity$']
206
+ dims = im.dims
207
+ coords = im.coords
208
+ # meta = im.attrs
209
+ intensity = im.asarray()
210
+ lifetime = lif.images[image + 'Fast Flim$'].asarray()
211
+ stddev = lif.images[image + 'Standard Deviation$'].asarray()
212
+ except Exception as exc:
213
+ raise ValueError(
214
+ f'{lif.filename!r} does not contain lifetime images'
215
+ ) from exc
216
+
217
+ attrs: dict[str, Any] = {'dims': dims, 'coords': coords}
218
+ flim = im.parent_image
219
+ if flim is not None and isinstance(flim, liffile.LifFlimImage):
220
+ xml = flim.parent.xml_element
221
+ frequency = xml.find('.//Dataset/RawData/LaserPulseFrequency')
222
+ if frequency is not None and frequency.text is not None:
223
+ attrs['frequency'] = float(frequency.text) * 1e-6
224
+ clock_period = xml.find('.//Dataset/RawData/ClockPeriod')
225
+ if clock_period is not None and clock_period.text is not None:
226
+ tmp = float(clock_period.text) * float(frequency.text)
227
+ samples = int(round(1.0 / tmp))
228
+ attrs['samples'] = samples
229
+ attrs['flim_rawdata'] = flim.attrs.get('RawData', {})
230
+
231
+ return (
232
+ lifetime.astype(numpy.float32),
233
+ intensity.astype(numpy.float32),
234
+ stddev.astype(numpy.float32),
235
+ attrs,
236
+ )
237
+
238
+
239
+ def signal_from_lif(
240
+ filename: str | PathLike[Any],
241
+ /,
242
+ *,
243
+ image: int | str | None = None,
244
+ dim: Literal['λ', 'Λ'] | str = 'λ',
245
+ ) -> DataArray:
246
+ """Return hyperspectral image and metadata from Leica image file.
247
+
248
+ Leica image files may contain hyperspectral images and metadata from laser
249
+ scanning microscopy measurements.
250
+
251
+ Parameters
252
+ ----------
253
+ filename : str or Path
254
+ Name of Leica image file to read.
255
+ image : str or int, optional
256
+ Index or regex pattern of image to return.
257
+ By default, return the first image containing hyperspectral data.
258
+ dim : str or None
259
+ Character code of hyperspectral dimension.
260
+ Either ``'λ'`` for emission (default) or ``'Λ'`` for excitation.
261
+
262
+ Returns
263
+ -------
264
+ xarray.DataArray
265
+ Hyperspectral image data.
266
+
267
+ - ``coords['C']``: wavelengths in nm.
268
+ - ``coords['T']``: time coordinates in s, if any.
269
+
270
+ Raises
271
+ ------
272
+ liffile.LifFileError
273
+ File is not a Leica image file.
274
+ ValueError
275
+ File is not a Leica image file or does not contain hyperspectral image.
276
+
277
+ Notes
278
+ -----
279
+ The implementation is based on the
280
+ `liffile <https://github.com/cgohlke/liffile/>`__ library.
281
+
282
+ Reading of TCSPC histograms from FLIM measurements is not supported
283
+ because the compression scheme is patent-pending.
284
+
285
+ Examples
286
+ --------
287
+ >>> signal = signal_from_lif('ScanModesExamples.lif') # doctest: +SKIP
288
+ >>> signal.values # doctest: +SKIP
289
+ array(...)
290
+ >>> signal.shape # doctest: +SKIP
291
+ (9, 128, 128)
292
+ >>> signal.dims # doctest: +SKIP
293
+ ('C', 'Y', 'X')
294
+ >>> signal.coords['C'].data # doctest: +SKIP
295
+ array([560, 580, 600, ..., 680, 700, 720])
296
+
297
+ """
298
+ import liffile
299
+
300
+ with liffile.LifFile(filename) as lif:
301
+ if image is None:
302
+ # find image with excitation or emission dimension
303
+ for im in lif.images:
304
+ if dim in im.dims:
305
+ break
306
+ else:
307
+ raise ValueError(
308
+ f'{lif.filename!r} does not contain hyperspectral image'
309
+ )
310
+ else:
311
+ im = lif.images[image]
312
+
313
+ if dim not in im.dims or im.sizes[dim] < 4:
314
+ raise ValueError(f'{im!r} does not contain spectral dimension')
315
+ if 'C' in im.dims:
316
+ raise ValueError(
317
+ 'hyperspectral image must not contain channel axis'
318
+ )
319
+
320
+ data = im.asarray()
321
+ coords: dict[str, Any] = {
322
+ ('C' if k == dim else k): (v * 1e9 if k == dim else v)
323
+ for (k, v) in im.coords.items()
324
+ }
325
+ dims = tuple(('C' if d == dim else d) for d in im.dims)
326
+
327
+ metadata = xarray_metadata(dims, im.shape, filename, **coords)
328
+
329
+ from xarray import DataArray
330
+
331
+ return DataArray(data, **metadata)