hdf5scan.py 35.1 KB
Newer Older
payno's avatar
payno committed
1
# coding: utf-8
payno's avatar
payno committed
2
# /*##########################################################################
payno's avatar
payno committed
3
# Copyright (C) 2016-2020 European Synchrotron Radiation Facility
payno's avatar
payno committed
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
#
#############################################################################*/

payno's avatar
payno committed
25
26
"""contains EDFTomoScan, class to be used with HDF5 acquisition"""

payno's avatar
payno committed
27
28
29
30
31
32

__authors__ = ["H.Payno"]
__license__ = "MIT"
__date__ = "09/08/2018"


payno's avatar
payno committed
33
from ..scanbase import TomoScanBase, _FOV
payno's avatar
payno committed
34
35
36
37
38
39
import json
import io
import os
import h5py
import numpy
from silx.io.url import DataUrl
40
from silx.utils.enum import Enum as _Enum
payno's avatar
payno committed
41
from tomoscan.utils import docstring
42
from tomoscan.io import HDF5File
43
from silx.io.utils import get_data
44
from ..unitsystem import metricsystem
45
from .utils import get_compacted_dataslices
46
from silx.io.utils import h5py_read_dataset
47
import typing
payno's avatar
payno committed
48
import logging
payno's avatar
payno committed
49
50
51
52

_logger = logging.getLogger(__name__)


53
class ImageKey(_Enum):
54
55
56
    ALIGNMENT = -1
    PROJECTION = 0
    FLAT_FIELD = 1
57
58
59
60
    DARK_FIELD = 2
    INVALID = 3


payno's avatar
payno committed
61
62
63
64
65
66
67
68
69
70
71
72
class HDF5TomoScan(TomoScanBase):
    """
    This is the implementation of a TomoBase class for an acquisition stored
    in a HDF5 file.

    For now several property of the acquisition is accessible thought a getter
    (like get_scan_range) and a property (scan_range).

    This is done to be compliant with TomoBase instantiation. But his will be
    replace progressively by properties at the 'TomoBase' level

    :param scan: scan directory or scan masterfile.h5
73
74
75
76
77
    :param Union[str, None] entry: name of the NXtomo entry to select. If given
                                   index is ignored.
    :param Union[int, None] index: of the NXtomo entry to select. Ignored if
                                   an entry is specified. For consistency
                                   entries are ordered alphabetically
payno's avatar
payno committed
78
79
    """

payno's avatar
payno committed
80
    _TYPE = "hdf5"
payno's avatar
payno committed
81

payno's avatar
payno committed
82
    _DICT_ENTRY_KEY = "entry"
payno's avatar
payno committed
83

payno's avatar
payno committed
84
    _PROJ_PATH = "instrument/detector/data"
payno's avatar
payno committed
85

payno's avatar
payno committed
86
    _SCAN_META_PATH = "scan_meta/technique/scan"
payno's avatar
payno committed
87

payno's avatar
payno committed
88
    _DET_META_PATH = "scan_meta/technique/detector"
89

payno's avatar
payno committed
90
    _ROTATION_ANGLE_PATH = "sample/rotation_angle"
91

92
93
94
95
96
97
    _X_TRANS_PATH = "sample/x_translation"

    _Y_TRANS_PATH = "sample/y_translation"

    _Z_TRANS_PATH = "sample/z_translation"

payno's avatar
payno committed
98
    _IMG_KEY_PATH = "instrument/detector/image_key"
99

payno's avatar
payno committed
100
    _IMG_KEY_CONTROL_PATH = "instrument/detector/image_key_control"
101

payno's avatar
payno committed
102
    _X_PIXEL_SIZE_PATH = "instrument/detector/x_pixel_size"
103

payno's avatar
payno committed
104
    _Y_PIXEL_SIZE_PATH = "instrument/detector/y_pixel_size"
105

payno's avatar
payno committed
106
    _X_PIXEL_MAG_SIZE_PATH = "instrument/detector/x_magnified_pixel_size"
107

payno's avatar
payno committed
108
    _Y_PIXEL_MAG_SIZE_PATH = "instrument/detector/y_magnified_pixel_size"
payno's avatar
payno committed
109

payno's avatar
payno committed
110
    _DISTANCE_PATH = "instrument/detector/distance"
payno's avatar
payno committed
111

payno's avatar
payno committed
112
    _FOV_PATH = "instrument/detector/field_of_view"
113

payno's avatar
payno committed
114
115
    _ESTIMATED_COR_FRM_MOTOR_PATH = "instrument/detector/estimated_cor_from_motor"

payno's avatar
payno committed
116
117
    _ENERGY_PATH = "beam/incident_energy"

118
119
120
121
    _START_TIME_PATH = "start_time"

    _END_TIME_START = "end_time"

payno's avatar
payno committed
122
    _SCHEME = "silx"
payno's avatar
payno committed
123

124
125
    _EPSILON_ROT_ANGLE = 0.02

payno's avatar
payno committed
126
    def __init__(
127
128
129
130
        self,
        scan: str,
        entry: str = None,
        index: typing.Union[int, None] = 0,
Pierre Paleo's avatar
Pierre Paleo committed
131
        ignore_projections: typing.Union[None, typing.Iterable] = None,
payno's avatar
payno committed
132
    ):
