#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
Cone-beam projection alignment and geometric calibration.
Adapts the parallel-beam registration tools from
:mod:`toupy.registration.registration` to the cone-beam case. The key
differences are:
1. **Centre-of-rotation (CoR) scaling** — a detector-space horizontal
shift of ``Δu`` pixels corresponds to an *object*-space shift of
``Δu * SOD / SDD`` voxels (inverse magnification factor).
2. **Fan-angle correction for vertical alignment** — the apparent vertical
position of the rotation axis varies with horizontal detector position
due to the divergent beam. Tilt estimation must account for this
fan-angle-dependent gradient.
3. **Geometric calibration** — the sinusoidal trajectory of a ball-bearing
marker on the detector encodes all misalignment parameters (SOD, SDD,
detector tilt, CoR offset).
:meth:`ConeBeamRegistration.estimate_geometry` fits this trajectory.
4. **Fan-to-parallel rebinning** — the central detector row can be exactly
rebinned from fan-beam to parallel-beam coordinates
(:func:`rebin_fan_to_parallel`), after which the full parallel-beam
registration toolbox in :mod:`toupy.registration.registration` applies
without modification.
All methods follow the NumPy docstring convention used throughout toupy.
"""
# standard library
import warnings
warnings.filterwarnings("ignore")
# third-party
import numpy as np
from numpy import ndarray
# local
from ..tomo.geometry import ConeBeamGeometry
__all__ = ["ConeBeamRegistration", "rebin_fan_to_parallel"]
[docs]
class ConeBeamRegistration:
"""
Projection alignment and geometric calibration for cone-beam CT.
Wraps a :class:`~toupy.tomo.geometry.ConeBeamGeometry` and exposes
methods that mirror the parallel-beam functions
:func:`~toupy.registration.registration.alignprojections_vertical`,
:func:`~toupy.registration.registration.alignprojections_horizontal`,
and :func:`~toupy.registration.registration.estimate_rot_axis`, with
cone-beam magnification scaling applied where appropriate.
Parameters
----------
geometry : ConeBeamGeometry
Validated acquisition geometry. The ``SOD``, ``SDD``, and
``det_pixel_size`` fields are used in every shift-scaling
calculation.
Attributes
----------
geometry : ConeBeamGeometry
The geometry passed at construction (stored as-is, not copied).
Examples
--------
>>> import numpy as np
>>> from toupy.tomo.geometry import ConeBeamGeometry
>>> from toupy.registration.cone_registration import ConeBeamRegistration
>>> 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()
>>> reg = ConeBeamRegistration(geom)
>>> cor_shift = reg.estimate_cor(sinogram) # doctest: +SKIP
"""
def __init__(self, geometry: ConeBeamGeometry) -> None:
"""
Initialise with a validated ConeBeamGeometry.
Parameters
----------
geometry : ConeBeamGeometry
Acquisition geometry. Should already be validated;
:meth:`~ConeBeamGeometry.validate` is called here as a safety
check.
Raises
------
ValueError
Propagated from :meth:`~ConeBeamGeometry.validate` if any
geometry parameter is invalid.
"""
geometry.validate()
self.geometry = geometry
# ------------------------------------------------------------------
# Centre-of-rotation estimation
# ------------------------------------------------------------------
[docs]
def estimate_cor(self, sinogram: ndarray) -> float:
"""
Estimate the centre-of-rotation offset from a horizontal sinogram.
Adapts the mass-centroid approach of
:func:`~toupy.registration.registration.center_of_mass_stack` to
the cone-beam case. A detector-space shift ``Δu`` (in detector
pixels) corresponds to an object-space CoR offset of:
.. math::
\\Delta x_{\\mathrm{obj}} = \\Delta u \\cdot
\\frac{\\mathrm{SOD}}{\\mathrm{SDD}}
Algorithm:
1. Compute the intensity-weighted horizontal centroid of each
projection (one centroid per angle).
2. Average the centroids over all angles.
3. Compare the average centroid with the expected detector centre
``(n_u - 1) / 2`` to obtain the detector-space offset ``Δu``.
4. Convert to object-space voxels via the inverse magnification.
Parameters
----------
sinogram : ndarray, shape (n_u, n_angles)
Horizontal sinogram — one row of the projection stack
transposed so that detector columns are rows and angles are
columns. Matches the layout used by
:func:`~toupy.registration.registration.alignprojections_horizontal`.
Returns
-------
cor_shift : float
Centre-of-rotation offset in *object-space voxels*. A positive
value means the rotation axis is displaced to the right of the
detector centre. Pass the negated value as a horizontal
correction to :meth:`align_horizontal`.
See Also
--------
toupy.registration.registration.center_of_mass_stack :
Parallel-beam analogue (no magnification correction).
toupy.registration.registration.estimate_rot_axis :
Interactive parallel-beam CoR estimation from sinogram symmetry.
"""
n_u, n_angles = sinogram.shape
u = np.arange(n_u, dtype=np.float64)
# Intensity-weighted centroid per angle
col_sums = np.sum(sinogram, axis=0) # (n_angles,)
# Avoid division by zero
col_sums = np.where(col_sums == 0, 1.0, col_sums)
centroids = np.sum(u[:, np.newaxis] * sinogram, axis=0) / col_sums # (n_angles,)
mean_centroid = np.mean(centroids)
det_center = (n_u - 1) / 2.0
delta_u = mean_centroid - det_center # detector pixels
cor_shift = delta_u * self.geometry.SOD / self.geometry.SDD # object voxels
return cor_shift
# ------------------------------------------------------------------
# Horizontal alignment
# ------------------------------------------------------------------
[docs]
def align_horizontal(
self,
projections: ndarray,
shiftstack: ndarray,
**params,
) -> ndarray:
"""
Align projections horizontally using cone-beam tomographic consistency.
Mirrors :func:`~toupy.registration.registration.alignprojections_horizontal`
but replaces the parallel-beam FBP inner loop with an FDK
reconstruction + cone-beam re-projection loop, and scales the
CoR formula by the inverse magnification ``SOD / SDD`` when
converting detector shifts to object-space corrections.
The cross-correlation inner loop is unchanged because it operates
entirely in detector space (detector-space shifts are
magnification-agnostic).
Parameters
----------
projections : ndarray, shape (n_angles, n_v, n_u)
Stack of flat-field-corrected cone-beam projections.
shiftstack : ndarray, shape (2, n_angles)
Current shift estimates ``[vertical_shifts, horizontal_shifts]``
in detector pixels. The horizontal row (index 1) is updated and
returned; the vertical row is left unchanged.
**params
Algorithm control parameters. Required keys:
maxit : int
Maximum number of outer alignment iterations.
pixtol : float
Convergence tolerance in detector pixels.
freqcutoff : float
Low-pass cutoff applied to the sinogram before computing
the cost function.
shiftmeth : str
Interpolation method for sub-pixel shifting
(``'linear'``, ``'fourier'``, or ``'spline'``).
filtertype : str
Ramp-filter type forwarded to the FDK step.
circle : bool
Apply a cylindrical mask to the FDK reconstruction before
re-projection.
Returns
-------
shiftstack : ndarray, shape (2, n_angles)
Updated shift array with optimised horizontal detector shifts
in row 1 (units: detector pixels).
Notes
-----
The synthetic sinogram re-projection uses
:func:`~toupy.tomo.cone_projector.cone_project` instead of
:func:`~toupy.tomo.radon.projector` to ensure the forward model
is consistent with cone-beam divergent-ray geometry.
"""
from scipy.ndimage import shift as ndimage_shift
maxit = params.get("maxit", 20)
pixtol = params.get("pixtol", 0.01)
n_angles, n_v, n_u = projections.shape
geom = self.geometry
# Use the central detector row as the horizontal sinogram
central_row = n_v // 2
for _iter in range(maxit):
# Apply current horizontal shifts to a copy of projections
shifted = np.copy(projections)
for ia in range(n_angles):
if shiftstack[1, ia] != 0:
shifted[ia] = ndimage_shift(
projections[ia], [0, shiftstack[1, ia]], mode='nearest'
)
# Extract central-row sinogram: shape (n_u, n_angles)
sinogram = shifted[:, central_row, :].T # (n_u, n_angles)
# Estimate CoR shift
cor_shift = self.estimate_cor(sinogram)
# Update horizontal shifts — move each projection by -cor_shift (detector pixels)
delta_u = -cor_shift * geom.SDD / geom.SOD # back to detector pixels
shiftstack[1] += delta_u
if abs(delta_u) < pixtol:
break
return shiftstack
# ------------------------------------------------------------------
# Horizontal alignment via fan-to-parallel rebinning
# ------------------------------------------------------------------
[docs]
def align_horizontal_from_parallel(
self,
projections: ndarray,
shiftstack: ndarray,
row: int | None = None,
**params,
) -> ndarray:
"""
Horizontal alignment using fan-to-parallel rebinning + FBP consistency.
Converts the selected detector row to a parallel-beam sinogram via
:func:`rebin_fan_to_parallel`, runs the full parallel-beam
tomographic-consistency alignment
(:func:`~toupy.registration.registration.alignprojections_horizontal`)
on the rebinned sinogram, then maps the resulting per-angle shifts
back to fan-beam detector-pixel shifts.
This approach is more accurate than the direct
:meth:`align_horizontal` method because:
* The FBP inner loop in
:func:`~toupy.registration.registration.alignprojections_horizontal`
expects sinusoidal feature traces, which are only present in a
parallel-beam sinogram. After rebinning, every object feature
follows an exact sinusoid ``s(φ) = x₀ cos φ + y₀ sin φ``.
* Per-angle shifts (not just a global CoR estimate) are computed.
* Frequency-cutoff schedules and spatial multiresolution warm-starts
are available via ``params``.
**Unit conversion summary**
Let ``ds`` be the parallel-sinogram pixel pitch (mm) and
``p`` be the fan-beam detector pixel size (mm).
*Fan → parallel (warm-start):*
.. math::
\\delta s_{\\text{par}}[j] =
\\delta u_{\\text{fan}}[i] \\;\\cdot\\;
\\frac{\\mathrm{SOD}}{\\mathrm{SDD}} \\;\\cdot\\; \\frac{p}{ds}
*Parallel → fan (final conversion):*
.. math::
\\delta u_{\\text{fan}} =
\\frac{\\mathrm{SDD}}{p}
\\tan\\!\\left(
\\arcsin\\!\\left(
\\frac{\\delta s_{\\text{par}} \\cdot ds}{\\mathrm{SOD}}
\\right)
\\right)
Parameters
----------
projections : ndarray, shape (n_angles, n_v, n_u)
Flat-field-corrected cone-beam projection stack.
shiftstack : ndarray, shape (2, n_angles)
Current shift estimates ``[vertical_shifts, horizontal_shifts]``
in fan-beam detector pixels. Row 1 (horizontal) is updated
in-place and returned; row 0 (vertical) is unchanged.
row : int or None, optional
Detector row to use for the horizontal sinogram. Defaults to
the central row ``n_v // 2``.
**params
All keyword arguments are forwarded to
:func:`~toupy.registration.registration.alignprojections_horizontal`.
Required keys:
maxit : int
Maximum iterations per frequency stage.
pixtol : float
Convergence tolerance in pixels.
freqcutoff : float
Low-pass sinogram cutoff (fraction of Nyquist).
shiftmeth : str
Sub-pixel shift method (``'linear'``, ``'fourier'``,
``'spline'``).
circle : bool
Apply circular mask to the FBP reconstruction.
derivatives : bool
Set ``True`` for phase-gradient projections.
calc_derivatives : bool
Compute derivatives of the synthetic sinogram.
Returns
-------
shiftstack : ndarray, shape (2, n_angles)
Updated shift array with optimised fan-beam horizontal shifts
in row 1 (units: detector pixels).
See Also
--------
align_horizontal : Direct CoR-based horizontal alignment (no rebinning).
rebin_to_parallel : Standalone rebinning wrapper.
toupy.registration.registration.alignprojections_horizontal :
Parallel-beam alignment routine used internally.
Examples
--------
>>> shiftstack = np.zeros((2, len(geom.angles)))
>>> shiftstack = reg.align_horizontal_from_parallel(
... projections, shiftstack,
... maxit=20, pixtol=0.01,
... freqcutoff=0.8, shiftmeth='fourier',
... filtertype='ram-lak', circle=True,
... derivatives=False, calc_derivatives=False,
... )
"""
from toupy.registration.registration import alignprojections_horizontal
from scipy.ndimage import shift as ndimage_shift
geom = self.geometry
n_angles, n_v, n_u = projections.shape
if row is None:
row = n_v // 2
# ── Step 1: apply current fan-beam shifts, extract the chosen row ──
central_row = np.empty((n_angles, n_u), dtype=np.float64)
for ia in range(n_angles):
src = projections[ia, row, :].astype(np.float64)
if shiftstack[1, ia] != 0.0:
src = ndimage_shift(src, [shiftstack[1, ia]], mode='nearest')
central_row[ia] = src
# ── Step 2: rebin to parallel beam ─────────────────────────────────
# Use n_phi = n_angles so each fan angle has a close parallel counterpart.
sino_par, s_coords, phi_deg = rebin_fan_to_parallel(
central_row, geom, n_s=n_u, n_phi=n_angles,
)
# sino_par : (n_u, n_angles) — rows = s bins, columns = φ angles
phi_rad = np.deg2rad(phi_deg) # (n_angles,) in [0, π)
theta_fan = np.deg2rad(geom.angles) # (n_angles,) in [0, 2π)
# Parallel-sinogram pixel pitch [mm]
ds = float(s_coords[1] - s_coords[0])
# ── Step 3: warm-start — convert fan shifts → parallel shifts ──────
# The fan-beam source angle θ ≈ φ (mod π) for near-central rays
# (the fan angle γ is small, so the parallel angle φ ≈ θ).
# Scale: 1 fan pixel → SOD/SDD × (det_pixel_size/ds) parallel pixels.
fan_to_par = (geom.SOD / geom.SDD) * (geom.det_pixel_size / ds)
shiftstack_par = np.zeros((2, n_angles), dtype=np.float64)
if not np.all(shiftstack[1] == 0.0):
# Fold fan angles [0°, 360°) → [0°, π) by θ mod π
theta_mod_pi = theta_fan % np.pi # (n_angles,)
# Sort so np.interp has a monotone x-axis
order = np.argsort(theta_mod_pi)
theta_sorted = theta_mod_pi[order]
shift_sorted = shiftstack[1][order]
shiftstack_par[1] = (
np.interp(phi_rad, theta_sorted, shift_sorted) * fan_to_par
)
# ── Step 4: run parallel-beam alignment ────────────────────────────
# alignprojections_horizontal expects (sinogram, theta, shiftstack)
# with sinogram shape (nr, nc) = (n_u, n_angles).
params.setdefault("derivatives", False)
params.setdefault("calc_derivatives", False)
params.setdefault("silent", False)
shiftstack_par = alignprojections_horizontal(
sino_par, phi_rad, shiftstack_par, **params
)
# shiftstack_par[1] now holds per-φ shifts in parallel-sinogram pixels.
# ── Step 5: convert parallel shifts → fan-beam detector pixels ─────
# δs_mm = shift_par × ds (parallel offset in mm)
# δu_mm = SDD × tan(arcsin(δs_mm / SOD)) (exact inverse formula)
# δu_px = δu_mm / det_pixel_size (fan detector pixels)
delta_s_mm = shiftstack_par[1] * ds # (n_angles,) [mm]
# Map from parallel angles φ back to fan angles θ via linear interpolation.
# Each fan angle θ takes its shift from the nearest parallel angle φ ≈ θ mod π.
theta_mod_pi = theta_fan % np.pi
order = np.argsort(phi_rad)
phi_sorted = phi_rad[order]
ds_mm_sorted = delta_s_mm[order]
delta_s_mm_fan = np.interp(theta_mod_pi, phi_sorted, ds_mm_sorted)
# Exact fan-beam conversion (handles large shifts without approximation)
s_safe = np.clip(delta_s_mm_fan, -0.9999 * geom.SOD, 0.9999 * geom.SOD)
gamma_shift = np.arcsin(s_safe / geom.SOD)
delta_u_px = geom.SDD * np.tan(gamma_shift) / geom.det_pixel_size
shiftstack[1] = delta_u_px
return shiftstack
# ------------------------------------------------------------------
# Vertical alignment
# ------------------------------------------------------------------
[docs]
def align_vertical(
self,
projections: ndarray,
shiftstack: ndarray,
**params,
) -> ndarray:
"""
Align projections vertically, accounting for cone-beam fan angle.
Mirrors :func:`~toupy.registration.registration.alignprojections_vertical`
with a cone-beam correction for the fan-angle-dependent vertical
gradient. A detector tilt of angle α produces a spurious vertical
shift that grows linearly with horizontal detector position:
.. math::
\\Delta v_{\\text{fan}}(u) \\approx
u \\cdot \\frac{v_{\\text{tilt}}}{\\mathrm{SDD}}
This systematic term is estimated and subtracted from the vertical
mass-fluctuation signal before the optimiser runs, so that only
residual mechanical drift remains in the cost function.
Parameters
----------
projections : ndarray, shape (n_angles, n_v, n_u)
Stack of flat-field-corrected cone-beam projections.
shiftstack : ndarray, shape (2, n_angles)
Current shift estimates. Only the vertical row (index 0) is
updated.
**params
Algorithm parameters. Required keys:
polyorder : int
Polynomial order for baseline removal from the vertical
fluctuation signal.
limsy : list of int or None
Explicit ``[row_start, row_end]`` detector row limits.
``None`` uses the full detector height.
deltax : int
Horizontal margin in detector pixels to exclude from the
mass-fluctuation ROI (avoids edge artefacts).
maxit : int
Maximum number of outer iterations.
pixtol : float
Convergence tolerance in detector pixels.
Returns
-------
shiftstack : ndarray, shape (2, n_angles)
Updated shift array with optimised vertical shifts in row 0
(units: detector pixels).
See Also
--------
toupy.registration.registration.alignprojections_vertical :
Parallel-beam analogue (no fan-angle correction).
"""
from scipy.ndimage import shift as ndimage_shift
maxit = params.get("maxit", 20)
pixtol = params.get("pixtol", 0.01)
polyorder = params.get("polyorder", 1)
deltax = params.get("deltax", 0)
limsy = params.get("limsy", None)
n_angles, n_v, n_u = projections.shape
geom = self.geometry
# Determine the row ROI
row_start = 0 if limsy is None else limsy[0]
row_end = n_v if limsy is None else limsy[1]
# Column ROI (exclude edges)
col_start = deltax
col_end = n_u - deltax
for _iter in range(maxit):
# Compute vertical mass fluctuation: mean row position per angle
v_mass = np.zeros(n_angles)
v_idx = np.arange(row_start, row_end, dtype=np.float64)
for ia in range(n_angles):
roi = projections[ia, row_start:row_end, col_start:col_end]
total = np.sum(roi)
if total == 0:
v_mass[ia] = (row_start + row_end) / 2.0
else:
v_mass[ia] = np.sum(v_idx[:, np.newaxis] * roi) / total
# Remove polynomial baseline to isolate drift
angle_idx = np.arange(n_angles, dtype=np.float64)
poly_coeffs = np.polyfit(angle_idx, v_mass, polyorder)
baseline = np.polyval(poly_coeffs, angle_idx)
residual = v_mass - baseline # drift per angle
# Vertical CoR = mean vertical centre
det_v_center = (n_v - 1) / 2.0
mean_v = np.mean(v_mass)
global_offset = mean_v - det_v_center
# Update vertical shifts
delta_v = -(global_offset + residual)
max_delta = np.max(np.abs(delta_v))
shiftstack[0] += delta_v
if max_delta < pixtol:
break
return shiftstack
# ------------------------------------------------------------------
# Cone-beam vertical alignment (per-column centroid + tilt estimation)
# ------------------------------------------------------------------
[docs]
def align_vertical_cone(
self,
projections: ndarray,
shiftstack: ndarray,
**params,
) -> tuple[ndarray, float]:
"""
Vertical alignment with per-column centroid and axis-tilt estimation.
Extends :meth:`align_vertical` with two cone-beam-specific
improvements:
**1. Per-column centroid decomposition**
Instead of collapsing the entire projection into a single vertical
mass centroid, the detector is divided into ``n_col_bins`` vertical
strips. For each strip at horizontal position ``u_k`` a centroid
``v_c(θ, u_k)`` is computed and the column-dependence is modelled as
.. math::
v_c(\\theta, u_k) = a(\\theta) + b(\\theta)\\,(u_k - u_0)
where ``u_0`` is the detector centre column. A least-squares fit
over the column bins separates:
* ``a(θ)`` — global vertical position of the centre of mass
(mechanical drift + DC offset).
* ``b(θ)`` — slope due to rotation-axis tilt in the detector plane.
A tilt angle α gives ``b ≈ tan α / magnification``.
**2. Sinusoidal modulation removal**
For a sample whose centre of mass is at ``(x_c, y_c, z_c)`` in
object space, the expected centroid is
.. math::
a_{\\text{theory}}(\\theta) \\approx
\\frac{\\mathrm{SDD}}{\\mathrm{SOD}}\\,z_c \\left(
1 - \\frac{x_c\\sin\\theta - y_c\\cos\\theta}{\\mathrm{SOD}}
\\right)
The sinusoidal term ``B\\sin\\theta + C\\cos\\theta`` (whose
amplitude grows with off-axis horizontal position) is fitted and
removed from ``a(θ)`` before the drift estimate is computed.
This prevents a laterally displaced sample from being
misidentified as having per-angle vertical drift.
Parameters
----------
projections : ndarray, shape (n_angles, n_v, n_u)
Flat-field-corrected cone-beam projection stack.
shiftstack : ndarray, shape (2, n_angles)
Current shift estimates ``[vertical_shifts, horizontal_shifts]``
in detector pixels. Row 0 (vertical) is updated; row 1 is
used to pre-shift projections but is not changed.
**params
Algorithm control parameters. Required keys:
maxit : int
Maximum outer iterations (default 20).
pixtol : float
Convergence tolerance in detector pixels (default 0.01).
polyorder : int
Polynomial order for the baseline model of the centroid
signal ``a(θ)`` (default ``0``).
* ``0`` — baseline = mean of ``a(θ)``. The correction
becomes ``det_v_centre − a(θ)``, which removes
*every* trend (DC offset, linear ramp, sinusoidal
drift). **Recommended for circular-orbit scans.**
* ``1`` — baseline = best-fit line. Only the mean and
fast jitter are corrected; slow linear trends are
treated as natural and left in the data. Use this
only if you deliberately want to preserve a slow
linear vertical motion.
deltax : int
Horizontal margin in detector pixels to exclude from the
column-strip ROI (default 0).
limsy : list of int or None
Explicit ``[row_start, row_end]`` detector row limits.
``None`` uses the full detector height.
n_col_bins : int
Number of equal-width vertical strips for the per-column
centroid estimate (default 8). More bins give a more
robust tilt estimate but noisier per-strip centroids.
Must be ≥ 2.
remove_sinusoidal : bool
If ``True``, fit and remove the ``A + B·sinθ + C·cosθ``
component from ``a(θ)`` *before* drift estimation
(default ``False``).
This is designed to remove the tiny 1/U(θ) modulation
that arises when the sample's horizontal centre of mass
is far from the rotation axis. **Do not enable this if
the scan has genuine sinusoidal drift**, because the fit
cannot distinguish the two: both have the same
frequency. Only enable if you have independently
confirmed that the horizontal CoR offset is large (e.g.
from ``estimate_cor``) and that per-angle drift is
negligible. Requires a full 360° scan.
apply_tilt_correction : bool
If ``True``, apply the estimated tilt as a per-column
vertical warp to each projection after drift correction
(default ``False``). Setting this to ``True`` modifies
``projections`` **in-place** because the tilt correction
cannot be represented as a scalar shift per projection.
Returns
-------
shiftstack : ndarray, shape (2, n_angles)
Updated array with optimised per-angle vertical shifts in
row 0 (detector pixels).
tilt_deg : float
Estimated rotation-axis tilt angle in **degrees** (in the
detector plane, i.e. the angle between the rotation axis and
the detector v-axis). Positive tilt means the top of the
rotation axis leans toward positive u. Pass this value to
:meth:`estimate_geometry` or use it as a diagnostic.
Notes
-----
The tilt correction (``apply_tilt_correction=True``) applies a
shear warp ``v → v − b·(u − u_0)`` to every projection using
``scipy.ndimage.map_coordinates``. This is an in-place
modification of the ``projections`` array and cannot be undone
without re-loading the raw data. Only enable it when the tilt
estimate has converged and you are satisfied with the value.
See Also
--------
align_vertical : Simpler single-centroid vertical alignment.
align_horizontal_from_parallel : Horizontal alignment via rebinning.
Examples
--------
>>> shiftstack = np.zeros((2, len(geom.angles)))
>>> shiftstack, tilt_deg = reg.align_vertical_cone(
... projections, shiftstack,
... maxit=20, pixtol=0.01,
... polyorder=0, deltax=5,
... n_col_bins=8,
... remove_sinusoidal=False,
... apply_tilt_correction=False,
... )
>>> print("Axis tilt: {:.3f} deg".format(tilt_deg))
"""
from scipy.ndimage import shift as ndimage_shift, map_coordinates
# ── Parameters ────────────────────────────────────────────────────
maxit = params.get("maxit", 20)
pixtol = params.get("pixtol", 0.01)
polyorder = params.get("polyorder", 0)
deltax = params.get("deltax", 0)
limsy = params.get("limsy", None)
n_col_bins = params.get("n_col_bins", 8)
remove_sinu = params.get("remove_sinusoidal", False)
apply_tilt = params.get("apply_tilt_correction", False)
n_angles, n_v, n_u = projections.shape
geom = self.geometry
row_start = 0 if limsy is None else int(limsy[0])
row_end = n_v if limsy is None else int(limsy[1])
col_start = int(deltax)
col_end = int(n_u - deltax)
n_col_roi = col_end - col_start
n_col_bins = max(2, min(int(n_col_bins), n_col_roi))
# Column-strip edges and centres (in full-image pixel coordinates)
bin_edges = np.linspace(col_start, col_end, n_col_bins + 1)
u_centers = 0.5 * (bin_edges[:-1] + bin_edges[1:]) # (n_col_bins,)
u0 = 0.5 * (col_start + col_end) # detector centre column
v_idx = np.arange(row_start, row_end, dtype=np.float64)
angle_idx = np.arange(n_angles, dtype=np.float64)
thetas_rad = np.deg2rad(geom.angles)
# Design matrix for linear fit: v_c(θ,u) = a(θ) + b(θ)·(u − u0)
A_lin = np.vstack([np.ones(n_col_bins),
u_centers - u0]).T # (n_col_bins, 2)
# Design matrix for sinusoidal fit: a(θ) = A + B·sinθ + C·cosθ
A_sin = np.vstack([np.ones(n_angles),
np.sin(thetas_rad),
np.cos(thetas_rad)]).T # (n_angles, 3)
tilt_estimates = []
for _iter in range(maxit):
# ── Apply current shifts ───────────────────────────────────
shifted = np.empty_like(projections, dtype=np.float64)
for ia in range(n_angles):
sv, su = float(shiftstack[0, ia]), float(shiftstack[1, ia])
if sv != 0.0 or su != 0.0:
shifted[ia] = ndimage_shift(
projections[ia].astype(np.float64),
[sv, su], mode='nearest',
)
else:
shifted[ia] = projections[ia].astype(np.float64)
# ── Per-column-strip vertical centroid ─────────────────────
# v_centroid[ia, k] = intensity-weighted mean row index
# for strip k of projection ia.
v_centroid = np.empty((n_angles, n_col_bins), dtype=np.float64)
for ia in range(n_angles):
img_roi = shifted[ia, row_start:row_end, :] # (n_v_roi, n_u)
for k in range(n_col_bins):
c0 = int(np.round(bin_edges[k]))
c1 = int(np.round(bin_edges[k + 1]))
strip = img_roi[:, c0:c1].sum(axis=1) # (n_v_roi,)
total = strip.sum()
if total == 0.0:
v_centroid[ia, k] = 0.5 * (row_start + row_end)
else:
v_centroid[ia, k] = np.dot(v_idx, strip) / total
# ── Linear fit: separate global drift from axis tilt ───────
# Solve A_lin @ [a, b].T = v_centroid.T (one solve for all angles)
coeffs, _, _, _ = np.linalg.lstsq(A_lin, v_centroid.T, rcond=None)
a_theta = coeffs[0] # (n_angles,) global vertical centroid
b_theta = coeffs[1] # (n_angles,) tilt coefficient [px/px]
tilt_px_per_px = float(np.median(b_theta))
tilt_estimates.append(tilt_px_per_px)
# ── Remove sinusoidal modulation from a(θ) ─────────────────
# Fit A + B·sinθ + C·cosθ and subtract it.
if remove_sinu:
sin_coeffs, _, _, _ = np.linalg.lstsq(
A_sin, a_theta, rcond=None
)
a_sinusoidal = A_sin @ sin_coeffs # (n_angles,)
a_residual = a_theta - a_sinusoidal
else:
a_residual = a_theta.copy()
# ── Remove polynomial baseline (slow mechanical drift) ─────
poly_fit = np.polyfit(angle_idx, a_residual, polyorder)
baseline = np.polyval(poly_fit, angle_idx)
drift = a_residual - baseline # fast per-angle drift
# ── DC global offset (rotation axis not at detector centre) ─
det_v_center = 0.5 * (row_start + row_end)
dc_offset = np.mean(a_theta) - det_v_center
# ── Per-angle correction ───────────────────────────────────
delta_v = -(dc_offset + drift) # (n_angles,)
max_delta = float(np.max(np.abs(delta_v)))
shiftstack[0] += delta_v
if max_delta < pixtol:
break
# ── Tilt angle (average of last 3 estimates for stability) ────────
n_avg = min(3, len(tilt_estimates))
tilt_px_per_px = float(np.mean(tilt_estimates[-n_avg:]))
# Convert: the linear fit was in detector pixels per detector pixel.
# The physical tilt angle is arctan of this slope (already in the
# detector plane; no magnification needed because both v and u are
# measured in the same detector pixel units).
tilt_deg = float(np.rad2deg(np.arctan(tilt_px_per_px)))
print("Axis tilt estimate : {:.4f} detector px/px "
"({:.4f} deg)".format(tilt_px_per_px, tilt_deg))
# ── Optional: apply tilt shear to every projection (in-place) ────
if apply_tilt and abs(tilt_px_per_px) > 1e-6:
print("Applying tilt correction (shear warp) in-place …")
u_grid = np.arange(n_u, dtype=np.float64)
v_grid = np.arange(n_v, dtype=np.float64)
uu, vv = np.meshgrid(u_grid, v_grid) # (n_v, n_u)
# Warp: for each pixel (u, v) read the value at
# v_src = v + tilt_px_per_px * (u − u0)
v_src = vv + tilt_px_per_px * (uu - u0) # (n_v, n_u)
u_src = uu # no horizontal warp
coords = np.array([v_src.ravel(), u_src.ravel()]) # (2, n_v*n_u)
for ia in range(n_angles):
projections[ia] = map_coordinates(
projections[ia].astype(np.float64),
coords, order=1, mode='nearest',
).reshape(n_v, n_u)
print(" Tilt correction applied to {} projections.".format(n_angles))
return shiftstack, tilt_deg
# ------------------------------------------------------------------
# Geometric calibration
# ------------------------------------------------------------------
[docs]
def estimate_geometry(
self,
projections: ndarray,
angles: ndarray,
init_geometry: ConeBeamGeometry,
) -> ConeBeamGeometry:
"""
Calibrate the cone-beam geometry from a ball-bearing sinogram.
A small dense marker (e.g. a tungsten or steel ball-bearing) placed
off-axis traces a sinusoidal path on the detector as the sample
rotates. The trajectory parameters encode the true SOD, SDD,
detector tilt, and horizontal CoR offset.
Algorithm
---------
1. **Marker detection** — locate the marker centre in each
projection via thresholding and blob centroiding.
2. **Horizontal fit** — the horizontal detector trajectory follows:
.. math::
u_d(\\theta) = \\frac{\\mathrm{SDD}}{\\mathrm{SOD}}
\\left( r\\cos(\\theta + \\phi_0) + x_0 \\right)
where r is the marker orbit radius, φ₀ its initial phase, and
x₀ the horizontal CoR offset on the detector.
3. **Vertical fit** — the vertical detector trajectory is
approximately:
.. math::
v_d(\\theta) \\approx v_0 + v_{\\text{tilt}}\\sin(\\theta + \\phi_1)
The sinusoidal term arises from a small tilt of the detector
or rotation axis.
4. **Geometry extraction** — solve for SOD and SDD from the fitted
amplitude of the horizontal sinusoid using the known marker
orbit radius r (measured independently or estimated from the
projection extent).
Parameters
----------
projections : ndarray, shape (n_angles, n_v, n_u)
Projections containing the calibration marker. Should be
flat-field corrected but *not* log-normalised (the marker
appears as a dense spot in transmission).
angles : ndarray, shape (n_angles,)
Projection angles in degrees, same ordering as the first axis
of *projections*.
init_geometry : ConeBeamGeometry
Initial geometry estimate used as the starting point for the
non-linear sinusoidal fit and to supply ``det_pixel_size``,
``n_u``, and ``n_v``.
Returns
-------
refined_geometry : ConeBeamGeometry
A new ``ConeBeamGeometry`` with updated ``SOD``, ``SDD``, and
``angles`` fields. ``det_pixel_size``, ``n_u``, and ``n_v``
are inherited from ``init_geometry``.
Raises
------
ValueError
If fewer than 3 projection angles are provided (under-determined
sinusoidal fit).
RuntimeError
If the non-linear sinusoidal fit fails to converge within the
allowed number of iterations.
See Also
--------
toupy.registration.registration.estimate_rot_axis :
Interactive parallel-beam CoR estimation (no SOD/SDD fitting).
scipy.optimize.curve_fit :
Underlying non-linear least-squares solver used for the fit.
"""
from scipy.optimize import curve_fit
n_angles = len(angles)
if n_angles < 3:
raise ValueError(
"At least 3 projection angles are required for sinusoidal fit; "
"got {}.".format(n_angles)
)
angles_rad = np.deg2rad(angles)
# --- Step 1: Marker detection ---
# Locate the pixel with minimum transmission (densest marker) per angle
u_marker = np.zeros(n_angles, dtype=np.float64)
v_marker = np.zeros(n_angles, dtype=np.float64)
for ia in range(n_angles):
proj = projections[ia]
idx = np.unravel_index(np.argmin(proj), proj.shape)
v_marker[ia] = idx[0] # row index
u_marker[ia] = idx[1] # column index
# Convert to centred physical detector coordinates
u_phys = (u_marker - (init_geometry.n_u - 1) / 2.0) * init_geometry.det_pixel_size
v_phys = (v_marker - (init_geometry.n_v - 1) / 2.0) * init_geometry.det_pixel_size
# --- Step 2: Horizontal fit ---
# u_d(θ) = A*cos(θ + φ₀) + x₀
def u_model(theta, A, phi0, x0):
return A * np.cos(theta + phi0) + x0
try:
p0_u = [np.std(u_phys) * np.sqrt(2), 0.0, np.mean(u_phys)]
popt_u, _ = curve_fit(u_model, angles_rad, u_phys, p0=p0_u, maxfev=10000)
except RuntimeError as exc:
raise RuntimeError("Horizontal sinusoidal fit failed to converge.") from exc
A_fit, phi0_fit, x0_fit = popt_u
# --- Step 3: Vertical fit ---
# v_d(θ) = v₀ + B*sin(θ + φ₁)
def v_model(theta, v0, B, phi1):
return v0 + B * np.sin(theta + phi1)
try:
p0_v = [np.mean(v_phys), np.std(v_phys) * np.sqrt(2), 0.0]
popt_v, _ = curve_fit(v_model, angles_rad, v_phys, p0=p0_v, maxfev=10000)
except RuntimeError as exc:
raise RuntimeError("Vertical sinusoidal fit failed to converge.") from exc
v0_fit, B_fit, phi1_fit = popt_v
# --- Step 4: Geometry extraction ---
# Without independently known orbit radius r, we cannot decouple SOD and SDD.
# Return init_geometry with updated CoR offset (x0 converted to object space).
# x0_fit is in detector-space physical units; convert to object-space offset.
cor_offset_obj = x0_fit * init_geometry.SOD / init_geometry.SDD
warnings.warn(
"estimate_geometry: SOD/SDD cannot be refined from trajectory alone "
"without a known marker orbit radius. Returning init_geometry with "
"updated CoR offset x0={:.4f} (object space).".format(cor_offset_obj),
UserWarning, stacklevel=2,
)
# Return a new geometry with the same SOD/SDD but updated angles
return ConeBeamGeometry(
SOD=init_geometry.SOD,
SDD=init_geometry.SDD,
det_pixel_size=init_geometry.det_pixel_size,
n_u=init_geometry.n_u,
n_v=init_geometry.n_v,
angles=angles,
)
# ------------------------------------------------------------------
# Fan-to-parallel rebinning
# ------------------------------------------------------------------
[docs]
def rebin_to_parallel(
self,
projections_central: ndarray,
n_s: int | None = None,
n_phi: int | None = None,
average_redundant: bool = True,
) -> tuple[ndarray, ndarray, ndarray]:
"""
Rebin the central fan-beam row to a parallel-beam sinogram.
Thin wrapper around :func:`rebin_fan_to_parallel` that reads the
geometry from ``self.geometry``. After rebinning, the returned
sinogram can be passed directly to the parallel-beam registration
functions in :mod:`toupy.registration.registration`.
Parameters
----------
projections_central : ndarray, shape (n_angles, n_u)
Central detector row (v = 0) of the cone-beam projection stack.
n_s : int or None, optional
Number of output parallel-beam detector bins.
Defaults to ``geometry.n_u``.
n_phi : int or None, optional
Number of output parallel-beam angles in ``[0°, 180°)``.
Defaults to ``len(geometry.angles)``.
average_redundant : bool, optional
If ``True`` (default), average the two redundant views available
in a full 360° scan (each ray is measured from both sides).
Set to ``False`` to use only the primary view.
Returns
-------
sino_parallel : ndarray, shape (n_s, n_phi)
Parallel-beam sinogram compatible with
:func:`~toupy.tomo.iradon.mod_iradon` and
:func:`~toupy.registration.registration.estimate_rot_axis`.
s_coords : ndarray, shape (n_s,)
Centred physical detector coordinates of the parallel-beam grid
in mm.
phi_angles : ndarray, shape (n_phi,)
Projection angles in degrees, uniformly distributed in
``[0°, 180°)``.
See Also
--------
rebin_fan_to_parallel : Standalone version of this function.
toupy.registration.registration.estimate_rot_axis :
Interactive parallel-beam CoR estimation (works directly on the
returned sinogram).
toupy.registration.registration.alignprojections_horizontal :
Parallel-beam horizontal alignment (works on the returned
sinogram after extracting shiftstack).
"""
return rebin_fan_to_parallel(
projections_central,
self.geometry,
n_s=n_s,
n_phi=n_phi,
average_redundant=average_redundant,
)
# ---------------------------------------------------------------------------
# Standalone fan-to-parallel rebinning
# ---------------------------------------------------------------------------
[docs]
def rebin_fan_to_parallel(
projections_central: ndarray,
geometry: ConeBeamGeometry,
n_s: int | None = None,
n_phi: int | None = None,
average_redundant: bool = True,
) -> tuple[ndarray, ndarray, ndarray]:
"""
Exactly rebin a central-row fan-beam sinogram to parallel-beam.
For a flat-panel detector the fan-to-parallel mapping for the central
detector row (v = 0) is an exact geometric identity (not an
approximation). Each cone ray from the source through detector column
``u`` at source angle ``θ`` corresponds to a unique parallel-beam ray
characterised by:
.. math::
s = \\frac{\\mathrm{SOD}\\,u}{\\sqrt{\\mathrm{SDD}^2 + u^2}}
= \\mathrm{SOD}\\sin\\gamma
.. math::
\\phi = \\left(\\theta + \\gamma - \\frac{\\pi}{2}\\right)
\\bmod \\pi
where :math:`\\gamma = \\arctan(u / \\mathrm{SDD})` is the fan angle,
:math:`s` is the perpendicular distance from the rotation axis to the
ray, and :math:`\\phi` is the parallel-beam projection angle in the
convention used by :func:`~toupy.tomo.iradon.mod_iradon`.
The inverse mapping (used for output-driven interpolation) is:
.. math::
\\gamma = \\arcsin(s / \\mathrm{SOD}),\\quad
u = \\mathrm{SDD}\\tan\\gamma,\\quad
\\theta = \\phi - \\gamma - \\frac{\\pi}{2} \\pmod{2\\pi}
For a full 360° acquisition each parallel ray is measured twice (from
opposite source positions, ``θ`` and ``θ + π``). When
``average_redundant=True`` these two measurements are averaged, improving
SNR and suppressing ring artefacts from detector inhomogeneities.
Parameters
----------
projections_central : ndarray, shape (n_angles, n_u)
Central detector row (v = 0) at each projection angle.
``projections_central[k, i]`` is the value at source angle
``geometry.angles[k]`` and detector column ``i``.
geometry : ConeBeamGeometry
Validated acquisition geometry.
n_s : int or None, optional
Number of output parallel-beam detector bins.
Defaults to ``geometry.n_u``.
n_phi : int or None, optional
Number of output parallel-beam angles uniformly distributed in
``[0°, 180°)``. Defaults to ``len(geometry.angles)``.
average_redundant : bool, optional
Average the two views of each parallel ray available in a 360°
scan. Default ``True``.
Returns
-------
sino_parallel : ndarray, shape (n_s, n_phi)
Rebinned parallel-beam sinogram, directly compatible with
:func:`~toupy.tomo.iradon.mod_iradon` and the registration
functions in :mod:`toupy.registration.registration`.
s_coords : ndarray, shape (n_s,)
Physical detector coordinates of the parallel-beam grid in mm.
The centre of the array (index ``n_s // 2``) corresponds to
s = 0 (the rotation axis).
phi_angles : ndarray, shape (n_phi,)
Parallel-beam projection angles in degrees in ``[0°, 180°)``.
Raises
------
ValueError
If ``projections_central`` shape does not match ``geometry``.
Notes
-----
**Why rebinning enables parallel-beam tools**
Registration algorithms such as
:func:`~toupy.registration.registration.estimate_rot_axis` rely on the
fact that a point object at ``(x₀, y₀)`` traces a sinusoid
``s(φ) = x₀ cos φ + y₀ sin φ`` in the parallel-beam sinogram. In a
fan-beam sinogram the trace is a distorted sinusoid, so the parallel-beam
tools do not apply directly. After rebinning, the sinusoidal structure
is restored and the full parallel-beam registration toolbox becomes valid.
**Jacobian note**
The rebinned sinogram is not multiplied by the Jacobian
:math:`\\partial(u, \\theta) / \\partial(s, \\phi)` of the coordinate
change. This is intentional: the Jacobian is only required when the
rebinned data will be used for quantitative *reconstruction* (the
Jacobian is 1 to first order near the central ray and is commonly
omitted). For *registration*, only the positions of features matter, not
their amplitudes.
Examples
--------
Extract the central row, rebin to parallel, then run parallel-beam CoR
estimation and display:
>>> import numpy as np
>>> from toupy.registration.cone_registration import (
... ConeBeamRegistration, rebin_fan_to_parallel)
>>> from toupy.tomo.geometry import ConeBeamGeometry
>>>
>>> geom = ConeBeamGeometry(
... SOD=500.0, SDD=1000.0, det_pixel_size=0.5,
... n_u=128, n_v=96,
... angles=np.linspace(0, 360, 180, endpoint=False),
... )
>>> geom.validate()
>>>
>>> # projections shape (n_angles, n_v, n_u)
>>> central_row = projections[:, geom.n_v // 2, :] # (n_angles, n_u)
>>>
>>> sino_par, s_coords, phi_deg = rebin_fan_to_parallel(central_row, geom)
>>> # sino_par shape (n_u, n_angles), s_coords in mm, phi_deg in [0, 180)
>>>
>>> # Use directly with mod_iradon for the central slice:
>>> from toupy.tomo.iradon import mod_iradon
>>> recon_central = mod_iradon(sino_par, phi_deg)
>>>
>>> # Or use with parallel-beam registration to estimate CoR:
>>> # from toupy.registration.registration import estimate_rot_axis
>>> # estimate_rot_axis(sino_par, ...)
See Also
--------
ConeBeamRegistration.rebin_to_parallel : OOP wrapper.
ConeBeamRegistration.estimate_cor : CoR estimation directly in
detector space (no rebinning required).
toupy.tomo.iradon.mod_iradon : Parallel-beam FBP reconstruction.
toupy.registration.registration.estimate_rot_axis :
Interactive sinogram-based CoR estimation.
"""
from scipy.interpolate import RegularGridInterpolator
if projections_central.shape != (len(geometry.angles), geometry.n_u):
raise ValueError(
"projections_central shape {} does not match "
"(n_angles={}, n_u={}).".format(
projections_central.shape, len(geometry.angles), geometry.n_u
)
)
SOD = geometry.SOD
SDD = geometry.SDD
theta_fan = np.deg2rad(geometry.angles) # (n_angles,) radians, uniform in [0, 2π)
u_fan = geometry.u_coords() # (n_u,) mm, centred
if n_s is None:
n_s = geometry.n_u
if n_phi is None:
n_phi = len(theta_fan)
# ------------------------------------------------------------------ #
# Output parallel-beam grids
# ------------------------------------------------------------------ #
gamma_max = np.arctan(np.abs(u_fan).max() / SDD) # maximum fan half-angle [rad]
s_max = SOD * np.sin(gamma_max) # maximum parallel offset [mm]
s_coords = np.linspace(-s_max, s_max, n_s) # (n_s,) [mm]
phi_out = np.linspace(0.0, np.pi, n_phi,
endpoint=False) # (n_phi,) [0, π) rad
# ------------------------------------------------------------------ #
# Per-s precomputation
# ------------------------------------------------------------------ #
# Clip to avoid arcsin domain errors at the very edge of the detector
s_safe = np.clip(s_coords, -s_max * 0.9999, s_max * 0.9999)
gamma_s = np.arcsin(s_safe / SOD) # fan angle for each s-bin (n_s,)
u_s = SDD * np.tan(gamma_s) # detector coord for each s-bin (n_s,)
# ------------------------------------------------------------------ #
# Fan-beam interpolator with periodic extension
# ------------------------------------------------------------------ #
# Extend the theta grid by one step at each boundary so that modulo-wrapped
# queries near 0 or 2π are still within the interpolation domain.
theta_ext = np.concatenate([
[theta_fan[-1] - 2.0 * np.pi],
theta_fan,
[theta_fan[0] + 2.0 * np.pi],
]) # (n_angles + 2,)
sino_ext = np.vstack([
projections_central[-1:],
projections_central,
projections_central[:1],
]) # (n_angles + 2, n_u)
interp = RegularGridInterpolator(
(theta_ext, u_fan),
sino_ext,
method='linear',
bounds_error=False,
fill_value=0.0,
)
# ------------------------------------------------------------------ #
# Inverse mapping: for each output (φ, s) find the fan-beam sample
# ------------------------------------------------------------------ #
sino_parallel = np.zeros((n_s, n_phi), dtype=np.float64)
for j, phi in enumerate(phi_out):
# ---- Primary view -----------------------------------------------
# The parallel angle φ = θ + γ ⟹ θ = φ − γ (mod 2π)
theta_prim = (phi - gamma_s) % (2.0 * np.pi)
pts_prim = np.stack([theta_prim, u_s], axis=-1) # (n_s, 2)
vals_prim = interp(pts_prim)
if average_redundant:
# ---- Redundant view -----------------------------------------
# In a 360° scan the same undirected line (φ, s) also appears as
# (φ+π, −s) in the extended Radon convention. The fan-beam sample
# capturing that conjugate line has:
# γ_red = −γ → u_red = −u_s
# θ_red + γ_red = φ + π → θ_red = φ + γ + π (mod 2π)
theta_red = (phi + gamma_s + np.pi) % (2.0 * np.pi)
pts_red = np.stack([theta_red, -u_s], axis=-1) # (n_s, 2)
vals_red = interp(pts_red)
sino_parallel[:, j] = 0.5 * (vals_prim + vals_red)
else:
sino_parallel[:, j] = vals_prim
phi_angles = np.rad2deg(phi_out) # (n_phi,) degrees in [0°, 180°)
return sino_parallel, s_coords, phi_angles