# Copyright (C) 2022 ISIS Rutherford Appleton Laboratory UKRI
# SPDX - License - Identifier: GPL-3.0-or-later
import enum
import traceback
from enum import auto, Enum
from logging import getLogger
from typing import TYPE_CHECKING, Optional, Union, Tuple
import h5py
import numpy as np
from mantidimaging.core.data import Images
from mantidimaging.core.data.dataset import StrictDataset
from mantidimaging.core.parallel import utility as pu
from mantidimaging.core.utility.data_containers import ProjectionAngles
if TYPE_CHECKING:
from mantidimaging.gui.windows.nexus_load_dialog.view import NexusLoadDialog # pragma: no cover
logger = getLogger(__name__)
[docs]
class Notification(Enum):
NEXUS_FILE_SELECTED = auto()
[docs]
class ImageKeys(enum.Enum):
Projections = 0
FlatField = 1
DarkField = 2
IMAGE_TITLE_MAP = {ImageKeys.Projections: "Projections", ImageKeys.FlatField: "Flat", ImageKeys.DarkField: "Dark"}
BEFORE_TITLE_MAP = {True: "Before", False: "After"}
TOMO_ENTRY = "tomo_entry"
DATA_PATH = "instrument/detector/data"
IMAGE_KEY_PATH = "instrument/detector/image_key"
ROTATION_ANGLE_PATH = "sample/rotation_angle"
def _missing_data_message(data_string: str) -> str:
"""
Creates a message for logging when certain data is missing in the NeXus file.
:param data_string: The name of the missing data.
:return: A string telling the user that the data is missing.
"""
return f"The NeXus file does not contain the {data_string} data."
[docs]
class NexusLoadPresenter:
view: 'NexusLoadDialog'
def __init__(self, view: 'NexusLoadDialog'):
self.view = view
self.nexus_file = None
self.tomo_entry = None
self.data = None
self.tomo_path = ""
self.image_key_dataset = None
self.title = ""
self.sample_array = None
self.dark_before_array = None
self.flat_before_array = None
self.flat_after_array = None
self.dark_after_array = None
self.projection_angles = None
[docs]
def notify(self, n: Notification):
try:
if n == Notification.NEXUS_FILE_SELECTED:
self.scan_nexus_file()
except RuntimeError as err:
self.view.show_exception(str(err), traceback.format_exc())
[docs]
def scan_nexus_file(self):
"""
Try to open the NeXus file and display its contents on the view.
"""
file_path = self.view.filePathLineEdit.text()
try:
with h5py.File(file_path, "r") as self.nexus_file:
self.tomo_entry = self._look_for_nxtomo_entry()
if self.tomo_entry is None:
return
self.data = self._look_for_tomo_data_and_update_view(DATA_PATH, 2)
if self.data is None:
return
self.image_key_dataset = self._look_for_tomo_data_and_update_view(IMAGE_KEY_PATH, 0)
if self.image_key_dataset is None:
return
rotation_angles = self._look_for_tomo_data_and_update_view(ROTATION_ANGLE_PATH, 1)
if rotation_angles is not None:
if "units" not in rotation_angles.attrs.keys():
logger.warning("No unit information found for rotation angles. Will infer from array values.")
self._read_rotation_angles(rotation_angles, np.abs(rotation_angles).max() > 2 * np.pi)
else:
self._read_rotation_angles(rotation_angles, "deg" in rotation_angles.attrs["units"])
self._get_data_from_image_key()
self.title = self._find_data_title()
except OSError:
unable_message = f"Unable to read NeXus data from {file_path}"
logger.error(unable_message)
self.view.show_data_error(unable_message)
self.view.disable_ok_button()
def _read_rotation_angles(self, rotation_angles: h5py.Dataset, degrees: bool):
"""
Reads the rotation angles array for the projections alone and coverts them to radians if needed.
:param rotation_angles: The rotation angle information for all the images.
:param degrees: Whether the data is in degrees or not. If this information isn't included in the file then a
guess was made.
"""
assert self.image_key_dataset is not None
self.projection_angles = rotation_angles[np.where(self.image_key_dataset[...] == ImageKeys.Projections.value)]
if degrees:
self.projection_angles = np.radians(self.projection_angles)
def _missing_data_error(self, field: str):
"""
Create a missing data message and display it on the view.
:param field: The name of the field that couldn't be found in the NeXus file.
"""
if "rotation_angle" in field:
error_msg = _missing_data_message(field)
logger.warning(error_msg)
else:
error_msg = _missing_data_message("required " + field)
logger.error(error_msg)
self.view.show_data_error(error_msg)
def _look_for_tomo_data_and_update_view(self, field: str,
position: int) -> Optional[Union[h5py.Group, h5py.Dataset]]:
"""
Looks for the data in the NeXus file and adds information about it to the view if it's found.
:param field: The name of the NeXus field.
:param position: The position of the field information row in the view's QTreeWidget.
:return: The h5py Group/Dataset if it could be found, None otherwise.
"""
dataset = self._look_for_tomo_data(field)
if dataset is None:
self._missing_data_error(field)
self.view.set_data_found(position, False, "", ())
self.view.disable_ok_button()
else:
self.view.set_data_found(position, True, self.tomo_path + "/" + field, dataset.shape)
return dataset
def _look_for_nxtomo_entry(self) -> Optional[h5py.Group]:
"""
Look for a tomo_entry field in the NeXus file. Generate an error and disable the view OK button if it can't be
found.
:return: The first tomo_entry group if one could be found, None otherwise.
"""
assert self.nexus_file is not None
for key in self.nexus_file.keys():
if TOMO_ENTRY in self.nexus_file[key].keys():
self.tomo_path = f"{key}/{TOMO_ENTRY}"
return self.nexus_file[key][TOMO_ENTRY]
self._missing_data_error(TOMO_ENTRY)
self.view.disable_ok_button()
return None
def _look_for_tomo_data(self, entry_path: str) -> Optional[Union[h5py.Group, h5py.Dataset]]:
"""
Retrieve data from the tomo entry field.
:param entry_path: The path in which the data is found.
:return: The Nexus Group/Dataset if it exists, None otherwise.
"""
assert self.tomo_entry is not None
try:
return self.tomo_entry[entry_path]
except KeyError:
return None
def _get_data_from_image_key(self):
"""
Looks for the projection and dark/flat before/after images and update the information on the view.
"""
self.sample_array = self._get_images(ImageKeys.Projections)
self.view.set_images_found(0, self.sample_array.size != 0, self.sample_array.shape)
if self.sample_array.size == 0:
self._missing_data_error("projection images")
self.view.disable_ok_button()
return
self.view.set_projections_increment(self.sample_array.shape[0])
self.flat_before_array = self._get_images(ImageKeys.FlatField, True)
self.view.set_images_found(1, self.flat_before_array.size != 0, self.flat_before_array.shape)
self.flat_after_array = self._get_images(ImageKeys.FlatField, False)
self.view.set_images_found(2, self.flat_after_array.size != 0, self.flat_after_array.shape)
self.dark_before_array = self._get_images(ImageKeys.DarkField, True)
self.view.set_images_found(3, self.dark_before_array.size != 0, self.dark_before_array.shape)
self.dark_after_array = self._get_images(ImageKeys.DarkField, False)
self.view.set_images_found(4, self.dark_after_array.size != 0, self.dark_after_array.shape)
def _get_images(self, image_key_number: ImageKeys, before: Optional[bool] = None) -> np.ndarray:
"""
Retrieve images from the data based on an image key number.
:param image_key_number: The image key number.
:param before: True if the function should return before images, False if the function should return after
images. Ignored when getting projection images.
:return: The set of images that correspond with a given image key.
"""
assert self.image_key_dataset is not None
assert self.data is not None
if image_key_number is ImageKeys.Projections:
indices = self.image_key_dataset[...] == image_key_number.value
else:
if before:
indices = self.image_key_dataset[:self.image_key_dataset.size // 2] == image_key_number.value
else:
indices = self.image_key_dataset[:] == image_key_number.value
indices[:self.image_key_dataset.size // 2] = False
# Shouldn't have to use numpy.where but h5py doesn't allow indexing with bool arrays currently
return self.data[np.where(indices)]
def _find_data_title(self) -> str:
"""
Find the title field in the tomo_entry.
:return: The title if it was found, "NeXus Data" otherwise.
"""
assert self.tomo_entry is not None
try:
return self.tomo_entry["title"][0].decode("UTF-8")
except (KeyError, ValueError):
logger.info("A valid title couldn't be found. Using 'NeXus Data' instead.")
return "NeXus Data"
[docs]
def get_dataset(self) -> Tuple[StrictDataset, str]:
"""
Create a LoadingDataset and title using the arrays that have been retrieved from the NeXus file.
:return: A tuple containing the Dataset and the data title string.
"""
sample_images = self._create_sample_images()
sample_images.name = self.title
return StrictDataset(sample=sample_images,
flat_before=self._create_images_if_required(self.flat_before_array, "Flat Before"),
flat_after=self._create_images_if_required(self.flat_after_array, "Flat After"),
dark_before=self._create_images_if_required(self.dark_before_array, "Dark Before"),
dark_after=self._create_images_if_required(self.dark_after_array, "Dark After"),
name=self.title), self.title
def _create_sample_images(self):
"""
Creates the sample Images object.
:return: An Images object containing projections. If given, projection angles, pixel size, and 180deg are also
set.
"""
assert self.sample_array is not None
# Create sample array and Images object
self.sample_array = self.sample_array[self.view.start_widget.value():self.view.stop_widget.value():self.view.
step_widget.value()]
sample_images = self._create_images(self.sample_array, "Projections")
# Set attributes
sample_images.pixel_size = int(self.view.pixelSizeSpinBox.value())
if self.projection_angles is not None:
sample_images.set_projection_angles(
ProjectionAngles(self.projection_angles[self.view.start_widget.value():self.view.stop_widget.value(
):self.view.step_widget.value()]))
return sample_images
def _create_images(self, data_array: np.ndarray, name: str) -> Images:
"""
Use a data array to create an Images object.
:param data_array: The images array obtained from the NeXus file.
:param name: The name of the image dataset.
:return: An Images object.
"""
data = pu.create_array(data_array.shape, self.view.pixelDepthComboBox.currentText())
data[:] = data_array
return Images(data, [f"{name} {self.title}"])
def _create_images_if_required(self, data_array: np.ndarray, name: str) -> Optional[Images]:
"""
Create the Images objects if the corresponding data was found in the NeXus file, and the user checked the
"Use?" checkbox.
:param data_array: The images data array.
:param name: The name of the images.
:return: An Images object or None.
"""
if data_array.size == 0 or not self.view.checkboxes[name].isChecked():
return None
return self._create_images(data_array, name)