dxfileconverter.py 25.9 KB
Newer Older
1
2
3
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
38
39
40
41
42
43
44
45
# coding: utf-8
# /*##########################################################################
#
# Copyright (c) 2015-2020 European Synchrotron Radiation Facility
#
# 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.
#
# ###########################################################################*/

"""
module to convert from dx file (hdf5) to nexus tomo compliant .nx (hdf5)
"""

__authors__ = [
    "H. Payno",
]
__license__ = "MIT"
__date__ = "18/05/2021"


from typing import Union
from tomoscan.io import HDF5File
from silx.io.utils import get_data
from silx.io.url import DataUrl
from nxtomomill.utils import ImageKey
from nxtomomill.converter.baseconverter import BaseConverter
from silx.io.utils import h5py_read_dataset
from nxtomomill.converter.hdf5.acquisition.baseacquisition import EntryReader
from nxtomomill.converter.hdf5.acquisition.baseacquisition import DatasetReader
46
from nxtomomill.converter.version import version as converter_version
47
from nxtomomill.utils import FieldOfView
48
from nxtomomill.utils import _FrameAppender
49
from nxtomomill.io.config import DXFileConfiguration
50
from tomoscan.unitsystem import metricsystem
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import logging
import numpy
import h5py
import os

_logger = logging.getLogger(__name__)


def from_dx_to_nx(
    input_file: str,
    output_file: Union[str, None] = None,
    file_extension: str = ".nx",
    duplicate_data: bool = True,
    input_entry="/",
    output_entry="entry0000",
payno's avatar
payno committed
66
    scan_range=(0.0, 180.0),
67
68
69
70
    pixel_size=(None, None),
    field_of_view=None,
    distance=None,
    overwrite=True,
71
    energy=None,
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
) -> tuple:
    """

    :param str input_file: dxfile to convert
    :param str output_file: output file to save
    :param str file_extension: file extension to give if the output file is
                               not provided.
    :param bool duplicate_data: if True frames will be duplicated. Otherwise
                                we will create (relative) link to the input
                                file.
    :param str input_entry: path to the HDF5 group to convert. For now it looks
                            each file can only contain one dataset. Just to
                            insure any future compatibility if it evolve with
                            time.
    :param str output_entry: path to store the NxTomo created.
    :param tuple scan_range: tuple of two elements with the minimum scan range.
                             projections are expected to be taken with equal
                            angular spaces.
    :param tuple pixel_size: pixel size can be provided (in meter and as
                             x_pizel_size, y_pixel_size)
    :param field_of_view: field of view
    :type field_of_view: None, str or FieldOfView
    :param distance: sample / detector distance in meter
    :type distance: None or float
    :param bool overwrite: if True and if the entry already exists in the
                           output file then will overwrite it.
    :return: tuple of (output_file, entry) created. For now the list should
             contain at most one of those tuple
    :rtype: tuple
    """
102
103
104
105
106
107
108
109
110
111
    configuration = DXFileConfiguration(input_file=input_file, output_file=output_file)
    configuration.file_extension = file_extension
    configuration.copy_data = duplicate_data
    configuration.input_entry = input_entry
    configuration.output_entry = output_entry
    configuration.scan_range = scan_range
    configuration.pixel_size = pixel_size
    configuration.field_of_view = field_of_view
    configuration.distance = distance
    configuration.overwrite = overwrite
112
    configuration.energy = energy
113
    converter = _DxFileToNxConverter(configuration=configuration)
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
    return converter.convert()


class _PathDoesNotExistsInExchange(Exception):
    pass


class _DxFileToNxConverter(BaseConverter):
    """
    Convert from dxfile to NXtomo.
    Dark and flats will be store at the beginning and we consider they are
    take at start so rotation angle will set to scan_range[0].
    Projection rotation angle will be interpolated from scan_range and
    with equality space distribution.
    We do not expect any alignment projection.
    """

