hdf5scan.py 25.8 KB
Newer Older
payno's avatar
payno committed
1
2
# coding: utf-8
#/*##########################################################################
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
25
26
27
28
29
30
31
32
33
34
35
36
37
#
# 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.
#
#############################################################################*/


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


from ..scanbase import TomoScanBase
import json
import io
import os
import h5py
import numpy
from silx.io.url import DataUrl
38
from silx.utils.enum import Enum as _Enum
payno's avatar
payno committed
39
from tomoscan.utils import docstring
40
from silx.io.utils import get_data
41
from ..unitsystem import metricsystem
payno's avatar
payno committed
42
import logging
43
import typing
payno's avatar
payno committed
44
45
46
47

_logger = logging.getLogger(__name__)


48
class ImageKey(_Enum):
49
50
51
    ALIGNMENT = -1
    PROJECTION = 0
    FLAT_FIELD = 1
52
53
54
55
    DARK_FIELD = 2
    INVALID = 3


payno's avatar
payno committed
56
57
58
59
60
61
62
63
64
65
66
67
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
68
69
70
71
72
    :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
73
74
75
    """
    _TYPE = 'hdf5'

76
    _DICT_ENTRY_KEY = 'entry'
payno's avatar
payno committed
77

78
    _PROJ_PATH = 'instrument/detector/data'
payno's avatar
payno committed
79
80
81
82
83

    _SCAN_META_PATH = 'scan_meta/technique/scan'

    _DET_META_PATH = 'scan_meta/technique/detector'

84
85
86
87
    _ROTATION_ANGLE_PATH = 'sample/rotation_angle'

    _IMG_KEY_PATH = 'instrument/detector/image_key'

88
89
    _IMG_KEY_CONTROL_PATH = 'instrument/detector/image_key_control'

90
91
92
93
    _X_PIXEL_SIZE_PATH = 'instrument/detector/x_pixel_size'

    _Y_PIXEL_SIZE_PATH = 'instrument/detector/y_pixel_size'

94
95
96
97
    _X_PIXEL_MAG_SIZE_PATH = 'instrument/detector/x_magnified_pixel_size'

    _Y_PIXEL_MAG_SIZE_PATH = 'instrument/detector/y_magnified_pixel_size'

payno's avatar
payno committed
98
99
    _DISTANCE_PATH = 'instrument/detector/distance'

100
101
    _ENERGY_PATH = 'beam/incident_energy'

payno's avatar
payno committed
102
103
    _SCHEME = 'silx'

104
105
    _EPSILON_ROT_ANGLE = 0.02

payno's avatar
payno committed
106
107
    def __init__(self, scan: str, entry: str = None,
                 index: typing.Union[int, None] = 0):
108
109
        if entry is not None:
            index = None
payno's avatar
payno committed
110
111
112
113
114
115
        # if the user give the master file instead of the scan dir...
        if scan is not None:
            if os.path.isfile(scan):
                self.master_file = scan
                scan = os.path.dirname(scan)
            else:
116
117
                self.master_file = self.get_master_file(scan)

payno's avatar
payno committed
118
                self.master_file = os.path.join(scan, os.path.basename(scan))
119
120
121
                if os.path.exists(self.master_file + '.nx'):
                    self.master_file = self.master_file + '.nx'
                elif os.path.exists(self.master_file + '.hdf5'):
payno's avatar
payno committed
122
                    self.master_file = self.master_file + '.hdf5'
123
                elif os.path.exists(self.master_file + '.h5'):
payno's avatar
payno committed
124
                    self.master_file = self.master_file + '.h5'
125
126
                else:
                    self.master_file = self.master_file + '.nx'
payno's avatar
payno committed
127
128
129
130
131
        else:
            self.master_file = None

        super(HDF5TomoScan, self).__init__(scan=scan, type_=HDF5TomoScan._TYPE)

132
133
134
135
136
        if scan is None:
            self._entry = None
        else:
            self._entry = entry or self._get_entry_at(index=index,
                                                      file_path=self.master_file)
137
138
            if self._entry is None:
                raise ValueError('unable to find a valid entry for %s' % self.master_file)
payno's avatar
payno committed
139
140
141
        # for now the default entry is 1_tomo but should change with time

        # data caches
payno's avatar
payno committed
142
        self._projections = None
143
144
        self._flats = None
        self._darks = None
payno's avatar
payno committed
145
146
147
148
149
150
151
152
153
154
        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
155
156
        self._x_pixel_size = None
        self._y_pixel_size = None
payno's avatar
payno committed
157
        # pixel dimensions (tuple)
158
159
        self._frames = None
        self._image_keys = None
160
        self._image_keys_control = None
161
        self._rotation_angles = None
payno's avatar
payno committed
162
        self._distance = None
163
        self._energy = None
164

165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
    @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))
            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'
            else:
                master_file = master_file + '.nx'
        return master_file

181
    @docstring(TomoScanBase.clear_caches)
payno's avatar
payno committed
182
    def clear_caches(self) -> None:
183
184
185
186
187
188
189
190
        self._projections = None
        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
191
192
        self._x_pixel_size = None
        self._y_pixel_size = None
193
194
        self._x_magnified_pixel_size = None
        self._y_magnified_pixel_size = None
195
        self._rotation_angles = None
payno's avatar
payno committed
196
        self._distance = None
197
        self._image_keys_control = None
198
199

    @staticmethod
payno's avatar
payno committed
200
    def _get_entry_at(index: int, file_path: str) -> str:
201
202
203
204
205
206
        """

        :param index:
        :param file_path:
        :return:
        """
207
        entries = HDF5TomoScan.get_valid_entries(file_path)
208
209
210
211
212
213
        if len(entries) == 0:
            return None
        else:
            return entries[index]

    @staticmethod
214
    def get_valid_entries(file_path: str) -> tuple:
215
216
217
218
219
220
221
222
223
224
225
226
        """
        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 = []

