Source code for tomobar.supp.funcs

"""Supporting functions"""

import numpy as np
from typing import Union, List, Tuple

cupy_enabled = False
try:
    import cupy as xp

    try:
        xp.cuda.Device(0).compute_capability
        cupy_enabled = True
    except xp.cuda.runtime.CUDARuntimeError:
        import numpy as xp

        print("CuPy is installed but the GPU device is inaccessible")
except ImportError:
    import numpy as xp


def _validate_ordsub_number(value, n_angles):
    """Normalize None to one; subsets must be nonempty integer partitions."""
    if value is None:
        value = 1
    if isinstance(value, (bool, np.bool_)) or not isinstance(value, (int, np.integer)):
        raise TypeError("The number of ordered subsets must be an integer")
    if not 1 <= value <= n_angles:
        raise ValueError(
            f"The number of ordered subsets must be between 1 and {n_angles}"
        )
    return int(value)


def _normalise_center_rot_offset(offset, n_angles, *, is_3d):
    """Validate detector offsets, retaining scalar and per-view representations.

    None means zero. A scalar or (n_angles,) array specifies horizontal
    displacement; 3D additionally accepts (n_angles, 2) horizontal/vertical
    pairs. Values are finite, real numbers in geometry length units (pixels
    for ToMoBAR's unit detector spacing). A length-two vector always means
    two horizontal offsets, never a constant horizontal/vertical pair.
    """
    if offset is None:
        return 0.0
    values = np.asarray(offset)
    if values.dtype.kind not in "iuf":
        raise TypeError("CenterRotOffset must contain real numeric values")
    allowed_shapes = [(), (n_angles,)]
    if is_3d:
        allowed_shapes.append((n_angles, 2))
    if values.shape not in allowed_shapes:
        raise ValueError(
            f"CenterRotOffset must have shape {allowed_shapes}, got {values.shape}"
        )
    values = np.array(values, dtype=np.float64, copy=True)
    if not np.all(np.isfinite(values)):
        raise ValueError("CenterRotOffset must contain only finite values")
    return float(values) if values.ndim == 0 else values


# define a 2D vector geometry
def _vec_geom_init2D(
    angles_rad: np.ndarray, CenterRotOffset: Union[float, List]
) -> np.ndarray:
    CenterRotOffset = _normalise_center_rot_offset(
        CenterRotOffset, angles_rad.size, is_3d=False
    )
    DetectorSpacingX = 1.0
    s0 = [0.0, -1.0]  # source
    u0 = [DetectorSpacingX, 0.0]  # detector coordinates
    vectors = np.zeros([angles_rad.size, 6])
    for i in range(0, angles_rad.size):
        if np.ndim(CenterRotOffset) == 0:
            d0 = [CenterRotOffset, 0.0]  # detector
        else:
            d0 = [CenterRotOffset[i], 0.0]  # detector
        theta = angles_rad[i]
        vec_temp = np.dot(__rotation_matrix2D(theta), s0)
        vectors[i, 0:2] = vec_temp[:]  # ray position
        vec_temp = np.dot(__rotation_matrix2D(theta), d0)
        vectors[i, 2:4] = vec_temp[:]  # center of detector position
        vec_temp = np.dot(__rotation_matrix2D(theta), u0)
        vectors[i, 4:6] = vec_temp[:]  # detector pixel (0,0) to (0,1).
    return vectors