131
132
133
134
135
136
    DEFAULT_DISTANCE_VALUE = 1.0 * metricsystem.MetricSystem.METER.value

    DEFAULT_PIXEL_VALUE = 1.0 * metricsystem.MetricSystem.MICROMETER.value

    DEFAULT_BEAM_ENERGY = 1.0 * metricsystem.EnergySI.KILOELECTRONVOLT.value

137
138
    def __init__(
        self,
139
        configuration: DXFileConfiguration,
140
    ):
141
142
        self._configuration = configuration
        if not len(self.scan_range) == 2:
143
144
            raise ValueError("scan_range expects to be a tuple with two elements")

145
146
        input_file = os.path.realpath(self.input_file)
        if self.output_file is None:
147
            input_file_basename, _ = os.path.splitext(input_file)
148
149
150
151
            if not self._configuration.file_extension.startswith("."):
                self._configuration.file_extension = (
                    "." + self._configuration.file_extension
                )
152
153
            output_file = os.path.join(
                os.path.dirname(input_file),
154
155
                os.path.basename(input_file_basename)
                + self._configuration.file_extension,
156
157
            )
        else:
158
            output_file = os.path.realpath(self.output_file)
payno's avatar
payno committed
159
        self._configuration.output_file = output_file
160
        if self.input_entry == "/":
payno's avatar
payno committed
161
            self._configuration._input_entry = ""
162
        else:
payno's avatar
payno committed
163
164
165
166
167
168
169
170
            self._configuration._input_entry = self.input_entry
        if self.field_of_view is not None:
            fov = self.field_of_view
            if isinstance(fov, str):
                fov = fov.title()
            self._configuration.field_of_view = FieldOfView.from_value(fov)
        self._configuration.distance = self.distance
        self._configuration.overwrite = self.overwrite
171
172

        self._n_frames = 0
173
        self._data_proj_url = None
174
175
176
177
178
179
180
181
182
183
        self._data_darks_url = None
        self._data_flats_url = None
        self._input_root_url = DataUrl(
            file_path=self.input_file,
            data_path=self.input_entry,
            scheme="silx",
        )

    @property
    def input_file(self):
184
        return self._configuration.input_file
185
186
187

    @property
    def input_entry(self):
188
        return self._configuration.input_entry
189
190
191

    @property
    def output_file(self):
192
        return self._configuration.output_file
193
194
195

    @property
    def output_entry(self):
196
        return self._configuration.output_entry
197
198
199

    @property
    def scan_range(self):
200
        return self._configuration.scan_range
201
202
203

    @property
    def copy_data(self):
204
        return self._configuration.copy_data
205
206
207

    @property
    def overwrite(self):
208
        return self._configuration.overwrite
209
210
211

    @property
    def distance(self) -> Union[float, None]:
212
        return self._configuration.distance
213
214
215

    @property
    def field_of_view(self) -> Union[FieldOfView, None]:
216
        return self._configuration.field_of_view
217

218
219
220
221
    @property
    def energy(self) -> Union[float, None]:
        return self._configuration.energy

222
223
224
225
226
    @property
    def input_root_url(self):
        return self._input_root_url

    def convert(self):
227
228
229
230
231
232
233
        """
        do conversion from dxfile to NXtomo

        :return: tuple of (output_file, entry) created. For now the list should
                 contain at most one of those tuple
        :rtype: tuple
        """
234
235
236
237
238
239
240
241
242
243
        with HDF5File(self.output_file, mode="a") as h5f:
            if self.output_entry in h5f:
                if self.overwrite:
                    del h5f[self.output_entry]
                else:
                    raise OSError(
                        "{} already exists cannot create requested NXtomo entry. Won't overwrite it as not requested"
                    )

        self._n_frames = 0
