API Reference

Every exported name of BasisSimulator.jl, generated from its docstrings at build time. The core surface is five structs, two entry points (simulate! and reconstruct!), and helpers grouped by workflow stage. The worked examples show them in real pipelines.

319 exported names

Overview

Every simulation walks the same pipeline:

Phantom             ─┐
EICT/PCCTScanner    ─┤
CTProtocol          ─┼─▶ create_*_workspace → simulate! → reconstruct! → to_hounsfield → recon (HU)
SimOptions          ─┤
ReconOptions        ─┘

All five stages are plain Julia structs. The backend (CPU or GPU) is chosen by where the phantom mask lives: wrap it in a CuArray, MtlArray or ROCArray for a GPU, or leave it a plain Array for the CPU. The same code path runs everywhere.

Everything below is generated from the package's docstrings when the site is built, so it cannot drift from the code. Each entry links to its source on GitHub. The worked examples show these functions inside real pipelines.

The five-struct API

These are the load-bearing types. Every public entry point takes some combination of them. Four are concrete structs with named fields. The scanner is the one abstraction: an abstract Scanner with one concrete family per detector type (EICTScanner for scintillators, PCCTScanner for photon counting). That split means a photon-counting parameter can't be set on a scintillator, and a scintillator parameter can't be set on a photon-counting detector.

src/object/phantom.jl

Phantomstruct
Phantom

Digital phantom with semantic mask and materials for polychromatic simulation.

Fields

  • mask::AbstractArray{<:Unsigned,3}: Region labels (UInt8 for ≤255, UInt16 for >255)

  • materials::Vector{XA.Material}: Materials for each region (indexed by mask_value + 1)

  • voxel_size::NTuple{3,Float64}: Voxel dimensions (cm) as (dx, dy, dz)

  • origin::NTuple{3,Float64}: Origin coordinates (cm) - center of first voxel

  • extent::NTuple{3,Float64}: Physical extent (cm) as (x, y, z)

Coordinate System

  • X: left-right (increasing right)

  • Y: anterior-posterior (increasing posterior)

  • Z: inferior-superior (increasing superior)

  • Origin at isocenter (0, 0, 0)

Design

A phantom stores no μ: polychromatic simulation computes μ(E) on demand at each spectrum energy. Use compute_μ(phantom, energy_keV) to get attenuation coefficients at any energy.

Usage

# Create phantom (no energy_keV needed!)
materials_dict = Dict(0 => XA.Materials.air, 1 => XA.Materials.water)
phantom = Phantom(labeled_array, materials_dict, (0.1, 0.1, 0.1))

# Get μ at any energy when needed
μ_60keV = compute_μ(phantom, 60.0)
μ_120keV = compute_μ(phantom, 120.0)

# Simulate via workspace API
ws = create_eict_workspace(scanner, protocol, sim_opts, recon_opts, phantom)
simulate!(ws, phantom, protocol, sim_opts)

# GPU workflow: mask on GPU, materials stay on CPU
using Metal
phantom_gpu = Phantom(
    MtlArray(phantom_cpu.mask),
    phantom_cpu.materials,
    phantom_cpu.voxel_size,
    phantom_cpu.origin,
    phantom_cpu.extent
)

See also: compute_μ


Phantom(labeled_array, materials_dict, voxel_size_cm; kwargs...) -> Phantom

Create a Phantom from a labeled array with materials stored internally.

This is the unified API: the returned Phantom contains everything needed for polychromatic simulation via the workspace-based simulate!() pipeline.

No energy is needed: a phantom stores no μ. Use compute_μ(phantom, energy_keV) to get attenuation coefficients at any energy.

Arguments

  • labeled_array::AbstractArray{<:Integer, 3}: Integer array where each voxel contains a region label. Auto-promotes to UInt16 when >255 labels.

  • materials_dict::Dict{Int, <:Any}: Mapping from label values to materials. Materials can be:

    • XA.Material: Direct XrayAttenuation.jl material

    • Symbol: Material name to look up (e.g., :water, :Ca_100)

  • voxel_size_cm::NTuple{3, Real}: Physical voxel dimensions in cm as (dx, dy, dz)

Keyword Arguments

  • origin::Union{Nothing, NTuple{3, Real}}=nothing: Origin coordinates (cm). If nothing, phantom is centered at isocenter.

Returns

A Phantom with:

  • mask: UInt8 mask (≤255 labels) or UInt16 mask (>255 labels)

  • materials: Vector{XA.Material} for polychromatic simulation

  • voxel_size, origin, extent: Geometry parameters

Example

using BasisSimulator, XrayAttenuation
import XrayAttenuation as XA

# Define materials
materials_dict = Dict{Int, XA.Material}(
    0 => XA.Materials.air,
    1 => XA.Materials.water,
    2 => XA.Materials.corticalbone
)

# Create phantom (1mm voxels)
phantom = Phantom(labeled_array, materials_dict, (0.1, 0.1, 0.1))

# Get μ at any energy when needed
μ_60keV = compute_μ(phantom, 60.0)

# Simulate via workspace API
ws = create_eict_workspace(scanner, protocol, SimOptions(), ReconOptions(), phantom)
simulate!(ws, phantom, protocol)

See also: compute_μ, create_phantom_from_mask, create_gammex_472

src/geometry/scanner.jl

Scannerabstract typesource ↗
Scanner{T<:AbstractFloat}

Abstract root of the scanner families, and the type every consumer dispatches on. It is not constructible: build an EICTScanner (scintillator detector) or a PCCTScanner (direct-conversion detector). Both compose a shared ScannerGeometry whose fields are reachable straight off the scanner, so the geometry parameters below read the same on either family, while the detector parameters belong to one family each and the other rejects them.

Use CTGeometry for simulation with pre-computed trajectories. All distances in mm for consistency with CatSim and medical imaging conventions.

Geometry Parameters (Required)

  • source_to_isocenter::T: Source-to-isocenter distance (mm), aka SID/SOD

  • source_to_detector::T: Source-to-detector distance (mm), aka SDD

Detector Parameters

  • detector_rows::Int: Number of detector rows (z-direction)

  • detector_cols::Int: Number of detector columns (fan direction)

  • detector_row_size::T: Detector element size in z (mm) at isocenter

  • detector_col_size::T: Detector element size in fan direction (mm) at isocenter

  • detector_row_offset::T: Row offset from centered position (rows)

The detector is an equiangular ARC (cylindrical, centred on the focal spot — clinical third-generation MDCT geometry) by DEFAULT; detector_shape = :flat selects a planar panel instead. Both shapes are supported end-to-end by all projectors (Siddon / DD / dd_fast), the FDK weighting (Kak-Slaney equiangular cosγ pre-weight + (γ/sinγ)² kernel for :arc), the helical WFBP rebinning, and the bowtie model (whose thickness tables are natively fan-angle-parameterised).

  • detector_col_offset::T: Column offset (quarter-detector offset for aliasing)

Source Parameters

  • focal_spot_width::T: Focal spot width (mm)

  • focal_spot_length::T: Focal spot length (mm)

  • target_angle::T: Anode target angle (degrees)

Gantry Parameters

  • gantry_rotation_time::T: Gantry rotation time (seconds)

  • scan_diameter::T: Maximum scan diameter (mm)

  • gantry_aperture::T: Gantry bore diameter (mm)

Filter Parameters

  • flat_filter_material::Symbol: Flat filter material (:aluminum, :copper, :titanium)

  • flat_filter_thickness::T: Flat filter thickness (mm)

  • bowtie_filter::Symbol: Bowtie filter name (:large_body, :medium_body, :small_body, :head, :none)

Detection Parameters

  • detector_material::Symbol: Detector scintillator/sensor material

  • detector_depth::T: Detector sensor depth (mm)

  • fill_factor_row::T: Active area fraction (row direction, 0-1)

  • fill_factor_col::T: Active area fraction (column direction, 0-1)

  • detection_gain::T: Conversion gain (electrons/keV) — EICTScanner only

  • electronic_noise::T: Electronic noise std dev (electrons) — EICTScanner only

Construction

scanner = EICTScanner(
    source_to_isocenter = 540.0,
    source_to_detector = 950.0,
    detector_rows = 64,
    detector_cols = 900,
    # ... other kwargs with defaults
)

CatSim Parameter Mapping

Scanner FieldCatSim Parameter
source_to_isocenterscanner.sid
source_to_detectorscanner.sdd
detector_rowsscanner.detectorRowCount
detector_colsscanner.detectorColCount
detector_row_sizescanner.detectorRowSize
detector_col_sizescanner.detectorColSize
detector_row_offsetscanner.detectorRowOffset
detector_col_offsetscanner.detectorColOffset
target_anglescanner.targetAngle
focal_spot_widthscanner.focalspotWidth
focal_spot_lengthscanner.focalspotLength
detector_materialscanner.detectorMaterial
detector_depthscanner.detectorDepth
fill_factor_rowscanner.detectorRowFillFraction
fill_factor_colscanner.detectorColFillFraction
detection_gainscanner.detectionGain
electronic_noisescanner.eNoise

References

  • CatSim scanner configuration: cfg/Scanner_Default.cfg

  • AAPM TG-233: CT Image Quality Standards

EICTScannerstruct
EICTScanner{T} <: Scanner{T}

An energy-integrating (scintillator) CT scanner: the shared ScannerGeometry plus the scintillator detection model (material, depth, fill factors, detection gain, electronic noise). Construct with EICTScanner(; kwargs...).


EICTScanner(; kwargs...)

Energy-integrating scanner. Geometry keywords as Scanner (distances in mm, detector array, focal spot, gantry, filtration, detector_shape) plus the scintillator model: detector_material = :lumex, detector_depth = 3.0, fill_factor_row = 0.9, fill_factor_col = 0.9, detection_gain = 15.0 (electrons/keV), electronic_noise = 5000.0 (electrons).

PCCTScannerstruct
PCCTScanner{T} <: Scanner{T}

A photon-counting CT scanner: the shared ScannerGeometry plus the direct-conversion detector model (sensor material and depth, fill factors, energy bins and thresholds, energy resolution, charge sharing, dead time, pixel mode, native dexel size and binning). Construct with PCCTScanner(; kwargs...).


PCCTScanner(; energy_thresholds, kwargs...)

Photon-counting scanner. Geometry keywords as Scanner plus the direct-conversion detector model: detector_material = :CdTe, detector_depth = 1.6, fill_factor_row = 0.9, fill_factor_col = 0.9, energy_thresholds (keV, ascending; required), n_energy_bins = length(energy_thresholds), energy_resolution = 0.0, charge_sharing_fwhm = 0.0, dead_time_ns = 0.0, pixel_mode = :standard, native_dexel_col_mm = 0 / native_dexel_row_mm = 0 (0 = infer from the binned pixel and magnification), binning_factor = 1; and the detector-model toggles pileup = true, pileup_correction = false, scatter_correction = false, noise_reduction = 0.0.

ScannerGeometrystructsource ↗
ScannerGeometry{T}

The detector-agnostic core shared by EICTScanner and PCCTScanner: source/detector distances, detector array, focal spot, gantry, filtration and detector shape. Built by the scanner constructors; its fields are reachable directly on the scanner (scanner.source_to_isocenter).

src/source/protocol.jl

CTProtocolstruct
CTProtocol

Scan protocol parameters for physical simulation.

Fields

  • mA: Tube current (milliamperes)

  • kVp: Tube peak voltage (kV)

  • views: Number of projections per rotation

  • rotation_time: Gantry rotation time in seconds

  • n_rotations: Number of gantry rotations

  • collimation_mm: Detector z-collimation in mm (nothing = use full detector)

  • anode_angle: IPEM anode angle in degrees (8 or 10)

  • additional_filters: Extra filter layers [(material, thickness_mm), ...] applied on top of the scanner's built-in flat filter in the spectrum domain.

  • pitch: Helical pitch (IEC: table feed per rotation ÷ collimation); nothing = axial.


CTProtocol(; mA=nothing, mAs=nothing, kVp=120.0, views=984, rotation_time=1.0, ...)

Create a CT protocol. You must provide either mA OR mAs.

Arguments

  • mA: Tube current (e.g., 200.0)

  • mAs: Total mAs (e.g., 200.0). If provided, mA is calculated as mAs / rotation_time.

  • kVp: Tube voltage (default: 120.0)

  • views: Projections per rotation (default: 984)

  • rotation_time: Rotation time in seconds (default: 1.0)

  • n_rotations: Number of gantry rotations (default: 1.0)

  • collimation_mm: Detector z-collimation in mm (default: nothing = full detector)

  • anode_angle: IPEM anode angle, 8 or 10 degrees (default: 10)

  • additional_filters: Extra filter layers [(material, thickness_mm), ...] (default: empty)

  • pitch: Helical pitch (default: nothing = axial scan). IEC 60601-2-44 definition: table feed per rotation ÷ total active collimation width. When set, the gantry traces a helix of n_rotations turns centred on isocentre — the table travel is pitch × collimation × n_rotations. Typical clinical range 0.5–1.5 (dual-source Flash up to 3.2).

Examples

# Simple axial
CTProtocol(kVp=120, mA=200, views=984)

# With collimation (128×0.625mm = 80mm)
CTProtocol(kVp=120, mA=200, views=984, collimation_mm=80.0)

# Helical: pitch 1.0, 8 rotations, 40 mm collimation → 320 mm table travel
CTProtocol(kVp=120, mA=200, views=984, collimation_mm=40.0, pitch=1.0, n_rotations=8)

# Extra filtration
CTProtocol(kVp=120, mA=200, additional_filters=[("Al", 4.5)])

src/api/options.jl

SimOptionsstruct
SimOptions

Resolved boolean toggles for one simulation run. Each use_* field is Bool (true = effect ON, false = effect OFF); every field is set by the keyword constructor.

Fields

  • use_fill_factor::Bool: Enable detector fill factor.

  • use_detector_efficiency::Bool: Enable energy-dependent detector efficiency.

  • use_scatter::Bool: Enable scatter simulation (correction is decoupled to notebook level).

  • use_optical_crosstalk::Bool: Enable optical crosstalk.

  • use_focal_spot::Bool: Enable focal spot blur.

  • use_noise::Bool: Enable noise. PCCT: exact per-bin integer Poisson count realizations of the MC-detector expected counts (no electronic noise — counting thresholds eliminate it). EICT: Gaussian quantum + electronic noise on counts.

  • use_lag::Bool: Enable detector lag (afterglow).

  • use_heel_effect::Bool: Enable anode heel effect.

  • seed::Union{Int, Nothing}: Random seed for reproducibility. Default 42.

  • detector_efficiency_mode::Symbol: Override detector efficiency calculation mode. :auto (default) = let driver decide; :mc_lut = force MC LUT; :beer_lambert = force analytical.

  • projector::Symbol: Forward-projection ray tracer. :dd_fast (default) = distance-driven, anti-aliased footprint integration with single-pass per-material path-length fused kernels — the full spectrum runs in ONE volume walk (measured 47x faster than the per-energy tiled distance-driven kernels on a 234-bin polychromatic forward on M4 Metal, agreeing with them to floating-point ordering); supports ≤ 64 materials (emits a prominent warning and falls back to the per-energy tiled kernels above that); call compact_materials to remove inactive table entries. :siddon = exact point-sampled ray tracing retained for comparison and compatibility; it is slower than :dd_fast for full polychromatic/spectral simulations and can ALIAS in severe beam-hardened regions. NOTE: to keep the iterative-recon system matrix consistent with the data, pass the SAME projector to create_hir_recon_workspace(; projector=…) (both default :dd_fast).


SimOptions(; kwargs...)

Physics toggles common to every detector family (all true except use_optical_crosstalk), the noise seed, the detector-efficiency mode and the projector. Detector-specific physics lives on the scanner: pile-up, pile-up correction, scatter correction and the count-noise blend are fields of PCCTScanner; the scintillator lag model applies to EICTScanner only.

  • use_fill_factor, use_detector_efficiency, use_scatter, use_optical_crosstalk, use_focal_spot, use_noise, use_lag, use_heel_effect::Bool

  • seed::Union{Int, Nothing} = 42 — noise RNG seed. nothing leaves the energy-integrating path unseeded; the photon-counting path seeds with 0, because its workspace always carries an RNG to reseed (apply_pcct_noise!).

  • detector_efficiency_mode::Symbol = :auto — :auto, :mc_lut, :beer_lambert

  • projector::Symbol = :dd_fast — :dd_fast or :siddon; anything else throws an ArgumentError

  • view_samples::Int = 1 — gantry rotation during a view: the detector integrates while the gantry turns through the view's arc, so a view records the transmitted intensity averaged over that arc, a blur of the object along the direction of rotation that grows with the distance from the isocentre and that the noise, counted once per view, does not share. view_samples points across the arc (the midpoint rule) sample the average; 1 is the instantaneous view (no rotation blur). The blur is the projection of an arc of length r · view_arc · Δθ at radius r.

  • view_arc::Real = 1.0 — the arc a view integrates over, as a fraction of the view spacing Δθ: 1 for a detector that integrates for the whole view period, the duty cycle of one energy for rapid kVp switching.

ReconOptionsstruct
ReconOptions

Reconstruction-grid configuration consumed by the workspace constructors. Three fields, each with one specific consumer.

Fields

  • matrix_size::NTuple{3,Int}: Output volume size (nx, ny, nz). Notebooks pull this and pass it explicitly into create_fdk_recon_workspace / create_hir_recon_workspace (those ctors take volume_size as a positional arg).

  • fov_cm::Float64: XY field of view in cm. Read by both PCCT and EICT workspace ctors via CTGeometry(scanner; fov_cm = recon_opts.fov_cm, ...).

  • z_cm::Union{Float64,Nothing}: Z extent in cm. nothing → auto-compute from detector coverage; set explicitly to sliceCount * sliceThickness / 10 for clinical slice-thickness control. Read by the same CTGeometry call.


ReconOptions(; matrix_size=(512,512,64), fov_cm=35.0, z_cm=nothing)

Construct ReconOptions. All three kwargs have sane defaults.

Examples

# Standard 512² × 64 recon at 35 cm FOV
ReconOptions(matrix_size = (512, 512, 64), fov_cm = 35.0)

# Clinical slice-thickness control (5 cm Z extent at 0.625 mm slice)
ReconOptions(matrix_size = (512, 512, 80), fov_cm = 50.0, z_cm = 5.0)

Phantom & materials

Built-in phantoms (Gammex 472, the XCIST voxelized XCAT chests), phantoms built from any labeled mask, material lookup, and the attenuation / HU helpers. Materials are XrayAttenuation.Materials, re-exported as XA.

src/object/phantom.jl

create_gammex_472functionsource ↗
create_gammex_472(; n_voxels=64, n_slices=nothing, fov_cm=35.0, z_cm=4.0)

Create a Gammex 472 calibration phantom with semantic mask.

Arguments

  • n_voxels::Int: Voxels per side in x/y (default 64 for fast iteration)

  • n_slices::Union{Int,Nothing}: Number of z slices (if specified, overrides z_cm calculation)

  • fov_cm::Float64: Field of view in x/y (cm), default 35.0

  • z_cm::Float64: Height in z (cm), default 4.0 (used if n_slices not specified)

Returns

Phantom with:

  • 330mm diameter solid water body

  • 7 calcium inserts (50-600 mg/ml) in inner ring (5cm radius)

  • 7 iodine inserts (2-20 mg/ml) in outer ring (10.5cm radius)

  • 28mm diameter rods

  • Semantic mask labeling each region

  • Materials vector for polychromatic simulation

Use compute_μ(phantom, energy_keV) to get attenuation coefficients at any energy.

Gammex 472 Specifications

  • Body: 330mm diameter solid water cylinder

  • Insert rods: 28mm diameter

  • Inner ring radius: 50mm (calcium inserts)

  • Outer ring radius: 105mm (iodine inserts)

  • Insert spacing: ~51.4° (7 inserts per ring)

Example

phantom = create_gammex_472(n_voxels=128)

# Get μ at any energy
μ_60 = compute_μ(phantom, 60.0)
μ_120 = compute_μ(phantom, 120.0)

# Simulate via the workspace API
ws = create_eict_workspace(scanner, protocol, SimOptions(), ReconOptions(), phantom)
simulate!(ws, phantom, protocol)

src/phantoms/xcat_artifacts.jl

load_xcat_phantomfunctionsource ↗
load_xcat_phantom(name::Symbol; path=nothing, materials=nothing,
                  voxel_size_cm=nothing, downsample=1, quiet=false)
    -> (; mask, materials, label_names, voxel_size_cm)

