diff --git a/src/fabio/fabioformats.py b/src/fabio/fabioformats.py index 95ca2e27..b1c52014 100644 --- a/src/fabio/fabioformats.py +++ b/src/fabio/fabioformats.py @@ -34,7 +34,7 @@ __contact__ = "valentin.valls@esrf.eu" __license__ = "MIT" __copyright__ = "European Synchrotron Radiation Facility, Grenoble, France" -__date__ = "10/02/2023" +__date__ = "02/05/2024" __status__ = "stable" __docformat__ = 'restructuredtext' @@ -91,6 +91,7 @@ def importer(module_name): ("mrcimage", "MrcImage"), ("esperantoimage", "EsperantoImage"), ("limaimage", "LimaImage"), + ("lambdaimage", "LambdaImage"), # For compatibility (maybe not needed) ("adscimage", "AdscImage"), ("sparseimage", "SparseImage"), diff --git a/src/fabio/lambdaimage.py b/src/fabio/lambdaimage.py new file mode 100644 index 00000000..76402ac3 --- /dev/null +++ b/src/fabio/lambdaimage.py @@ -0,0 +1,259 @@ +# coding: utf-8 +# +# Project: X-ray image reader +# https://github.com/silx-kit/fabio +# +# Copyright (C) European Synchrotron Radiation Facility, Grenoble, France +# +# 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. + +""" +Basic read support for NeXus/HDF5 files saved by Lambda-detectors. +""" + +__authors__ = ["Jérôme Kieffer"] +__contact__ = "jerome.kieffer@esrf.fr" +__license__ = "MIT" +__copyright__ = "ESRF" +__date__ = "02/05/2024" + +import logging +logger = logging.getLogger(__name__) +import posixpath +import os +import numpy +from .fabioimage import FabioImage +from .fabioutils import NotGoodReader +from . import nexus +try: + import h5py +except ImportError: + h5py = None +try: + import hdf5plugin +except ImportError: + hdf5plugin = None + + +class LambdaImage(FabioImage): + """FabIO image class for Images for Lambda detector + + Lambda detector are medipix based detectors sold by X-Spectrum: + https://x-spectrum.de/products/lambda/ + """ + + DESCRIPTION = "HDF5 file produces by Lambda" + + DEFAULT_EXTENSIONS = ["h5", "hdf5", "nxs"] + DETECTOR_GRP = "/entry/instrument/detector" + + def __init__(self, data=None, header=None): + """ + Set up initial values + """ + if not h5py: + raise RuntimeError("fabio.LambdaImage cannot be used without h5py. Please install h5py and restart") + + self.dataset = [data] + self._data = None + FabioImage.__init__(self, data, header) + self.h5 = None + + @property + def nframes(self): + """Returns the number of frames contained in this file + + :rtype: int + """ + return len(self.dataset) + + def get_data(self): + if self._data is None and len(self.dataset) >= self.currentframe: + self._data = self.dataset[self.currentframe] + return self._data + + def set_data(self, data, index=None): + """Set the data for frame index + + :param data: numpy array + :param int index: index of the frame (by default: current one) + :raises IndexError: If the frame number is out of the available range. + """ + if index is None: + index = self.currentframe + if isinstance(self.dataset, list): + if index == len(self.dataset): + self.dataset.append(data) + elif index > len(self.dataset): + # pad dataset with None ? + self.dataset += [None] * (1 + index - len(self.dataset)) + self.dataset[index] = data + else: + self.dataset[index] = data + if index == self.currentframe: + self._data = data + + data = property(get_data, set_data) + + def __repr__(self): + if self.h5 is None: + return "%s object at %s" % (self.__class__.__name__, hex(id(self))) + else: + return "Lambda/nexus dataset with %i frames from %s" % (self.nframes, self.h5.filename) + + def _readheader(self, infile): + """ + Read and decode the header of an image: + + :param infile: Opened python file (can be stringIO or bzipped file) + """ + # list of header key to keep the order (when writing) + self.header = self.check_header() + data_path = posixpath.join(self.DETECTOR_GRP, "data") + description_path = posixpath.join(self.DETECTOR_GRP, "description") + name_path = posixpath.join(self.DETECTOR_GRP, "local_name") + with h5py.File(infile, mode="r") as h5: + if not (data_path in h5 and description_path in h5): + raise NotGoodReader("HDF5's does not look like a Lambda-detector NeXus file.") + description = h5[description_path][()] + if isinstance(description, bytes): + description = description.decode() + else: + description = str(description) + if description != "Lambda": + raise NotGoodReader("Nexus file does not look like it has been written by a Lambda-detector.") + if name_path in h5: + self.header["detector"] = str(h5[name_path][()]) + else: + self.header["detector"] = "detector" + + def read(self, fname, frame=None): + """ + Try to read image + + :param fname: name of the file + :param frame: number of the frame + """ + + self.resetvals() + with self._open(fname) as infile: + self._readheader(infile) + # read the image data and declare it + + self.dataset = None + # read the image data + self.h5 = h5py.File(fname, mode="r") + data_path = posixpath.join(self.DETECTOR_GRP, "data") + if data_path in self.h5: + ds = self.h5[data_path] + else: + raise NotGoodReader("HDF5's default entry does not exist.") + self.dataset = ds + self._nframes = ds.shape[0] + + if frame is not None: + return self.getframe(int(frame)) + else: + self.currentframe = 0 + self.data = self.dataset[self.currentframe] + self._shape = None + return self + + def getframe(self, num): + """ returns the frame numbered 'num' in the stack if applicable""" + if self.nframes > 1: + new_img = None + if (num >= 0) and num < self.nframes: + data = self.dataset[num] + new_img = self.__class__(data=data, header=self.header) + new_img.dataset = self.dataset + new_img.h5 = self.h5 + new_img._nframes = self.nframes + new_img.currentframe = num + else: + raise IOError(f"getframe({num}) out of range [0, {self.nframes}[") + else: + new_img = FabioImage.getframe(self, num) + return new_img + + def previous(self): + """ returns the previous file in the series as a FabioImage """ + new_image = None + if self.nframes == 1: + new_image = FabioImage.previous(self) + else: + new_idx = self.currentframe - 1 + new_image = self.getframe(new_idx) + return new_image + + def next(self): + """Returns the next file in the series as a fabioimage + + :raise IOError: When there is no next file or image in the series. + """ + new_image = None + if self.nframes == 1: + new_image = FabioImage.next(self) + else: + new_idx = self.currentframe + 1 + new_image = self.getframe(new_idx) + return new_image + + def close(self): + if self.h5 is not None: + self.h5.close() + self.dataset = None + + def write(self, filename): + """Write a file that looks like one saved by Lambda-detector.""" + start_time = nexus.get_isotime() + abs_name = os.path.abspath(filename) + mode = "w" + if hdf5plugin is None: + logger.warning("hdf5plugin is needed for bitshuffle-LZ4 compression, falling back on gzip (slower)") + compression = {"compression":"gzip", + "compression_opts":1} + else: + compression = hdf5plugin.Bitshuffle() + + with nexus.Nexus(abs_name, mode=mode) as nxs: + entry = nxs.new_entry(entry="entry", + program_name=None, + force_time=start_time, + force_name=True) + instrument_grp = nxs.new_class(entry, "instrument", class_type="NXinstrument") + detector_grp = nxs.new_class(instrument_grp, "detector", class_type="NXdetector") + detector_grp["description"] = b"Lambda" + detector_grp["local_name"] = self.header.get("detector", "detector").encode() + detector_grp["layout"] = "X".join(str(i) for i in self.shape[-1::-1]).encode() + header_grp = nxs.new_class(detector_grp, "collection", class_type="NXcollection") + acq_grp = nxs.new_class(detector_grp, "acquisition", class_type="NXcollection") + + acq_grp["frame_numbers"] = numpy.int32(self.nframes) + + shape = (self.nframes,) + self.shape + dataset = detector_grp.create_dataset("data", shape=shape, chunks=(1,) + self.shape, dtype=self.dtype, **compression) + dataset.attrs["interpretation"] = "image" + for i, frame in enumerate(self.dataset): + dataset[i] = frame + +# This is for compatibility with old code: +lambdaimage = LambdaImage diff --git a/src/fabio/meson.build b/src/fabio/meson.build index 215855f2..7efaed7f 100644 --- a/src/fabio/meson.build +++ b/src/fabio/meson.build @@ -34,6 +34,7 @@ py.install_sources([ 'jpeg2kimage.py', 'jpegimage.py', 'kcdimage.py', + 'lambdaimage.py', 'limaimage.py', 'mar345image.py', 'marccdimage.py', diff --git a/src/fabio/openimage.py b/src/fabio/openimage.py index 124a044b..f03f9e7d 100644 --- a/src/fabio/openimage.py +++ b/src/fabio/openimage.py @@ -96,7 +96,7 @@ (b"No", "kcd"), (b"<", "xsd"), (b"\n\xb8\x03\x00", 'pixi'), - (b"\x89\x48\x44\x46\x0d\x0a\x1a\x0a", "eiger/lima/sparse/hdf5"), + (b"\x89\x48\x44\x46\x0d\x0a\x1a\x0a", "eiger/lima/sparse/hdf5/lambda"), (b"R-AXIS", 'raxis'), (b"\x93NUMPY", 'numpy'), (b"\\$FFF_START", 'fit2d'), @@ -118,11 +118,12 @@ def do_magic(byts, filename): for magic, format_type in MAGIC_NUMBERS: if byts.startswith(magic): if "/" in format_type: - if format_type == "eiger/lima/sparse/hdf5": + if format_type == "eiger/lima/sparse/hdf5/lambda": if "::" in filename: return "hdf5" else: - # check if the creator is LIMA + # check if the creator is LIMA or other + lambda_path = "/entry/instrument/detector/description" import h5py with h5py.File(filename, "r") as h: creator = h.attrs.get("creator") @@ -144,6 +145,8 @@ def do_magic(byts, filename): return "lima" elif str(creator).startswith("pyFAI"): return "sparse" + elif lambda_path in h and h[lambda_path][()].decode() == "Lambda": + return "lambda" else: return "eiger" elif format_type == "marccd/tif": diff --git a/src/fabio/test/codecs/__init__.py b/src/fabio/test/codecs/__init__.py index b21c0c5f..b698f49c 100755 --- a/src/fabio/test/codecs/__init__.py +++ b/src/fabio/test/codecs/__init__.py @@ -30,7 +30,7 @@ __contact__ = "jerome.kieffer@esrf.eu" __license__ = "GPLv3+" __copyright__ = "European Synchrotron Radiation Facility, Grenoble, France" -__date__ = "09/02/2023" +__date__ = "02/05/2024" import sys import unittest @@ -58,6 +58,7 @@ def suite(): from . import test_numpyimage from . import test_pilatusimage from . import test_eigerimage + from . import test_lambdaimage from . import test_hdf5image from . import test_fit2dimage from . import test_speimage @@ -108,6 +109,7 @@ def suite(): testSuite.addTest(test_hipicimage.suite()) testSuite.addTest(test_binaryimage.suite()) testSuite.addTest(test_xcaliburimage.suite()) + testSuite.addTest(test_lambdaimage.suite()) return testSuite diff --git a/src/fabio/test/codecs/meson.build b/src/fabio/test/codecs/meson.build index 081fd02c..ef0d64f2 100644 --- a/src/fabio/test/codecs/meson.build +++ b/src/fabio/test/codecs/meson.build @@ -18,6 +18,7 @@ py.install_sources( 'test_jpeg2kimage.py', 'test_jpegimage.py', 'test_kcdimage.py', + 'test_lambdaimage.py', 'test_limaimage.py', 'test_mar345image.py', 'test_mccdimage.py', diff --git a/src/fabio/test/codecs/test_lambdaimage.py b/src/fabio/test/codecs/test_lambdaimage.py new file mode 100644 index 00000000..c1a28906 --- /dev/null +++ b/src/fabio/test/codecs/test_lambdaimage.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Project: Fable Input Output +# https://github.com/silx-kit/fabio +# +# Copyright (C) European Synchrotron Radiation Facility, Grenoble, France +# +# Principal author: Jérôme Kieffer (Jerome.Kieffer@ESRF.eu) +# +# 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.# + +"""Test lambda images +""" + +import os +import numpy +import fabio.lambdaimage +from fabio.openimage import openimage +from ..utilstest import UtilsTest + +import unittest +import logging +logger = logging.getLogger(__name__) + + +class TestLambda(unittest.TestCase): + # filename dim1 dim2 min max mean stddev + TESTIMAGES = [ + ("l1_test02_00002_m01.nxs", 1554, 516, 0, 548, 0.00, 0.81024), # WIP + ("l1_test02_00002_m02.nxs", 1554, 516, 0, 0, 0.0, 0.0), # WIP + ("l1_test02_00002_m03.nxs", 1554, 516, 0, 45, 0.00 ,0.0534), # WIP + ("l1_test02_00002_master.nxs", 1555, 1813, 0, 548, 0.00, 0.433), # WIP + ] + + def test_read(self): + """ + Test the reading of lambda images + """ + for params in self.TESTIMAGES: + name = params[0] + logger.debug("Processing: %s" % name) + dim1, dim2 = params[1:3] + shape = dim2, dim1 + mini, maxi, mean, stddev = params[3:] + obj = fabio.lambdaimage.LambdaImage() + filename =UtilsTest.getimage(name) + obj.read(filename) + + self.assertAlmostEqual(mini, obj.getmin(), 2, "getmin [%s,%s]" % (mini, obj.getmin())) + self.assertAlmostEqual(maxi, obj.getmax(), 2, "getmax [%s,%s]" % (maxi, obj.getmax())) + self.assertAlmostEqual(mean, obj.getmean(), 2, "getmean [%s,%s]" % (mean, obj.getmean())) + self.assertAlmostEqual(stddev, obj.getstddev(), 2, "getstddev [%s,%s]" % (stddev, obj.getstddev())) + + self.assertEqual(shape, obj.shape, "dim1") + + def test_write(self): + """read file using fabio.open and save and reopen ... check consistency""" + for params in self.TESTIMAGES: + fname = UtilsTest.getimage(params[0]) + obj = openimage(fname) + self.assertEqual(obj.shape, params[2:0:-1]) + + dst = os.path.join(UtilsTest.tempdir, os.path.basename(fname)) + obj.write(dst) + for idx, read_back in enumerate(openimage(dst)): + self.assertTrue(numpy.all(read_back.data == obj.getframe(idx).data), f"data are the same {fname} #{idx}") + + +def suite(): + loadTests = unittest.defaultTestLoader.loadTestsFromTestCase + testsuite = unittest.TestSuite() + testsuite.addTest(loadTests(TestLambda)) + return testsuite + + +if __name__ == '__main__': + runner = unittest.TextTestRunner() + runner.run(suite()) diff --git a/version.py b/version.py index ea2ede9e..634a68f8 100755 --- a/version.py +++ b/version.py @@ -58,7 +58,7 @@ __contact__ = "Jerome.Kieffer@ESRF.eu" __license__ = "MIT" __copyright__ = "European Synchrotron Radiation Facility, Grenoble, France" -__date__ = "11/04/2024" +__date__ = "02/05/2024" __status__ = "production" __docformat__ = 'restructuredtext' __all__ = ["date", "version_info", "strictversion", "hexversion", "debianversion", @@ -76,9 +76,9 @@ "candidate": "rc"} MAJOR = 2024 -MINOR = 4 +MINOR = 5 MICRO = 0 -RELEV = "final" # <16 +RELEV = "dev" # <16 SERIAL = 0 # <16 date = __date__