← Examples/

QRM-Thorax Pure-Material PCCT VMI: Photon-Counting Reference

The photon-counting counterpart of notebook 07: the same analytic QRM-Thorax phantom with four pure-material rods (water · lipid · collagen · 5 mg/mL iodine), the same reconstruction grid, VMI energies and ROIs, scanned by the Siemens NAEOTOM Alpha model of the basis-spectral-denoising study at 140 kVp with four energy bins. All four corrected bins enter the package's VMI chain, BS.vmi_pipeline, with that study's denoiser, reconstruction windows and spectral pair, and every rod is checked against the HU its material has in theory and against the same chain applied to the noise-free acquisition.

StageMatrixVoxel (mm)Extent
Phantom1600 × 1100 × 200.2 isotropic320 × 220 × 4 mm
Recon512 × 512 × 30.625 isotropicFOV 32 cm × 1.875 mm z
Collimation5.0 mm nominal—cone-guard rows added automatically
ScannerSiemens NAEOTOM Alpha0.302 × 0.353 mm pixels (2 × 2 binned)144 rows, 50 cm scan field
ProtocolPCCT, 4 bins140 kVp · 1200 views · 0.5 s rotation · 5 sub-views per viewthe physical scan's 10.12 mGy CTDIvol
qrm_thorax_slice (analytic 2-D shapes) → bore rods 9–12 into the heart → tile z → BS.Phantom
   → simulate!(…; keep_projection = true)    noise-free draw; keeps the projection
   → simulate!(…; projection)                calibration and measured draws: only the noise is new
        4 corrected bins + per-ray air counts I0[col, row, bin]
   → BS.spectral_basis(ws; I0)               (per-ray absolute response of each bin)
   → calibration draw → spectral_pair        (pair_basis, composite_energy)
   → BS.vmi_pipeline(; denoiser = SpectralHYPR(), fbp_filter = PairFilter(…), pair_basis,
                       composite_energy)     (projection HYPR → K = 4 decomposition → FDK of
                                              composite + complement → ACNR → image HYPR → VMIs)
   → per-rod measured vs theoretical HU at 50 / 70 / 100 / 140 keV, and vs the noise-free draw

When to reach for this notebook

Use 08 for photon-counting VMI accuracy on a body-sized phantom and field of view at the resolution a NAEOTOM Alpha delivers. Notebook 04 runs the same scanner and chain on the Gammex 472; notebook 07 scans this phantom with dual-kVp switching.

Why pure end-members?

XA.Materials.basis_fat is ICRU-44 adipose tissue (≈ 83 % triolein, 17 % water and trace electrolytes), not a pure lipid. XA.Materials.basis_lipid (H/C/O at 0.92 g/cm³) and basis_collagen (H/C/N/O at 1.26 g/cm³) are mathematically pure end members, made for clean water/lipid/collagen decomposition checks.

Notebook Setup

begin
    import Pkg
    Pkg.activate(joinpath(@__DIR__, ".."))
end
using Markdown: @md_str, Markdown
using Statistics: mean, std, quantile
import BasisSimulator as BS
# Use CairoMakie for faithful build-time rendering. Snapshot can still isolate
# and compile independent browser-safe islands without hoisting this import.
import CairoMakie as Mke
import PlutoUI
begin
    import GPUSelect
    AT = GPUSelect.Storage()     # the backend array type, directly: MtlArray / CuArray / ROCArray
    to_gpu(x) = AT(x)
    GPU_BACKEND = (name = string(nameof(AT)),)
end;

Backend detected: CuArray

Scan Setup and Simulation

One clinical PCCT acquisition: the QRM-Thorax phantom, the Siemens NAEOTOM Alpha photon-counting scanner, the 140 kVp protocol, and the four-bin forward projection, drawn three times from one projection.

1. Phantom: QRM-Thorax with 4 Pure-Material Rods

The QRM-Thorax phantom is built analytically, from 2-D shapes: a flat-fronted superellipse body, the two lungs, the cardiac insert and the mediastinum above it, a water rod and an air rod in the lungs, six ribs and the sternum, and the vertebra with its canal, laminae and spinous process. Every dimension was measured on the labelled 1600 × 1100 (0.2 mm) mid-slice of the scanned phantom, in CT display orientation (spine at the bottom). The slice is z-tiled to 1600 × 1100 × 20 at 0.2 mm isotropic (320 × 220 × 4 mm), finer than the demagnified detector pitch, so the forward projector samples a high-resolution object.

LabelMaterialLabelMaterial
1air7air rod
2lung8(unused)
3soft tissue9rod → water
4cortical bone10rod → lipid
5bone marrow11rod → collagen
6water rod (lung)12rod → 5 mg/mL iodine

As in the reference slice, the cardiac insert is soft tissue (label 3, like the body) with the four pure-material rods 9–12 bored into it, so label 8 is unused. qrm_thorax_slice draws labels 1–7; the rods are bored below, at the centres QRM_GEOMETRY.heart_rods records. Every material is a prebuilt XA.Materials entry.