133
134
        if entry is not None:
            index = None
payno's avatar
payno committed
135
136
        # if the user give the master file instead of the scan dir...
        if scan is not None:
payno's avatar
payno committed
137
            if not os.path.exists(scan) and "." in os.path.split(scan)[-1]:
138
139
                self.master_file = scan
                scan = os.path.dirname(scan)
Tomas Farago's avatar
Tomas Farago committed
140
            elif os.path.isfile(scan) or ():
payno's avatar
payno committed
141
142
143
                self.master_file = scan
                scan = os.path.dirname(scan)
            else:
144
                self.master_file = self.get_master_file(scan)
payno's avatar
payno committed
145
146
147
        else:
            self.master_file = None

148
149
150
        super(HDF5TomoScan, self).__init__(
            scan=scan, type_=HDF5TomoScan._TYPE, ignore_projections=ignore_projections
        )
payno's avatar
payno committed
151

152
153
154
        if scan is None:
            self._entry = None
        else:
payno's avatar
payno committed
155
156
157
            self._entry = entry or self._get_entry_at(
                index=index, file_path=self.master_file
            )
158
            if self._entry is None:
payno's avatar
payno committed
159
160
161
                raise ValueError(
                    "unable to find a valid entry for %s" % self.master_file
                )
payno's avatar
payno committed
162
163
164
        # for now the default entry is 1_tomo but should change with time

        # data caches
165
        self._projections_compacted = None
166
167
        self._flats = None
        self._darks = None
payno's avatar
payno committed
168
169
170
171
172
173
174
175
176
177
        self._tomo_n = None
        # number of projections / radios
        self._dark_n = None
        # number of dark image made during acquisition
        self._ref_n = None
        # number of flat field made during acquisition
        self._scan_range = None
        # scan range, in degree
        self._dim_1, self._dim_2 = None, None
        # image dimensions
178
179
        self._x_pixel_size = None
        self._y_pixel_size = None
180
181
        self._x_magnified_pixel_size = None
        self._y_magnified_pixel_size = None
payno's avatar
payno committed
182
        # pixel dimensions (tuple)
183
184
        self._frames = None
        self._image_keys = None
185
        self._image_keys_control = None
186
        self._rotation_angles = None
payno's avatar
payno committed
187
        self._distance = None
payno's avatar
payno committed
188
        self._fov = None
189
        self._energy = None
payno's avatar
payno committed
190
        self._estimated_cor_frm_motor = None
191
192
        self._start_time = None
        self._end_time = None
193
194
195
        self._x_translations = None
        self._y_translations = None
        self._z_translations = None
196

197
198
199
200
201
202
    @staticmethod
    def get_master_file(scan_path):
        if os.path.isfile(scan_path):
            master_file = scan_path
        else:
            master_file = os.path.join(scan_path, os.path.basename(scan_path))
payno's avatar
payno committed
203
204
205
206
207
208
            if os.path.exists(master_file + ".nx"):
                master_file = master_file + ".nx"
            elif os.path.exists(master_file + ".hdf5"):
                master_file = master_file + ".hdf5"
            elif os.path.exists(master_file + ".h5"):
                master_file = master_file + ".h5"
209
            else:
payno's avatar
payno committed
210
                master_file = master_file + ".nx"
211
212
        return master_file

213
    @docstring(TomoScanBase.clear_caches)
payno's avatar
payno committed
214
    def clear_caches(self) -> None:
payno's avatar
payno committed
215
        super().clear_caches()
216
        self._projections = None
217
        self._projections_compacted = None
218
219
220
221
222
223
224
        self._flats = None
        self._darks = None
        self._tomo_n = None
        self._dark_n = None
        self._ref_n = None
        self._scan_range = None
        self._dim_1, self._dim_2 = None, None
225
226
        self._x_pixel_size = None
        self._y_pixel_size = None
227
228
        self._x_magnified_pixel_size = None
        self._y_magnified_pixel_size = None
229
        self._rotation_angles = None
payno's avatar
payno committed
230
        self._distance = None
payno's avatar
payno committed
231
        self._fov = None
232
        self._image_keys_control = None
233
234
235
        self._x_translations = None
        self._y_translations = None
        self._z_translations = None
236
237

    @staticmethod
payno's avatar
payno committed
238
    def _get_entry_at(index: int, file_path: str) -> str:
239
240
241
242
243
244
        """

        :param index:
        :param file_path:
        :return:
        """
245
        entries = HDF5TomoScan.get_valid_entries(file_path)
246
247
248
249
250
251
        if len(entries) == 0:
            return None
        else:
            return entries[index]

    @staticmethod
252
    def get_valid_entries(file_path: str) -> tuple:
253
254
255
256
257
258
259
260
261
262
263
264
        """
        return the list of 'Nxtomo' entries at the root level

        :param str file_path:
        :return: list of valid Nxtomo node (ordered alphabetically)
        :rtype: tuple

        ..note: entries are sorted to insure consistency
        """
        res = []
        res_buf = []

265
        if not os.path.isfile(file_path):
payno's avatar
payno committed
266
            raise ValueError("given file path should be a file")
267

payno's avatar
payno committed
268
        with HDF5File(file_path, "r", swmr=True) as h5f:
