# Copyright (C) 2022 ISIS Rutherford Appleton Laboratory UKRI
# SPDX - License - Identifier: GPL-3.0-or-later
import datetime
import json
import os.path
import uuid
from copy import deepcopy
from typing import List, Optional, Any, Dict, Union, TextIO
import numpy as np
from mantidimaging.core.data.utility import mark_cropped
from mantidimaging.core.operation_history import const
from mantidimaging.core.parallel import utility as pu
from mantidimaging.core.utility.data_containers import ProjectionAngles, Counts, Indices
from mantidimaging.core.utility.imat_log_file_parser import IMATLogFile
from mantidimaging.core.utility.sensible_roi import SensibleROI
[docs]
class Images:
NO_FILENAME_IMAGE_TITLE_STRING = "Image: {}"
name: str
def __init__(self,
data: np.ndarray,
filenames: Optional[List[str]] = None,
indices: Union[List[int], Indices, None] = None,
metadata: Optional[Dict[str, Any]] = None,
sinograms: bool = False,
name: Optional[str] = None):
"""
:param data: Images of the Sample/Projection data
:param filenames: All filenames that were matched for loading
:param indices: Indices that were actually loaded
:param metadata: Properties to copy when creating a new stack from an existing one
:param name: A name for the stack
"""
self._data = data
self.indices = indices
self._id = uuid.uuid4()
self._filenames = filenames
self.metadata: Dict[str, Any] = deepcopy(metadata) if metadata else {}
self._is_sinograms = sinograms
self._proj180deg: Optional[Images] = None
self._log_file: Optional[IMATLogFile] = None
self._projection_angles: Optional[ProjectionAngles] = None
if name is None:
if filenames is not None:
self.name = os.path.splitext(os.path.basename(filenames[0]))[0]
else:
self.name = "untitled"
else:
self.name = name
def __eq__(self, other):
if isinstance(other, Images):
return np.array_equal(self.data, other.data) \
and self.is_sinograms == other.is_sinograms \
and self.metadata == other.metadata \
and self.indices == other.indices
elif isinstance(other, np.ndarray):
return np.array_equal(self.data, other)
else:
raise ValueError(f"Cannot compare against {other}")
def __ne__(self, other):
return not self == other
def __str__(self):
return f'Image Stack: data={self.data.shape} | properties|={len(self.metadata)}'
[docs]
def count(self) -> int:
return len(self._filenames) if self._filenames else 0
@property
def filenames(self) -> Optional[List[str]]:
return self._filenames
@filenames.setter
def filenames(self, new_ones: List[str]):
assert len(new_ones) == self.data.shape[0], "Number of filenames and number of images must match."
self._filenames = new_ones
@property
def id(self) -> uuid.UUID:
return self._id
[docs]
def record_operation(self, func_name: str, display_name, *args, **kwargs):
if const.OPERATION_HISTORY not in self.metadata:
self.metadata[const.OPERATION_HISTORY] = []
def accepted_type(o):
return any([isinstance(o, expected) for expected in [str, int, float, bool, tuple, list, SensibleROI]])
def prepare(o):
if isinstance(o, SensibleROI):
return list(o)
else:
return o
self.metadata[const.OPERATION_HISTORY].append({
const.TIMESTAMP:
datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
const.OPERATION_NAME:
func_name,
const.OPERATION_KEYWORD_ARGS: {k: prepare(v)
for k, v in kwargs.items() if accepted_type(v)},
const.OPERATION_DISPLAY_NAME:
display_name
})
[docs]
def copy(self, flip_axes=False) -> 'Images':
shape = (self.data.shape[1], self.data.shape[0], self.data.shape[2]) if flip_axes else self.data.shape
data_copy = pu.create_array(shape, self.data.dtype)
if flip_axes:
data_copy[:] = np.swapaxes(self.data, 0, 1)
else:
data_copy[:] = self.data[:]
images = Images(data_copy,
indices=deepcopy(self.indices),
metadata=deepcopy(self.metadata),
sinograms=not self.is_sinograms if flip_axes else self.is_sinograms)
return images
[docs]
def copy_roi(self, roi: SensibleROI):
shape = (self.data.shape[0], roi.height, roi.width)
data_copy = pu.create_array(shape, self.data.dtype)
data_copy[:] = self.data[:, roi.top:roi.bottom, roi.left:roi.right]
images = Images(data_copy,
indices=deepcopy(self.indices),
metadata=deepcopy(self.metadata),
sinograms=self._is_sinograms)
mark_cropped(images, roi)
return images
[docs]
def index_as_images(self, index) -> 'Images':
return Images(np.asarray([self.data[index]]), metadata=deepcopy(self.metadata), sinograms=self.is_sinograms)
@property
def height(self):
if not self._is_sinograms:
return self.data.shape[1]
else:
return self.data.shape[0]
@property
def width(self):
return self.data.shape[2]
@property
def h_middle(self) -> float:
"""
Returns the horizontal middle (middle column) of the projections
"""
return self.width / 2
@property
def v_middle(self) -> float:
"""
Returns the vertical middle (middle row) of the projections
"""
return self.height / 2
@property
def num_images(self) -> int:
return self.data.shape[0]
@property
def num_projections(self) -> int:
if not self._is_sinograms:
return self.data.shape[0]
else:
return self.data.shape[1]
@property
def num_sinograms(self) -> int:
return self.height
[docs]
def sino(self, slice_idx) -> np.ndarray:
if not self._is_sinograms:
return np.swapaxes(self.data, 0, 1)[slice_idx]
else:
return self.data[slice_idx]
[docs]
def projection(self, projection_idx) -> np.ndarray:
if self._is_sinograms:
return np.swapaxes(self.data, 0, 1)[projection_idx]
else:
return self.data[projection_idx]
[docs]
def has_proj180deg(self):
return self._proj180deg is not None
@property
def proj180deg(self) -> Optional['Images']:
return self._proj180deg
@proj180deg.setter
def proj180deg(self, value: 'Images'):
assert isinstance(value, Images)
self._proj180deg = value
@property
def projections(self):
return self._data if not self._is_sinograms else np.swapaxes(self._data, 0, 1)
@property
def sinograms(self):
return self._data if self._is_sinograms else np.swapaxes(self._data, 0, 1)
@property
def data(self) -> np.ndarray:
return self._data
@data.setter
def data(self, other: np.ndarray):
self._data = other
@property
def dtype(self):
return self._data.dtype
[docs]
@staticmethod
def create_empty_images(shape, dtype, metadata):
arr = pu.create_array(shape, dtype)
return Images(arr, metadata=metadata)
@property
def is_sinograms(self) -> bool:
return self._is_sinograms
@property
def log_file(self):
return self._log_file
@log_file.setter
def log_file(self, value: IMATLogFile):
if value is not None:
self.metadata[const.LOG_FILE] = value.source_file
elif value is None:
del self.metadata[const.LOG_FILE]
self._log_file = value
[docs]
def set_projection_angles(self, angles: ProjectionAngles):
if len(angles.value) != self.num_images:
raise RuntimeError("The number of angles does not match the number of images. "
f"Num angles {len(angles.value)} and num images {self.num_images}")
self._projection_angles = angles
[docs]
def projection_angles(self, max_angle: float = 360.0) -> ProjectionAngles:
"""
Return projection angles, in priority order:
- From a log
- From the manually loaded file with a list of angles
- Automatically generated with equidistant step
:param max_angle: The maximum angle up to which the angles will be generated.
Only used when the angles are generated, if they are provided
via a log or a file the argument will be ignored.
"""
if self._log_file is not None:
return self._log_file.projection_angles()
elif self._projection_angles is not None:
return self._projection_angles
else:
return ProjectionAngles(np.linspace(0, np.deg2rad(max_angle), self.num_projections))
[docs]
def counts(self) -> Optional[Counts]:
if self._log_file is not None:
return self._log_file.counts()
else:
return None
@property
def pixel_size(self):
return self.metadata.get(const.PIXEL_SIZE, 0)
@pixel_size.setter
def pixel_size(self, value: int):
self.metadata[const.PIXEL_SIZE] = value
[docs]
def clear_proj180deg(self):
self._proj180deg = None
[docs]
def make_name_unique(self, existing_names: List[str]):
name = self.name
num = 1
while self.name in existing_names:
num += 1
self.name = f"{name}_{num}"
if num > 1000:
raise ValueError(f"Could not make unique name for: {name}")