← Examples/

QRM-Thorax Pure-Material VMI: Dual-kVp Reference

A GE Revolution Apex Elite rapid-kVp-switching scan (80 + 140 kVp) of a body-sized QRM-Thorax phantom with four pure-material rods (water · lipid · collagen · 5 mg/mL iodine) in its cardiac insert. The virtual monoenergetic images (VMIs) come from the package's VMI chain, BS.vmi_pipeline, and every rod is checked against a noise-free acquisition of the same scan and against the HU its material has in theory at 50, 70, 100 and 140 keV. The scanner, the acquisition and the chain are those of the basis-spectral-denoising paper, as in notebook 03.

StageMatrixVoxel (mm)Extent
Phantom1600 × 1100 × 200.2 isotropic320 × 220 × 4 mm
Recon512 × 512 × 30.625 isotropicFOV 32 cm × 1.875 mm z
Collimation5 mm nominal—cone-guard rows added automatically
ScannerGE Revolution Apex Elite0.625 × 0.6 mm pixels256 × 834 detector
Protocolrapid kVp switching80 + 140 kVp, duty cycle 0.65 / 0.35, 984 views, 0.5 s rotationdose-matched to 10.07 mGy CTDIvol

The only economy is in z: three saved slices and a phantom deep enough to cover every ray those slices use. The workspace adds the detector guard rows the reconstruction cylinder needs.

qrm_thorax_slice (analytic 2-D shapes) → bore rods 9–12 into the heart → tile z → BS.Phantom
   → simulate! at 80 kVp and at 140 kVp, each view integrated over its duty-cycle arc
     one projection per exposure → noise-free, measured and calibration draws
   → BS.spectral_basis_from_acquisitions     (per-ray absolute response of each kVp)
   → calibration draw → pair_basis (E*, β), composite_energy
   → BS.vmi_pipeline(; denoiser = SpectralHYPR(), fbp_filter = PairFilter(…), pair_basis, composite_energy)
        projection HYPR → K = 2 decomposition → FDK of the composite and complement
        → ACNR → image HYPR → VMI synthesis
   → per-rod measured vs noise-free vs theoretical HU at 50 / 70 / 100 / 140 keV

When to reach for this notebook

Use 07 for VMI accuracy on a body-sized phantom and field of view at the resolution a GE Revolution Apex Elite delivers. Notebook 03 runs the same chain on the smaller Gammex 472 phantom; notebook 08 scans this phantom with a photon-counting CT.

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 rapid-kVp-switching acquisition: the QRM-Thorax phantom, the GE Apex Elite scanner, the 80 + 140 kVp protocol, and the forward projection that turns them into two sinograms.

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. EICTScanner: GE Revolution Apex Elite

The basis-spectral-denoising model of the Apex Elite, value for value, as in notebook 03: the geometry (834 columns × 0.6 mm on an arc with the quarter-detector offset, 256 rows × 0.625 mm), the Gemstone scintillator (:lumex) and the GE large-body bowtie.

scanner = BS.EICTScanner(
    source_to_isocenter = 625.6,
    source_to_detector = 1100.0,

    detector_rows = 256,
    detector_cols = 834,
    detector_row_size = 0.625,
    detector_col_size = 0.6,
    detector_col_offset = 0.25,
    detector_shape = :arc,

    focal_spot_width = 1.0,
    focal_spot_length = 1.0,
    target_angle = 10.0,

    flat_filter_material = :aluminum,
    flat_filter_thickness = 2.5,
    bowtie_filter = :ge_revolution_large,

    detector_material = :lumex,
    detector_depth = 3.0,
    fill_factor_row = 0.9,
    fill_factor_col = 0.9,

    electronic_noise = 0,
    detection_gain = 10.0,
);

3. Dual-kVp Protocols (Rapid kVp Switching)