269
270
271
            for root_node in h5f.keys():
                node = h5f[root_node]
                if HDF5TomoScan.node_is_nxtomo(node) is True:
272
                    res_buf.append(root_node)  # cannnot be node because of sym links
273

274
            [res.append(node) for node in res_buf]
275
276
277
278
        res.sort()
        return tuple(res)

    @staticmethod
payno's avatar
payno committed
279
    def node_is_nxtomo(node: h5py.Group) -> bool:
280
        """check if the given h5py node is an nxtomo node or not"""
payno's avatar
payno committed
281
282
        if "NX_class" in node.attrs or "NXclass" in node.attrs:
            _logger.info(node.name + " is recognized as an nx class.")
283
        else:
payno's avatar
payno committed
284
            _logger.info(node.name + " is node an nx class.")
285
            return False
payno's avatar
payno committed
286
287
        if "definition" in node.attrs and node.attrs["definition"].lower() == "nxtomo":
            _logger.info(node.name + " is recognized as an NXtomo class.")
288
            return True
289
290
291
292
293
294
295
        elif (
            "instrument" in node
            and "NX_class" in node["instrument"].attrs
            and node["instrument"].attrs["NX_class"] == "NXinstrument"
        ):
            instrument_node = node["instrument"]
            return "detector" in node["instrument"]
296
297
        else:
            return False
payno's avatar
payno committed
298
299
300

    @docstring(TomoScanBase.is_tomoscan_dir)
    @staticmethod
301
302
303
    def is_tomoscan_dir(directory: str, **kwargs) -> bool:
        if os.path.isfile(directory):
            master_file = directory
payno's avatar
payno committed
304
        else:
305
306
            master_file = HDF5TomoScan.get_master_file(scan_path=directory)
        if master_file:
307
            entries = HDF5TomoScan.get_valid_entries(file_path=master_file)
308
            return len(entries) > 0
payno's avatar
payno committed
309
310
311
312
313
314

    @docstring(TomoScanBase.is_abort)
    def is_abort(self, **kwargs):
        # for now there is no abort definition in .hdf5
        return False

315
316
317
    @docstring(TomoScanBase.to_dict)
    def to_dict(self) -> dict:
        res = super().to_dict()
318
        res[self.DICT_PATH_KEY] = self.master_file
319
320
321
        res[self._DICT_ENTRY_KEY] = self.entry
        return res

payno's avatar
payno committed
322
    @staticmethod
payno's avatar
payno committed
323
    def from_dict(_dict: dict):
payno's avatar
payno committed
324
325
326
327
328
        scan = HDF5TomoScan(scan=None)
        scan.load_from_dict(_dict=_dict)
        return scan

    @docstring(TomoScanBase.load_from_dict)
payno's avatar
payno committed
329
    def load_from_dict(self, _dict: dict) -> TomoScanBase:
payno's avatar
payno committed
330
331
332
333
334
335
336
337
338
        """

        :param _dict:
        :return:
        """
        if isinstance(_dict, io.TextIOWrapper):
            data = json.load(_dict)
        else:
            data = _dict
339
        if not (self.DICT_TYPE_KEY in data and data[self.DICT_TYPE_KEY] == self._TYPE):
payno's avatar
payno committed
340
            raise ValueError("Description is not an HDF5Scan json description")
341
        if HDF5TomoScan._DICT_ENTRY_KEY not in data:
payno's avatar
payno committed
342
            raise ValueError("No hdf5 entry specified")
payno's avatar
payno committed
343

344
        assert self.DICT_PATH_KEY in data
345
        self._entry = data[self._DICT_ENTRY_KEY]
payno's avatar
payno committed
346
347
348
349
350
351
        self.master_file = self.get_master_file(data[self.DICT_PATH_KEY])

        if os.path.isdir(data[self.DICT_PATH_KEY]):
            self.path = data[self.DICT_PATH_KEY]
        else:
            self.path = os.path.dirname(data[self.DICT_PATH_KEY])
payno's avatar
payno committed
352
353
        return self

354
    @property
payno's avatar
payno committed
355
    def entry(self) -> str:
356
357
        return self._entry

payno's avatar
payno committed
358
359
    @property
    @docstring(TomoScanBase.projections)
360
    def projections(self) -> typing.Union[dict, None]:
361
362
        if self._projections is None:
            if self.frames:
Pierre Paleo's avatar
Pierre Paleo committed
363
                ignored_projs = []
Pierre Paleo's avatar
Pierre Paleo committed
364
365
                if self.ignore_projections is not None:
                    ignored_projs = self.ignore_projections
payno's avatar
payno committed
366
367
                proj_frames = tuple(
                    filter(
Pierre Paleo's avatar
Pierre Paleo committed
368
369
370
371
372
                        lambda x: (
                            x.image_key == ImageKey.PROJECTION
                            and x.index not in ignored_projs
                            and x.is_control == False
                        ),
payno's avatar
payno committed
373
374
375
                        self.frames,
                    )
                )
376
377
378
                self._projections = {}
                for proj_frame in proj_frames:
                    self._projections[proj_frame.index] = proj_frame.url
payno's avatar
payno committed
379
380
381
        return self._projections

    @projections.setter
