Source code for toupy.tomo.cone_projector

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

"""
Cone-beam forward projector.

Computes the cone-beam Radon transform (line integrals along divergent
rays) of a 3-D volume for a circular source orbit.

Primary use-cases:

* **Consistency checks** — compare measured projections with the
  re-projection of the FDK reconstruction to quantify residual
  reconstruction artefacts.
* **Iterative reconstruction** — the forward model is the mandatory
  companion to the FDK back-projector in a SART-style cone-beam update
  step.

The function signature mirrors :func:`~toupy.tomo.radon.projector`
for the parallel-beam case.

GPU path  : stub analogous to :func:`~toupy.tomo.radon.radon_cuda`;
            will delegate to a CuPy CUDA ray-casting kernel.
CPU path  : NumPy reference using trilinear interpolation.
"""

# standard library
import warnings
warnings.filterwarnings("ignore")

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

# local
from .geometry import ConeBeamGeometry
from .iradon import CUDA_AVAILABLE

if CUDA_AVAILABLE:
    import cupy as cp

__all__ = ["cone_project"]


[docs] def cone_project( volume: ndarray, geometry: ConeBeamGeometry, cuda: bool = False, ) -> ndarray: """ Compute the cone-beam forward projection of a 3-D volume. For each projection angle θ in ``geometry.angles`` and each detector pixel ``(u_i, v_j)`` the function evaluates the line integral along the cone ray from the point source through the voxel grid to the detector. The perspective-projection mapping of a voxel at object-space coordinates ``(x, y, z)`` onto the detector at angle θ is: .. math:: U(\\theta) = \\mathrm{SOD} + x\\sin\\theta - y\\cos\\theta .. math:: u_d = \\frac{\\mathrm{SDD}}{U}\\,(x\\cos\\theta + y\\sin\\theta), \\qquad v_d = \\frac{\\mathrm{SDD}}{U}\\, z The voxel grid is assumed isotropic with pitch ``geometry.effective_pixel_size``, centred at the origin (the rotation axis). Parameters ---------- volume : ndarray, shape (n_v, N, N) The 3-D volume to project. Axis order: ``(z, y, x)`` where z is the rotation (axial) direction and (y, x) is the transaxial plane. ``N`` is the transaxial voxel count and may differ from ``geometry.n_u``; the coordinate mapping is scaled accordingly. geometry : ConeBeamGeometry Validated acquisition geometry. Provides SOD, SDD, ``det_pixel_size``, and the set of projection angles. cuda : bool, optional If ``True``, attempt to use the CuPy GPU path. Falls back to CPU with a :mod:`warnings` warning when CuPy is unavailable. Default ``False``. Returns ------- projections : ndarray, shape (n_angles, n_v, n_u) Simulated cone-beam projections. Axis order matches the input convention expected by :func:`~toupy.tomo.fdk.fdk_weight`. Raises ------ ValueError If ``volume.ndim != 3``. ValueError If ``volume.shape[0] != geometry.n_v`` (axial size mismatch between the volume and the detector). See Also -------- toupy.tomo.radon.projector : Parallel-beam forward projector (2-D). toupy.tomo.fdk.fdk_reconstruct : Paired cone-beam reconstruction. Notes ----- This forward projector is the exact adjoint of :func:`fdk_backproject` (without the ``(SOD/U)²`` weighting applied in the back-projector). For a consistent SART-style iterative solver, the weighting must be applied symmetrically in both the forward and back steps, or the normal equations must be formed explicitly. """ from scipy.interpolate import RegularGridInterpolator if volume.ndim != 3: raise ValueError( "volume must be 3-D (n_v, N, N), got ndim={}.".format(volume.ndim) ) if volume.shape[0] != geometry.n_v: raise ValueError( "volume.shape[0]={} does not match geometry.n_v={}.".format( volume.shape[0], geometry.n_v ) ) # GPU path stub if cuda: if not CUDA_AVAILABLE: warnings.warn( "CuPy unavailable — falling back to CPU cone_project.", UserWarning, stacklevel=2, ) # else: CUDA not yet implemented, fall through to CPU n_v_vol, N, _ = volume.shape SOD = geometry.SOD SDD = geometry.SDD p = geometry.effective_pixel_size # Centred object-space coordinate grids (N x N) coords = (np.arange(N) - (N - 1) / 2.0) * p x, y = np.meshgrid(coords, coords, indexing='xy') # x varies along cols, y along rows # Object-space z-coordinates (axial) z_coords = (np.arange(n_v_vol) - (n_v_vol - 1) / 2.0) * p # shape (n_v_vol,) # Detector coordinate arrays u_det = geometry.u_coords() # shape (n_u,) v_det = geometry.v_coords() # shape (n_v,) n_angles = len(geometry.angles) angles_rad = np.deg2rad(geometry.angles) # Build interpolator for the volume: points = (z_coords, y_coords, x_coords) # volume shape is (n_v_vol, N, N) with axes (z, y, x) vol_interp = RegularGridInterpolator( (z_coords, coords, coords), volume, method='linear', bounds_error=False, fill_value=0.0, ) # ----------------------------------------------------------------------- # Restrict ray sampling to the volume's bounding box # ----------------------------------------------------------------------- # The full source-to-detector path is ~SDD long but the object occupies only # a small central fraction. Sampling the full ray with a fixed n_steps gives # only a handful of samples inside the object, causing severe aliasing. # # Strategy: parameterise t in [0, 1] (source→detector), but restrict # t_vals to [t_lo, t_hi] centred at the isocenter crossing (t ≈ SOD/SDD). # The half-span covers the bounding-box diagonal of the volume, with a # 20 % margin. n_steps is chosen so that the physical step inside the # object is ≈ effective_pixel_size (one sample per voxel). # ----------------------------------------------------------------------- half_xy = (N - 1) / 2.0 * p # object half-extent in x, y [mm] half_z = (n_v_vol - 1) / 2.0 * p # object half-extent in z [mm] half_diag = np.sqrt(2.0 * half_xy**2 + half_z**2) # half of 3-D diagonal [mm] t_center = SOD / SDD # isocenter crossing in t t_span = 1.2 * half_diag / SDD # t-half-span with 20 % margin t_lo = max(0.0, t_center - t_span) t_hi = min(1.0, t_center + t_span) # Step size ≈ p along the central ray (length ≈ SDD) n_steps = max(int(np.ceil(2.0 * t_span * SDD / p)) + 1, max(N, n_v_vol) * 2) projections = np.zeros((n_angles, geometry.n_v, geometry.n_u), dtype=np.float64) for i_angle, theta in enumerate(angles_rad): cos_th = np.cos(theta) sin_th = np.sin(theta) # Source position: S = (-SOD*sin_th, SOD*cos_th, 0) # Verify: U(S) = SOD + (-SOD*sin_th)*sin_th - (SOD*cos_th)*cos_th = 0. ✓ x_src = -SOD * sin_th y_src = SOD * cos_th # Build detector pixel grid in 3-D lab frame. # Beam axis direction (source → isocenter): d = (sin_th, -cos_th, 0) # Detector centre: isocenter + (SDD-SOD)*d # Detector u-axis: e_u = (cos_th, sin_th, 0) [perpendicular to d in xy] # Detector v-axis: e_v = (0, 0, 1) uu, vv = np.meshgrid(u_det, v_det, indexing='xy') # (n_v_det, n_u) x_det = (SDD - SOD) * sin_th + uu * cos_th # (n_v_det, n_u) y_det = -(SDD - SOD) * cos_th + uu * sin_th # (n_v_det, n_u) z_det = vv # (n_v_det, n_u) # Ray direction vector (source → detector pixel) dx = x_det - x_src # (n_v_det, n_u) dy = y_det - y_src # (n_v_det, n_u) dz = z_det # (n_v_det, n_u), z_src = 0 # Sample only within the volume's bounding-box region [t_lo, t_hi] t_vals = np.linspace(t_lo, t_hi, n_steps) # (n_steps,) x_ray = x_src + t_vals[:, np.newaxis, np.newaxis] * dx[np.newaxis, :, :] y_ray = y_src + t_vals[:, np.newaxis, np.newaxis] * dy[np.newaxis, :, :] z_ray = t_vals[:, np.newaxis, np.newaxis] * dz[np.newaxis, :, :] # Query volume interpolator: points = (z, y, x) pts = np.stack([z_ray.ravel(), y_ray.ravel(), x_ray.ravel()], axis=-1) vals = vol_interp(pts).reshape(n_steps, geometry.n_v, geometry.n_u) # Trapezoidal integration: physical step = |ray_dir| * Δt ray_length = np.sqrt(dx**2 + dy**2 + dz**2) # (n_v_det, n_u) [mm] dt = (t_hi - t_lo) / (n_steps - 1) step_size = ray_length * dt # (n_v_det, n_u) [mm] projections[i_angle] = np.trapezoid(vals, axis=0) * step_size return projections