Rapid kVp switching alternates the tube between 80 and 140 kVp within every view period, on one tube and one detector, so both channels see the same rays. Each energy is read for its duty cycle of the view period, 0.65 at 80 kVp and 0.35 at 140 kVp: its tube current is the instantaneous current (407 and 405 mA on the physical scan) times its duty cycle, and each of its views integrates over that fraction of the arc the gantry turns per view (view_arc). The acquisition is modelled as two co-registered acquisitions on one geometry, one per kVp, each with 984 views over a 0.5 s rotation, 5 mm collimation and 4.5 mm of added aluminium.

The simulator's tube output per mA is generic, not GE's, so both currents are scaled by one factor, DOSE_SCALE, that makes the simulated CTDIvol (32 cm body phantom, summed over the exposures) the 10.07 mGy of the physical GE scan basis-spectral-denoising modelled. The saved grid is 3 slices of 0.625 mm; the workspace adds the symmetric detector rows those slices need across the full field of view.

# The two exposures of one rotation; each has its own noise seed, so the two channels carry
# independent quantum noise.
exposures = (
    low = (label = "80 kVp", kVp = 80, mA = 407 * 0.65, view_arc = 0.65, seed = 1234,
           filters = [("Al", 4.5)]),
    high = (label = "140 kVp", kVp = 140, mA = 405 * 0.35, view_arc = 0.35, seed = 4321,
            filters = [("Al", 4.5)]),
);
begin
    "The `CTProtocol` of exposure `e`, its tube current scaled by `mA_scale`."
    exposure_protocol(e; mA_scale = 1.0) = BS.CTProtocol(
        kVp = e.kVp,
        mA = e.mA * mA_scale,
        views = 984,
        rotation_time = 0.5,
        collimation_mm = 5.0,
        additional_filters = e.filters,
    )
    "The CTDIvol (mGy, 32 cm body phantom) of `protocol` on `scanner`."
    ctdi_vol(protocol) = BS.compute_dose(BS.dose_source(scanner, protocol), protocol).ctdi_vol_mGy
end;
1.0314357269390726
# One factor on both tube currents, so the simulated CTDIvol is the physical scan's 10.07 mGy.
DOSE_SCALE = 10.07 / sum(e -> ctdi_vol(exposure_protocol(e)), exposures)
protocols = map(e -> exposure_protocol(e; mA_scale = DOSE_SCALE), exposures);

DOSE_SCALE = 1.0314. The simulated exposures:

exposurephysical mAduty cycle, view_arcsimulated mACTDIvol (mGy)
80 kVp407.00.65272.92.8
140 kVp405.00.35146.27.27

4. SimOptions and ReconOptions

The physics switches of the basis-spectral-denoising GE model: every effect of the energy-integrating path on except optical crosstalk, and the detector efficiency from the Monte Carlo table of the Lumex scintillator (detector_efficiency_mode = :mc_lut).

View integration. A detector integrates while the gantry turns, so each view records the transmitted intensity averaged over the arc it sweeps, r · view_arc · Δθ at radius r: 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. Without it the simulated signal is sharper, relative to its noise, than a physical scanner's.

Draws. Each exposure is simulated three times, differing only in the noise: the noise-free expectation (draw 0, use_noise = false), the measured scan (draw 1) and a calibration scan (draw 9) of its own seeds. The reconstruction grid is 512 × 512 × 3 at 0.625 mm, independent of the 0.2 mm phantom grid; both share the isocenter.

begin
    VIEW_SAMPLES = 5
    ge_opts = BS.SimOptions(
        projector = :dd_fast,
        detector_efficiency_mode = :mc_lut,
        view_samples = VIEW_SAMPLES,
    )
    "The same physics switches with one field replaced."
    with_option(opts, field, value) = BS.SimOptions(;
        (f => getfield(opts, f) for f in fieldnames(BS.SimOptions) if f !== field)...,
        (field => value,)...,
    )