382
    def projections(self, projections: dict):
payno's avatar
payno committed
383
384
        self._projections = projections

payno's avatar
payno committed
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
    @property
    @docstring(TomoScanBase.alignment_projections)
    def alignment_projections(self) -> typing.Union[dict, None]:
        if self._alignment_projections is None:
            if self.frames:
                proj_frames = tuple(
                    filter(
                        lambda x: x.image_key == ImageKey.PROJECTION
                        and x.is_control == True,
                        self.frames,
                    )
                )
                self._alignment_projections = {}
                for proj_frame in proj_frames:
                    self._alignment_projections[proj_frame.index] = proj_frame.url
        return self._alignment_projections

payno's avatar
payno committed
402
403
    @property
    @docstring(TomoScanBase.darks)
404
    def darks(self) -> typing.Union[dict, None]:
405
406
        if self._darks is None:
            if self.frames:
payno's avatar
payno committed
407
408
409
                dark_frames = tuple(
                    filter(lambda x: x.image_key == ImageKey.DARK_FIELD, self.frames)
                )
410
411
412
                self._darks = {}
                for dark_frame in dark_frames:
                    self._darks[dark_frame.index] = dark_frame.url
413
        return self._darks
payno's avatar
payno committed
414
415
416

    @property
    @docstring(TomoScanBase.flats)
417
    def flats(self) -> typing.Union[dict, None]:
418
419
        if self._flats is None:
            if self.frames:
payno's avatar
payno committed
420
421
422
                flat_frames = tuple(
                    filter(lambda x: x.image_key == ImageKey.FLAT_FIELD, self.frames)
                )
423
424
425
                self._flats = {}
                for flat_frame in flat_frames:
                    self._flats[flat_frame.index] = flat_frame.url
426
        return self._flats
payno's avatar
payno committed
427
428

    @docstring(TomoScanBase.update)
payno's avatar
payno committed
429
    def update(self) -> None:
payno's avatar
payno committed
430
        """update list of radio and reconstruction by parsing the scan folder"""
431
        if self.master_file is None or not os.path.exists(self.master_file):
payno's avatar
payno committed
432
            return
payno's avatar
payno committed
433
        self.projections = self._get_projections_url()
payno's avatar
payno committed
434
435
        # TODO: update darks and flats too

payno's avatar
payno committed
436
    @docstring(TomoScanBase.get_proj_angle_url)
payno's avatar
payno committed
437
438
439
    def _get_projections_url(self):
        if self.master_file is None or not os.path.exists(self.master_file):
            return
440
441
        frames = self.frames
        if frames is not None:
442
            urls = {}
443
444
445
            for frame in frames:
                if frame.image_key is ImageKey.PROJECTION:
                    urls[frame.index] = frame.url
payno's avatar
payno committed
446
            return urls
447
448
        else:
            return None
payno's avatar
payno committed
449
450
451

    @docstring(TomoScanBase.tomo_n)
    @property
payno's avatar
payno committed
452
    def tomo_n(self) -> typing.Union[None, int]:
453
454
455
456
        """we are making two asumptions for computing tomo_n:
        - if a rotation = scan_range +/- EPSILON this is a return projection
        - The delta between each projections is constant
        """
payno's avatar
payno committed
457
458
459
460
461
        if (
            self._tomo_n is None
            and self.master_file
            and os.path.exists(self.master_file)
        ):
462
            if self.projections:
463
                return len(self.projections)
464
465
466
467
468
469
            else:
                return None
        else:
            return None

    @property
payno's avatar
payno committed
470
    def return_projs(self) -> typing.Union[None, list]:
471
472
473
        """"""
        frames = self.frames
        if frames:
474
            return_frames = list(filter(lambda x: x.is_control == True, frames))
475
476
477
478
479
            return return_frames
        else:
            return None

    @property
480
    def rotation_angle(self) -> typing.Union[None, tuple]:
481
        if self._rotation_angles is None:
482
            self._check_hdf5scan_validity()
payno's avatar
payno committed
483
            with HDF5File(self.master_file, "r", swmr=True) as h5_file:
484
485
486
                _rotation_angles = h5py_read_dataset(
                    h5_file[self._entry][self._ROTATION_ANGLE_PATH]
                )
487
                # cast in float
payno's avatar
payno committed
488
489
490
                self._rotation_angles = tuple(
                    [float(angle) for angle in _rotation_angles]
                )
491
492
        return self._rotation_angles

493
494
495
496
497
498
499
    @property
    def x_translation(self) -> typing.Union[None, tuple]:
        if self._x_translations is None:
            self._check_hdf5scan_validity()
            with HDF5File(self.master_file, "r", swmr=True) as h5_file:
                _translations = h5_file[self._entry][self._X_TRANS_PATH][()]
                # cast in float
500
                self._x_translations = tuple([float(trans) for trans in _translations])
501
502
503
504
505
506
507
508
509
        return self._x_translations

    @property
    def y_translation(self) -> typing.Union[None, tuple]:
        if self._y_translations is None:
            self._check_hdf5scan_validity()
            with HDF5File(self.master_file, "r", swmr=True) as h5_file:
                _translations = h5_file[self._entry][self._Y_TRANS_PATH][()]
                # cast in float
