Source code for toupy.tomo.geometry

#!/usr/bin/env python
# -*- coding: utf-8 -*-

"""
Cone-beam acquisition geometry descriptor.

``ConeBeamGeometry`` is a plain dataclass that encodes every metric
parameter of a circular-orbit cone-beam CT setup.  It is intentionally
kept free of reconstruction imports so that the registration sub-package
can import it without pulling in NumPy FFT or CuPy.

Typical usage::

    from toupy.tomo.geometry import ConeBeamGeometry
    import numpy as np

    geom = ConeBeamGeometry(
        SOD=500.0, SDD=1000.0,
        det_pixel_size=0.1,
        n_u=1024, n_v=512,
        angles=np.linspace(0, 360, 720, endpoint=False),
    )
    geom.validate()
    u = geom.u_coords()   # shape (1024,)
    v = geom.v_coords()   # shape (512,)
"""

# standard library
from __future__ import annotations
from dataclasses import dataclass, field

# third-party
import numpy as np
from numpy import ndarray

__all__ = ["ConeBeamGeometry"]


[docs] @dataclass class ConeBeamGeometry: """ Circular-orbit cone-beam acquisition geometry. All distances are expected in consistent physical units (e.g. mm or µm). Angles are in degrees. Parameters ---------- SOD : float Source-to-object distance (source to rotation axis), in physical length units. Must satisfy ``SOD < SDD``. SDD : float Source-to-detector distance, in the same units as ``SOD``. det_pixel_size : float Physical pitch of one detector pixel (square pixels assumed), in the same units as ``SOD``/``SDD``. Must be strictly positive. n_u : int Number of detector columns (horizontal / transaxial direction). Must be a positive integer. n_v : int Number of detector rows (vertical / axial direction). Must be a positive integer. angles : ndarray, shape (n_angles,) Projection angles in degrees. Arbitrary spacing is allowed; the FDK weighting step uses them only to iterate over views. Attributes ---------- magnification : float Geometric magnification ``SDD / SOD``. A voxel at the rotation axis appears enlarged by this factor on the detector. effective_pixel_size : float Physical size of one *object-space* pixel at the rotation axis, ``det_pixel_size / magnification``. Use this value to set the reconstruction voxel size. Examples -------- >>> import numpy as np >>> from toupy.tomo.geometry import ConeBeamGeometry >>> geom = ConeBeamGeometry( ... SOD=500.0, SDD=1000.0, det_pixel_size=0.055, ... n_u=2048, n_v=2048, ... angles=np.linspace(0, 360, 1800, endpoint=False), ... ) >>> geom.validate() >>> print(geom.magnification) # 2.0 >>> print(geom.effective_pixel_size) # 0.0275 """ SOD: float SDD: float det_pixel_size: float n_u: int n_v: int angles: ndarray = field(default_factory=lambda: np.array([])) # ------------------------------------------------------------------ # Properties # ------------------------------------------------------------------ @property def magnification(self) -> float: """ Geometric magnification factor ``SDD / SOD``. Returns ------- float The ratio of source-to-detector distance to source-to-object distance. Always > 1 for a valid geometry (``SDD > SOD``). Raises ------ ZeroDivisionError If ``SOD`` is zero (caught earlier by :meth:`validate`). """ return self.SDD / self.SOD @property def effective_pixel_size(self) -> float: """ Object-space pixel size at the rotation axis. Defined as ``det_pixel_size / magnification``, i.e. the physical extent of one detector pixel back-projected to the object plane. Use this as the reconstruction voxel pitch. Returns ------- float Effective voxel pitch in the same units as ``det_pixel_size``. """ return self.det_pixel_size / self.magnification # ------------------------------------------------------------------ # Coordinate helpers # ------------------------------------------------------------------
[docs] def u_coords(self) -> ndarray: """ Centred horizontal (transaxial) detector coordinate array. Returns a 1-D array of length ``n_u`` containing the physical *detector-plane* u-coordinates of each column centre, measured from the beam axis. The coordinate of column ``i`` is:: u_i = (i - (n_u - 1) / 2) * det_pixel_size Returns ------- u : ndarray, shape (n_u,) Detector u-coordinates in physical units, centred on zero. """ return (np.arange(self.n_u) - (self.n_u - 1) / 2.0) * self.det_pixel_size
[docs] def v_coords(self) -> ndarray: """ Centred vertical (axial) detector coordinate array. Analogous to :meth:`u_coords` but along the vertical detector axis. The coordinate of row ``j`` is:: v_j = (j - (n_v - 1) / 2) * det_pixel_size Returns ------- v : ndarray, shape (n_v,) Detector v-coordinates in physical units, centred on zero. """ return (np.arange(self.n_v) - (self.n_v - 1) / 2.0) * self.det_pixel_size
# ------------------------------------------------------------------ # Validation # ------------------------------------------------------------------
[docs] def validate(self) -> None: """ Check self-consistency of all geometry fields. Should be called once after construction, before passing the geometry object to any reconstruction or registration function. Raises ------ ValueError With a descriptive message for each invalid condition: * ``SOD <= 0`` — source-to-object distance must be positive. * ``SDD <= 0`` — source-to-detector distance must be positive. * ``SOD >= SDD`` — the detector must lie beyond the object; equivalently, ``magnification > 1`` must hold. * ``det_pixel_size <= 0`` — pixel pitch must be positive. * ``n_u <= 0`` or ``n_v <= 0`` — detector dimensions must be positive integers. * ``len(angles) == 0`` — at least one projection angle is required. """ if self.SOD <= 0: raise ValueError("SOD must be strictly positive, got {}.".format(self.SOD)) if self.SDD <= 0: raise ValueError("SDD must be strictly positive, got {}.".format(self.SDD)) if self.SOD >= self.SDD: raise ValueError( "SOD must be less than SDD (magnification > 1 required); " "got SOD={}, SDD={}.".format(self.SOD, self.SDD) ) if self.det_pixel_size <= 0: raise ValueError( "det_pixel_size must be strictly positive, got {}.".format( self.det_pixel_size ) ) if self.n_u <= 0: raise ValueError( "n_u must be a positive integer, got {}.".format(self.n_u) ) if self.n_v <= 0: raise ValueError( "n_v must be a positive integer, got {}.".format(self.n_v) ) if len(self.angles) == 0: raise ValueError("angles must contain at least one projection angle.")