end;
begin
    # Draw 0 is the noise-free expectation; draw r ≥ 1 gives each exposure the seed
    # `seed + 1000 (r − 1)`, so the exposures stay independent within a draw and the draws of
    # each other.
    DRAWS = (reference = 0, measured = 1, calibration = 9)
    realization_seed(e, r) = e.seed + 1000 * (max(r, 1) - 1)
    "The options of draw `r` of exposure `e`: its seed, its noise, its arc."
    draw_opts(e, r) = with_option(with_option(with_option(ge_opts,
        :seed, realization_seed(e, r)), :use_noise, r > 0), :view_arc, e.view_arc)
end;
recon_opts = BS.ReconOptions(
    matrix_size = (512, 512, 3),
    fov_cm = 32.0,
    z_cm = 0.1875,
);

5. Forward Project: simulate!

Each exposure is projected once. The noise-free draw runs first with keep_projection = true, which returns everything before the noise; the measured and calibration draws pass it back (projection) and only draw their noise, bit-identically to simulating each afresh. Each acquisition keeps what the spectral basis needs: its corrected log sinograms, its per-ray air counts I0_ray (the detector's air count × the bowtie air profile) and its per-ray detected spectrum from resolve_source_spectrum_full (source × filtration × bowtie × detector response), the same model the forward projector applied.

dual_scans = map(collect(keys(exposures))) do k
    e, protocol = exposures[k], protocols[k]
    @info "Simulating dual-kVp channel: $(e.label)"
    channels = Dict{Int, Array{Float32, 3}}()
    projection = nothing
    model = nothing
    elapsed_s = @elapsed for r in (DRAWS.reference, DRAWS.measured, DRAWS.calibration)
        opts = draw_opts(e, r)
        ws = BS.create_workspace(scanner, protocol, opts, recon_opts, phantom)
        try
            result = BS.simulate!(ws, phantom, protocol, opts; report_dose = false,
                keep_projection = projection === nothing, projection)
            projection === nothing && (projection = result.projection)
            channels[r] = Float32.(Array(ws.sinogram))
            if model === nothing
                air = ws.bowtie_air_reference === nothing ?
                    ones(Float32, ws.geom.n_cols, ws.geom.n_rows) :
                    Float32.(Array(ws.bowtie_air_reference))
                I0 = BS.compute_detector_I0(ws.geom, protocol, sum(ws.weights)) * Float64(ws.η_eff)
                energies, response = BS.resolve_source_spectrum_full(
                    opts, protocol; scanner = scanner, geom = ws.geom,
                )
                model = (
                    geom = ws.geom, I0_ray = Float32.(I0 .* air),
                    energies = Float64.(energies), response = Float32.(response),
                )
            end
        finally
            BS.release_backend!(ws)
        end
    end
    (; label = e.label, channels, elapsed_s, model...)
end;

The three draws of the two exposures took 32.3 s for 80 kVp and 4.6 s for 140 kVp, one projection each. Each sinogram is 834 × 8 × 984 (columns × rows × views).

# 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, first(dual_scans).geom, recon_opts.matrix_size; method = :nearest,
);

VMI Pipeline

1. Spectral Basis from the Two Acquisitions

spectral_basis_from_acquisitions merges the two kVp energy grids onto their union and scales each acquisition's per-ray spectrum by its own air counts, so the likelihood sees the absolute response $\Phi_k(E)$ of every ray and channel: source, filtration, bowtie and detector, the model that generated the data. It does not depend on the noise, so every draw shares it.

basis = BS.spectral_basis_from_acquisitions(acquisitions = [
    (energies = s.energies, response = s.response, I0_ray = s.I0_ray) for s in dual_scans
]);

The basis holds 2 channels on a 274-point energy grid for 834 × 8 rays, spanning 4 to 140 keV; the response sums to the air counts to within 1.1e-8 (relative).

2. The Chain's Settings

The denoiser is generalized HYPR-LR in both domains, BS.SpectralHYPR(): before the decomposition, a 3 × 3 (column × view) window on the counts of each detector row, pooling views of one parity so the odd- and even-view halves stay independent, with each channel's dispersion measured on the air rays; after the reconstruction, the complement of the basis pair pooled within each slice over the window with the least estimated risk. Neither pools across detector rows or slices. The decomposition is the K-channel maximum-likelihood estimator with its published controls; T-LBF (a photon-counting filter) is off; the view-direction antialias is on; ACNR is on (vmi_pipeline's default).