begin
    # The QRM-Thorax mid-slice as 2-D shapes, measured on the labelled 1600 × 1100 (0.2 mm)
    # reference slice. Millimetres from the slice centre, +x to the image right and +y up:
    # the CT display orientation, sternum at the top and spine at the bottom.
    const QRM_GEOMETRY = (
        # |x/a|^n + |(y - yc)/b|^n ≤ 1, cut flat at the front and back (y range)
        body = (a = 150.2, b = 106.75, n = 2.30, yc = 0.0, y = (-100.2, 101.0)),
        lungs = (a = 127.6, b = 92.2, n = 2.10, yc = 0.4, y = (-76.8, 76.8)),
        # soft tissue between the lungs above the heart; its corners at the lung apex are rounded
        mediastinum = (half_width = 16.8, corner_r = 2.0),
        heart = (center = (0.0, 18.4), r = 55.2),     # the cardiac insert, soft tissue (label 3)
        # the four pure-material rods in the heart, as the reference slice has them (labels 9–12)
        heart_rods = (
            (center = (0.0, 45.8), r = 7.5, label = 9),              # north: water
            (center = (24.9, 20.7), r = 7.5, label = 10),            # east: lipid
            (center = (0.0, -4.3), r = 7.5, label = 11),             # south: collagen
            (center = (-25.1, 20.7), r = 7.5, label = 12),           # west: 5 mg/mL iodine
        ),
        lung_rods = (
            (center = (-91.4, 0.0), r = 7.5, label = 6),             # water rod
            (center = (90.2, 0.0), r = 7.5, label = 7),              # air rod
        ),
        # the spine; `cortex` is the thickness of the cortical-bone shell, mm
        vertebra = (center = (0.0, -53.3), r = 17.4, cortex = 0.6),
        canal = (center = (0.0, -69.6), r = 8.0, cortex = 0.6),      # the spinal canal
        laminae = (corners = ((-35.4, -77.2), (35.4, -77.2), (0.0, -62.3)), cortex = 1.0),
        spinous = (corners = ((-2.6, -76.8), (2.6, -76.8), (0.0, -91.6)), cortex = 0.6),
        ribs = (                                                     # (x, y, long-axis angle in °)
            (-112.4, 64.9, 40.0), (108.9, 67.9, -40.0),
            (-138.1, 1.5, 90.0), (139.0, 6.1, 90.0),
            (-109.2, -65.9, -40.0), (109.2, -67.5, 40.0),
        ),
        rib_axes = (8.2, 3.0),                                       # semi-axes, mm
        sternum = (center = (-2.7, 89.4), axes = (7.6, 3.2)),
        marrow_scale = 0.85, # rib and sternum marrow = their cortical ellipse scaled by this
    )

    """
        qrm_thorax_slice(g = QRM_GEOMETRY; n = (1600, 1100), voxel_mm = 0.2, inserts = ())

    Label image of the QRM-Thorax mid-slice (1 air, 2 lung, 3 soft tissue, including the
    cardiac insert, 4 cortical bone, 5 marrow, 6 water rod, 7 air rod). `inserts` are extra
    discs `(center, r, label)` in mm, painted last: the heart rods of `g.heart_rods`.
    """
    function qrm_thorax_slice(g = QRM_GEOMETRY; n = (1600, 1100), voxel_mm = 0.2, inserts = ())
        superellipse(x, y, s) =
            s.y[1] ≤ y ≤ s.y[2] && abs(x / s.a)^s.n + abs((y - s.yc) / s.b)^s.n ≤ 1
        disc(x, y, c, r) = (x - c[1])^2 + (y - c[2])^2 ≤ r^2
        function ellipse(x, y, c, axes, θ)
            s, co = sincosd(θ)
            u, v = co * (x - c[1]) + s * (y - c[2]), -s * (x - c[1]) + co * (y - c[2])
            return (u / axes[1])^2 + (v / axes[2])^2 ≤ 1
        end
        # inside all three edges, each moved `inset` mm toward the interior
        function triangle(x, y, t, inset = 0.0)
            for k in 1:3
                p, q, r = t[k], t[mod1(k + 1, 3)], t[mod1(k + 2, 3)]
                ex, ey = q[1] - p[1], q[2] - p[2]
                side(px, py) = (ex * (py - p[2]) - ey * (px - p[1])) / hypot(ex, ey)
                side(x, y) * sign(side(r...)) ≥ inset || return false
            end
            return true
        end
        function lung(x, y)
            superellipse(x, y, g.lungs) || return false
            disc(x, y, g.heart.center, g.heart.r) && return false
            m = g.mediastinum
            abs(x) < m.half_width && y > g.heart.center[2] && return false
            # round the apex corner where the lung top meets the mediastinum
            cx, cy = m.half_width + m.corner_r, g.lungs.y[2] - m.corner_r
            abs(x) < cx && y > cy && hypot(abs(x) - cx, y - cy) > m.corner_r && return false
            return true
        end

        bones = (
            ((c = (rx, ry), axes = g.rib_axes, θ = θ) for (rx, ry, θ) in g.ribs)...,
            (c = g.sternum.center, axes = g.sternum.axes, θ = 0.0),
        )
        img = ones(UInt8, n)
        for j in 1:n[2], i in 1:n[1]
            x, y = (i - (n[1] + 1) / 2) * voxel_mm, (j - (n[2] + 1) / 2) * voxel_mm
            superellipse(x, y, g.body) || continue
            label = lung(x, y) ? 2 : 3    # the heart is not lung, so it is soft tissue
            for rod in g.lung_rods
                disc(x, y, rod.center, rod.r) && (label = rod.label)
            end
            for b in bones
                ellipse(x, y, b.c, b.axes, b.θ) &&
                    (label = ellipse(x, y, b.c, b.axes .* g.marrow_scale, b.θ) ? 5 : 4)
            end
            for t in (g.laminae, g.spinous)
                triangle(x, y, t.corners) && (label = triangle(x, y, t.corners, t.cortex) ? 5 : 4)
            end
            v, cn = g.vertebra, g.canal
            disc(x, y, v.center, v.r) && (label = disc(x, y, v.center, v.r - v.cortex) ? 5 : 4)
            disc(x, y, cn.center, cn.r) && (label = disc(x, y, cn.center, cn.r - cn.cortex) ? 3 : 4)
            for (c, r, l) in inserts
                disc(x, y, c, r) && (label = l)
            end
            img[i, j] = label
        end
        return img
    end