510
                self._y_translations = tuple([float(trans) for trans in _translations])
511
512
513
514
515
516
517
518
519
        return self._y_translations

    @property
    def z_translation(self) -> typing.Union[None, tuple]:
        if self._z_translations is None:
            self._check_hdf5scan_validity()
            with HDF5File(self.master_file, "r", swmr=True) as h5_file:
                _translations = h5_file[self._entry][self._Z_TRANS_PATH][()]
                # cast in float
520
                self._z_translations = tuple([float(trans) for trans in _translations])
521
522
        return self._z_translations

523
    @property
payno's avatar
payno committed
524
    def image_key(self) -> typing.Union[list, None]:
525
526
        if self._entry and self._image_keys is None:
            self._check_hdf5scan_validity()
payno's avatar
payno committed
527
            with HDF5File(self.master_file, "r", swmr=True) as h5_file:
528
529
530
                self._image_keys = h5py_read_dataset(
                    h5_file[self._entry][self._IMG_KEY_PATH]
                )
531
        return self._image_keys
payno's avatar
payno committed
532

533
534
535
536
    @property
    def image_key_control(self) -> typing.Union[list, None]:
        if self._entry and self._image_keys_control is None:
            self._check_hdf5scan_validity()
payno's avatar
payno committed
537
            with HDF5File(self.master_file, "r", swmr=True) as h5_file:
538
                if self._IMG_KEY_CONTROL_PATH in h5_file[self._entry]:
539
540
541
                    self._image_keys_control = h5py_read_dataset(
                        h5_file[self._entry][self._IMG_KEY_CONTROL_PATH]
                    )
542
543
544
545
                else:
                    self._image_keys_control = None
        return self._image_keys_control

payno's avatar
payno committed
546
547
    @docstring(TomoScanBase.dark_n)
    @property
payno's avatar
payno committed
548
    def dark_n(self) -> typing.Union[None, int]:
549
550
551
552
        if self.darks is not None:
            return len(self.darks)
        else:
            return None
payno's avatar
payno committed
553
554
555

    @docstring(TomoScanBase.ref_n)
    @property
payno's avatar
payno committed
556
    def ref_n(self) -> typing.Union[None, int]:
557
558
559
560
        if self.flats is not None:
            return len(self.flats)
        else:
            return None
payno's avatar
payno committed
561

payno's avatar
payno committed
562
    @docstring(TomoScanBase.ff_interval)
payno's avatar
payno committed
563
    @property
payno's avatar
payno committed
564
    def ff_interval(self):
payno's avatar
payno committed
565
566
567
        raise NotImplementedError(
            "not implemented for hdf5. But we have " "acquisition sequence instead."
        )
payno's avatar
payno committed
568
569
570

    @docstring(TomoScanBase.scan_range)
    @property
payno's avatar
payno committed
571
    def scan_range(self) -> typing.Union[None, int]:
572
        """For now scan range should return 180 or 360. We don't expect other value."""
payno's avatar
payno committed
573
574
575
576
577
578
        if (
            self._scan_range is None
            and self.master_file
            and os.path.exists(self.master_file)
            and self._entry is not None
        ):
579
580
581
582
583
584
585
586
            rotation_angle = self.rotation_angle
            if rotation_angle is not None:
                dist_to180 = abs(180 - numpy.max(rotation_angle))
                dist_to360 = abs(360 - numpy.max(rotation_angle))
                if dist_to180 < dist_to360:
                    self._scan_range = 180
                else:
                    self._scan_range = 360
payno's avatar
payno committed
587
588
589
        return self._scan_range

    @property
payno's avatar
payno committed
590
    def dim_1(self) -> typing.Union[None, int]:
591
592
        if self._dim_1 is None:
            self._get_dim1_dim2()
payno's avatar
payno committed
593
594
595
        return self._dim_1

    @property
payno's avatar
payno committed
596
    def dim_2(self) -> typing.Union[None, int]:
597
598
        if self._dim_2 is None:
            self._get_dim1_dim2()
payno's avatar
payno committed
599
600
601
        return self._dim_2

    @property
payno's avatar
payno committed
602
    def pixel_size(self) -> typing.Union[None, float]:
603
        """return x pixel size in meter"""
604
605
606
        return self.x_pixel_size

    @property
payno's avatar
payno committed
607
    def x_pixel_size(self) -> typing.Union[None, float]:
608
        """return x pixel size in meter"""
payno's avatar
payno committed
609
610
611
612
613
        if (
            self._x_pixel_size is None
            and self.master_file
            and os.path.exists(self.master_file)
        ):
614
615
616
617
618
619
            self._x_pixel_size, self._y_pixel_size = self._get_x_y_pixel_values()

        return self._x_pixel_size

    def _get_x_y_pixel_values(self):
        """read x and y pixel values"""
payno's avatar
payno committed
620
        with HDF5File(self.master_file, "r", swmr=True) as h5_file:
621
            x_pixel_dataset = h5_file[self._entry][self._X_PIXEL_SIZE_PATH]
payno's avatar
payno committed
622
            _x_pixel_size = self._get_value(x_pixel_dataset, default_unit="meter")
623
            y_pixel_dataset = h5_file[self._entry][self._Y_PIXEL_SIZE_PATH]