A pair of FDK windows. A VMI is $A(E)\,M + B(E)\,I_\perp$, with $M$ the minimum-noise VMI (the composite) and $I_\perp$ its complement, whose noise is uncorrelated with $M$'s. BS.PairFilter gives each its own window; one pair serves every energy. A window per basis image instead would stop the anti-correlated noise of the water and iodine images cancelling wherever the two windows differ. The windows are the GE pair basis-spectral-denoising fitted to the physical Gammex scan, $W(f) = \exp(-(f/f_c)^p)$.

Rescaled to this grid. A window is defined on the frequency axis of the grid it is applied on, [0, grid_bandlimit] of the detector's Nyquist. The pair was fitted on the GE's clinical grid, 512² over 35 cm; this grid, 512² over 32 cm, has finer pixels and carries higher frequencies, so the windows are the same functions of physical frequency rescaled onto its axis, $\exp(-(f\,r/f_c)^p)$ with $r$ the ratio of the two grids' bandlimits, as basis-spectral-denoising's RECON_BY does for its XCAT grid.

# the published denoiser: ProjectionHYPR(3 × 3 box, view_stride = 2) and ImageHYPR()
HYPR_CHAIN = BS.SpectralHYPR();
VMI_CHAIN = (method = :nchannel, controls = BS.NChannelControls(), use_tlbf = false, antialias = true);
# The GE pair of basis-spectral-denoising (`RECON.ge`): W(f) = exp(-(f / f_c)^p) at 11 knots.
GE_KERNELS = (composite = (fc = 0.55, p = 1.5), complement = (fc = 1.05, p = 1.0));
1.09375
# The ratio of this grid's bandlimit to that of the grid the pair was fitted on: the GE's
# clinical series, 512² × 8 slices over 35 cm.
BANDLIMIT_RATIO = let
    fitted_geom = BS.CTGeometry(scanner; n_angles = 984, fov_cm = 35.0, z_cm = 0.5, collimation_mm = 5.0)
    BS.grid_bandlimit(first(dual_scans).geom, recon_opts.matrix_size) /
        BS.grid_bandlimit(fitted_geom, (512, 512, 8))
end
PAIR_FILTER = let knots = Tuple(range(0.0, 1.0, length = 11)), r = BANDLIMIT_RATIO
    window(q) = BS.CustomFilter(knots, Tuple(round(exp(-(x * r / q.fc)^q.p), digits = 5) for x in knots))
    BS.PairFilter(window(GE_KERNELS.composite), window(GE_KERNELS.complement))
end;
de_vmi_energies = [50.0, 70.0, 100.0, 140.0];

3. The Calibration Draw: pair_basis and composite_energy

The pair the windows act on, $(E^\star, \beta)$, and the energy of the composite that ACNR and the image-domain HYPR act on are properties of the scanner and protocol, measured once on the calibration draw and fixed for every other draw, as basis-spectral-denoising's PAIR_BASIS does: the calibration draw decomposed as measured and after the projection-domain HYPR alone; BS.spectral_pair of the first with a standard soft-tissue window gives $E^\star$ and $\beta$ (pair_basis); BS.spectral_pair of the second on that basis gives the minimum-noise energy after the projection-domain HYPR (composite_energy). Near its minimum the VMI noise hardly changes with energy, so the argmin of one acquisition is itself noise; measured on a draw of its own it cannot tune the chain to the draw being scored, and the noise-free reference is reconstructed with the same pair.