227
228
229
        if not os.path.isfile(file_path):
            raise ValueError('given file path should be a file')

230
231
232
233
        with h5py.File(file_path, 'r') as h5f:
            for root_node in h5f.keys():
                node = h5f[root_node]
                if HDF5TomoScan.node_is_nxtomo(node) is True:
234
                    res_buf.append(root_node)  # cannnot be node because of sym links
235

236
            [res.append(node) for node in res_buf]
237
238
239
240
        res.sort()
        return tuple(res)

    @staticmethod
payno's avatar
payno committed
241
    def node_is_nxtomo(node: h5py.Group) -> bool:
242
243
244
245
246
247
248
249
250
251
252
        """check if the given h5py node is an nxtomo node or not"""
        if 'NX_class' in node.attrs or 'NXclass' in node.attrs:
            _logger.info(node.name + ' is recognized as an nx class.')
        else:
            _logger.info(node.name + ' is node an nx class.')
            return False
        if 'definition' in node.attrs and node.attrs['definition'].lower() == 'nxtomo':
            _logger.info(node.name + ' is recognized as an NXtomo class.')
            return True
        else:
            return False
payno's avatar
payno committed
253
254
255

    @docstring(TomoScanBase.is_tomoscan_dir)
    @staticmethod
256
257
258
    def is_tomoscan_dir(directory: str, **kwargs) -> bool:
        if os.path.isfile(directory):
            master_file = directory
payno's avatar
payno committed
259
        else:
260
261
            master_file = HDF5TomoScan.get_master_file(scan_path=directory)
        if master_file:
262
            entries = HDF5TomoScan.get_valid_entries(file_path=master_file)
263
            return len(entries) > 0
payno's avatar
payno committed
264
265
266
267
268
269

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

270
271
272
273
274
275
    @docstring(TomoScanBase.to_dict)
    def to_dict(self) -> dict:
        res = super().to_dict()
        res[self._DICT_ENTRY_KEY] = self.entry
        return res

payno's avatar
payno committed
276
    @staticmethod