# define 3D vector geometry
def _vec_geom_init3D(angles_rad, DetectorSpacingX, DetectorSpacingY, CenterRotOffset):
    """Build 3D vectors from scalar, (n,) horizontal or (n, 2) H/V offsets.

    Positive horizontal displacement follows the detector column direction;
    positive vertical displacement follows detector rows (world +Z for CT).
    Offsets are geometry lengths, independent of the detector pixel spacing.
    """
    CenterRotOffset = _normalise_center_rot_offset(
        CenterRotOffset, angles_rad.size, is_3d=True
    )
    s0 = [0.0, -1.0, 0.0]  # source
    u0 = [DetectorSpacingX, 0.0, 0.0]  # detector coordinates
    v0 = [0.0, 0.0, DetectorSpacingY]  # detector coordinates

    vectors = np.zeros([angles_rad.size, 12])
    for i in range(0, angles_rad.size):
        if np.ndim(CenterRotOffset) == 0:
            d0 = [CenterRotOffset, 0.0, 0.0]  # detector
        elif np.ndim(CenterRotOffset) == 1:
            d0 = [CenterRotOffset[i], 0.0, 0.0]  # horizontal offset per view
        else:
            d0 = [CenterRotOffset[i, 0], 0.0, CenterRotOffset[i, 1]]  # detector
        theta = angles_rad[i]
        vec_temp = np.dot(__rotation_matrix3D(theta), s0)
        vectors[i, 0:3] = vec_temp[:]  # ray position
        vec_temp = np.dot(__rotation_matrix3D(theta), d0)
        vectors[i, 3:6] = vec_temp[:]  # center of detector position
        vec_temp = np.dot(__rotation_matrix3D(theta), u0)
        vectors[i, 6:9] = vec_temp[:]  # detector pixel (0,0) to (0,1).
        vec_temp = np.dot(__rotation_matrix3D(theta), v0)
        vectors[i, 9:12] = vec_temp[:]  # Vector from detector pixel (0,0) to (1,0)
    return vectors


# define 2D rotation matrix
def __rotation_matrix2D(theta):
    return np.array([[np.cos(theta), -np.sin(theta)], [np.sin(theta), np.cos(theta)]])


# define 3D rotation matrix
def __rotation_matrix3D(theta):
    return np.array(
        [
            [np.cos(theta), -np.sin(theta), 0.0],
            [np.sin(theta), np.cos(theta), 0.0],
            [0.0, 0.0, 1.0],
        ]
    )


def __get_swap_tuple(data_axis_labels, labels_order):
    swap_tuple = None
    for in_l1, str_1 in enumerate(labels_order):
        for in_l2, str_2 in enumerate(data_axis_labels):
            if str_1 == str_2:
                # get the indices only IF the order is different
                if in_l1 != in_l2:
                    swap_tuple = (in_l1, in_l2)
                    return swap_tuple
    return swap_tuple


def _axis_permutation(source_axes, target_axes):
    """Return source-to-target indices for a complete 2D/3D projection layout."""
    for name, axes in (("source_axes", source_axes), ("target_axes", target_axes)):
        if not isinstance(axes, (list, tuple)) or not all(
            isinstance(axis, str) for axis in axes
        ):
            raise ValueError(f"{name} must be a list or tuple of axis labels")
        if len(axes) not in (2, 3) or len(set(axes)) != len(axes):
            raise ValueError(f"{name} must contain two or three unique axis labels")
        expected = {"angles", "detX"} if len(axes) == 2 else {"detY", "angles", "detX"}
        if set(axes) != expected:
            raise ValueError(f"{name} must contain exactly {sorted(expected)}")
    if len(source_axes) != len(target_axes):
        raise ValueError("Source and target axes must have the same length")
    return tuple(source_axes.index(axis) for axis in target_axes)


def _transpose_data_axes(data, *, source_axes, target_axes):
    """Reorder NumPy/CuPy arrays or estimator shape tuples without copying."""
    permutation = _axis_permutation(source_axes, target_axes)
    ndim = len(data) if isinstance(data, tuple) else data.ndim
    if ndim != len(permutation):
        raise ValueError(
            f"Axis labels describe {len(permutation)} dimensions, got {ndim}"
        )
    if permutation == tuple(range(ndim)):
        return data
    if isinstance(data, tuple):
        return tuple(data[index] for index in permutation)
    return data.transpose(permutation)


def _resolve_data_axes(explicit_axes, legacy_axes, default_axes):
    """Resolve explicit input/output axes and the legacy keyword consistently."""
    if explicit_axes is not None:
        _axis_permutation(explicit_axes, default_axes)
    if legacy_axes is not None:
        _axis_permutation(legacy_axes, default_axes)
    if explicit_axes is not None and legacy_axes is not None:
        if tuple(explicit_axes) != tuple(legacy_axes):
            raise ValueError("Conflicting axis orders in explicit and legacy keywords")
    return (
        explicit_axes
        if explicit_axes is not None
        else (legacy_axes if legacy_axes is not None else default_axes)
    )