payno's avatar
payno committed
624
            _y_pixel_size = self._get_value(y_pixel_dataset, default_unit="meter")
625
626
        return _x_pixel_size, _y_pixel_size

627
    def _get_x_y_magnified_pixel_values(self):
payno's avatar
payno committed
628
        with HDF5File(self.master_file, "r", swmr=True) as h5_file:
629
            x_m_pixel_dataset = h5_file[self._entry][self._X_PIXEL_MAG_SIZE_PATH]
payno's avatar
payno committed
630
            _x_m_pixel_size = self._get_value(x_m_pixel_dataset, default_unit="meter")
631
            y_m_pixel_dataset = h5_file[self._entry][self._Y_PIXEL_MAG_SIZE_PATH]
payno's avatar
payno committed
632
            _y_m_pixel_size = self._get_value(y_m_pixel_dataset, default_unit="meter")
633
634
        return _x_m_pixel_size, _y_m_pixel_size

payno's avatar
payno committed
635
    def _get_fov(self):
payno's avatar
payno committed
636
        with HDF5File(self.master_file, "r", swmr=True, libver="latest") as h5_file:
payno's avatar
payno committed
637
            if self._FOV_PATH in h5_file[self._entry]:
638
                fov = h5py_read_dataset(h5_file[self._entry][self._FOV_PATH])
payno's avatar
payno committed
639
640
641
642
                return _FOV.from_value(fov)
            else:
                return None

payno's avatar
payno committed
643
644
645
    def _get_estimated_cor_frm_motor(self):
        with HDF5File(self.master_file, "r", swmr=True, libver="latest") as h5_file:
            if self._ESTIMATED_COR_FRM_MOTOR_PATH in h5_file[self._entry]:
646
647
648
                value = h5py_read_dataset(
                    h5_file[self._entry][self._ESTIMATED_COR_FRM_MOTOR_PATH]
                )
payno's avatar
payno committed
649
650
651
652
                return float(value)
            else:
                return None

653
654
655
656
    def _get_dim1_dim2(self):
        if self.master_file and os.path.exists(self.master_file):
            if self.projections is not None:
                if len(self.projections) > 0:
payno's avatar
payno committed
657
658
659
                    self._dim_2, self._dim_1 = get_data(
                        list(self.projections.values())[0]
                    ).shape
660

661
    @property
payno's avatar
payno committed
662
    def y_pixel_size(self) -> typing.Union[None, float]:
663
        """return y pixel size in meter"""
payno's avatar
payno committed
664
665
666
667
668
        if (
            self._y_pixel_size is None
            and self.master_file
            and os.path.exists(self.master_file)
        ):
669
670
            self._x_pixel_size, self._y_pixel_size = self._get_x_y_pixel_values()
        return self._y_pixel_size
payno's avatar
payno committed
671

672
673
    @property
    def x_magnified_pixel_size(self) -> typing.Union[None, float]:
674
        """return x magnified pixel size in meter"""
payno's avatar
payno committed
675
676
677
678
679
680
681
682
683
        if (
            self._x_magnified_pixel_size is None
            and self.master_file
            and os.path.exists(self.master_file)
        ):
            (
                self._x_magnified_pixel_size,
                self._y_magnified_pixel_size,
            ) = self._get_x_y_magnified_pixel_values()
684
685
686
687
        return self._x_magnified_pixel_size

    @property
    def y_magnified_pixel_size(self) -> typing.Union[None, float]:
688
        """return y magnified pixel size in meter"""
payno's avatar
payno committed
689
690
691
692
693
694
695
696
697
        if (
            self._y_magnified_pixel_size is None
            and self.master_file
            and os.path.exists(self.master_file)
        ):
            (
                self._x_magnified_pixel_size,
                self._y_magnified_pixel_size,
            ) = self._get_x_y_magnified_pixel_values()
698
699
        return self._y_magnified_pixel_size

payno's avatar
payno committed
700
    @property
payno's avatar
payno committed
701
    def distance(self) -> typing.Union[None, float]:
702
        """return sample detector distance in meter"""
payno's avatar
payno committed
703
704
705
706
707
        if (
            self._distance is None
            and self.master_file
            and os.path.exists(self.master_file)
        ):
708
            self._check_hdf5scan_validity()
payno's avatar
payno committed
709
            with HDF5File(self.master_file, "r", swmr=True) as h5_file:
payno's avatar
payno committed
710
                distance_dataset = h5_file[self._entry][self._DISTANCE_PATH]
payno's avatar
payno committed
711
                self._distance = self._get_value(distance_dataset, default_unit="m")
payno's avatar
payno committed
712
713
        return self._distance

payno's avatar
payno committed
714
    @property
payno's avatar
payno committed
715
    @docstring(TomoScanBase.field_of_view)
payno's avatar
payno committed
716
    def field_of_view(self):
payno's avatar
payno committed
717
        if self._fov is None and self.master_file and os.path.exists(self.master_file):
payno's avatar
payno committed
718
719
720
            self._fov = self._get_fov()
        return self._fov