244
        self._data_proj_url = DataUrl(
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
            file_path=self.input_file,
            data_path="/".join((self.input_entry, "exchange", "data")),
            scheme="silx",
        )
        self._data_darks_url = DataUrl(
            file_path=self.input_file,
            data_path="/".join((self.input_entry, "exchange", "data_dark")),
            scheme="silx",
        )
        self._data_flats_url = DataUrl(
            file_path=self.input_file,
            data_path="/".join((self.input_entry, "exchange", "data_white")),
            scheme="silx",
        )

        # convert frames
        if self.copy_data:
            self._convert_frames_with_duplication()
        else:
            self._convert_frames_without_duplication()

        # convert detector extra information
        #  x pixel size
payno's avatar
payno committed
268
        x_pixel_size, y_pixel_size = self._configuration.pixel_size
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
        if x_pixel_size is None:
            try:
                x_pixel_size = self._read_x_pixel_size()
            except Exception:
                x_pixel_size = None
        if x_pixel_size is None:
            x_pixel_size = self.DEFAULT_PIXEL_VALUE
            _logger.warning(
                "No x pixel size found or provided. Set the it to the default value"
            )
        with HDF5File(self.output_file, mode="a") as h5f:
            root_grp = h5f.require_group(self.output_entry)
            instrument_grp = root_grp.require_group("instrument")
            detector_grp = instrument_grp.require_group("detector")
            detector_grp["x_pixel_size"] = x_pixel_size
            detector_grp["x_pixel_size"].attrs["unit"] = "m"
285
        #  y pixel size
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
        if y_pixel_size is None:
            try:
                y_pixel_size = self._read_y_pixel_size()
            except Exception:
                y_pixel_size = None
        if y_pixel_size is None:
            y_pixel_size = self.DEFAULT_PIXEL_VALUE
            _logger.warning(
                "No y pixel size found or provided. Set the it to the default value"
            )
        with HDF5File(self.output_file, mode="a") as h5f:
            root_grp = h5f.require_group(self.output_entry)
            instrument_grp = root_grp.require_group("instrument")
            detector_grp = instrument_grp.require_group("detector")
            detector_grp["y_pixel_size"] = y_pixel_size
            detector_grp["y_pixel_size"].attrs["unit"] = "m"
302
303
304
305
306
307
308
309
        #  field of view
        if self.field_of_view is not None:
            with HDF5File(self.output_file, mode="a") as h5f:
                root_grp = h5f.require_group(self.output_entry)
                instrument_grp = root_grp.require_group("instrument")
                detector_grp = instrument_grp.require_group("detector")
                detector_grp["field_of_view"] = self.field_of_view.value
        #  distance
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
        if self.distance is None:
            try:
                self._configuration.distance = self._read_distance()
            except Exception:
                self._configuration.distance = None
        # set default distance value
        if self.distance is None:
            self._configuration.distance = self.DEFAULT_DISTANCE_VALUE
            _logger.warning(
                "No detector / sample distance found or provided. "
                "Set the it  the default value"
            )
        with HDF5File(self.output_file, mode="a") as h5f:
            root_grp = h5f.require_group(self.output_entry)
            instrument_grp = root_grp.require_group("instrument")
            detector_grp = instrument_grp.require_group("detector")
            detector_grp["distance"] = self.distance
            detector_grp["distance"].attrs["unit"] = "m"
        # energy
        if self.energy is None:
            try:
                self._configuration.energy = self._read_energy()
            except Exception as e:
                self._configuration.energy = None
        if self.energy is None:
            self._configuration.energy = self.DEFAULT_BEAM_ENERGY
            _logger.warning(
                "No energy found or provided. " "Set the it to the default value"
            )
        with HDF5File(self.output_file, mode="a") as h5f:
            root_grp = h5f.require_group(self.output_entry)
            beam_grp = root_grp.require_group("beam")
            beam_grp["incident_energy"] = (
payno's avatar
payno committed
343
                self.energy / metricsystem.EnergySI.KILOELECTRONVOLT.value
344
345
346
            )
            beam_grp["incident_energy"].attrs["unit"] = "kev"