payno's avatar
payno committed
277
    def from_dict(_dict: dict):
payno's avatar
payno committed
278
279
280
281
282
        scan = HDF5TomoScan(scan=None)
        scan.load_from_dict(_dict=_dict)
        return scan

    @docstring(TomoScanBase.load_from_dict)
payno's avatar
payno committed
283
    def load_from_dict(self, _dict: dict) -> TomoScanBase:
payno's avatar
payno committed
284
285
286
287
288
289
290
291
292
        """

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

298
299
        assert self.DICT_PATH_KEY in data
        self.path = data[self.DICT_PATH_KEY]
300
        self._entry = data[self._DICT_ENTRY_KEY]
payno's avatar
payno committed
301
302
        return self

303
    @property
payno's avatar
payno committed
304
    def entry(self) -> str:
305
306
        return self._entry

payno's avatar
payno committed
307
308
    @property
    @docstring(TomoScanBase.projections)
309
    def projections(self) -> typing.Union[dict, None]:
310
311
312
        """projections / radio, does not include the return projections"""
        if self._projections is None:
            if self.frames:
313
                proj_frames = tuple(filter(lambda x: x.image_key == ImageKey.PROJECTION and x.is_control == False, self.frames))
314
315
316
                self._projections = {}
                for proj_frame in proj_frames:
                    self._projections[proj_frame.index] = proj_frame.url
payno's avatar
payno committed
317
318
319
        return self._projections

    @projections.setter
320
    def projections(self, projections: dict):
payno's avatar
payno committed
321
322
323
324
        self._projections = projections

    @property
    @docstring(TomoScanBase.darks)
325
    def darks(self) -> typing.Union[dict, None]:
326
327
        if self._darks is None:
            if self.frames:
328
                dark_frames = tuple(filter(lambda x: x.image_key == ImageKey.DARK_FIELD, self.frames))
329
330
331
                self._darks = {}
                for dark_frame in dark_frames:
                    self._darks[dark_frame.index] = dark_frame.url
332
        return self._darks
payno's avatar
payno committed
333
334
335

    @property
    @docstring(TomoScanBase.flats)
336
    def flats(self) -> typing.Union[dict, None]:
337
338
        if self._flats is None:
            if self.frames:
339
                flat_frames = tuple(filter(lambda x: x.image_key == ImageKey.FLAT_FIELD, self.frames))
340
341
342
                self._flats = {}
                for flat_frame in flat_frames:
                    self._flats[flat_frame.index] = flat_frame.url
343
        return self._flats
payno's avatar
payno committed
344
345

    @docstring(TomoScanBase.update)
payno's avatar
payno committed
346
    def update(self) -> None:
payno's avatar
payno committed
347
348
        """update list of radio and reconstruction by parsing the scan folder
        """
349
        if self.master_file is None or not os.path.exists(self.master_file):
payno's avatar
payno committed
350
            return
payno's avatar
payno committed
351
        self.projections = self._get_projections_url()
payno's avatar
payno committed
352
353
        # TODO: update darks and flats too

payno's avatar
payno committed
354
    @docstring(TomoScanBase.get_proj_angle_url)
payno's avatar
payno committed
355
356
357
    def _get_projections_url(self):
        if self.master_file is None or not os.path.exists(self.master_file):
            return
358
359
        frames = self.frames
        if frames is not None:
360
            urls = {}
361
362
363
            for frame in frames:
                if frame.image_key is ImageKey.PROJECTION:
                    urls[frame.index] = frame.url
payno's avatar
payno committed
364
            return urls
365
366
        else:
            return None
payno's avatar
payno committed
367
368
369

    @docstring(TomoScanBase.tomo_n)
    @property
payno's avatar
payno committed
370
    def tomo_n(self) -> typing.Union[None, int]:
371
372
373
374
        """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
375
376
        if (self._tomo_n is None and self.master_file and
                os.path.exists(self.master_file)):
377
            if self.projections:
378
                return len(self.projections)