payno's avatar
payno committed
721
722
723
724
725
726
727
728
729
730
731
    @property
    @docstring(TomoScanBase.estimated_cor_frm_motor)
    def estimated_cor_frm_motor(self):
        if (
            self._estimated_cor_frm_motor is None
            and self.master_file
            and os.path.exists(self.master_file)
        ):
            self._estimated_cor_frm_motor = self._get_estimated_cor_frm_motor()
        return self._estimated_cor_frm_motor

732
733
    @property
    def energy(self) -> typing.Union[None, float]:
734
        """energy in keV"""
payno's avatar
payno committed
735
736
737
738
739
        if (
            self._energy is None
            and self.master_file
            and os.path.exists(self.master_file)
        ):
740
            self._check_hdf5scan_validity()
payno's avatar
payno committed
741
            with HDF5File(self.master_file, "r", swmr=True) as h5_file:
742
                energy_dataset = h5_file[self._entry][self._ENERGY_PATH]
payno's avatar
payno committed
743
                self._energy = self._get_value(energy_dataset, default_unit="keV")
744
745
        return self._energy

746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
    @property
    def start_time(self):
        if self._start_time is None and self.master_file and os.path.exists:
            self._check_hdf5scan_validity()
            with HDF5File(self.master_file, "r", swmr=True) as h5_file:
                if self._START_TIME_PATH in h5_file[self._entry]:
                    self._start_time = h5py_read_dataset(
                        h5_file[self._entry][self._START_TIME_PATH]
                    )
        return self._start_time

    @property
    def end_time(self):
        if self._end_time is None and self.master_file and os.path.exists:
            self._check_hdf5scan_validity()
            with HDF5File(self.master_file, "r", swmr=True) as h5_file:
                if self._END_TIME_START in h5_file[self._entry]:
                    self._end_time = h5py_read_dataset(
                        h5_file[self._entry][self._END_TIME_START]
                    )
        return self._end_time

768
    @property
payno's avatar
payno committed
769
    def frames(self) -> typing.Union[None, tuple]:
770
771
772
773
        """return tuple of frames. Frames contains """
        if self._frames is None:
            image_keys = self.image_key
            rotation_angles = self.rotation_angle
774
775
776
            x_translation = self.x_translation
            y_translation = self.y_translation
            z_translation = self.z_translation
777
            if len(image_keys) != len(rotation_angles):
payno's avatar
payno committed
778
779
780
781
782
                raise ValueError(
                    "`rotation_angle` and `image_key` have "
                    "incoherent size (%s vs %s). Unable to "
                    "deduce frame properties" % (len(rotation_angles), len(image_keys))
                )
783
784
            self._frames = []

payno's avatar
payno committed
785
786
787
            def is_return(
                lframe, llast_proj_frame, ldelta_angle, return_already_reach
            ) -> tuple:
788
789
790
791
                """return is_return, delta_angle"""
                if ImageKey.from_value(img_key) is not ImageKey.PROJECTION:
                    return False, None
                if ldelta_angle is None and llast_proj_frame is not None:
payno's avatar
payno committed
792
793
794
                    delta_angle = (
                        lframe.rotation_angle - llast_proj_frame.rotation_angle
                    )
795
796
797
798
                    return False, delta_angle
                elif return_already_reach:
                    return True, ldelta_angle
                else:
payno's avatar
payno committed
799
800
801
802
                    current_angle = (
                        lframe.rotation_angle - llast_proj_frame.rotation_angle
                    )
                    return abs(current_angle) <= 2 * ldelta_angle, ldelta_angle
803
804
805
806

            delta_angle = None
            last_proj_frame = None
            return_already_reach = False
807
            for i_frame, rot_a, img_key, x_tr, y_tr, z_tr in zip(
808
809
810
811
812
813
                range(len(rotation_angles)),
                rotation_angles,
                image_keys,
                x_translation,
                y_translation,
                z_translation,
payno's avatar
payno committed
814
815
816
817
818
819
820
821
822
            ):
                url = DataUrl(
                    file_path=self.master_file,
                    data_slice=(i_frame),
                    data_path=self.entry + "/instrument/detector/data",
                    scheme="silx",
                )

                frame = Frame(
823
824
825
826
827
828
829
                    index=i_frame,
                    url=url,
                    image_key=img_key,
                    rotation_angle=rot_a,
                    x_translation=x_tr,
                    y_translation=y_tr,
                    z_translation=z_tr,
payno's avatar
payno committed
830
                )
831
                if self.image_key_control is not None:
payno's avatar
payno committed
832
833
834
                    is_control_frame = (
                        self.image_key_control[frame.index] == ImageKey.ALIGNMENT.value
                    )
835
                else:
payno's avatar
payno committed
836
837
838
839
840
841
                    return_already_reach, delta_angle = is_return(
                        lframe=frame,
                        llast_proj_frame=last_proj_frame,
                        ldelta_angle=delta_angle,
                        return_already_reach=return_already_reach,
                    )
842
                    is_control_frame = return_already_reach
843
                frame._is_control_frame = is_control_frame
844
845
846
847
848
                self._frames.append(frame)
                last_proj_frame = frame
            self._frames = tuple(self._frames)
        return self._frames

payno's avatar
payno committed
849
    @docstring(TomoScanBase.get_proj_angle_url)
payno's avatar
payno committed
850
    def get_proj_angle_url(self) -> typing.Union[dict, None]:
851
852
853
854
        if self.frames is not None:
            res = {}
            for frame in self.frames:
                if frame.image_key is ImageKey.PROJECTION:
855
                    if frame.is_control is False:
856
857
                        res[frame.rotation_angle] = frame.url
                    else:
payno's avatar
payno committed
858
                        res[str(frame.rotation_angle) + "(1)"] = frame.url
859
860
861
            return res
        else:
            return None
862

863
864
865
866
867
    @property
    def projections_compacted(self):
        """
        Return a compacted view of projection frames.

payno's avatar
payno committed
868
        :return: Dictionary where the key is a list of indices, and the value
869
            is the corresponding `silx.io.url.DataUrl` with merged data_slice
payno's avatar
payno committed
870
        :rtype: dict
871
872
        """
        if self._projections_compacted is None:
873
            self._projections_compacted = get_compacted_dataslices(self.projections)
874
875
        return self._projections_compacted

876
    def __str__(self):
payno's avatar
payno committed
877
878
879
880
881
        return "hdf5 scan(path: %s, master_file: %s, entry: %s)" % (
            self.path,
            self.master_file,
            self.entry,
        )
882

883
884
885
886
887
888
    @staticmethod
    def _get_value(node: h5py.Group, default_unit: str):
        """convert the value contained in the node to the adapted unit.
        Unit can be defined in on of the group attributes. It it is the case
        will pick this unit, otherwise will use the default unit
        """
889
        value = h5py_read_dataset(node)
payno's avatar
payno committed
890
891
892
893
        if "unit" in node.attrs:
            unit = node.attrs["unit"]
        elif "units" in node.attrs:
            unit = node.attrs["units"]
894
895
        else:
            unit = default_unit
896
        return value * metricsystem.MetricSystem.from_value(unit).value
897

898
899
    def _check_hdf5scan_validity(self):
        if self.master_file is None:
payno's avatar
payno committed
900
            raise ValueError("No master file provided")
901
        if self.entry is None:
payno's avatar
payno committed
902
903
            raise ValueError("No entry provided")
        with HDF5File(self.master_file, "r", swmr=True) as h5_file:
904
            if self._entry not in h5_file:
payno's avatar
payno committed
905
906
907
908
                raise ValueError(
                    "Given entry %s is not in the master "
                    "file %s" % (self._entry, self.master_file)
                )
909

910
911
912

class Frame:
    """class to store all metadata information of a frame"""
payno's avatar
payno committed
913
914
915
916
917
918
919
920

    def __init__(
        self,
        index: int,
        url: typing.Union[None, DataUrl] = None,
        image_key: typing.Union[None, ImageKey, int] = None,
        rotation_angle: typing.Union[None, float] = None,
        is_control_proj: bool = False,
921
        x_translation: typing.Union[None, float] = None,
922
923
        y_translation: typing.Union[None, float] = None,
        z_translation: typing.Union[None, float] = None,
payno's avatar
payno committed
924
    ):
925
926
927
928
929
        assert type(index) is int
        self._index = index
        self._image_key = ImageKey.from_value(image_key)
        self._rotation_angle = rotation_angle
        self._url = url
930
        self._is_control_frame = is_control_proj
931
        self._data = None
932
933
934
        self._x_translation = x_translation
        self._y_translation = y_translation
        self._z_translation = z_translation
935
936

    @property
payno's avatar
payno committed
937
    def index(self) -> int:
938
939
940
        return self._index

    @property
payno's avatar
payno committed
941
    def image_key(self) -> ImageKey:
942
943
944
        return self._image_key

    @image_key.setter
payno's avatar
payno committed
945
    def image_key(self, image_key: ImageKey) -> None:
946
947
948
949
950
951
952
        self._image_key = image_key

    @property
    def rotation_angle(self) -> float:
        return self._rotation_angle

    @rotation_angle.setter
payno's avatar
payno committed
953
    def rotation_angle(self, angle: float) -> None:
954
955
956
        self._rotation_angle = angle

    @property
payno's avatar
payno committed
957
    def url(self) -> DataUrl:
958
959
960
        return self._url

    @property
961
962
    def is_control(self) -> bool:
        return self._is_control_frame
963

964
965
966
967
968
969
970
971
972
973
974
975
    @property
    def x_translation(self):
        return self._x_translation

    @property
    def y_translation(self):
        return self._y_translation

    @property
    def z_translation(self):
        return self._z_translation

976
977
978
    @is_control.setter
    def is_control(self, is_return: bool):
        self._is_control_frame = is_return
payno's avatar
payno committed
979
980
981
982
983
984

    def __str__(self):
        return (
            "Frame {index},: image_key: {image_key},"
            "is_control: {is_control},"
            "rotation_angle: {rotation_angle},"
985
986
987
            "x_translation: {x_translation},"
            "y_translation: {y_translation},"
            "z_translation: {z_translation},"
payno's avatar
payno committed
988
989
990
991
992
993
            "url: {url}".format(
                index=self.index,
                image_key=self.image_key,
                is_control=self.is_control,
                rotation_angle=self.rotation_angle,
                url=self.url.path(),
994
995
996
                x_translation=self.x_translation,
                y_translation=self.y_translation,
                z_translation=self.z_translation,
payno's avatar
payno committed
997
998
            )
        )