end;
const QRM_NZ = 20;    # 20 × 0.2 mm = 4 mm: a short z-invariant phantom
const QRM_VOXEL_SIZE_CM = (0.02, 0.02, 0.02);   # (x, y, z) cm: 0.2 mm isotropic

Bore 4 rod inserts into the heart where the reference slice has them (QRM_GEOMETRY.heart_rods: 15 mm rods, 25 mm north, east, south and west of a point 20.7 mm above the slice centre), as labels 9–12 (matching materials_dict):

DirectionLabelMaterial
North9basis_water
East10basis_lipid
South11basis_collagen
West12gammex_472_i5_0
# (center in mm, radius in mm, label) of each heart rod
HEART_RODS = [(rod.center, rod.r, rod.label) for rod in QRM_GEOMETRY.heart_rods];
mask_3d = let
    slice = qrm_thorax_slice(; voxel_mm = QRM_VOXEL_SIZE_CM[1] * 10, inserts = HEART_RODS)
    repeat(slice; outer = (1, 1, QRM_NZ))
end;
materials_dict = Dict{Int, BS.XA.Material}(
    # Anatomy
    1 => BS.XA.Materials.air,
    2 => BS.XA.Materials.lung,
    3 => BS.XA.Materials.muscle,           # soft tissue: ICRU-44 muscle
    4 => BS.XA.Materials.corticalbone,
    5 => BS.XA.Materials.marrow_red,
    6 => BS.XA.Materials.water,            # water rod in the lung
    7 => BS.XA.Materials.air,              # air rod
    8 => BS.XA.Materials.muscle,           # unused: the heart insert is soft tissue (label 3)
    # Pure-material rod inserts
    9 => BS.XA.Materials.basis_water,
    10 => BS.XA.Materials.basis_lipid,
    11 => BS.XA.Materials.basis_collagen,
    12 => BS.XA.Materials.gammex_472_i5_0,  # Gammex 472 5 mg/mL iodine insert
);
phantom_cpu = BS.create_phantom_from_mask(
    Array{Int, 3}(mask_3d),
    materials_dict,
    QRM_VOXEL_SIZE_CM,
);
phantom = BS.Phantom(
    to_gpu(phantom_cpu.mask),
    phantom_cpu.materials,
    phantom_cpu.voxel_size,
    phantom_cpu.origin,
    phantom_cpu.extent,
);
QRM_LABEL_NAMES = Dict{Int, String}(
    1 => "1 air",
    2 => "2 lung",
    3 => "3 soft tissue",
    4 => "4 cortical bone",
    5 => "5 marrow",
    6 => "6 water rod",
    7 => "7 air rod",
    8 => "8 (unused)",
    9 => "9 water",
    10 => "10 lipid",
    11 => "11 collagen",
    12 => "12 iodine 5 mg/mL",
);

The body measures 30.0 × 20.1 cm (the QRM-Thorax-small envelope without the fat ring, ≈ 30 × 20 cm).

labelvoxels in the mid-slicearea (cm²)
1 air464230185.69
2 lung577220230.89
3 soft tissue647663259.07
4 cortical bone89093.56
5 marrow3550614.2
6 water rod44041.76
7 air rod44041.76
9 water44041.76
10 lipid44211.77
11 collagen44181.77
12 iodine 5 mg/mL44211.77

2. PCCTScanner: Siemens NAEOTOM Alpha (4 thresholds)

The NAEOTOM Alpha model of basis-spectral-denoising (and semmd-bayesian), value for value, as in notebook 04: a CdTe direct-conversion detector with native dexels 0.275 × 0.322 mm at the detector face read out 2 × 2 binned, an arc detector with the quarter-detector offset, a 50 cm scan field behind the large-body bowtie, 10 keV energy resolution, charge sharing and 5 ns dead time. Pile-up is simulated and corrected, and so is scatter; the counts are exact Poisson. The thresholds T = [20, 35, 55, 70] keV define four bins:

BinRange (keV)
120 – 35
235 – 55
355 – 70
4> 70

The bowtie makes the air response vary across the fan, so the spectral model is per ray.

