Source code for odl.tomo.geometry.parallel

# Copyright 2014-2020 The ODL contributors
#
# This file is part of ODL.
#
# This Source Code Form is subject to the terms of the Mozilla Public License,
# v. 2.0. If a copy of the MPL was not distributed with this file, You can
# obtain one at https://mozilla.org/MPL/2.0/.

"""Parallel beam geometries in 2 or 3 dimensions."""

from __future__ import absolute_import, division, print_function

import numpy as np

from odl.discr import uniform_partition
from odl.tomo.geometry.detector import Flat1dDetector, Flat2dDetector
from odl.tomo.geometry.geometry import AxisOrientedGeometry, Geometry
from odl.tomo.util import euler_matrix, is_inside_bounds, transform_system
from odl.util import array_str, indent, signature_string

__all__ = ('ParallelBeamGeometry',
           'Parallel2dGeometry',
           'Parallel3dEulerGeometry', 'Parallel3dAxisGeometry',
           'parallel_beam_geometry')


[docs] class ParallelBeamGeometry(Geometry): """Abstract parallel beam geometry in 2 or 3 dimensions. Parallel geometries are characterized by a virtual source at infinity, such that a unit vector from a detector point towards the source (`det_to_src`) is independent of the location on the detector. For details, check `the online docs <https://odlgroup.github.io/odl/guide/geometry_guide.html>`_. """
[docs] def __init__(self, ndim, apart, detector, det_pos_init, **kwargs): """Initialize a new instance. Parameters ---------- ndim : {2, 3} Number of dimensions of this geometry, i.e. dimensionality of the physical space in which this geometry is embedded. apart : `RectPartition` Partition of the angle set. detector : `Detector` The detector to use in this geometry. det_pos_init : `array-like` Initial position of the detector reference point. kwargs : Further parameters passed on to `Geometry`. """ super(ParallelBeamGeometry, self).__init__( ndim, apart, detector, **kwargs) if self.ndim not in (2, 3): raise ValueError('`ndim` must be 2 or 3, got {}'.format(ndim)) self.__det_pos_init = np.asarray(det_pos_init, dtype='float64') if self.det_pos_init.shape != (self.ndim,): raise ValueError('`det_pos_init` must have shape ({},), got {}' ''.format(self.ndim, self.det_pos_init.shape))
@property def det_pos_init(self): """Initial position of the detector reference point.""" return self.__det_pos_init @property def angles(self): """All angles of this geometry as an array. If ``motion_params.ndim == 1``, the array has shape ``(N,)``, where ``N`` is the number of angles. Otherwise, the array shape is ``(ndim, N)``, where ``N`` is the total number of angles, and ``ndim`` is ``motion_partitioin.ndim``. The order of axes is chosen such that ``geometry.angles`` can be used directly as input to any of the other methods of the geometry. """ if self.motion_partition.ndim == 1: return self.motion_grid.coord_vectors[0] else: return self.motion_grid.points().T
[docs] def det_refpoint(self, angle): """Return the position(s) of the detector ref. point at ``angle``. The reference point is given by a rotation of the initial position by ``angle``. For an angle (or a vector of angles) ``phi``, the detector position is given by :: det_ref(phi) = translation + rotation_matrix(phi) * (det_pos_init - translation) where ``det_pos_init`` is the detector reference point at initial state. This default implementation assumes in the case of 2 or 3 motion parameters that they are to be interpreted as Euler angles. Subclasses with a deviating intended interpretation should override this method. Parameters ---------- angle : `array-like` or sequence One or several (Euler) angles in radians at which to evaluate. If ``motion_params.ndim >= 2``, a sequence of that length must be provided. Returns ------- refpt : `numpy.ndarray` Vector(s) pointing from the origin to the detector reference point at ``angle``. If ``angle`` is a single parameter, the returned array has shape ``(ndim,)``, otherwise - ``angle.shape + (ndim,)`` if `motion_params` is 1D, - ``broadcast(*angle).shape + (ndim,)`` if `motion_params` is 2D or 3D (Euler angles). See Also -------- rotation_matrix Examples -------- For 2d and default arguments, the detector starts at ``e_y`` and rotates to ``-e_x`` at 90 degrees: >>> apart = odl.uniform_partition(0, np.pi, 10) >>> dpart = odl.uniform_partition(-1, 1, 20) >>> geom = Parallel2dGeometry(apart, dpart) >>> geom.det_refpoint(0) array([ 0., 1.]) >>> np.allclose(geom.det_refpoint(np.pi / 2), [-1, 0]) True The method is vectorized, i.e., it can be called with multiple angles at once (or n-dimensional arrays of parameters): >>> points = geom.det_refpoint([0, np.pi]) >>> np.allclose(points[0], [0, 1]) True >>> np.allclose(points[1], [0, -1]) True >>> geom.det_refpoint(np.zeros((4, 5))).shape (4, 5, 2) In 3d with single rotation axis ``e_z``, we have the same situation, except that the vectors have a third component equal to 0: >>> apart = odl.uniform_partition(0, np.pi, 10) >>> dpart = odl.uniform_partition([-1, -1], [1, 1], (20, 20)) >>> geom = Parallel3dAxisGeometry(apart, dpart) >>> geom.det_refpoint(0) array([ 0., 1., 0.]) >>> np.allclose(geom.det_refpoint(np.pi / 2), [-1, 0, 0]) True """ if self.motion_params.ndim == 1: squeeze_out = (np.shape(angle) == ()) angle = np.array(angle, dtype=float, copy=False, ndmin=1) rot_matrix = self.rotation_matrix(angle) extra_dims = angle.ndim elif self.motion_params.ndim in (2, 3): squeeze_out = (np.broadcast(*angle).shape == ()) angle = tuple(np.array(a, dtype=float, copy=False, ndmin=1) for a in angle) rot_matrix = self.rotation_matrix(angle) extra_dims = len(np.broadcast(*angle).shape) else: raise NotImplementedError( 'no default implementation available for `det_refpoint` ' 'with `motion_params.ndim == {}`' ''.format(self.motion_params.ndim)) rot_part = rot_matrix.dot(self.det_pos_init - self.translation) # Broadcast along extra dimensions pt_slc = (None,) * extra_dims + (slice(None),) refpoint = self.translation[pt_slc] + rot_part if squeeze_out: refpoint = refpoint.squeeze() return refpoint
[docs] def det_to_src(self, angle, dparam): """Direction from a detector location to the source. The direction vector is computed as follows:: dir = rotation_matrix(angle).dot(detector.surface_normal(dparam)) Note that for flat detectors, ``surface_normal`` does not depend on the parameter ``dparam``, hence this function is constant in that variable. Parameters ---------- angle : `array-like` or sequence One or several (Euler) angles in radians at which to evaluate. If ``motion_params.ndim >= 2``, a sequence of that length must be provided. dparam : `array-like` or sequence Detector parameter(s) at which to evaluate. If ``det_params.ndim >= 2``, a sequence of that length must be provided. Returns ------- det_to_src : `numpy.ndarray` Vector(s) pointing from a detector point to the source (at infinity). The shape of the returned array is obtained from the (broadcast) shapes of ``angle`` and ``dparam``, and broadcasting is supported within both parameters and between them. The precise definition of the shape is ``broadcast(bcast_angle, bcast_dparam).shape + (ndim,)``, where ``bcast_angle`` is - ``angle`` if `motion_params` is 1D, - ``broadcast(*angle)`` otherwise, and ``bcast_dparam`` defined analogously. Examples -------- The method works with single parameter values, in which case a single vector is returned: >>> apart = odl.uniform_partition(0, np.pi, 10) >>> dpart = odl.uniform_partition(-1, 1, 20) >>> geom = odl.tomo.Parallel2dGeometry(apart, dpart) >>> geom.det_to_src(0, 0) array([ 0., -1.]) >>> geom.det_to_src(0, 1) array([ 0., -1.]) >>> dir = geom.det_to_src(np.pi / 2, 0) >>> np.allclose(dir, [1, 0]) True >>> dir = geom.det_to_src(np.pi / 2, 1) >>> np.allclose(dir, [1, 0]) True Both variables support vectorized calls, i.e., stacks of parameters can be provided. The order of axes in the output (left of the ``ndim`` axis for the vector dimension) corresponds to the order of arguments: >>> dirs = geom.det_to_src(0, [-1, 0, 0.5, 1]) >>> dirs array([[ 0., -1.], [ 0., -1.], [ 0., -1.], [ 0., -1.]]) >>> dirs.shape # (num_dparams, ndim) (4, 2) >>> dirs = geom.det_to_src([0, np.pi / 2, np.pi], 0) >>> np.allclose(dirs, [[0, -1], ... [1, 0], ... [0, 1]]) True >>> dirs.shape # (num_angles, ndim) (3, 2) >>> # Providing 3 pairs of parameters, resulting in 3 vectors >>> dirs = geom.det_to_src([0, np.pi / 2, np.pi], [-1, 0, 1]) >>> dirs[0] # Corresponds to angle = 0, dparam = -1 array([ 0., -1.]) >>> dirs.shape (3, 2) >>> # Pairs of parameters arranged in arrays of same size >>> geom.det_to_src(np.zeros((4, 5)), np.zeros((4, 5))).shape (4, 5, 2) >>> # "Outer product" type evaluation using broadcasting >>> geom.det_to_src(np.zeros((4, 1)), np.zeros((1, 5))).shape (4, 5, 2) """ # Always call the downstream methods with vectorized arguments # to be able to reliably manipulate the final axes of the result if self.motion_params.ndim == 1: squeeze_angle = (np.shape(angle) == ()) angle = np.array(angle, dtype=float, copy=False, ndmin=1) matrix = self.rotation_matrix(angle) # shape (m, ndim, ndim) else: squeeze_angle = (np.broadcast(*angle).shape == ()) angle = tuple(np.array(a, dtype=float, copy=False, ndmin=1) for a in angle) matrix = self.rotation_matrix(angle) # shape (m, ndim, ndim) if self.det_params.ndim == 1: squeeze_dparam = (np.shape(dparam) == ()) dparam = np.array(dparam, dtype=float, copy=False, ndmin=1) else: squeeze_dparam = (np.broadcast(*dparam).shape == ()) dparam = tuple(np.array(p, dtype=float, copy=False, ndmin=1) for p in dparam) normal = self.detector.surface_normal(dparam) # shape (d, ndim) # Perform matrix-vector multiplication along the last axis of both # `matrix` and `normal` while "zipping" all axes that do not # participate in the matrix-vector product. In other words, the axes # are labelled # [0, 1, ..., r-1, r, r+1] for `matrix` and # [0, 1, ..., r-1, r+1] for `normal`, and the output axes are set to # [0, 1, ..., r-1, r]. This automatically supports broadcasting # along the axes 0, ..., r-1. matrix_axes = list(range(matrix.ndim)) normal_axes = list(range(matrix.ndim - 2)) + [matrix_axes[-1]] out_axes = list(range(matrix.ndim - 1)) det_to_src = np.einsum(matrix, matrix_axes, normal, normal_axes, out_axes) if squeeze_angle and squeeze_dparam: det_to_src = det_to_src.squeeze() return det_to_src
[docs] class Parallel2dGeometry(ParallelBeamGeometry): """Parallel beam geometry in 2d. The motion parameter is the counter-clockwise rotation angle around the origin, and the detector is a line detector. In the standard configuration, the detector is perpendicular to the ray direction, its reference point is initially at ``(0, 1)``, and the initial detector axis is ``(1, 0)``. For details, check `the online docs <https://odlgroup.github.io/odl/guide/geometry_guide.html>`_. """ _default_config = dict(det_pos_init=(0, 1), det_axis_init=(1, 0))
[docs] def __init__(self, apart, dpart, det_pos_init=(0, 1), **kwargs): """Initialize a new instance. Parameters ---------- apart : 1-dim. `RectPartition` Partition of the angle interval. dpart : 1-dim. `RectPartition` Partition of the detector parameter interval. det_pos_init : `array-like`, shape ``(2,)``, optional Initial position of the detector reference point. Other Parameters ---------------- det_axis_init : `array-like` (shape ``(2,)``), optional Initial axis defining the detector orientation. The default depends on ``det_pos_init``, see Notes. translation : `array-like`, shape ``(2,)``, optional Global translation of the geometry. This is added last in any method that computes an absolute vector, e.g., `det_refpoint`, and also shifts the center of rotation. Default: ``(0, 0)`` check_bounds : bool, optional If ``True``, methods computing vectors check input arguments. Checks are vectorized and add only a small overhead. Default: ``True`` Notes ----- In the default configuration, the initial detector reference point is ``(0, 1)``, and the initial detector axis is ``(1, 0)``. If a different ``det_pos_init`` is chosen, the new default axis is given as a rotation of the original one by a matrix that transforms ``(0, 1)`` to the new (normalized) ``det_pos_init``. This matrix is calculated with the `rotation_matrix_from_to` function. Expressed in code, we have :: init_rot = rotation_matrix_from_to((0, 1), det_pos_init) det_axis_init = init_rot.dot((1, 0)) If ``det_pos_init == (0, 0)``, no rotation is performed. Examples -------- Initialization with default parameters: >>> apart = odl.uniform_partition(0, np.pi, 10) >>> dpart = odl.uniform_partition(-1, 1, 20) >>> geom = Parallel2dGeometry(apart, dpart) >>> np.allclose(geom.det_refpoint(0), (0, 1)) True >>> np.allclose(geom.det_point_position(0, 1), (1, 1)) True Checking the default orientation: >>> e_x, e_y = np.eye(2) # standard unit vectors >>> np.allclose(geom.det_pos_init, e_y) True >>> np.allclose(geom.det_axis_init, e_x) True Specifying an initial detector position by default rotates the standard configuration to this position: >>> geom = Parallel2dGeometry(apart, dpart, det_pos_init=(-1, 0)) >>> np.allclose(geom.det_pos_init, -e_x) True >>> np.allclose(geom.det_axis_init, e_y) True >>> geom = Parallel2dGeometry(apart, dpart, det_pos_init=(0, -1)) >>> np.allclose(geom.det_pos_init, -e_y) True >>> np.allclose(geom.det_axis_init, -e_x) True The initial detector axis can also be set explicitly: >>> geom = Parallel2dGeometry( ... apart, dpart, det_pos_init=(0, -1), det_axis_init=(1, 0)) >>> np.allclose(geom.det_pos_init, -e_y) True >>> np.allclose(geom.det_axis_init, e_x) True """ default_det_pos_init = self._default_config['det_pos_init'] default_det_axis_init = self._default_config['det_axis_init'] # Handle the initial coordinate system. We need to assign `None` to # the vectors first in order to signalize to the `transform_system` # utility that they should be transformed from default since they # were not explicitly given. det_axis_init = kwargs.pop('det_axis_init', None) # Store some stuff for repr if det_pos_init is not None: self._det_pos_init_arg = np.asarray(det_pos_init, dtype=float) else: self._det_pos_init_arg = None if det_axis_init is not None: self._det_axis_init_arg = np.asarray(det_axis_init, dtype=float) else: self._det_axis_init_arg = None # Compute the transformed system and the transition matrix. We # transform only those vectors that were not explicitly given. vecs_to_transform = [] if det_axis_init is None: vecs_to_transform.append(default_det_axis_init) transformed_vecs = transform_system( det_pos_init, default_det_pos_init, vecs_to_transform) transformed_vecs = list(transformed_vecs) det_pos_init = transformed_vecs.pop(0) if det_axis_init is None: det_axis_init = transformed_vecs.pop(0) assert transformed_vecs == [] # Translate the absolute vectors by the given translation translation = np.asarray(kwargs.pop('translation', (0, 0)), dtype=float) det_pos_init += translation # Initialize stuff. Normalization of the detector axis happens in # the detector class. `check_bounds` is needed for both detector # and geometry. check_bounds = kwargs.get('check_bounds', True) detector = Flat1dDetector(dpart, axis=det_axis_init, check_bounds=check_bounds) super(Parallel2dGeometry, self).__init__( ndim=2, apart=apart, detector=detector, det_pos_init=det_pos_init, translation=translation, **kwargs) if self.motion_partition.ndim != 1: raise ValueError('`apart` dimension {}, expected 1' ''.format(self.motion_partition.ndim))
[docs] @classmethod def frommatrix(cls, apart, dpart, init_matrix, **kwargs): """Create an instance of `Parallel2dGeometry` using a matrix. This alternative constructor uses a matrix to rotate and translate the default configuration. It is most useful when the transformation to be applied is already given as a matrix. Parameters ---------- apart : 1-dim. `RectPartition` Partition of the angle interval. dpart : 1-dim. `RectPartition` Partition of the detector parameter interval. init_matrix : `array_like`, shape ``(2, 2)`` or ``(2, 3)``, optional Transformation matrix whose left ``(2, 2)`` block is multiplied with the default ``det_pos_init`` and ``det_axis_init`` to determine the new vectors. If present, the third column acts as a translation after the initial transformation. The resulting ``det_axis_init`` will be normalized. kwargs : Further keyword arguments passed to the class constructor. Returns ------- geometry : `Parallel2dGeometry` Examples -------- Mirror the second unit vector, creating a left-handed system: >>> apart = odl.uniform_partition(0, np.pi, 10) >>> dpart = odl.uniform_partition(-1, 1, 20) >>> matrix = np.array([[1, 0], ... [0, -1]]) >>> geom = Parallel2dGeometry.frommatrix(apart, dpart, matrix) >>> e_x, e_y = np.eye(2) # standard unit vectors >>> np.allclose(geom.det_pos_init, -e_y) True >>> np.allclose(geom.det_axis_init, e_x) True >>> np.allclose(geom.translation, (0, 0)) True Adding a translation with a third matrix column: >>> matrix = np.array([[1, 0, 1], ... [0, -1, 1]]) >>> geom = Parallel2dGeometry.frommatrix(apart, dpart, matrix) >>> np.allclose(geom.translation, (1, 1)) True >>> np.allclose(geom.det_pos_init, -e_y + (1, 1)) True """ # Get transformation and translation parts from `init_matrix` init_matrix = np.asarray(init_matrix, dtype=float) if init_matrix.shape not in ((2, 2), (2, 3)): raise ValueError('`matrix` must have shape (2, 2) or (2, 3), ' 'got array with shape {}' ''.format(init_matrix.shape)) trafo_matrix = init_matrix[:, :2] translation = init_matrix[:, 2:].squeeze() # Transform the default vectors default_det_pos_init = cls._default_config['det_pos_init'] default_det_axis_init = cls._default_config['det_axis_init'] vecs_to_transform = [default_det_axis_init] transformed_vecs = transform_system( default_det_pos_init, None, vecs_to_transform, matrix=trafo_matrix) # Use the standard constructor with these vectors det_pos, det_axis = transformed_vecs if translation.size != 0: kwargs['translation'] = translation return cls(apart, dpart, det_pos, det_axis_init=det_axis, **kwargs)
@property def det_axis_init(self): """Detector axis at angle 0.""" return self.detector.axis
[docs] def det_axis(self, angle): """Return the detector axis (axes) at ``angle``. Parameters ---------- angle : float or `array-like` Angle(s) in radians describing the counter-clockwise rotation of the detector. Returns ------- axis : `numpy.ndarray` Unit vector(s) along which the detector is aligned. If ``angle`` is a single parameter, the returned array has shape ``(2,)``, otherwise ``angle.shape + (2,)``. Examples -------- Calling the method with a single angle produces a single vector: >>> apart = odl.uniform_partition(0, np.pi, 10) >>> dpart = odl.uniform_partition(-1, 1, 20) >>> geom = Parallel2dGeometry(apart, dpart) >>> geom.det_axis(0) array([ 1., 0.]) >>> np.allclose(geom.det_axis(np.pi / 2), [0, 1]) True The method is vectorized, i.e., it can be called with multiple angles at once (or n-dimensional arrays of parameters): >>> np.allclose(geom.det_axis([0, np.pi / 2]), [[1, 0], ... [0, 1]]) True >>> geom.det_axis(np.zeros((4, 5))).shape (4, 5, 2) """ return self.rotation_matrix(angle).dot(self.det_axis_init)
[docs] def rotation_matrix(self, angle): """Return the rotation matrix to the system state at ``angle``. For an angle ``phi``, the matrix is given by :: rot(phi) = [[cos(phi), -sin(phi)], [sin(phi), cos(phi)]] Parameters ---------- angle : float or `array-like` Angle(s) in radians describing the counter-clockwise rotation of the detector. Returns ------- rot : `numpy.ndarray` The rotation matrix (or matrices) mapping vectors at the initial state to the ones in the state defined by ``angle``. The rotation is extrinsic, i.e., defined in the "world" coordinate system. If ``angle`` is a single parameter, the returned array has shape ``(2, 2)``, otherwise ``angle.ndim + (2, 2)``. """ squeeze_out = (np.shape(angle) == ()) angle = np.array(angle, dtype=float, copy=False, ndmin=1) if (self.check_bounds and not is_inside_bounds(angle, self.motion_params)): raise ValueError('`angle` {} not in the valid range {}' ''.format(angle, self.motion_params)) if squeeze_out: matrix = euler_matrix(angle).squeeze() else: matrix = euler_matrix(angle) return matrix
def __repr__(self): """Return ``repr(self)``.""" posargs = [self.motion_partition, self.det_partition] optargs = [] if not np.allclose(self.det_pos_init - self.translation, self._default_config['det_pos_init']): optargs.append( ['det_pos_init', array_str(self.det_pos_init), '']) if self._det_axis_init_arg is not None: optargs.append( ['det_axis_init', array_str(self._det_axis_init_arg), '']) if not np.array_equal(self.translation, (0, 0)): optargs.append( ['translation', array_str(self.translation), '']) sig_str = signature_string(posargs, optargs, sep=',\n') return '{}(\n{}\n)'.format(self.__class__.__name__, indent(sig_str))
[docs] def __getitem__(self, indices): """Return self[slc] This is defined by:: self[indices].partition == self.partition[indices] where all other parameters are the same. Examples -------- >>> apart = odl.uniform_partition(0, 4, 4) >>> dpart = odl.uniform_partition(-1, 1, 20) >>> geom = odl.tomo.Parallel2dGeometry(apart, dpart) Extract sub-geometry with every second angle: >>> geom[::2] Parallel2dGeometry( nonuniform_partition( [ 0.5, 2.5], min_pt=0.0, max_pt=4.0 ), uniform_partition(-1.0, 1.0, 20) ) """ part = self.partition[indices] apart = part.byaxis[0] dpart = part.byaxis[1] return Parallel2dGeometry(apart, dpart, det_pos_init=self.det_pos_init, det_axis_init=self._det_axis_init_arg, translation=self.translation)
[docs] class Parallel3dEulerGeometry(ParallelBeamGeometry): """Parallel beam geometry in 3d. The motion parameters are two or three Euler angles, and the detector is flat and two-dimensional. In the standard configuration, the detector reference point starts at ``(0, 1, 0)``, and the initial detector axes are ``[(1, 0, 0), (0, 0, 1)]``. For details, check `the online docs <https://odlgroup.github.io/odl/guide/geometry_guide.html>`_. """ _default_config = dict(det_pos_init=(0, 1, 0), det_axes_init=((1, 0, 0), (0, 0, 1)))
[docs] def __init__(self, apart, dpart, det_pos_init=(0, 1, 0), **kwargs): """Initialize a new instance. Parameters ---------- apart : 2- or 3-dim. `RectPartition` Partition of the angle parameter set. dpart : 2-dim. `RectPartition` Partition of the detector parameter set. det_pos_init : `array-like`, shape ``(3,)``, optional Initial position of the detector reference point. Other Parameters ---------------- det_axes_init : 2-tuple of `array-like`'s (shape ``(3,)``), optional Initial axes defining the detector orientation. The default depends on ``det_pos_init``, see Notes. translation : `array-like`, shape ``(3,)``, optional Global translation of the geometry. This is added last in any method that computes an absolute vector, e.g., `det_refpoint`, and also shifts the center of rotation. Default: ``(0, 0, 0)`` check_bounds : bool, optional If ``True``, methods computing vectors check input arguments. Checks are vectorized and add only a small overhead. Default: ``True`` Notes ----- In the default configuration, the initial detector reference point is ``(0, 1, 0)``, and the initial detector axes are ``[(1, 0, 0), (0, 0, 1)]``. If a different ``det_pos_init`` is chosen, the new default axes are given as a rotation of the original ones by a matrix that transforms ``(0, 1, 0)`` to the new (normalized) ``det_pos_init``. This matrix is calculated with the `rotation_matrix_from_to` function. Expressed in code, we have :: init_rot = rotation_matrix_from_to((0, 1, 0), det_pos_init) det_axes_init[0] = init_rot.dot((1, 0, 0)) det_axes_init[1] = init_rot.dot((0, 0, 1)) Examples -------- Initialization with default parameters and 2 Euler angles: >>> apart = odl.uniform_partition([0, 0], [np.pi, 2 * np.pi], ... (10, 20)) >>> dpart = odl.uniform_partition([-1, -1], [1, 1], (20, 20)) >>> geom = Parallel3dEulerGeometry(apart, dpart) >>> geom.det_refpoint([0, 0]) array([ 0., 1., 0.]) >>> geom.det_point_position([0, 0], [-1, 1]) array([-1., 1., 1.]) Checking the default orientation: >>> e_x, e_y, e_z = np.eye(3) # standard unit vectors >>> np.allclose(geom.det_pos_init, e_y) True >>> np.allclose(geom.det_axes_init, (e_x, e_z)) True Specifying an initial detector position by default rotates the standard configuration to this position: >>> geom = Parallel3dEulerGeometry(apart, dpart, ... det_pos_init=(1, 0, 0)) >>> np.allclose(geom.det_pos_init, e_x) True >>> np.allclose(geom.det_axes_init, (-e_y, e_z)) True >>> geom = Parallel3dEulerGeometry(apart, dpart, ... det_pos_init=(0, 0, 1)) >>> np.allclose(geom.det_pos_init, e_z) True >>> np.allclose(geom.det_axes_init, (e_x, -e_y)) True The initial detector axes can also be set explicitly: >>> geom = Parallel3dEulerGeometry( ... apart, dpart, det_pos_init=(-1, 0, 0), ... det_axes_init=((0, 1, 0), (0, 0, 1))) >>> np.allclose(geom.det_pos_init, -e_x) True >>> np.allclose(geom.det_axes_init, (e_y, e_z)) True """ default_det_pos_init = self._default_config['det_pos_init'] default_det_axes_init = self._default_config['det_axes_init'] # Handle the initial coordinate system. We need to assign `None` to # the vectors first in order to signalize to the `transform_system` # utility that they should be transformed from default since they # were not explicitly given. det_axes_init = kwargs.pop('det_axes_init', None) # Store some stuff for repr if det_axes_init is not None: self._det_axes_init_arg = tuple( np.asarray(a, dtype=float) for a in det_axes_init) else: self._det_axes_init_arg = None # Compute the transformed system and the transition matrix. We # transform only those vectors that were not explicitly given. vecs_to_transform = [] if det_axes_init is None: vecs_to_transform.extend(default_det_axes_init) transformed_vecs = transform_system( det_pos_init, default_det_pos_init, vecs_to_transform) transformed_vecs = list(transformed_vecs) det_pos_init = transformed_vecs.pop(0) if det_axes_init is None: det_axes_init = (transformed_vecs.pop(0), transformed_vecs.pop(0)) assert transformed_vecs == [] # Translate the absolute vectors by the given translation translation = np.asarray(kwargs.pop('translation', (0, 0, 0)), dtype=float) det_pos_init += translation # Initialize stuff. Normalization of the detector axis happens in # the detector class. `check_bounds` is needed for both detector # and geometry. check_bounds = kwargs.get('check_bounds', True) detector = Flat2dDetector(dpart, axes=det_axes_init, check_bounds=check_bounds) super(Parallel3dEulerGeometry, self).__init__( ndim=3, apart=apart, detector=detector, det_pos_init=det_pos_init, translation=translation, **kwargs) if self.motion_partition.ndim not in (2, 3): raise ValueError('`apart` has dimension {}, expected ' '2 or 3'.format(self.motion_partition.ndim))
[docs] @classmethod def frommatrix(cls, apart, dpart, init_matrix, **kwargs): """Create an instance of `Parallel3dEulerGeometry` using a matrix. This alternative constructor uses a matrix to rotate and translate the default configuration. It is most useful when the transformation to be applied is already given as a matrix. Parameters ---------- apart : 2- or 3-dim. `RectPartition` Partition of the parameter set consisting of 2 or 3 Euler angles. dpart : 2-dim. `RectPartition` Partition of the detector parameter set. init_matrix : `array_like`, shape ``(3, 3)`` or ``(3, 4)``, optional Transformation matrix whose left ``(3, 3)`` block is multiplied with the default ``det_pos_init`` and ``det_axes_init`` to determine the new vectors. If present, the fourth column acts as a translation after the initial transformation. The resulting ``det_axes_init`` will be normalized. kwargs : Further keyword arguments passed to the class constructor. Returns ------- geometry : `Parallel3dEulerGeometry` Examples -------- Map unit vectors ``e_x -> e_z`` and ``e_z -> -e_x``, keeping the right-handedness: >>> apart = odl.uniform_partition([0, 0], [np.pi, 2 * np.pi], ... (10, 20)) >>> dpart = odl.uniform_partition([-1, -1], [1, 1], (20, 20)) >>> matrix = np.array([[0, 0, -1], ... [0, 1, 0], ... [1, 0, 0]]) >>> geom = Parallel3dEulerGeometry.frommatrix( ... apart, dpart, init_matrix=matrix) >>> geom.det_pos_init array([ 0., 1., 0.]) >>> geom.det_axes_init array([[ 0., 0., 1.], [-1., 0., 0.]]) Adding a translation with a fourth matrix column: >>> matrix = np.array([[0, 0, -1, 0], ... [0, 1, 0, 1], ... [1, 0, 0, 1]]) >>> geom = Parallel3dEulerGeometry.frommatrix(apart, dpart, matrix) >>> geom.translation array([ 0., 1., 1.]) >>> geom.det_pos_init # (0, 1, 0) + (0, 1, 1) array([ 0., 2., 1.]) """ # Get transformation and translation parts from `init_matrix` init_matrix = np.asarray(init_matrix, dtype=float) if init_matrix.shape not in ((3, 3), (3, 4)): raise ValueError('`matrix` must have shape (3, 3) or (3, 4), ' 'got array with shape {}' ''.format(init_matrix.shape)) trafo_matrix = init_matrix[:, :3] translation = init_matrix[:, 3:].squeeze() # Transform the default vectors default_det_pos_init = cls._default_config['det_pos_init'] default_det_axes_init = cls._default_config['det_axes_init'] vecs_to_transform = default_det_axes_init transformed_vecs = transform_system( default_det_pos_init, None, vecs_to_transform, matrix=trafo_matrix) # Use the standard constructor with these vectors det_pos, det_axis_0, det_axis_1 = transformed_vecs if translation.size != 0: kwargs['translation'] = translation return cls(apart, dpart, det_pos, det_axes_init=[det_axis_0, det_axis_1], **kwargs)
@property def det_axes_init(self): """Initial axes of the detector.""" return self.detector.axes
[docs] def det_axes(self, angles): """Return the detector axes tuple at ``angles``. Parameters ---------- angles : `array-like` or sequence Euler angles in radians describing the rotation of the detector. The length of the provided argument (along the first axis in case of an array) must be equal to the number of Euler angles in this geometry. Returns ------- axes : `numpy.ndarray` Unit vector(s) along which the detector is aligned. If ``angles`` is a single pair (or triplet) of Euler angles, the returned array has shape ``(2, 3)``, otherwise ``broadcast(*angles).shape + (2, 3)``. Notes ----- To get an array that enumerates the detector axes in the first dimension, move the second-to-last axis to the first position: axes = det_axes(angle) axes_enumeration = np.moveaxis(deriv, -2, 0) Examples -------- Calling the method with a single set of angles produces a ``(2, 3)`` array of vertically stacked vectors: >>> apart = odl.uniform_partition([0, 0], [np.pi, 2 * np.pi], ... (10, 20)) >>> dpart = odl.uniform_partition([-1, -1], [1, 1], (20, 20)) >>> geom = Parallel3dEulerGeometry(apart, dpart) >>> geom.det_axes([0, 0]) array([[ 1., 0., 0.], [ 0., 0., 1.]]) >>> np.allclose(geom.det_axes([np.pi / 2, 0]), [[0, 1, 0], ... [0, 0, 1]]) True The method is vectorized, i.e., it can be called with multiple angle parameters at once. Each of the angle arrays can have different shapes and will be broadcast against each other to determine the final shape: >>> # The first axis enumerates the angles >>> np.allclose(geom.det_axes(([0, np.pi / 2], [0, 0])), ... [[[1, 0, 0], ... [0, 0, 1]], ... [[0, 1, 0], ... [0, 0, 1]]]) True >>> # Pairs of Euler angles in a (4, 5) array each >>> geom.det_axes((np.zeros((4, 5)), np.zeros((4, 5)))).shape (4, 5, 2, 3) >>> # Using broadcasting for "outer product" type result >>> geom.det_axes((np.zeros((4, 1)), np.zeros((1, 5)))).shape (4, 5, 2, 3) """ # Transpose to take dot along axis 1 axes = self.rotation_matrix(angles).dot(self.det_axes_init.T) # `axes` has shape (a, 3, 2), need to roll the last dimensions # to the second to last place return np.rollaxis(axes, -1, -2)
[docs] def rotation_matrix(self, angles): """Return the rotation matrix to the system state at ``angles``. Parameters ---------- angles : `array-like` or sequence Euler angles in radians describing the rotation of the detector. The length of the provided argument (along the first axis in case of an array) must be equal to the number of Euler angles in this geometry. Returns ------- rot : `numpy.ndarray` Rotation matrix (or matrices) mapping vectors at the initial state to the ones in the state defined by ``angles``. The rotation is extrinsic, i.e., defined in the "world" coordinate system. If ``angles`` is a single pair (or triplet) of Euler angles, an array of shape ``(3, 3)`` representing a single matrix is returned. Otherwise, the shape of the returned array is ``broadcast(*angles).shape + (3, 3)``. """ squeeze_out = (np.broadcast(*angles).shape == ()) angles_in = angles angles = tuple(np.array(angle, dtype=float, copy=False, ndmin=1) for angle in angles) if (self.check_bounds and not is_inside_bounds(angles, self.motion_params)): raise ValueError('`angles` {} not in the valid range ' '{}'.format(angles_in, self.motion_params)) matrix = euler_matrix(*angles) if squeeze_out: matrix = matrix.squeeze() return matrix
def __repr__(self): """Return ``repr(self)``.""" posargs = [self.motion_partition, self.det_partition] optargs = [] if not np.allclose(self.det_pos_init - self.translation, self._default_config['det_pos_init']): optargs.append( ['det_pos_init', array_str(self.det_pos_init), '']) if self._det_axes_init_arg is not None: optargs.append( [('det_axes_init', tuple(array_str(a) for a in self._det_axes_init_arg), None)]) if not np.array_equal(self.translation, (0, 0, 0)): optargs.append(['translation', array_str(self.translation), '']) sig_str = signature_string(posargs, optargs, sep=',\n') return '{}(\n{}\n)'.format(self.__class__.__name__, indent(sig_str))
[docs] class Parallel3dAxisGeometry(ParallelBeamGeometry, AxisOrientedGeometry): """Parallel beam geometry in 3d with single rotation axis. The motion parameter is the rotation angle around the specified axis, and the detector is a flat 2d detector perpendicular to the ray direction. In the standard configuration, the rotation axis is ``(0, 0, 1)``, the detector reference point starts at ``(0, 1, 0)``, and the initial detector axes are ``[(1, 0, 0), (0, 0, 1)]``. For details, check `the online docs <https://odlgroup.github.io/odl/guide/geometry_guide.html>`_. """ _default_config = dict(axis=(0, 0, 1), det_pos_init=(0, 1, 0), det_axes_init=((1, 0, 0), (0, 0, 1)))
[docs] def __init__(self, apart, dpart, axis=(0, 0, 1), **kwargs): """Initialize a new instance. Parameters ---------- apart : 1-dim. `RectPartition` Partition of the angle interval. dpart : 2-dim. `RectPartition` Partition of the detector parameter rectangle. axis : `array-like`, shape ``(3,)``, optional Vector defining the fixed rotation axis of this geometry. Other Parameters ---------------- det_pos_init : `array-like`, shape ``(3,)``, optional Initial position of the detector reference point. The default depends on ``axis``, see Notes. det_axes_init : 2-tuple of `array-like`'s (shape ``(3,)``), optional Initial axes defining the detector orientation. The default depends on ``axis``, see Notes. translation : `array-like`, shape ``(3,)``, optional Global translation of the geometry. This is added last in any method that computes an absolute vector, e.g., `det_refpoint`, and also shifts the axis of rotation. Default: ``(0, 0, 0)`` check_bounds : bool, optional If ``True``, methods computing vectors check input arguments. Checks are vectorized and add only a small overhead. Default: ``True`` Notes ----- In the default configuration, the rotation axis is ``(0, 0, 1)``, the initial detector reference point position is ``(0, 1, 0)``, and the default detector axes are ``[(1, 0, 0), (0, 0, 1)]``. If a different ``axis`` is provided, the new default initial position and the new default axes are the computed by rotating the original ones by a matrix that transforms ``(0, 0, 1)`` to the new (normalized) ``axis``. This matrix is calculated with the `rotation_matrix_from_to` function. Expressed in code, we have :: init_rot = rotation_matrix_from_to((0, 0, 1), axis) det_pos_init = init_rot.dot((0, 1, 0)) det_axes_init[0] = init_rot.dot((1, 0, 0)) det_axes_init[1] = init_rot.dot((0, 0, 1)) Examples -------- Initialization with default parameters: >>> apart = odl.uniform_partition(0, np.pi, 10) >>> dpart = odl.uniform_partition([-1, -1], [1, 1], (20, 20)) >>> geom = Parallel3dAxisGeometry(apart, dpart) >>> geom.det_refpoint(0) array([ 0., 1., 0.]) >>> geom.det_point_position(0, [-1, 1]) array([-1., 1., 1.]) Checking the default orientation: >>> e_x, e_y, e_z = np.eye(3) # standard unit vectors >>> np.allclose(geom.axis, e_z) True >>> np.allclose(geom.det_pos_init, e_y) True >>> np.allclose(geom.det_axes_init, (e_x, e_z)) True Specifying an axis by default rotates the standard configuration to this position: >>> geom = Parallel3dAxisGeometry(apart, dpart, axis=(0, 1, 0)) >>> np.allclose(geom.axis, e_y) True >>> np.allclose(geom.det_pos_init, -e_z) True >>> np.allclose(geom.det_axes_init, (e_x, e_y)) True >>> geom = Parallel3dAxisGeometry(apart, dpart, axis=(1, 0, 0)) >>> np.allclose(geom.axis, e_x) True >>> np.allclose(geom.det_pos_init, e_y) True >>> np.allclose(geom.det_axes_init, (-e_z, e_x)) True The initial detector position and axes can also be set explicitly: >>> geom = Parallel3dAxisGeometry( ... apart, dpart, det_pos_init=(-1, 0, 0), ... det_axes_init=((0, 1, 0), (0, 0, 1))) >>> np.allclose(geom.axis, e_z) True >>> np.allclose(geom.det_pos_init, -e_x) True >>> np.allclose(geom.det_axes_init, (e_y, e_z)) True """ default_axis = self._default_config['axis'] default_det_pos_init = self._default_config['det_pos_init'] default_det_axes_init = self._default_config['det_axes_init'] # Handle initial coordinate system. We need to assign `None` to # the vectors first since we want to check that `init_matrix` # is not used together with those other parameters. det_pos_init = kwargs.pop('det_pos_init', None) det_axes_init = kwargs.pop('det_axes_init', None) # Store some stuff for repr if det_pos_init is not None: self._det_pos_init_arg = np.asarray(det_pos_init, dtype=float) else: self._det_pos_init_arg = None if det_axes_init is not None: self._det_axes_init_arg = tuple( np.asarray(a, dtype=float) for a in det_axes_init) else: self._det_axes_init_arg = None # Compute the transformed system and the transition matrix. We # transform only those vectors that were not explicitly given. vecs_to_transform = [] if det_pos_init is None: vecs_to_transform.append(default_det_pos_init) if det_axes_init is None: vecs_to_transform.extend(default_det_axes_init) transformed_vecs = transform_system( axis, default_axis, vecs_to_transform) transformed_vecs = list(transformed_vecs) axis = transformed_vecs.pop(0) if det_pos_init is None: det_pos_init = transformed_vecs.pop(0) if det_axes_init is None: det_axes_init = (transformed_vecs.pop(0), transformed_vecs.pop(0)) assert transformed_vecs == [] # Translate the absolute vectors by the given translation translation = np.asarray(kwargs.pop('translation', (0, 0, 0)), dtype=float) det_pos_init += translation # Initialize stuff. Normalization of the detector axis happens in # the detector class. `check_bounds` is needed for both detector # and geometry. AxisOrientedGeometry.__init__(self, axis) check_bounds = kwargs.get('check_bounds', True) detector = Flat2dDetector(dpart, axes=det_axes_init, check_bounds=check_bounds) super(Parallel3dAxisGeometry, self).__init__( ndim=3, apart=apart, detector=detector, det_pos_init=det_pos_init, translation=translation, **kwargs) if self.motion_partition.ndim != 1: raise ValueError('`apart` has dimension {}, expected 1' ''.format(self.motion_partition.ndim))
[docs] @classmethod def frommatrix(cls, apart, dpart, init_matrix, **kwargs): """Create an instance of `Parallel3dAxisGeometry` using a matrix. This alternative constructor uses a matrix to rotate and translate the default configuration. It is most useful when the transformation to be applied is already given as a matrix. Parameters ---------- apart : 1-dim. `RectPartition` Partition of the parameter interval. dpart : 2-dim. `RectPartition` Partition of the detector parameter set. init_matrix : `array_like`, shape ``(3, 3)`` or ``(3, 4)``, optional Transformation matrix whose left ``(3, 3)`` block is multiplied with the default ``det_pos_init`` and ``det_axes_init`` to determine the new vectors. If present, the fourth column acts as a translation after the initial transformation. The resulting ``det_axes_init`` will be normalized. kwargs : Further keyword arguments passed to the class constructor. Returns ------- geometry : `Parallel3dAxisGeometry` Examples -------- Map unit vectors ``e_y -> e_z`` and ``e_z -> -e_y``, keeping the right-handedness: >>> apart = odl.uniform_partition(0, np.pi, 10) >>> dpart = odl.uniform_partition([-1, -1], [1, 1], (20, 20)) >>> matrix = np.array([[1, 0, 0], ... [0, 0, -1], ... [0, 1, 0]]) >>> geom = Parallel3dAxisGeometry.frommatrix( ... apart, dpart, init_matrix=matrix) >>> geom.axis array([ 0., -1., 0.]) >>> geom.det_pos_init array([ 0., 0., 1.]) >>> geom.det_axes_init array([[ 1., 0., 0.], [ 0., -1., 0.]]) Adding a translation with a fourth matrix column: >>> matrix = np.array([[0, 0, -1, 0], ... [0, 1, 0, 1], ... [1, 0, 0, 1]]) >>> geom = Parallel3dAxisGeometry.frommatrix(apart, dpart, matrix) >>> geom.translation array([ 0., 1., 1.]) >>> geom.det_pos_init # (0, 1, 0) + (0, 1, 1) array([ 0., 2., 1.]) """ # Get transformation and translation parts from `init_matrix` init_matrix = np.asarray(init_matrix, dtype=float) if init_matrix.shape not in ((3, 3), (3, 4)): raise ValueError('`matrix` must have shape (3, 3) or (3, 4), ' 'got array with shape {}' ''.format(init_matrix.shape)) trafo_matrix = init_matrix[:, :3] translation = init_matrix[:, 3:].squeeze() # Transform the default vectors default_axis = cls._default_config['axis'] default_det_pos_init = cls._default_config['det_pos_init'] default_det_axes_init = cls._default_config['det_axes_init'] vecs_to_transform = (default_det_pos_init,) + default_det_axes_init transformed_vecs = transform_system( default_axis, None, vecs_to_transform, matrix=trafo_matrix) # Use the standard constructor with these vectors axis, det_pos, det_axis_0, det_axis_1 = transformed_vecs if translation.size != 0: kwargs['translation'] = translation return cls(apart, dpart, axis, det_pos_init=det_pos, det_axes_init=[det_axis_0, det_axis_1], **kwargs)
@property def det_axes_init(self): """Initial axes of the detector.""" return self.detector.axes
[docs] def det_axes(self, angle): """Return the detector axes tuple at ``angle``. Parameters ---------- angle : float or `array-like` Angle(s) in radians describing the counter-clockwise rotation of the detector around `axis`. Returns ------- axes : `numpy.ndarray` Unit vectors along which the detector is aligned. If ``angle`` is a single parameter, the returned array has shape ``(2, 3)``, otherwise ``broadcast(*angle).shape + (2, 3)``. Notes ----- To get an array that enumerates the detector axes in the first dimension, move the second-to-last axis to the first position: axes = det_axes(angle) axes_enumeration = np.moveaxis(deriv, -2, 0) Examples -------- Calling the method with a single angle produces a ``(2, 3)`` array of vertically stacked vectors: >>> apart = odl.uniform_partition(0, np.pi, 10) >>> dpart = odl.uniform_partition([-1, -1], [1, 1], (20, 20)) >>> geom = Parallel3dAxisGeometry(apart, dpart) >>> geom.det_axes(0) array([[ 1., 0., 0.], [ 0., 0., 1.]]) >>> np.allclose(geom.det_axes(np.pi / 2), [[0, 1, 0], ... [0, 0, 1]]) True The method is vectorized, i.e., it can be called with multiple angles at once (or n-dimensional arrays of parameters): >>> np.allclose(geom.det_axes([0, np.pi / 2]), ... [[[1, 0, 0], ... [0, 0, 1]], ... [[0, 1, 0], ... [0, 0, 1]]]) True >>> geom.det_axes(np.zeros((4, 5))).shape (4, 5, 2, 3) """ # Transpose to take dot along axis 1 axes = self.rotation_matrix(angle).dot(self.det_axes_init.T) # `axes` has shape (a, 3, 2), need to roll the last dimensions # to the second to last place return np.rollaxis(axes, -1, -2)
def __repr__(self): """Return ``repr(self)``.""" posargs = [self.motion_partition, self.det_partition] optargs = [] if not np.allclose(self.axis, self._default_config['axis']): optargs.append(['axis', array_str(self.axis), '']) if self._det_pos_init_arg is not None: optargs.append(['det_pos_init', array_str(self._det_pos_init_arg), None]) if self._det_axes_init_arg is not None: optargs.append( ['det_axes_init', tuple(array_str(a) for a in self._det_axes_init_arg), None]) if not np.array_equal(self.translation, (0, 0, 0)): optargs.append(['translation', array_str(self.translation), '']) sig_str = signature_string(posargs, optargs, sep=',\n') return '{}(\n{}\n)'.format(self.__class__.__name__, indent(sig_str))
[docs] def __getitem__(self, indices): """Return self[indices]. This is defined by :: self[indices].partition == self.partition[indices] where all other parameters are the same. Examples -------- >>> apart = odl.uniform_partition(0, 4, 4) >>> dpart = odl.uniform_partition([-1, -1], [1, 1], [20, 20]) >>> geom = odl.tomo.Parallel3dAxisGeometry(apart, dpart) Extract sub-geometry with every second angle: >>> geom[::2] Parallel3dAxisGeometry( nonuniform_partition( [ 0.5, 2.5], min_pt=0.0, max_pt=4.0 ), uniform_partition([-1., -1.], [ 1., 1.], (20, 20)) ) """ part = self.partition[indices] apart = part.byaxis[0] dpart = part.byaxis[1:] return Parallel3dAxisGeometry(apart, dpart, axis=self.axis, det_pos_init=self._det_pos_init_arg, det_axes_init=self._det_axes_init_arg, translation=self.translation)
# Manually override the abstract method in `Geometry` since it's found # first rotation_matrix = AxisOrientedGeometry.rotation_matrix
[docs] def parallel_beam_geometry(space, num_angles=None, det_shape=None): r"""Create default parallel beam geometry from ``space``. This is intended for simple test cases where users do not need the full flexibility of the geometries, but simply want a geometry that works. This default geometry gives a fully sampled sinogram according to the Nyquist criterion, which in general results in a very large number of samples. In particular, a ``space`` that is not centered at the origin can result in very large detectors. Parameters ---------- space : `DiscretizedSpace` Reconstruction space, the space of the volumetric data to be projected. Needs to be 2d or 3d. num_angles : int, optional Number of angles. Default: Enough to fully sample the data, see Notes. det_shape : int or sequence of int, optional Number of detector pixels. Default: Enough to fully sample the data, see Notes. Returns ------- geometry : `ParallelBeamGeometry` If ``space`` is 2d, return a `Parallel2dGeometry`. If ``space`` is 3d, return a `Parallel3dAxisGeometry`. Examples -------- Create a parallel beam geometry from a 2d space: >>> space = odl.uniform_discr([-1, -1], [1, 1], (20, 20)) >>> geometry = parallel_beam_geometry(space) >>> geometry.angles.size 45 >>> geometry.detector.size 31 Notes ----- According to [NW2001]_, pages 72--74, a function :math:`f : \mathbb{R}^2 \to \mathbb{R}` that has compact support .. math:: \| x \| > \rho \implies f(x) = 0, and is essentially bandlimited .. math:: \| \xi \| > \Omega \implies \hat{f}(\xi) \approx 0, can be fully reconstructed from a parallel beam ray transform if (1) the projection angles are sampled with a spacing of :math:`\Delta \psi` such that .. math:: \Delta \psi \leq \frac{\pi}{\rho \Omega}, and (2) the detector is sampled with an interval :math:`\Delta s` that satisfies .. math:: \Delta s \leq \frac{\pi}{\Omega}. The geometry returned by this function satisfies these conditions exactly. If the domain is 3-dimensional, the geometry is "separable", in that each slice along the z-dimension of the data is treated as independed 2d data. References ---------- .. [NW2001] Natterer, F and Wuebbeling, F. *Mathematical Methods in Image Reconstruction*. SIAM, 2001. https://dx.doi.org/10.1137/1.9780898718324 """ # Find maximum distance from rotation axis corners = space.domain.corners()[:, :2] rho = np.max(np.linalg.norm(corners, axis=1)) # Find default values according to Nyquist criterion. # We assume that the function is bandlimited by a wave along the x or y # axis. The highest frequency we can measure is then a standing wave with # period of twice the inter-node distance. min_side = min(space.partition.cell_sides[:2]) omega = np.pi / min_side num_px_horiz = 2 * int(np.ceil(rho * omega / np.pi)) + 1 if space.ndim == 2: det_min_pt = -rho det_max_pt = rho if det_shape is None: det_shape = num_px_horiz elif space.ndim == 3: num_px_vert = space.shape[2] min_h = space.domain.min_pt[2] max_h = space.domain.max_pt[2] det_min_pt = [-rho, min_h] det_max_pt = [rho, max_h] if det_shape is None: det_shape = [num_px_horiz, num_px_vert] if num_angles is None: num_angles = int(np.ceil(omega * rho)) angle_partition = uniform_partition(0, np.pi, num_angles) det_partition = uniform_partition(det_min_pt, det_max_pt, det_shape) if space.ndim == 2: return Parallel2dGeometry(angle_partition, det_partition) elif space.ndim == 3: return Parallel3dAxisGeometry(angle_partition, det_partition) else: raise ValueError('``space.ndim`` must be 2 or 3.')
if __name__ == '__main__': from odl.util.testutils import run_doctests run_doctests()