← Examples/04 · PCCT VMI on Gammex 472
View source on GitHub

Photon-Counting CT Virtual Monoenergetic Imaging

Siemens Naeotom Alpha photon-counting CT simulation (140 kVp / 174 mA, 4-threshold acquisition, Gammex 472 phantom) with a fully projection-domain VMI pipeline — every denoising and decomposition step operates on log-line-integrals before the reconstruction runs.

Simulate 140 kVp PCCT  (4 bins; scatter + noise + pile-up + corrections)
   → 4-bin joint SVD denoise  (edge-aware bilateral — main, zero resolution loss)
   → Bin combine  (1 + 2 + 3 → low, 4 → high)
   → Fine 2-channel Gaussian SVD on the combined (low, high) pair
   → Projection-domain material decomposition  (Cong; iodine + water)
   → FBP × 2 with per-basis apodization  (soft iodine + soft water)
   → Kalender-1988 true ACNR on the basis maps  (beta_max = 20)
   → Monoenergetic VMI synthesis  (50 / 70 / 100 / 140 keV)
   → Measured-vs-theoretical per-rod verification

Why Projection Domain?

Two structural differences vs an image-domain PCCT pipeline:

  1. Material decomposition before reconstruction. The per-ray Cong univariate solver consumes log-line-integrals directly, so the basis fit sees the actual polychromatic transmission physics. No pre-FBP linearization, no HU-to-fraction inverse polynomial.

  2. Image-domain anti-correlated noise reduction (ACNR). Material decomposition stamps anti-correlated noise on the basis maps (the VMI-noise "U"); BS.apply_acnr_kalender! removes it after FBP with a data-adaptive covariance eigen-rotation + edge-aware joint bilateral, keeping the structure axis pixel-perfect (no resolution loss).

References

  • Cong, De Man, Wang (2022), J X-Ray Sci Technol — projection- domain univariate solver (dual-kVp DECT).

  • Black (in prep.) — generalization of Cong 2022 to PCCT / split-spectrum via an effective spectral response Φ_k(ε) ≥ 0.

  • Clark, Badea (2023), Med Phys — image-domain RSKR (rank-sparse bandwidth); the Kalender true ACNR in BS.apply_acnr_kalender! adapts these moves to the water/iodine basis-map pair.

Notebook Setup

begin
    import Pkg
    Pkg.activate(joinpath(@__DIR__, ".."))
end
using Markdown: @md_str, Markdown
using Statistics: mean, std, quantile, median
import PlutoUI
import BasisSimulator as BS
# import CairoMakie as Mke
import WasmMakie as Mke
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: MtlArray

Scan Setup and Simulation

01. Phantom() Struct

Gammex 472

phantom_cpu = BS.create_gammex_472(
    n_voxels = 512,
    n_slices = 16,
    fov_cm = 35.0,
    z_cm = 1.0,
);
phantom = BS.Phantom(
    to_gpu(phantom_cpu.mask),
    phantom_cpu.materials,
    phantom_cpu.voxel_size,
    phantom_cpu.origin,
    phantom_cpu.extent,
);

02. Scanner() Struct

Siemens Naeotom Alpha (PCCT, 4-threshold)

CdTe direct-conversion detector with native dexels 0.275 × 0.322 mm at the detector face (2×2 binned in DAS). Energy thresholds T = [20, 35, 55, 70] keV define 4 bins:

BinRange (keV)
120 – 35
235 – 55
355 – 70
4> 70
scanner = let
    native_col_mm = 0.275
    native_row_mm = 0.322
    sid = 610.0
    sdd = 1113.0
    magnification = sdd / sid
    bf = 2

    pixel_col_iso = (native_col_mm * bf) / magnification
    pixel_row_iso = (native_row_mm * bf) / magnification
    n_cols = ceil(Int, 360.0 / pixel_col_iso)

    BS.Scanner(
        source_to_isocenter = sid,
        source_to_detector = sdd,

        detector_rows = 144,
        detector_cols = n_cols,
        detector_row_size = pixel_row_iso,
        detector_col_size = pixel_col_iso,
        detector_row_offset = 0.0,
        detector_col_offset = pixel_col_iso / 2,

        focal_spot_width = 0.4,
        focal_spot_length = 0.5,
        target_angle = 7.0,

        gantry_rotation_time = 0.5,
        scan_diameter = 360.0,
        gantry_aperture = 820.0,

        flat_filter_material = :aluminum,
        flat_filter_thickness = 3.0,

        detector_material = :cdte,
        detector_depth = 1.6,
        fill_factor_row = 0.95,
        fill_factor_col = 0.95,
        detection_gain = 1.0,
        electronic_noise = 0.0,

        detector_type = :photon_counting,
        n_energy_bins = 4,
        energy_thresholds = [20.0, 35.0, 55.0, 70.0],
        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 = bf,
    )
end;

03. CTProtocol() Struct

140 kVp Photon-Counting

Clinical 140 kVp single-energy acquisition. additional_filters = [("Ti", 0.9)] is the Vectron tube's inherent 0.9 mm titanium window on top of the 3 mm Al flat filter.

protocol = BS.CTProtocol(
    kVp = 140,
    mA = 174.0,
    views = 1200,
    rotation_time = 0.5,
    collimation_mm = 5.0,
    additional_filters = [("Ti", 0.9)],
);

04. SimOptions() & ReconOptions()

fidelity = :pcct switches the simulator into the photon-counting path (per-bin sinograms + DRM + Compton scatter modeling).

sim_opts = BS.SimOptions(
    fidelity = :pcct,
    seed = 1234,
    projector = :dd_fast,  # same anti-aliased DD physics, single-pass fused kernels (~47× faster poly)

    # ─── Inert for PCCT (flag exists but does nothing) ───
    use_fill_factor = false,
    use_detector_efficiency = false,
    use_optical_crosstalk = false,
    use_focal_spot = false,
    use_lag = false,
    use_heel_effect = false,

    # ─── Active for PCCT — all applied INSIDE simulate!() ───
    use_scatter = false,                  # EICT scatter flag — OFF (PCCT uses use_pcct_scatter)
    use_noise = true,                     # quantum noise (src :count, nr below)
    use_pcct_scatter = true,              # PCCT scatter injection
    use_pcct_scatter_correction = true,   # PCCT model-based scatter correction
    use_pcct_pileup = true,               # PCCT MC pile-up forward
    use_pcct_pileup_correction = true,    # PCCT pile-up correction (inverse S)
    # DETECTOR-LEVEL CORRECTION SURROGATE — explicitly NOT a recon-level
    # (QIR/iterative) stand-in: this chain is pure FBP end to end, and its
    # ACCURACY does not depend on this knob (pure chain, nr = 0, noise off:
    # rods within ~3 % of NIST, solid water < 3 HU).  The simulator
    # Monte-Carlo models the detector DEGRADATIONS (charge sharing,
    # fluorescence escape, pulse pileup, spectral distortion via the MC DRM)
    # but not the vendor's DETECTOR-side correction algorithms for them —
    # anti-coincidence/charge-sharing event reconstruction, count-rate
    # linearization beyond our inverse-S, threshold/spectral-distortion
    # compensation.  Those corrections recover count statistics at the
    # detector output; nr = 0.7 stands in for that recovery and nothing
    # else.
    pcct_noise_reduction = 0.7,
)
recon_opts = let
    slice_thickness_mm = 0.4
    n_recon_slices = max(1, round(Int, protocol.collimation_mm / slice_thickness_mm))
    BS.ReconOptions(
        matrix_size = (512, 512, n_recon_slices),
        fov_cm = 35.0,
        z_cm = protocol.collimation_mm / 10.0,
    )
end;

05. Forward Project: simulate!