347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
        #  count_time
        self._copy_count_time()

        # start time
        with EntryReader(self.input_root_url) as input_entry:
            if "file_creation_datetime" in input_entry:
                with HDF5File(self.output_file, mode="a") as h5f:
                    root_grp = h5f.require_group(self.output_entry)
                    root_grp["start_time"] = h5py_read_dataset(
                        input_entry["file_creation_datetime"]
                    )

        return ((self.output_file, self.output_entry),)

    def _copy_count_time(self):
        """Try to read and copy count_time / exposure_period"""

        def read_and_write_count_time(dataset: h5py.Dataset):
            count_time = h5py_read_dataset(dataset)
            if count_time == "Error: Unknown Attribute":
                _logger.info(
                    "Count time not stored on {}@{}".format(
                        self.input_entry, self.input_file
                    )
                )
            else:
                try:
                    if len(count_time) == self._n_frames:
                        with HDF5File(self.output_file, mode="a") as h5f:
                            root_grp = h5f.require_group(self.input_entry)
                            instrument_grp = root_grp.require_group("instrument")
                            detector_grp = instrument_grp.require_group("detector")
                            detector_grp["count_time"] = count_time
                    else:
                        _logger.warning(
                            "exposure period and data frame have an incoherent size ({} vs {})".format(
                                len(count_time), self._n_frames
                            )
                        )
                except Exception as e:
                    _logger.error(
                        "Failed to get 'count_time' / 'exposure_period'. reason is {}".format(
                            e
                        )
                    )

        with EntryReader(self.input_root_url) as input_entry:
            if "measurement" in input_entry:
                measurement_grp = input_entry["measurement"]
                if "detector" in measurement_grp:
                    detector_grp = measurement_grp["detector"]
                    if "exposure_period" in detector_grp:
                        read_and_write_count_time(detector_grp["exposure_period"])

    def _convert_frames_with_duplication(self):
        image_key = []
        image_key_control = []
        rotation_angle = []
        data = None

        # handle darks
        try:
            data_dark = get_data(self._data_darks_url)
        except _PathDoesNotExistsInExchange:
            _logger.warning(
                "No darks found in {}@{}".format(self.input_entry, self.input_file)
            )
        else:
            image_key.extend([ImageKey.DARK_FIELD.value] * len(data_dark))
            image_key_control.extend([ImageKey.DARK_FIELD.value] * len(data_dark))
            rotation_angle.extend([self.scan_range[0]] * len(data_dark))
            if data is None:
                data = data_dark
            else:
                data = numpy.concatenate((data, data_dark), axis=0)
        # handle flats
        try:
            data_flat = get_data(self._data_flats_url)
        except _PathDoesNotExistsInExchange:
            _logger.warning(
                "No flats found in {}@{}".format(self.input_entry, self.input_file)
            )
        else:
            image_key.extend([ImageKey.FLAT_FIELD.value] * len(data_flat))
            image_key_control.extend([ImageKey.FLAT_FIELD.value] * len(data_flat))
            rotation_angle.extend([self.scan_range[0]] * len(data_flat))
            if data is None:
                data = data_flat
            else:
                data = numpy.concatenate((data, data_flat), axis=0)
        # handle projections
438
        data_proj = get_data(self._data_proj_url)
439
440
        image_key.extend([ImageKey.PROJECTION.value] * len(data_proj))
        image_key_control.extend([ImageKey.PROJECTION.value] * len(data_proj))
payno's avatar
payno committed
441
442
443
444
445
446
        assert (
            self.scan_range[0] is not None
        ), "scan range is expected to be a tuple of float"
        assert (
            self.scan_range[1] is not None
        ), "scan range is expected to be a tuple of float"
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
        rotation_angle.extend(
            numpy.linspace(
                self.scan_range[0],
                self.scan_range[1],
                num=len(data_proj),
                endpoint=True,
            )
        )
        if data is None:
            data = data_proj
        else:
            data = numpy.concatenate((data, data_proj), axis=0)
        self._n_frames = len(data)

        with HDF5File(self.output_file, mode="a") as h5f:
            root_grp = h5f.require_group(self.output_entry)
            instrument_grp = root_grp.require_group("instrument")
            detector_grp = instrument_grp.require_group("detector")
            detector_grp["data"] = data
            detector_grp["image_key"] = image_key
            detector_grp["image_key_control"] = image_key_control
            sample_grp = root_grp.require_group("sample")
            sample_grp["rotation_angle"] = rotation_angle
            sample_grp["rotation_angle"].attrs["unit"] = "degree"