scanner = let
    native_col_mm, native_row_mm, binning = 0.275, 0.322, 2
    sid, sdd = 610.0, 1113.0
    col_iso = native_col_mm * binning / (sdd / sid)     # binned pixel at isocentre, mm
    row_iso = native_row_mm * binning / (sdd / sid)
    BS.PCCTScanner(
        source_to_isocenter = sid,
        source_to_detector = sdd,
        detector_rows = 144,
        detector_cols = ceil(Int, 500.0 / col_iso),     # covers the 50 cm scan field
        detector_row_size = row_iso,
        detector_col_size = col_iso,
        detector_shape = :arc,
        detector_col_offset = 0.25,                     # quarter-detector offset
        focal_spot_width = 0.4,
        focal_spot_length = 0.5,
        target_angle = 7.0,
        gantry_rotation_time = 0.5,
        scan_diameter = 500.0,
        gantry_aperture = 820.0,
        flat_filter_material = :aluminum,
        flat_filter_thickness = 3.0,
        bowtie_filter = :large_body,
        detector_material = :cdte,
        detector_depth = 1.6,
        fill_factor_row = 0.95,
        fill_factor_col = 0.95,
        energy_thresholds = [20.0, 35.0, 55.0, 70.0],   # four energy windows, keV
        energy_resolution = 10.0,
        charge_sharing_fwhm = 0.08,
        dead_time_ns = 5.0,
        pixel_mode = :standard,
        native_dexel_col_mm = native_col_mm,
        native_dexel_row_mm = native_row_mm,
        binning_factor = binning,
        pileup_correction = true,      # model-based inverse of the pile-up migration
        scatter_correction = true,     # scatter re-estimated and subtracted per bin
        noise_reduction = 0.0,         # exact Poisson counts
    )
end;

3. CTProtocol: 140 kVp, 5.0 mm Collimation, at the Physical Scan's Dose