A single BS.simulate! call produces the 4 per-bin log-line-integral sinograms with the complete PCCT physics + corrections, all gated by the sim_opts flags above:

forward → scatter inject → quantum noise → pile-up fwd →
pile-up correction → scatter correction

Scatter (use_pcct_scatter + use_pcct_scatter_correction) and pile-up (use_pcct_pileup + use_pcct_pileup_correction) now happen inside simulate!() — no decoupled notebook-level correction steps. Bins are -log(N_recorded / I0_truth[b]); I0_bins is the truth per-bin air baseline.

# === Forward project + full PCCT physics + corrections via simulate!() ===
# One src call: forward → scatter inject → noise → pile-up fwd → pile-up
# correction → scatter correction, all gated by the `sim_opts` flags.
sim_bins = let
    @info "Simulating: $(Int(protocol.kVp)) kVp / $(round(protocol.mA, digits = 1)) mA (PCCT 4-bin) — full physics + corrections via simulate!()"
    ws = BS.create_workspace(scanner, protocol, sim_opts, recon_opts, phantom)
    result = BS.simulate!(ws, phantom, protocol, sim_opts)

    bins = [Array(b) for b in result.pcct_sino.bins]
    I0_bins = copy(result.I0_bins)
    geom = ws.geom
    # The EXACT per-bin detected spectra the forward applied (w·η·DRM with
    # the workspace's MC-LUT η and the centre-pixel bowtie fold).  The
    # decomposition basis consumes THESE — the inversion's forward model is
    # the model simulate! actually applied, by construction.
    energies = Float64.(ws.energies)
    W_applied = Float64.(Array(ws.W_matrix_gpu))[1:length(ws.energies), :]

    ws = nothing; result = nothing
    GC.gc(true)
    (bins = bins, I0_bins = I0_bins, geom = geom,
     energies = energies, W_applied = W_applied)