471
472
473
            self._path_nxtomo_attrs(root_grp)

    def _path_nxtomo_attrs(self, root_grp):
Henri Payno's avatar
PEP8    
Henri Payno committed
474
475
        root_grp.attrs["NX_class"] = "NXentry"
        root_grp.attrs["definition"] = "NXtomo"
476
477
478
479
480
481
482
483
484
485
486
487
488
        root_grp.attrs["version"] = converter_version()
        root_grp.attrs["default"] = "instrument/detector"
        instrument_grp = root_grp.require_group("instrument")
        instrument_grp.attrs["NX_class"] = "NXinstrument"
        instrument_grp.attrs["default"] = "detector"
        detector_grp = instrument_grp.require_group("detector")
        detector_grp.attrs["NX_class"] = "NXdetector"
        detector_grp.attrs["NX_class"] = "NXdata"
        detector_grp.attrs["signal"] = "data"
        detector_grp.attrs["SILX_style/axis_scale_types"] = ["linear", "linear"]
        if "data" in detector_grp:
            detector_grp["data"].attrs["interpretation"] = "image"
        sample_node = root_grp.require_group("sample")
Henri Payno's avatar
PEP8    
Henri Payno committed
489
        sample_node.attrs["NX_class"] = "NXsample"
490
491
492
493
494

    def _convert_frames_without_duplication(self):
        image_key = []
        image_key_control = []
        rotation_angle = []
495
496
497
498
        dataset_path = "/".join((self.output_entry, "instrument", "detector", "data"))
        n_dark = 0
        n_flat = 0
        n_proj = 0
499
500
501
        # handle darks
        try:
            with DatasetReader(self._data_darks_url) as dark_dataset:
502
503
504
505
506
507
508
509
510
511
512
513
514
                n_dark = dark_dataset.shape[0]
            _FrameAppender(
                self._data_darks_url,
                self.output_file,
                data_path=dataset_path,
                where="end",
                logger=_logger,
            ).process()
        except Exception:
            _logger.error(
                "No darks found in {} or unable to add them to the dataset".format(
                    self._data_darks_url.path()
                )
515
516
            )
        else:
517
518
519
520
            image_key.extend([ImageKey.DARK_FIELD.value] * n_dark)
            image_key_control.extend([ImageKey.DARK_FIELD.value] * n_dark)
            rotation_angle.extend([self.scan_range[0]] * n_dark)

521
522
        # handle flats
        try:
523
524
525
526
527
528
529
530
531
532
533
            with DatasetReader(self._data_flats_url) as flat_dataset:
                n_flat = flat_dataset.shape[0]

            _FrameAppender(
                self._data_flats_url,
                self.output_file,
                data_path=dataset_path,
                where="end",
                logger=_logger,
            ).process()
        except Exception:
534
            _logger.warning(
535
536
537
                "No flats found in {} or unable to add them to the dataset".format(
                    self._data_flats_url.path()
                )
538
539
            )
        else:
540
541
542
543
            image_key.extend([ImageKey.FLAT_FIELD.value] * n_flat)
            image_key_control.extend([ImageKey.FLAT_FIELD.value] * n_flat)
            rotation_angle.extend([self.scan_range[0]] * n_flat)