calibration = let
    geom = first(dual_scans).geom
    matrix_size = recon_opts.matrix_size
    channels = [s.channels[DRAWS.calibration] for s in dual_scans]
    decompose(denoiser) = BS.vmi_pipeline(;
        channels, basis, geom, to_backend = to_gpu, matrix_size, denoiser,
        keep_sinograms = true, use_acnr = false, VMI_CHAIN...,
    ).sinograms
    pair(d; kw...) = BS.spectral_pair(d.water, d.iodine, geom, matrix_size;
        filter = BS.SoftFilter(), antialias = VMI_CHAIN.antialias, to_backend = to_gpu, kw...)
    x = pair(decompose(nothing))
    c = pair(decompose(BS.SpectralHYPR(image = nothing)); basis = (Estar = x.Estar, β = x.β))
    (pair_basis = (Estar = x.Estar, β = x.β), composite_energy = c.Estar)
end

4. vmi_pipeline

One call, from the two measured sinograms 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 reference is the noise-free draw through the same reconstruction, windows and pair, with nothing to denoise (no HYPR, no ACNR).

dual_vmi = BS.vmi_pipeline(;
    channels = [s.channels[DRAWS.measured] for s in dual_scans],
    basis,
    geom = first(dual_scans).geom,
    to_backend = to_gpu,
    matrix_size = recon_opts.matrix_size,
    vmi_energies = Tuple(de_vmi_energies),
    denoiser = HYPR_CHAIN,
    fbp_filter = PAIR_FILTER,
    pair_basis = calibration.pair_basis,
    composite_energy = calibration.composite_energy,
    keep_sinograms = true,
    VMI_CHAIN...,
);
reference_vmi = BS.vmi_pipeline(;
    channels = [s.channels[DRAWS.reference] for s in dual_scans],
    basis,
    geom = first(dual_scans).geom,
    to_backend = to_gpu,
    matrix_size = recon_opts.matrix_size,
    vmi_energies = Tuple(de_vmi_energies),
    fbp_filter = PAIR_FILTER,
    pair_basis = calibration.pair_basis,
    composite_energy = calibration.composite_energy,
    use_acnr = false,
    VMI_CHAIN...,
);

The windows were rescaled by r = 1.094. The calibration draw fixed their pair at E* = 65 keV, β = 0.0151, and the composite at 68 keV. On the measured draw the decomposition solved 6565248 rays in 1.7 s, 1.7 outer iterations on average; 0.0% did not converge and 0.0% / 0.0% touched the iodine / water bounds. The projection HYPR measured dispersions, the variance-to-mean ratio of the counts on the air rays, of 1.29 and 1.28 for 80 and 140 kVp. The image HYPR pooled the complement over a 5 × 5 window.

5. Basis Maps and VMIs

The water and iodine basis pair after ACNR and 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 stacks keyed by energy, (512, 512, 3) HU volumes: measured and noise-free.
begin
    vmi_HU_final = Dict(
        Float64(E) => dual_vmi.vmis[:, :, :, k] for (k, E) in enumerate(dual_vmi.energies)
    )
    vmi_HU_reference = Dict(
        Float64(E) => reference_vmi.vmis[:, :, :, k] for (k, E) in enumerate(reference_vmi.energies)
    )
end;

Results

Per-rod measured, noise-free and 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, of the measured VMIs.

  • Noise-free HU: the same mean on the noise-free reference.

  • 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: the standard deviation of the measured VMI minus the noise-free reference, the noise alone.

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 and noise-free. 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, and a difference between the two bars what the noise and the denoising add.

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

Heart-Centre Noise ROI

The noise in a 7.5 mm ROI in the soft-tissue heart, at the centre of the four rods, at every VMI energy: the standard deviation of the measured VMI about the noise-free reference, how the chain carries noise from the basis pair into each VMI.