379
380
381
382
383
384
            else:
                return None
        else:
            return None

    @property
payno's avatar
payno committed
385
    def return_projs(self) -> typing.Union[None, list]:
386
387
388
        """"""
        frames = self.frames
        if frames:
389
            return_frames = list(filter(lambda x: x.is_control == True, frames))
390
391
392
393
394
            return return_frames
        else:
            return None

    @property
payno's avatar
payno committed
395
    def rotation_angle(self) -> typing.Union[None, list]:
396
        if self._rotation_angles is None:
397
            self._check_hdf5scan_validity()
398
            with h5py.File(self.master_file, 'r') as h5_file:
399
400
401
402
403
404
                _rotation_angles = h5_file[self._entry][self._ROTATION_ANGLE_PATH][()]
                # cast in float
                self._rotation_angles = tuple([float(angle) for angle in _rotation_angles])
        return self._rotation_angles

    @property
payno's avatar
payno committed
405
    def image_key(self) -> typing.Union[list, None]:
406
407
        if self._entry and self._image_keys is None:
            self._check_hdf5scan_validity()
408
409
410
            with h5py.File(self.master_file, 'r') as h5_file:
                self._image_keys = h5_file[self._entry][self._IMG_KEY_PATH][()]
        return self._image_keys
payno's avatar
payno committed
411

412
413
414
415
416
417
418
419
420
421
422
    @property
    def image_key_control(self) -> typing.Union[list, None]:
        if self._entry and self._image_keys_control is None:
            self._check_hdf5scan_validity()
            with h5py.File(self.master_file, 'r') as h5_file:
                if self._IMG_KEY_CONTROL_PATH in h5_file[self._entry]:
                    self._image_keys_control = h5_file[self._entry][self._IMG_KEY_CONTROL_PATH][()]
                else:
                    self._image_keys_control = None
        return self._image_keys_control

payno's avatar
payno committed
423
424
    @docstring(TomoScanBase.dark_n)
    @property
payno's avatar
payno committed
425
    def dark_n(self) -> typing.Union[None, int]:
426
427
428
429
        if self.darks is not None:
            return len(self.darks)
        else:
            return None
payno's avatar
payno committed
430
431
432

    @docstring(TomoScanBase.ref_n)
    @property
payno's avatar
payno committed
433
    def ref_n(self) -> typing.Union[None, int]:
434
435
436
437
        if self.flats is not None:
            return len(self.flats)
        else:
            return None
payno's avatar
payno committed
438

payno's avatar
payno committed
439
    @docstring(TomoScanBase.ff_interval)
payno's avatar
payno committed
440
    @property
payno's avatar
payno committed
441
    def ff_interval(self):
442
443
        raise NotImplementedError('not implemented for hdf5. But we have '
                                  'acquisition sequence instead.')
payno's avatar
payno committed
444
445
446

    @docstring(TomoScanBase.scan_range)
    @property
payno's avatar
payno committed
447
    def scan_range(self) -> typing.Union[None, int]:
448
449
450
451
452
453
454
455
456
457
458
        """For now scan range should return 180 or 360. We don't expect other value."""
        if (self._scan_range is None and self.master_file and
                os.path.exists(self.master_file) and self._entry is not None):
            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
459
460
461
        return self._scan_range

    @property
payno's avatar
payno committed
462
    def dim_1(self) -> typing.Union[None, int]:
463
464
        if self._dim_1 is None:
            self._get_dim1_dim2()
payno's avatar
payno committed
465
466
467
        return self._dim_1

    @property
payno's avatar
payno committed
468
    def dim_2(self) -> typing.Union[None, int]:
469
470
        if self._dim_2 is None:
            self._get_dim1_dim2()
payno's avatar
payno committed
471
472
473
        return self._dim_2

    @property
payno's avatar
payno committed
474
    def pixel_size(self) -> typing.Union[None, float]:
475
476
477
        return self.x_pixel_size

    @property