544
        # handle projections
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
        try:
            with DatasetReader(self._data_proj_url) as proj_dataset:
                n_proj = proj_dataset.shape[0]
            _FrameAppender(
                self._data_proj_url,
                self.output_file,
                data_path=dataset_path,
                where="end",
                logger=_logger,
            ).process()
        except Exception as e:
            _logger.warning(
                "No projections found in {} or unable to add them to the dataset".format(
                    self._data_proj_url.path()
                )
560
561
            )
        else:
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
            image_key.extend([ImageKey.PROJECTION.value] * n_proj)
            image_key_control.extend([ImageKey.PROJECTION.value] * n_proj)
            rotation_angle.extend(
                numpy.linspace(
                    self.scan_range[0],
                    self.scan_range[1],
                    num=n_proj,
                    endpoint=True,
                )
            )

        self._n_frames = n_flat + n_dark + n_proj

        with HDF5File(self.output_file, mode="a") as h5f:
            root_grp = h5f.require_group(self.output_entry)
            instrument_grp = root_grp.require_group("instrument")
            detector_grp = instrument_grp.require_group("detector")
            detector_grp["image_key"] = image_key
            detector_grp["image_key_control"] = image_key_control
            sample_grp = root_grp.require_group("sample")
            sample_grp["rotation_angle"] = rotation_angle
            sample_grp["rotation_angle"].attrs["unit"] = "degree"
            self._path_nxtomo_attrs(root_grp)
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663

    def _read_distance(self):
        with EntryReader(self.input_root_url) as input_entry:
            path = "/".join(
                ("measurement", "instrument", "sample", "setup", "detector_distance")
            )
            dataset = input_entry[path]
            distance = h5py_read_dataset(dataset)
            try:
                if "units" in dataset.attrs:
                    unit = dataset.attrs["units"]
                else:
                    unit = dataset.attrs["unit"]
                unit = metricsystem.MetricSystem.from_value(unit).value
            except Exception:
                unit = 1
            return distance * unit

    def _read_energy(self):
        with EntryReader(self.input_root_url) as input_entry:
            path = "/".join(("measurement", "instrument", "source", "energy"))
            dataset = input_entry[path]
            energy = float(h5py_read_dataset(dataset))
            try:
                if "units" in dataset.attrs:
                    unit = dataset.attrs["units"]
                else:
                    unit = dataset.attrs["unit"]
                # patch until next tomoscan is released
                if unit.lower() in ("mev", "megaelectronvolt"):
                    unit = metricsystem.EnergySI.ELECTRONVOLT.value * 1e6
                elif unit.lower() in ("gev", "gigaelectronvolt"):
                    unit = metricsystem.EnergySI.ELECTRONVOLT.value * 1e9
                else:
                    unit = metricsystem.EnergySI.from_value(unit).value
            except Exception as e:
                raise e
                unit = 1
            return energy * unit

    def _read_x_pixel_size(self):
        with EntryReader(self.input_root_url) as input_entry:
            path = "/".join(
                ("measurement", "instrument", "detector", "actual_pixel_size_x")
            )
            dataset = input_entry[path]
            pixel_size = float(h5py_read_dataset(dataset))
            try:
                if "units" in dataset.attrs:
                    unit = dataset.attrs["units"]
                else:
                    unit = dataset.attrs["unit"]
                # patch until next tomoscan is released
                if unit == "microns":
                    unit = "um"
                unit = metricsystem.MetricSystem.from_value(unit).value
            except Exception:
                unit = 1
            return pixel_size * unit

    def _read_y_pixel_size(self):
        with EntryReader(self.input_root_url) as input_entry:
            path = "/".join(
                ("measurement", "instrument", "detector", "actual_pixel_size_y")
            )
            dataset = input_entry[path]
            pixel_size = float(h5py_read_dataset(dataset))
            try:
                if "units" in dataset.attrs:
                    unit = dataset.attrs["units"]
                else:
                    unit = dataset.attrs["unit"]
                # patch until next tomoscan is released
                if unit == "microns":
                    unit = "um"
                unit = metricsystem.MetricSystem.from_value(unit).value
            except Exception:
                unit = 1
            return pixel_size * unit