vmi_noise_by_keV = Dict(
    E => let v = roi_values(vmi_HU_final[E], rod_rois.heart),
             v0 = roi_values(vmi_HU_reference[E], rod_rois.heart)
        (mean = mean(v), std = std(v .- v0), n = length(v))
    end
    for E in de_vmi_energies
);
# The basis-pair noise covariance in the heart ROI (measured minus noise-free), 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.
basis_covariance = let
    w = roi_values(dual_vmi.images.water, rod_rois.heart) .-
        roi_values(reference_vmi.images.water, rod_rois.heart)
    i = (roi_values(dual_vmi.images.iodine, rod_rois.heart) .-
         roi_values(reference_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;

In the heart ROI the noise of the water basis is σ = 12.3 mg/mL, that of the iodine basis σ = 0.32 mg/mL, and their correlation is ρ = -0.73. The noise-optimal mixing ratio is α* = 27.8 HU per mg/mL, reached at about 68 keV; the chain's composite is at 68 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.01.0-0.03
Lipid0.920.93-1.64
Collagen1.261.168-1.51
Iodine 5 mg/mL1.031.0024.78
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 de_vmi_energies]
    ref = [mean(roi_values(vmi_HU_reference[E], rod_rois.rods[lab])) for lab in ROD_LABELS, E in de_vmi_energies]
    theo = [theoretical_hu(mat, E) for mat in ROD_MATERIALS, E in de_vmi_energies]
    rmse(a) = [sqrt(mean(abs2, a[i, :] .- theo[i, :])) for i in eachindex(ROD_LABELS)]
    (labels = ROD_LABELS, names = ROD_NAMES, measured = meas, reference = ref, theoretical = theo,
     rmse = rmse(meas), rmse_reference = rmse(ref))
end;

Per-Rod Measured vs Theoretical HU

Solid lines show the measured HU, crosses the noise-free reference and dashed lines the theoretical HU.

Measured / noise-free / theoretical HU per rod and energy, in HU:

rod50 keV70 keV100 keV140 keVRMSE vs theoryRMSE vs theory, noise-free
Water-2.0 / -1.3 / 0.0-1.1 / -1.1 / 0.0-0.6 / -1.1 / 0.0-0.4 / -1.1 / 0.01.21.1
Lipid-159.2 / -158.7 / -158.3-112.7 / -112.3 / -111.7-88.7 / -88.3 / -90.5-78.8 / -78.4 / -81.81.92.1
Collagen86.2 / 81.9 / 88.2128.9 / 129.3 / 136.0151.1 / 153.8 / 157.4160.2 / 163.8 / 166.05.75.1
Iodine 5 mg/mL261.4 / 260.0 / 270.6126.2 / 124.7 / 129.956.2 / 54.7 / 56.727.5 / 26.0 / 27.25.06.0

✅ Verification: PASS

checkvaluepass
all channels enter the decomposition2✅
Σ response matches I0 (max relative error)1.1e-8✅
chain: SpectralHYPR, ACNR, pair of windows(true, true, true)✅
measured and reference share the pairtrue✅
VMI energies[50.0, 70.0, 100.0, 140.0]✅
VMI values finitetrue✅
water rod within ±10 HU at every energy[-2.0, -1.1, -0.6, -0.4]✅

Summary

qrm_thorax_slice: analytic QRM-Thorax (1600 × 1100 × 20 @ 0.2 mm), rods 9–12 in the heart
   → simulate! at 80 and 140 kVp (GE Apex Elite, duty cycle 0.65 / 0.35, view_samples = 5)
     one projection per exposure → noise-free, measured and calibration draws
   → BS.spectral_basis_from_acquisitions
   → calibration draw → pair_basis, composite_energy
   → BS.vmi_pipeline(; denoiser = SpectralHYPR(), fbp_filter = PAIR_FILTER, pair_basis, composite_energy)
   → per-rod measured vs noise-free vs theoretical HU at 50 / 70 / 100 / 140 keV

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

  • the water rod reads -2.0 / -1.1 / -0.6 / -0.4 HU, and -1.3 / -1.1 / -1.1 / -1.1 HU noise-free (theory: 0 HU);

  • measured vs theoretical HU agree to an RMSE of Water 1.2 HU, Lipid 1.9 HU, Collagen 5.7 HU, Iodine 5 mg/mL 5.0 HU;

  • the noise in the heart is σ = 11.9 / 8.3 / 9.9 / 11.1 HU.

Notebook 08 runs the same phantom, rods, grid, energies and ROIs through a photon-counting scanner, so the two notebooks compare directly.