payno's avatar
payno committed
478
479
480
    def x_pixel_size(self) -> typing.Union[None, float]:
        if (self._x_pixel_size is None and self.master_file and
                os.path.exists(self.master_file)):
481
482
483
484
485
486
487
488
            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"""
        with h5py.File(self.master_file, 'r') as h5_file:
            x_pixel_dataset = h5_file[self._entry][self._X_PIXEL_SIZE_PATH]
489
            _x_pixel_size = self._get_value(x_pixel_dataset, default_unit='micrometer')
490
            y_pixel_dataset = h5_file[self._entry][self._Y_PIXEL_SIZE_PATH]
491
            _y_pixel_size = self._get_value(y_pixel_dataset, default_unit='micrometer')
492
493
        return _x_pixel_size, _y_pixel_size

494
495
496
497
498
499
500
501
    def _get_x_y_magnified_pixel_values(self):
        with h5py.File(self.master_file, 'r') as h5_file:
            x_m_pixel_dataset = h5_file[self._entry][self._X_PIXEL_MAG_SIZE_PATH]
            _x_m_pixel_size = self._get_value(x_m_pixel_dataset, default_unit='micrometer')
            y_m_pixel_dataset = h5_file[self._entry][self._Y_PIXEL_MAG_SIZE_PATH]
            _y_m_pixel_size = self._get_value(y_m_pixel_dataset, default_unit='micrometer')
        return _x_m_pixel_size, _y_m_pixel_size

502
503
504
505
506
507
    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:
                    self._dim_2, self._dim_1 = get_data(list(self.projections.values())[0]).shape

508
    @property
payno's avatar
payno committed
509
510
511
    def y_pixel_size(self) -> typing.Union[None, float]:
        if (self._y_pixel_size is None and self.master_file and
                os.path.exists(self.master_file)):
512
513
            self._x_pixel_size, self._y_pixel_size = self._get_x_y_pixel_values()
        return self._y_pixel_size
payno's avatar
payno committed
514

515
516
517
518
519
520
521
522
523
524
525
526
527
528
    @property
    def x_magnified_pixel_size(self) -> typing.Union[None, float]:
        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()
        return self._x_magnified_pixel_size

    @property
    def y_magnified_pixel_size(self) -> typing.Union[None, float]:
        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()
        return self._y_magnified_pixel_size

payno's avatar
payno committed
529
    @property
payno's avatar
payno committed
530
531
532
    def distance(self) -> typing.Union[None, float]:
        if (self._distance is None and self.master_file and
                os.path.exists(self.master_file)):
533
            self._check_hdf5scan_validity()
payno's avatar
payno committed
534
535
536
537
538
            with h5py.File(self.master_file, 'r') as h5_file:
                distance_dataset = h5_file[self._entry][self._DISTANCE_PATH]
                self._distance = self._get_value(distance_dataset, default_unit='m')
        return self._distance

539
540
541
542
    @property
    def energy(self) -> typing.Union[None, float]:
        if (self._energy is None and self.master_file and
                os.path.exists(self.master_file)):
543
            self._check_hdf5scan_validity()
544
545
546
547
548
            with h5py.File(self.master_file, 'r') as h5_file:
                energy_dataset = h5_file[self._entry][self._ENERGY_PATH]
                self._energy = self._get_value(energy_dataset, default_unit='keV')
        return self._energy

549
    @property
payno's avatar
payno committed
550
    def frames(self) -> typing.Union[None, tuple]:
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
        """return tuple of frames. Frames contains """
        if self._frames is None:
            image_keys = self.image_key
            rotation_angles = self.rotation_angle
            if len(image_keys) != len(rotation_angles):
                raise ValueError('`rotation_angle` and `image_key` have '
                                 'incoherent size (%s vs %s). Unable to '
                                 'deduce frame properties' % (len(rotation_angles),
                                                              len(image_keys)))
            self._frames = []

            def is_return(lframe, llast_proj_frame, ldelta_angle, return_already_reach) -> tuple:
                """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:
                    delta_angle = lframe.rotation_angle - llast_proj_frame.rotation_angle
                    return False, delta_angle
                elif return_already_reach:
                    return True, ldelta_angle
                else:
                    current_angle = lframe.rotation_angle - llast_proj_frame.rotation_angle
                    return abs(current_angle) <= 2*ldelta_angle, ldelta_angle

            delta_angle = None
            last_proj_frame = None
            return_already_reach = False
            for i_frame, rot_a, img_key in zip(range(len(rotation_angles)), rotation_angles, image_keys):
                url = DataUrl(file_path=self.master_file,
                              data_slice=(i_frame),
                              data_path=self.entry + '/instrument/detector/data',
                              scheme='silx')

                frame = Frame(index=i_frame, url=url, image_key=img_key,
                              rotation_angle=rot_a)