The protocol of the physical NAEOTOM Alpha Gammex scan: 140 kVp, 0.5 s rotation, 1200 views, 5 mm collimation, 0.9 mm titanium added, 174 mA and a reported CTDIvol of 10.12 mGy. A tube's output per mA is its own and the simulator's source is generic, so the tube current is scaled by one factor, DOSE_SCALE, that makes the simulated CTDIvol (BasisSimulator's Monte Carlo dose in the 32 cm body phantom) equal the physical scan's. The reconstruction is the same centred 3-slice grid as notebook 07; the workspace adds the symmetric detector guard rows for full cone support.

"The NAEOTOM Alpha's 140 kVp axial protocol at tube current `mA`."
alpha_protocol(mA) = BS.CTProtocol(
    kVp = 140,
    mA = mA,
    views = 1200,
    rotation_time = 0.5,
    collimation_mm = 5.0,
    additional_filters = [("Ti", 0.9)],
);
1.1567318380112646
# The tube-current scale that makes the simulated CTDIvol equal the physical scan's
DOSE_SCALE = let physical = (mA = 174.0, ctdi_vol_mGy = 10.12)
    p = alpha_protocol(physical.mA)
    physical.ctdi_vol_mGy / BS.compute_dose(BS.dose_source(scanner, p), p).ctdi_vol_mGy
end
protocol = alpha_protocol(174.0 * DOSE_SCALE);

4. SimOptions and ReconOptions

A PCCTScanner selects the photon-counting path (per-bin sinograms, the Monte Carlo detector response, Compton scatter, pile-up); the detector-model settings live on the scanner, and SimOptions carries the physics common to both detector families. These are the switches of the basis-spectral-denoising Alpha model: noise, scatter and the focal-spot blur on; the scintillator effects (fill factor, optical crosstalk, the scintillator efficiency table, lag) off, since the CdTe detector's efficiency, fill and charge sharing are in its Monte Carlo response; the heel effect off.

View integration. A detector integrates while the gantry turns, so each view reads the transmitted intensity averaged over the arc it sweeps: a blur of the object, largest far from the isocentre, that the noise, counted once per view, does not share. view_samples = 5 samples that arc with five sub-views per view (the midpoint rule), and view_arc = 1.0 because a photon-counting detector reads for the whole view period.

Three draws, one projection. Everything before the noise is the same for every draw, so the projection (sub-views included) is computed once, on the noise-free draw (keep_projection = true), and the other draws reuse it (projection), bit-identically to simulating them afresh:

drawnoiseseedused for
referenceoff—the noise-free expectation every result is compared with
calibrationon9234fixing the spectral pair, never measured
measuredon1234every result below
"The Alpha model's physics switches for one draw."
draw_options(; seed, use_noise) = BS.SimOptions(
    seed = seed,
    use_noise = use_noise,
    projector = :dd_fast,
    use_fill_factor = false,
    use_detector_efficiency = false,
    use_optical_crosstalk = false,
    use_focal_spot = true,
    use_heel_effect = false,
    use_lag = false,
    view_samples = 5,     # sub-views per view: the arc the gantry turns while a view is read
    view_arc = 1.0,       # a photon-counting detector integrates for the whole view period
);
# basis-spectral-denoising's seeds: draw r has seed 1234 + 1000 (r − 1); the calibration draw is draw 9
DRAWS = (
    reference = (seed = 1234, use_noise = false),
    calibration = (seed = 1234 + 1000 * (9 - 1), use_noise = true),
    measured = (seed = 1234, use_noise = true),
);
# The saved recon grid of notebook 07: 512 × 512 at 0.625 mm isotropic, 3 slices.
recon_opts = BS.ReconOptions(
    matrix_size = (512, 512, 3),
    fov_cm = 32.0,
    z_cm = 0.1875,
);

5. Forward Project: simulate! and spectral_basis

simulate! runs the whole photon-counting detector model on the GPU: polychromatic forward projection of every sub-view through the Monte Carlo detector response, scatter injection, Poisson counts in every bin, pile-up migration, then pile-up and scatter correction. It returns the four corrected sinograms $h_k = -\log(y_k / I_{0,k})$ (pcct_sino.bins, [col, row, view]), the air count of every ray in every bin (I0_bins, [col, row, bin]), the dose report and, with keep_projection = true, the projection the next draw reuses.

spectral_basis(ws; I0) is read from the same workspace: the response the simulation applied, so the decomposition inverts exactly what was simulated and needs no calibration scan. It checks that the response sums to I0 in every ray and bin. The model does not depend on the noise, so one basis serves every draw.

acquisition = let
    projection = nothing
    model = nothing
    channels = Dict{Symbol, Vector{Array{Float32, 3}}}()
    seconds = Dict{Symbol, Float64}()
    for name in (:reference, :calibration, :measured)     # the noise-free draw first: it projects
        opts = draw_options(; DRAWS[name]...)
        t0 = time()
        ws = BS.create_workspace(scanner, protocol, opts, recon_opts, phantom)
        try
            # the dose and the projection do not depend on the noise: both come from the first draw
            sim = BS.simulate!(ws, phantom, protocol, opts; capture_raw_counts = false,
                report_dose = projection === nothing,
                keep_projection = projection === nothing, projection = projection)
            projection === nothing && (projection = sim.projection)
            channels[name] = [Array(b) for b in sim.pcct_sino.bins]   # the four corrected bins
            if model === nothing
                I0 = Float32.(Array(sim.I0_bins))                     # [n_cols, n_rows, n_bins]
                model = (
                    I0 = I0,
                    basis = BS.spectral_basis(ws; I0 = I0),           # ray-resolved spectral model
                    geom = ws.geom,
                    dose = sim.dose,
                )
            end
        finally
            BS.release_backend!(ws)
        end
        seconds[name] = time() - t0
    end
    (; channels, seconds, model...)
end;
quantityvalue
sinogram per bin1659 columns × 14 rows × 1200 views
air counts per ray and view, centre ray (bins 1 to 4)16655 / 17518 / 9827 / 7174
spectral basis274 energies, ray-resolved = true
largest relative mismatch of Σ Φ and I07.81e-8
tube current201.3 mA (174 mA × DOSE_SCALE = 1.157)
CTDIvol (32 cm body phantom)10.12 mGy
wall time: noise-free draw (projects) / calibration / measured95.3 s / 5.6 s / 4.8 s
# The phantom labels resampled onto the reconstruction grid (nearest neighbour); every ROI
# below is built from these labels in recon-voxel coordinates.
phantom_in_recon = BS.resample_to_recon(
    phantom_cpu, acquisition.geom, recon_opts.matrix_size; method = :nearest,
);

VMI Pipeline

1. The Chain's Settings

The denoiser is generalized HYPR-LR in both domains, BS.SpectralHYPR(), the chain of basis-spectral-denoising:

  • projection domain: within each detector row, each ray's split of its total count across the four bins is pooled over a 3 × 3 (column × view) window whose view neighbours are two views apart (view_stride = 2), so the odd and even views stay independent; the count dispersion of every bin is measured from the air rays;

  • image domain: the reconstructed basis pair is rewritten as the minimum-noise VMI (the composite), kept as reconstructed, and a complement whose noise is uncorrelated with it, pooled within its slice with likelihood weights on the composite over the window width (3 to 41 pixels) with the least estimated risk. Every noise level comes from the difference of the odd- and even-view reconstructions, and no slices are averaged.

The decomposition is the K-channel maximum-likelihood estimator on all four bins with its published controls; T-LBF is off; the view-direction antialias is on; ACNR acts on the complement, before the image HYPR.

HYPR_CHAIN = BS.SpectralHYPR();
# The decomposition and reconstruction settings of the chain; ACNR is added on the measured draw
VMI_CHAIN = (method = :nchannel, controls = BS.NChannelControls(), use_tlbf = false, antialias = true);
pcct_vmi_energies = [50.0, 70.0, 100.0, 140.0];

2. The Reconstruction Windows, Rescaled to This Grid

BS.PairFilter gives the composite and its complement each their own FDK window (the water and iodine images, whose noise is anti-correlated, must be filtered alike, or their noise stops cancelling in the quiet VMIs). Each window is $W(f) = \exp(-(f/f_c)^p)$ on the grid-Nyquist axis, sampled at 11 knots as a CustomFilter. basis-spectral-denoising fitted the Alpha's pair to the physical Gammex scan's MTF and NPS shape on 512² over 35 cm: composite $f_c = 0.5$, $p = 2$; complement $f_c = 0.45$, $p = 6$. This grid is 512² over 32 cm, whose finer pixels represent higher frequencies, so each window is kept the same function of physical frequency by rescaling its argument by the ratio r of the two grids' bandlimits (BS.grid_bandlimit), as the study does for its other grids.

ALPHA_WINDOWS = (composite = (fc = 0.5, p = 2.0), complement = (fc = 0.45, p = 6.0));
PAIR_FILTER = let knots = Tuple(range(0.0, 1.0, length = 11))
    # the grid the windows were fitted on: the Alpha's clinical series, 512² over 35 cm
    fitted = BS.CTGeometry(scanner; n_angles = protocol.views, fov_cm = 35.0, z_cm = 0.48,
        collimation_mm = protocol.collimation_mm)
    r = BS.grid_bandlimit(acquisition.geom, recon_opts.matrix_size) /
        BS.grid_bandlimit(fitted, (512, 512, 12))
    window(q) = BS.CustomFilter(knots, Tuple(round(exp(-(x * r / q.fc)^q.p), digits = 5) for x in knots))
    (filter = BS.PairFilter(window(ALPHA_WINDOWS.composite), window(ALPHA_WINDOWS.complement)), r = r)
end;

3. The Spectral Pair, Fixed from the Calibration Draw

Near its minimum the VMI noise hardly changes with energy, so the minimum-noise energy measured on one acquisition is itself noise; it is a property of the scanner and protocol, measured once on the calibration draw and fixed for the measured and the noise-free draws: pair_basis = (Estar, β) from the calibration draw decomposed without denoising and reconstructed with SoftFilter (the pair the windows act on), and composite_energy from the same draw after the projection-domain HYPR (the composite ACNR and the image HYPR act on).

PAIR_BASIS = let ch = acquisition.channels[:calibration], I0 = acquisition.I0
    decompose(channels) = BS.vmi_pipeline(; channels, basis = acquisition.basis,
        geom = acquisition.geom, to_backend = to_gpu, matrix_size = recon_opts.matrix_size,
        vmi_energies = Tuple(pcct_vmi_energies), keep_sinograms = true, use_acnr = false,
        VMI_CHAIN...).sinograms
    p = HYPR_CHAIN.projection
    d_none = decompose(ch)
    d_proj = decompose(BS.hypr_lr(ch, I0; kernel = p.kernel, dispersion = BS.estimate_dispersion(ch, I0),
        view_stride = p.view_stride, to_backend = to_gpu))
    sp(d; kw...) = BS.spectral_pair(d.water, d.iodine, acquisition.geom, recon_opts.matrix_size;
        filter = BS.SoftFilter(), antialias = VMI_CHAIN.antialias, to_backend = to_gpu, kw...)
    x = sp(d_none)
    c = sp(d_proj; basis = (Estar = x.Estar, β = x.β))
    (Estar = x.Estar, β = x.β, composite = c.Estar)
end

4. vmi_pipeline

One call, from the four corrected bins of the measured draw to the VMI stack, reconstructed on the notebook's grid with every detector row kept. keep_sinograms = true also returns the decomposed basis sinograms, shown below. The noise-free draw goes through the same call without the denoiser and ACNR: the reference, reconstructed exactly as its noisy counterpart.

pcct_vmi = BS.vmi_pipeline(;
    channels = acquisition.channels[:measured],
    basis = acquisition.basis,
    geom = acquisition.geom,
    to_backend = to_gpu,
    matrix_size = recon_opts.matrix_size,
    vmi_energies = Tuple(pcct_vmi_energies),
    denoiser = HYPR_CHAIN,
    fbp_filter = PAIR_FILTER.filter,
    pair_basis = (Estar = PAIR_BASIS.Estar, β = PAIR_BASIS.β),
    composite_energy = PAIR_BASIS.composite,
    keep_sinograms = true,
    VMI_CHAIN...,
);
reference_vmi = BS.vmi_pipeline(;
    channels = acquisition.channels[:reference],
    basis = acquisition.basis,
    geom = acquisition.geom,
    to_backend = to_gpu,
    matrix_size = recon_opts.matrix_size,
    vmi_energies = Tuple(pcct_vmi_energies),
    fbp_filter = PAIR_FILTER.filter,
    pair_basis = (Estar = PAIR_BASIS.Estar, β = PAIR_BASIS.β),
    composite_energy = PAIR_BASIS.composite,
    use_acnr = false,
    VMI_CHAIN...,
);

The decomposition solved 27871200 rays in 8.4 s (2.1 outer iterations on average); 0.0% did not converge and 0.0% / 0.0% touched the iodine / water bounds. The projection HYPR measured dispersions (variance / mean of the counts on the air rays) of 1.0 / 1.0 / 1.0 / 1.0 for bins 1 to 4. From the calibration draw, the windows act on the pair at E* = 68 keV (β = 0.00915), and the composite is fixed at 68 keV. The windows' bandlimit ratio on this grid is r = 1.0938, and the image HYPR chose a 7-pixel window for the complement.

3. Basis Maps and VMIs

The water and iodine basis pair after ACNR and the image HYPR (mid slice), and the VMIs synthesized from it: $\mu(E) = c_\mathrm{water}\,(\mu/\rho)_\mathrm{water}(E) + c_\mathrm{iodine}\,(\mu/\rho)_\mathrm{iodine}(E)$, in HU relative to water at the same energy. Every VMI comes from the same basis pair.

# The VMI stack keyed by energy, (512, 512, 3) HU volumes.
vmi_HU_final = Dict(
    Float64(E) => pcct_vmi.vmis[:, :, :, k] for (k, E) in enumerate(pcct_vmi.energies)
);
# The noise-free reference's VMI stack, keyed alike.
vmi_HU_reference = Dict(
    Float64(E) => reference_vmi.vmis[:, :, :, k] for (k, E) in enumerate(reference_vmi.energies)
);

Results

Per-rod measured vs theoretical HU at 50 / 70 / 100 / 140 keV, the water rod's mean HU, and the noise in the heart.

  • Measured HU: the mean over a circular ROI at each rod's centroid, over every slice.

  • Theoretical HU: $1000\,(\mu_\mathrm{rod}(E) - \mu_\mathrm{water}(E)) / \mu_\mathrm{water}(E)$ from BS.compute_μ_at_energy(material, E); pure physics, no fitting.

  • Noise-free HU: the same ROI on the noise-free reference, the same decomposition and reconstruction without noise (and so without the denoiser). Its error against theory is the bias of the iodine–water basis model itself; the measured draw's departure from it is what the noise and the denoising add.

const ROD_LABELS = (UInt8(9), UInt8(10), UInt8(11), UInt8(12));
const ROD_NAMES = ("Water", "Lipid", "Collagen", "Iodine 5 mg/mL");
const ROD_MATERIALS = (
    BS.XA.Materials.basis_water,
    BS.XA.Materials.basis_lipid,
    BS.XA.Materials.basis_collagen,
    BS.XA.Materials.gammex_472_i5_0,
);
const ROD_ROI_RADIUS_PX = 8;    # 5 mm at 0.625 mm/voxel, inside the 7.5 mm rods
const HEART_NOISE_ROI_RADIUS_PX = 12;    # 7.5 mm: fits between the four rods
begin
    "Every value of `vol` inside the in-plane `roi`, over all slices."
    roi_values(vol, roi) = Float64[vol[c, z] for z in axes(vol, 3) for c in roi]

    # Rod centroids from the phantom labels on the recon grid; the noise ROI sits at the
    # centre of the rod cross, the mean of the four rod centroids.
    rod_rois = let
        mask_2d = phantom_in_recon[:, :, size(phantom_in_recon, 3) ÷ 2 + 1]
        nx, ny = size(mask_2d)
        function centroid(label)
            idx = findall(==(label), mask_2d)
            isempty(idx) && error("no recon voxel carries label $(label)")
            (sum(c -> c[1], idx) / length(idx), sum(c -> c[2], idx) / length(idx))
        end
        disc(c, r) = [CartesianIndex(i, j) for j in 1:ny for i in 1:nx
                      if (i - c[1])^2 + (j - c[2])^2 ≤ r^2]
        centers = Dict(lab => centroid(lab) for lab in ROD_LABELS)
        heart_center = (
            mean(first(centers[l]) for l in ROD_LABELS),
            mean(last(centers[l]) for l in ROD_LABELS),
        )
        (
            centers = centers,
            rods = Dict(lab => disc(centers[lab], ROD_ROI_RADIUS_PX) for lab in ROD_LABELS),
            heart_center = heart_center,
            heart = disc(heart_center, HEART_NOISE_ROI_RADIUS_PX),
        )
    end
end;

Phantom-Recon Alignment Verification

Before trusting any ROI built on phantom_in_recon: the 70 keV VMI and the resampled phantom labels side by side on the same recon grid, then overlaid, so the rod, heart and bone edges can be checked against the image.

Water ROI

The water-rod ROI (label 9, basis_water) in red on the 70 keV VMI, and its mean HU at every VMI energy, measured (bars) and on the noise-free reference (black diamonds). Water is the reference of the HU scale, so the bars should sit at 0 HU; a constant offset would be a basis-decomposition bias, a trend with energy a spectral-model error.

water_rod_hu = [mean(roi_values(vmi_HU_final[E], rod_rois.rods[UInt8(9)])) for E in pcct_vmi_energies];
water_rod_hu_reference = [mean(roi_values(vmi_HU_reference[E], rod_rois.rods[UInt8(9)])) for E in pcct_vmi_energies];

Heart-Centre Noise ROI

The standard deviation of HU in a 7.5 mm ROI in the soft-tissue heart, at the centre of the four rods, at every VMI energy: how the chain carries noise from the basis pair into each VMI. It is measured on the difference of the measured VMI and the noise-free reference, in which any structure in the ROI (streaks, cupping) cancels; the labels give the measured VMI's mean HU.

vmi_noise_by_keV = Dict(
    E => let v = roi_values(vmi_HU_final[E], rod_rois.heart),
             d = roi_values(vmi_HU_final[E] .- vmi_HU_reference[E], rod_rois.heart)
        (mean = mean(v), std = std(v), std_vs_reference = std(d), n = length(v))
    end
    for E in pcct_vmi_energies
);
# The basis-pair covariance in the heart ROI, which sets the noise of every VMI:
#   σ_HU(E)² = 10⁶ V_w + α(E)² V_i + 2·10³ α(E) C_iw,  α(E) = (μ/ρ)_I(E) / (μ/ρ)_w(E),
# with water in g/mL and iodine in mg/mL; lowest at α* = -10³ C_iw / V_i. Anti-correlated basis noise (ρ < 0) is what ACNR removes.
basis_covariance = let
    w = roi_values(pcct_vmi.images.water, rod_rois.heart)
    i = roi_values(pcct_vmi.images.iodine, rod_rois.heart) .* 1000.0   # mg/mL
    Vw, Vi = mean(abs2, w .- mean(w)), mean(abs2, i .- mean(i))
    Ciw = mean((w .- mean(w)) .* (i .- mean(i)))
    α(E) = BS.compute_mass_μ_at_energy(BS.XA.Elements.Iodine, E) /
        BS.compute_mass_μ_at_energy(BS.XA.Materials.water, E)
    α_star = -1000.0 * Ciw / Vi
    E_star = let grid = 40.0:1.0:140.0
        grid[argmin(abs.(α.(grid) .- α_star))]
    end
    (σ_water = sqrt(Vw), σ_iodine = sqrt(Vi), ρ = Ciw / sqrt(Vw * Vi), α_star, E_star,
     α_range = (α(140.0), α(40.0)))
end;

The standard deviation of the measured VMI itself differs from that of its difference from the noise-free reference by at most 0.02 HU at any energy: the ROI holds noise only.

In the heart ROI the water basis has σ = 10.3 mg/mL, the iodine basis σ = 0.29 mg/mL, and their correlation is ρ = -0.61. The noise-optimal mixing ratio is α* = 22.0 HU per mg/mL, reached at about 75 keV.

Basis Values in the Rods

The mean basis pair in each rod ROI. The water rod should read $c_\mathrm{water} ≈ 1$ g/mL and $c_\mathrm{iodine} ≈ 0$; the iodine rod carries its 5 mg/mL on a water-equivalent base. Lipid and collagen are not made of water and iodine, so the chain represents them as the water/iodine mixture that attenuates the same way (lipid with a negative iodine value).

roddensity (g/cm³)water (g/mL)iodine (mg/mL)
Water1.00.9990.03
Lipid0.920.931-1.65
Collagen1.261.174-1.6
Iodine 5 mg/mL1.030.9945.13
rod_data = let
    μ_water(E) = BS.compute_μ_at_energy(BS.XA.Materials.water, E)
    theoretical_hu(mat, E) = 1000.0 * (BS.compute_μ_at_energy(mat, E) - μ_water(E)) / μ_water(E)
    meas = [mean(roi_values(vmi_HU_final[E], rod_rois.rods[lab])) for lab in ROD_LABELS, E in pcct_vmi_energies]
    noise_free = [mean(roi_values(vmi_HU_reference[E], rod_rois.rods[lab])) for lab in ROD_LABELS, E in pcct_vmi_energies]
    theo = [theoretical_hu(mat, E) for mat in ROD_MATERIALS, E in pcct_vmi_energies]
    rmse = [sqrt(mean(abs2, meas[i, :] .- theo[i, :])) for i in eachindex(ROD_LABELS)]
    rmse_noise_free = [sqrt(mean(abs2, noise_free[i, :] .- theo[i, :])) for i in eachindex(ROD_LABELS)]
    (labels = ROD_LABELS, names = ROD_NAMES, measured = meas, noise_free = noise_free, theoretical = theo,
     rmse = rmse, rmse_noise_free = rmse_noise_free)
end;

Per-Rod Measured vs Theoretical HU

Solid lines with circles: measured; dashed lines: theory; open diamonds: the noise-free reference.

Measured (noise-free) / theoretical HU per rod and energy:

rod50 keV70 keV100 keV140 keVRMSE (noise-free)
Water0.4 (-0.7) / 0.0-0.3 (-0.7) / 0.0-0.7 (-0.7) / 0.0-0.8 (-0.7) / 0.00.6 (0.7)
Lipid-158.6 (-157.2) / -158.3-111.8 (-112.7) / -111.7-87.7 (-89.7) / -90.5-77.7 (-80.2) / -81.82.5 (1.2)
Collagen87.6 (87.4) / 88.2132.8 (132.6) / 136.0156.2 (156.1) / 157.4165.8 (165.7) / 166.01.8 (1.9)
Iodine 5 mg/mL272.7 (267.9) / 270.6127.5 (127.7) / 129.952.3 (55.2) / 56.721.5 (25.4) / 27.23.9 (2.1)

✅ Verification: PASS

checkvaluepass
all channels enter the decomposition4✅
Σ response matches I0 (max relative error)7.8e-8✅
VMI energies[50.0, 70.0, 100.0, 140.0]✅
simulated CTDIvol equals the physical scan's 10.12 mGy10.12✅
VMI values finite (measured and reference)true✅
water rod within ±10 HU at every energy[0.4, -0.3, -0.7, -0.8]✅
noise-free water rod within ±10 HU at every energy[-0.7, -0.7, -0.7, -0.7]✅

Summary

qrm_thorax_slice: analytic QRM-Thorax (1600 × 1100 × 20 @ 0.2 mm), rods 9–12 in the heart
   → simulate! at 140 kVp, 4 bins, 5 sub-views per view (Siemens NAEOTOM Alpha, 10.12 mGy)
        noise-free, calibration and measured draws from one projection
   → BS.spectral_basis(ws; I0)
   → calibration draw → spectral_pair → pair_basis, composite_energy
   → BS.vmi_pipeline(; denoiser = SpectralHYPR(), fbp_filter = PairFilter(fitted, rescaled),
                       pair_basis, composite_energy)
   → per-rod measured vs theoretical HU at 50 / 70 / 100 / 140 keV, and vs the noise-free draw

On this scan, at 50 / 70 / 100 / 140 keV:

  • the water rod reads 0.4 / -0.3 / -0.7 / -0.8 HU (noise-free: -0.7 / -0.7 / -0.7 / -0.7 HU; theory: 0 HU);

  • measured vs theoretical HU agree to an RMSE of Water 0.6 HU, Lipid 2.5 HU, Collagen 1.8 HU, Iodine 5 mg/mL 3.9 HU; on the noise-free reference, Water 0.7 HU, Lipid 1.2 HU, Collagen 1.9 HU, Iodine 5 mg/mL 2.1 HU;

  • the noise in the heart (measured − noise-free) is σ = 12.4 / 8.3 / 8.7 / 9.5 HU.

Notebook 07 runs the same phantom, rods, grid, energies and ROIs through a dual-kVp scanner, so the two notebooks compare directly.