def _swap_data_axes_to_accepted(data_axes_labels, required_labels_order):
    """A module to ensure that the input tomographic data is prepeared for reconstruction
    in the axes order required.

    Args:
        data_axes_labels (list):  a list of data labels, e.g. given as ['angles', 'detX', 'detY']
        required_labels_order (list): the required (fixed) order of axis labels for data, e.g. ["detY", "angles", "detX"].

    Returns:
        list: A list of two tuples for input data swaping axis. If both are None, then no swapping needed.
    """

    _axis_permutation(data_axes_labels, required_labels_order)
    swap_tuple2 = None
    # check if the labels names are the accepted ones
    for str_1 in data_axes_labels:
        if str_1 not in required_labels_order:
            raise ValueError(
                f'Axis title "{str_1}" is not valid, please use one of these: "angles", "detX", or "detY"'
            )
    data_axes_labels_copy = list(data_axes_labels)

    # check the order and produce a swapping tuple if needed
    swap_tuple1 = __get_swap_tuple(data_axes_labels_copy, required_labels_order)

    if swap_tuple1 is not None:
        # swap elements in the list and check the list again
        data_axes_labels_copy[swap_tuple1[0]], data_axes_labels_copy[swap_tuple1[1]] = (
            data_axes_labels_copy[swap_tuple1[1]],
            data_axes_labels_copy[swap_tuple1[0]],
        )
        swap_tuple2 = __get_swap_tuple(data_axes_labels_copy, required_labels_order)

    if swap_tuple2 is not None:
        # swap elements in the list
        data_axes_labels_copy[swap_tuple2[0]], data_axes_labels_copy[swap_tuple2[1]] = (
            data_axes_labels_copy[swap_tuple2[1]],
            data_axes_labels_copy[swap_tuple2[0]],
        )

    return [swap_tuple1, swap_tuple2]


[docs] def swap_tuple_elements(tup: Tuple[int, int, int], idx1: int, idx2: int): items = list(tup) items[idx1], items[idx2] = items[idx2], items[idx1] return tuple(items)
def _data_swap( data: xp.ndarray | Tuple[int, int, int], data_swap_list: list ) -> xp.ndarray: """Swap data labels based on the provided list of tuples Args: data (xp.ndarray): Numpy or CuPu 2D or 3D array data_swap_list (list): List of tuples to swap to Returns: xp.ndarray: swapped array to the desired format """ for swap_tuple in data_swap_list: if swap_tuple is not None: if isinstance(data, tuple): data = swap_tuple_elements(data, swap_tuple[0], swap_tuple[1]) else: if cupy_enabled: xpp = xp.get_array_module(data) data = xpp.swapaxes(data, swap_tuple[0], swap_tuple[1]) else: data = np.swapaxes(data, swap_tuple[0], swap_tuple[1]) return data def _parse_device_argument(device_int_or_string) -> Tuple: """Convert a cpu/gpu string or integer gpu number into a tuple.""" if isinstance(device_int_or_string, int): return "gpu", device_int_or_string elif device_int_or_string == "gpu": return "gpu", 0 elif device_int_or_string == "cpu": return "cpu", -1 else: raise ValueError( 'Unknown device {0}. Expecting either "cpu" or "gpu" strings OR the gpu device integer'.format( device_int_or_string ) ) def _data_dims_swapper( data: xp.ndarray, data_axes_labels_order: list, required_labels_order: list ) -> xp.ndarray: """Swaps data axes as it required Args: data (xp.ndarray): 2D or 3D array. data_axes_labels_order (list): The input data axes. required_labels_order (list): The required data axes. Returns: xp.ndarray: An array with swapped (or not) axes. """ return _transpose_data_axes( data, source_axes=data_axes_labels_order, target_axes=required_labels_order )