Source code for mumott.core.geometry

from __future__ import annotations
import logging
import tarfile
import tempfile
import json
import os
import codecs
import numpy as np
from typing import NamedTuple, Union
from scipy.spatial.transform import Rotation
from mumott.core.hashing import list_to_hash
from mumott.core.probed_coordinates import ProbedCoordinates
from numpy.typing import NDArray

logger = logging.getLogger(__name__)


[docs]class GeometryTuple(NamedTuple): """Tuple for passing and returning projection-wise geometry information. This is a helper class used by :class:`Geometry`. Attributes ---------- rotation Rotation matrix. If the :attr:`angle` and :attr:`axis` arguments are given, this matrix should correspond to the matrix given by R_outer @ R_inner, where R_inner is defined by a rotation by :attr:`inner_angle` about :attr:`inner_axis`, and similarly for R_outer. j_offset Offset to align projection in the direction j. k_offset Offset to align projection in the direction k. inner_angle Angle of rotation about :attr:`inner_axis` in radians. outer_angle Angle of rotation about :attr:`outer_axis` in radians. inner_axis Inner rotation axis. outer_axis Outer rotation axis. """ rotation: np.ndarray[float] = np.eye(3, dtype=float) j_offset: float = float(0) k_offset: float = float(0) inner_angle: float = None outer_angle: float = None inner_axis: np.ndarray[float] = None outer_axis: np.ndarray[float] = None def __hash__(self) -> int: to_hash = [self.rotation.ravel(), self.j_offset, self.k_offset, self.inner_angle, self.outer_angle, self.inner_axis, self.outer_axis] return int(list_to_hash(to_hash), 16) def __str__(self) -> str: wdt = 74 s = [] s += ['-' * wdt] s += ['GeometryTuple'.center(wdt)] s += ['-' * wdt] with np.printoptions(threshold=4, precision=5, linewidth=60, edgeitems=1): ss = ', '.join([f'{r}' for r in self.rotation]) s += ['{:18} : {}'.format('rotation', ss)] s += ['{:18} : {}'.format('j_offset', self.j_offset)] s += ['{:18} : {}'.format('k_offset', self.k_offset)] s += ['{:18} : {}'.format('inner_angle', self.inner_angle)] s += ['{:18} : {}'.format('outer_angle', self.outer_angle)] ss = ', '.join([f'{r}' for r in self.inner_axis]) s += ['{:18} : {}'.format('inner_axis', ss)] ss = ', '.join([f'{r}' for r in self.outer_axis]) s += ['{:18} : {}'.format('outer_axis', ss)] s += ['{:18} : {}'.format('Hash', hex(hash(self))[2:8])] s += ['-' * wdt] return '\n'.join(s) def _repr_html_(self) -> str: s = [] s += ['<h3>GeometryTuple</h3>'] s += ['<table border="1" class="dataframe">'] s += ['<thead><tr><th style="text-align: left;">Field</th><th>Size</th><th>Data</th></tr></thead>'] s += ['<tbody>'] with np.printoptions(threshold=4, edgeitems=2, precision=2, linewidth=40): s += ['<tr><td style="text-align: left;">Rotation</td>'] s += [f'<td>{self.rotation.shape}</td><td>{self.rotation}</td></tr>'] s += ['<tr><td style="text-align: left;">j_offset</td>'] s += [f'<td>{1}</td><td>{self.j_offset:.4f}</td></tr>'] s += ['<tr><td style="text-align: left;">k_offset</td>'] s += [f'<td>{1}</td><td>{self.k_offset:.4f}</td></tr>'] s += ['<tr><td style="text-align: left;">inner_angle</td>'] s += [f'<td>{1}</td>' f'<td>{self.inner_angle}'] s += ['<tr><td style="text-align: left;">outer_angle</td>'] s += [f'<td>{1}</td>' f'<td>{self.outer_angle}</td>'] s += ['<tr><td style="text-align: left;">inner_axis</td>'] s += [f'<td>{self.inner_axis.shape}</td><td>{self.inner_axis}</td></tr>'] s += ['<tr><td style="text-align: left;">outer_axis</td>'] s += [f'<td>{self.outer_axis.shape}</td><td>{self.outer_axis}</td></tr>'] s += [f'<td>{6}</td>' f'<td>{hex(hash(self))[2:8]} (hash)</td></tr>'] s += ['</tbody>'] s += ['</table>'] return '\n'.join(s)
[docs]class Geometry: """ Stores information about the system geometry. Instances of this class are used by :class:`DataContainer <mumott.data_handling.DataContainer>` and :class:`ProjectionStack <mumott.core.projection_stack.ProjectionStack>` to maintain geometry information. They can be stored as a file using the :meth:`write` method. This allows one to (re)create a :class:`Geometry` instance from an earlier and overwrite the geometry information read by a :class:`DataContainer <mumott.data_handling.DataContainer>` instance. This is useful, for example, in the context of alignment. Parameters ---------- filename Name of file from which to read geometry information. Defaults to ``None``, in which case the instance is created with default parameters. """ def __init__(self, filename: str = None): self._rotations = [] self._j_offsets = [] self._k_offsets = [] self._p_direction_0 = np.array([0, 1, 0]).astype(float) self._j_direction_0 = np.array([1, 0, 0]).astype(float) self._k_direction_0 = np.array([0, 0, 1]).astype(float) self._detector_direction_origin = np.array([1, 0, 0]).astype(float) self._detector_direction_positive_90 = np.array([0, 0, 1]).astype(float) self._projection_shape = np.array([0, 0]).astype(np.int32) self._volume_shape = np.array([0, 0, 0]).astype(np.int32) self._detector_angles = np.array([]).astype(float) self._two_theta = np.array([0.0]) self._reconstruction_rotations = [] self._system_rotations = [] self._inner_angles = [] self._outer_angles = [] self._inner_axes = [] self._outer_axes = [] self._full_circle_covered = False if filename is not None: self.read(filename)
[docs] def write(self, filename: str) -> None: """Method for writing the current state of a :class:`Geometry` instance to file. Notes ----- Any rotations in :attr:`reconstruction_rotations` and :attr:`system_rotations` will be applied to the :attr:`rotations` and system vectors respectively prior to writing. Parameters ---------- filename Name of output file. """ to_write = dict(_rotations=self.rotations_as_array.tolist(), _j_offsets=self._j_offsets, _k_offsets=self._k_offsets, p_direction_0=self.p_direction_0.tolist(), j_direction_0=self.j_direction_0.tolist(), k_direction_0=self.k_direction_0.tolist(), detector_direction_origin=self.detector_direction_origin.tolist(), detector_direction_positive_90=self.detector_direction_positive_90.tolist(), two_theta=self.two_theta.tolist(), projection_shape=self.projection_shape.tolist(), volume_shape=self.volume_shape.tolist(), detector_angles=self.detector_angles.tolist(), full_circle_covered=[bool(self.full_circle_covered)], _inner_angles=self._inner_angles, _outer_angles=self._outer_angles, _inner_axes=[j.tolist() if j is not None else None for j in self._inner_axes], _outer_axes=[j.tolist() if j is not None else None for j in self._outer_axes], checksum=[hash(self)]) with tarfile.open(name=filename, mode='w') as tar_file: for key, item in to_write.items(): temp_file = tempfile.NamedTemporaryFile(delete=False) temp_file.close() with codecs.open(temp_file.name, 'w', encoding='utf-8') as tf: json.dump(item, tf) tf.flush() with open(temp_file.name, 'rb') as tt: tar_info = tar_file.gettarinfo(arcname=key, fileobj=tt) tar_file.addfile(tar_info, tt) os.remove(temp_file.name)
[docs] def read(self, filename: str) -> None: """Method for reading the current state of a :class:`Geometry` instance from file. Parameters ---------- filename Name of input file. """ to_read = ['_rotations', '_j_offsets', '_k_offsets', 'p_direction_0', 'j_direction_0', 'k_direction_0', 'detector_direction_origin', 'detector_direction_positive_90', 'two_theta', 'projection_shape', 'volume_shape', 'detector_angles', 'full_circle_covered', '_inner_angles', '_outer_angles', '_inner_axes', '_outer_axes', 'checksum'] with tarfile.open(name=filename, mode='r') as tar_file: for key in to_read: temp_file = tempfile.NamedTemporaryFile(delete=False) try: temp_file.write(tar_file.extractfile(key).read()) except KeyError: logger.warning(f'Key {key} not found!') temp_file.close() continue temp_file.close() with codecs.open(temp_file.name, 'r', encoding='utf-8') as f: text = f.read() data_as_list = json.loads(text) for i, entry in enumerate(data_as_list): if entry == 'null': data_as_list[i] = None if key == 'checksum': checksum = data_as_list[0] elif key in ('_rotations', '_inner_axes', '_outer_axes'): setattr(self, key, []) for entry in data_as_list: # necessary check to avoid [array(None), ...] structure if entry is not None: entry = np.array(entry) getattr(self, key).append(entry) elif key in ('_j_offsets', '_k_offsets', '_inner_angles', '_outer_angles'): setattr(self, key, data_as_list) elif key in ('full_circle_covered'): setattr(self, key, data_as_list[0]) else: setattr(self, key, np.array(data_as_list)) if checksum != hash(self): logger.warning(f'Checksum does not match! Checksum is {checksum},' f' but hash(self) is {hash(self)}. This may be due to' ' version differences, but please proceed with caution!')
[docs] def rotate_reconstruction(self, A: np.ndarray[float] = None, axis: np.ndarray[float] = None, angle: np.ndarray[float] = None): r""" Rotates the reconstruction geometry. The given rotation matrix will modify the rotation matrix of each projection by multiplication from the right, such that .. math :: R_i' = R_i A where :math:`R_i` is the rotation matrix of projection :math:`i` and :math:`A` is the rotation matrix. For each projection, the system vectors are then rotated by .. math :: v_i = (R_i')^T v = A^T R_i^T v where :math:`v` corresponds to e.g., :attr:`p_direction_0`. Notes ----- It is not possible to directly modify :attr:`rotations` after adding a reconstruction rotation. Parameters ---------- A A 3-by-3 rotation matrix. If not given, then :attr:`axis` and :attr:`angle` must be provided. axis An axis, given as a unit length 3-vector, about which the rotation is defined. Not used if :attr:`A` is provided. angle The angle in radians of the rotation about :attr:`axis`. Not used if :attr:`A` is provided. """ if A is None: A = Rotation.from_rotvec(axis * angle / np.linalg.norm(axis)).as_matrix() elif axis is not None or angle is not None: logger.warning('A provided along with axis and/or angle; axis/angle will be ignored!') self._reconstruction_rotations.append(A)
[docs] def rotate_system_vectors(self, A: np.ndarray[float] = None, axis: np.ndarray[float] = None, angle: np.ndarray[float] = None): r""" Rotates the system vectors. The given rotation matrix will modify the system vectors by .. math :: v' = A v where :math:`v` is a system vector, e.g., :attr:`p_direction_0`, and :math:`A` is the rotation matrix. For each projection, the system vectors are then rotated by .. math :: v_i = R_i^T A v where :math:`R_i` corresponds to :attr:`rotations` for projection :math:`i`. Notes ----- It is not possible to directly modify the system vectors after adding a system rotation. Parameters ---------- A A 3-by-3 rotation matrix. If not given, then :attr:`axis` and :attr:`angle` must be provided. axis An axis, given as a 3-vector, about which a rotation can be defined. Not used if :attr:`A` is provided. angle The angle in radians of the rotation about :attr:`axis`. Not used if :attr:`A` is provided. """ if A is None: A = Rotation.from_rotvec(axis * angle / np.linalg.norm(axis)).as_matrix() elif axis is not None or angle is not None: logger.warning('A provided along with axis and/or angle; axis/angle will be ignored!') self._system_rotations.append(A)
[docs] def append(self, value: GeometryTuple) -> None: """ Appends projection-wise geometry data provided as a :class:`GeometryTuple <mumott.core.geometry.GeometryTuple>`. """ self._rotations.append(value.rotation) self._j_offsets.append(value.j_offset) self._k_offsets.append(value.k_offset) self._inner_angles.append(value.inner_angle) self._outer_angles.append(value.outer_angle) self._inner_axes.append(value.inner_axis) self._outer_axes.append(value.outer_axis)
[docs] def insert(self, key: int, value: GeometryTuple) -> None: """ Inserts projection-wise data handed via a :class:`GeometryTuple <mumott.core.geometry.GeometryTuple>`. """ self._rotations.insert(key, value.rotation) self._j_offsets.insert(key, value.j_offset) self._k_offsets.insert(key, value.k_offset) self._inner_angles.insert(key, value.inner_angle) self._outer_angles.insert(key, value.outer_angle) self._inner_axes.insert(key, value.inner_axis) self._outer_axes.insert(key, value.outer_axis)
def __setitem__(self, key: int, value: GeometryTuple) -> None: """ Sets projection-wise data handed via a :class:`GeometryTuple`.""" self._rotations[key] = value.rotation self._j_offsets[key] = value.j_offset self._k_offsets[key] = value.k_offset self._inner_angles[key] = value.inner_angle self._outer_angles[key] = value.outer_angle self._inner_axes[key] = value.inner_axis self._outer_axes[key] = value.outer_axis def __getitem__(self, key: int) -> GeometryTuple: """ Returns projection-wise data as a :class:`GeometryTuple`.""" return GeometryTuple(rotation=self.rotations[key], j_offset=self._j_offsets[key], k_offset=self._k_offsets[key], inner_angle=self._inner_angles[key], outer_angle=self._outer_angles[key], inner_axis=self._inner_axes[key], outer_axis=self._outer_axes[key]) def __delitem__(self, key: int) -> None: del self._rotations[key] del self._j_offsets[key] del self._k_offsets[key] del self._inner_angles[key] del self._outer_angles[key] del self._inner_axes[key] del self._outer_axes[key] def _get_probed_coordinates(self) -> NDArray[np.float_]: """ Calculates and returns the probed polar and azimuthal coordinates on the unit sphere at each angle of projection and for each detector segment in the geometry of the system. """ n_proj = len(self) n_seg = len(self.detector_angles) probed_directions_zero_rot = np.zeros((n_seg, 3, 3)) # Impose symmetry if needed. if not self.full_circle_covered: shift = np.pi else: shift = 0 det_bin_middles_extended = np.copy(self.detector_angles) det_bin_middles_extended = np.insert(det_bin_middles_extended, 0, det_bin_middles_extended[-1] + shift) det_bin_middles_extended = np.append(det_bin_middles_extended, det_bin_middles_extended[1] + shift) for ii in range(n_seg): # Check if the interval from the previous to the next bin goes over the -pi +pi discontinuity before = det_bin_middles_extended[ii] now = det_bin_middles_extended[ii + 1] after = det_bin_middles_extended[ii + 2] if abs(before - now + 2 * np.pi) < abs(before - now): before = before + 2 * np.pi elif abs(before - now - 2 * np.pi) < abs(before - now): before = before - 2 * np.pi if abs(now - after + 2 * np.pi) < abs(now - after): after = after - 2 * np.pi elif abs(now - after - 2 * np.pi) < abs(now - after): after = after + 2 * np.pi # Generate a linearly spaced set of angles covering the detector segment start = 0.5 * (before + now) end = 0.5 * (now + after) angles = np.linspace(start, end, 3) # Make the zero-rotation-projection vectors corresponding to the given angles probed_directions_zero_rot[ii, :, :] = np.cos(angles[:, np.newaxis]) * \ self.detector_direction_origin[np.newaxis, :] probed_directions_zero_rot[ii, :, :] += np.sin(angles[:, np.newaxis]) * \ self.detector_direction_positive_90[np.newaxis, :] # Do wide-angle rotation n_twotheta = len(self.two_theta) twotheta = np.repeat(self.two_theta, n_seg)[:, np.newaxis, np.newaxis] probed_directions_zero_rot = np.tile(probed_directions_zero_rot, (n_twotheta, 1, 1)) probed_directions_zero_rot = probed_directions_zero_rot * np.cos(twotheta/2)\ - np.sin(twotheta/2) * self.p_direction_0 # Initialize array for vectors probed_direction_vectors = np.zeros((n_proj, n_seg * n_twotheta, 3, 3), dtype=np.float64) # Calculate all the rotations probed_direction_vectors[...] = \ np.einsum('kij,mli->kmlj', self.rotations, probed_directions_zero_rot) great_circle_offsets = np.einsum('kij,mli->kmlj', self.rotations, -np.sin(twotheta / 2) * self.p_direction_0) return ProbedCoordinates(probed_direction_vectors, great_circle_offsets)
[docs] def delete_projections(self) -> None: """ Delete all projections.""" self._rotations = [] self._j_offsets = [] self._k_offsets = [] self._inner_angles = [] self._outer_angles = [] self._inner_axes = [] self._outer_axes = []
def _get_reconstruction_rotation(self) -> np.ndarray[float]: """ Internal method for composing reconstruction rotations. """ reconstruction_rotation = np.eye(3, dtype=float) for r in self.reconstruction_rotations: reconstruction_rotation = reconstruction_rotation @ r return reconstruction_rotation def _get_system_rotation(self) -> np.ndarray[float]: """ Internal method for composing system rotations. """ system_rotation = np.eye(3, dtype=float) for r in self.system_rotations: system_rotation = r @ system_rotation return system_rotation @property def system_rotations(self) -> list[np.ndarray[float]]: """ list of rotation matrices sequentially applied to the basis vectors of the system. """ return self._system_rotations @system_rotations.setter def system_rotations(self, value: list[np.ndarray[float]]) -> list[np.ndarray[float]]: self._system_rotations = list(value) @property def reconstruction_rotations(self) -> list[np.ndarray[float]]: """ list of rotation matrices sequentially applied to the reconstruction geometry of the system. """ return self._reconstruction_rotations @reconstruction_rotations.setter def reconstruction_rotations(self, value: list[np.ndarray[float]]) -> list[np.ndarray[float]]: self._reconstruction_rotations = list(value) @property def rotations(self) -> list[np.ndarray[float]]: """ Rotation matrices for the experimental rotation corresponding to each projection of data.""" if len(self.reconstruction_rotations) > 0: reconstruction_rotation = self._get_reconstruction_rotation() return [r @ reconstruction_rotation for r in self._rotations] return self._rotations @property def rotations_as_array(self) -> np.ndarray[float]: """ Rotation matrices corresponding to each projection of data as an array.""" if len(self) == 0: return np.array([]) return np.stack(list(self.rotations), axis=0) @rotations.setter def rotations(self, value: Union[list, np.ndarray[float]]) -> None: if len(self._reconstruction_rotations) > 0: raise ValueError('Cannot modify rotations when reconstruction ' 'rotations are in use.') self._rotations = list(value) @property def p_direction_0(self) -> np.ndarray[float]: """ The projection direction when no experimental rotation is applied.""" if len(self._system_rotations) > 0: system_rotation = self._get_system_rotation() return system_rotation @ self._p_direction_0 return self._p_direction_0 @p_direction_0.setter def p_direction_0(self, value: np.ndarray[float]) -> None: if len(self.system_rotations) > 0: raise ValueError('Cannot modify system vectors when system ' 'rotations are in use.') if np.size(value) != 3: raise ValueError('The size of the new value must be 3, but ' f'the provided value has size {np.size(value)}') self._p_direction_0[...] = value @property def j_direction_0(self) -> np.ndarray[float]: """ The direction corresponding to the first index in each projection when no experimental rotation is applied.""" if len(self._system_rotations) > 0: system_rotation = np.eye(3) for r in self.system_rotations: system_rotation = system_rotation @ r return system_rotation @ self._j_direction_0 return self._j_direction_0 @j_direction_0.setter def j_direction_0(self, value: np.ndarray[float]) -> None: if len(self.system_rotations) > 0: raise ValueError('Cannot modify system vectors when system ' 'rotations are in use.') if np.size(value) != 3: raise ValueError('The size of the new value must be 3, but ' f'the provided value has size {np.size(value)}') self._j_direction_0[...] = value @property def k_direction_0(self) -> np.ndarray[float]: """ The direction corresponding to the second index in each projection when no experimental rotation is applied.""" if len(self._system_rotations) > 0: system_rotation = self._get_system_rotation() return system_rotation @ self._k_direction_0 return self._k_direction_0 @k_direction_0.setter def k_direction_0(self, value: np.ndarray[float]) -> None: if len(self.system_rotations) > 0: raise ValueError('Cannot modify system vectors when system ' 'rotations are in use.') if np.size(value) != 3: raise ValueError('The size of the new value must be 3, but ' f'the provided value has size {np.size(value)}') self._k_direction_0[...] = value @property def detector_direction_origin(self) -> np.ndarray[float]: """ The direction at which the angle on the detector is zero, when no experimental rotation is applied.""" if len(self._system_rotations) > 0: system_rotation = self._get_system_rotation() return system_rotation @ self._detector_direction_origin return self._detector_direction_origin @detector_direction_origin.setter def detector_direction_origin(self, value: np.ndarray[float]) -> None: if len(self.system_rotations) > 0: raise ValueError('Cannot modify system vectors when system ' 'rotations are in use.') if np.size(value) != 3: raise ValueError('The size of the new value must be 3, but ' f'the provided value has size {np.size(value)}') self._detector_direction_origin[...] = value @property def detector_direction_positive_90(self) -> np.ndarray[float]: """ Rotation matrices corresponding to each projection of data.""" if len(self._system_rotations) > 0: system_rotation = self._get_system_rotation() return system_rotation @ self._detector_direction_positive_90 return self._detector_direction_positive_90 @detector_direction_positive_90.setter def detector_direction_positive_90(self, value: np.ndarray[float]) -> None: if len(self.system_rotations) > 0: raise ValueError('Cannot modify system vectors when system ' 'rotations are in use.') if np.size(value) != 3: raise ValueError('The size of the new value must be 3, but ' f'the provided value has size {np.size(value)}') self._detector_direction_positive_90[...] = value @property def j_offsets(self) -> list[float]: """Offsets to align projection in the direction j.""" return self._j_offsets @property def j_offsets_as_array(self) -> np.ndarray[float]: """Offsets to align projection in the direction j as an array.""" if len(self._j_offsets) == 0: return np.array([]) return np.stack(self.j_offsets, axis=0) @j_offsets.setter def j_offsets(self, value: Union[list[float], np.ndarray[float]]) -> None: self._j_offsets = list(value) @property def outer_angles(self) -> list[float]: """Rotation angles for inner rotation, in radians.""" return list(self._outer_angles) @property def outer_angles_as_array(self) -> np.ndarray[float]: """Rotation angles for inner rotations, in radians, as an array.""" if len(self._outer_angles) == 0: return np.array([]) return np.stack(self.outer_angles, axis=0) @outer_angles.setter def outer_angles(self, value: Union[list[float], np.ndarray[float]]) -> None: self._outer_angles = list(value) self._update_rotations() @property def inner_angles(self) -> list[float]: """Rotation angles for inner rotation, in radians.""" return self._inner_angles @property def probed_coordinates(self) -> ProbedCoordinates: """ An array of 3-vectors with the (x, y, z)-coordinates on the reciprocal space map probed by the method. Structured as ``(N, K, 3, 3)``, where ``N`` is the number of projections, ``K`` is the number of detector segments, the second-to-last axis contains start-, mid-, and endpoints, and the last axis contains the (x, y, z)-coordinates. Notes ----- The number of detector segments is `len(geometry.detecor_angles)*len(geometry.two_theta)` i.e. the product of the number of two_theta bins times the number of azimuthal bins. As a default, only on two theta bin is used. When several two_theta bins are used, the second index corresponds to a raveled array, where the azimuthal is the fast index and two theta is the slow index. """ return self._get_probed_coordinates() def _get_hashable_axes_and_angles(self) -> tuple[np.ndarray[float], ...]: """ Internal method for getting hashable ste of axes and angles, as well as checking if set is valid or if it contains any ``None`` entries.""" attributes = [self._inner_angles, self._outer_angles, self._inner_axes, self._outer_axes] array_props = [self.inner_angles_as_array, self.outer_angles_as_array, self.inner_axes_as_array, self.outer_axes_as_array] return_values = [] for elements, arrays in zip(attributes, array_props): for a in elements: if a is None: return_values.append(None) break else: return_values.append(arrays) return tuple(return_values) @property def inner_angles_as_array(self) -> np.ndarray[float]: """Rotation angles for inner rotations, in radians, as an array.""" if len(self._inner_angles) == 0: return np.array([]) return np.stack(self.inner_angles, axis=0) @inner_angles.setter def inner_angles(self, value: Union[list[float], np.ndarray]) -> None: self._inner_angles = [j for j in value] self._update_rotations() @property def inner_axes(self) -> list[np.ndarray[float]]: """Inner rotation axes. All axes can be set at once using a single array with three entries.""" return self._inner_axes @property def inner_axes_as_array(self) -> np.ndarray[float]: """Inner rotation axes as an array.""" if len(self._inner_axes) == 0: return np.array([]) return np.stack([j if j is not None else [None, None, None] for j in self._inner_axes], axis=0) @inner_axes.setter def inner_axes(self, value: Union[list[float], np.ndarray]) -> None: value = np.array(value) if value.ndim == 1: if value.shape != (3,): raise ValueError('inner_axes may be set using either ' 'a list/array of size-3 arrays or an array ' 'of shape (3,), but the provided array has shape ' f'{value.shape}.') self._inner_axes = [value for _ in self._inner_axes] elif value.ndim == 2: if len(value) != len(self): raise ValueError('If inner_axes is set using a list/array of ' 'size-3 arrays, then it must be of the same length ' f'as the Geometry instance ({len(self)}), ' f'but it is of length {len(value)}.') if value[0].size != 3: raise ValueError('inner_axes may be set using either ' 'a list/array of size-3 arrays or an array ' 'of shape 3, but the provided array has shape ' f'{value.shape}.') self._inner_axes = [j for j in value] else: raise ValueError('inner_axes must be set either with a list/array of ' f'shape ({len(self)}, 3) or with an array of shape ' f'(3,), but the provided array has shape {value.shape}!') self._update_rotations() @property def outer_axes(self) -> list[np.ndarray[float]]: """Inner rotation axes. All axes can be set at once using a single array with three entries.""" return self._outer_axes @property def outer_axes_as_array(self) -> np.ndarray[float]: """Outer rotation axes as an array.""" if len(self._outer_axes) == 0: return np.array([]) return np.stack([j if j is not None else [None, None, None] for j in self._outer_axes], axis=0) @outer_axes.setter def outer_axes(self, value: Union[list[np.ndarray[float]], np.ndarray[float]]) -> None: value = np.array(value) if value.ndim == 1: if value.shape != (3,): raise ValueError('outer_axes may be set using either ' 'a list/array of size-3 arrays or an array ' 'of shape (3,), but the provided array has shape ' f'{value.shape}.') self._outer_axes = [value for j in self._outer_axes] elif value.ndim == 2: if len(value) != len(self): raise ValueError('If outer_axes is set using a list/array of ' 'size-3 arrays, then it must be of the same length ' f'as the Geometry instance ({len(self)}), but it is of length {len(value)}.') if value[0].size != 3: raise ValueError('outer_axes may be set using either ' 'a list/array of size-3 arrays or an array ' 'of shape 3, but the provided array has shape ' f'{value.shape}.') self._outer_axes = [j for j in value] else: raise ValueError('outer_axes must be set either with a list/array of ' f'shape ({len(self)}, 3) or with an array of shape ' f'(3,), but the provided array has shape {value.shape}!') self._update_rotations() def _update_rotations(self) -> None: """Internal method for updating rotations based on changes to inner and outer axes. """ can_update_rotations = np.all([h is not None for h in self._get_hashable_axes_and_angles()]) if not can_update_rotations: logger.info('None values found in some axis or angle entries,' ' rotations not updated.') return for i in range(len(self)): R_inner = Rotation.from_rotvec(self.inner_angles[i] * self.inner_axes[i]).as_matrix() R_outer = Rotation.from_rotvec(self.outer_angles[i] * self.outer_axes[i]).as_matrix() self.rotations[i] = R_outer @ R_inner @property def k_offsets(self) -> list[float]: """Offsets to align projection in the direction k.""" return self._k_offsets @property def k_offsets_as_array(self) -> np.ndarray[float]: """Offsets to align projection in the direction k as an array.""" if len(self._k_offsets) == 0: return np.array([]) return np.stack(self.k_offsets, axis=0) @property def hash_rotations(self) -> str: """ A blake2b hash of :attr:`rotations_as_array`. """ return list_to_hash([self.rotations_as_array]) @property def hash_j_offsets(self) -> str: """ A blake2b hash of :attr:`j_offsets_as_array`. """ return list_to_hash([self.j_offsets_as_array]) @property def hash_k_offsets(self) -> str: """ A blake2b hash of :attr:`k_offsets_as_array`. """ return list_to_hash([self.k_offsets_as_array]) @property def hash_inner_angles(self) -> str: """ A blake2b hash of :attr:`inner_angle`. """ return list_to_hash(self.inner_angles) @property def hash_outer_angles(self) -> str: """ A blake2b hash of :attr:`outer_anglesy`. """ return list_to_hash(self.outer_angles) @property def hash_inner_axes(self) -> str: """ A blake2b hash of :attr:`inner_axes`. """ return list_to_hash(self.inner_axes) @property def hash_outer_axes(self) -> str: """ A blake2b hash of :attr:`outer_axes`. """ return list_to_hash(self.outer_axes) @property def projection_shape(self) -> NDArray[int]: """ 2D shape of the raster-scan. 1st element is the number of steps in the j-direction and the second is the number of steps in the k-direction.""" return self._projection_shape @projection_shape.setter def projection_shape(self, value: NDArray[int]) -> None: if type(value) is not tuple: value = tuple(value) if len(value) != 2: raise ValueError('Length of projection_shape must be exactly 2.') if not all(isinstance(item, (int, np.integer)) for item in value): first_wrong = next((item for item in value if type(item) is not int)) raise TypeError(f'{type(first_wrong)} cannot be interpreted as an integer.') self._projection_shape = np.array(value).astype(np.int32) @property def volume_shape(self) -> NDArray[int]: """ 3D shape of the reconstruction voxel array. 1st element is the number of points along the x-direction. 2nd is y and 3rd is z.""" return self._volume_shape @volume_shape.setter def volume_shape(self, value: NDArray[int]) -> None: if type(value) is not tuple: value = tuple(value) if len(value) != 3: raise ValueError('Length of volume_shape must be exactly 2.') if not all(isinstance(item, (int, np.integer)) for item in value): first_wrong = next((item for item in value if type(item) is not int)) raise TypeError(f'{type(first_wrong)} cannot be interpreted as an integer.') self._volume_shape = np.array(value).astype(np.int32) @property def detector_angles(self) -> NDArray(float): """ Azimuthal angles of detector segments in radians. One-dimensional sequence of center positions""" return self._detector_angles @detector_angles.setter def detector_angles(self, value: NDArray(float)) -> None: value = np.array(value).astype(float) if np.ndim(value) != 1: raise ValueError('Detector angles must be a one-dimensional sequence.') self._detector_angles = value @property def two_theta(self) -> NDArray(float): """ Scattering angle in radians. Can be list of angles, if multiple radial bins are used.""" return self._two_theta @two_theta.setter def two_theta(self, value: NDArray(float)) -> None: value = np.array(value) if np.ndim(value) == 0: value = value.flatten() elif np.ndim(value) > 1: raise ValueError('Only scalars or one-dimensional sequences are valid values of two_theta.') self._two_theta = value.astype(float) @property def full_circle_covered(self) -> bool: """ Whether the azimuthal bins cover a half-circle of the detector (False) or the full circle (True). """ return self._full_circle_covered @full_circle_covered.setter def full_circle_covered(self, value: bool) -> None: self._full_circle_covered = bool(value) @k_offsets.setter def k_offsets(self, value: Union[list[float], np.ndarray[float]]) -> None: self._k_offsets = list(value) def __hash__(self) -> int: to_hash = [self.rotations_as_array, self.j_offsets_as_array, self.k_offsets_as_array, self.p_direction_0, self.j_direction_0, self.k_direction_0, self.detector_direction_origin, self.detector_direction_positive_90, self.two_theta, self.projection_shape, self.volume_shape, self.detector_angles, self.full_circle_covered, *self._get_hashable_axes_and_angles()] return int(list_to_hash(to_hash), 16) def __len__(self) -> int: return len(self._rotations) def _get_str_representation(self, max_lines: int = 25) -> str: """ Retrieves a string representation of the object with specified maximum number of lines. Parameters ---------- max_lines The maximum number of lines to return. """ wdt = 74 s = [] s += ['-' * wdt] s += ['Geometry'.center(wdt)] s += ['-' * wdt] with np.printoptions(threshold=3, edgeitems=1, precision=3, linewidth=60): s += ['{:18} : {}'.format('hash_rotations', self.hash_rotations[:6])] s += ['{:18} : {}'.format('hash_j_offsets', self.hash_j_offsets[:6])] s += ['{:18} : {}'.format('hash_k_offsets', self.hash_k_offsets[:6])] s += ['{:18} : {}'.format('p_direction_0', self.p_direction_0)] s += ['{:18} : {}'.format('j_direction_0', self.j_direction_0)] s += ['{:18} : {}'.format('k_direction_0', self.k_direction_0)] s += ['{:18} : {}'.format('hash_inner_angles', self.hash_inner_angles[:6])] s += ['{:18} : {}'.format('hash_outer_angles', self.hash_outer_angles[:6])] s += ['{:18} : {}'.format('hash_inner_axes', self.hash_inner_axes[:6])] s += ['{:18} : {}'.format('hash_outer_axes', self.hash_outer_axes[:6])] s += ['{:18} : {}'.format('detector_direction_origin', self.detector_direction_origin)] s += ['{:18} : {}'.format('detector_direction_positive_90', self.detector_direction_positive_90)] s += ['{:18} : {}°'.format('two_theta', np.rad2deg(self.two_theta))] s += ['{:18} : {}'.format('projection_shape', self.projection_shape)] s += ['{:18} : {}'.format('volume_shape', self.volume_shape)] s += ['{:18} : {}'.format('detector_angles', self.detector_angles)] s += ['{:18} : {}'.format('full_circle_covered', self.full_circle_covered)] s += ['-' * wdt] truncated_s = [] leave_loop = False while not leave_loop: line = s.pop(0).split('\n') for split_line in line: if split_line != '': truncated_s += [split_line] if len(truncated_s) > max_lines - 2: if split_line != '...': truncated_s += ['...'] if split_line != ('=' * wdt): truncated_s += ['=' * wdt] leave_loop = True break if len(s) == 0: leave_loop = True return '\n'.join(truncated_s) def __str__(self) -> str: return self._get_str_representation() def _get_html_representation(self, max_lines: int = 25) -> str: """ Retrieves an html representation of the object with specified maximum number of lines. Parameters ---------- max_lines The maximum number of lines to return. """ s = [] s += ['<h3>Geometry</h3>'] s += ['<table border="1" class="dataframe">'] s += ['<thead><tr><th style="text-align: left;">Field</th><th>Size</th><th>Data</th></tr></thead>'] s += ['<tbody>'] with np.printoptions(threshold=3, edgeitems=1, precision=2, linewidth=40): s += ['<tr><td style="text-align: left;">rotations</td>'] s += [f'<td>{len(self.rotations)}</td>' f'<td>{self.hash_rotations[:6]} (hash)</td></tr>'] s += ['<tr><td style="text-align: left;">j_offsets</td>'] s += [f'<td>{len(self.j_offsets)}</td>' f'<td>{self.hash_j_offsets[:6]} (hash)</td></tr>'] s += ['<tr><td style="text-align: left;">k_offsets</td>'] s += [f'<td>{len(self.k_offsets)}</td>' f'<td>{self.hash_k_offsets[:6]} (hash)</td></tr>'] s += ['<tr><td style="text-align: left;">p_direction_0</td>'] s += [f'<td>{len(self.p_direction_0)}</td><td>{self.p_direction_0}</td></tr>'] s += ['<tr><td style="text-align: left;">j_direction_0</td>'] s += [f'<td>{len(self.j_direction_0)}</td><td>{self.j_direction_0}</td></tr>'] s += ['<tr><td style="text-align: left;">k_direction_0</td>'] s += [f'<td>{len(self.k_direction_0)}</td><td>{self.k_direction_0}</td></tr>'] s += ['<tr><td style="text-align: left;">inner_angles</td>'] s += [f'<td>{len(self.inner_angles)}</td>' f'<td>{self.hash_inner_angles[:6]} (hash)</td></tr>'] s += ['<tr><td style="text-align: left;">outer_angles</td>'] s += [f'<td>{len(self.outer_angles)}</td>' f'<td>{self.hash_outer_angles[:6]} (hash)</td></tr>'] s += ['<tr><td style="text-align: left;">inner_axes</td>'] s += [f'<td>{len(self.inner_axes)}</td>' f'<td>{self.hash_inner_axes[:6]} (hash)</td></tr>'] s += ['<tr><td style="text-align: left;">outer_axes</td>'] s += [f'<td>{len(self.outer_axes)}</td>' f'<td>{self.hash_outer_axes[:6]} (hash)</td></tr>'] s += ['<tr><td style="text-align: left;">detector_direction_origin</td>'] s += [f'<td>{len(self.detector_direction_origin)}</td>' f'<td>{self.detector_direction_origin}</td></tr>'] s += ['<tr><td style="text-align: left;">detector_direction_positive_90</td>'] s += [f'<td>{len(self.detector_direction_positive_90)}</td>' f'<td>{self.detector_direction_positive_90}</td></tr>'] s += ['<tr><td style="text-align: left;">two_theta</td>'] s += [f'<td>{len(self.two_theta)}</td>' '<td>' + f'{self.two_theta * 180 / np.pi}' + r'${}^{\circ}$</td>'] s += ['<tr><td style="text-align: left;">projection_shape</td>'] s += [f'<td>{len(self.projection_shape)}</td><td>{self.projection_shape}</td></tr>'] s += ['<tr><td style="text-align: left;">volume_shape</td>'] s += [f'<td>{len(self.volume_shape)}</td><td>{self.volume_shape}</td></tr>'] s += ['<tr><td style="text-align: left;">detector_angles</td>'] s += [f'<td>{len(self.detector_angles)}</td><td>{self.detector_angles}</td></tr>'] s += ['<tr><td style="text-align: left;">full_circle_covered</td>'] s += [f'<td>{1}</td><td>{self.full_circle_covered}</td></tr>'] s += ['</tbody>'] s += ['</table>'] truncated_s = [] line_count = 0 leave_loop = False while not leave_loop: line = s.pop(0).split('\n') for split_line in line: truncated_s += [split_line] if '</tr>' in split_line: line_count += 1 # Catch if last line had ellipses last_tr = split_line if line_count > max_lines - 1: if last_tr != '<tr><td style="text-align: left;">...</td></tr>': truncated_s += ['<tr><td style="text-align: left;">...</td></tr>'] truncated_s += ['</tbody>'] truncated_s += ['</table>'] leave_loop = True break if len(s) == 0: leave_loop = True return '\n'.join(truncated_s) def _repr_html_(self) -> str: return self._get_html_representation()