586
                if self.image_key_control is not None:
587
                    is_control_frame = (self.image_key_control[frame.index] == ImageKey.ALIGNMENT.value)
588
589
590
591
592
593
                else:
                    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)
                    is_control_frame = return_already_reach
594
                frame._is_control_frame = is_control_frame
595
596
597
598
599
                self._frames.append(frame)
                last_proj_frame = frame
            self._frames = tuple(self._frames)
        return self._frames

payno's avatar
payno committed
600
    @docstring(TomoScanBase.get_proj_angle_url)
payno's avatar
payno committed
601
    def get_proj_angle_url(self) -> typing.Union[dict, None]:
602
603
604
605
        if self.frames is not None:
            res = {}
            for frame in self.frames:
                if frame.image_key is ImageKey.PROJECTION:
606
                    if frame.is_control is False:
607
608
609
610
611
612
                        res[frame.rotation_angle] = frame.url
                    else:
                        res[str(frame.rotation_angle) + '(1)'] = frame.url
            return res
        else:
            return None
613
614
615
616
617
618

    def __str__(self):
        return 'hdf5 scan(path: %s, master_file: %s, entry: %s)' % (self.path,
                                                                    self.master_file,
                                                                    self.entry)

619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
    @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
        """
        value = node[()]
        if 'unit' in node.attrs:
            unit = node.attrs['unit']
        if 'units' in node.attrs:
            unit = node.attrs['units']
        else:
            unit = default_unit
        return value * metricsystem.MetricSystem.from_value(unit).value

634
635
636
637
638
639
640
641
642
643
    def _check_hdf5scan_validity(self):
        if self.master_file is None:
            raise ValueError('No master file provided')
        if self.entry is None:
            raise ValueError('No entry provided')
        with h5py.File(self.master_file, 'r') as h5_file:
            if self._entry not in h5_file:
                raise ValueError('Given entry %s is not in the master '
                                 'file %s' % (self._entry, self.master_file))

644
645
646
647
648
649

class Frame:
    """class to store all metadata information of a frame"""
    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,
650
                 is_control_proj: bool = False):
651
652
653
654
655
        assert type(index) is int
        self._index = index
        self._image_key = ImageKey.from_value(image_key)
        self._rotation_angle = rotation_angle
        self._url = url
656
        self._is_control_frame = is_control_proj
657
658
659
        self._data = None

    @property
payno's avatar
payno committed
660
    def index(self) -> int:
661
662
663
        return self._index

    @property
payno's avatar
payno committed
664
    def image_key(self) -> ImageKey:
665
666
667
        return self._image_key

    @image_key.setter
payno's avatar
payno committed
668
    def image_key(self, image_key: ImageKey) -> None:
669
670
671
672
673
674
675
        self._image_key = image_key

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

    @rotation_angle.setter
payno's avatar
payno committed
676
    def rotation_angle(self, angle: float) -> None:
677
678
679
        self._rotation_angle = angle

    @property
payno's avatar
payno committed
680
    def url(self) -> DataUrl:
681
682
683
        return self._url

    @property
684
685
    def is_control(self) -> bool:
        return self._is_control_frame
686

687
688
689
    @is_control.setter
    def is_control(self, is_return: bool):
        self._is_control_frame = is_return