Download (if needed) and load XCAT phantom name, returning the labeled pieces: the CPU mask (UInt8, one label per voxel, 0 = air), the materials map (label → XA.Material), the label_names (label → XCIST name), and the voxel size (cm). You build the Phantom yourself in one line — placing the mask on your device first when you want a GPU simulation (create_eict_workspace picks the compute backend from the mask's array type):

p = load_xcat_female_slab()                                     # auto-download → pieces
phantom = Phantom(p.mask, p.materials, p.voxel_size_cm)          # CPU
phantom = Phantom(to_gpu(p.mask), p.materials, p.voxel_size_cm)  # GPU

# customize materials up front, by XCIST name …
mats = xcat_default_materials(); mats["ncat_blood"] = XA.Materials.wholeblood
p = load_xcat_female_slab(; materials = mats, downsample = 6)
# … or edit the returned label-keyed dict before building:
p.materials[7] = XA.Materials.wholeblood

path / caching: see download_xcat_phantom. downsample is a label-preserving nearest-neighbor integer factor (extent preserved).

download_xcat_phantomfunctionsource ↗
download_xcat_phantom(name::Symbol; path=nothing, quiet=false) -> String

Return a directory holding the XCIST voxelized files for XCAT phantom name (one of xcat_phantoms).

The xcat_citation attribution is logged on every call (cached or fresh) — these phantoms are other people's work, so the ask to cite them should not be something a user sees once and then never again. Pass quiet = true to silence it (and the progress log) in scripts and loops.

Resolution order:

  1. If path is given, it is returned unchanged (bring-your-own copy — point at the extracted phantom folder).

  2. If the phantom is already in Julia's artifact store (~/.julia/artifacts/), its cached path is returned — no download.

  3. Otherwise the .zip is downloaded from the XCIST repository (with a progress log), verified by SHA-256, extracted, and installed into the artifact store keyed by its git-tree-sha1.

The full-volume phantoms are ~65 MB; the slabs are <100 KB.

src/object/attenuation.jl

compute_μ_at_energyfunctionsource ↗
compute_μ_at_energy(material, energy_keV::Float64)

Get linear attenuation coefficient for a single material at single energy.

Arguments

  • material: XA.Material object

  • energy_keV::Float64: Energy in keV

Returns

  • μ::Float64: Linear attenuation coefficient (cm⁻¹)

compute_mass_μ_at_energyfunctionsource ↗
compute_mass_μ_at_energy(material, energy_keV::Float64)

Get mass attenuation coefficient for a single material at single energy.

Arguments

  • material: XA.Material object

  • energy_keV::Float64: Energy in keV

Returns

  • μ_ρ::Float64: Mass attenuation coefficient (cm²/g)

μ_to_HUfunction
μ_to_HU(μ::Real, μ_water::Real)

Convert linear attenuation coefficient to Hounsfield Units (HU).

HU = 1000 × (μ - μ_water) / μ_water

Arguments

  • μ: Linear attenuation coefficient (cm⁻¹)

  • μ_water: Linear attenuation of water at the same energy (cm⁻¹)

Returns

  • HU::Float64: Hounsfield Units

Reference values

  • Air: HU ≈ -1000

  • Water: HU = 0

  • Bone: HU ≈ 400-1000

  • Soft tissue: HU ≈ 40-80


μ_to_HU(μ::AbstractArray, μ_water::Real)

Convert array of linear attenuation coefficients to HU.

HU_to_μfunctionsource ↗
HU_to_μ(HU::Real, μ_water::Real)

Convert Hounsfield Units back to linear attenuation coefficient.

μ = μ_water × (1 + HU/1000)

Arguments

  • HU: Hounsfield Units

  • μ_water: Linear attenuation of water (cm⁻¹)

Returns

  • μ::Float64: Linear attenuation coefficient (cm⁻¹)

get_reference_μ_waterfunctionsource ↗
get_reference_μ_water(energy_keV::Float64=60.0)

Get linear attenuation coefficient of water at specified energy from XrayAttenuation.jl.

For polychromatic spectra, use compute_polychromatic_μ_water instead.

Default energy is 60 keV (approximate effective energy for 100 kVp spectrum).

to_hounsfieldfunctionsource ↗
to_hounsfield(reconstruction::AbstractArray; water_mask=nothing, μ_water=nothing)

Convert reconstruction from linear attenuation (cm⁻¹) to Hounsfield Units.

This is the recommended way to convert reconstruction results to HU.

Arguments

  • reconstruction: 3D array in μ (cm⁻¹) from FDK or iterative reconstruction

Keyword Arguments

  • water_mask::Union{Nothing,AbstractArray{Bool}}=nothing: Boolean mask identifying water regions. If provided, μ_water is empirically calibrated from the mean attenuation in the masked region. This is the most accurate approach.

  • μ_water::Union{Nothing,Real}=nothing: Manual water attenuation value (cm⁻¹). If not provided and no water_mask given, defaults to NIST water at 70 keV.

Returns

  • Array in Hounsfield Units where water ≈ 0 HU

Best Practices

Option 1: Empirical calibration with water_mask (most accurate)

recon = fdk_reconstruct(sinogram, geom, matrix_size)

# Create water mask from phantom
water_mask = phantom.mask .== UInt8(REGION_SOLID_WATER)

# Convert to HU with empirical calibration
recon_hu = to_hounsfield(recon; water_mask=water_mask)
# Water region will be ~0 HU by construction

Option 2: Manual μ_water specification

# Measure μ_water from reconstruction
cx, cy, cz = size(recon) .÷ 2
μ_water_measured = mean(recon[cx-5:cx+5, cy-5:cy+5, cz])

# Convert
recon_hu = to_hounsfield(recon; μ_water=μ_water_measured)

Option 3: Use NIST reference (less accurate)

# Uses NIST water at 70 keV (~0.193 cm⁻¹)
# May have offset due to reconstruction scaling
recon_hu = to_hounsfield(recon)

Notes

  • For polychromatic CT, empirical calibration is preferred because the effective attenuation depends on spectrum, filtration, and patient size.

  • VMI synthesis (vmi_pipeline, synthesize_vmi_stack) already returns HU, referenced to water at each target energy; it needs no to_hounsfield.

get_basis_mufunctionsource ↗
get_basis_mu(material::Symbol, energy_keV::Float64) -> Float64

Get linear attenuation coefficient for a basis material at the given energy.

Supports: :water, :iodine (5 mg/mL solution), :calcium (200 mg/cc equivalent).

src/object/materials.jl

get_materialfunctionsource ↗
get_material(symbol::Symbol) -> XA.Material

Get material by symbol from registry or XA.Materials.

get_region_materialsfunctionsource ↗
get_region_materials() -> Vector{XA.Material}

Return a vector of materials indexed by region number (1-based). Used for polychromatic simulation where μ_by_energy[region, energy] is needed.

The vector has 27 elements (indices 1-27, but only 18 are used):

  • Index 1 (REGION 0): air (background)

  • Index 2 (REGION 1): air

  • Index 3 (REGION 2): water

  • Index 4 (REGION 3): solid_water

  • Indices 5-10: unused (filled with air)

  • Index 11-17 (REGION 10-16): Ca_50 through Ca_600

  • Indices 18-20: unused (filled with air)

  • Index 21-27 (REGION 20-26): I_2_0 through I_20_0

src/object/phantom.jl

compute_μfunctionsource ↗
compute_μ(phantom::Phantom, energy_keV::Real) -> Array{Float32,3}

Compute linear attenuation coefficient volume at specified energy.

This function efficiently computes μ by:

  1. Computing μ for each unique material once (O(n_materials))

  2. Broadcasting via mask indexing (O(n_voxels), but just integer lookups)

This is exactly what create_μ_volume!() does internally in polychromatic forward projection, so there's no performance penalty vs the old μ field.

Arguments

  • phantom::Phantom: Phantom with mask and materials

  • energy_keV::Real: Energy in keV for attenuation computation

Returns

  • Array{Float32,3}: Linear attenuation coefficients (cm⁻¹) at specified energy

Example

phantom = create_gammex_472(n_voxels=128)

# Get μ at different energies
μ_60 = compute_μ(phantom, 60.0)   # ~0.207 cm⁻¹ for water
μ_120 = compute_μ(phantom, 120.0) # ~0.165 cm⁻¹ for water
create_phantom_from_maskfunctionsource ↗
create_phantom_from_mask(labeled_array, materials, voxel_size_cm; kwargs...) -> Phantom

Create a Phantom from an arbitrary labeled array with custom material mapping.

This function enables loading arbitrary phantoms (XCAT, custom segmentations, etc.) by providing a mapping from integer labels to materials.

Note: This function is equivalent to the Phantom() constructor. Consider using Phantom(labeled_array, materials_dict, voxel_size) directly.

Arguments

  • labeled_array::AbstractArray{<:Integer, 3}: Integer array where each voxel contains a region label. Auto-promotes to UInt16 when >255 labels.

  • materials::Dict{Int, <:Any}: Mapping from label values to materials. Materials can be:

    • XA.Material: Direct XrayAttenuation.jl material

    • Symbol: Material name to look up in MATERIALS_REGISTRY (e.g., :water, :Ca_100)

  • voxel_size_cm::NTuple{3, Real}: Physical voxel dimensions in cm as (dx, dy, dz)

Keyword Arguments

  • origin::Union{Nothing, NTuple{3, Real}}=nothing: Origin coordinates (cm). If nothing, phantom is centered at isocenter.

Returns

A Phantom struct with:

  • mask: UInt8 mask (≤255 labels) or UInt16 mask (>255 labels)

  • materials: Vector{XA.Material} for polychromatic simulation

  • voxel_size: Physical voxel dimensions (cm)

  • origin: Origin coordinates (cm)

  • extent: Physical extent (cm)

Use compute_μ(phantom, energy_keV) to get attenuation coefficients at any energy.

Example

using BasisSimulator, XrayAttenuation
import XrayAttenuation as XA

# Any labeled integer volume, e.g. a segmentation read from disk
xcat_mask = rand(UInt8(0):UInt8(3), 400, 400, 200)

# Define materials for each label
materials_dict = Dict{Int, XA.Material}(
    0 => XA.Materials.air,
    1 => XA.Materials.water,  # soft tissue approximation
    2 => XA.Materials.corticalbone,
    3 => XA.Materials.lung,
)

# Create phantom (0.1 cm = 1mm voxels)
phantom = create_phantom_from_mask(xcat_mask, materials_dict, (0.1, 0.1, 0.1))

# Get μ at any energy when needed
μ_70 = compute_μ(phantom, 70.0)

# Simulate via workspace API
ws = create_eict_workspace(scanner, protocol, SimOptions(), ReconOptions(), phantom)
simulate!(ws, phantom, protocol)

See also: Phantom, compute_μ, create_gammex_472

compact_materialsfunctionsource ↗
compact_materials(phantom::Phantom) -> Phantom

Return a projection-equivalent phantom whose mask labels and material vector contain only materials that are actually present in phantom.mask. Geometry and attenuation are unchanged; labels are remapped densely from zero. The input phantom is not modified, so semantic-label workflows can retain the original object.

Compacting sparse or shared material tables is especially important for :dd_fast: its single-pass spectral kernel supports up to 64 active materials. Device masks are copied to the host once to discover/remap labels, then copied back to the same backend. Prefer calling this before transferring a CPU phantom to the GPU.

src/phantoms/xcat_artifacts.jl

xcat_default_materialsfunctionsource ↗
xcat_default_materials() -> Dict{String, XA.Material}

The default mapping from XCIST/NCAT material names to XrayAttenuation materials used by load_xcat_phantom. Copy, edit, and pass back via the materials keyword to override (e.g. to dope the blood pool with iodine).

download_xcat_female_chestfunctionsource ↗
download_xcat_female_chest(; path = nothing, quiet = false) -> String

Shorthand for download_xcat_phantom(:female_chest; kwargs...): Adult female 50th-percentile chest — full volume (1650 × 1050 × 880 voxels). Returns the directory of the XCIST voxelized files. The phantom is downloaded into Julia's artifact store only when it isn't already there.

download_xcat_female_slabfunctionsource ↗
download_xcat_female_slab(; path = nothing, quiet = false) -> String

Shorthand for download_xcat_phantom(:female_slab; kwargs...): Adult female 50th-percentile chest — single slab (slice 400) (1650 × 1050 × 1 voxels). Returns the directory of the XCIST voxelized files. The phantom is downloaded into Julia's artifact store only when it isn't already there.

download_xcat_male_chestfunctionsource ↗
download_xcat_male_chest(; path = nothing, quiet = false) -> String

Shorthand for download_xcat_phantom(:male_chest; kwargs...): Adult male 50th-percentile chest — full volume (1700 × 1050 × 900 voxels). Returns the directory of the XCIST voxelized files. The phantom is downloaded into Julia's artifact store only when it isn't already there.

download_xcat_male_slabfunctionsource ↗
download_xcat_male_slab(; path = nothing, quiet = false) -> String

Shorthand for download_xcat_phantom(:male_slab; kwargs...): Adult male 50th-percentile chest — single slab (slice 400) (1700 × 1050 × 1 voxels). Returns the directory of the XCIST voxelized files. The phantom is downloaded into Julia's artifact store only when it isn't already there.

load_xcat_female_chestfunctionsource ↗
load_xcat_female_chest(; path = nothing, materials = nothing, voxel_size_cm = nothing,
      downsample = 1, quiet = false) -> (; mask, materials, label_names, voxel_size_cm)

Shorthand for load_xcat_phantom(:female_chest; kwargs...): Adult female 50th-percentile chest — full volume (1650 × 1050 × 880 voxels before downsample). Downloads the phantom first if needed. Returns the labeled pieces; build the Phantom with Phantom(p.mask, p.materials, p.voxel_size_cm).

load_xcat_female_slabfunctionsource ↗
load_xcat_female_slab(; path = nothing, materials = nothing, voxel_size_cm = nothing,
      downsample = 1, quiet = false) -> (; mask, materials, label_names, voxel_size_cm)

Shorthand for load_xcat_phantom(:female_slab; kwargs...): Adult female 50th-percentile chest — single slab (slice 400) (1650 × 1050 × 1 voxels before downsample). Downloads the phantom first if needed. Returns the labeled pieces; build the Phantom with Phantom(p.mask, p.materials, p.voxel_size_cm).

load_xcat_male_chestfunctionsource ↗
load_xcat_male_chest(; path = nothing, materials = nothing, voxel_size_cm = nothing,
      downsample = 1, quiet = false) -> (; mask, materials, label_names, voxel_size_cm)

Shorthand for load_xcat_phantom(:male_chest; kwargs...): Adult male 50th-percentile chest — full volume (1700 × 1050 × 900 voxels before downsample). Downloads the phantom first if needed. Returns the labeled pieces; build the Phantom with Phantom(p.mask, p.materials, p.voxel_size_cm).

load_xcat_male_slabfunctionsource ↗
load_xcat_male_slab(; path = nothing, materials = nothing, voxel_size_cm = nothing,
      downsample = 1, quiet = false) -> (; mask, materials, label_names, voxel_size_cm)

Shorthand for load_xcat_phantom(:male_slab; kwargs...): Adult male 50th-percentile chest — single slab (slice 400) (1700 × 1050 × 1 voxels before downsample). Downloads the phantom first if needed. Returns the labeled pieces; build the Phantom with Phantom(p.mask, p.materials, p.voxel_size_cm).

Geometry & coordinate mapping

CTGeometry pre-computes the source and detector trajectory of every view, axial or helical, for arc or flat detectors. The affine helpers map between phantom voxels, world coordinates and the reconstruction grid.

src/geometry/scanner.jl

CTGeometrystruct
CTGeometry

CT scanner geometry with pre-computed trajectories.

All source and detector positions are pre-computed at construction time to enable Reactant/XLA compilation (no runtime trig).

Fields

  • SAD::Float64: Source-to-axis distance (cm)

  • SDD::Float64: Source-to-detector distance (cm)

  • n_angles::Int: Number of projection angles

  • n_rows::Int: Detector rows (z direction)

  • n_cols::Int: Detector columns (fan direction)

  • pixel_size::Float64: Detector pixel size (cm) at isocenter

  • angles::Vector{Float64}: Projection angles (radians)

  • source_positions::Matrix{Float64}: [3, n_angles] source XYZ positions

  • detector_centers::Matrix{Float64}: [3, n_angles] detector center XYZ

  • detector_u::Matrix{Float64}: [3, n_angles] detector u-axis (column direction)

  • detector_v::Matrix{Float64}: [3, n_angles] detector v-axis (row direction)

  • fov::NTuple{3, Float64}: (fov_x, fov_y, fov_z) volume FOV in cm

Coordinate System

  • X: left-right (increasing right)

  • Y: anterior-posterior (source starts at -SAD on Y-axis)

  • Z: inferior-superior (increasing superior)

  • Rotation around Z-axis, counter-clockwise when viewed from above


CTGeometry(scanner::Scanner; n_angles=360, fov_cm=nothing, z_cm=nothing, n_rows=nothing, n_cols=nothing, collimation_mm=nothing)

Create a CTGeometry from a Scanner definition.

This constructor converts the physical Scanner parameters into pre-computed trajectory positions suitable for simulation.

Arguments

  • scanner::Scanner: Scanner definition with physical parameters (in mm)

Keyword Arguments

  • n_angles::Int = 360: Number of projection angles PER ROTATION

  • fov_cm::Union{Float64,Nothing} = nothing: Reconstruction XY FOV in cm. If nothing, uses full detector coverage at isocenter.

  • z_cm::Union{Float64,Nothing} = nothing: Reconstruction Z extent in cm. If nothing, computes from detector coverage (axial) or table_travel − collimation (helical).

  • pitch::Union{Float64,Nothing} = nothing: Helical pitch (IEC: table feed per rotation ÷ active collimation). nothing = axial circular orbit. When set, source AND detector translate along z over n_rotations turns, helix centred on isocentre; total views = n_angles × n_rotations.

  • n_rotations::Real = 1.0: Number of gantry rotations (helical only; ignored for axial).

  • n_rows::Union{Int,Nothing} = nothing: Override detector rows. If nothing, uses scanner.detector_rows.

  • n_cols::Union{Int,Nothing} = nothing: Override detector columns. If nothing, uses scanner.detector_cols.

  • collimation_mm::Union{Float64,Nothing} = nothing: Detector z-collimation in mm. Derives the nominal row count automatically. For axial scans with explicit fov_cm and z_cm, symmetric cone-guard rows are added as needed to support the complete reconstruction cylinder. Errors if the guarded count exceeds the physical detector or if n_rows is also specified.

  • extended_collimation::Bool = false: Simulator-only escape hatch that bypasses the collimation_mm > scanner_max check with a loud @warn. Use to approximate Siemens NAEOTOM Alpha's high-pitch (3.2) Flash helical mode as a single wider axial rotation — the Alpha really only has 57.6 mm of detector but its Flash mode covers ~120 mm in ~160 ms via table translation, not via a wider detector. This kwarg renders that effective coverage as a single axial scan.

    Physical caveats when extended:

    • The detector array becomes artificially taller (_n_rows exceeds scanner.detector_rows). Forward sim still works because every downstream function treats n_rows as the geometry's source of truth.

    • Per-voxel projection density is HIGHER than real Flash (full rotation vs. a ~64% sweep) — image quality is better than the actual hardware would produce, which is fine for training-data generation but wrong for hardware-fidelity claims.

    • Dual-source 66 ms temporal resolution is NOT modeled. Treat the result as a 0.25 s (or longer) single-rotation scan.

Returns

CTGeometry with pre-computed source/detector positions.

Example

# Create scanner and geometry
scanner = EICTScanner(
    source_to_isocenter = 541.0,
    source_to_detector = 949.0,
    detector_rows = 64,
    detector_cols = 900
)
geom = CTGeometry(scanner; n_angles=360, fov_cm=35.0)

# Or with reduced detector for fast testing
geom_fast = CTGeometry(scanner; n_angles=90, n_rows=16, n_cols=128, fov_cm=35.0)

# With collimation (derives n_rows automatically)
geom_coll = CTGeometry(scanner; n_angles=360, collimation_mm=80.0)

src/geometry/affine.jl

phantom_to_world_affinefunctionsource ↗
phantom_to_world_affine(phantom::Phantom) -> Matrix{Float64}

4×4 affine matrix mapping 0-indexed phantom voxel (i,j,k) to world (x,y,z) in cm.

The transform encodes:

  • Scale: phantom.voxel_size (cm per voxel)

  • Translation: phantom.origin (center of first voxel in world coordinates)

Example

A = phantom_to_world_affine(phantom)
# World coordinate of voxel (0,0,0):
world = A * [0, 0, 0, 1]  # == [origin_x, origin_y, origin_z, 1]
recon_to_world_affinefunctionsource ↗
recon_to_world_affine(geom::CTGeometry, matrix_size) -> Matrix{Float64}

4×4 affine matrix mapping 0-indexed reconstruction voxel (i,j,k) to world (x,y,z) in cm.

matrix_size is (nx, ny, nz) — the reconstruction volume dimensions.

The reconstruction grid is centered at isocenter with voxel size geom.fov ./ matrix_size.

Example

A = recon_to_world_affine(geom, (512, 512, 64))
# World coordinate of first voxel:
world = A * [0, 0, 0, 1]  # == [-fov_x/2 + vx/2, -fov_y/2 + vy/2, -fov_z/2 + vz/2, 1]
resample_to_reconfunctionsource ↗
resample_to_recon(phantom, geom::CTGeometry, matrix_size; method=:nearest) -> Array{UInt8,3}

Resample phantom labels onto the reconstruction grid.

For each reconstruction voxel, computes its world coordinate, maps to the continuous phantom voxel index, and samples phantom.mask.

Returns a UInt8 array of size matrix_size with resampled phantom labels. CPU-only (one-time operation).

Arguments

  • phantom::Phantom: Source phantom (mask can be on GPU — will be pulled to CPU)

  • geom::CTGeometry: Reconstruction geometry

  • matrix_size: (nx, ny, nz) reconstruction volume dimensions

Keyword Arguments

  • method::Symbol = :nearest: Interpolation method

    • :nearest — nearest-neighbor (preserves label integrity, default)

    • :linear — trilinear interpolation (returns Float32 array; useful for continuous volumes)

Example

ground_truth = resample_to_recon(phantom, geom, (350, 350, 128))
# ground_truth is UInt8, same shape as reconstruction volume
# Overlay: heatmap(ground_truth[:,:,64]) vs heatmap(recon[:,:,64])
resample_field_to_reconfunctionsource ↗
resample_field_to_recon(field, voxel_size, origin, geom, matrix_size; outside = 0, to_backend = identity)

The exact box average of a continuous field — a material fraction, a density — onto the reconstruction grid: every output voxel is the mean of the field over that voxel's own footprint, from the axis-aligned overlap of the two grids. This is what a 0.2 mm truth has to become on 0.625 mm slices for its partial volume to be the partial volume a reconstruction sees; point sampling (resample_to_recon with :linear) reads the field at the voxel centre and misses it.

field is (nx, ny, nz) on a grid with voxel_size and origin in cm, in the Phantom convention (origin is the centre of voxel (1, 1, 1)); geom and matrix_size define the reconstruction grid as recon_to_world_affine does. Where an output voxel reaches beyond the field's grid the missing part is outside — 1 for an air fraction, 0 for everything else — so a set of fractions that sums to one still does.

The overlap is separable, so it is three matrix products, not a voxel loop: a 1850 × 1350 × 75 field onto 512 × 512 × 24 takes well under a second.

to_backend uploads the banded overlap weights; with field already on a device (a CuArray, say) and to_backend = CuArray, the whole contraction runs there — as AcceleratedKernels kernels, no BLAS — and the result comes back as an Array{Float32, 3}. The axes are contracted in order of decreasing reduction ratio, so the largest array is shrunk first: a 1600 × 1400 × 200 window onto 512 × 512 × 24 loses its z first and never holds more than the input's size in Float64.

src/geometry/scanner.jl

column_offsetfunctionsource ↗
column_offset(geom::CTGeometry) -> Float64

The fractional column by which the central ray is displaced from the detector's geometric centre: the central ray crosses column (n_cols + 1) / 2 + column_offset(geom). It comes from the scanner's detector_col_offset (columns) and is honoured identically by every forward projector, the fan weighting, every backprojector and the bowtie, so that a simulation and its reconstruction agree on where each ray is. A quarter column is the clinical value.

column_centerfunctionsource ↗
column_center(::Type{T}, geom::CTGeometry) where T

The fractional column index the central ray crosses — the detector's centre plus its offset.

is_helicalfunctionsource ↗
is_helical(geom::CTGeometry) -> Bool

true when the geometry carries a helical trajectory (non-zero table feed).

rotate_geometryfunctionsource ↗
rotate_geometry(geom, δ) -> CTGeometry

geom with the gantry turned on by δ radians about the rotation axis ($z$ through the isocentre), every view alike: sources, detector centres and column axes rotated, $(x, y) → (x cos δ + y sin δ, y cos δ − x sin δ)$ (the direction in which the angles advance), the angles shifted by δ, and on a helix the gantry $z$ advanced by the table feed over δ.

is_arcfunctionsource ↗
is_arc(geom::CTGeometry) -> Bool

true for an equiangular (cylindrical, source-centred) detector. The column angular pitch is Δγ = geom.pixel_size / geom.SAD (the column pitch at isocentre interpreted as arc length at the isocentre radius).

required_axial_detector_rowsfunctionsource ↗
required_axial_detector_rows(scanner; fov_cm, z_cm) -> Int

Minimum symmetric detector-row count needed to support the complete axial reconstruction cylinder. Cone divergence magnifies the terminal z coordinate at the near edge of the reconstruction FOV by SAD / (SAD - radius).

Spectrum & source

The X-ray spectrum is resolved once per simulation from the scanner's flat filter, the protocol's additional filters and, optionally, the bowtie. These helpers are usually called inside simulate!. They're public because beam-hardening correction and μ_water calibration need the same spectrum the simulator uses. Focal spot, heel effect and protocol helpers also live here.

src/api/driver.jl

resolve_source_spectrum_without_bowtiefunctionsource ↗
resolve_source_spectrum_without_bowtie(sim_opts, protocol; scanner=nothing) -> (energies, weights)

1D centered spectrum — tube × flat filter × additional filter, NO bowtie.

Returns the source-side spectrum along the centered reference ray. This is what leaves the tube and passes through the fixed filtration (flat filter + any additional_filters in the protocol) at the source-to-detector distance. It does NOT include:

  • Bowtie filter — rays at the fan edge see more filtration than center. Use resolve_source_spectrum_with_bowtie when bowtie matters (basis decomposition, beam hardening correction, etc.).

  • Detector response (QE, energy-resolving DRM) — callers apply these separately when needed (see PCCT basis construction in src/scanners/).

Suitable for: quick mean-energy diagnostics, global BHC calibration (when bowtie is ignored), scatter estimation, and Monte-Carlo source spectra. Not suitable for: per-ray polychromatic forward modelling when the scanner has a bowtie — use the _with_bowtie variant.

Pass scanner so the pipeline can read flat_filter_material, flat_filter_thickness, and source_to_detector from the hardware spec.

apply_bowtie_to_spectrumfunctionsource ↗
apply_bowtie_to_spectrum(w_1d, e, scanner, geom, protocol; include_bowtie=true, label="") -> ŵ

Inject the bowtie transmission into a 1D source spectrum → per-ray 3D ŵ.

Given a 1D centered spectrum w_1d (from resolve_source_spectrum_without_bowtie) and its energy grid e, multiplies by the bowtie attenuation B[col, row, e] pixel-by-pixel and renormalizes so Σ_k ŵ[col, row, k] = 1 per ray. Returns:

  • ŵ::Array{Float32,3} of shape [n_col, n_row, n_E] when bowtie is present.

  • Float32 vector (normalized w_1d) unchanged when include_bowtie == false OR the scanner has no bowtie configured (bowtie_filter property absent, :none, or nothing).

This is the low-level primitive. Most callers should use resolve_source_spectrum_with_bowtie(...), which composes the two steps.

Reference (algorithm port from XCIST / CatSim, GE Research)

  • gecatsim/pyfiles/Xray_Filter.py:bowtie_filter() — per-ray attenuation for the 4-material {Al, graphite, Cu, Ti} stack: B(α, E) = exp(−Σ_m μ_m(E) · t_m(α)) with t_m interpolated from the vendor bowtie thickness file by fan angle and divided by cos(alpha) for the cone-angle path correction. BasisSim's compute_bowtie_attenuation_spectral in src/source/bowtie_filter.jl implements the same Beer-Lambert sum for the same four materials.

  • gecatsim/pyfiles/Resample_Spectrum_Bowtie_FlatFilter.py — the per-pixel spectrum × bowtie × flat-filter product spec.netIvec = spec.Ivec · bowtie.transVec · FiltrationTransVec. We do the spectrum × bowtie step here and the flat-filter step in resolve_source_spectrum_without_bowtie (so this primitive only handles the bowtie multiplication).

The Julia code is original; the algorithm and the {Al, graphite, Cu, Ti} material composition are inherited from CatSim.

Arguments

  • w_1d, e — 1D spectrum + its energy grid (from _without_bowtie).

  • scanner, geom, protocol — passed to resolve_bowtie_filter and compute_bowtie_attenuation_spectral to assemble B[col, row, e].

Keyword Arguments

  • include_bowtie::Bool = true — set false to bypass and return a 1D normalized spectrum, useful as a runtime toggle.

  • label::String = "" — prefix for the @info diagnostic line (e.g. "low", "high") so multi-bin pipelines label their logs.

resolve_source_spectrum_with_bowtiefunctionsource ↗
resolve_source_spectrum_with_bowtie(sim_opts, protocol; scanner, geom, include_bowtie=true, label="") -> (e, ŵ)

Bowtie-aware spectrum — resolve_source_spectrum_without_bowtie composed with apply_bowtie_to_spectrum.

Convenience wrapper: returns the 1D energy grid e and a per-ray spectrum ŵ. When bowtie is present AND include_bowtie == true, ŵ is 3D [n_col, n_row, n_E] with the bowtie transmission baked in and each ray normalized to Σ_k ŵ = 1. When bowtie is absent or disabled, ŵ collapses to a 1D [n_E] vector (the same as _without_bowtie output, just normalized). Downstream code should dispatch on ndims(ŵ).

Use this instead of calling the two primitives yourself whenever you want basis decomposition, per-ray BHC, or any other forward-model step that needs the exact spectrum each ray saw.

resolve_source_spectrum_fullfunctionsource ↗
resolve_source_spectrum_full(
    sim_opts, protocol;
    scanner, geom, phantom = nothing,
    diagnostic = false, label = "",
) -> (e, ŵ)

Full-physics effective spectrum — the spectrum each detector ray actually sees after every spectrum-shaping effect simulate! applies, normalized per ray so Σ_E ŵ[col, row, E] = 1.

This composes resolve_source_spectrum_without_bowtie (tube spectrum × flat filter × additional filters × inverse-square SDD scaling) with the same PhysicsConfig simulate! builds (via build_physics_config), then multiplies in the bowtie, the heel-effect spectrum, and the energy-dependent detector efficiency η(E) — each conditionally on its sim_opts.use_* flag.

The output ŵ is what to feed to per-ray polychromatic inverters (Cong, beam-hardening correction, etc.) when you want the inversion's forward model to match the forward model simulate! actually applied. Use this instead of chaining resolve_source_spectrum_with_bowtie + manual heel + manual η.

Composition

w_source(E) = resolve_source_spectrum_without_bowtie(sim_opts, protocol)
B(col,row,E) = compute_bowtie_attenuation_spectral(scanner.bowtie_filter, geom, E)
heel(col,row,E) = compute_heel_spectral(config.heel_effect, geom, E)         [if heel enabled]
η(E)        = compute_eid_efficiency_vector(config.detector_efficiency, E)   [if η enabled]

ŵ_raw(col,row,E) = w_source(E) · η(E) · B(col,row,E) · heel(col,row,E)
ŵ(col,row,E)     = ŵ_raw / Σ_E ŵ_raw

When sim_opts.use_heel_effect = false or use_detector_efficiency = false, the corresponding factor collapses to 1 (via the config.heel_effect === nothing branch), so the function automatically follows whatever the SimOptions config says — guaranteeing the inversion sees exactly what simulate! applied.

Arguments

  • sim_opts::SimOptions — same struct passed to simulate!; its use_* flags gate which spectrum effects are baked in.

  • protocol::CTProtocol — kVp / anode angle / additional filters.

Keyword Arguments

  • scanner — Scanner hardware definition.

  • geom — Workspace geometry (ws.geom from a forward-projected sim); needed to assemble the per-(col, row) bowtie / heel maps.

  • phantom = nothing — Forwarded to build_physics_config (only matters for scatter scaling, which is sinogram-domain and irrelevant here).

  • diagnostic = false — When true, log the center-vs-edge mean energy to help spot bowtie / heel calibration drift.

  • label = "" — Prepended to the diagnostic log line for multi-kVp runs.

Returns

  • e::Vector — Energy grid (keV).

  • ŵ::Array{Float32, 3} — Per-ray effective spectrum, shape [n_col, n_row, n_E], normalized per ray.

Example

e_L, ŵ_L = BS.resolve_source_spectrum_full(
    sim_opts, protocol_low;
    scanner = scanner, geom = sim_low.geom, phantom = phantom,
)
e_H, ŵ_H = BS.resolve_source_spectrum_full(
    sim_opts, protocol_high;
    scanner = scanner, geom = sim_high.geom, phantom = phantom,
)
material_basis = (ŵ_L = ŵ_L, p_L = …, q_L = …, ŵ_H = ŵ_H, p_H = …, q_H = …)

src/source/bowtie_filter.jl

BowtieFilterstructsource ↗
BowtieFilter

Bowtie filter specification with angle-dependent thickness profile.

The filter attenuates the beam based on fan angle from central ray: I(θ, E) = I₀(E) × exp(-Σᵢ μᵢ(E) × tᵢ(θ))

where tᵢ(θ) is the thickness of material i at angle θ.

Fields

  • angles: Fan angles (radians) at which thickness is defined

  • thickness: Matrix of thicknesses [n_angles, n_materials] in cm

  • materials: Vector of material names (for μ lookup)

  • name: Filter name/type

bowtie_filter_headfunctionsource ↗
bowtie_filter_head()

Head bowtie filter optimized for brain imaging.

Flatter profile since head is more circular.

load_builtin_bowtiefunctionsource ↗
load_builtin_bowtie(size::String; bowtie_dir::AbstractString=BOWTIE_DIR) -> BowtieFilter

Load a built-in bowtie filter profile from the bundled CatSim/XCIST data.

The package ships with GE Revolution bowtie profiles (888 data points each, 4-material columns) from the CatSim/XCIST open-source CT simulator.

Arguments

  • size::String: Filter size — "large", "medium", or "small"

  • bowtie_dir::AbstractString: Directory containing bowtie data files (default: bundled data)

Returns

BowtieFilter with 888-point asymmetric profile.

Example

filter = load_builtin_bowtie("large")
length(filter.angles)  # 888
resolve_bowtie_filterfunctionsource ↗
resolve_bowtie_filter(name::Symbol; kVp::Int=120) -> BowtieFilter

Resolve a bowtie filter symbol to a BowtieFilter object.

This is the primary entry point for the driver to look up bowtie filters by name.

Supported Names

  • :large_body / :ge_revolution_large → CatSim large body (888 pts)

  • :medium_body / :ge_revolution_medium → CatSim medium body (888 pts)

  • :small_body / :ge_revolution_small → CatSim small body (888 pts)

  • :head → Generic head profile (7 pts, flat-ish for circular cross-section)

  • :none → No bowtie filter (flat field)

Arguments

  • name::Symbol: Filter name

  • kVp::Int: Tube voltage (unused — CatSim profiles are physical thickness, not kVp-dependent)

Returns

BowtieFilter ready for use with spectral bowtie attenuation computation.

Example

filter = resolve_bowtie_filter(:large_body)
filter = resolve_bowtie_filter(:ge_revolution_large)
filter = resolve_bowtie_filter(:none)
load_catsim_bowtiefunctionsource ↗
load_catsim_bowtie(filepath::String; name::String="catsim") -> BowtieFilter

Load bowtie filter from CatSim/XCIST native format.

Mirrors the file reader in gecatsim/pyfiles/Xray_Filter.py:bowtie_filter() (line ~51, np.loadtxt(bowtieFile, comments=['#', '%'])). Material order [Al, graphite, Cu, Ti] is fixed by the upstream convention.

This function loads bowtie profiles in the exact format used by CatSim/XCIST:

  • Lines starting with # are comments

  • First column: fan angle in radians (not degrees!)

  • Columns 2-5: thickness in cm for Al, graphite, Cu, Ti

CatSim File Format (from gecatsim/bowtie/*.txt):

# angle, materials in cm ('al', 'graphite', 'cu', 'ti')
-0.479582   3.537235   0.000000   0.000000   0.000000
-0.478503   3.535054   0.000000   0.000000   0.000000
...

Arguments

  • filepath::String: Path to CatSim bowtie file

  • name::String: Filter name (default: "catsim")

Returns

BowtieFilter with angles in radians and thickness in cm.

Example

# Load CatSim's medium body bowtie
filter = load_catsim_bowtie("/path/to/gecatsim/bowtie/medium.txt", name="catsim_medium")

Implementation Notes

  • CatSim bowtie files are pre-computed for specific fan angle ranges

  • Angles are NOT symmetric; negative angles are included explicitly

  • For verification, compare profiles using interpolate_thickness and manual checks

get_bowtie_mufunctionsource ↗
get_bowtie_mu(material::String, energy_keV::Float64) -> Float64

Get linear attenuation coefficient (cm⁻¹) for a filter/bowtie material at the given energy. Delegates to get_filter_mu() from spectrum.jl.

interpolate_thicknessfunctionsource ↗
interpolate_thickness(filter::BowtieFilter, fan_angle::Float64) -> Vector{Float64}

Interpolate filter thickness at given fan angle for all materials.

Fan angle in radians, measured from central ray (0 = center). Returns thickness vector in cm for each material.

compute_bowtie_attenuation_spectralfunctionsource ↗
compute_bowtie_attenuation_spectral(filter::BowtieFilter, geom::CTGeometry,
                                    energies::Vector{Float64}) -> Array{Float64,3}

Compute energy-dependent bowtie attenuation for polychromatic simulation.

Direct port of gecatsim/pyfiles/Xray_Filter.py :: bowtie_filter() (BSD 3-Clause, GE Precision HealthCare via CatSim/XCIST). The CatSim reference (lines ~56-67) does, in pseudocode:

muT = 0
for material in [Al, graphite, Cu, Ti]:
    mu     = GetMu(material, Evec)                       # μ_m(E)
    t      = interp1d(γ₀, t₀[:, m])(γ_det) / cos(α_det)  # thickness/cos(α)
    muT   += outer(t, mu)                                # [pixel, E]
trans = exp(-muT)

get_filter_mu() plays the role of CatSim's GetMu; interpolate_thickness plays the role of scipy.interpolate.interp1d(kind='linear', ...).

Returns [n_cols, n_rows, n_energies] transmission factors.

Arguments

  • filter::BowtieFilter: Bowtie filter specification

  • geom::CTGeometry: Scanner geometry

  • energies: Vector of energies in keV

Returns

3D array of transmission factors per energy bin.

src/source/focal_spot.jl

FocalSpotstructsource ↗
FocalSpot

Focal spot specification with size and shape parameters.

The focal spot is characterized by its physical dimensions on the anode target and the intensity distribution within those dimensions.

Fields

  • width::Float64: Focal spot width in mm (fan direction, perpendicular to anode-cathode axis). This is the dimension that primarily affects in-plane spatial resolution.

  • length::Float64: Focal spot length in mm (cone direction, along anode- cathode axis). This affects z-axis resolution.

  • shape::Symbol: Intensity distribution shape within the focal spot.

    • :gaussian (default): Realistic Gaussian distribution

    • :uniform: Flat rectangular distribution

    • :bimodal: Two peaks (for bloomed/degraded focal spots)

  • n_samples::Int: Number of samples per dimension for ray-tracing mode. Typical values: 3-5. Higher values improve accuracy but increase computation.

Typical Clinical Values

Scanner TypeSmall FS (mm)Large FS (mm)
GE Revolution0.6 × 0.70.9 × 0.9
Siemens Force0.7 × 0.71.2 × 1.2
Canon Aquilion0.5 × 0.50.9 × 0.9

Note: The nominal "0.6 mm focal spot" refers to the effective size after accounting for the anode target angle (line focus principle).

Example

# GE Revolution small focal spot
fs = FocalSpot(0.6, 0.7, :gaussian, 3)

# Large focal spot for high-power imaging
fs_large = FocalSpot(1.2, 1.2, :gaussian, 5)

See also: focal_spot_small, focal_spot_medium, focal_spot_large, compute_focal_spot_blur_fwhm

focal_spot_smallfunctionsource ↗
focal_spot_small()

Small focal spot for high-resolution imaging.

Typical dimensions: 0.5 × 0.5 mm (or smaller for micro-CT).

focal_spot_mediumfunctionsource ↗
focal_spot_medium()

Medium focal spot for general diagnostic imaging.

Typical dimensions: 0.8 × 0.8 mm.

focal_spot_largefunctionsource ↗
focal_spot_large()

Large focal spot for high-power imaging (thick patients).

Typical dimensions: 1.2 × 1.2 mm.

focal_spot_pointfunctionsource ↗
focal_spot_point()

Ideal point source (no focal spot blur).

Use for testing or when focal spot effects are not needed.

compute_focal_spot_blur_fwhmfunctionsource ↗
compute_focal_spot_blur_fwhm(fs::FocalSpot, geom::CTGeometry, object_distance::Float64) -> Tuple

Compute the FWHM of focal spot blur at the detector plane.

Mathematical Background

The geometric blur (penumbra) at the detector due to finite focal spot size is:

blur_detector = fs_size × (SDD - SOD) / SOD = fs_size × (M - 1)

where M = SDD/SOD is the magnification at the object position.

This function converts the blur to detector pixels using:

blur_pixels = blur_cm / pixel_size_detector_cm

The pixel size at the detector is computed from the isocenter pixel size:

pixel_size_detector = pixel_size_iso × (SDD / SID)

CatSim Equivalence

This function produces identical FWHM values to CatSim's multi-sample source approach when compared at the same object distance and geometry. The blur formula is mathematically equivalent to averaging rays from a finite source.

Arguments

  • fs::FocalSpot: Focal spot specification (width and length in mm)

  • geom::CTGeometry: Scanner geometry (must have SAD, SDD, pixel_size)

  • object_distance::Float64: Distance from source to object center (cm). Use geom.SAD for objects at the isocenter.

Returns

  • Tuple{Float64,Float64}: (blur_width_fwhm, blur_length_fwhm) in detector pixels

Example

fs = FocalSpot(1.0, 1.0, :gaussian, 3)  # 1 mm focal spot
geom = CTGeometry(EICTScanner(detector_rows=64, detector_cols=512); n_angles=360, fov_cm=35.0)

# Blur at isocenter
blur_iso = compute_focal_spot_blur_fwhm(fs, geom, geom.SAD)

# Blur for object closer to source (more magnification, more blur)
blur_near = compute_focal_spot_blur_fwhm(fs, geom, geom.SAD * 0.7)
@assert blur_near[1] > blur_iso[1]  # Closer = more blur

# Blur for object farther from source (less magnification, less blur)
blur_far = compute_focal_spot_blur_fwhm(fs, geom, geom.SAD * 1.3)
@assert blur_far[1] < blur_iso[1]  # Farther = less blur

See also: create_focal_spot_kernel_spatial, apply_focal_spot_blur!

create_focal_spot_kernel_spatialfunctionsource ↗
create_focal_spot_kernel_spatial(fs::FocalSpot, blur_fwhm::Tuple{Float64,Float64})

Create small 2D blur kernel for spatial domain focal spot convolution.

Returns a normalized kernel array of size (2extent+1) × (2extent+1), capped at MAX_FOCAL_SPOT_KERNEL_SIZE.

apply_focal_spot_blur!functionsource ↗
apply_focal_spot_blur!(sinogram, fs::FocalSpot, geom::CTGeometry;
                       object_distance=nothing) -> sinogram

Apply focal spot blur to sinogram (in-place, GPU-native).

Uses spatial domain convolution for GPU compatibility.

Arguments

  • sinogram: Input sinogram [n_cols, n_rows, n_angles]

  • fs::FocalSpot: Focal spot specification

  • geom::CTGeometry: Scanner geometry

  • object_distance: Distance from source to object center (cm). Default: isocenter (SAD)

Returns

Modified blurred sinogram.

apply_focal_spot_blurfunctionsource ↗
apply_focal_spot_blur(sinogram, fs::FocalSpot, geom::CTGeometry;
                      object_distance = nothing) -> blurred sinogram

Allocating form of apply_focal_spot_blur!. It blurs a copy of sinogram ([n_cols, n_rows, n_angles]) and leaves the input unchanged. object_distance is the source-to-object distance in cm, geom.SAD (the isocenter) by default.

generate_focal_spot_samplesfunctionsource ↗
generate_focal_spot_samples(fs::FocalSpot) -> (positions, weights)

Generate sample positions and weights across the focal spot.

Returns

  • positions: Array of (dx, dy) offsets from focal spot center (mm)

  • weights: Normalized weights for each sample

src/source/heel_effect.jl

HeelEffectstructsource ↗
HeelEffect

Model for X-ray tube heel effect (anode self-attenuation).

Fields

  • anode_angle_deg: Anode angle in degrees (typically 7-12°)

  • target_material: Target material (:tungsten, :molybdenum, :rhodium)

  • effective_thickness_mm: Effective target thickness for attenuation

  • enabled: Whether heel effect is active

Notes

  • Anode side has lower intensity (more self-attenuation)

  • Effect is more pronounced at steeper anode angles

  • Affects both intensity and effective spectrum (beam hardening on anode side)

default_heel_effectfunctionsource ↗
default_heel_effect(; anode_angle_deg=7.0, target_material=:tungsten, effective_thickness_mm=0.01)

Create heel effect model with specified parameters.

Default values for typical CT tube:

  • anode_angle: 7° (common for CT)

  • target: tungsten

  • effective_thickness: 0.001 mm — the mean x-ray production depth; gives ≈ 2 %/degree of cone angle for a 7° tungsten anode, the order of the textbook heel effect (30–45 % across ±11°).

The anode angle must exceed the half-cone angle of the collimation (a 160 mm cone at 610 mm is ±7.5°, so a 7° anode cannot be used with it): rays that would leave below the target surface are an invalid geometry, not a clamp.

apply_heel_effect!functionsource ↗
apply_heel_effect!(intensity, heel, geom)

Apply heel effect to intensity data (in-place) using CatSim-exact formula.

Algorithm

The heel effect is modeled using the CatSim formula (from CreateHeelEffect.py):

I(θ) = I₀ × exp(-μ × d × cos(θ_target) / sin(θ_target + θ))

where:

  • μ = target material attenuation coefficient (cm⁻¹)

  • d = electron penetration depth (cm)

  • θ_target = anode (target) angle

  • θ = take-off angle deviation from central ray

The cos(θ_target) factor accounts for the electron beam penetration geometry in the tilted anode surface.

Arguments

  • intensity: Intensity array [n_cols, n_rows, n_angles] (modified in place)

  • heel: HeelEffect model

  • geom: CTGeometry

Returns

  • Modified intensity array with heel effect applied

GPU Compatibility

  • ✅ Metal (via AcceleratedKernels.jl)

  • ✅ CUDA

  • ✅ ROCm

  • ✅ CPU fallback

References

  1. CatSim CreateHeelEffect.py - HeelEffectIntensity function

  2. Bushberg JT, et al. "The Essential Physics of Medical Imaging"

Example

heel = default_heel_effect(anode_angle_deg=7.0)
apply_heel_effect!(intensity, heel, geom)
compute_heel_spectralfunctionsource ↗
compute_heel_spectral(heel, geom, energies_keV) -> Array{Float64, 3}

Compute the per-row, per-energy heel effect transmission: [n_cols, n_rows, n_energies] (flat along the fan).

Models anode self-attenuation with energy-dependent tungsten μ(E): T(row, E) = exp(-μW(E) × d × cos(θanode) / sin(θ_anode − α(row))) normalized to central ray.

This is the spectral-domain heel effect, analogous to bowtie spectral transmission. Applied during forward projection by multiplying into the spectral weight matrix.

src/source/protocol.jl

validate_protocolfunctionsource ↗
validate_protocol(protocol::CTProtocol, scanner::Scanner) -> (valid::Bool, messages::Vector{String})

Validate CT protocol parameters against physical constraints and scanner limits.

constant_dose_protocolfunctionsource ↗
constant_dose_protocol(base::CTProtocol, new_views::Int) -> CTProtocol

Create a new protocol with same mA (same dose) but different view count.

constant_noise_protocolfunctionsource ↗
constant_noise_protocol(base::CTProtocol, new_views::Int) -> CTProtocol

Create a new protocol with adjusted mA to maintain constant noise per view. new_mA = base_mA × (new_views / base_views).

src/source/spectrum.jl

load_spectrumfunctionsource ↗
load_spectrum(kVp::Int; target_angle::Float64=7.0, source::Symbol=:xspect)

Load a pre-computed X-ray spectrum from .dat file.

Arguments

  • kVp::Int: Peak voltage (70, 80, 90, 100, 110, 120, 130, 140)

  • target_angle::Float64: Anode target angle (7.0 or 10.0 degrees)

  • source::Symbol: Spectrum source (:xspect or :xcist)

Returns

  • energies::Vector{Float64}: Energy bin centers (keV)

  • weights::Vector{Float64}: Photon fluence per energy bin

File format

  • Line 1: Number of energy bins

  • Lines 2+: energy_keV, photon_fluence (CSV)

Units

  • xspect: photons/mA/cm²/s at 1m

  • xcist: photons/mA/mm²/s at 1m

Example

energies, weights = load_spectrum(120)
energies, weights = load_spectrum(120; target_angle=10.0)
spectrum_mean_energyfunctionsource ↗
spectrum_mean_energy(energies, weights)

Compute fluence-weighted mean energy of spectrum.

Returns

Mean energy in keV.

downsample_spectrumfunctionsource ↗
downsample_spectrum(energies, weights, n_bins::Int)

Downsample spectrum to fewer energy bins by averaging.

This is important for memory efficiency in polychromatic projection. 240 bins → 24 bins reduces memory by 10x with minimal accuracy loss.

Arguments

  • energies: Original energy values (keV)

  • weights: Original photon fluence weights

  • n_bins: Target number of bins (default 24)

Returns

  • new_energies: Downsampled energy bin centers

  • new_weights: Downsampled weights (summed within each bin)

Example

energies, weights = load_spectrum(120)  # 240 bins
energies_ds, weights_ds = downsample_spectrum(energies, weights, 24)
load_spectrum_unfilteredfunctionsource ↗
load_spectrum_unfiltered(kVp::Int; anode_angle::Int=10) -> (energies, flux)

Load a raw (unfiltered) IPEM Anode spectrum at 0.5 keV resolution.

Arguments

  • kVp::Int: Peak voltage (80, 100, 120, or 140)

  • anode_angle::Int: IPEM anode angle (8 or 10 degrees)

Returns

  • energies::Vector{Float64}: Energy bin centres (keV), 0.5 keV steps

  • flux::Vector{Float64}: Photon fluence per bin (photons/mAs/mm² at 750 mm)

File format

Line 1: header (ignored), then whitespace-separated energy flux pairs.

filter_spectrumfunctionsource ↗
filter_spectrum(energies, flux; filters, sdd_mm=750.0) -> (energies, filtered_flux)

Apply Beer-Lambert attenuation and inverse-square-law distance scaling to a raw X-ray spectrum.

For each energy bin:

flux_filtered[e] = flux[e] × exp(-Σᵢ μᵢ(E) × tᵢ) × (750 / sdd_mm)²

where μᵢ(E) is from XrayAttenuation.jl and tᵢ is filter thickness in cm.

Arguments

  • energies::Vector{Float64}: Energy bins (keV)

  • flux::Vector{Float64}: Raw photon fluence per bin

  • filters::Vector{Tuple{String,Float64}}: Each tuple is (material, thickness_mm). Materials: "Al", "Cu", "Sn", "Ti", "C"/"graphite".

  • sdd_mm::Float64=750.0: Source-to-detector distance in mm. IPEM spectra are normalised at 750 mm; pass actual SDD for inverse-square-law correction.

Returns

  • (energies, filtered_flux) — same energy grid, attenuated flux.

Forward projection

Allocate a workspace once and reuse it across simulate! calls. After JIT warm-up the hot path doesn't allocate. Below the workspace API sit the ray-driven (Siddon) and distance-driven (DD, dd_fast) projectors, the cached per-material path lengths, and the memory-budget helpers.

src/api/workspace.jl

create_eict_workspacefunctionsource ↗
create_eict_workspace(scanner, protocol, sim_opts, recon_opts, phantom;
                      T=Float32, spectrum_override=nothing)

Create a pre-allocated workspace for zero-allocation EICT single-kVp simulate!().

Keyword arguments

  • spectrum_override::Union{Nothing, Tuple{Vector{Float64}, Vector{Float64}}} = nothing — bypass the polychromatic IPEM lookup and use a custom (energies, weights) pair for the source spectrum. When nothing (default), the spectrum is loaded from the IPEM tables via resolve_source_spectrum_without_bowtie(sim_opts, protocol; scanner).

    Important — weights is ABSOLUTE photon flux (photons / mAs / mm² at 750 mm), matching what load_spectrum_unfiltered returns. compute_detector_I0 reads sum(weights) to size the per-pixel I0 budget, so passing [1.0] yields ~1 photon / mAs / mm² total — far below the noise clamp, leading to a uniform saturated recon.

    For a monoenergetic injection at E_mono with the same flux budget as a polychromatic protocol at the same kVp, fetch the polychromatic flux total and put it all at the mono bin:

    _, w_poly = BS.resolve_source_spectrum_without_bowtie(sim_opts, protocol; scanner)
    spectrum_override = ([E_mono], [sum(Float64.(w_poly))])

    Bowtie + heel + detector response evaluate at the override's actual energy grid, so per-ray spectral physics still runs.

create_workspacefunction
create_workspace(scanner, protocol, sim_opts, recon_opts, phantom; T=Float32)

Create a pre-allocated workspace for zero-allocation simulate!() calls.

Uses similar(phantom.mask, T, shape) for GPU-side buffers, which auto-detects the correct GPU backend (Metal, CUDA, ROCm, or CPU).

Pre-computes all setup data (geometry, spectrum, physics config, detector, spectral response matrices) so that simulate!() has zero allocations.

Arguments

  • scanner: Scanner specification (provides detector geometry)

  • protocol: CT protocol (provides number of views)

  • sim_opts: Simulation options (the common physics toggles)

  • recon_opts: Reconstruction options (provides fov_cm / z_cm for CTGeometry)

  • phantom: Phantom struct (provides mask for backend detection and volume shape)

  • T: Element type, default Float32

Returns

A PCCTWorkspace{T, A3, A1} with all buffers and setup data pre-computed.

Example

ws = create_workspace(scanner, protocol, sim_opts, recon_opts, phantom)
result = simulate!(ws, phantom, protocol, sim_opts)
# Second call: zero allocations
result2 = simulate!(ws, phantom, protocol, sim_opts)

create_workspace(scanner::EICTScanner, protocol, sim_opts, recon_opts, phantom; kwargs...)

The energy-integrating workspace, create_eict_workspace: one constructor for every detector family, so a caller builds and simulates any scanner the same way.

src/api/driver.jl

simulate!function
simulate!(ws::PCCTWorkspace, phantom, protocol, sim_opts;
          capture_raw_counts=true, report_dose=true, dose_kwargs=(;),
          noise_rng=:serial)

Run PCCT simulation using pre-allocated workspace buffers for zero allocations on the second and later calls (the first call may JIT-allocate).

All setup data (geometry, spectrum, physics config, detector, spectral response, materials) is baked into the workspace at create_workspace() time. The PCCT detector model always applies the MC-LUT detector response matrix (compute_mc_drm); pcct_forward_project builds it when no precomputed matrix is passed, and there is no ideal-binning path.

Pulse pileup is on by default and toggleable via PCCTScanner(; pileup); its model-based correction (PCCTScanner(; pileup_correction)) and scatter correction (PCCTScanner(; scatter_correction)) run inside simulate! when enabled. Bin combination, water BHC and reconstruction are the caller's: the returned per-bin sinograms are log-transmissions against the per-ray air response I0_bins ([n_cols, n_rows, n_bins]), which is what spectral_basis and vmi_pipeline take.

Returns

  • pcct_sino — EnergyResolvedSinogram (per-bin log line integrals).

  • I0_bins — the air response of every ray and bin, [n_cols, n_rows, n_bins] (per ray, not a per-bin scalar); I0_bins[:, :, b] .* exp.(-bin_b) recovers the recorded counts.

  • pileup_S — MC pulse-pileup migration matrix, or nothing when disabled.

  • dose — the acquisition's DoseReport, or nothing with report_dose = false (dose_kwargs reaches compute_dose; noise_rng = :threaded draws the counts in independent per-chunk streams, see apply_pcct_noise!).

  • raw_counts — captured by default; independent per-bin arrays containing the detector counts immediately before pile-up correction and scatter correction. With noise on and pile-up off they are captured verbatim at the sampling site: bit-exact integer Poisson realizations with TRUE ZEROS preserved (the floor at 1 applies only to the log-domain sinograms, where a zero count has no finite representation — the simulator imposes no zero-count policy on the counts themselves). With pile-up on, forward migration (S × counts) re-mixes the draws into fractional recorded counts (real detector physics); with noise off, they are the continuous expected counts λ.

Capturing raw counts allocates one additional full-size array per energy bin; pass capture_raw_counts=false only when memory-constrained.


simulate!(ws::EICTWorkspace, phantom, protocol, sim_opts)

Run EICT single-kVp simulation using pre-allocated workspace buffers.

Mutates ws.sinogram in place (the final log line-integral sinogram); read it (and ws.geom) off the workspace. Returns (; dose), the DoseReport of this acquisition (CTDIvol, DLP, …) computed from the simulated beam, or (; dose = nothing) with report_dose = false.

report_dose attaches this acquisition's DoseReport; it costs a cached Monte Carlo (about a second) the first time a beam is seen and nothing afterwards, so set it to false in a tight loop over noise realisations if that second matters. dose_kwargs reaches the keywords of compute_dose — the defaults are one tube and the 32 cm body phantom, so a head protocol wants dose_kwargs = (; phantom = :head16) and a dual-source scanner (; n_tubes = 2).

Pass paths from material_paths to skip the volume walk (point views only).

keep_projection = true adds projection to the result: everything before the noise, which every noise draw of this phantom and protocol shares. Pass it back as projection — to a workspace of the same scanner, protocol and grid — to draw another realization without projecting again, bit-identically to a full simulation with that call's seed. The walk depends on the geometry and the material map alone, so one cache serves every spectrum measured through that geometry, and a several-kVp study of one phantom stops paying for it repeatedly (four tube voltages of a 1024² × 60 phantom: 4.81 s of forward projection down to 2.16 s, bit-identical).

Create the workspace with create_eict_workspace(scanner, protocol, sim_opts, recon_opts, phantom); scanner-derived noise constants (η_eff, σ_e_photon) are baked into ws at that point, so simulate! no longer needs scanner.

Reconstruction is NOT included — handled by the caller via reconstruct!.

compute_detector_I0functionsource ↗
compute_detector_I0(geom, protocol, spectrum_flux_sum) -> Float64

Expected primary photon count per detector pixel per view (no detector quantum efficiency applied — combine with quantum_efficiency_vector at the call site if needed).

I₀ = spectrum_flux_sum × mA × time_per_view × pixel_area_at_detector_mm²

where time_per_view = protocol.rotation_time / protocol.views and pixel_area_at_detector_mm² = (pixel_size · M) × (pixel_row_size · M) · 100 with M = SDD/SAD and pixel sizes in cm at isocenter (the · 100 folds in the cm→mm conversion of both edges).

spectrum_flux_sum = sum(weights) from resolve_source_spectrum_without_bowtie. That spectrum has units of photons/mAs/mm² at the scanner SDD — the (750 mm / SDD)² inverse-square-law correction is already baked into the upstream filter_spectrum step, so no distance factor appears here.

Reference (port from XCIST/CatSim, GE Research)

  • gecatsim/pyfiles/Spectrum.py — defines viewTime = rotationTime/views and Ivec *= mA × viewTime.

  • gecatsim/pyfiles/Detection_Flux.py — multiplies by detector active area and the inverse-square distance factor (which BasisSimulator folds into filter_spectrum instead).

add_system_noise_floor!functionsource ↗
add_system_noise_floor!(vol, sigma_hu; seed=nothing) -> vol

Add IID zero-mean Gaussian noise of standard deviation sigma_hu to a reconstruction volume in the HU domain, in place. Returns vol.

This is an empirical, post-reconstruction noise-floor model — not a port of CatSim, TIGRE, or any other simulator. CatSim and TIGRE both stop at the sinogram-domain pipeline (Poisson + electronic noise on counts); neither adds an HU-domain noise floor.

Why a separate dose-independent floor?

Real scanners exhibit a noise component that does not scale as 1/√dose: imperfect scatter correction residuals, detector calibration drift, and ring-correction residuals. In low-dose regimes this floor can rival the quantum (Poisson) noise that the sinogram-domain simulator already produces.

Combining quadratically: σ_total = √(σ_quantum² + σ_floor²). Adding the floor in image space is correct as long as the floor is approximately white in HU after reconstruction — empirically true for clinical scanners at the spatial frequencies that matter for soft-tissue contrast.

Never use this to compare reconstruction algorithms

Noise added here bypasses the reconstruction operator, so it is identical in an FBP and an iterative recon of the same sinogram. Any FBP-vs-IR noise comparison that includes this floor understates the iterative reduction — at σ = 28 HU it reported Hybrid IR at 1.6 % noise reduction where the true figure was ~26 %. Reserve this helper for effects that genuinely survive reconstruction.

DAS / electronic noise does NOT belong here

A/D and DAS electronic noise enters the counts, before the log transform, and reconstruction therefore acts on it. simulate! already models it from scanner.electronic_noise (electrons) — see the λ_noisy += σ_e · randn() term in the EICT noise kernel. Set that field rather than adding an equivalent HU-domain floor here. (At standard dose its contribution is small: 5000 e⁻ moves a soft-tissue σ from 22.2 to 22.5 HU at 120 kVp / 250 mA. It matters at low dose and behind dense anatomy, which is exactly where it must be allowed to propagate through the reconstruction.)

Do not apply to PCCT

A photon-counting detector discards charge pulses below its lowest energy threshold, which is precisely the mechanism that removes electronic noise — the headline PCCT advantage. Its residual spectral distortion (charge sharing, K-escape, electronic smearing) is already encoded in the Monte-Carlo detector response matrix on the forward path, and pile-up / count-loss are modeled explicitly. Adding a white HU floor to a PCCT recon would erase the advantage the simulation exists to demonstrate.

For physical background see Kalender, Computed Tomography, 3rd ed., Wiley 2011, Ch. 4 (noise sources in CT) and Hsieh, Computed Tomography, 3rd ed., SPIE Press 2015 (electronic-noise floor at low dose). For a PCCT-specific characterization see Leng et al., IEEE TMI 37(11), 2018.

Choosing sigma_hu

Empirical, scanner-specific. Notebooks 01/05/11 use σ ≈ 28 HU, which matches a soft-tissue ROI std measured on a clinical GE Apex Elite (120 kVp, standard-dose abdominal protocol) after FBP reconstruction at 5 mm slice thickness. Re-measure for other scanners / kernels / slice thicknesses.

Note that σ ≈ 28 HU is a total soft-tissue σ, not a residual-only floor, so using it here double-counts the quantum noise simulate! has already added. Notebook 02 instead leaves this helper off and lets scanner.electronic_noise carry the DAS term through the reconstruction.

Arguments

  • vol::AbstractArray{T}: reconstruction volume in HU (mutated in place).

  • sigma_hu::Real: noise-floor standard deviation in HU. sigma_hu ≤ 0 → no-op; vol is returned unchanged.

Keyword Arguments

  • seed::Union{Int,Nothing}=nothing: when an Int is given a private MersenneTwister(seed + 7919) is used so the per-volume realization is bit-reproducible across runs without disturbing Random.default_rng(). When nothing, draws from the global RNG.

Returns

The mutated vol (same object — === to the input).

build_physics_configfunctionsource ↗
build_physics_config(scanner::Scanner, sim_opts::SimOptions, energies::Vector{Float64}, weights::Vector{Float64}; phantom=nothing) -> PhysicsConfig

Build a complete PhysicsConfig from Scanner hardware fields and SimOptions toggles.

For effects with Scanner fields (focal_spot, fill_factor, detector_efficiency, heel_effect), the Scanner hardware parameters are used to construct the effect structs. For effects without Scanner fields (scatter, optical_crosstalk, lag), factory function defaults are used.

Noise is not part of PhysicsConfig — it is applied externally via compute_detector_I0() + quantum noise when sim_opts.use_noise == true.

Arguments

  • scanner: Scanner hardware definition (provides physical parameters)

  • sim_opts: SimOptions with resolved boolean toggles

  • energies: Energy bin centers (keV) for the current spectrum

  • weights: Photon weights for each energy bin

Keyword Arguments

  • phantom::Union{Nothing, Phantom} = nothing: Phantom for automatic scatter scaling. If provided and scatter is enabled, the phantom diameter is estimated from the mask and used to scale the scatter coefficient appropriately.

Returns

A PhysicsConfig consumed by simulate! via the workspace pathway (create_workspace / create_eict_workspace cache it on the workspace at construction time). Original BasisSim glue — not a CatSim port. CatSim's equivalent is the per-effect callback registry on cfg.physics.*Callback.

material_pathsfunctionsource ↗
material_paths(ws::EICTWorkspace, phantom; volume_extent=nothing) -> Array
material_paths!(paths, ws::EICTWorkspace, phantom; volume_extent=nothing) -> paths

Walk phantom once through the workspace's geometry and return the per-material path length of every detector element, in cm — (n_materials, n_cols, n_rows, n_views) on the phantom's backend.

The walk is the expensive part of a polychromatic forward projection (about 95 % of it) and depends only on the geometry and the material map: not on the tube voltage, the filtration, the bowtie or the detector. So one cache serves every spectrum measured through that geometry — pass it to simulate! as paths and each further acquisition costs the spectral conversion alone, bit-identically.

paths = material_paths(ws_120, phantom)              # one walk
for (kvp, ws) in workspaces                          # 80, 100, 120, 140 kVp
    simulate!(ws, phantom, protocols[kvp], sim_opts; paths)
end

Warning

The cache belongs to the phantom and geometry it was walked for, and nothing downstream can tell that it does not: reusing it with a different phantom silently projects the old one. Its size is also the cost — n_materials × n_cols × n_rows × n_views floats, 1.7 GiB for 16 materials on an 834 × 34 × 1000 sinogram — so compact_materials(phantom) first.

The cache is of point views: with view integration (SimOptions(; view_samples > 1)) every sub-view is a geometry of its own, and the noise draws of one acquisition share their projection instead (keep_projection, projection).

material_paths!functionsource ↗
material_paths(ws::EICTWorkspace, phantom; volume_extent=nothing) -> Array
material_paths!(paths, ws::EICTWorkspace, phantom; volume_extent=nothing) -> paths

Walk phantom once through the workspace's geometry and return the per-material path length of every detector element, in cm — (n_materials, n_cols, n_rows, n_views) on the phantom's backend.

The walk is the expensive part of a polychromatic forward projection (about 95 % of it) and depends only on the geometry and the material map: not on the tube voltage, the filtration, the bowtie or the detector. So one cache serves every spectrum measured through that geometry — pass it to simulate! as paths and each further acquisition costs the spectral conversion alone, bit-identically.

paths = material_paths(ws_120, phantom)              # one walk
for (kvp, ws) in workspaces                          # 80, 100, 120, 140 kVp
    simulate!(ws, phantom, protocols[kvp], sim_opts; paths)
end

Warning

The cache belongs to the phantom and geometry it was walked for, and nothing downstream can tell that it does not: reusing it with a different phantom silently projects the old one. Its size is also the cost — n_materials × n_cols × n_rows × n_views floats, 1.7 GiB for 16 materials on an 834 × 34 × 1000 sinogram — so compact_materials(phantom) first.

The cache is of point views: with view integration (SimOptions(; view_samples > 1)) every sub-view is a geometry of its own, and the noise draws of one acquisition share their projection instead (keep_projection, projection).

src/api/view_integration.jl

view_sample_offsetsfunctionsource ↗
view_sample_offsets(geom, sim_opts) -> Vector{Float64}

The angular offsets (rad) of the sub-views that sample a view's integration arc: the midpoints of sim_opts.view_samples equal parts of the arc sim_opts.view_arc × Δθ centred on the view, with Δθ the geometry's view spacing. [0.0] for point views.

src/api/workspace.jl

PCCTWorkspacemutable structsource ↗
PCCTWorkspace{T, A3, A1}

Pre-allocated workspace for zero-allocation PCCT simulation.

Type parameters:

  • T: Element type (typically Float32)

  • A3: 3D array type matching GPU backend (e.g., Array{T,3} for CPU)

  • A1: 1D array type matching GPU backend (e.g., Vector{T} for CPU)

All GPU-side buffers match the backend of the phantom mask passed to create_workspace. CPU-side buffers are always plain Array.

Pre-computed setup data (geometry, spectrum, physics config, detector) is computed once in create_workspace() and stored here for zero-alloc reuse.

EICTWorkspacemutable structsource ↗
EICTWorkspace{T, A3, A1}

Pre-allocated workspace for zero-allocation EICT single-kVp simulation.

Type parameters:

  • T: Element type (typically Float32)

  • A3: 3D array type matching GPU backend

  • A1: 1D array type matching GPU backend

src/projection/dd.jl

dd_forward_project!functionsource ↗
dd_forward_project!(sinogram, volume, geom; volume_extent=nothing, ...) -> sinogram

Distance-driven forward projection (CatSim/XCIST DD3, gather form). Drop-in replacement for siddon_forward_project!: same arguments, same [n_cols, n_rows, n_angles] sinogram, same cm units and Σμ·l output.

dd_fused_poly_project!functionsource ↗
dd_fused_poly_project!(sinogram, mask, geom, μ_table_gpu, wη_gpu, Val(N_E); kwargs...)

Distance-driven analog of siddon_fused_poly_project!: one gather pass per detector cell through the material mask, accumulating line integrals for all N_E energy bins, then Beer-Lambert -log(Σ_e wη[e]·exp(-L_e)). Same arguments, output, and bowtie-spectral handling as the Siddon version.

See dd_fast_fused_poly_project! for the single-pass per-material path-length variant (identical DD model, no per-energy register pressure).

dd_fused_spectral_project!functionsource ↗
dd_fused_spectral_project!(pilot, outputs_flat, n_bins, mask, geom,
    μ_table_gpu, W_gpu, Val(K), tile_start; kwargs...)

Distance-driven analog of siddon_fused_spectral_project!: one gather pass per detector cell accumulating K energy bins (tile starting at tile_start), then ADDS per-bin partial Beer-Lambert sums to outputs_flat. Same tiled-accumulation contract as the Siddon version (zero-init before the first tile; tiles accumulate).

See dd_fast_fused_spectral_project! for the single-pass per-material path-length variant (identical DD model; K can be the full energy count).

src/projection/dd_fast.jl

dd_fast_fused_poly_project!functionsource ↗
dd_fast_fused_poly_project!(sinogram, mask, geom, μ_table_gpu, wη_gpu, Val(N_E); kwargs...)

Single-pass per-material path-length variant of dd_fused_poly_project! (the :dd_fast projector). Identical distance-driven footprint and weights; accumulation reassociated by linearity so ALL N_E energies are produced in one volume walk with n_materials registers. Falls back to the legacy kernel when size(μ_table_gpu, 1) > 64, with a prominent performance warning.

dd_fast_fused_spectral_project!functionsource ↗
dd_fast_fused_spectral_project!(pilot, outputs_flat, n_bins, mask, geom,
    μ_table_gpu, W_gpu, Val(K), tile_start; kwargs...)

Single-pass per-material path-length variant of dd_fused_spectral_project! (the :dd_fast projector). Identical distance-driven footprint and weights; K may be the FULL padded energy count in a single call (no per-energy registers, no per-tile volume re-walk). Falls back to the legacy kernel when size(μ_table_gpu, 1) > 64, with a prominent performance warning.

dd_fast_material_paths!functionsource ↗
dd_fast_material_paths!(paths, mask, geom; kwargs...) -> paths

Pass 1 of the two-pass polychromatic projection: walk the volume once and store the per-material path length of every detector element, in cm.

paths is (n_materials, n_cols, n_rows, n_views). The walk depends only on the geometry and the material map, so the same paths serves every spectrum measured through that geometry — every tube voltage, filtration, bowtie and detector. Feed it to dd_fast_poly_from_paths!.

Warning

paths belongs to the phantom and geometry it was computed for. Nothing downstream can tell that it was not, so reusing it across a different phantom silently reconstructs the old one.

dd_fast_poly_from_paths!functionsource ↗
dd_fast_poly_from_paths!(sinogram, paths, μ_table_gpu, wη_gpu; ws_bowtie_spectral=nothing)

Pass 2 of the two-pass polychromatic projection: convert cached per-material path lengths (dd_fast_material_paths!) into one polychromatic log line-integral sinogram.

Bit-identical to the single-pass dd_fast_fused_poly_project! on the same inputs: the accumulation inside a cell and the energy loop are the same expressions in the same order.

src/projection/dd_transpose.jl

dd_backproject!functionsource ↗
dd_backproject!(volume, sinogram, geom; volume_extent=nothing, ...)

Apply the exact algebraic transpose of dd_forward_project!. Each voxel gathers the detector cells overlapped by its projected footprint and reuses _dd_cell_setup plus the identical ox * oz * norm coefficient from the forward operator. The gather formulation is deterministic and needs no floating-point atomics on GPU backends.

src/projection/siddon.jl

siddon_forward_project!functionsource ↗
siddon_forward_project!(sinogram, volume, geom) -> sinogram

Compute forward projection (sinogram) from a 3D attenuation volume using Siddon's exact ray-tracing algorithm. This is an in-place operation that modifies the sinogram array directly.

Algorithm

Implements Siddon (1985) for CT forward projection:

  1. For each projection angle θ and detector pixel (u, v):

    • Compute source position: S(θ) from gantry geometry

    • Compute detector pixel center: D(θ, u, v)

    • Trace ray from S to D through volume

    • Store line integral in sinogram

The line integral for each ray is:

p(θ, u, v) = ∫ μ(x, y, z) dl = Σᵢ μᵢ × lᵢ

where μᵢ is the attenuation of voxel i and lᵢ is the path length through it.

Arguments

  • sinogram::AbstractArray{T,3}: Output sinogram array of size [n_cols, n_rows, n_angles], modified in place. Will contain line integrals.

    • n_cols: Number of detector columns (transaxial direction)

    • n_rows: Number of detector rows (axial direction)

    • n_angles: Number of projection angles

  • volume::AbstractArray{T,3}: Input attenuation volume of size [nx, ny, nz]. Values are linear attenuation coefficients μ in mm⁻¹. The volume is assumed to be centered at the isocenter with extent defined by geom.fov.

  • geom::CTGeometry: CT scanner geometry containing:

    • fov::Tuple{T,T,T}: Field of view (x, y, z) in mm

    • SAD::T: Source-to-axis (isocenter) distance in mm

    • SDD::T: Source-to-detector distance in mm

    • pixel_size::T: Detector pixel size at isocenter in mm

    • source_positions::Matrix{T}: Source positions [3, n_angles]

    • detector_centers::Matrix{T}: Detector centers [3, n_angles]

    • detector_u::Matrix{T}: Detector u-direction vectors [3, n_angles]

    • detector_v::Matrix{T}: Detector v-direction vectors [3, n_angles]

Returns

  • sinogram::AbstractArray{T,3}: The modified sinogram array (same as input)

GPU Compatibility

Automatically selects compute backend based on array type:

Array TypeBackendPerformance
ArrayCPU (multi-threaded)Baseline
MtlArrayMetal (Apple Silicon)~50-100× faster
CuArrayCUDA (NVIDIA)~50-100× faster
ROCArrayROCm (AMD)~50-100× faster

No code changes needed - just pass appropriate array types.

Example

using BasisSimulator
using Metal  # or: using CUDA

# Create geometry for GE Revolution Apex-like scanner
scanner = GERevolutionApex()
geom = CTGeometry(scanner; n_angles=360, fov=(350.0, 350.0, 40.0))

# Create a simple water cylinder phantom
phantom = zeros(Float32, 256, 256, 64)
for i in 1:256, j in 1:256, k in 1:64
    x, y = (i - 128.5) * 1.37, (j - 128.5) * 1.37  # mm from center
    if x^2 + y^2 < 100^2  # 100mm radius cylinder
        phantom[i, j, k] = 0.02f0  # μ_water ≈ 0.02 mm⁻¹ at 60 keV
    end
end

# GPU forward projection
phantom_gpu = MtlArray(phantom)  # or CuArray
sinogram_gpu = similar(phantom_gpu, Float32, geom.n_cols, geom.n_rows, geom.n_angles)
fill!(sinogram_gpu, 0f0)

siddon_forward_project!(sinogram_gpu, phantom_gpu, geom)

# Result: sinogram_gpu contains line integrals for FDK reconstruction

Performance Notes

  • O(N) complexity per ray, where N = number of voxels intersected

  • Typical CT ray traverses ~256-512 voxels

  • GPU parallelization over all (n_cols × n_rows × n_angles) rays

  • Memory bandwidth limited on GPU; compute-limited on CPU

References

  1. Siddon RL. "Fast calculation of the exact radiological path for a three-dimensional CT array." Med Phys. 1985;12(2):252-255. doi:10.1118/1.595715

  2. Biguri A, et al. "TIGRE: A MATLAB-GPU toolbox for CBCT image reconstruction." Biomed Phys Eng Express. 2016;2(5):055010. doi:10.1088/2057-1976/2/5/055010

See Also

siddon_forward_projectfunctionsource ↗
siddon_forward_project(volume, geom) -> sinogram

Compute forward projection (sinogram) from a 3D attenuation volume using Siddon's exact ray-tracing algorithm. This is an allocating version that creates and returns a new sinogram array.

Algorithm

Implements Siddon (1985) for CT forward projection. See siddon_forward_project! for detailed algorithm description.

Arguments

  • volume::AbstractArray{T,3}: Input attenuation volume of size [nx, ny, nz]. Values are linear attenuation coefficients μ in mm⁻¹.

  • geom::CTGeometry: CT scanner geometry (see siddon_forward_project! for full description of required fields).

Returns

  • sinogram::AbstractArray{T,3}: Newly allocated sinogram of size [n_cols, n_rows, n_angles] containing line integrals. The array is allocated on the same device as volume (CPU, Metal, CUDA, etc.).

GPU Compatibility

The returned sinogram is allocated on the same device as the input volume:

# CPU version
sinogram = siddon_forward_project(Array(phantom), geom)  # returns Array

# GPU version (Metal)
sinogram = siddon_forward_project(MtlArray(phantom), geom)  # returns MtlArray

# GPU version (CUDA)
sinogram = siddon_forward_project(CuArray(phantom), geom)  # returns CuArray

Example

using BasisSimulator

# Create scanner and geometry
scanner = GERevolutionApex()
geom = CTGeometry(scanner; n_angles=180, fov=(300.0, 300.0, 32.0))

# Create uniform water phantom (μ ≈ 0.02 mm⁻¹)
phantom = fill(0.02f0, 128, 128, 32)

# Forward projection - automatically allocates sinogram
sinogram = siddon_forward_project(phantom, geom)

# sinogram is Float32 Array of size (geom.n_cols, geom.n_rows, 180)
println("Sinogram size: ", size(sinogram))
println("Mean projection value: ", mean(sinogram))

Performance Notes

For repeated projections (e.g., iterative reconstruction), prefer the in-place version siddon_forward_project! to avoid repeated allocations.

References

  1. Siddon RL. "Fast calculation of the exact radiological path for a three-dimensional CT array." Med Phys. 1985;12(2):252-255. doi:10.1118/1.595715

  2. Biguri A, et al. "TIGRE: A MATLAB-GPU toolbox for CBCT image reconstruction." Biomed Phys Eng Express. 2016;2(5):055010. doi:10.1088/2057-1976/2/5/055010

See Also

siddon_fused_poly_project!functionsource ↗
siddon_fused_poly_project!(sinogram, mask, geom, μ_table_gpu, wη_gpu, Val(N_E); kwargs...)

Fused polychromatic forward projection: single DDA pass through material mask, accumulating line integrals for all energy bins simultaneously.

Mathematically equivalent to the unfused path (sequential energy loop), but traces each ray only ONCE instead of N_E times. The material mask (UInt16) is read once per voxel, and μ values for all energies are looked up from a [n_materials × n_energies] table that fits in GPU cache.

Arguments

  • sinogram::AbstractArray{T,3}: Output [n_cols, n_rows, n_angles]. Written in-place with -log(I_total) values.

  • mask::AbstractArray{<:Unsigned,3}: Material index volume [nx, ny, nz]. 0-indexed region indices (UInt8 or UInt16). Supports up to 65,535 materials with UInt16.

  • geom::CTGeometry: Scanner geometry.

  • μ_table_gpu::AbstractArray{T,2}: Attenuation lookup table [n_materials, n_energies] on GPU. μ_table[mat+1, e] gives μ in mm⁻¹ for material mat at energy bin e.

  • wη_gpu::AbstractArray{T,1}: Pre-computed weights_norm .* η on GPU [n_energies].

  • ::Val{N_E}: Number of energy bins as compile-time constant for loop unrolling.

Keyword Arguments

  • volume_extent: Override volume physical bounds (for phantoms with extent ≠ recon FOV).

  • ws_source_positions, ws_detector_centers, ws_detector_u, ws_detector_v: Pre-allocated geometry arrays on GPU (avoids per-call allocation).

  • ws_bowtie_spectral: Per-pixel bowtie spectral transmission [n_cols, n_rows, n_energies] on GPU. If nothing, no bowtie correction applied.

siddon_fused_spectral_project!functionsource ↗
siddon_fused_spectral_project!(pilot, outputs_flat, n_bins, mask, geom,
    μ_table_gpu, W_gpu, Val(K), tile_start; kwargs...)

Tiled spectral forward projection for PCCT: single DDA pass through material mask, accumulating K energy bins, then writing partial Beer-Lambert sums to n_bins output channels.

Each tile processes K energies (starting at tile_start). Multiple tiles are called sequentially. After all tiles, caller applies -log(outputs/I0_bin) per bin.

Arguments

  • pilot::AbstractArray{T,3}: Sinogram-shaped reference array [n_cols, n_rows, n_angles]. Used only for AK.foreachindex iteration count and backend detection. Not written.

  • outputs_flat::AbstractArray{T,1}: Flattened output [n_elements * n_bins]. Layout: [sino_bin1..., sino_bin2..., ...]. Partial Beer-Lambert sums are ADDED to existing values (must be zero-initialized before first tile).

  • n_bins::Int32: Number of output bins (e.g., 4 for NAEOTOM Alpha).

  • mask::AbstractArray{<:Unsigned,3}: Material index volume [nx, ny, nz].

  • geom::CTGeometry: Scanner geometry (provides detector layout + magnification).

  • μ_table_gpu::AbstractArray{T,2}: Attenuation lookup [n_materials, n_energies_padded].

  • W_gpu::AbstractArray{T,2}: Weight matrix [n_energies_padded, n_bins]. W[e,b] = I0 * w[e] * η[e] * R[e,b] for energy e, bin b.

  • ::Val{K}: Tile size (compile-time, typically 16).

  • tile_start::Int32: 1-based start index into energy dimension for this tile.

Keyword Arguments

  • volume_extent: Override volume physical bounds (phantom extent).

  • ws_source_positions, ws_detector_centers, ws_detector_u, ws_detector_v: Pre-allocated geometry arrays on GPU.

src/reconstruction/workspace/memory_budget.jl

estimate_pcct_workspace_bytesfunctionsource ↗
estimate_pcct_workspace_bytes(sino_shape, volume_shape, n_bins, binning_factor;
                              T = Float32)

Conservative byte estimate for the persistent PCCT workspace buffers. The GPU estimate includes binned and native-resolution bins/scratch/output storage; the host estimate includes the two full sinogram noise staging buffers. Small spectral tables and geometry vectors are covered by a 16 MiB allowance.

check_pcct_workspace_budgetfunctionsource ↗
check_pcct_workspace_budget(template, estimate)

Reject a PCCT workspace allocation before it begins when the backend reports a recommended device working set and the estimated workspace would consume more than half of the currently unallocated portion, so a second retained workspace is refused instead of exhausting device memory.

backend_memory_snapshotfunctionsource ↗
backend_memory_snapshot(template = nothing) -> NamedTuple

Return host memory and, when the array backend exposes it, device allocation information for long-running notebook audits. No GPU package is imported.

release_backend!functionsource ↗
release_backend!(object; collect = true) -> Int

Deterministically release GPU arrays reachable from a workspace after all required values have been copied to the CPU. The object must not be used afterward. CPU arrays are untouched. Returns the number of released handles.

Dose & CTDI

CTDI is measured the way IEC 60601-2-44 defines it. The beam is transported by Monte Carlo through a PMMA cylinder (32 cm body or 16 cm head), and the air kerma is integrated over a 100 mm pencil chamber at the centre and at the four peripheral holes. Because it's simulated rather than looked up, it follows the spectrum, filtration and bowtie of the scanner being simulated.

src/source/dose.jl

compute_dosefunctionsource ↗
compute_dose(src::DoseSource, protocol::CTProtocol; phantom = :body32, overbeam_mm = 0.0,
             table_increment_mm = nothing, n_tubes = 1, dose_calibration = 1.0,
             n_histories = 1_000_000, seed = 1) -> DoseReport
compute_dose(scanner::Scanner, protocol::CTProtocol; kwargs...) -> DoseReport

CTDIvol and DLP of an acquisition.

  • phantom: :body32 (the body convention) or :head16.

  • overbeam_mm: how much wider than N·T the real beam is at isocentre. The simulator's beam is exactly the collimation, so the default is 0; a clinical collimator over-beams by about 3 mm, which matters most for narrow collimations.

  • table_increment_mm: axial table step between rotations; defaults to N·T (contiguous). Ignored for helical protocols, which use protocol.pitch.

  • n_tubes: dual-source scanners run n_tubes identical beams.

  • dose_calibration: multiplies the result, to pin a scanner to a measured CTDIvol.

Helical: CTDIvol = CTDIw · mAs / pitch, scan length pitch · N·T · n_rotations. Axial: CTDIvol = CTDIw · mAs · N·T / Δd, scan length n_rotations · Δd.

DoseReportstructsource ↗
DoseReport

The dose of one acquisition. ctdi_vol_mGy and dlp_mGy_cm are the headline numbers; the rest records how they were obtained so they can be audited.

muen_rho_airfunctionsource ↗
muen_rho_air(E::Real)

Mass energy-absorption coefficient of dry air (cm²/g) at E keV, log-log interpolated on the NIST table. Outside it the value is extrapolated: below 5 keV as E^-3, above 200 keV as the last tabulated value. Neither matters for a diagnostic beam.

DoseSourcestructsource ↗
DoseSource

The tube-side description of a beam, enough to compute its dose: the absolute fluence spectrum at isocentre after flat and added filtration but before the bowtie (phi_iso, photons per mAs per mm² per energy bin of E), the bowtie, the source-to-isocentre distance and the nominal collimation N·T at isocentre. Build it with dose_source; every workspace carries one as ws.dose_source.

dose_sourcefunctionsource ↗
dose_source(scanner, protocol; spectrum = nothing) -> DoseSource

The beam of scanner run with protocol. By default the spectrum is the one the simulator uses (IPEM-78 tungsten anode, Beer-Lambert through the scanner's flat filter and the protocol's added filters). Pass spectrum = (energies_keV, weights) — in the simulator's units, photons per mAs per mm² AT THE DETECTOR — when a workspace was built with a spectrum override.

air_kerma_free_in_airfunctionsource ↗
air_kerma_free_in_air(src::DoseSource; fan_angle = 0.0) -> Float64

Air kerma per mAs at isocentre, free in air, behind the bowtie at fan_angle (rad), in mGy/mAs: K = Σ_E Φ(E) · T_bowtie(E) · E · (μ_en/ρ)_air(E).

ctdi100functionsource ↗
ctdi100(src::DoseSource; phantom = :body32, beam_width_mm = src.nominal_collimation_mm,
        nominal_collimation_mm = src.nominal_collimation_mm,
        n_histories = 1_000_000, seed = 1) -> NamedTuple

CTDI100 per mAs (mGy/mAs) at the centre and at the periphery (1 cm below the surface) of the :body32 (32 cm) or :head16 (16 cm) PMMA CTDI phantom, by Monte Carlo transport of the beam. beam_width_mm is the actual beam width at isocentre; the integral is divided by nominal_collimation_mm. Returns (center, periphery, primary_center, primary_periphery, ctdi\_w, rel\_stat\_uncertainty).

The result is deterministic for a given seed, whatever the number of threads, and is cached.

compute_dlpfunctionsource ↗
compute_dlp(report::DoseReport) -> Float64
compute_dlp(scanner, protocol; kwargs...) -> Float64

Dose-length product in mGy·cm: CTDIvol times the scan length.

dose_reportfunctionsource ↗
dose_report(ws, protocol; kwargs...) -> Union{DoseReport, Nothing}

The dose of the acquisition a workspace simulates, from the beam stored in ws.dose_source, or nothing when that workspace has no beam in absolute units (a monoenergetic or otherwise overridden spectrum). Every simulate! result carries this report as result.dose; pass dose_kwargs there to reach the keywords of compute_dose, which this forwards — the defaults are a single tube and the 32 cm body phantom.

Reconstruction

Analytic FDK for a circular orbit, weighted FBP for a helical one, and penalized iterative HIR (Hybrid IR). The volume reconstructors share a single reconstruct! entry point, which dispatches on the workspace type. The filters, back-projectors and iterative-reconstruction utilities are documented alongside.

src/api/driver.jl

reconstruct!function
reconstruct!(ws::FDKReconWorkspace, sinogram, geom) -> ws.volume

Zero-allocation FDK reconstruction using pre-allocated workspace buffers.

The four-step pipeline (copy → filter → backproject → FOV-mask) and the supported filter set (:ram_lak, :shepp_logan, :cosine, :hamming, :hann, plus the BasisSim-specific :standard/:soft/:bone clinical kernels) follow Feldkamp-Davis-Kress (1984) and the TIGRE Toolbox implementation.

Each step is a separate exported primitive (filter_sinogram!, backproject!, apply_fov_mask!) so callers can rebuild the same pipeline piecewise (the hybrid-IR path reuses all three).

The filter kernel, kernel size (driven by cutoff), and output volume size are all locked at workspace creation time (create_fdk_recon_workspace). There are no runtime overrides on this hot path — re-create the workspace to change them.

Reference (algorithm port from TIGRE Toolbox)

  • Feldkamp LA, Davis LC, Kress JW. "Practical cone-beam algorithm." J Opt Soc Am A 1(6):612–619 (1984). doi:10.1364/JOSAA.1.000612

  • TIGRE: MATLAB/Algorithms/FDK.m — same filtering → backprojection flow, same filter options. Cone-beam backprojection is in Common/CUDA/voxel_backprojection.cu ("CUDA function for backprojection using FDK weights for CBCT") and BasisSim's src/reconstruction/core/backprojection.jl is the Julia/Metal/CUDA port of the same voxel-driven cone-beam scheme.

  • Note: this path does NOT use the Siddon (1985) ray-tracing algorithm. Siddon is forward-projection only — see siddon_forward_project!. FDK reconstruction is voxel-driven backprojection, a different algorithm.

Arguments

  • ws::FDKReconWorkspace{T} — pre-allocated buffers from create_fdk_recon_workspace(sinogram, geom, volume_size; filter, cutoff).

  • sinogram::AbstractArray{T,3} — (n_col, n_row, n_view) log line-integral sinogram, on the same backend as ws (CPU/Metal/CUDA/AMDGPU).

  • geom::CTGeometry — cone-beam geometry.

helical_q and coverage reach the helical chain and are refused on an axial geometry; see wfbp_helical_reconstruct. coverage must share the volume's element type and backend, and is masked outside the reconstruction circle along with the volume.

Returns

The mutated ws.volume (same object — === to the workspace field).


reconstruct!(ws::HIRReconWorkspace, sinogram, geom; init_volume=nothing, air_reference=nothing) -> ws.volume

Zero-allocation Hybrid IR reconstruction using pre-allocated workspace buffers.

Pipeline: FDK initialization → Ordered-Subsets Penalized Weighted Least Squares (OS-PWLS) with Huber edge-preserving regularization on a 6-connected neighborhood.

Reference (algorithm — Fessler / U-Michigan school, NOT a TIGRE port)

TIGRE has SART, OS-SART, MLEM, OSEM, CGLS, FISTA, ASD-POCS — but no PWLS and no Huber prior. The HIR algorithm is from a different family entirely. The canonical reference implementation is Jeff Fessler's MIRT (Michigan Image Reconstruction Toolbox); each component below is matched 1-to-1 with a MIRT source file.

  • PWLS framework for transmission CT: Sauer K, Bouman C. "A local update strategy for iterative reconstruction from projections." IEEE Trans Signal Process 41(2):534–548 (1993). Fessler JA. "Penalized weighted least-squares image reconstruction for positron emission tomography." IEEE Trans Med Imaging 13(2):290–300 (1994). doi:10.1109/42.293921

  • Ordered-Subsets PWLS (the OS path used here): Erdoğan H, Fessler JA. "Ordered subsets algorithms for transmission tomography." Phys Med Biol 44(11):2835–2851 (1999). doi:10.1088/0031-9155/44/11/311 → MIRT: transmission/tpl_os_sps.m (T-PL-OS-SPS — "Transmission Penalized-Likelihood Ordered-Subsets Separable Paraboloidal Surrogates"). Our OS loop structure (stat-weight init → outer epoch → inner subset loop → forward + backproject + V_inv-preconditioned update) mirrors tpl_os_sps's outer iteration; we use a SIRT-style V_inv preconditioner in place of MIRT's per-pixel SPS curvatures (denom) — same algorithm class, fixed-curvature variant.

  • Huber edge-preserving penalty + gradient: Huber PJ. "Robust estimation of a location parameter." Ann Math Stat 35(1):73–101 (1964). → MIRT: penalty/huber_pot.m (potential function) and huber_dpot.m (potential derivative). compute_huber_gradient! in src/reconstruction/ir/utils.jl is a direct GPU port of huber_dpot applied to a 6-connected finite-difference stencil (penalty/Cdiffs.m).

  • Volume update rule: SIRT-style multiplicative update with V_inv image- domain weights — Andersen & Kak SART/SIRT framework, generalized inside the same MIRT family (general/qpwls_*, transmission/tpl_*).

The strength-keyed HIRParams lookup table targets the noise-reduction behavior of vendor IR (GE ASIR-V, Siemens SAFIRE, Philips iDose-4, Canon AIDR 3D) as reported in the clinical validation literature (Geyer et al. Radiology 2015; Willemink & Noël Eur Radiol 2019; Ghetti et al. PMC5714520). Those papers describe the target performance; they do not provide vendor algorithms (which are proprietary). Our underlying PWLS+OS solver is the open-literature substitute.

Implementation

The workspace pre-allocates everything the OS-PWLS loop needs: ordered subsets (ws.subsets, ws.subset_geometries), per-subset GPU geometry arrays and angle maps, the folded data-weight buffer (data_weights = W_proj ⊙ statistical weights), image-domain weights (V_inv), regularization gradient (reg_grad), and the forward-projection buffer. Each subset gathers its rays out of the full sinogram through ws.subset_angle_idx[s] rather than staging them into a copy.

At strength = 0 (nepochs == 0) the PWLS loop is skipped entirely and the FDK initialization is returned — i.e. plain FBP.

The filter kernel, kernel size, and output volume size are all locked at workspace creation time — no runtime overrides on this hot path.

Keyword Arguments

  • init_volume: Optional warm-start volume on the requested grid (same shape as ws.volume). It replaces the FDK seed inside the requested window only: the rest of HIR's support (the patient outside the requested circle, the cone halo along z) is still seeded by FDK from the sinogram, so the model holds everything the detector saw. The caller's array is unchanged.

  • air_reference: Optional 2D array [n_cols, n_rows] of bowtie air reference values. When provided, statistical weights are scaled by the air reference to account for position-dependent noise from the bowtie filter (edge pixels have fewer counts → more noise → lower weight).

weights selects the statistical weighting of the data term, and the right choice depends on what the sinogram IS, not on which scanner made it. :transmission (default) is exp(-y) scaled by air_reference when given: right for any log-transmission sinogram — an energy-integrating acquisition, a photon-counting acquisition's summed bins, either tube voltage of a dual-energy pair — because a ray through more material carried fewer photons. :uniform weights every ray equally, leaving only the geometric ray-length normalisation. An array of the sinogram's shape is used as the per-ray statistical weight directly: for a material-basis sinogram from a projection-domain decomposition (photon-counting bins or a dual-kVp pair through decompose_nchannel), whose values are g/cm² and not log-transmissions, pass the decomposition's inverse-variance map (see vmi_pipeline) or :uniform; exp(-y) has no meaning for it.

src/api/workspace.jl

create_fdk_recon_workspacefunctionsource ↗
create_fdk_recon_workspace(sinogram, geom, volume_size; T=eltype(sinogram), filter=StandardFilter(), cutoff=1.0)

Create a pre-allocated workspace for zero-allocation FDK reconstruct!().

filter can be a FilterType struct (e.g., StandardFilter()) or a Symbol (e.g., :standard, :ram_lak).

create_hir_recon_workspacefunctionsource ↗
create_hir_recon_workspace(sinogram, geom, volume_size; T=eltype(sinogram), strength=60, filter=StandardFilter(), cutoff=1.0)

Create a pre-allocated workspace for zero-allocation Hybrid IR reconstruct!().

strength is the IR strength percentage, 0–100 in steps of 10 — the one dial for the whole HIR pipeline (0 = pure FBP, 60 = standard clinical, 100 = maximum noise reduction). See get_hir_params for the strength → noise-reduction table.

filter can be a FilterType struct or a Symbol (e.g., :standard).

projector (:dd_fast default, or :siddon) selects the forward ray tracer used for the IR system matrix (A·x and W = 1/(A·1)). Set it to the SAME value as the simulation's SimOptions(; projector=…) so the recon inverts the operator that generated the data.

FDKReconWorkspacemutable structsource ↗
FDKReconWorkspace{T, A3, A2, A1}

Pre-allocated workspace for zero-allocation FDK reconstruction.

Type parameters:

  • T: Element type (Float32, Float64)

  • A3: 3D array type (Array{T,3}, MtlArray{T,3}, CuArray{T,3})

  • A2: 2D array type for geometry arrays (Matrix{T}, MtlArray{T,2}, etc.)

  • A1: 1D array type for filter kernel

Create with create_fdk_recon_workspace.

HIRReconWorkspacemutable structsource ↗
HIRReconWorkspace{T, A3, A2, A1}

Pre-allocated workspace for zero-allocation Hybrid IR reconstruction.

Includes FDK initialization buffers + PWLS iteration buffers. Geometry arrays are shared between forward projection and backprojection.

Type parameters:

  • T: Element type (Float32, Float64)

  • A3: 3D array type (Array{T,3}, MtlArray{T,3}, CuArray{T,3})

  • A2: 2D array type for geometry arrays

  • A1: 1D array type for filter kernel

Create with create_hir_recon_workspace.

src/reconstruction/core/backprojection.jl

backproject!functionsource ↗
backproject!(volume, sinogram, geom; weighted=true)

In-place backprojection using AcceleratedKernels.jl.

Automatically runs on GPU (Metal/CUDA/ROCm) or CPU based on array type.

Arguments

  • volume: Output volume [nx, ny, nz] (modified in place)

  • sinogram: Filtered sinogram [n_cols, n_rows, n_angles]

  • geom: CTGeometry with scanner parameters

Keyword Arguments

  • weighted: If true (default), apply FDK distance weighting for filtered backprojection. If false, use matched/unweighted backprojection for iterative algorithms (SIRT, CGLS).

Returns

The modified volume array

Notes

  • Use weighted=true for FDK reconstruction (after ramp filtering)

  • Use weighted=false for iterative methods like SIRT and CGLS

The weighted version applies the FDK distance weight: w = SAD² / dist² The unweighted version is the matched adjoint of Siddon forward projection.

backprojectfunctionsource ↗
backproject(sinogram, geom, volume_size; weighted=true)

Allocating version of backprojection.

Arguments

  • sinogram: Filtered sinogram [n_cols, n_rows, n_angles]

  • geom: CTGeometry with scanner parameters

  • volume_size: (nx, ny, nz) output volume dimensions

Keyword Arguments

  • weighted: If true (default), apply FDK distance weighting for filtered backprojection. If false, use matched/unweighted backprojection for iterative algorithms.

Returns

New volume array [nx, ny, nz]

src/reconstruction/core/filtering.jl

StandardFilterstructsource ↗
struct StandardFilter

CatSim 'standard' filter — ramp × apodization window.

Matches CatSim/XCIST default kernelType = 'standard' from createHSP.py. Apodization defined at 5 control points (quadratic interpolation): f_norm: [0, 0.25, 0.5, 0.75, 1.0] window: [1, 0.9338, 0.7441, 0.4425, 0.0531]

Provides ~2.1× noise reduction vs pure Ram-Lak at the cost of spatial resolution.

SoftFilterstructsource ↗
struct SoftFilter

CatSim 'soft' filter — ramp × soft-tissue apodization.

Matches CatSim/XCIST kernelType = 'soft' from createHSP.py. Provides stronger smoothing than StandardFilter.

BoneFilterstructsource ↗
struct BoneFilter

CatSim 'bone' filter — ramp × bone-enhancing apodization.

Matches CatSim/XCIST kernelType = 'bone' from createHSP.py. Boosts mid-frequencies for sharper bone edges; higher noise than StandardFilter.

CustomFilterstructsource ↗
CustomFilter(control_x, control_y)

Custom frequency-domain apodization filter with user-specified control points.

Applies ramp × piecewise-linear window defined at normalized frequency control points, using the same CatSim-style mechanism as StandardFilter, SoftFilter, and BoneFilter.

Arguments

  • control_x: Tuple of normalized frequency positions (0.0 to 1.0)

  • control_y: Tuple of window values at each position (1.0 = full pass, 0.0 = block)

Example

# Soft-tissue–optimised apodization
f = CustomFilter((0.0, 0.25, 0.5, 0.75, 1.0),
                 (1.0, 0.82, 0.54, 0.26, 0.001))
ws = create_fdk_recon_workspace(sino, geom, recon_size; filter = f)
create_spatial_kernelfunctionsource ↗
create_spatial_kernel(n, filter_type, pixel_size; bandlimit = 1.0)

The FBP convolution kernel in the spatial domain: the discrete Ram-Lak ramp at sample spacing pixel_size (Kak & Slaney — h[0] = 1/(4Δ), h[k] = -1/(π²k²Δ) for odd k, zero for even k; the extra factor Δ matches the FFT-based normalisation), multiplied in the frequency domain by the apodization window of filter_type (frequency_window).

bandlimit is the fraction of the sampling Nyquist 1/(2Δ) that the reconstruction can carry: min(1, Δ / Δ_grid) for an image grid of pixel Δ_grid at isocentre, which grid_bandlimit computes from a geometry and a volume size. The window is stretched over [0, bandlimit] of the sampling Nyquist and the response is zero above it. Two things follow: a named kernel means the same resolution on any detector, and no frequency the image grid cannot represent reaches the backprojector, where it would alias into the image as fine grain (a 0.30 mm photon-counting column on a 0.68 mm grid passed 2.3× the grid's Nyquist before this). With bandlimit = 1 — a detector no finer than the grid — the kernel is the classical one.

grid_bandlimitfunctionsource ↗
grid_bandlimit(geom, volume_size; ray_spacing = geom.pixel_size) -> Float64

The fraction of the ray sampling's Nyquist frequency that a reconstruction grid of volume_size over geom.fov can represent: min(1, Δ_ray / Δ_grid), with Δ_grid the larger of the two in-plane pixels (cm, at isocentre) and Δ_ray the ray spacing at isocentre — geom.pixel_size, or the rebinned parallel spacing of a helical reconstruction. It is 1 for a detector no finer than the grid, and the bandlimit every reconstruction here passes to create_spatial_kernel.

frequency_windowfunctionsource ↗
frequency_window(filter, f) -> window value at normalised frequency `f`

The apodization window of filter against frequency normalised to its cutoff: f = 0 is DC, f = 1 the cutoff. Ram-Lak: 1. Shepp-Logan: sinc(f/2) = sin(πf/2)/(πf/2). Cosine: cos(πf/2). Hamming: 0.54 + 0.46·cos(πf). Hann: ½(1 + cos(πf)). The CatSim kernels — standard, soft, bone and a CustomFilter — interpolate their control points (createHSP.py).

filter_from_symbolfunctionsource ↗
filter_from_symbol(sym::Symbol) -> FilterType

Convert a filter symbol (e.g., from ReconOptions.filter) to a FilterType struct.

Supported symbols: :ram_lak, :shepp_logan, :cosine, :hamming, :hann, :standard, :soft, :bone.

filter_sinogram!functionsource ↗
filter_sinogram!(sinogram, geom; filter=StandardFilter(), cutoff=1.0)

Apply FDK filtering to sinogram in-place using GPU-native spatial domain convolution.

Steps:

  1. Cosine weighting for cone-beam geometry

  2. Row-by-row convolution with ramp filter kernel

Arguments

  • sinogram: Sinogram [n_cols, n_rows, n_angles] (modified in place)

  • geom: CTGeometry with scanner parameters

  • filter: Filter type (RampFilter, SheppLoganFilter, etc.)

  • cutoff: Frequency cutoff (0-1) - controls kernel truncation

  • bandlimit: fraction of the ray sampling's Nyquist the reconstruction grid can carry (grid_bandlimit); the kernel's window is stretched to it and zero above. Default 1.

Returns

The filtered sinogram (modified in place)

Example

filter_sinogram!(sinogram, geom; filter=SheppLoganFilter(), cutoff=0.8)
filter_sinogramfunctionsource ↗
filter_sinogram(sinogram, geom; filter=StandardFilter(), cutoff=1.0)

Allocating version of FDK filtering.

Arguments

  • sinogram: Sinogram [n_cols, n_rows, n_angles]

  • geom: CTGeometry with scanner parameters

  • filter: Filter type (RampFilter, SheppLoganFilter, etc.)

  • cutoff: Frequency cutoff as fraction of Nyquist (0-1)

Returns

New filtered sinogram

Example

filtered = filter_sinogram(sinogram, geom; filter=StandardFilter())

src/reconstruction/fbp/fdk.jl

fdk_reconstructfunction
fdk_reconstruct(sinogram, geom, volume_size; filter=StandardFilter(), cutoff=1.0)

Perform FDK (Feldkamp-Davis-Kress) cone-beam CT reconstruction from a sinogram.

This is the primary reconstruction function for circular-orbit cone-beam CT. It implements the complete FDK algorithm: cosine weighting, ramp filtering, and weighted backprojection, all executed on GPU via AcceleratedKernels.jl.

Algorithm

The FDK algorithm reconstructs a 3D volume f(x,y,z) from cone-beam projections p(θ,u,v) in three steps (Feldkamp et al., 1984):

Step 1: Cosine Weighting

Pre-weight projections to correct for oblique ray geometry:

p̃(θ, u, v) = p(θ, u, v) × D / √(D² + u² + v²)

where D is the source-to-detector distance. This weight equals cos(γ) where γ is the angle between the ray and the central ray.

Step 2: Ramp Filtering

Apply 1D convolution with ramp (Ram-Lak) filter along each detector row:

p̂(θ, u, v) = p̃(θ, u, v) * h(u)

The discrete ramp filter kernel h[n] (Kak & Slaney, 1988):

  • h[0] = 1/(4Δ²)

  • h[n] = 0 for even n ≠ 0

  • h[n] = -1/(π²n²Δ²) for odd n

where Δ is the detector pixel spacing.

Step 3: Weighted Backprojection

For each voxel (x, y, z), integrate weighted filtered projections:

f(x, y, z) = (π/N) Σθ [D² / (D - s)²] × p̂(θ, u*, v*)

where:

  • (u, v) are detector coordinates of the ray through voxel (x, y, z)

  • s = x·sin(θ) - y·cos(θ) is signed distance to source plane

  • D²/(D-s)² is the FDK distance weight

  • N is the number of projection angles

Arguments

  • sinogram::AbstractArray{T,3}: Input sinogram of size [n_cols, n_rows, n_angles]. Contains line integrals (dimensionless, typically from forward projection).

    • n_cols: Number of detector columns (transaxial/fan direction)

    • n_rows: Number of detector rows (axial/cone direction)

    • n_angles: Number of projection angles

  • geom::CTGeometry: CT scanner geometry containing:

    • SAD::T: Source-to-axis (isocenter) distance in mm

    • SDD::T: Source-to-detector distance in mm

    • fov::Tuple{T,T,T}: Field of view (x, y, z) in mm

    • pixel_size::T: Detector pixel size at isocenter in mm

    • angles::Vector{T}: Projection angles in radians

    • source_positions::Matrix{T}: Source positions [3, n_angles] in mm

    • detector_centers::Matrix{T}: Detector centers [3, n_angles] in mm

    • detector_u::Matrix{T}: Detector u-direction vectors [3, n_angles]

    • detector_v::Matrix{T}: Detector v-direction vectors [3, n_angles]

  • volume_size::NTuple{3,Int}: Output volume dimensions (nx, ny, nz). Voxel size is computed as FOV / volume_size.

Keyword Arguments

  • filter::FilterType = StandardFilter(): Reconstruction filter type. Options:

    • StandardFilter(): CatSim-matched apodized ramp (clinical default, balanced noise/resolution)

    • SoftFilter(): Stronger smoothing than Standard (lower noise, softer edges)

    • BoneFilter(): Boosted mid-frequencies (sharper bone edges, higher noise)

    • RampFilter(): Pure Ram-Lak (sharpest, highest noise)

    • SheppLoganFilter(): Ramp × sinc (moderate smoothing)

    • CosineFilter(): Ramp × cos² (smooth)

    • HammingFilter(): Ramp × Hamming window (low noise)

    • HannFilter(): Ramp × Hann window (lowest noise, softest)

  • cutoff::Float64 = 1.0: Frequency cutoff as fraction of Nyquist frequency. Range: 0.0 to 1.0. Lower values reduce noise but also spatial resolution. Typical values: 0.8-1.0 for diagnostic imaging, 0.5-0.7 for dose reduction.

Returns

  • volume::AbstractArray{T,3}: Reconstructed volume of size [nx, ny, nz]. Contains linear attenuation coefficients μ (mm⁻¹). Convert to HU: HU = 1000 × (μ - μ_water) / μ_water

    The volume is allocated on the same device as the input sinogram (CPU Array, Metal MtlArray, CUDA CuArray, etc.).

GPU Compatibility

Automatically selects compute backend based on array type:

Array TypeBackendTypical Speedup
ArrayCPU (multi-threaded)1× (baseline)
MtlArrayMetal (Apple Silicon)20-50×
CuArrayCUDA (NVIDIA)50-100×
ROCArrayROCm (AMD)50-100×

No code changes needed - just pass appropriate array types.

Example

using BasisSimulator
using Metal  # or: using CUDA

# Create scanner and geometry
scanner = GERevolutionApex()
geom = CTGeometry(scanner; n_angles=360, fov=(350.0, 350.0, 160.0))

# Create a water cylinder phantom
phantom = zeros(Float32, 256, 256, 64)
μ_water = 0.02f0  # mm⁻¹ at ~60 keV
for i in 1:256, j in 1:256, k in 1:64
    x, y = (i - 128.5) * 1.37, (j - 128.5) * 1.37  # mm from center
    if x^2 + y^2 < 100^2  # 100mm radius cylinder
        phantom[i, j, k] = μ_water
    end
end

# Forward projection on GPU
phantom_gpu = MtlArray(phantom)
sinogram_gpu = siddon_forward_project(phantom_gpu, geom)

# FDK reconstruction
recon_gpu = fdk_reconstruct(sinogram_gpu, geom, (256, 256, 64))

# Convert to Hounsfield Units
recon_cpu = Array(recon_gpu)
recon_hu = 1000f0 .* (recon_cpu .- μ_water) ./ μ_water

# Water region should have HU ≈ 0
water_mask = phantom .> 0
println("Mean HU in water: ", mean(recon_hu[water_mask]))  # Should be ≈ 0 ± 20

Filter Selection Guide

FilterNoiseResolutionUse Case
RampFilterHighestBestHigh-contrast imaging
SheppLoganFilterHighGoodGeneral purpose
CosineFilterMediumMediumSoft tissue
HammingFilterLowReducedLow-dose protocols
HannFilterLowestMost reducedPediatric/dose-critical

Cone-Beam Artifacts

FDK is an approximate algorithm for circular orbits. Be aware of:

  • Cone-beam artifacts: Shading/streaks increase with cone angle

  • Intensity drop-off: Objects near z-FOV edges appear darker

  • Practical limit: Cone angle < 10° for diagnostic quality

For wider cone angles or higher accuracy, consider:

Performance Notes

  • Filtering: O(n_cols × n_rows × n_angles × kernel_size)

  • Backprojection: O(nx × ny × nz × n_angles)

  • Memory: Allocates filtered sinogram + output volume

  • Typical 256³ reconstruction: ~1-2 seconds on GPU, ~30-60 seconds on CPU

For repeated reconstructions with same geometry, consider pre-computing the filter kernel with lower-level functions in Filtering.jl.

References

  1. Feldkamp LA, Davis LC, Kress JW. "Practical cone-beam algorithm." J Opt Soc Am A. 1984;1(6):612-619. doi:10.1364/JOSAA.1.000612

  2. Kak AC, Slaney M. "Principles of Computerized Tomographic Imaging." IEEE Press, 1988. Chapter 3. Available: http://www.slaney.org/pct/

  3. Biguri A, et al. "TIGRE: A MATLAB-GPU toolbox for CBCT image reconstruction." Biomed Phys Eng Express. 2016;2(5):055010. doi:10.1088/2057-1976/2/5/055010

  4. Hsieh J. "Computed Tomography: Principles, Design, Artifacts, and Recent Advances." 3rd ed. SPIE Press, 2015. Chapter 3. ISBN: 978-1628418255

See Also


fdk_reconstruct(sinogram, geom, volume_size, fov; filter=StandardFilter(), cutoff=1.0)

FDK reconstruction with explicit field-of-view (FOV) specification.

This variant allows reconstructing to a different FOV than specified in the scanner geometry. Useful for zoomed reconstructions, targeted ROI imaging, or when the reconstruction grid should cover a different region than the original acquisition geometry.

Arguments

  • sinogram::AbstractArray{T,3}: Input sinogram of size [n_cols, n_rows, n_angles]

  • geom::CTGeometry: CT scanner geometry (see fdk_reconstruct)

  • volume_size::NTuple{3,Int}: Output volume dimensions (nx, ny, nz)

  • fov::NTuple{3,Float64}: Reconstruction field of view in mm as (fx, fy, fz). The reconstructed volume will cover:

    • x: [-fx/2, +fx/2] mm

    • y: [-fy/2, +fy/2] mm

    • z: [-fz/2, +fz/2] mm

    Voxel size = FOV / volume_size.

Keyword Arguments

  • filter::FilterType = StandardFilter(): Reconstruction filter type

  • cutoff::Float64 = 1.0: Frequency cutoff (0-1)

Returns

  • volume::AbstractArray{T,3}: Reconstructed volume of size [nx, ny, nz]

Example

using BasisSimulator

# Standard reconstruction at full FOV
recon_full = fdk_reconstruct(sinogram, geom, (256, 256, 64))

# Zoomed reconstruction: smaller FOV, same voxel count = higher resolution
# This focuses on the central 150mm × 150mm × 80mm region
recon_zoom = fdk_reconstruct(sinogram, geom, (256, 256, 64), (150.0, 150.0, 80.0))

# Reduced FOV for faster reconstruction (coarser voxels)
recon_quick = fdk_reconstruct(sinogram, geom, (128, 128, 32), (350.0, 350.0, 160.0))

Use Cases

  • Zoomed reconstruction: Smaller FOV + same voxels = finer detail in ROI

  • Extended FOV: Larger FOV for peripheral structures

  • Asymmetric FOV: Different z-coverage than transaxial

  • Dose studies: Match FOV exactly to dose measurement region

Notes

  • The FOV is centered at the isocenter (0, 0, 0)

  • Voxels outside the original acquisition cone will have artifacts

  • Zooming beyond the acquired data doesn't improve true resolution

See Also

apply_fov_mask!functionsource ↗
apply_fov_mask!(volume, geom; sentinel_μ=-0.04)

Mask voxels outside the circular reconstruction FOV to a sentinel value.

Clinical CT scanners set voxels outside the reconstruction circle to a fixed value (typically -1024 or -2048 HU equivalent) to suppress FDK edge artifacts. The default sentinel_μ = -0.04 cm⁻¹ gives approximately -2048 HU for typical μ_water ≈ 0.02 cm⁻¹.

Arguments

  • volume: 3D reconstruction volume (nx, ny, nz), modified in-place

  • geom: CT geometry (provides FOV for circle radius)

Keyword Arguments

  • sentinel_μ: Raw μ value to assign outside FOV (default: -0.04 cm⁻¹)

src/reconstruction/hybrid_ir/hybrid_ir.jl

HIRParamsstructsource ↗
HIRParams

Parameters for OS-PWLS HIR reconstruction at a given strength. Tuned via sensitivity analysis to land in the SAFIRE / ASIR-V / iDose-4 / AIDR 3D noise-reduction band reported in Ghetti PMC5714520 + Geyer 2015.

Fields

  • strength: Strength percentage, 0–100 in steps of 10. Higher → more regularization → more noise reduction. 0 is pure FBP.

  • lambda: Regularization strength (Huber prior weight).

  • nepochs: Number of OS epochs. One epoch with n_subsets subsets ≈ n_subsets full-data iterations of convergence (Erdoğan & Fessler 1999). 0 at strength = 0, which skips the PWLS loop entirely.

  • n_subsets: Number of ordered subsets — fixed at 12 for all strengths.

  • huber_delta: Huber penalty edge threshold δ. Below δ the penalty is quadratic (smoothing); above δ it's linear (edge-preserving).

  • relaxation: SIRT-style relaxation parameter on the data-fit update, chosen < 1.0 for stability at higher lambda.

  • target_noise_reduction: Expected noise reduction band (min%, max%).

get_hir_paramsfunctionsource ↗
get_hir_params(strength::Integer) -> HIRParams

Parameter lookup for a strength percentage: 0 (pure FBP) … 100 (maximum smoothing), in steps of 10.

strength is the single dial for the whole HIR pipeline — it is the only knob create_hir_recon_workspace needs, and it moves λ, the Huber edge threshold, the relaxation, and the epoch count together along one tuned trajectory.

Strength → clinical use

StrengthNoise red.Use case
0 %0 %pure FBP (no PWLS refinement)
20 %8–15 %preserve FBP texture (lung nodules)
40 %15–25 %light smoothing
60 %25–35 %standard clinical (recommended)
80 %30–42 %strong smoothing
100 %35–50 %maximum noise reduction

The odd decades (10, 30, 50, 70, 90) are linearly interpolated between the neighbouring anchors; nepochs rounds to the nearest whole epoch (never below 1 once strength > 0).

Vendor-equivalent target bands (clinical-validation sources, NOT

algorithm sources — see file header)

  • GE ASIR-V — strength IS the ASIR-V percentage.

  • Siemens SAFIRE — strength 20/40/60/80/100 = SAFIRE S1-S5.

  • Philips iDose-4 — strength 20/40/60/80/100 ≈ iDose-4 levels 1-5.

  • Canon AIDR 3D — strength 40/60/80 ≈ Mild / Standard / Strong.

src/reconstruction/ir/utils.jl

compute_huber_gradient!functionsource ↗
compute_huber_gradient!(grad, x, delta)

Compute gradient of the Huber edge-preserving roughness penalty in-place, on a 6-connected (face-neighbor) finite-difference stencil.

Reference

Direct GPU port of Fessler MIRT's potential-derivative + finite-difference combination:

  • penalty/huber_dpot.m — g(t,δ) = t for |t| ≤ δ, else δ·sign(t) (Huber 1964).

  • penalty/Cdiffs.m — stacked first-difference operator with face-neighbor offsets — what we apply by hand here in 6-connected form.

For the surrounding OS-PWLS hot path that consumes this gradient see reconstruct!(::HIRReconWorkspace, …) in src/api/driver.jl.

compute_projection_weightsfunctionsource ↗
compute_projection_weights(geom, volume_size, T; projector, like) -> sinogram-shaped weights

Compute W = 1 / (A · 1). Normalizes for ray length differences.

like is an optional prototype array selecting the backend the projection runs on (defaults to the CPU). Pass the caller's sinogram to keep this on the GPU.

create_ordered_subsetsfunctionsource ↗
create_ordered_subsets(n_angles, n_subsets) -> Vector{Vector{Int}}

Distribute angles across subsets for maximum angular separation.

VMI & spectral decomposition

Virtual monoenergetic imaging from any set of spectral channels (dual-kVp, dual-source or photon-counting bins). vmi_pipeline is the whole chain: an optional SpectralHYPR denoiser, channel merging, K-channel (:nchannel) or Cong (:cong) projection-domain decomposition, basis reconstruction, and synthesis of the VMI stack. Each stage is also public on its own.

src/reconstruction/vmi/nchannel.jl

vmi_pipelinefunctionsource ↗
vmi_pipeline(; channels, basis, geom, to_backend = identity, kwargs...)

Measured channels to virtual monoenergetic images:

channels ─ hypr_lr ─┬─ merge_channels ─┬─ reduce_detector_rows ─┬─ decompose_nchannel ─┐
                    └──────────────────┘                        └─ decompose_cong ─────┤
    └─ tlbf_denoise ─┬─ reconstruct_basis_slice ─ apply_acnr_kalender! ────────────────┬─ synthesize_vmi_stack
                     └─ spectral_pair ─ acnr_complement! ─ image_hypr ─────────────────┘

Every stage's settings are keywords, so the function makes no decision the caller cannot see and override, which is what makes it usable as the inner call of an ablation sweep.

Required: channels (vector of K corrected log-transmission sinograms), basis (spectral_basis), geom (the acquisition geometry), matrix_size.

Stages, all optional:

  • denoiser = nothing or a SpectralHYPR — generalized HYPR-LR: its projection-domain instance (hypr_lr) on the counts of each detector row before anything else, with the dispersion measured from the acquisition's air rays by default, and its image-domain instance (image_hypr) on the reconstructed pair, after ACNR. Either can be nothing.

  • method = :nchannel or :cong; controls = NChannelControls().

  • merge_groups = nothing — channel groups summed before decomposing, e.g. [1:2, 3:4].

  • reduce_rows = false, rows = : — sum detector rows in counts, for a z-invariant object whose slices are all the same. Without it every row is kept and the volume is reconstructed slice by slice onto matrix_size.

  • use_tlbf = false, tlbf_alpha1, tlbf_alpha2, tlbf_radius — photon-counting only; filters each detector row in its own (column, view) plane.

  • use_acnr = true, acnr_passes = 4, acnr_beta_max = 20, acnr_hp_sigma_px = 1.5, acnr_window = 4. On a pair reconstructed by spectral_pair — a PairFilter, or the image-domain instance — ACNR acts on the complement only (acnr_complement!), before the image-domain instance; on a plain FDK pair it is basis-vmi's apply_acnr_kalender!.

  • matrix_size (required) — the reconstruction grid, (nx, ny, nz); this function never sees a workspace's ReconOptions, so the caller states the grid. One slice when the rows were reduced. fbp_filter = SoftFilter() — one window, or a PairFilter, a window for the composite and one for its complement; pair_basis = nothing, or (Estar = …, β = …) to fix the pair spectral_pair otherwise measures (a noise-free acquisition, reconstructed as its noisy counterpart); composite_energy = nothing, or the energy of the composite ACNR and the image-domain instance act on, fixed for a scanner and protocol (spectral_pair); antialias = true, recon_rows = geom.n_rows.

  • recon_method = :fbp (the published chain) or :hir, with hir_strength = 60, recon_projector = :dd_fast and hir_reference_kev = 70. :hir reconstructs the basis pair with the penalized iterative reconstructor and nothing else changes: T-LBF, ACNR and the synthesis run as before. Each material is reconstructed in μ-equivalent units at the reference energy, weighted by its own inverse variance from the K-channel Fisher information (uniformly for :cong, which has none), through a one-row geometry when the rows were reduced — see reconstruct_basis_slice for why each of those is needed. Run once each way for the analytic and iterative stacks of one acquisition.

  • vmi_energies = (40, 70, 100, 140).

  • keep_sinograms adds the decomposed pair; keep_diagnostics adds the estimator's per-ray maps (Fisher information, quality flags) for :nchannel.

The published photon-counting configuration of basis-vmi is vmi_pipeline(; channels, basis, geom, to_backend, reduce_rows = true, use_tlbf = true). The generic chain of basis-spectral-denoising, the same for photon-counting, rapid kVp-switching and dual-source acquisitions and with every detector row kept, is vmi_pipeline(; channels, basis, geom, to_backend, matrix_size, denoiser = SpectralHYPR(), fbp\_filter = PairFilter(composite, complement)).

Returns (vmis, energies, images = (water, iodine), quality, elapsed_s, settings), with vmis (nx, ny, nz, n_energies) in HU.

spectral_basisfunction
spectral_basis(; energies, response, I0, tolerance = 5e-5) -> NamedTuple

Assemble the estimator's spectral input from an ABSOLUTE per-energy response.

  • energies: length nE grid, keV.

  • response: (nc, nr, nE, K); nc or nr may be 1, meaning one response serves every ray.

  • I0: (nc, nr, K) absolute air counts, matching the ray axes of response.

  • tolerance: the largest relative disagreement allowed between sum(response; dims = 3) and I0. The likelihood needs absolute responses, not independently normalised spectra, so a disagreement is an error rather than something to renormalise away.

Returns (E, Φ, μρ_I, μρ_W, I0, μI_eff, μW_eff, normal_II, normal_IW, normal_WW, I0_relerr, n\_channels, ray\_resolved). The effective-energy quantities initialise the solver only.


spectral_basis(ws::PCCTWorkspace; I0 = ws.I0, tolerance = 5e-5)

Basis straight from a photon-counting workspace: the response the simulation applied (ws.W_matrix_gpu on ws.energies, through the per-ray source transmission ws.bowtie_spectral when the scanner has a bowtie) and the air response of every ray. Because this is the model that generated the data, the decomposition inverts exactly what was simulated and needs no calibration scan.

src/reconstruction/vmi/basis.jl

p_photoelectricfunctionsource ↗
p_photoelectric(E_keV)

Photoelectric cross-section per electron at energy E_keV (Cong Eq 3c).

q_comptonfunctionsource ↗
q_compton(E_keV)

Klein-Nishina Compton cross-section per electron at energy E_keV (Cong Eq 3d; f_kn from Eq 3e).

src/reconstruction/vmi/cong.jl

CongWorkspacestructsource ↗
CongWorkspace{T, AŴ_L, AŴ_H, A1}

Pre-staged spectral basis on the sinogram's backend. Cong is per-pixel parallel and writes directly to the user's outputs, so the workspace has no sino-shape scratch — only the six basis tables.

Type parameters: • T — element type (Float32 enforced). • AŴ_L — type of staged ŵ_L (1D Vector{Float32} for centered spectrum, 3D [n_col,n_row,n_E] for per-ray bowtie). • AŴ_H — same for ŵ_H (must share dimensionality with AŴ_L). • A1 — type of staged p_L/q_L/p_H/q_H (always 1D Vector).

Construct with create_cong_workspace.

create_cong_workspacefunctionsource ↗
create_cong_workspace(sino_template, basis) -> CongWorkspace

Stage the Cong basis (ŵ_L, p_L, q_L, ŵ_H, p_H, q_H) onto the same backend as sino_template.

Cong is per-pixel parallel with no per-tile scratch, so this workspace's sole purpose is amortizing the basis-staging cost across repeated apply_cong! calls (e.g., hyperparameter sweeps, multiple scans sharing the same basis).

Arguments

  • sino_template : witness for the target backend.

  • basis : NamedTuple with ŵ_L, p_L, q_L, ŵ_H, p_H, q_H, all Float32 — built by your basis-construction cell (see notebook 06 / 07 for the canonical setup).

ŵ_L / ŵ_H may be 1D [n_E] (centered spectrum) or 3D [n_col, n_row, n_E] (per-ray bowtie). Both must share dimensionality.

apply_cong!functionsource ↗
apply_cong!(ws, sino_y, sino_c, sino_low, sino_high;
            water_basis,
            newton_max_iter = 12,
            newton_tol      = eps(Float32),
            y_max_factor    = 0.99,
            y_max_cap       = 1f7) -> (sino_y, sino_c)

Per-ray Cong 2022 decomposition, writing results into sino_y and sino_c. All four sino arrays share shape (nx, nv, nr) and Float32.

Arguments

  • ws : CongWorkspace from create_cong_workspace(...). REQUIRED — no internal allocation path.

  • sino_y, sino_c : output photoelectric and Compton line integrals, mutated in place.

  • sino_low, sino_high : measured low- / high-kVp sinograms (log line integrals).

Keyword arguments

  • water_basis : Float32 NamedTuple (a, c), water's coefficients in the (p, q) basis the workspace was built with — (a = 0f0, c = 1f0) for the material basis (p = iodine, q = water) that decompose_cong passes.

  • newton_max_iter : inner Newton iterations on the Eq 8 quintic.

  • newton_tol : |Δ| break tolerance for the Newton inner loop.

  • y_max_factor : safety factor on the outer Brent upper bound y_max = factor · p_L_meas / min(p_L).

  • y_max_cap : hard ceiling on y_max.

src/reconstruction/vmi/image_domain_decomp.jl

synth_vmi_2basis!functionsource ↗
synth_vmi_2basis!(HU_E, c_water, c_iodine; energy_keV,
                  water_material  = XA.Materials.water,
                  iodine_material = XA.Elements.Iodine)
    -> HU_E

Per-energy VMI synthesis from a two-basis (water + iodine) image-domain decomposition, written into HU_E:

μ(E)  = c_water · μρ_water(E) + c_iodine · 1e-3 · μρ_iodine(E)
HU(E) = 1000·(μ(E) − μρ_water(E)) / μρ_water(E)
      = 1000·(c_water − 1) + c_iodine · α(E),    α(E) = μρ_iodine(E) / μρ_water(E)

μρ are the mass attenuation coefficients of water_material and iodine_material at energy_keV. c_water is in g/mL (≈ 1 for water) and c_iodine in mg/mL. HU_E, c_water and c_iodine are 3-D Float32 arrays of the same size (an error is thrown otherwise); any array backend works.

synth_vmi_2basisfunctionsource ↗
synth_vmi_2basis(c_water, c_iodine; energy_keV,
                 water_material = XA.Materials.water,
                 iodine_material = XA.Elements.Iodine) -> AbstractArray{Float32, 3}

Allocating form of synth_vmi_2basis!: a new Float32 volume like c_water (same size and array backend) with the virtual monoenergetic HU at energy_keV, HU(E) = 1000·(c_water − 1) + c_iodine · μρ_iodine(E)/μρ_water(E), c_water in g/mL and c_iodine in mg/mL (3-D Float32 arrays of the same size).

src/reconstruction/vmi/mono_plus.jl

MonoPlusWorkspacemutable structsource ↗
MonoPlusWorkspace{T, A3}

Pre-allocated scratch + output volumes for apply_mono_plus!.

Layout: • 3 vol-shape Float32 scratch: lp_buf, lp_opt, hp_opt. • out_vols: Vector{Array{Float32, 3}} of length n_energies, each shape matching the input volumes — reused across calls. • kernel_cache: lazy Dict{Float64, Matrix{Float64}} keyed by σ.

Construct with create_mono_plus_workspace.

create_mono_plus_workspacefunctionsource ↗
create_mono_plus_workspace(vol_template; n_energies, mem_budget_GB = nothing)
    -> MonoPlusWorkspace

Allocate the Mono+ workspace on the same backend as vol_template.

Arguments

  • vol_template : an AbstractArray{<:AbstractFloat, 3} whose shape + backend the workspace should match (one example VMI volume).

Keyword arguments

  • n_energies : number of target energies — sizes out_vols.

  • mem_budget_GB : explicit budget override. nothing = auto-probe via Sys.free_memory() · 0.6. Even though Mono+ runs on CPU (FFTW), the output volumes can be large enough on big inputs to OOM the host — the constructor will retry-with-shrink-on-failure.

Mono+ does not auto-tile; if even allocation at tile_size = 1 fails, the total working set (3 scratch + n_energies output volumes) just doesn't fit. Downsize the volume or reduce n_energies.

apply_mono_plus!functionsource ↗
apply_mono_plus!(ws, volumes, energies;
                 E_noise_opt = 70.0,
                 σ_lp_px     = 2.0,
                 verbose     = true) -> NamedTuple

Compute Mono+(E) for each energy in energies using Grant 2014's frequency-split rule:

Mono+(E)     = LP_σ(VMI_E) + VMI_opt − LP_σ(VMI_opt)
Mono+(E_opt) = VMI_opt   (identity at the noise-optimal reference)

Mutates ws.out_vols in place and returns a NamedTuple with energies, volumes (= references into ws.out_vols), σ_lp_px, E_noise_opt. Copy if you need to retain across subsequent calls on the same ws.

Arguments

  • ws : MonoPlusWorkspace from create_mono_plus_workspace. REQUIRED — no internal allocation path.

  • volumes : Vector{Array{Float32, 3}} aligned to energies.

  • energies : Vector of target keV values.

Keyword arguments

  • E_noise_opt : noise-optimal reference energy (must appear in energies).

  • σ_lp_px : Gaussian LP σ in pixels. Either a scalar (same σ at every energy) or a per-energy vector (length == energies). σ = 0 is a valid identity case (Mono+(E) = VMI_E exactly, no FFT).

  • verbose : log timing + identity-sanity message.

  • phantom_mask: optional Bool array same shape as the volumes. When provided, voxels where the mask is false are reverted to the raw input VMI_E after the Mono+ formula. Eliminates the bright ring at the phantom-air boundary that LP-based frequency splitting injects via HP_opt = VMI_opt − LP_σ(VMI_opt). Pass an eroded phantom mask (≥ ~3·σ pixels of erosion — see BS.erode_mask_3d) so the residual edge spike falls outside the masked region. nothing (default) → no masking, identical to legacy behavior.

apply_mono_plus_regression!functionsource ↗
apply_mono_plus_regression!(ws, volumes, energies;
                            E_noise_opt = 70.0,
                            σ_lp_px     = 1.5,
                            window      = 4,
                            beta_max    = 4.0,
                            verbose     = true,
                            phantom_mask = nothing) -> NamedTuple

Structure-preserving Mono+. Same LP/HP skeleton as [apply_mono_plus!] (Grant 2014) — the quantitative low-frequency band is kept from the target energy E, so rod HU is untouched — but the hard high-frequency swap is replaced by a per-pixel local regression of the target HF onto the low-noise reference HF, the exact analog of apply_acnr_kalender!:

Mono+_res(E) = LP_σ(VMI_E) + β(x)·HP_σ(VMI_opt)
             = VMI_E − ( hE − β(x)·hOpt )

β(x) = clamp( Σ_win hE·hOpt / Σ_win hOpt² , 0, β_max )

with hE = VMI_E − LP_σ(VMI_E), hOpt = VMI_opt − LP_σ(VMI_opt).

Why this reduces noise with zero resolution cost (by construction, not by an edge heuristic):

  • Real edge (present in both energies) → hE, hOpt strongly correlated → β ≈ the true local contrast ratio CE/Copt. β·hOpt rebuilds the target energy's TRUE edge amplitude (fixing plain Mono+'s contrast loss for fine iodine detail) but carried on the low-noise reference → denoised. Edge geometry = hOpt's geometry exactly (β only scales amplitude) → no blur, no misregistration.

  • Flat / noise-only region → the two energies' HF are independent noise realizations → uncorrelated → β ≈ 0 → HF ≈ 0 → HF noise removed (strictly better than plain Mono+, which injects hOpt's residual HF noise everywhere).

  • E == E_opt → hE ≡ hOpt → β ≡ 1 → output = VMI_opt identically (Grant's reference-identity property retained).

β_max is additionally variance-limited to the global HF-energy ratio λ = √(Σ hE² / Σ hOpt²) (Kalender's original bound), so a noisy local β̂ can never over-amplify the reference HF.

Keyword arguments

  • E_noise_opt : noise-optimal reference energy (must appear in energies).

  • σ_lp_px : Gaussian LP σ (px) for the HF split. Scalar or per-energy vector. σ = 0 ⇒ identity at that energy (Mono+res(E) = VMI\E).

  • window : half-width of the (2w+1)² regression window (px). Sizes only the ESTIMATE of β — the correction stays a per-pixel linear combination, so no signal smoothing occurs regardless of window.

  • beta_max : hard ceiling on the HF amplification β (further capped by the per-energy global HF ratio λ).

  • phantom_mask: optional eroded Bool mask; voxels where false revert to the raw VMI_E (kills the LP boundary ring — same semantics as apply_mono_plus!).

Mutates ws.out_vols in place; copy the returned volumes to retain them across subsequent calls on the same ws. Returns a NamedTuple with energies, volumes, σ_lp_px, E_noise_opt, and per-energy diagnostics β_mean / β_frac_active (fraction of pixels with β > 0).

apply_mono_plus_structure!functionsource ↗
apply_mono_plus_structure!(ws, volumes, energies;
                           E_noise_opt = 70.0,
                           σ_lp_px     = 6.0,
                           window      = 5,
                           beta_max    = 6.0,
                           verbose     = true,
                           phantom_mask = nothing) -> NamedTuple

The powerful resolution-preserving Mono+. Same Grant-2014 frequency-split skeleton and the same per-pixel contrast-matched transplant as [apply_mono_plus_regression!], but run in the regime that actually denoises:

Mono+(E) = LP_σ(VMI_E) + β(x)·HP_σ(VMI_opt),   HP_σ = I − LP_σ

Two things make it much stronger than either prior variant:

  1. WIDE transplant band (low cutoff / large σ). Post-FBP CT noise is high-frequency-weighted (ramp filter), so most of the noise variance lives above even a low cutoff. The generic Gaussian Mono+ and the narrow-σ regression Mono+ used a HIGH cutoff (σ≈1.5) → they only transplanted the top sliver of the spectrum → ~all the noise stayed in the LP band (impotent, esp. at low keV where σVMI is dominated by the broadband α(E)²σ²I term). Here σ is LARGE: the LP band keeps only the coarse quantitative base (per-region HU ⇒ rod accuracy preserved) and the entire broadband detail is transplanted from the low-noise 70 keV anchor. In a flat region this collapses the output to a heavily-smoothed base ⇒ σ plummets (≈ 15–20× for ramp-weighted noise).

  2. Contrast-matched transplant (local β) — the anatomical-identity prior, quantified. β(x) = clamp(Σwin hE·hOpt / Σwin hOpt², 0, βmax) is the LOCAL contrast ratio: ≈0 in flat regions (⇒ pure smoothed base, noise killed), ≈ α(E)/α(Eopt) at real iodine/anatomy edges (⇒ the anchor's sharp low-noise edge, re-scaled to the TARGET energy's amplitude). This is why it does not blur: edges come from the full-bandwidth anchor at the target's own contrast. βmax is variance-limited to the global HF ratio λ = √(Σhᴇ²/Σhopt²) so a noisy β̂ cannot over-amplify.

Net: flat → smoothed base (huge low-keV denoise); edges → low-noise anchor structure at correct target contrast (full resolution). E == E_opt ⇒ β ≡ 1 ⇒ output = VMI_opt identically.

Only the default σ (6.0 vs 1.5) and window (5 vs 4) differ from apply_mono_plus_regression! in mechanism; σ_lp_px is the master dial — larger = wider transplant = more denoising (until the LP base gets too coarse to hold per-rod HU). All arguments as in apply_mono_plus_regression!.

src/reconstruction/vmi/nchannel.jl

NChannelControlsstructsource ↗
NChannelControls(; kwargs...)

Numerical controls of the K-channel estimator. The defaults are the published ones.

  • iodine_bounds = (-0.10, 0.40) g/cm². Scaled to per-ray noise, not to geometry, so negative values are admitted: clipping at zero would bias the mean of a noisy zero upward.

  • water_bounds = (-2, 50) g/cm². The ceiling must exceed the longest water-equivalent path through the object; 50 leaves about 1.5× margin for a 33 cm body.

  • outer_iterations = 16, inner_iterations = 12, bisection_iterations = 28

  • max_iodine_step = 0.05, max_water_step = 5 (per Newton step, g/cm²)

  • parameter_tolerance = 5e-5 (relative), fisher_condition_limit = 1e8

  • air_gate = 0 — rays whose every |h_k| is below this are set to zero without solving. Zero disables the gate; it is off in every published configuration.

  • tile_views = 8 — views sent to the backend per kernel launch.

spectral_basis_from_binsfunctionsource ↗
spectral_basis_from_bins(; energies, W_applied, I0, transmission = nothing, tolerance = 5e-5)

Basis for the K energy windows of one photon-counting acquisition. W_applied[e, k] is the detected response the forward model applied to the unattenuated central beam and I0[col, row, k] the air count of every ray and bin. With transmission[col, row, e] — the source transmission (bowtie × heel) the simulation applied per ray — the response is ray-resolved, W_applied[e, k]·transmission[col, row, e], which is exactly what each ray saw; without it one response serves every ray and I0 must then be constant across the fan.

spectral_basis_from_acquisitionsfunctionsource ↗
spectral_basis_from_acquisitions(; acquisitions, tolerance = 5e-5)

Basis for K separate acquisitions: rapid kVp switching, or two tubes. Each entry of acquisitions is a NamedTuple with

  • energies: that acquisition's own grid, keV;

  • response: (nc, nr, nE) relative detected spectrum per ray;

  • I0_ray: (nc, nr) absolute air counts for that acquisition.

Grids are merged onto their sorted union, so 80 and 140 kVp coexist without resampling either. Each response is normalised over energy and scaled by its own I0_ray, which is where mA, duty cycle and rotation time enter: they change how many photons an acquisition delivers, not the shape of its spectrum.

reduce_detector_rowsfunctionsource ↗
reduce_detector_rows(; channels, basis, rows = :, floor_counts = 1e-12)

Combine detector rows into one by summing counts, carrying Φ and I0 through the same sum.

For an object invariant over the active longitudinal extent the rows are repeated measurements of one in-plane ray, so Y_k = Σ_r I0_{k,r} · exp(-h_{k,r}) is again Poisson with the summed rate and the likelihood is unchanged except that Φ_k and I0_k are the summed ones. Summing transmission and re-logging gives the same answer only when I0 does not vary across rows; it does whenever a bowtie is modelled, so the sum is taken in counts unconditionally.

Returns (channels, basis, n_rows, selected_rows).

merge_channelsfunctionsource ↗
merge_channels(; channels, basis, groups, floor_counts = 1e-12)

Sum channels into groups, in counts, carrying Φ and I0 with them: counts add, transmission does not, so merged Φ and I0 are summed over the group and never averaged. groups = [1:2, 3:4] collapses four photon-counting windows into a low and a high channel, which is how a two-measurement comparator is given the same acquisition.

The groups must partition the channels, each appearing exactly once: a channel in two groups would enter the likelihood twice as though it were an independent measurement, and one in none would be discarded silently.

Returns (channels, basis, groups).

prepare_channelsfunctionsource ↗
prepare_channels(; channels, basis, merge_groups = nothing, reduce_rows = false, rows = :)

Everything the decomposition needs done to its inputs first: sum channel groups, then sum detector rows. Both reductions add counts and rebuild the response so that sum(Φ[:, k]) still equals that channel's I0. Returns (channels, basis, n_rows).

nchannel_profile_tile!functionsource ↗
nchannel_profile_tile!(sino_I, sino_W, fisher_AA, fisher_AC, fisher_CC, quality_flag,
                       score_norm, outer_count, inner_count, hs::NTuple{K},
                       Φ, μρ_I, μρ_W, I0, μI_eff, μW_eff,
                       normal_II, normal_IW, normal_WW, controls::NChannelControls)

One tile of the K-channel profile-likelihood estimator; every array lives on one backend. hs holds the K corrected log-transmission tiles (n_col, n_row, n_view_tile).

Quality flag bits: 1 iodine at a bound, 2 water at a bound, 4 not converged, 8 ill-conditioned Fisher matrix or invalid initialiser, 16 the summed counts are not attainable anywhere inside the bounds, 32 a non-finite result.

Counts may be fractional after detector corrections, so nothing here assumes integers.

decompose_nchannelfunctionsource ↗
decompose_nchannel(; channels, basis, controls = NChannelControls(), to_backend = identity,
                   tile_views = controls.tile_views, keep_diagnostics = false)

The K-channel estimator over a full sinogram. channels is a vector of K corrected log-transmission arrays (n_col, n_row, n_view); basis comes from spectral_basis or one of its builders; to_backend uploads an array to the compute backend (CuArray, MtlArray, …; the default stays on the CPU).

Views are tiled so the backend never holds a whole sinogram of workspace, and every tile's buffers are released without a full garbage collection.

Returns (sino_iodine, sino_water, quality, elapsed_s, method = :nchannel, n_channels) with both sinograms in g/cm². keep_diagnostics = true adds the per-ray maps.

cong_material_basisfunctionsource ↗
cong_material_basis(basis) -> NamedTuple

Translate a two-channel spectral_basis into the (ŵ, p, q) form apply_cong! wants. Φ carries absolute per-energy air counts; Cong wants the shape only, normalised so Σ_E ŵ = 1, because the absolute scale enters through the measured line integrals. p and q are the iodine and water mass attenuations: the same material-direct pair the K-channel estimator uses, so both invert the identical physical basis. A response with singleton ray axes collapses to a 1-D spectrum; a ray-resolved one stays 3-D, which is how a bowtie reaches the solver.

decompose_congfunctionsource ↗
decompose_cong(; channels, basis, to_backend = identity, newton_max_iter = 12,
               newton_tol = eps(Float32), y_max_factor = 0.99, y_max_cap = 1f7)

The Cong 2022 two-measurement estimator (apply_cong!) behind the same signature and return shape as decompose_nchannel, so one configuration can be sent through either. Exactly two channels are required; pass merge_groups to prepare_channels to sum a photon-counting acquisition into two.

angular_antialias_responsefunctionsource ↗
angular_antialias_response(; n_views, matrix_nx) -> Vector{Float64}

Deterministic angular response: unit gain through mode ⌈π·matrix_nx/4⌉, then a raised-cosine roll-off across the oversampling margin only. It is all ones when the acquisition does not oversample the target grid, in which case the filter is an identity and is skipped. This is not a tunable denoiser: a 1200-view acquisition oversamples what a 512-pixel grid can represent, and the roll-off discards only the angular frequencies that grid cannot carry without aliasing.

reconstruct_basis_slicefunctionsource ↗
reconstruct_basis_slice(sino, geom, matrix_size; to_backend = identity,
                        filter = SoftFilter(), n_rows = geom.n_rows, antialias = true,
                        method = :fbp, hir_strength = 60, projector = :dd_fast,
                        hir_weights = :uniform, scale = 1)

Reconstruct one sinogram, whether it carries a measured channel or an estimated basis material.

method = :fbp is FDK as basis-vmi does it: a single-row sinogram (what reduce_detector_rows leaves) is repeated to n_rows and backprojected onto the requested grid with filter.

method = :hir is the penalized iterative reconstructor at hir_strength (0-100, see get_hir_params). Three things make it sound on a basis sinogram, none of which FDK needs:

  • The weights. HIR's default data weighting is exp(-y), the Poisson heuristic for a log-transmission. A basis sinogram is g/cm², not a transmission — through a body it reads 20 to 30 for water and a fraction of one for iodine — so that weighting would switch the data term off inside the object for one material and leave it uniform for the other. hir_weights is what is passed to reconstruct! instead: :uniform, or the per-ray inverse-variance map from the decomposition's Fisher information (see vmi_pipeline).

  • The units. HIR's Huber threshold and regularisation strength were tuned on attenuation images in cm⁻¹. scale multiplies the sinogram before reconstruction and divides the image after, so with scale = μ/ρ of the material at a reference energy the reconstruction happens in μ-equivalent units and the strength dial means what its table says. FDK is linear and needs no such thing.

  • The geometry. A single-row sinogram is NOT repeated: it is reconstructed through a one-row geometry with a volume one row thick. Repeating one row to n_rows asserts that every row saw the same thing, which a one-slice forward model cannot reproduce at the outer rows; FDK ignores the contradiction, PWLS would fit it.

hir_weights and scale are ignored by :fbp.

synthesize_vmi_stackfunctionsource ↗
synthesize_vmi_stack(water, iodine, energies) -> Array{Float32,4}

Virtual monoenergetic images in HU from a reconstructed basis pair: (nx, ny, nz, n_energies), every slice of the pair at every energy. water is a density in g/mL and iodine a mass density in g/cm³; the latter is converted to mg/mL for synth_vmi_2basis.

src/reconstruction/vmi/phantom_mask.jl

erode_mask_2dfunctionsource ↗
erode_mask_2d(mask2d::AbstractMatrix{Bool}; erode_px::Real) -> BitMatrix

"Soft" erosion of a 2D Bool mask by approximately erode_px voxels. Implementation: FFT-Gaussian-blur the float-cast mask with σ = erode_px pixels, then threshold at 0.999 — interior voxels (whose neighborhood is fully inside the mask) survive; edge voxels (whose neighborhood is partially outside) get rejected.

erode_px ≤ 0 returns a copy of the input mask (no-op).

erode_mask_3dfunctionsource ↗
erode_mask_3d(mask3d::AbstractArray{Bool, 3}; erode_px::Real,
              broadcast_in_z::Bool = true) -> BitArray{3}

Per-slice 2D erosion broadcast across all z slices.

broadcast_in_z = true (default): erode the FIRST slice only and replicate (correct for z-invariant masks). Faster.

broadcast_in_z = false: erode each slice independently. Use when the mask varies in z.

src/reconstruction/vmi/roots_kernels.jl

brent_solvefunctionsource ↗
brent_solve(f, a, b; xabstol=eps(T), xreltol=eps(T), maxiters=100)

Find a root of f in the bracket [a, b] using Brent's method.

Returns (root, converged::Bool). On failure (non-finite value, or maxiters hit without nextfloat(a) == b), converged = false and root is the best of (a, b) by |f|.

Identical algorithm to Roots.find_zero(f, (a, b), Roots.Brent()) for Float64 / Float32 inputs. Tolerances default to eps(T) (matches Roots.jl default_tolerances at convergence.jl:55-56).

Caller contract

  • f(a) and f(b) must have opposite signs (bracket must be valid).

  • f must be pure (no allocations, no I/O) for GPU execution.

Denoising

Projection-domain and image-domain noise reduction. This includes generalized HYPR-LR (SpectralHYPR, hypr_lr, image_hypr), ACNR, the RSKR joint bilateral filter, T-LBF, the sinogram SVD / SF-JSD denoisers and the median-z filter.

src/denoising/hypr.jl

SpectralHYPRstructsource ↗
SpectralHYPR(; projection = ProjectionHYPR(), image = ImageHYPR())

The spectral chain's denoiser for vmi_pipeline: the projection-domain instance on the counts before the decomposition, and the image-domain instance on the basis pair after FDK. Pass nothing for either to run the other alone.

The published configuration is vmi_pipeline(; …, denoiser = SpectralHYPR()).

src/denoising/acnr.jl

apply_acnr!functionsource ↗
apply_acnr!(sino_a, sino_b;
            c_a, c_b,
            σ = nothing,
            λ = nothing,
            γ = 1.0,
            verbose = true) -> NamedTuple

In-place ACNR on a basis-sinogram pair. The signal direction is (c_a, c_b); ACNR attenuates only the orthogonal component.

Exactly one of σ (Gaussian LP) or λ (Tikhonov) must be specified.

Arguments

  • sino_a, sino_b : Float32 material sinograms (mutated in place)

Keyword arguments

  • c_a, c_b : signal-direction coefficients at the reference energy

  • σ : Gaussian LP kernel σ in pixels (if using Gaussian)

  • λ : FFT Tikhonov strength (if using Tikhonov)

  • γ : correction strength ∈ [0, 1]; γ=0 ⇒ identity

Returns a NamedTuple of diagnostics (smoother choice, γ, σ_n_orth, σ_s_orth, σ_s_smooth).

apply_acnrfunctionsource ↗
apply_acnr(sino_a, sino_b; kwargs...) -> (sino_a_corr, sino_b_corr, info)

Allocating wrapper around apply_acnr!.

apply_image_acnr!functionsource ↗
apply_image_acnr!(W, I; gamma=1.0, bilat_radius=3, bilat_sigma_s=2.0,
                  bilat_range_k=2.5) -> info

Image-domain, data-adaptive Anti-Correlated Noise Reduction for a basis-map pair — water W and iodine I (3-D volumes), modified IN PLACE. The image-domain counterpart of [apply_acnr!] (which is sinogram-domain).

Material decomposition stamps strongly anti-correlated noise on the basis maps (ρ_basis < 0); that anti-correlation is the VMI-noise "U". Rather than assuming the signal/noise axes, this LEARNS them from the joint W–I covariance via a closed-form 2×2 eigen-rotation:

  • e1 (large-variance axis = correlated structure) is kept pixel-perfect;

  • e2 (small-variance axis = the anti-correlated noise) is denoised with a joint bilateral filter guided by BOTH basis maps, so real water/iodine edges (Δ ≳ bilat_range_k·σ_noise) survive — only locally-flat anti-correlated noise is removed. Resolution is preserved two ways (pixel-perfect e1 + edge-aware e2).

Keyword arguments (all knobs)

  • gamma::Real = 1.0 : strength ∈ [0,1]; 0 ⇒ identity (no-op).

  • bilat_radius::Integer = 3 : bilateral spatial window radius (px).

  • bilat_sigma_s::Real = 2.0 : bilateral spatial Gaussian σ (px).

  • bilat_range_k::Real = 2.5 : range σ = k · per-basis noise std (edge threshold).

Returns

info::NamedTuple with θ_deg, ρ_struct, σ_W, σ_I, γ (diagnostics). W and I are updated in place.

apply_image_acnrfunctionsource ↗
apply_image_acnr(W, I; kwargs...) -> (W_out, I_out, info)

Allocating wrapper around [apply_image_acnr!].

apply_acnr_kalender!functionsource ↗
apply_acnr_kalender!(W, I; hp_sigma_px=1.5, window=2, beta_max=8.0) -> info

TRUE anti-correlated noise reduction (Kalender, Klotz & Kostaridou 1988, the original dual-energy ACNR): per-pixel LOCAL linear regression between the two basis maps' high-frequency channels.

Physics: dual-energy basis noise is ANTI-correlated (cov(n_W, n_I) < 0) while anatomical structure is positively correlated. For each pixel take hW = W − G(W), hI = I − G(I) (high-pass, σ = hp_sigma_px), estimate the local regression slope β = cov(hI, hW)/var(hW) in a (2w+1)² window (the window sizes only the ESTIMATE of β — the correction itself stays a per-pixel linear combination, so no signal smoothing occurs; larger windows reduce β̂ variance, whose noise otherwise re-introduces a small positive residual correlation that flips the VMI noise-vs-keV tail upward), and CLAMP it to [−beta_max, 0]:

  • noise-dominated pixels → β ≈ −σ_I/σ_W → I − β·hW cancels the predictable anti-correlated component EXACTLY (pixelwise subtraction, no smoothing operator ever multiplies the signal);

  • structure-dominated pixels → local cov > 0 → β clamps to 0 → the pixel is BIT-UNTOUCHED.

Resolution preservation is therefore by construction, not by an edge-stopping heuristic. Symmetric correction is applied to W from hI. Returns (ρ_hp, σ_hW, σ_hI) global HF statistics.

src/denoising/hypr.jl

HYPRProfileabstract typesource ↗
abstract type HYPRProfile

Spatial profile of a HYPR-LR window: the weight a neighbour receives as a function of its distance from the centre, as a fraction x ∈ [0, 1] of one step past the window's half-width (so the outermost neighbours keep a nonzero weight). Separable along each window axis.

CustomProfilestructsource ↗
CustomProfile(control_x, control_y)

Piecewise-linear window profile through control points, exactly as CustomFilter specifies an FBP apodization over frequency: control_x runs from 0 (the centre) to 1 (one step past the window's edge), control_y is the weight there.

Example

p = CustomProfile((0.0, 0.5, 1.0), (1.0, 0.8, 0.2))
k = HYPRKernel((5, 5), p)
HYPRKernelstructsource ↗
HYPRKernel(window, profile = BoxProfile(); guided = true, linear = true)

A generalized HYPR-LR kernel: the window extent in odd widths — (columns, views) within a detector row in the projection domain, (x, y, 1) within a slice in the image domain; its spatial profile (HYPRProfile), separable along each axis; whether each weight is also multiplied by the likelihood that the neighbour's composite shares the centre's (guided); and whether the complement is fitted locally linear (linear) or locally constant — HYPR-LR's pooled ratio. The local linear fit is available in the projection domain; image-domain kernels are locally constant.

HYPRKernel((7, 7); guided = false, linear = false) is Leng's HYPR-LR in the counts.

ProjectionHYPRstructsource ↗
ProjectionHYPR(; kernel = HYPRKernel((3, 3)), dispersion = :measured, view_stride = 2)

The projection-domain instance: count-domain generalized HYPR-LR within each detector row (hypr_lr). dispersion is :measured — read off the acquisition's own air rays by estimate_dispersion — or a vector of K variance-to-mean ratios. view_stride = 2 pools views of one parity (the kernel's view neighbours are two views apart), so that the odd and even halves of the acquisition stay independent: the image-domain instance measures the noise of the chain from their difference, which pooling adjacent views would correlate and understate.

ImageHYPRstructsource ↗
ImageHYPR(; candidates = (3, 5, 7, 11, 15, 21, 31, 41), noise_window = 15)

The image-domain instance on the reconstructed basis pair (image_hypr). The composite, the minimum-noise VMI, is kept as reconstructed; the complement is pooled within its slice — adjacent slices are never averaged, which on an object that does not change along z would be a thicker slice — over the width among candidates with the least estimated full-data risk. noise_window is the width of the neighbourhood over which the composite's local noise, the scale of every weight, is measured from the half-view difference (225 samples at 15: about 5 % precision).

hypr_lrfunctionsource ↗
hypr_lr(channels, I0; kernel = HYPRKernel((3, 3)), dispersion = ones(K), view_stride = 1,
        to_backend = identity) -> Vector{Array{Float32,3}}

Count-domain generalized HYPR-LR within each detector row: each ray keeps its own total count, and its split across the K channels is the kernel's pooled split of its neighbourhood (pooled_split), ŷ_ik = T_i p̂_ik. Returns the K denoised log-transmission sinograms. Rows are never mixed.

  • channels: K corrected log-transmission sinograms (n_col, n_row, n_view).

  • I0: (nc, nr, K) air counts; nc, nr may be 1 when every ray shares one value.

  • dispersion: each channel's variance-to-mean ratio (estimate_dispersion); 1 for photon counting.

estimate_dispersionfunctionsource ↗
estimate_dispersion(channels, I0; max_attenuation = 0.05) -> Vector{Float64}

D_k, the variance-to-mean ratio of each channel's photon-equivalent counts, read off the acquisition's air rays: the (column, row) positions whose attenuation, averaged over the rotation, is below max_attenuation (95 % transmission; a centimetre of water attenuates by 0.2), which miss the object in every view. At such a ray the expected signal is the same, or changes only slowly, from view to view, while its noise is independent from view to view, so half the mean square difference of successive views is the noise variance, free of the ray's residual attenuation (the air of a phantom's volume, a bowtie edge) and of any structure that changes slowly with the view. Each air ray gives one estimate; D_k is their median, which a few rays grazing the object cannot move. 1.0, with a warning, when the acquisition has no air ray; a photon-counting acquisition should return values near one.

guided_poolfunctionsource ↗
guided_pool(X, G, σ, kernel; to_backend = identity) -> Array{Float32,3}

The local estimate of X at every voxel over kernel's in-plane window (x, y, 1), within the voxel's slice: each neighbour weighted by the kernel's spatial profile and, if kernel.guided, by the Gaussian likelihood that its guide value equals the voxel's, exp(-(G_i - G_j)² / 2(σ_i² + σ_j²)), with σ the guide's noise, a map the size of X. Slices are never pooled: on an object that does not change along z that would be a thicker slice.

local_noisefunctionsource ↗
local_noise(d, width) -> Array{Float64,3}

The local noise of an image from its half-view difference d (half the odd-view minus the even-view reconstruction, whose noise is the full reconstruction's): the root mean square of d over the width × width neighbourhood of every voxel, within its slice. FDK noise is not stationary — it is highest where the rays are most attenuated — so the weights of the image-domain instance take it voxel by voxel; one value per slice would make the noisiest voxels look like structure to the weights, and leave them unpooled.

PairFilterstructsource ↗
PairFilter(composite, complement)
PairFilter(; composite, complement)

The FDK windows of a spectral reconstruction, on the pair of images whose noise is uncorrelated: the composite M, the minimum-noise VMI, and its complement I⊥ (spectral_pair). The water and iodine images are the wrong pair to filter differently: their noise is strongly anti-correlated, a VMI near the minimum-noise energy is quiet because that noise cancels, and it cancels only at the frequencies where both were filtered alike — two windows there leave a shelf of uncancelled noise in every VMI. On the uncorrelated pair the windows may differ freely: the composite's sets the resolution and noise texture at E*, the complement's how the resolution changes away from it, and every VMI of the acquisition comes from the same two reconstructions.

spectral_pairfunctionsource ↗
spectral_pair(sino_water, sino_iodine, geom, matrix_size; filter = SoftFilter(),
              basis = nothing, composite_energy = nothing, antialias = true,
              n_rows = geom.n_rows, to_backend = identity, energies = 40:140) -> NamedTuple

FDK of a decomposed basis pair (g/cm², as vmi_pipeline returns them) and of its odd- and even-view halves, whose difference measures the noise of everything downstream:

  • the pair's noise covariance Σ from half the halves' difference inside the reconstruction circle, both basis images reconstructed with the composite's window;

  • the minimum-noise energy E* = argmin gᵀΣg over energies, g = (μ_I, μ_W) / μ_W, f = (μ_I(E*), μ_W(E*)), the composite M = f₁ a + f₂ c and the complement I⊥ = a − βM, β = (Σf)₁ / fᵀΣf, the iodine component whose noise is uncorrelated with M;

  • with a PairFilter, M reconstructed from its own sinogram f₁ p_a + f₂ p_c with the composite's window and I⊥ from p_a − β(f₁ p_a + f₂ p_c) with the complement's, and the pair recovered, a = I⊥ + βM, c = (M − f₁ a) / f₂; with one window, FDK of each basis image;

  • then the minimum-noise composite of the reconstructed pair itself, E*, f and β from the noise of its halves: what ACNR and the image-domain instance act on.

composite_energy fixes the composite's energy instead of measuring it; β is still measured from the pair's halves at that energy. Where the noise of the VMIs hardly changes with energy near its minimum, the measured argmin is itself noise and moves between acquisitions of one scanner and protocol; its energy is a property of the scanner and protocol, measured once.

basis = (Estar = …, β = …) fixes the pair the windows act on instead of measuring it: a noise-free acquisition has no noise to measure it from, and is reconstructed exactly as its noisy counterpart. The composite of the result is still measured from the result (for a noise-free acquisition, from its aliasing alone, which nothing downstream uses).

Returns (water, iodine, halves, Estar, f, β, Σ, basis): the reconstructed pair, the (water, iodine) pairs of the odd and even views reconstructed alike, the minimum-noise composite of the pair and its noise covariance Σ, and basis, the (Estar, β) the windows acted on.

acnr_complement!functionsource ↗
acnr_complement!(pair; hp_sigma_px = 1.5, window = 4, passes = 4, beta_max = 20) -> pair

Anti-correlated noise reduction (Kalender 1988, apply_acnr_kalender!) acting only on what it is for: the anti-correlated part of the pair. The Kalender correction is applied to the pair and the composite then restored — a = I⊥′ + βM, c = (M − f₁ a) / f₂, with I⊥′ the complement of the corrected pair and M the composite before it — so the minimum-noise image, which carries no anti-correlated noise, is unchanged by construction; left to itself the per-pixel regression also moves the composite, and adds noise at the energies where the VMIs are quietest. Applied alike to the odd- and even-view halves of pair (spectral_pair), so that the noise downstream steps measure is the noise of what they receive.

image_hyprfunctionsource ↗
image_hypr(pair; image = ImageHYPR(), to_backend = identity) -> NamedTuple

The image-domain instance on a reconstructed pair (spectral_pair, after ACNR when the chain uses it), slice by slice — adjacent slices are never pooled:

  • the composite M = f₁ a + f₂ c at the pair's E*, kept as reconstructed; its local noise σ_M from the half-view difference (local_noise);

  • the complement I⊥ = a − βM with the pair's β, measured by spectral_pair before any step that changes the pair, so that its noise is uncorrelated with M's (ACNR's correction, acnr_complement!, leaves the complement correlated with the composite; re-measuring β after it would fold part of the composite, its edges included, into the pooled complement);

  • I⊥ pooled with weights from M, exp(-(M_i − M_j)² / 2(σ_i² + σ_j²)): the complement carries only spectral information, which changes where the material does, and there the composite does too. Its window is the candidate width with the least estimated full-data risk: the weights come from M, whose noise is uncorrelated with I⊥'s, so the pool is a linear smoother S of I⊥, and with I⊥₁, I⊥₂ the halves' complements and δ = (I⊥₁ − I⊥₂) / 2 — the full reconstruction's noise — E‖S I⊥₁ − I⊥₂‖² − E‖S δ‖² = ‖(S − 1) I⊥‖² + tr(S C Sᵀ) + const, bias² plus variance of the full-data estimate (C the complement's noise covariance), summed inside the reconstruction circle. The halves must be independent, which the projection-domain instance keeps (ProjectionHYPR's view_stride);

  • the pair recombined, a = Î⊥ + βM, c = (M − f₁ a) / f₂.

Returns (water, iodine, Estar, β, window, risks, σM), window the complement's width and risks each candidate's estimated risk relative to the smallest's.

src/denoising/median_z.jl

apply_median_z!functionsource ↗
apply_median_z!(out::AbstractArray{Float32, 3},
                src::AbstractArray{Float32, 3};
                adjacent_slices::Int = 1)
    -> out

In-place 1D median along z, per (x, y). adjacent_slices is the number of slices included on each side of the center slice:

adjacent_sliceswindow sizeeffect
01 (center)identity — no filtering
131 above + center + 1 below
252 above + center + 2 below
n2n + 1n above + center + n below

At slice boundaries the window shrinks to the available extent (no padding bias).

apply_median_zfunctionsource ↗
apply_median_z(src::AbstractArray{Float32, 3}; adjacent_slices::Int = 1)
    -> Array{Float32, 3}

Allocating wrapper around apply_median_z!. Returns a freshly allocated denoised volume (same shape + element type as src).

adjacent_slices = 0 returns a plain copy of src (no filtering).

src/denoising/rskr.jl

mad_haar_σfunctionsource ↗
mad_haar_σ(vol::AbstractArray{Float32, 3}) -> Float32

Robust noise σ estimate from the median absolute deviation of the 2D Haar HH (diagonal high-pass) of the mid-z slice. Scaled by 1.4826 to match Gaussian σ. Used by RSKR per-SV adaptive σ.

joint_bf_2ch_gpu!functionsource ↗
joint_bf_2ch_gpu!(out1, out2, in1, in2, σ1, σ2, h, radius)

In-place 2-channel joint bilateral filter on a Metal/CUDA-resident pair. Spherical neighborhood of radius voxels; product-of-channels range kernel with per-channel scale (h·σ_c).

This is an internal helper for apply_rskr. Inputs/outputs must all be on the same backend (Metal/CUDA) and have matching shapes.

joint_bf_4ch_gpu!functionsource ↗
joint_bf_4ch_gpu!(out1, out2, out3, out4, in1, in2, in3, in4,
                  σ1, σ2, σ3, σ4, h, radius)

In-place 4-channel joint bilateral filter (PCCT 4-bin). Same structure as joint_bf_2ch_gpu! with four channels.

apply_rskrfunctionsource ↗
apply_rskr(vols::Vector{Array{Float32, 3}};
           n_iter::Int    = 4,
           h_param::Real  = 1.0,
           radius::Int    = 6,
           γ::Real        = 0.5,
           gpu_arr_type   = identity,
           verbose::Bool  = true)
    -> Vector{Array{Float32, 3}}

Joint multi-channel RSKR denoising of a basis pair (length(vols) == 2) or PCCT 4-bin set (length(vols) == 4).

Arguments

  • vols: vector of equally-shaped Float32 volumes (CPU). Length must be 2 or 4.

Keyword arguments

  • n_iter : number of SVD-and-jBF iterations. Each iteration re-balances noise across channels (2 is plenty for typical basis pairs; 4 for noisier 4-bin PCCT).

  • h_param : range-kernel scale h₀. 1.0 is the paper default; 0.5–2.0 typical. Lower → less smoothing.

  • radius : spatial neighborhood half-radius (voxels). Spherical mask of radius² distance²; 2–6 typical. Larger → more averaging per voxel + slower.

  • γ : rank-sparse h-scaling exponent. 0.5 default; higher → smaller SVs get more smoothing.

  • gpu_arr_type : array constructor for the BF backend. Default identity (CPU — AK.foreachindex falls back to threaded loops over Array{Float32, 3}). Pass MtlArray / CuArray / ROCArray to dispatch the kernel onto the corresponding device.

  • verbose : per-iteration timing + σ/h/Σ logging.

Returns

A Vector{Array{Float32, 3}} of denoised volumes (CPU), same length and shapes as vols.

Example — DE basis pair

out = BS.apply_rskr([vol_low, vol_high]; n_iter = 2, h_param = 2.0, radius = 2)

src/denoising/sino_sfjsd.jl

apply_sino_sfjsd_denoisefunctionsource ↗
apply_sino_sfjsd_denoise(channels, I0; σ₀ = 0.0, verbose = true)
    -> Vector{Array{Float32, 3}}

Two-channel projection-domain SF-JSD joint denoiser. Operates on a co-registered pair of log-line-integral sinograms (channels[1], channels[2]) with per-channel scalar reference photon flux (I0[1], I0[2]) — typically BS.compute_detector_I0(geom, protocol, sum(spectrum\_without\_bowtie)) for the corresponding tube / energy-bin partition.

Arguments

  • channels::Vector{<:AbstractArray{Float32, 3}} — the two log-line- integral sinograms (n_col, n_row, n_view). Length must be 2.

  • I0::Vector{<:Real} — per-channel scalar I₀ (photons / pixel / view, before patient). Length must be 2.

Keyword arguments

  • σ₀::Real = 0.0 — principal smoothing scale in detector pixels. 0.0 ⇒ SURE auto-selects on the mid detector row (recommended). Any positive value is used directly.

  • verbose::Bool = true — log auto-derived stride / n_iter / σ₀★.

Returns

A Vector{Array{Float32, 3}} of denoised log-line-integral sinograms, one per channel, same shapes as the inputs.

Example — dual-kVp pair

e_lo, w_lo = BS.resolve_source_spectrum_without_bowtie(
    sim_opts, protocol_low; scanner = scanner,
)
e_hi, w_hi = BS.resolve_source_spectrum_without_bowtie(
    sim_opts, protocol_high; scanner = scanner,
)
I0_lo = BS.compute_detector_I0(sim_low.geom,  protocol_low,  Float64(sum(w_lo)))
I0_hi = BS.compute_detector_I0(sim_high.geom, protocol_high, Float64(sum(w_hi)))

out = BS.apply_sino_sfjsd_denoise(
    [Float32.(sim_low.sino), Float32.(sim_high.sino)],
    [I0_lo, I0_hi],
)
sino_low_d, sino_high_d = out[1], out[2]

Example — PCCT bin-combined pair

I0 = sim_bins.I0_bins                             # per ray, [n_cols, n_rows, 4]
I0_lo = mean(sum(I0[:, :, 1:2]; dims = 3))        # bins 1+2 → low channel (fan mean; the denoiser takes one count per channel)
I0_hi = mean(sum(I0[:, :, 3:4]; dims = 3))        # bins 3+4 → high channel
out = BS.apply_sino_sfjsd_denoise(
    [Float32.(sino_lo_combined), Float32.(sino_hi_combined)],
    [I0_lo, I0_hi],
)

Reference

Black (in prep.), Joint Sinogram Denoising via Subspace–Frequency Reduction for Two-Channel Spectral CT.

src/denoising/sino_svd.jl

apply_sino_svd_denoise!functionsource ↗
apply_sino_svd_denoise!(out, channels; σ_px = 1.5) -> out

In-place N-channel projection-domain SVD joint denoiser. Writes into out[k] (vector of pre-allocated Array{Float32, 3}); channels[k] is the corresponding raw sinogram input. See the module-level docstring for the full algorithm description and inspiration.

Arguments

  • out::Vector{<:AbstractArray{Float32, 3}} : pre-allocated output arrays, one per channel. Same shape as the input channels.

  • channels::Vector{<:AbstractArray{Float32, 3}}: input sinograms (n_col, n_row, n_view). Length 2 (dual-kVp), 3, 4 (PCCT 4-bin), …

Keyword arguments

  • σ_px::Real = 1.5 : Gaussian σ in pixels for the smoothing of U[:, 2..N] in (col, view). σ_px ≤ 0 ⇒ passthrough copy.

Returns

out — same vector that was passed in.

apply_sino_svd_denoisefunctionsource ↗
apply_sino_svd_denoise(channels; σ_px = 1.5) -> Vector{Array{Float32, 3}}

Allocating wrapper around apply_sino_svd_denoise!. Returns a freshly allocated vector of denoised Array{Float32, 3} (one per channel), same shape as the inputs.

Example — dual-kVp (2 channels)

out = BS.apply_sino_svd_denoise([sino_low, sino_high]; σ_px = 1.5)
sino_low_d, sino_high_d = out[1], out[2]

Example — PCCT 4-bin (4 channels)

out = BS.apply_sino_svd_denoise(sim_bins.bins; σ_px = 1.5)
# out[1..4] are the denoised bins; combine to low/high downstream.
apply_sino_svd_denoise_bilateral!functionsource ↗
apply_sino_svd_denoise_bilateral!(out, channels;
    bilat_radius = 3, bilat_sigma_s = 2.0, bilat_range_k = 2.0) -> out

Same per-row N-channel SVD as [apply_sino_svd_denoise!] — U[:, 1] (common log-attenuation / anatomy) is kept pixel-perfect — but the residual components U[:, 2..N] are cleaned with an edge-preserving joint bilateral filter instead of a Gaussian, so streaks / decorrelated quantum noise are removed with zero spatial-resolution cost (no blur across edges).

This is the SVD counterpart of the Kalender-ACNR and regression-Mono+ reworks: the noise-suppression operator is switched from a linear low-pass (which blurs real spectral edges in the residual components) to a structure-aware filter.

Filter

For each residual component k = 2..N, reshaped to the row's (col, view) plane, each pixel is a bilateral-weighted average of its (2·bilat_radius+1)² neighbourhood:

w(p,q) = exp(-‖p-q‖² / 2σ_s²)                    # spatial (bilat_sigma_s)
       · exp(-(g_p - g_q)² / 2σ_g²)              # range on the U[:,1] guide
       · exp(-(u_p - u_q)² / 2σ_t²)              # range on U[:,k] itself

σ_g and σ_t are per-row robust scales (MAD·1.4826) of the guide U[:,1] and the target U[:,k], each times bilat_range_k. A neighbour is averaged in only where both the anatomy guide and the component itself are locally flat (noise/streak) → smoothed; at any edge present in either → range weight collapses → the pixel is preserved. Resolution preservation is structural (an edge in either channel stops the averaging), not a global low-pass.

Keyword arguments

  • bilat_radius::Integer = 3 : spatial window radius (px) in (col, view).

  • bilat_sigma_s::Real = 2.0: spatial Gaussian σ (px).

  • bilat_range_k::Real = 2.0: range-σ multiplier; smaller ⇒ edges protected more aggressively (less smoothing), larger ⇒ more smoothing. ≤ 0 ⇒ passthrough copy.

Returns

out — same vector that was passed in.

src/denoising/tlbf.jl

total_measured_countsfunctionsource ↗
total_measured_counts(channels, I0) -> Array{Float32,3}

Summed corrected counts across channels, Σ_k I0_k · exp(-h_k): the quantity the total-likelihood filter scores neighbours against.

  • channels: K corrected log-transmission sinograms (n_col, n_row, n_view).

  • I0: (nc, nr, K) absolute air counts; nc, nr may be 1 when every ray shares one value.

total_expected_countsfunctionsource ↗
total_expected_counts(sino_iodine, sino_water, Φ, μρ_I, μρ_W; to_backend = identity)

Summed expected counts Σ_k λ_k(A, C) under the forward model the decomposition inverted, evaluated at the estimated basis pair. Φ is the absolute response (nc, nr, nE, K); it is summed over channels here. The result is floored at 1e-6.

tlbf_denoisefunctionsource ↗
tlbf_denoise(sino_iodine, sino_water, expected, measured;
             alpha1 = 0.9, alpha2 = 24.635648571666497, radius = 2,
             to_backend = identity) -> (; sino_iodine, sino_water)

Lee total-likelihood bilateral filter on a projection-domain basis pair.

For centre ray i with measured total counts Y_i and Poisson log-likelihood ℓ(x) = -x + Y_i log x, a neighbour j in the (2·radius+1)² column × view window gets

w_ij = exp(-(Δc² + Δv²) / (2·alpha1²)) · exp(-(ℓ(x_j) - ℓ(x_i))² / alpha2²)

with x the expected total counts (see total_expected_counts). The normalised weights are applied to iodine and water alike. Views wrap circularly; detector columns do not.

alpha2 = Inf keeps the spatial weights only; alpha2 ≤ 0 is the identity. Both are the ablation endpoints. The defaults are the fixed Lee-2025 configuration selected in notebook 04.

The neighbourhood is (column, view) and never crosses detector rows, so a sinogram with any number of rows is filtered row by row, each in its own plane — on a single row (what reduce_detector_rows leaves) that is exactly the published filter, and on many it is that filter applied to each.

Corrections & calibration

Beam-hardening correction (water and two-material, in the sinogram or the image domain), radial cupping correction, PCCT pile-up correction, and the calibration helpers.

src/api/workspace.jl

calibrate_pcct_poly_bhcfunctionsource ↗
calibrate_pcct_poly_bhc(ws; order=5, max_path_cm=50, n_points=100)

Build water BHC for an I₀-weighted combination of all PCCT bins from the workspace's exact detected spectrum (source × η × DRM × bowtie). The same per-column model and μ_water_ref contract as calibrate_bhc_water is returned.

src/correction/bhc_image_domain.jl

apply_bhc_image_domainfunctionsource ↗
apply_bhc_image_domain(recon_μ, geom, matrix_size, μ_water_ref;
                       hu_low=70.0, hu_high=150.0, scale_factor=0.5)

Apply image-domain beam hardening correction (So et al. 2009).

Arguments

  • recon_μ::AbstractArray{T,3}: reconstructed image in μ (cm⁻¹), already corrected with water-only or two-material sinogram BHC. GPU array.

  • geom: CTGeometry used for reconstruction.

  • matrix_size::Tuple{Int,Int,Int}: reconstruction matrix (nx, ny, nz).

  • μ_water_ref::Real: water linear attenuation at the reference energy.

Keyword Arguments

  • hu_low::Real=70.0: lower HU threshold; voxels ≤ this are 100% soft tissue (WF=0).

  • hu_high::Real=150.0: upper HU threshold; voxels ≥ this are 100% high-atten (WF=1).

  • scale_factor::Real=0.5: scale of error-image subtraction.

    • 0.0 = no correction (identity)

    • 1.0 = full subtraction

    • Typical 0.2–0.7; start at 0.5.

  • projector::Symbol=:dd_fast: forward ray tracer for the internal error-sinogram projection. Pass the SAME value as the simulation's SimOptions(; projector=…) (:dd_fast default and fastest general path; :siddon is retained for compatibility but aliases) so the correction is computed with the model that generated the data.

Returns

The modified recon_μ array (same object, mutated).

Note on geometry

Both halves of the internal forward+back-projection round-trip use geom's recon FOV. Passing a separate volume_extent (the physical extent of the original phantom) would be conceptually wrong here: by the time this function runs, the phantom is gone — we're working with a reconstructed image that already lives in geom's recon grid. An earlier signature accepted such a kwarg and produced bone-shaped halo + diagonal Moiré artifacts whenever the two extents differed; it has been removed.

Reference

So A, Hsieh J, Li JY, Lee TY. Phys Med Biol. 2009;54(10):3031-3050.

src/correction/bhc_sinogram.jl

BHCPolynomialstructsource ↗
BHCPolynomial

Polynomial beam hardening correction coefficients.

p_corrected = a₀ + a₁·p + a₂·p² + … + aₙ·pⁿ

p is the measured polychromatic line integral, p_corrected the monochromatic-equivalent value at reference_energy_keV.

Fields

  • coefficients::Vector{Float64}: [a₀, a₁, …, aₙ] ascending order.

  • order::Int: polynomial order (length(coefficients) - 1). Typical 3-5.

  • reference_energy_keV::Float64: reference energy for the monochromatic target. Diagnostic metadata — not read by the apply kernel.

CatSim (Prep_BHC_Accurate.py) uses the same polynomial formulation; default order in CatSim is 5.

BeamHardeningCorrectionstructsource ↗
BeamHardeningCorrection

Polynomial BHC + raw calibration data, returned by calibrate_bhc.

Fields

  • polynomial::BHCPolynomial: fitted correction polynomial.

  • calibration_paths::Vector{Float64}: water path lengths (cm). Diagnostic.

  • calibration_measured::Vector{Float64}: polychromatic line integrals at each path. Diagnostic.

  • calibration_true::Vector{Float64}: monochromatic line integrals at each path. Diagnostic.

The three calibration_* fields are stashed for offline plotting / quality assessment and are not read by any apply path.

TwoMaterialBHCPerColumnstructsource ↗
TwoMaterialBHCPerColumn

Bowtie-aware two-material BHC. One polynomial per detector column, each fit from the spectrum that column actually saw (tube × filters × bowtie at that fan angle), plus the per-column normalized spectrum and global μ refs needed for the bone-segmentation second stage.

Fields

  • water_bhc_per_col::Vector{BeamHardeningCorrection}: per-column water poly. Length equals the sinogram's n_col.

  • energies::Vector{Float64}: energy grid (keV).

  • w_norm_per_col::Matrix{Float64}: [n_E, n_col] normalized spectrum.

  • μ_water_E, μ_bone_E::Vector{Float64}: per-energy μ for water / cortical bone (cm⁻¹).

  • μ_water_ref, μ_bone_ref::Float64: global μ at reference_energy_keV. The mono-equivalent sinogram produced by the per-column polynomials targets this single global reference so FBP downstream sees one effective energy.

  • reference_energy_keV::Float64: diagnostic metadata (not read by apply).

  • hu_low, hu_high::Float64: bone-fraction smoothstep thresholds (HU).

calibrate_bhcfunctionsource ↗
calibrate_bhc(energies, weights; order=3, max_path_cm=50.0, n_points=100, reference_energy_keV=70.0)
    -> BeamHardeningCorrection

Calibrate water-based BHC from a single 1-D source spectrum by fitting a polynomial to the water calibration curve.

This is the inner helper used by the per-column two-material flow; one call per detector column. Notebooks should use the high-level calibrate_bhc_two_material(sim_opts, protocol; …) entry instead, which handles the bowtie + per-column dispatch.

apply_bhc!function
apply_bhc!(sinogram, bhcs_per_col::AbstractVector{BeamHardeningCorrection}; ws_coeffs_gpu=nothing)

Per-column (bowtie-aware) sinogram BHC. Convenience wrapper that unwraps the BeamHardeningCorrection objects and delegates to the BHCPolynomial-vector method below.


apply_bhc!(sinogram, polys_per_col::AbstractVector{BHCPolynomial}; ws_coeffs_gpu=nothing) -> sinogram

Per-column (bowtie-aware) BHC: applies a different polynomial to each detector column, indexed off the sinogram's BS storage [n_col, n_row, n_view] layout. All polynomials must share the same order.

calibrate_bhc_two_materialfunctionsource ↗
calibrate_bhc_two_material(sim_opts, protocol; scanner, geom, kwargs...)
    -> TwoMaterialBHCPerColumn

High-level entry — bowtie-aware by construction. Auto-resolves the bowtie-hardened source spectrum via resolve_source_spectrum_with_bowtie, collapses to per-column weights via bhc_spectrum_per_column, and dispatches to the per-column polynomial fit. This captures the column-dependent spectral shaping any modern bowtie filter imposes — a single global polynomial leaves residual radial cupping behind that this path eliminates.

If reference_energy_keV is nothing (default), it's set to the column-mean spectrum's mean energy so all columns share a single mono-equivalent target.

Example

bhc = calibrate_bhc_two_material(
    sim_opts, protocol;
    scanner = scanner, geom = ws.geom,
    order   = 2,
    hu_low  = 450.0,
    hu_high = 600.0,
)
calibrate_bhc_two_material(energies, weights_per_col::AbstractMatrix; kwargs...)
    -> TwoMaterialBHCPerColumn

Lower-level variant when you already have a [n_E, n_col] per-column spectrum matrix — same path the high-level method dispatches into.

bhc_spectrum_per_columnfunctionsource ↗
bhc_spectrum_per_column(e, w) -> (e, w_per_col)

Normalize a source-spectrum array to the [n_E, n_col] shape that calibrate_bhc_two_material(::Vector, ::Matrix; …) expects.

  • 1-D w[n_E] → returned as-is (caller must duplicate per column).

  • 2-D w[n_E, n_col] or w[n_col, n_E] → auto-transpose to [n_E, n_col].

  • 3-D bowtie-aware w[n_col, n_row, n_E] (from resolve_source_spectrum_with_bowtie) → take the center detector row, return [n_E, n_col]. Bowtie variation is primarily in-plane (fan angle); row-direction variation is small.

compute_polychromatic_μ_waterfunctionsource ↗
compute_polychromatic_μ_water(sim_opts, protocol; scanner, geom, water_path_cm) -> Float64

Spectrum-analytic polychromatic-effective μ_water for an in-phantom voxel — the analytic counterpart to "average μ in a solid-water ROI of the FBP volume". Use this as the HU-conversion reference for image-domain pipelines that don't run BHC.

Pipeline:

  1. Resolve the bowtie-aware source spectrum via resolve_source_spectrum_with_bowtie — picks up the scanner flat filter, protocol.additional_filters, and the bowtie attenuation.

  2. Collapse the per-ray ŵ to the central-column 1D fluence profile via bhc_spectrum_per_column.

  3. Pre-harden the spectrum by water_path_cm of solid water at each energy bin via Beer-Lambert (low-E photons drop out preferentially, raising the mean energy — same effect a center-of-phantom voxel sees).

  4. Return the fluence-weighted μ_water on the hardened spectrum.

Keyword arguments

  • scanner::Scanner — flat filter + bowtie geometry.

  • geom::CTGeometry — for bowtie attenuation per column.

  • water_path_cm::Real — total chord-length of solid water seen by an FBP line integral through the reference voxel. For a center voxel, the chord crosses the full phantom diameter (one entry + one exit half), so pass the phantom diameter, e.g. 33.0 for a Gammex 472 (33 cm body), not the radius.

calibrate_bhc_waterfunction
calibrate_bhc_water(sim_opts, protocol; scanner, geom, order=5,
                    max_path_cm=50.0, n_points=100,
                    reference_energy_keV=nothing) -> WaterBHC

Precompute the knobless water BHC from pure physics: resolves the FULL detected spectrum per detector column (resolve_source_spectrum_full — every sim_opts.use_*-gated factor the simulation actually applied) and fits the poly→mono water curve per column. Nothing here touches the data being corrected and there are no segmentation thresholds.


calibrate_bhc_water(energies, weights_per_col; order=5, max_path_cm=50.0,
                    n_points=100, reference_energy_keV=70.0) -> WaterBHC

Low-level form for CUSTOM per-column spectra (e.g. measured detector response overrides): builds the same knobless per-column water poly→mono polynomials from an explicit [n_E, n_col] weight matrix.

apply_bhc_waterfunctionsource ↗
apply_bhc_water(sinogram_raw, bhc::WaterBHC) -> corrected sinogram

Water BHC: one sinogram-domain per-column polynomial pass. Knobless, no reconstruction round-trip, no projector, helical-safe.

apply_bhc_two_materialfunctionsource ↗
apply_bhc_two_material(sinogram_raw, bhc::TwoMaterialBHCPerColumn, geom, matrix_size)

Bowtie-aware sinogram-domain two-material BHC.

Algorithm (3 stages)

  1. Per-column water polynomial — apply bhc.water_bhc_per_col to the sinogram, giving a water-only mono-equivalent estimate.

  2. Bone-fraction segmentation — FDK-reconstruct the water-corrected sino, convert μ → HU using bhc.μ_water_ref, and compute a smooth bone fraction via the Elbakri/Fessler 2003 C1-continuous smoothstep t²·(3 - 2t) on [hu_low, hu_high]. Forward-project the bone-fraction-weighted μ image.

  3. Per-column polychromatic forward subtraction — for each pixel, solve the polychromatic forward model with per-column normalized spectrum w_norm_per_col and add the (mono − poly) residual to the original sinogram.

Keyword Arguments

  • projector::Symbol=:dd_fast: forward ray tracer for Stage 2's bone-fraction projection. Pass the SAME value as the simulation's SimOptions(; projector=…) (:dd_fast default and fastest general path; :siddon is retained for compatibility but aliases) so the correction matches the model that generated the data.

Note on geometry

Stage 2's internal FDK + forward-projection round-trip both use geom's recon FOV. An earlier signature accepted volume_extent=phantom.extent and forwarded it to the forward projector only — the matching FDK call always used geom's FOV, which caused a grid mismatch and bone-shaped halo artifacts whenever the two extents differed (e.g. nb02's XCAT body, where phantom.extent ≠ recon FOV). The kwarg has been removed.

src/correction/pcct_pileup_correction.jl

apply_pcct_pileup!functionsource ↗
apply_pcct_pileup!(bins, I0, S, rates, rate_air)

Record the pile-up of every ray at that ray's own count rate: the truth counts c_b = I0[col, row, b]·exp(-bin_b) are mixed by the lower-triangular S(rate) of the ray's rate (rate_air · Σ_b c_b / Σ_b I0_brightest_b), and the bins are rewritten as -log(recorded / I0[col, row, b]) so I0 · exp(-bin) = recorded count holds per ray.

apply_pcct_pileup_correction!functionsource ↗
apply_pcct_pileup_correction!(bins, I0, S, rates, rate_air)

Undo apply_pcct_pileup! per ray: the recorded counts are known, the truth counts fix the rate, so S(rate)·t = r is solved by forward substitution with the rate re-estimated from the solution (three fixed-point rounds — S(rate) varies slowly in log rate, and the first round already starts within a few percent). The bins come back as log-transmissions against I0[col, row, b].

src/correction/radial_capping_basis.jl

apply_radial_capping_basis!functionsource ↗
apply_radial_capping_basis!(a, c;
                            fov_cm=35.0,
                            poly_order=2,
                            q_lo=0.25,
                            q_hi=0.75,
                            verbose=true)

Per-slice radial even-polynomial capping correction on both basis images. Mutates a and c in place.

Keyword arguments

  • fov_cm : transverse FOV in cm (pixel → cm scale)

  • poly_order : even polynomial terms beyond c₀ (1=parabolic, 2=matches 06, 3+=stronger)

  • q_lo, q_hi : quantile range over in-FOV voxels used as background

Returns a NamedTuple with per-basis mean c₁ coefficients and implied FOV-edge drops for diagnostics.

src/correction/radial_cupping.jl

measure_radial_cuppingfunctionsource ↗
measure_radial_cupping(hu_vol; fov_cm=35.0, half_window=90.0, poly_order=2) -> NamedTuple

QA-ONLY, non-mutating radial-cup measurement. Fits an even polynomial in r to water-like voxels and returns (cup_hu, dc_hu, coeffs) per volume (worst slice). Robust to noise: the voxel window is centred on the MEDIAN of a first pass (a fixed asymmetric window truncates noise asymmetrically and biases the DC — measured +8 HU injection on σ≈83 HU data, which is why apply_radial_cupping_correction! is deprecated as a correction).

Doctrine: after a correct full-spectrum BHC both numbers should be ≈ 0. A large value means the upstream chain is wrong — fix it there.

apply_radial_cupping_correction!functionsource ↗
apply_radial_cupping_correction!(hu_vol; fov_cm=35.0, hu_lo=-100.0, hu_hi=80.0, poly_order=2, target_hu=0.0)

Post-reconstruction radial correction for residual cupping/capping artifacts.

Fits a low-order even polynomial in radius c₀ + c₁ r² + c₂ r⁴ + … to the water-like background voxels (those with HU in [hu_lo, hu_hi]), then subtracts the fitted offset so the background becomes flat at target_hu.

Operates slice-by-slice; modifies hu_vol in place.

Arguments

  • hu_vol::Array{Float32, 3}: reconstructed HU volume (modified in place).

Keyword Arguments

  • fov_cm::Float64=35.0: field of view in cm (used to convert pixel index to radius).

  • hu_lo::Float64=-100.0: lower HU threshold for water-like voxels.

  • hu_hi::Float64=80.0: upper HU threshold for water-like voxels.

  • poly_order::Int=2: number of even polynomial terms (fits c₀ + c₁ r² + … + c_poly_order · r^(2 · poly_order)).

  • target_hu::Float64=0.0: target HU for the water background after correction.

Returns

The modified hu_vol array.

Detector physics

Both detector families use Monte Carlo–derived response models. The energy-integrating (EICT) path uses per-energy efficiency lookup tables, fill factor, optical crosstalk and lag. The photon-counting (PCCT) path uses the full detector response matrix and the pile-up model. Scatter estimation and injection are also here.

src/detector/detector_efficiency.jl

GEMSTONE_MC_EFFICIENCY_LUTconstantsource ↗
GEMSTONE_MC_EFFICIENCY_LUT

Monte Carlo-derived detector efficiency lookup table for Ce:(Tb,Lu)₃Al₅O₁₂ (Gemstone-type garnet scintillator) as a function of photon energy.

Computed from a full MCNP simulation of the GE Revolution Apex detector with composition Tb(0.287) / Lu(0.316) / Al(0.163) / O(0.231) / Ce(0.002).

Key features captured by MC (not in Beer-Lambert)

  1. Fluorescence escape at Tb K-edge (52 keV): η drops 0.956 → 0.807 due to Tb Kα fluorescence photons (~44 keV) escaping the detector volume.

  2. Fluorescence escape at Lu K-edge (63 keV): η drops 0.846 → 0.762 due to Lu Kα fluorescence photons (~54 keV) escaping.

  3. L-edge effects at 8-12 keV from Tb/Lu L-shells.

  4. Compton scattering escape at higher energies.

Beer-Lambert predicts η INCREASES at K-edges (more absorption); MC correctly shows DECREASES because fluorescent photons escape, reducing deposited energy.

Reference

  • Material: PMC10179960 (Ce:(Tb,Lu)₃Al₅O₁₂ garnet scintillator)

  • Simulation: MCNP with ENDF photon cross-sections

  • Origin: PR #5

UFC_MC_EFFICIENCY_LUTconstantsource ↗
UFC_MC_EFFICIENCY_LUT

Monte Carlo-derived detector efficiency lookup table for the Siemens UFC (Ultra-Fast Ceramic, Gd₂O₂S:Pr,Ce) scintillator as a function of photon energy — the Siemens SOMATOM Force sister of [GEMSTONE_MC_EFFICIENCY_LUT].

Computed from a full Monte-Carlo transport simulation of the SOMATOM Force StellarInfinity detector by Hamidreza Khodajou-Chokami, PhD (UC Irvine Medical Imaging Laboratory), efficiency_results.csv, 2026-06-08. Values are verbatim from that dataset on a 1-keV grid (1–140 keV).

Key features captured by MC (not in Beer-Lambert)

  1. Fluorescence escape at the Gd K-edge (50.24 keV): η drops 0.969 → 0.741 between 50 and 51 keV as Gd Kα fluorescence (~43 keV) escapes the crystal. Beer-Lambert predicts the opposite jump.

  2. Gd L-edge structure near 7–8 keV (L₃ 7.24 / L₂ 7.93 / L₁ 8.38 keV).

  3. High-energy roll-off from primary transmission + Compton escape (0.897 at 100 keV → 0.816 at 140 keV).

First validated end-to-end (poly + dual-source VMI) in docs/notebooks/09_siemens_force_ufc_dual_source_vmi.jl.

UFC_FLASH_MC_EFFICIENCY_LUTconstantsource ↗
UFC_FLASH_MC_EFFICIENCY_LUT

Monte Carlo-derived detector efficiency lookup table for the Siemens UFC (Ultra-Fast Ceramic, Gd₂O₂S:Pr,Ce) scintillator of the SOMATOM Definition Flash — the dual-source sister of [UFC_MC_EFFICIENCY_LUT] (SOMATOM Force).

Computed from a full Monte-Carlo transport simulation of the Definition Flash detector by Hamidreza Khodajou-Chokami, PhD (UC Irvine Medical Imaging Laboratory), flash_efficiency_results.csv, 2026-08-26 (CRSP lab share). This LUT is the tracked copy; values are verbatim from that dataset on a 1-keV grid (1–140 keV).

Key features captured by MC (not in Beer-Lambert)

  1. Fluorescence escape at the Gd K-edge (50.24 keV): η drops 0.925 → 0.739 between 50 and 51 keV as Gd Kα fluorescence (~43 keV) escapes the crystal. Beer-Lambert predicts the opposite jump.

  2. Gd L-edge structure near 7–8 keV (L₃ 7.24 / L₂ 7.93 / L₁ 8.38 keV).

  3. High-energy roll-off much steeper than the Force LUT — a thinner crystal transmits more primaries (η 0.825 at 100 keV → 0.588 at 140 keV, vs the Force's 0.897 → 0.816). Low-energy values (≤5 keV) are bit-identical to the Force run: those photons are absorbed within the first microns, before the geometries differ.

DetectorEfficiencyModeenumsource ↗
DetectorEfficiencyMode

Selector for how detector efficiency is computed.

  • BEER_LAMBERT — Analytical Beer-Lambert absorption law using μ(E) from XCOM

  • MC_LUT — Monte Carlo-derived lookup table (captures fluorescence escape)

BEER_LAMBERTconstantsource ↗
BEER_LAMBERT::DetectorEfficiencyMode

Analytic scintillator absorption, η(E) = 1 − exp(−μ(E)·d), with μ(E) from get_scintillator_mu and d the crystal thickness. It can't model the K-edge fluorescence escape that MC_LUT captures. Select it for an EICTScanner with SimOptions(detector_efficiency_mode = :beer_lambert), e.g. to compare against the LUT.

DetectorEfficiencystructsource ↗
DetectorEfficiency

Detector efficiency model specification.

Fields

  • material: Scintillator material name (Gemstone aliases, or "CdTe" via PCCT)

  • thickness_mm: Scintillator thickness in mm

  • fill_factor: Fraction of detector area that is active (0-1) — physical property stored here but applied separately via FillFactorModel

  • mode: BEER_LAMBERT or MC_LUT

compute_eid_efficiency_vector computes scintillator absorption η(E) only; fill factor is applied in the physics pipeline.

detector_efficiency_gemstonefunctionsource ↗
detector_efficiency_gemstone(; mode::Symbol=:mc_lut, thickness_mm=3.0,
                              fill_factor=0.90)

GE Gemstone Clarity Ce:(Tb,Lu)₃Al₅O₁₂ garnet scintillator detector.

Modes

  • :mc_lut (default) — Monte Carlo-derived efficiency LUT from MCNP simulation. Captures fluorescence escape at Tb/Lu K-edges that Beer-Lambert cannot model.

  • :beer_lambert — Analytical Beer-Lambert using XCOM-derived μ(E) data.

detector_efficiency_ufcfunctionsource ↗
detector_efficiency_ufc(; mode::Symbol=:mc_lut, thickness_mm=1.4,
                         fill_factor=0.90)

Siemens UFC (Ultra-Fast Ceramic) Gd₂O₂S:Pr,Ce scintillator detector (SOMATOM Force StellarInfinity) — sister factory of detector_efficiency_gemstone.

Modes

  • :mc_lut (default) — Monte Carlo-derived efficiency LUT ([UFC_MC_EFFICIENCY_LUT]). Captures Gd K-edge fluorescence escape that Beer-Lambert cannot model.

  • :beer_lambert — Analytical Beer-Lambert using the Gd₂O₂S μ(E) table.

The 1.4 mm default thickness is a documented assumption — Siemens does not publish the UFC layer depth (the value is inert in :mc_lut mode; with μ ≈ 46 mm⁻¹ at 120 kVp mean energies, ≥1 mm already absorbs >99%).

detector_efficiency_ufc_flashfunctionsource ↗
detector_efficiency_ufc_flash(; mode::Symbol=:mc_lut, thickness_mm=1.0,
                               fill_factor=0.90)

Siemens UFC (Ultra-Fast Ceramic) Gd₂O₂S:Pr,Ce scintillator detector of the SOMATOM Definition Flash (dual-source) — sister factory of detector_efficiency_ufc (Force) with its own per-scanner MC LUT.

Modes

  • :mc_lut (default) — Monte Carlo-derived efficiency LUT ([UFC_FLASH_MC_EFFICIENCY_LUT]). Captures Gd K-edge fluorescence escape that Beer-Lambert cannot model.

  • :beer_lambert — Analytical Beer-Lambert using the Gd₂O₂S μ(E) table.

The 1.0 mm default thickness is a documented assumption — Siemens does not publish the Definition Flash UFC layer depth (the value is inert in :mc_lut mode). The MC high-energy roll-off implies a Beer-Lambert- effective depth of ≈1.07 mm (η = 0.588 at 140 keV with μ ≈ 8.3 cm⁻¹).

get_scintillator_mufunctionsource ↗
get_scintillator_mu(material::String, energy_keV::Float64) -> Float64

Get linear attenuation coefficient (cm⁻¹) for scintillator material at given energy. Log-linear interpolation of tabulated NIST XCOM data; K-edge discontinuities are captured by including data points at the edges.

Live callers:

  • EICT Beer-Lambert efficiency via compute_eid_efficiency_vector

  • PCCT crystal η(E) via photon_counting.jl::get_detector_material_attenuation

get_gemstone_mc_efficiencyfunctionsource ↗
get_gemstone_mc_efficiency(energy_keV::Float64) -> Float64

Look up Monte Carlo-derived detector efficiency for Ce:(Tb,Lu)₃Al₅O₁₂ Gemstone scintillator at the given photon energy.

Linear interpolation of the 1-140 keV MC efficiency table. Energies outside the range are clamped.

Key physics

  • Tb K-edge fluorescence escape at 52-53 keV (η: 0.956 → 0.807)

  • Lu K-edge fluorescence escape at 63-64 keV (η: 0.846 → 0.762)

  • Peak efficiency ~0.985 at 20-27 keV

  • Efficiency ~0.859 at 100 keV, declining to 0.754 at 140 keV

get_ufc_mc_efficiencyfunctionsource ↗
get_ufc_mc_efficiency(energy_keV::Float64) -> Float64

Look up Monte Carlo-derived detector efficiency for the Siemens UFC Gd₂O₂S:Pr,Ce scintillator at the given photon energy.

Linear interpolation of the 1–140 keV MC efficiency table ([UFC_MC_EFFICIENCY_LUT]). Energies outside the range are clamped.

Key physics

  • Gd K-edge fluorescence escape at 50–51 keV (η: 0.969 → 0.741)

  • Gd L-edge dip near 8 keV

  • Peak efficiency ~0.986 at ~20 keV

  • η ≈ 0.897 at 100 keV, declining to 0.816 at 140 keV

get_ufc_flash_mc_efficiencyfunctionsource ↗
get_ufc_flash_mc_efficiency(energy_keV::Float64) -> Float64

Look up Monte Carlo-derived detector efficiency for the Siemens SOMATOM Definition Flash UFC Gd₂O₂S:Pr,Ce scintillator at the given photon energy.

Linear interpolation of the 1–140 keV MC efficiency table ([UFC_FLASH_MC_EFFICIENCY_LUT]). Energies outside the range are clamped.

Key physics

  • Gd K-edge fluorescence escape at 50–51 keV (η: 0.925 → 0.739)

  • Gd L-edge dip near 8 keV

  • Peak efficiency ~0.986 at ~20 keV

  • η ≈ 0.825 at 100 keV, declining to 0.588 at 140 keV (thinner crystal than the Force — steeper roll-off)

compute_eid_efficiency_vectorfunctionsource ↗
compute_eid_efficiency_vector(model::DetectorEfficiency, energies) -> Vector{Float64}

Compute detector efficiency η(E) for each energy in the spectrum.

Routes through the Monte Carlo LUT for Gemstone-type scintillators (captures fluorescence escape at K-edges) or Beer-Lambert for other materials. This vector weights the polychromatic Beer-Lambert sum in the EID pipeline: I = Σ wₑ × η(E) × exp(-∫μₑ dl).

src/detector/detector_lag.jl

LagModelstructsource ↗
LagModel

Detector lag (afterglow) model with multi-exponential decay.

Fields

  • amplitudes: Decay amplitudes for each exponential component

  • time_constants: Time constants τ in milliseconds

  • frame_time: Time between frames (views) in milliseconds

lag_gadoxfunctionsource ↗
lag_gadox()

Typical Gd₂O₂S (GOS/Gadox) scintillator lag.

GOS detectors have moderate afterglow with ~1-2% lag. Two-component decay: fast (~1ms) and slow (~10ms).

compute_lag_coefficientsfunctionsource ↗
compute_lag_coefficients(model::LagModel, n_frames::Int) -> Vector{Float64}

Compute lag coefficients for each frame offset.

Returns a vector where coef[k] is the fraction of frame n-k that contributes to frame n due to lag.

apply_lag!functionsource ↗
apply_lag!(sinogram, model::LagModel; n_history::Int=20) -> sinogram

Apply detector lag to sinogram (in-place, GPU-native).

Each output pixel (col, row, angle) is a weighted sum over the previous n_history angles, with the weights from compute_lag_coefficients. Operates in intensity domain (exp / log conversions handled internally).

src/detector/fill_factor.jl

FillFactorModelstruct
FillFactorModel

Detector fill factor specification.

Fields

  • row_fill: Fill factor in row direction (0-1)

  • col_fill: Fill factor in column direction (0-1)

  • uniform: Whether fill factor is uniform across detector

The effective fill factor is row_fill × col_fill.


FillFactorModel(fill::Float64)

Create uniform fill factor model with equal row and column fill.

fill_factor_standardfunctionsource ↗
fill_factor_standard()

Standard CT detector fill factor (90%) — third-generation default, used as the driver fallback when scanner row/col fill fields are 0.

apply_fill_factor!functionsource ↗
apply_fill_factor!(sinogram, model::FillFactorModel) -> sinogram

Apply fill factor effect to projection-domain sinogram (in-place, GPU-native).

Mathematical formulation: p_out = p_in − log(ff) where ff is the effective fill factor. Since ff < 1, −log(ff) > 0, so projection values increase uniformly (more apparent attenuation due to reduced active area).

auto-cancelled by simulate!

The EICT simulate! driver subtracts this same −log(ff_eff) offset after the air-scan / log step, matching the way real scanners absorb detector gain into their calibration reference. Notebooks that call simulate! get back log-line-integrals with this effect already cancelled; only direct callers of apply_fill_factor! need to worry about the residual offset.

src/detector/optical_crosstalk.jl

OpticalCrosstalkModelstructsource ↗
OpticalCrosstalkModel

Optical crosstalk model using CatSim-exact separable 3×3 convolution kernels.

Fields

  • row_coeff::Float64: Row crosstalk coefficient α (typical: 0.04-0.05)

  • col_coeff::Float64: Column crosstalk coefficient β (typical: 0.04-0.05)

The center kernel weight is (1-2α)(1-2β); each neighbor gets a fraction proportional to α and/or β. See create_optical_crosstalk_kernel.

create_optical_crosstalk_kernelfunctionsource ↗
create_optical_crosstalk_kernel(model::OpticalCrosstalkModel)

Create 3×3 separable optical crosstalk kernel as the outer product of the row and column 1D kernels.

apply_optical_crosstalk!functionsource ↗
apply_optical_crosstalk!(sinogram, model::OpticalCrosstalkModel) -> sinogram

Apply optical crosstalk to projection-domain sinogram (in-place, GPU-native).

Converts to intensity, applies 3×3 separable convolution, converts back.

src/detector/pcct/mc_pileup.jl

PileupResultstructsource ↗
PileupResult

Result from a single MC pulse train simulation.

Fields

  • n_true::Int: Number of true photons arriving at detector

  • n_recorded::Int: Number of recorded events (after pileup/dead-time)

  • true_energies::Vector{Float64}: Energies of arriving photons [keV]

  • recorded_energies::Vector{Float64}: Energies of recorded events [keV]

  • true_bin_counts::Vector{Int}: Photon counts per energy bin (true)

  • recorded_bin_counts::Vector{Int}: Photon counts per energy bin (recorded)

  • trigger_bins::Vector{Int}: For each recorded event, the bin of the triggering photon (the first photon to break dead time). Photons that pile up afterwards contribute energy to the event but do not get their own trigger entry — that is the count-loss mechanism. Used by compute_mc_pileup_matrix to build a non-degenerate (true → recorded) migration matrix. 0 indicates a sub-threshold trigger.

simulate_pulse_trainfunctionsource ↗
simulate_pulse_train(spectrum_weights, energies, count_rate, dead_time_ns;
                      observation_time_s=1e-3,
                      model=:seminonparalyzable,
                      f_retrigger=0.3,
                      thresholds_keV=[20.0, 35.0, 55.0, 70.0],
                      rng=Random.default_rng()) -> PileupResult

Simulate a single pulse train with dead-time effects and pileup.

Generates Poisson-distributed photon arrival times for one pixel, assigns energies from the incident spectrum, and applies dead-time gating with energy summation for piled-up events.

Arguments

  • spectrum_weights: Normalized spectral weights (probability per energy bin)

  • energies: Energy values in keV corresponding to weights

  • count_rate: True photon count rate [photons/s per pixel]

  • dead_time_ns: Detector dead time [nanoseconds]

Keyword Arguments

  • observation_time_s: Duration to simulate [seconds] (default: 1 ms)

  • model: Dead-time model — :nonparalyzable or :seminonparalyzable

  • f_retrigger: Retrigger probability for seminonparalyzable (default: 0.3)

  • thresholds_keV: Energy thresholds for binning

  • rng: Random number generator

Returns

  • PileupResult with true and recorded counts per bin

compute_mc_pileup_matrixfunctionsource ↗
compute_mc_pileup_matrix(thresholds_keV, spectrum_weights, energies,
                          count_rate, dead_time_ns;
                          n_trials=10000,
                          observation_time_s=1e-3,
                          model=:seminonparalyzable,
                          seed=42) -> Matrix{Float64}

Compute the spectral-migration matrix S from Monte Carlo pulse-train simulations. S[i, j] = fraction of true bin-j photons that end up recorded in bin i. Column sums are ≤ 1 — the column-j deficit is the count-loss for bin-j photons that fell during another event's dead time and contributed energy without seeding a recorded event of their own.

Algorithm

For each MC trial we run simulate_pulse_train (Poisson arrivals → spectrum-CDF energies → dead-time gating with energy summation), and the trial result links each recorded event to the bin of its triggering photon (the one that broke dead time). We accumulate transition_counts[recorded_bin, trigger_bin] += 1 per event, count every incident photon in total_true[bin], and divide column-wise: S[i, j] = transition_counts[i, j] / total_true[j].

This is non-degenerate by construction (each column reflects what happens to the photons of a specific true bin) and obeys the physical asymmetry that pile-up can only push counts UP in energy (S[i, j] ≈ 0 for i < j).

Arguments

  • thresholds_keV: Energy threshold values [keV]

  • spectrum_weights: Normalized spectral weights

  • energies: Energy values [keV]

  • count_rate: True photon count rate [photons/s per pixel]

  • dead_time_ns: Detector dead time [ns]

Keyword Arguments

  • n_trials: Number of MC pulse trains to simulate (default: 10000)

  • observation_time_s: Duration per trial (default: 1 ms)

  • model: :nonparalyzable or :seminonparalyzable

  • seed: Random seed for reproducibility

Returns

  • Matrix{Float64}: [n_bins × n_bins] spectral migration matrix

Example

energies, weights = load_spectrum(120)
S = compute_mc_pileup_matrix(
    [20.0, 35.0, 55.0, 70.0],
    weights, energies,
    1e8, 5.0;   # 100 Mcps, 5 ns dead time
    n_trials=50000
)

src/detector/pcct/mc_response.jl

default_mc_drm_pathfunctionsource ↗
default_mc_drm_path() -> String

Return the path to the bundled MC detector response file (cdte_response_v4.jls, co-located with this source file in src/detector/pcct/).

MCResponseDatastructsource ↗
MCResponseData

Container for MC-simulated CdTe detector response data.

Fields

  • energies_keV::Vector{Int}: Incident photon energies [keV] (typically 1:140)

  • thresholds_keV::Vector{Int}: Energy threshold values [keV]

  • R_total::Matrix{Float64}: [n_energies × n_thresholds] — cumulative counts per incident photon above each threshold (summed over 3×3 pixel neighborhood)

  • R_perpixel::Array{Float64,4}: [n_energies × 3 × 3 × n_thresholds] — per-pixel response

  • Var_total::Matrix{Float64}: [n_energies × n_thresholds] — variance of total counts

  • Cov_total::Array{Float64,3}: [n_energies × n_thresholds × n_thresholds] — covariance

  • Cov_pixpair::Array{Float64,5}: [n_energies × 9 × 9 × n_thresholds × n_thresholds]

load_mc_responsefunctionsource ↗
load_mc_response(npz_path::String) -> MCResponseData

Load MC-simulated CdTe detector response from a .npz file.

The NPZ file must contain arrays produced by the MC detector simulation code:

  • energies_keV: (n_E,) incident energies

  • thresholds_keV: (n_t,) threshold values

  • R_total: (n_E, n_t) cumulative counts ≥ threshold per incident photon

  • R_perpixel: (n_E, 3, 3, n_t) per-pixel response

  • Var_total: (n_E, n_t) variance

  • Cov_total: (n_E, n_t, n_t) covariance

  • Cov_pixpair: (n_E, 9, 9, n_t, n_t) pixel-pair covariance

Example

data = load_mc_response(default_mc_drm_path())
println("Energies: $(data.energies_keV[1])–$(data.energies_keV[end]) keV")
println("Thresholds: $(data.thresholds_keV) keV")
println("R_total size: $(size(data.R_total))")
mc_cumulative_to_binsfunctionsource ↗
mc_cumulative_to_bins(R_cumulative, thresholds_keV) -> Matrix{Float64}

Convert cumulative threshold counts R(E, ≥t) to differential bin counts.

MC R_total[E, k] = expected counts with deposited energy ≥ threshold[k]. This is cumulative (like survival function). For energy BINS we need:

R_bin[E, k] = R_cumulative[E, k] - R_cumulative[E, k+1]   (for k < n_t)
R_bin[E, n_t] = R_cumulative[E, n_t]                       (highest bin: ≥ last threshold)

Arguments

  • R_cumulative: [n_E × n_t] cumulative response (counts ≥ threshold)

  • thresholds_keV: Threshold values (must be sorted ascending)

Returns

  • Matrix{Float64}: [n_E × n_t] differential bin counts

compute_mc_drmfunctionsource ↗
compute_mc_drm(detector::PhotonCountingDetector, kVp::Real;
                npz_path::String=default_mc_drm_path(),
                n_energy_points::Int=200) -> Matrix{Float64}

Compute a Detector Response Matrix from MC simulation data.

Returns D[i, b] = probability that a photon of energy E_i registers in bin b.

Algorithm

  1. Load MC response (cumulative counts ≥ threshold per incident photon)

  2. Convert to differential bins (counts in each energy window)

  3. Map MC thresholds to detector's target thresholds (interpolation if needed)

  4. Interpolate from MC's 1-keV spacing to BasisSimulator's uniform energy grid

Arguments

  • detector: PhotonCountingDetector with target energy thresholds

  • kVp: Maximum tube voltage [keV]

  • npz_path: Path to MC detector_response.npz file (default: bundled file)

  • n_energy_points: Output energy grid resolution (default: 200)

Returns

  • Matrix{Float64}: [n_energy_points × n_bins] DRM matrix

Example

det = BasisSimulator._build_pcct_detector(PCCTScanner(energy_thresholds = [20.0, 35.0, 55.0, 70.0]))
D = compute_mc_drm(det, 120.0)  # uses bundled response file
compute_mc_count_momentsfunctionsource ↗
compute_mc_count_moments(detector, energies, weights; η=nothing, R=nothing)

Compute spectrum-averaged first and second moments of differential PCCT bin counts from the bundled Monte Carlo detector response. The returned covariance is the compound-Poisson covariance per incident spectral photon, E[Cov(Y|E) + E[Y|E]E[Y|E]′]; an ideal one-count categorical detector therefore reduces exactly to independent Poisson bins (fano == 1, zero correlation).

The MC cumulative-threshold covariance is transformed to the detector's differential bins, interpolated in energy, and scaled to the same DRM row used by the forward projector. mean, fano, correlation, and covariance are returned as Float64 arrays.

src/detector/photon_counting.jl

PhotonCountingDetectorstruct
PhotonCountingDetector{T<:AbstractFloat}

Photon-counting detector specification for PCCT simulation.

Fields

Detector Material Properties

  • material::DetectorMaterialPCCT: Semiconductor material (CdTe, CZT, Si)

  • thickness_mm::T: Sensor thickness in mm (typical: 1.5-3.0 mm)

  • pixel_size_mm::Tuple{T,T}: Native dexel size at detector face (row, col) in mm

Energy Binning

  • energy_thresholds_keV::Vector{T}: Energy threshold values in keV

  • energy_resolution_keV::T: Energy resolution FWHM at 60 keV (typical: 8-12 keV)

Charge Sharing

  • charge_sharing_fwhm_mm::T: Charge cloud FWHM in mm (typical: 0.05-0.15 mm)

  • enable_charge_sharing::Bool: Whether to simulate charge sharing

Pulse Pile-up

  • dead_time_ns::T: Detector dead time in nanoseconds (typical: 20-100 ns)

  • enable_pile_up::Bool: Whether to simulate pulse pile-up

Anti-Coincidence

  • enable_anti_coincidence::Bool: Whether to apply anti-coincidence logic

  • coincidence_window_ns::T: Time window for coincidence detection (typical: 20-50 ns)

Noise

  • electronic_noise_keV::T: Electronic noise RMS in keV (typical: 1-2 keV)

Control

  • seed::Union{Nothing, Int}: Random seed for reproducibility

Example

# NAEOTOM Alpha-like detector
detector = PhotonCountingDetector(
    material = CDTE_MATERIAL,
    thickness_mm = 1.6,
    pixel_size_mm = (0.302, 0.302),
    energy_thresholds_keV = [20.0, 35.0, 55.0, 70.0],
    charge_sharing_fwhm_mm = 0.08,
    dead_time_ns = 5.0
)

Built from a PCCTScanner by the internal _build_pcct_detector(scanner).


PhotonCountingDetector(; kwargs...)

Construct a PhotonCountingDetector with keyword arguments.

Keyword Arguments

  • material::DetectorMaterialPCCT=CDTE_MATERIAL: Detector material

  • thickness_mm::Float64=1.6: Sensor thickness

  • pixel_size_mm::Tuple{Float64,Float64}=(0.302, 0.302): Pixel size (row, col)

  • energy_thresholds_keV::Vector{Float64}=[20.0, 35.0, 55.0, 70.0]: Energy thresholds

  • energy_resolution_keV::Float64=10.0: Energy resolution FWHM at 60 keV (informational — see note)

  • charge_sharing_fwhm_mm::Float64=0.08: Charge cloud FWHM (informational — see note)

  • enable_charge_sharing::Bool=true: Enable charge sharing simulation

  • dead_time_ns::Float64=5.0: Detector dead time

  • enable_pile_up::Bool=true: Enable pile-up simulation

  • enable_anti_coincidence::Bool=true: Enable anti-coincidence correction

  • coincidence_window_ns::Float64=30.0: Coincidence time window

  • electronic_noise_keV::Float64=1.5: Electronic noise RMS (informational — see note)

  • seed::Union{Nothing,Int}=nothing: Random seed

Informational spectral-response fields

energy_resolution_keV, charge_sharing_fwhm_mm, and electronic_noise_keV describe the detector but do not drive the simulated spectral response. The effective energy resolution, charge sharing, K-escape, and electronic-noise smearing are all encoded in the Monte-Carlo-derived detector response matrix (cdte_response_v4.jls, see mc_response.jl), which is the single source of detector physics on the forward path. These fields are informational only.

EnergyResolvedSinogramstructsource ↗
EnergyResolvedSinogram{T,A}

Container for energy-resolved sinogram data from photon-counting CT.

Each energy bin contains a full sinogram, allowing spectral analysis and reconstruction at specific energies or with spectral weighting.

Fields

  • bins::Vector{A}: Vector of sinograms, one per energy bin

  • thresholds_keV::Vector{T}: Lower energy threshold of each bin (N values for N bins)

  • n_cols::Int: Number of detector columns

  • n_rows::Int: Number of detector rows

  • n_angles::Int: Number of projection angles

Energy Bins

For thresholds [T₁, T₂, ..., Tₙ]:

  • Bin 1: T₁ ≤ E < T₂

  • Bin 2: T₂ ≤ E < T₃

  • ...

  • Bin N: Tₙ ≤ E < ∞ (or max tube kVp)

Example

# After PCCT forward projection
pcct_sino = pcct_forward_project(volume, geom, detector, spectrum)

# Access individual energy bins
bin1 = pcct_sino.bins[1]  # Lowest energy bin
bin4 = pcct_sino.bins[4]  # Highest energy bin

# Total counts (sum of all bins)
total = sum(pcct_sino.bins)

See also: pcct_forward_project, PhotonCountingDetector

n_energy_binsfunctionsource ↗
n_energy_bins(sino::EnergyResolvedSinogram) -> Int

Number of energy bins in sino, length(sino.bins).

spatial_bin!functionsource ↗
spatial_bin!(output, input, factor)

Sum factor × factor native dexels into one binned pixel (GPU-native).

Operates on count-domain data (before log transform). The sum of independent Poisson counts is Poisson-distributed with rate = sum of rates, so spatial binning before Poisson noise is physically correct.

Arguments

  • output::AbstractArray{T,3}: Binned sinogram (n_cols÷factor, n_rows÷factor, n_angles)

  • input::AbstractArray{T,3}: Native-res sinogram (n_cols, n_rows, n_angles)

  • factor::Int: Binning factor (2 for 2×2)

pcct_forward_projectfunctionsource ↗
pcct_forward_project(mask, geom, detector; energies, weights, materials, kwargs...) -> EnergyResolvedSinogram

Perform polychromatic photon-counting CT forward projection with MC DRM spectral response.

This is the main PCCT forward projection API. It:

  1. Computes energy-dependent μ-volumes from mask + materials (per energy)

  2. Forward projects at each energy using Siddon ray-tracing

  3. Applies quantum efficiency η(E) weighting (material-dependent)

  4. Applies spectral response matrix R(E,b) from MC DRM for realistic energy binning

  5. Applies spatial binning if native dexel resolution is used

  6. Returns energy-resolved sinograms in line-integral domain

All detector physics (charge sharing, fluorescence escape, pileup, anti-coincidence) are encoded in the MC DRM R matrix. No separate analytical detector effects are applied.

Arguments

  • mask::AbstractArray{UInt8,3}: Material mask (region indices)

  • geom::CTGeometry: CT geometry

  • detector::PhotonCountingDetector: PCCT detector specification

Keyword Arguments

  • energies::AbstractVector: Spectral energies in keV

  • weights::AbstractVector: Spectral weights (normalized photon fluence)

  • materials::Vector: Material vector (from get_region_materials())

  • I0::Real=1e6: Reference photon count per detector element

Returns

EnergyResolvedSinogram with one sinogram per energy bin.

Physics

The per-bin photon count is: Nb = ΣE R(E,b) × S(E) × η(E) × exp(-∫μ(x,E)dl)

where:

  • R(E,b) = MC DRM spectral response matrix entry (probability E registers in bin b)

  • S(E) = normalized tube spectrum weight

  • η(E) = quantum detection efficiency of detector crystal

  • ∫μ(x,E)dl = line integral of energy-dependent attenuation

Example

detector = BasisSimulator._build_pcct_detector(PCCTScanner(energy_thresholds = [20.0, 35.0, 55.0, 70.0]))
energies, weights = load_spectrum(120)
materials = get_region_materials()

pcct_sino = pcct_forward_project(
    phantom.mask, geom, detector;
    energies=energies, weights=weights,
    materials=materials
)

# Access energy bins
low_E_sino = pcct_sino.bins[1]   # 20-35 keV
high_E_sino = pcct_sino.bins[4]  # 70+ keV

See also: PhotonCountingDetector, EnergyResolvedSinogram

apply_pcct_noise!functionsource ↗
apply_pcct_noise!(sino::EnergyResolvedSinogram, I0;
                  seed=nothing, ws_noise_staging=nothing, ws_rng=nothing,
                  noise_reduction=0.0) -> EnergyResolvedSinogram

Draw exact per-bin Poisson count realizations in-place.

Incident photons are Poisson distributed and the MC-LUT detector response (quantum efficiency + spectral migration) acts as independent per-photon thinning, so the recorded counts in each bin are exactly Poisson with the DRM-shaped expected counts as their mean. Charge-sharing double counting across bins is the one residual effect outside this model. NO electronic noise — eliminated by the counting thresholds, the fundamental PCCT advantage.

I0_bins must be the SAME per-bin air calibration the sinograms are normalized against (ws.I0, per ray), so a downstream I0 · exp(-h) recovers the sampled integer counts exactly.

For each bin b and ray:

  1. expected counts λ = I0[col, row, b] · exp(-h);

  2. N ~ Poisson(λ) — exact integer sampling at every λ (_poisson_sample);

  3. optional noise_reduction blend λ + (1 - nr)·(N - λ); any nonzero value leaves the strict Poisson count model (vendor-denoising surrogate only);

  4. the measured count is written verbatim to raw_out[b] when provided (TRUE ZEROS preserved), then floored at 1 ONLY for the log-domain bins — a zero count has no finite log line integral — and stored back as h = -log(max(N, 1) / I0[col, row, b]).

The simulator imposes no zero-count policy on the counts product: any epsilon/interpolation convention for zeros is a downstream (user) choice.

Arguments

  • sino::EnergyResolvedSinogram: per-bin log line integrals, mutated in place.

  • I0: the air response per ray and bin, [n_cols, n_rows, n_bins] (the workspace's I0_cpu).

Keyword Arguments

  • seed::Union{Nothing,Int}: RNG seed for reproducibility.

  • ws_noise_staging: optional pre-allocated sinogram-shaped CPU buffer.

  • ws_rng: optional pre-allocated RNG (re-seeded each call).

  • noise_reduction::Float64: 0.0 = exact Poisson counts (default).

  • raw_out: optional vector of per-bin arrays (same backend/shape as the bins) that receives the measured counts BEFORE the log-domain floor. With noise_reduction = 0 these are the exact integer draws.

  • rng_mode::Symbol: :serial (default) draws every element from one MersenneTwister in index order, which is the stream earlier versions produced and the one an oracle test can reproduce. :threaded splits the sinogram into a fixed number of chunks with an independent stream each, and is 10-15x faster on a clinical photon-counting scan (the serial draw is about 78 % of such a simulate!: 16 s of 20 s at 1195 x 62 x 1200 x 4 bins). It gives a DIFFERENT realisation of the same distribution, and the same one for any thread count.

get_detector_material_propertiesfunctionsource ↗
get_detector_material_properties(material::DetectorMaterialPCCT) -> NamedTuple

Master lookup for ALL material-dependent PCCT detector parameters.

This is the SINGLE source of truth for material constants. All physics functions dispatch through this function to ensure material-agnostic operation.

Returns NamedTuple with fields:

  • elements: Element symbols

  • atomic_numbers: Z values for each element

  • mass_fractions: Mass fraction of each element in compound

  • density_g_cm3: Crystal density

  • k_edges_keV: K-edge energies for each element

  • k_alpha_keV: K-α fluorescence line energies

  • k_yields: Fluorescence yields ω_K for each element

  • k_fluorescence_range_mm: Mean free path of fluorescence X-rays in crystal

  • mu_e_tau_e: Electron mobility-lifetime product (cm²/V)

  • mu_h_tau_h: Hole mobility-lifetime product (cm²/V)

  • bias_voltage_V: Typical operating bias voltage

  • pair_creation_energy_eV: Energy per electron-hole pair

  • fano_factor: Fano factor for intrinsic energy resolution

  • charge_cloud_sigma_mm: Charge cloud size at typical bias

  • energy_resolution_fwhm_keV: Measured system FWHM at 60 keV

References

  • CdTe: Taguchi & Iwanczyk, Med Phys 2013; del Risco Norrlid et al, NIMPA 2017

  • CZT: Pennicard et al, JINST 2014; Veale et al, NIMPA 2014

  • Si: Persson et al, Phys Med Biol 2014; Fredenberg et al, NIMPA 2010

Example

props = get_detector_material_properties(CDTE_MATERIAL)
props.density_g_cm3  # 5.85
props.k_edges_keV    # [26.7, 31.8]
get_detector_material_attenuationfunctionsource ↗
get_detector_material_attenuation(material::DetectorMaterialPCCT, energy_keV::Real) -> Float64

Get linear attenuation coefficient μ (cm⁻¹) for PCCT detector material at given energy.

Uses the tabulated data in DetectorEfficiency.jl (SCINTILLATOR_MU_DATA) with log-linear interpolation. This provides NIST XCOM-calibrated values with proper K-edge handling.

Arguments

  • material::DetectorMaterialPCCT: Detector material enum

  • energy_keV::Real: Photon energy in keV

Returns

  • Float64: Linear attenuation coefficient in cm⁻¹

Example

μ = get_detector_material_attenuation(CDTE_MATERIAL, 60.0)  # ~33 cm⁻¹
μ = get_detector_material_attenuation(SI_MATERIAL, 60.0)    # ~0.46 cm⁻¹
quantum_efficiencyfunctionsource ↗
quantum_efficiency(material::DetectorMaterialPCCT, thickness_mm::Real, energy_keV::Real) -> Float64

Compute quantum detection efficiency η(E) for a PCCT detector crystal.

The probability that a photon of energy E is absorbed in the detector: η(E) = 1 - exp(-μ_det(E) × d)

where μ_det(E) is the linear attenuation of the detector material and d is thickness.

Arguments

  • material::DetectorMaterialPCCT: Detector crystal material

  • thickness_mm::Real: Crystal thickness in mm

  • energy_keV::Real: Photon energy in keV

Returns

  • Float64: Detection efficiency in [0, 1]

Physics

  • CdTe 1.6mm: η ≈ 0.99 at 60 keV (nearly opaque)

  • CZT 2.0mm: η ≈ 0.99 at 60 keV (similar to CdTe)

  • Si 1.6mm: η ≈ 0.05 at 60 keV (mostly transparent!)

  • Si needs 30+ mm thickness for >90% efficiency at CT energies

Example

η_cdte = quantum_efficiency(CDTE_MATERIAL, 1.6, 60.0)  # ≈ 0.995
η_si = quantum_efficiency(SI_MATERIAL, 1.6, 60.0)      # ≈ 0.071
quantum_efficiency_vectorfunctionsource ↗
quantum_efficiency_vector(material::DetectorMaterialPCCT, thickness_mm::Real,
                          energies::AbstractVector) -> Vector{Float64}

Compute quantum efficiency η(E) for a vector of energies (batch operation).

More efficient than calling quantum_efficiency in a loop for spectrum processing.

src/detector/physics_pipeline.jl

PhysicsConfigstructsource ↗
PhysicsConfig

Configuration for physics effects pipeline.

All fields are optional - set to nothing to skip that effect.

Fields

  • fill_factor: FillFactorModel for detector active area

  • scatter: ScatterModel for patient scatter (correction decoupled to notebook level)

  • optical_crosstalk: OpticalCrosstalkModel for optical crosstalk

  • focal_spot: FocalSpot for geometric blur

  • detector_efficiency: DetectorEfficiency for scintillator response

  • lag: LagModel for temporal persistence

  • noise_seed: Random seed for noise (for reproducibility)

  • energy_keV: X-ray energy for filter calculations (default: 60.0)

  • heel_effect: HeelEffect for anode self-attenuation (intensity domain)

default_physics_configfunctionsource ↗
default_physics_config(; kwargs...) -> PhysicsConfig

Create a physics configuration with default settings.

By default, all effects are disabled. Use keyword arguments to enable specific effects with their models.

src/detector/scatter.jl

ScatterModelstructsource ↗
ScatterModel

Parameters for analytic scatter simulation using XCIST-style convolution model.

The scatter contribution at each detector pixel is: S = convolve(I × p × C × scale, kernel)

where:

  • I = primary intensity (exp(-projection))

  • p = path length (-log(I/I₀) = projection value)

  • C = base scatter coefficient (~0.025)

  • scale = user-adjustable scale factor

  • kernel = scatter point spread function

create_scatter_kernel_spatialfunctionsource ↗
create_scatter_kernel_spatial(model::ScatterModel) -> Matrix{Float64}

Create 2D scatter kernel for spatial domain convolution.

Returns a compact kernel (max size MAX_SCATTER_KERNEL_SIZE) for GPU-compatible spatial convolution. The kernel is truncated at 3σ.

compute_scatter_energy_weightsfunctionsource ↗
compute_scatter_energy_weights(energies::Vector{Float64}) -> Vector{Float64}

Compute per-energy Compton scatter fractions for the tube spectrum.

Returns a weight for each energy representing the probability that a photon at that energy contributes to scatter (vs photoelectric absorption). This is the pluggable interface for the scatter energy model:

  • Analytical (current): 1 / (1 + (20/E)³) — empirical Compton fraction matching NIST XCOM within 5% for 20–140 keV diagnostic range.

  • MC LUT (future): Load pre-computed per-energy scatter data from table.

These weights are combined with the spatial scatter field to produce the full per-energy scatter contribution. The detector model (EICT integration or PCCT DRM binning) then determines how scatter enters the measured signal.

Returns

Vector{Float64} of length length(energies), values in [0, 1].

References

  • NIST XCOM: Photon cross-section database (physics.nist.gov/xcom)

  • Klein-Nishina formula: Compton scattering cross-section vs energy

inject_scatter!functionsource ↗
inject_scatter!(sinogram, scatter_field, scatter_weight)

Add scatter to a single polychromatic sinogram (EICT path).

The scatter contribution at each pixel is scatter_field × scatter_weight, added in intensity domain. scatter_weight is the spectrum-integrated Compton fraction: Σ(w_E × ew_E) / Σ(w_E) where ew_E are per-energy weights from compute_scatter_energy_weights.

Matches the physics of inject_scatter_bins! (PCCT) — same spatial field, same per-energy model — but integrated over the full spectrum since an energy-integrating detector sums all energies.

References

  • Ohnesorge B et al., Eur Radiol 1999 (convolution scatter model)

inject_scatter_bins!functionsource ↗
inject_scatter_bins!(bins, scatter_field, I0, I0_all, bin_scatter_weights; subtract = false)

Add (or with subtract, remove) scatter in the per-bin sinograms, per ray.

scatter_field is the scatter-to-air fraction from the Ohnesorge convolution of the combined transmission; the scatter counts of a ray and bin are scatter_field × I0_all[col, row] × bin\_weight\_b — scaled by that ray's own whole-spectrum air response, so the scatter behind a bowtie falls off across the fan with the primary — with bin_weight_b from compute_scatter_bin_weights (the per-energy scatter spectrum through the detector response). I0[col, row, b] is the per-ray, per-bin air response the bins are normalised against.

References

  • Ohnesorge B et al., Eur Radiol 1999 (convolution scatter model)

compute_scatter_bin_weightsfunctionsource ↗
compute_scatter_bin_weights(energies, weights, energy_weights, η, R) -> Vector{Float64}

Compute per-bin scatter weights from per-energy weights convolved through the DRM.

This replaces the old compute_scatter_bin_fractions with an explicit energy_weights input, making the per-energy scatter model visible and pluggable.

The per-bin weight for bin b is: w_b = Σ_E weights[E] × energy_weights[E] × η[E] × R[E, b] normalized to sum to 1.0.

Arguments

  • energies: Photon energies (keV)

  • weights: Tube spectrum weights (photons per energy bin)

  • energy_weights: Per-energy Compton scatter fractions from compute_scatter_energy_weights

  • η: Quantum detection efficiency per energy

  • R: Detector response matrix (n_energies × n_bins)

Returns

Vector{Float64} of length n_bins, summing to 1.0.

estimate_phantom_diameter_cmfunctionsource ↗
estimate_phantom_diameter_cm(mask::AbstractArray{<:Unsigned,3}, voxel_size_mm) -> Float64

Estimate effective phantom diameter from material mask.

Computes the effective diameter as sqrt(width × height) from the bounding box of non-air voxels (region index > 0) in the central slice.

Arguments

  • mask: Material index volume [nx, ny, nz], where 0 = air

  • voxel_size_mm: Tuple of voxel dimensions (dx, dy, dz) in mm

Returns

Effective diameter in cm.

Notes

Uses the effective diameter formula: d_eff = sqrt(AP × LAT), which is standard in CT dosimetry (AAPM Task Group 220).

geometry_aware_scatter_modelfunctionsource ↗
geometry_aware_scatter_model(scanner::Scanner; scale_factor=1.0, kernel_type=:gaussian, phantom_diameter_cm=nothing)

Create a spatial-only scatter model scaled for scanner geometry and phantom size.

Energy dependence is handled separately by compute_scatter_energy_weights(), which returns per-energy Compton fractions. This separation enables pluggable energy models (analytical Compton fraction now, MC LUT in future).

Scaling Behavior

  • Geometry: SCATTER_REF_COEFFICIENT × (air_gap_ref / air_gap)²

  • Size: × (diameter / 30)^1.5

  • Kernel FWHM: physical_fwhm_mm / detector_pixel_pitch_mm

  • scale_factor applies on top of all automatic scaling

Example

scanner = EICTScanner()
model = geometry_aware_scatter_model(scanner)
# model.scatter_coefficient ≈ 0.025 × geometry_scale × size_scale

# GE Revolution + large patient
scanner = EICTScanner(source_to_isocenter=626.0, source_to_detector=1097.0)
model = geometry_aware_scatter_model(scanner; phantom_diameter_cm=40.0)
# geometry_scale ≈ 0.76, size_scale ≈ 1.54
# model.scatter_coefficient ≈ 0.025 * 0.76 * 1.54 ≈ 0.029

See also: ScatterModel, compute_scatter_energy_weights

estimate_scatter_field!functionsource ↗
estimate_scatter_field!(output, sinogram, model::ScatterModel; ...) -> output

Compute the scatter intensity spatial distribution WITHOUT modifying the input sinogram.

Ohnesorge convolution model — returns only the spatial scatter intensity field. The output is in intensity units relative to I0=1. Used by both EICT and PCCT paths.

Returns

output filled with scatter intensity at each detector pixel. Multiply by I0_total to get absolute scatter counts.

Constants & labels

The semantic region labels that the built-in phantom masks store (air, water, the Gammex 472 calcium and iodine inserts). Each is a RegionLabel value.

src/object/phantom.jl

RegionLabelenumsource ↗
@enum RegionLabel::UInt8

Region labels for phantom masks.

Each voxel in the mask is assigned a label indicating its material type. Used for automated validation after reconstruction.

All labels are prefixed with REGION_ to avoid conflicts with material exports.

REGION_AIRconstantsource ↗
REGION_AIR::RegionLabel  # mask value 1

RegionLabel for the air inside the field of view. A phantom mask stores it as the integer 1; compare with phantom.mask .== UInt8(REGION_AIR), since an Enum never equals an integer.

REGION_BACKGROUNDconstantsource ↗
REGION_BACKGROUND::RegionLabel  # mask value 0

RegionLabel for the background outside the phantom body (air). A phantom mask stores it as the integer 0; compare with phantom.mask .== UInt8(REGION_BACKGROUND), since an Enum never equals an integer.

REGION_CA_100constantsource ↗
REGION_CA_100::RegionLabel  # mask value 11

RegionLabel for the Gammex 472 calcium insert, 100 mg/mL. A phantom mask stores it as the integer 11; compare with phantom.mask .== UInt8(REGION_CA_100), since an Enum never equals an integer.

REGION_CA_200constantsource ↗
REGION_CA_200::RegionLabel  # mask value 12

RegionLabel for the Gammex 472 calcium insert, 200 mg/mL. A phantom mask stores it as the integer 12; compare with phantom.mask .== UInt8(REGION_CA_200), since an Enum never equals an integer.

REGION_CA_300constantsource ↗
REGION_CA_300::RegionLabel  # mask value 13

RegionLabel for the Gammex 472 calcium insert, 300 mg/mL. A phantom mask stores it as the integer 13; compare with phantom.mask .== UInt8(REGION_CA_300), since an Enum never equals an integer.

REGION_CA_400constantsource ↗
REGION_CA_400::RegionLabel  # mask value 14

RegionLabel for the Gammex 472 calcium insert, 400 mg/mL. A phantom mask stores it as the integer 14; compare with phantom.mask .== UInt8(REGION_CA_400), since an Enum never equals an integer.

REGION_CA_50constantsource ↗
REGION_CA_50::RegionLabel  # mask value 10

RegionLabel for the Gammex 472 calcium insert, 50 mg/mL. A phantom mask stores it as the integer 10; compare with phantom.mask .== UInt8(REGION_CA_50), since an Enum never equals an integer.

REGION_CA_500constantsource ↗
REGION_CA_500::RegionLabel  # mask value 15

RegionLabel for the Gammex 472 calcium insert, 500 mg/mL. A phantom mask stores it as the integer 15; compare with phantom.mask .== UInt8(REGION_CA_500), since an Enum never equals an integer.

REGION_CA_600constantsource ↗
REGION_CA_600::RegionLabel  # mask value 16

RegionLabel for the Gammex 472 calcium insert, 600 mg/mL. A phantom mask stores it as the integer 16; compare with phantom.mask .== UInt8(REGION_CA_600), since an Enum never equals an integer.

REGION_I_10_0constantsource ↗
REGION_I_10_0::RegionLabel  # mask value 24

RegionLabel for the Gammex 472 iodine insert, 10 mg/mL. A phantom mask stores it as the integer 24; compare with phantom.mask .== UInt8(REGION_I_10_0), since an Enum never equals an integer.

REGION_I_15_0constantsource ↗
REGION_I_15_0::RegionLabel  # mask value 25

RegionLabel for the Gammex 472 iodine insert, 15 mg/mL. A phantom mask stores it as the integer 25; compare with phantom.mask .== UInt8(REGION_I_15_0), since an Enum never equals an integer.

REGION_I_20_0constantsource ↗
REGION_I_20_0::RegionLabel  # mask value 26

RegionLabel for the Gammex 472 iodine insert, 20 mg/mL. A phantom mask stores it as the integer 26; compare with phantom.mask .== UInt8(REGION_I_20_0), since an Enum never equals an integer.

REGION_I_2_0constantsource ↗
REGION_I_2_0::RegionLabel  # mask value 20

RegionLabel for the Gammex 472 iodine insert, 2.0 mg/mL. A phantom mask stores it as the integer 20; compare with phantom.mask .== UInt8(REGION_I_2_0), since an Enum never equals an integer.

REGION_I_2_5constantsource ↗
REGION_I_2_5::RegionLabel  # mask value 21

RegionLabel for the Gammex 472 iodine insert, 2.5 mg/mL. A phantom mask stores it as the integer 21; compare with phantom.mask .== UInt8(REGION_I_2_5), since an Enum never equals an integer.

REGION_I_5_0constantsource ↗
REGION_I_5_0::RegionLabel  # mask value 22

RegionLabel for the Gammex 472 iodine insert, 5.0 mg/mL. A phantom mask stores it as the integer 22; compare with phantom.mask .== UInt8(REGION_I_5_0), since an Enum never equals an integer.

REGION_I_7_5constantsource ↗
REGION_I_7_5::RegionLabel  # mask value 23

RegionLabel for the Gammex 472 iodine insert, 7.5 mg/mL. A phantom mask stores it as the integer 23; compare with phantom.mask .== UInt8(REGION_I_7_5), since an Enum never equals an integer.

REGION_SOLID_WATERconstantsource ↗
REGION_SOLID_WATER::RegionLabel  # mask value 3

RegionLabel for the solid-water phantom body (the Gammex 472 cylinder). A phantom mask stores it as the integer 3; compare with phantom.mask .== UInt8(REGION_SOLID_WATER), since an Enum never equals an integer.

REGION_WATERconstantsource ↗
REGION_WATER::RegionLabel  # mask value 2

RegionLabel for the water. A phantom mask stores it as the integer 2; compare with phantom.mask .== UInt8(REGION_WATER), since an Enum never equals an integer.