end;
Dict{Symbol, Any}(:msg => "MethodError: no method matching WasmMakie.Axis(; title::String, subtitle::String, titlesize::Int64, subtitlesize::Int64, xlabel::String, ylabel::String, xlabelsize::Int64, ylabelsize::Int64, xticklabelsize::Int64, yticklabelsize::Int64)\nThis method does not support all of the given keyword arguments (and may not support any).\n\n\e[0mClosest candidates are:\n\e[0m WasmMakie.Axis(\e[91m::Int64\e[39m, \e[91m::Int64\e[39m, \e[91m::Int64\e[39m, \e[91m::Int64\e[39m, \e[91m::String\e[39m, \e[91m::String\e[39m, \e[91m::String\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::String\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Int64\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Int64\e[39m, \e[91m::Int64\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Bool\e[39m, \e[91m::Int64\e[39m, \e[91m::Int64\e[39m, \e[91m::Int64\e[39m, \e[91m::Vector{WasmMakie.LinesPlot}\e[39m, \e[91m::Vector{WasmMakie.ScatterPlot}\e[39m, \e[91m::Vector{WasmMakie.BarPlotData}\e[39m, \e[91m::Vector{WasmMakie.HeatmapPlot}\e[39m, \e[91m::Vector{WasmMakie.ImagePlot}\e[39m, \e[91m::Vector{WasmMakie.HVLines}\e[39m, \e[91m::Vector{WasmMakie.HVSpan}\e[39m, \e[91m::Vector{WasmMakie.ABLines}\e[39m, \e[91m::Vector{WasmMakie.SegmentsPlot}\e[39m, \e[91m::Vector{WasmMakie.FilledCurve}\e[39m, \e[91m::Vector{WasmMakie.BandPlot}\e[39m, \e[91m::Vector{WasmMakie.PolyPlot}\e[39m, \e[91m::Vector{WasmMakie.MeshPlot}\e[39m, \e[91m::Vector{Tuple{Int64, Int64}}\e[39m)\e[91m got unsupported keyword arguments \"title\", \"subtitle\", \"titlesize\", \"subtitlesize\", \"xlabel\", \"ylabel\", \"xlabelsize\", \"ylabelsize\", \"xticklabelsize\", \"yticklabelsize\"\e[39m\n\e[0m\e[90m @\e[39m \e[35mWasmMakie\e[39m \e[90m~/.julia/packages/WasmMakie/PSjmW/src/core/\e[39m\e[90m\e[4mfigure.jl:14\e[24m\e[39m\n\e[0m WasmMakie.Axis(\e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m)\e[91m got unsupported keyword arguments \"title\", \"subtitle\", \"titlesize\", \"subtitlesize\", \"xlabel\", \"ylabel\", \"xlabelsize\", \"ylabelsize\", \"xticklabelsize\", \"yticklabelsize\"\e[39m\n\e[0m\e[90m @\e[39m \e[35mWasmMakie\e[39m \e[90m~/.julia/packages/WasmMakie/PSjmW/src/core/\e[39m\e[90m\e[4mfigure.jl:14\e[24m\e[39m\n\e[0m WasmMakie.Axis(; title, xlabel, ylabel, subtitle, titlealign)\e[91m got unsupported keyword arguments \"titlesize\", \"subtitlesize\", \"xlabelsize\", \"ylabelsize\", \"xticklabelsize\", \"yticklabelsize\"\e[39

Intermediate FBP per Bin (μ-domain sanity check)

A quick per-bin FDK on the raw simulator sinograms — before the SVD denoise, the bin combine, or the Cong decomposition — so we can eyeball that the photon-counting forward model is producing physically sensible images at each energy band.

Output is the linear attenuation coefficient μ (cm⁻¹), the natural unit of the FBP — no HU conversion yet. Lower energy bins should register higher μ for the same attenuator (μ rolls off with E), and the same rod ordering should be visible across all four bins.

sim_bins_fbp = let
    matrix_size = recon_opts.matrix_size
    geom = sim_bins.geom

    fdk_filter = BS.CustomFilter(
        (0.0, 0.25, 0.5, 0.75, 1.0),
        (1.0, 0.75, 0.6, 0.2, 0.001),
    )

    function _fbp(sino_cpu)
        sino_gpu = to_gpu(Float32.(sino_cpu))
        ws = BS.create_fdk_recon_workspace(
            sino_gpu, geom, matrix_size; filter = fdk_filter,
        )
        recon = Array(BS.reconstruct!(ws, sino_gpu, geom))
        ws = nothing; sino_gpu = nothing
        GC.gc(true)
        return Float32.(recon)
    end

    [_fbp(b) for b in sim_bins.bins]
end;
Dict{Symbol, Any}(:msg => "UndefVarError: `DataAspect` not defined in `WasmMakie`\nSuggestion: check for spelling errors or missing imports.", :stacktrace => Dict{Symbol, Any}[Dict(:call_short => "getproperty", :inlined => true, :url => nothing, :path => "./Base_compiler.jl", :source_package => nothing, :call => "getproperty", :linfo_type => "Nothing", :line => 47, :file => "Base_compiler.jl", :func => "getproperty", :parent_module => nothing, :from_c => false), Dict(:call_short => "macro expansion", :inlined => true, :url => nothing, :path => "/Users/daleblack/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/04_pcct_vmi.jl#==#06000006-0000-4000-8000-000000000060", :source_package => nothing, :call => "macro expansion", :linfo_type => "Nothing", :line => 17, :file => "04_pcct_vmi.jl#==#06000006-0000-4000-8000-000000000060", :func => "macro expansion", :parent_module => nothing, :from_c => false)], :plain_error => "UndefVarError: `DataAspect` not defined in `WasmMakie`\nSuggestion: check for spelling errors or missing imports.\nStacktrace:\n [1] getproperty\n @ ./Base_compiler.jl:47 [inlined]\n [2] macro expansion\n @ ~/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/04_pcct_vmi.jl#==#06000006-0000-4000-8000-000000000060:17 [inlined]")

VMI Pipeline

01. SVD Denoise

4-Bin Joint, Edge-Aware Bilateral (main denoise, zero resolution loss)

Per detector row, an SVD across the 4 raw bins. U[:,1] = the common anatomy shared by all bins (~√4 SNR), kept pixel-perfect; the residual U[:,2..4] (spectral difference + decorrelated quantum noise) is cleaned with an edge-preserving joint bilateral → no blur across edges, spatial resolution untouched. This is the main, resolution-safe stage on the 4 bins; a subtle 2-channel Gaussian on the combined pair (step 03) does the fine cleanup nb03 gets from denoising its final channels. APPLY_SVD = false ⇒ passthrough.

bins_denoised = let
    APPLY_SVD = true   # A/B TOGGLE — false ⇒ passthrough raw bins
    if APPLY_SVD
        out = BS.apply_sino_svd_denoise_bilateral(
            sim_bins.bins;
            bilat_radius  = 3,     # search-window radius (px)
            bilat_sigma_s = 2.0,   # spatial Gaussian σ (px)
            bilat_range_k = 2.0,   # edge sensitivity (↓ protects edges harder)
        )
        (bins = out, I0_bins = sim_bins.I0_bins, geom = sim_bins.geom)
    else
        (bins = sim_bins.bins, I0_bins = sim_bins.I0_bins, geom = sim_bins.geom)
    end
end;

02. Bin Combine

4 Bins → Low / High Pair

I₀-weighted Beer recombination of the 4 SVD-denoised bins into the two-channel (low, high) pair the Cong decomposition consumes:

N_grp = Σ_{b ∈ grp} I0[b] · exp(-p[b]),   p_grp = -log(N_grp / Σ I0[b])
  • Low = bins 1 + 2 + 3 (20 – 70 keV)

  • High = bin 4 ( > 70 keV)

The two-channel (low, high) log line integrals feed the fine SVD (step 03) and then Cong. Physical DAS floor (counts ≥ 1) applied.

sino_combined = let
    low_bins = collect(1:3)
    high_bins = [4]

    I0_lo = Float32(sum(Float64.(bins_denoised.I0_bins[low_bins])))
    I0_hi = Float32(sum(Float64.(bins_denoised.I0_bins[high_bins])))

    sz = size(bins_denoised.bins[1])
    N_lo = zeros(Float32, sz)
    N_hi = zeros(Float32, sz)
    for b in low_bins
        I0b = Float32(bins_denoised.I0_bins[b])
        @. N_lo += I0b * exp(-Float32(bins_denoised.bins[b]))
    end
    for b in high_bins
        I0b = Float32(bins_denoised.I0_bins[b])
        @. N_hi += I0b * exp(-Float32(bins_denoised.bins[b]))
    end

    N_lo_f = max.(N_lo, 1.0f0)   # physical DAS floor (1 count)
    N_hi_f = max.(N_hi, 1.0f0)
    sino_low = Float32.(.- log.(N_lo_f ./ I0_lo))
    sino_high = Float32.(.- log.(N_hi_f ./ I0_hi))

    (
        sino_low = sino_low, sino_high = sino_high,
        I0_lo = I0_lo, I0_hi = I0_hi,
        geom = bins_denoised.geom,
    )
end;

03. Fine SVD

Subtle 2-Channel Gaussian on the Combined Pair

A subtle Gaussian apply_sino_svd_denoise on the combined (low, high) pair that Cong consumes: U[:,1] (anatomy) kept pixel-perfect, U[:,2] (iodine contrast) lightly Gaussian-smoothed at SVD2_SIGMA px — the extra cleanup nb03 gets from denoising the final channels the decomposition sees.

sim_lohi = let
    SVD2_SIGMA = 0.5f0   # subtle 2-channel Gaussian σ (px) on the combined pair
    out = BS.apply_sino_svd_denoise(
        [sino_combined.sino_low, sino_combined.sino_high]; σ_px = SVD2_SIGMA,
    )
    (
        sino_low = out[1], sino_high = out[2],
        I0_lo = sino_combined.I0_lo, I0_hi = sino_combined.I0_hi,
        geom = sino_combined.geom,
    )
end;
Dict{Symbol, Any}(:msg => "MethodError: no method matching WasmMakie.Axis(; title::String, subtitle::String, titlesize::Int64, subtitlesize::Int64, xlabel::String, ylabel::String, xlabelsize::Int64, ylabelsize::Int64, xticklabelsize::Int64, yticklabelsize::Int64)\nThis method does not support all of the given keyword arguments (and may not support any).\n\n\e[0mClosest candidates are:\n\e[0m WasmMakie.Axis(\e[91m::Int64\e[39m, \e[91m::Int64\e[39m, \e[91m::Int64\e[39m, \e[91m::Int64\e[39m, \e[91m::String\e[39m, \e[91m::String\e[39m, \e[91m::String\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::String\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Int64\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Int64\e[39m, \e[91m::Int64\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Bool\e[39m, \e[91m::Int64\e[39m, \e[91m::Int64\e[39m, \e[91m::Int64\e[39m, \e[91m::Vector{WasmMakie.LinesPlot}\e[39m, \e[91m::Vector{WasmMakie.ScatterPlot}\e[39m, \e[91m::Vector{WasmMakie.BarPlotData}\e[39m, \e[91m::Vector{WasmMakie.HeatmapPlot}\e[39m, \e[91m::Vector{WasmMakie.ImagePlot}\e[39m, \e[91m::Vector{WasmMakie.HVLines}\e[39m, \e[91m::Vector{WasmMakie.HVSpan}\e[39m, \e[91m::Vector{WasmMakie.ABLines}\e[39m, \e[91m::Vector{WasmMakie.SegmentsPlot}\e[39m, \e[91m::Vector{WasmMakie.FilledCurve}\e[39m, \e[91m::Vector{WasmMakie.BandPlot}\e[39m, \e[91m::Vector{WasmMakie.PolyPlot}\e[39m, \e[91m::Vector{WasmMakie.MeshPlot}\e[39m, \e[91m::Vector{Tuple{Int64, Int64}}\e[39m)\e[91m got unsupported keyword arguments \"title\", \"subtitle\", \"titlesize\", \"subtitlesize\", \"xlabel\", \"ylabel\", \"xlabelsize\", \"ylabelsize\", \"xticklabelsize\", \"yticklabelsize\"\e[39m\n\e[0m\e[90m @\e[39m \e[35mWasmMakie\e[39m \e[90m~/.julia/packages/WasmMakie/PSjmW/src/core/\e[39m\e[90m\e[4mfigure.jl:14\e[24m\e[39m\n\e[0m WasmMakie.Axis(\e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m)\e[91m got unsupported keyword arguments \"title\", \"subtitle\", \"titlesize\", \"subtitlesize\", \"xlabel\", \"ylabel\", \"xlabelsize\", \"ylabelsize\", \"xticklabelsize\", \"yticklabelsize\"\e[39m\n\e[0m\e[90m @\e[39m \e[35mWasmMakie\e[39m \e[90m~/.julia/packages/WasmMakie/PSjmW/src/core/\e[39m\e[90m\e[4mfigure.jl:14\e[24m\e[39m\n\e[0m WasmMakie.Axis(; title, xlabel, ylabel, subtitle, titlealign)\e[91m got unsupported keyword arguments \"titlesize\", \"subtitlesize\", \"xlabelsize\", \"ylabelsize\", \"xticklabelsize\", \"yticklabelsize\"\e[39

04. Material Decomposition

Cong (Projection Domain)

Per-ray Cong univariate solver mapped to PCCT via the generalization in Black (in prep.) — re-derives the Cong 2022 framework around an effective spectral response Φk(ε) ≥ 0 so the same algorithm runs on dual-kVp DECT, split-filter, dual-layer, and PCCT acquisitions without code changes. The bin-combine partition (1+2 → low, 3+4 → high) is baked into Φk by summing the relevant DRM columns:

Φ_low(ε)  = S(ε) · η(ε) · Σ_{b ∈ {1,2}} R(ε, b)        ← Table 1 row 3
Φ_high(ε) = S(ε) · η(ε) · Σ_{b ∈ {3,4}} R(ε, b)        ← (counting, no ε)

(p, q) are the iodine + water mass-attenuation coefficients at the shared energy grid (matter-based variant, Cong follow-up §2.7) — same array for both channels since only Φ differs.

Output sinograms are per-ray basis line integrals:

sino_iodine = ∫c_iodine(r)dr   (g/cm²)
sino_water  = ∫c_water(r)dr    (g/cm²)

Calibration-free — no forward-projected step-wedge fit, no Chebyshev grid resolution to tune.

4:4
# Bin-combine partition feeding the two Cong channels.  Must match the
# `_combine` calls in §7 — change here AND there together.
begin
    # Partition from analytic noise optimization (iodine σ_y and water σ_c
    # over candidate 2-channel splits behind 33 cm water): moving bin 3
    # (55–70 keV — spectrally muddy in the high channel) into LOW raises the
    # separation determinant 0.392 → 0.523, cutting σ_y ≈ 16 % and σ_c ≈ 15 %
    # while keeping every photon.
    low_bins = 1:3          # PCCT bins forming the "low"  channel
    high_bins = 4:4          # PCCT bins forming the "high" channel
end
material_basis = let
    # Per-channel spectra = column sums of the sim's APPLIED W matrix over
    # the bin partition — exact by construction.  (The old manual w·η·R
    # rebuild used the analytic η while the sim applies the MC-LUT η and
    # the bowtie-centre fold; together with the pre-air-cal normalization
    # this produced the historical +28…47 HU solid-water bias.)
    e = sim_bins.energies
    ΦL = Float32.(vec(sum(sim_bins.W_applied[:, collect(low_bins)]; dims = 2)))
    ΦH = Float32.(vec(sum(sim_bins.W_applied[:, collect(high_bins)]; dims = 2)))
    ŵ_L_f32 = ΦL ./ sum(ΦL)
    ŵ_H_f32 = ΦH ./ sum(ΦH)

    p = Float32[Float32(BS.compute_mass_μ_at_energy(BS.XA.Elements.Iodine, Float64(E))) for E in e]
    q = Float32[Float32(BS.compute_mass_μ_at_energy(BS.XA.Materials.water, Float64(E))) for E in e]

    @info "[Cong basis · applied-W] low ⟨E⟩ = $(round(sum(e .* Float64.(ŵ_L_f32)); digits = 1)) keV · " *
        "high ⟨E⟩ = $(round(sum(e .* Float64.(ŵ_H_f32)); digits = 1)) keV"

    (
        ŵ_L = ŵ_L_f32, p_L = p, q_L = q,
        ŵ_H = ŵ_H_f32, p_H = copy(p), q_H = copy(q),
    )
end;
sino_basis = let
    sino_low_gpu = to_gpu(Float32.(sim_lohi.sino_low))
    sino_high_gpu = to_gpu(Float32.(sim_lohi.sino_high))

    sino_y = similar(sino_low_gpu)   # iodine basis line integrals (g/cm²)
    sino_c = similar(sino_low_gpu)   # water  basis line integrals (g/cm²)
    fill!(sino_y, 0.0f0); fill!(sino_c, 0.0f0)

    cong_ws = BS.create_cong_workspace(sino_low_gpu, material_basis)
    BS.apply_cong!(
        cong_ws, sino_y, sino_c, sino_low_gpu, sino_high_gpu;
        water_basis = (a = 0.0f0, c = 1.0f0),
    )

    sino_iodine_cpu = Array(sino_y)
    sino_water_cpu = Array(sino_c)
    @info "[Cong decomp] ⟨∫ρ_I·dr⟩ = $(round(mean(sino_iodine_cpu), sigdigits = 4)) g/cm²   " *
        "⟨∫ρ_W·dr⟩ = $(round(mean(sino_water_cpu), sigdigits = 4)) g/cm²"

    sino_low_gpu = nothing; sino_high_gpu = nothing
    sino_y = nothing; sino_c = nothing; cong_ws = nothing
    GC.gc(true)
    (
        sino_iodine = sino_iodine_cpu,
        sino_water = sino_water_cpu,
        geom = sim_lohi.geom,
    )
end;
Dict{Symbol, Any}(:msg => "MethodError: no method matching WasmMakie.Axis(; title::String, titlesize::Int64, subtitlesize::Int64, xlabel::String, ylabel::String, xlabelsize::Int64, ylabelsize::Int64, xticklabelsize::Int64, yticklabelsize::Int64)\nThis method does not support all of the given keyword arguments (and may not support any).\n\n\e[0mClosest candidates are:\n\e[0m WasmMakie.Axis(\e[91m::Int64\e[39m, \e[91m::Int64\e[39m, \e[91m::Int64\e[39m, \e[91m::Int64\e[39m, \e[91m::String\e[39m, \e[91m::String\e[39m, \e[91m::String\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::String\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Int64\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Int64\e[39m, \e[91m::Int64\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Bool\e[39m, \e[91m::Int64\e[39m, \e[91m::Int64\e[39m, \e[91m::Int64\e[39m, \e[91m::Vector{WasmMakie.LinesPlot}\e[39m, \e[91m::Vector{WasmMakie.ScatterPlot}\e[39m, \e[91m::Vector{WasmMakie.BarPlotData}\e[39m, \e[91m::Vector{WasmMakie.HeatmapPlot}\e[39m, \e[91m::Vector{WasmMakie.ImagePlot}\e[39m, \e[91m::Vector{WasmMakie.HVLines}\e[39m, \e[91m::Vector{WasmMakie.HVSpan}\e[39m, \e[91m::Vector{WasmMakie.ABLines}\e[39m, \e[91m::Vector{WasmMakie.SegmentsPlot}\e[39m, \e[91m::Vector{WasmMakie.FilledCurve}\e[39m, \e[91m::Vector{WasmMakie.BandPlot}\e[39m, \e[91m::Vector{WasmMakie.PolyPlot}\e[39m, \e[91m::Vector{WasmMakie.MeshPlot}\e[39m, \e[91m::Vector{Tuple{Int64, Int64}}\e[39m)\e[91m got unsupported keyword arguments \"title\", \"titlesize\", \"subtitlesize\", \"xlabel\", \"ylabel\", \"xlabelsize\", \"ylabelsize\", \"xticklabelsize\", \"yticklabelsize\"\e[39m\n\e[0m\e[90m @\e[39m \e[35mWasmMakie\e[39m \e[90m~/.julia/packages/WasmMakie/PSjmW/src/core/\e[39m\e[90m\e[4mfigure.jl:14\e[24m\e[39m\n\e[0m WasmMakie.Axis(\e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m)\e[91m got unsupported keyword arguments \"title\", \"titlesize\", \"subtitlesize\", \"xlabel\", \"ylabel\", \"xlabelsize\", \"ylabelsize\", \"xticklabelsize\", \"yticklabelsize\"\e[39m\n\e[0m\e[90m @\e[39m \e[35mWasmMakie\e[39m \e[90m~/.julia/packages/WasmMakie/PSjmW/src/core/\e[39m\e[90m\e[4mfigure.jl:14\e[24m\e[39m\n\e[0m WasmMakie.Axis(; title, xlabel, ylabel, subtitle, titlealign)\e[91m got unsupported keyword arguments \"titlesize\", \"subtitlesize\", \"xlabelsize\", \"ylabelsize\", \"xticklabelsize\", \"yticklabelsize\"\e[39m\n\e[0m\e[90m @\e[39m \e[35mWasmMakie\e[39m

05. FBP Basis Maps

Per-Basis Apodization

Two FDK passes, one apodization filter per basis: a soft iodine filter crushes the α-amplified low-keV noise, and a soft water (SoftFilter) filter holds down the σ_W floor. Because every VMI is W + α(E)·I, softening each basis once selectively shapes noise across energies (selectivity is emergent from α(E)). The iodine + water reconstructions land in basis-density units (g/cm³) directly.

basis_volumes = let
    matrix_size = recon_opts.matrix_size
    geom = sino_basis.geom

    # PER-BASIS apodization (NOT per-keV).  Applied ONCE to each basis sinogram;
    # every VMI is VMI_E = W + α(E)·I.  A SOFT iodine filter crushes the low-keV
    # noise (α(50) large); a SOFT water filter holds down the σ_W floor that
    # dominates the high-keV VMIs.  Selectivity is emergent from α(E).
    #
    # ── TUNE iodine softness here: lower mid/high values ⇒ softer ⇒ less
    #    low-keV noise (but softer iodine detail).
    iodine_filter = BS.CustomFilter(
        (0.0, 0.25, 0.5, 0.75, 1.0),
        (1.0, 0.40, 0.12, 0.03, 0.001),
    )
    water_filter = BS.SoftFilter()

    function _fbp(sino_cpu, filt)
        sino_gpu = to_gpu(Float32.(sino_cpu))
        ws = BS.create_fdk_recon_workspace(
            sino_gpu, geom, matrix_size; filter = filt,
        )
        recon = Array(BS.reconstruct!(ws, sino_gpu, geom))
        ws = nothing; sino_gpu = nothing
        GC.gc(true)
        return Float32.(recon)
    end

    (
        vol_iodine_raw = _fbp(sino_basis.sino_iodine, iodine_filter),
        vol_water_raw = _fbp(sino_basis.sino_water, water_filter),
        geom = geom,
    )
end;
Dict{Symbol, Any}(:msg => "UndefVarError: `DataAspect` not defined in `WasmMakie`\nSuggestion: check for spelling errors or missing imports.", :stacktrace => Dict{Symbol, Any}[Dict(:call_short => "getproperty(x::Module, f::Symbol)", :inlined => false, :url => "https://github.com/JuliaLang/julia/tree/15346901f0039751c5488744f1f62de7d87510a8/base/Base_compiler.jl#L47", :path => "./Base_compiler.jl", :source_package => "Main", :call => "getproperty(x::Module, f::Symbol)", :linfo_type => "Core.MethodInstance", :line => 47, :file => "Base_compiler.jl", :func => "getproperty", :parent_module => "Base", :from_c => false), Dict(:call_short => "top-level scope", :inlined => false, :url => nothing, :path => "/Users/daleblack/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/04_pcct_vmi.jl#==#0600000a-0000-4000-8000-000000000030", :source_package => nothing, :call => "top-level scope", :linfo_type => "Core.CodeInfo", :line => 21, :file => "04_pcct_vmi.jl#==#0600000a-0000-4000-8000-000000000030", :func => "top-level scope", :parent_module => nothing, :from_c => false)], :plain_error => "UndefVarError: `DataAspect` not defined in `WasmMakie`\nSuggestion: check for spelling errors or missing imports.\nStacktrace:\n [1] getproperty(x::Module, f::Symbol)\n @ Base ./Base_compiler.jl:47\n [2] top-level scope\n @ ~/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/04_pcct_vmi.jl#==#0600000a-0000-4000-8000-000000000030:21")

06. ACNR

Anti-Correlated Noise Reduction

Material decomposition stamps anti-correlated noise on the basis maps (ρ_basis < 0) — that anti-correlation is the VMI-noise U. BS.apply_acnr_kalender! (data-adaptive cov-ACNR, denoising/acnr.jl) removes it: a closed-form 2×2 covariance eigen-rotation keeps the structure axis e1 pixel-perfect and joint-bilateral-denoises only the anti-correlated noise axis e2 (edge-aware, so real water/iodine edges survive). Runs on the FBP basis maps, just before VMI synthesis.

# Kalender-1988 true ACNR on the FBP basis maps via `BS.apply_acnr_kalender!`.
basis_acnr = let
    APPLY_ACNR = true          # Kalender-1988 true ACNR; false ⇒ passthrough
    ACNR_PASSES = 4            # geometric shrink of residual anti-correlation per pass
    ACNR_BETA_MAX = 20.0       # ← THE monotonicity lever: caps the per-pixel regression
    #   coefficient.  The default (8) UNDER-corrects, leaving Cov≈−6.3 when
    #   monotonicity needs |Cov| ≤ 5σ_I² ≈ 2.7 (α★ sat at ~100 keV → 140 keV tail
    #   flipped up).  Raising it lets strongly anti-correlated pixels actually be
    #   regressed out, pulling Cov under the bar in few passes.

    W = copy(basis_volumes.vol_water_raw)
    I = copy(basis_volumes.vol_iodine_raw)

    if APPLY_ACNR
        # TRUE ACNR (Kalender 1988): per-pixel local regression between the
        # two maps' high-frequency channels — anti-correlated (noise) content
        # subtracted exactly, structure pixels clamped to zero correction and
        # bit-untouched.  Zero blur by construction.
        info = BS.apply_acnr_kalender!(W, I; passes = ACNR_PASSES, beta_max = ACNR_BETA_MAX)
        @info "[ACNR · Kalender-1988 true ACNR] ρ_hp(W,I)=$(round(info.ρ_hp, digits = 3)) · σ_hp(W)=$(round(info.σ_hW, sigdigits = 3)) σ_hp(I)=$(round(info.σ_hI, sigdigits = 3))"
    else
        @info "[ACNR] OFF (passthrough)"
    end

    (vol_iodine_raw = I, vol_water_raw = W, geom = basis_volumes.geom)
end;

07. VMI Synthesis

BS.synth_vmi_2basis(c_water, c_iodine; energy_keV) evaluates the textbook 2-basis linear mix (McCollough 2015) at the target keV:

μ(E)  = c_water(r) · (μ/ρ)_water(E) + c_iodine(r) · (μ/ρ)_iodine(E)
HU(E) = 1000 · (μ(E) − (μ/ρ)_water(E)) / (μ/ρ)_water(E)

The denominator is the mono-energetic linear attenuation of pure water at the target VMI energy from NIST tables. VMI grid: 50, 70, 100, 140 keV.

Solid-Water Diagnostic

The solid_water_basis cell below measures ⟨c_water⟩ and ⟨c_iodine⟩ over a deeply-eroded solid-water ROI. Its synth-evaluated μ_water at each VMI energy is logged next to the textbook mono divisor as a Δ% drift. This is diagnostic only — after the SVD sinogram denoising + Cong decomposition, the residual bias is small enough that the textbook analytical divisor recovers correct HUs directly without needing an empirical anchor.

solid_water_basis = let
    # Mid-slice SW mask, broadcast across all recon z (Gammex 472 SW
    # background is z-invariant).  Then DEEPLY erode (σ = 12 px ≈ 8 mm
    # at 0.683 mm/px) so we sample only deep-interior SW voxels — well
    # clear of rod edges where partial-volume mixing with iodine / Ca
    # contaminates the basis means.
    ERODE_PX = 12.0

    mask_2d_raw = phantom_cpu.mask[:, :, size(phantom_cpu.mask, 3) ÷ 2]
    sw_bool_raw = (mask_2d_raw .== UInt8(BS.REGION_SOLID_WATER))
    sw_bool = BS.erode_mask_2d(sw_bool_raw; erode_px = ERODE_PX)

    n_raw = count(sw_bool_raw); n_eroded = count(sw_bool)
    n_eroded == 0 && error(
        "solid_water_basis: deep erosion (σ = $(ERODE_PX) px) wiped out the SW " *
            "ROI (raw count = $(n_raw)).  Reduce erode_px or check phantom mask."
    )
    @info "solid_water_basis: SW mid-slice voxel count $(n_raw) → $(n_eroded) " *
        "after $(ERODE_PX)-px erosion"

    sw_idx = findall(sw_bool)
    n_z = size(basis_acnr.vol_water_raw, 3)
    function _mean(vol)
        s = 0.0; n = 0
        for z in 1:n_z, ci in sw_idx
            s += vol[ci, z]; n += 1
        end
        return s / n
    end

    c_w = Float64(_mean(basis_acnr.vol_water_raw))
    c_i = Float64(_mean(basis_acnr.vol_iodine_raw))
    @info "solid_water_basis: ⟨c_water⟩_SW = $(round(c_w, digits = 4)) g/cm³, " *
        "⟨c_iodine⟩_SW = $(round(c_i, digits = 6)) g/cm³"

    (
        c_water = c_w, c_iodine = c_i, n_voxels = length(sw_idx) * n_z,
        mask_2d = collect(sw_bool),
    )   # for downstream viz
end;
pcct_vmi_energies = [50.0, 70.0, 100.0, 140.0];
vmi_HU_by_keV = let
    # `BS.synth_vmi_2basis` expects c_iodine in mg/mL; our basis maps
    # are in g/cm³ (= g/mL).  Multiply by 1000 to convert.
    c_iodine_mg_per_mL = basis_acnr.vol_iodine_raw .* 1000.0f0

    out = Dict{Float64, Array{Float32, 3}}()
    for E in pcct_vmi_energies
        # Diagnostic-only — the SW-ROI synth-evaluated μ_water vs the
        # textbook mono divisor.  Drift = residual basis-decomp bias.
        μρ_w = BS.compute_mass_μ_at_energy(BS.XA.Materials.water, E)
        μρ_I = BS.compute_mass_μ_at_energy(BS.XA.Elements.Iodine, E)
        μ_water_anchor = solid_water_basis.c_water * μρ_w +
            solid_water_basis.c_iodine * μρ_I
        Δ_pct = 100.0 * (μ_water_anchor - μρ_w) / μρ_w
        @info "VMI synth @ $(Int(E)) keV: divisor = $(round(μρ_w, digits = 5)) cm⁻¹ " *
            "(mono μρ_water);  SW-ROI anchor = " *
            "$(round(μ_water_anchor, digits = 5)) → Δ = $(round(Δ_pct, digits = 2))%"

        out[E] = BS.synth_vmi_2basis(
            basis_acnr.vol_water_raw, c_iodine_mg_per_mL;
            energy_keV = E,
        )
    end
    out
end;
Dict{Symbol, Any}(:msg => "UndefVarError: `DataAspect` not defined in `WasmMakie`\nSuggestion: check for spelling errors or missing imports.", :stacktrace => Dict{Symbol, Any}[Dict(:call_short => "getproperty", :inlined => true, :url => nothing, :path => "./Base_compiler.jl", :source_package => nothing, :call => "getproperty", :linfo_type => "Nothing", :line => 47, :file => "Base_compiler.jl", :func => "getproperty", :parent_module => nothing, :from_c => false), Dict(:call_short => "macro expansion", :inlined => true, :url => nothing, :path => "/Users/daleblack/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/04_pcct_vmi.jl#==#0600000c-0000-4000-8000-000000000040", :source_package => nothing, :call => "macro expansion", :linfo_type => "Nothing", :line => 13, :file => "04_pcct_vmi.jl#==#0600000c-0000-4000-8000-000000000040", :func => "macro expansion", :parent_module => nothing, :from_c => false)], :plain_error => "UndefVarError: `DataAspect` not defined in `WasmMakie`\nSuggestion: check for spelling errors or missing imports.\nStacktrace:\n [1] getproperty\n @ ./Base_compiler.jl:47 [inlined]\n [2] macro expansion\n @ ~/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/04_pcct_vmi.jl#==#0600000c-0000-4000-8000-000000000040:13 [inlined]")

Results

Per-rod measured vs theoretical HU at the canonical four VMI energies (50 / 70 / 100 / 140 keV).

Methodology

  • Measured HU = mean over an 8-px-radius circular ROI at the rod centroid, broadcast across all z slices. The small core ROI avoids partial-volume bleed at the rod edge.

  • Theoretical HU = 1000 · (μ_r(E) − μ_water(E)) / μ_water(E) from BS.compute_μ_at_energy(material_r, E) — pure physics, no fitting, no calibration assumption.

What the Plots Show

Two panels: Calcium rods (50 – 600 mg/mL, Compton-dominated smooth roll-off as E increases) and Iodine rods (2 – 20 mg/mL, K-edge at 33.2 keV so 50 keV still amplifies iodine HU strongly vs the 70+ keV plateau).

Solid line = measured. Dashed line = theoretical. Tight overlay means the projection-domain pipeline is recovering the underlying physics correctly.

ROD_LABELS = (
    Ca = (UInt8(10), UInt8(11), UInt8(12), UInt8(13), UInt8(14), UInt8(15), UInt8(16)),
    I = (UInt8(20), UInt8(21), UInt8(22), UInt8(23), UInt8(24), UInt8(25), UInt8(26)),
);
ROD_NAMES = (
    Ca = ("50 mg/mL", "100 mg/mL", "200 mg/mL", "300 mg/mL", "400 mg/mL", "500 mg/mL", "600 mg/mL"),
    I = ("2.0 mg/mL", "2.5 mg/mL", "5.0 mg/mL", "7.5 mg/mL", "10.0 mg/mL", "15.0 mg/mL", "20.0 mg/mL"),
);
rod_data = let
    materials = phantom_cpu.materials
    mask_2d = phantom_cpu.mask[:, :, size(phantom_cpu.mask, 3) ÷ 2]
    nx, ny = size(mask_2d)
    ROI_RADIUS_PX = 8

    function rod_centroid(label::UInt8)
        idx = findall(==(label), mask_2d)
        isempty(idx) && error("rod_centroid: no voxels with label $label")
        cx = sum(ci -> Float64(ci[1]), idx) / length(idx)
        cy = sum(ci -> Float64(ci[2]), idx) / length(idx)
        return (cx, cy)
    end

    function rod_roi_mask(label::UInt8)
        cx, cy = rod_centroid(label)
        i_lo = max(1, floor(Int, cx - ROI_RADIUS_PX))
        i_hi = min(nx, ceil(Int, cx + ROI_RADIUS_PX))
        j_lo = max(1, floor(Int, cy - ROI_RADIUS_PX))
        j_hi = min(ny, ceil(Int, cy + ROI_RADIUS_PX))
        roi = CartesianIndex{2}[]
        r² = Float64(ROI_RADIUS_PX)^2
        for j in j_lo:j_hi, i in i_lo:i_hi
            ((i - cx)^2 + (j - cy)^2) ≤ r² && push!(roi, CartesianIndex(i, j))
        end
        return roi
    end

    rod_rois = Dict(
        lab => rod_roi_mask(lab)
            for lab in vcat(collect(ROD_LABELS.Ca), collect(ROD_LABELS.I))
    )

    μ_water_E = Dict(
        E => BS.compute_μ_at_energy(BS.XA.Materials.water, E)
            for E in pcct_vmi_energies
    )

    function theoretical_hu(material, E::Float64)
        μ = BS.compute_μ_at_energy(material, E)
        return 1000.0 * (μ - μ_water_E[E]) / μ_water_E[E]
    end

    function measured_hu(vmi_vol, label::UInt8)
        roi = rod_rois[label]
        s = 0.0; n = 0
        for z in 1:size(vmi_vol, 3), ci in roi
            s += vmi_vol[ci, z]; n += 1
        end
        return s / n
    end

    out = Dict{Symbol, NamedTuple}()
    for group in (:Ca, :I)
        labels = ROD_LABELS[group]
        n_rods = length(labels)
        n_E = length(pcct_vmi_energies)
        meas = zeros(Float64, n_rods, n_E)
        theo = zeros(Float64, n_rods, n_E)
        for (i, lab) in pairs(labels)
            mat = materials[Int(lab) + 1]   # mask_value + 1
            for (j, E) in pairs(pcct_vmi_energies)
                meas[i, j] = measured_hu(vmi_HU_by_keV[E], lab)
                theo[i, j] = theoretical_hu(mat, E)
            end
        end
        out[group] = (
            labels = labels, names = ROD_NAMES[group],
            measured = meas, theoretical = theo,
        )
    end
    out
end;

Water ROI

Left panel: the deeply-eroded solid-water ROI (12-px erosion ≈ 8 mm) overlaid in red on the 70 keV VMI slice — exactly the voxels feeding the solid_water_basis diagnostic. Right panel: mean HU over that ROI vs VMI energy.

How to Read the Bars

With the textbook mono divisor μρ_water(E), solid water reads at 1000·(⟨c_water⟩_SW − 1) — a roughly constant offset that quantifies the residual basis-decomp bias.

  • All bars cluster near 0 HU with a small (~few HU) consistent offset → pipeline is recovering physics correctly.

  • Energy-dependent drift (offset varies systematically across keVs) → spectral-shape problem upstream worth investigating.

Dict{Symbol, Any}(:msg => "UndefVarError: `DataAspect` not defined in `WasmMakie`\nSuggestion: check for spelling errors or missing imports.", :stacktrace => Dict{Symbol, Any}[Dict(:call_short => "getproperty(x::Module, f::Symbol)", :inlined => false, :url => "https://github.com/JuliaLang/julia/tree/15346901f0039751c5488744f1f62de7d87510a8/base/Base_compiler.jl#L47", :path => "./Base_compiler.jl", :source_package => "Main", :call => "getproperty(x::Module, f::Symbol)", :linfo_type => "Core.MethodInstance", :line => 47, :file => "Base_compiler.jl", :func => "getproperty", :parent_module => "Base", :from_c => false), Dict(:call_short => "top-level scope", :inlined => false, :url => nothing, :path => "/Users/daleblack/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/04_pcct_vmi.jl#==#0600000e-0000-4000-8000-000000000005", :source_package => nothing, :call => "top-level scope", :linfo_type => "Core.CodeInfo", :line => 11, :file => "04_pcct_vmi.jl#==#0600000e-0000-4000-8000-000000000005", :func => "top-level scope", :parent_module => nothing, :from_c => false)], :plain_error => "UndefVarError: `DataAspect` not defined in `WasmMakie`\nSuggestion: check for spelling errors or missing imports.\nStacktrace:\n [1] getproperty(x::Module, f::Symbol)\n @ Base ./Base_compiler.jl:47\n [2] top-level scope\n @ ~/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/04_pcct_vmi.jl#==#0600000e-0000-4000-8000-000000000005:11")

Water-Region Noise

HU noise (σ) over the large eroded solid-water region (solid_water_basis.mask_2d) — the whole solid-water background between the rods, deeply eroded to stay clear of every rod edge. Canonical water ROI for all noise measurements in this notebook (not a tiny central circle).

Right panel = σ vs VMI energy. Diagnoses how the textbook (c_water, c_iodine) → HU(E) synth propagates noise through the PCCT pipeline (Cong-Φ_k + image-domain cov-ACNR). Expectation: σ(50) ≫ σ(70) ≳ σ(140) — monotonic-decreasing, with the natural noise-optimal energy near 70 keV.

const WATER_NOISE_ROI_RADIUS_PX = 12;   # ≈8.2 mm at 0.683 mm/px (FOV 35 cm / 512)
# Central circular noise ROI in the solid-water background (image center =
# isocenter = phantom center for the centered Gammex 472).
water_noise_roi = let
    nx_r, ny_r, nz_r = size(basis_acnr.vol_water_raw)
    cx = nx_r ÷ 2 + 1
    cy = ny_r ÷ 2 + 1

    roi_bool = falses(nx_r, ny_r)
    r² = Float64(WATER_NOISE_ROI_RADIUS_PX)^2
    @inbounds for j in 1:ny_r, i in 1:nx_r
        ((i - cx)^2 + (j - cy)^2) ≤ r² && (roi_bool[i, j] = true)
    end

    n_vox = count(roi_bool)
    @info "water_noise_roi: center = ($(cx), $(cy)), radius = $(WATER_NOISE_ROI_RADIUS_PX) px, " *
        "$(n_vox) vx × $(nz_r) z = $(n_vox * nz_r) total"

    (
        center_xy = (Float64(cx), Float64(cy)), mask_2d = roi_bool,
        n_voxels = n_vox, n_total = n_vox * nz_r,
    )
end;
# Per-keV HU noise (σ) + mean over the LARGE ERODED solid-water region
# (`solid_water_basis.mask_2d`) — the canonical water ROI for ALL noise
# measurements (not the tiny central circle).
vmi_noise_by_keV = let
    roi_idx = findall(solid_water_basis.mask_2d)
    nz_r = size(vmi_HU_by_keV[70.0], 3)

    out = Dict{Float64, NamedTuple}()
    for E in pcct_vmi_energies
        vol = vmi_HU_by_keV[E]
        vals = Float64[Float64(vol[ci, z]) for z in 1:nz_r, ci in roi_idx]
        μ = mean(vals); σ = std(vals)
        out[E] = (mean = μ, std = σ, n = length(vals))
        @info "water-region noise @ $(Int(E)) keV: ⟨HU⟩ = $(round(μ, digits = 2)),  σ = $(round(σ, digits = 2)) HU  (n = $(length(vals)))"
    end
    out
end;
Dict{Symbol, Any}(:msg => "UndefVarError: `DataAspect` not defined in `WasmMakie`\nSuggestion: check for spelling errors or missing imports.", :stacktrace => Dict{Symbol, Any}[Dict(:call_short => "getproperty", :inlined => true, :url => nothing, :path => "./Base_compiler.jl", :source_package => nothing, :call => "getproperty", :linfo_type => "Nothing", :line => 47, :file => "Base_compiler.jl", :func => "getproperty", :parent_module => nothing, :from_c => false), Dict(:call_short => "macro expansion", :inlined => true, :url => nothing, :path => "/Users/daleblack/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/04_pcct_vmi.jl#==#0600000e-0000-4000-8000-000000000070", :source_package => nothing, :call => "macro expansion", :linfo_type => "Nothing", :line => 10, :file => "04_pcct_vmi.jl#==#0600000e-0000-4000-8000-000000000070", :func => "macro expansion", :parent_module => nothing, :from_c => false)], :plain_error => "UndefVarError: `DataAspect` not defined in `WasmMakie`\nSuggestion: check for spelling errors or missing imports.\nStacktrace:\n [1] getproperty\n @ ./Base_compiler.jl:47 [inlined]\n [2] macro expansion\n @ ~/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/04_pcct_vmi.jl#==#0600000e-0000-4000-8000-000000000070:10 [inlined]")

Per-Rod Regression

Dict{Symbol, Any}(:msg => "UndefVarError: `cgrad` not defined in `WasmMakie`\nSuggestion: check for spelling errors or missing imports.", :stacktrace => Dict{Symbol, Any}[Dict(:call_short => "getproperty", :inlined => true, :url => nothing, :path => "./Base_compiler.jl", :source_package => nothing, :call => "getproperty", :linfo_type => "Nothing", :line => 47, :file => "Base_compiler.jl", :func => "getproperty", :parent_module => nothing, :from_c => false), Dict(:call_short => "macro expansion", :inlined => true, :url => nothing, :path => "/Users/daleblack/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/04_pcct_vmi.jl#==#0600000f-0000-4000-8000-000000000010", :source_package => nothing, :call => "macro expansion", :linfo_type => "Nothing", :line => 6, :file => "04_pcct_vmi.jl#==#0600000f-0000-4000-8000-000000000010", :func => "macro expansion", :parent_module => nothing, :from_c => false)], :plain_error => "UndefVarError: `cgrad` not defined in `WasmMakie`\nSuggestion: check for spelling errors or missing imports.\nStacktrace:\n [1] getproperty\n @ ./Base_compiler.jl:47 [inlined]\n [2] macro expansion\n @ ~/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/04_pcct_vmi.jl#==#0600000f-0000-4000-8000-000000000010:6 [inlined]")

Linear Regression

Same data, scattered as (theoretical, measured) per rod-energy pair with a per-energy least-squares line and the y = x identity. Ca and I are split into separate panels because Ca lives at much higher HU and would otherwise dominate a shared-axis fit.

How to Read the Fit

ObservationMeans
Slope ≈ 1, b ≈ 0, R² ≈ 1Pipeline recovers physics
Slope ≠ 1Multiplicative cal mismatch (mass-attn, basis)
Intercept ≠ 0Additive offset (residual cup, water baseline)
Low R²Non-linear distortion (partial volume, decomp)
Dict{Symbol, Any}(:msg => "UndefVarError: `RGBf` not defined in `WasmMakie`\nSuggestion: check for spelling errors or missing imports.", :stacktrace => Dict{Symbol, Any}[Dict(:call_short => "getproperty(x::Module, f::Symbol)", :inlined => false, :url => "https://github.com/JuliaLang/julia/tree/15346901f0039751c5488744f1f62de7d87510a8/base/Base_compiler.jl#L47", :path => "./Base_compiler.jl", :source_package => "Main", :call => "getproperty(x::Module, f::Symbol)", :linfo_type => "Core.MethodInstance", :line => 47, :file => "Base_compiler.jl", :func => "getproperty", :parent_module => "Base", :from_c => false), Dict(:call_short => "top-level scope", :inlined => false, :url => nothing, :path => "/Users/daleblack/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/04_pcct_vmi.jl#==#0600000f-0000-4000-8000-000000000030", :source_package => nothing, :call => "top-level scope", :linfo_type => "Core.CodeInfo", :line => 5, :file => "04_pcct_vmi.jl#==#0600000f-0000-4000-8000-000000000030", :func => "top-level scope", :parent_module => nothing, :from_c => false)], :plain_error => "UndefVarError: `RGBf` not defined in `WasmMakie`\nSuggestion: check for spelling errors or missing imports.\nStacktrace:\n [1] getproperty(x::Module, f::Symbol)\n @ Base ./Base_compiler.jl:47\n [2] top-level scope\n @ ~/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/04_pcct_vmi.jl#==#0600000f-0000-4000-8000-000000000030:5")

Verification

Quantitative PASS/FAIL against first-principles theory — per rod, per VMI energy, plus the two chain-health invariants: solid-water HU accuracy and the CRITICAL clinical requirement that VMI noise decreases MONOTONICALLY with keV. Runs on the pure chain (no SVD, no median, nr = 0.0, Kalender ACNR only).

✅ NB04 VERIFICATION: PASS (3/3)

checkvalueexpectedpass
solid water worstHUacross keV2.8
noise monotonic ↓ with keV: σ = 61.7 > 44.5 > 38.9 > 37.71.0[1.0, 1.0]
rods passing at ALL energies14.0[14, 14]

Per-rod measured / theory, gate |Δ| ≤ max(15 HU, 10 %):

rod50 keV70 keV100 keV140 keV
50 mg/mL244.0 / 239.0 ✅179.0 / 175.0 ✅145.0 / 145.0 ✅131.0 / 134.0 ✅
100 mg/mL490.0 / 482.0 ✅321.0 / 313.0 ✅234.0 / 235.0 ✅198.0 / 206.0 ✅
200 mg/mL993.0 / 994.0 ✅625.0 / 612.0 ✅434.0 / 436.0 ✅356.0 / 370.0 ✅
300 mg/mL1487.0 / 1492.0 ✅919.0 / 899.0 ✅625.0 / 627.0 ✅504.0 / 525.0 ✅
400 mg/mL1992.0 / 1990.0 ✅1216.0 / 1187.0 ✅815.0 / 817.0 ✅650.0 / 679.0 ✅
500 mg/mL2495.0 / 2488.0 ✅1513.0 / 1474.0 ✅1005.0 / 1008.0 ✅796.0 / 833.0 ✅
600 mg/mL3023.0 / 3008.0 ✅1824.0 / 1776.0 ✅1205.0 / 1210.0 ✅950.0 / 997.0 ✅
2.0 mg/mL115.0 / 111.0 ✅56.0 / 55.0 ✅26.0 / 26.0 ✅13.0 / 14.0 ✅
2.5 mg/mL143.0 / 138.0 ✅70.0 / 68.0 ✅32.0 / 31.0 ✅16.0 / 16.0 ✅
5.0 mg/mL276.0 / 271.0 ✅131.0 / 130.0 ✅56.0 / 57.0 ✅26.0 / 27.0 ✅
7.5 mg/mL412.0 / 402.0 ✅195.0 / 192.0 ✅82.0 / 82.0 ✅36.0 / 38.0 ✅
10.0 mg/mL542.0 / 534.0 ✅256.0 / 253.0 ✅108.0 / 108.0 ✅47.0 / 49.0 ✅
15.0 mg/mL819.0 / 803.0 ✅385.0 / 380.0 ✅160.0 / 160.0 ✅67.0 / 71.0 ✅
20.0 mg/mL1106.0 / 1086.0 ✅523.0 / 518.0 ✅222.0 / 223.0 ✅98.0 / 103.0 ✅

Summary

Simulate 140 kVp PCCT  (4 bins; scatter + noise + pile-up + corrections)
   → 4-bin joint SVD denoise  (edge-aware bilateral — main, zero resolution loss)
   → Bin combine  (1+2+3 → low, 4 → high)
   → Fine 2-channel Gaussian SVD on the combined (low, high) pair
   → Projection-domain material decomposition  (Cong, PCCT-Φ_k)
   → FBP × 2 with per-basis apodization  (soft iodine + soft water)
   → Kalender-1988 true ACNR  (BS.apply_acnr_kalender!, beta_max = 20)
   → Monoenergetic VMI synthesis  (textbook 2-basis, mono μρ_water divisor)
   → Automated verification  (water HU · monotonic noise-vs-keV · 14-rod NIST regression)

The 4-bin SVD denoise and Cong decomposition run upstream of FBP, so quantum noise and beam-hardening residuals can't propagate into the basis maps. The Cong univariate solver — generalized to PCCT via the effective- spectral-response Φ_k(ε) ≥ 0 in Black (in prep.) — handles beam hardening through the polychromatic transmission integral a linear closed-form inversion misses on a 33 cm phantom, calibration-free. The per-basis apodization softens only the iodine channel, so low-keV VMI noise drops preferentially (α(E)² weighting) while water/anatomy resolution stays sharp; Kalender true ACNR then removes the anti-correlated basis noise — yielding HU-quantitative VMIs with low streak content.