← Examples/03 · Dual-kVp Switching VMI on Gammex 472
View source on GitHub

Dual-kVp Rapid-Switching Virtual Monoenergetic Imaging

GE Revolution Apex Elite rapid-kVp-switching simulation (80 + 140 kVp, 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 80 / 140 kVp
   → Jensen debias + projection-domain SVD-bilateral denoise
   → Projection-domain material decomposition  (Cong; iodine + water)
   → FBP × 2 with per-basis apodization  (soft iodine, sharp water)
   → Kalender-1988 true ACNR on the basis maps
   → Monoenergetic VMI synthesis  (50 / 70 / 100 / 140 keV)
   → Measured-vs-theoretical per-rod verification

Why Projection Domain?

Two structural differences vs an image-domain DECT 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 (and the bowtie's per-ray spectral hardening). 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 per-ray univariate solver (dual-kVp DECT); material-basis variant (iodine + water).

  • 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
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 / oneArray / ROCArray / Array
    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

GE Revolution Apex Elite

scanner = BS.Scanner(
    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,

    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,
);

03. CTProtocol() Struct

Dual-kVp Switching

kVpInstantaneous mADuty cycleEffective mA
804070.65264.55
1404050.35141.75
protocol_low = BS.CTProtocol(
    # DICOM-faithful GSI per-view exposure (Revolution Apex scan 2026-02-23,
    # DE_80_140kVp_10.07mGyCTDI, tags 0053,1085/1086): the tube runs 407 mA
    # during each 0.508 ms 80 kVp view → 0.207 mAs/view.  The real scanner
    # acquires 492 views/channel interleaved and its GSI chain
    # view-interpolates back to the full 984-view grid before recon; here the
    # 984-view simulation stands in for that interpolated grid, with the
    # per-view photon statistics (what controls starvation) matching the
    # clinical scan exactly.  (The earlier 265 mA-eff had the right dose but
    # 1.54× fewer photons per view → starvation streaks the real GSI does
    # not show.)
    kVp = 80,
    mA = 407.0,
    views = 984,
    rotation_time = 0.5,
    collimation_mm = 5.0,
    additional_filters = [("Al", 4.5)],
);
protocol_high = BS.CTProtocol(
    # 140 kVp channel: 405 mA × 0.508 ms dwell (DICOM tags 0053,1083/1084),
    # same 984-view interpolated grid.
    kVp = 140,
    mA = 405.0,
    views = 984,
    rotation_time = 0.5,
    collimation_mm = 5.0,
    additional_filters = [("Al", 4.5)],
);

04. SimOptions() & ReconOptions()

sim_opts = BS.SimOptions(
    fidelity = :eict,
    seed = 1234,
    projector = :dd_fast,  # same anti-aliased DD physics, single-pass fused kernels (~47× faster poly)
);
recon_opts = let
    slice_thickness_mm = 0.625
    n_recon_slices = round(Int, 5.0 / slice_thickness_mm)
    BS.ReconOptions(
        matrix_size = (512, 512, n_recon_slices),
        fov_cm = 35.0,
        z_cm = 0.5,
    )
end;

05. Forward Project: simulate!

Run BS.simulate! on each kVp protocol. The EICT path bakes in per-ray spatial scatter + Compton + Rayleigh, and we keep the simulator's noisy line-integral sinogram (ws.sinogram).

sim_low = let
    @info "Simulating: 80 kVp / $(round(protocol_low.mA, digits = 1)) mA-eff (DE low)…"
    ws = BS.create_eict_workspace(scanner, protocol_low, sim_opts, recon_opts, phantom)
    BS.simulate!(ws, phantom, protocol_low, sim_opts)
    # Per-ray air-scan counts I0(col,row) — needed by the Jensen debias
    # (bowtie + heel make the EICT flux ray-dependent).
    I0_scalar = BS.compute_detector_I0(ws.geom, protocol_low, sum(ws.weights)) * Float64(ws.η_eff)
    air_ref = ws.bowtie_air_reference === nothing ? ones(Float32, ws.geom.n_cols, ws.geom.n_rows) :
        Array(ws.bowtie_air_reference)
    result = (sino = Array(ws.sinogram), geom = ws.geom,
        I0_ray = Float32.(I0_scalar .* Float64.(air_ref)))
    ws = nothing; GC.gc(true)
    result
end;
sim_high = let
    @info "Simulating: 140 kVp / $(round(protocol_high.mA, digits = 1)) mA-eff (DE high)…"
    ws = BS.create_eict_workspace(scanner, protocol_high, sim_opts, recon_opts, phantom)
    BS.simulate!(ws, phantom, protocol_high, sim_opts)
    # Per-ray air-scan counts I0(col,row) — needed by the Jensen debias
    # (bowtie + heel make the EICT flux ray-dependent).
    I0_scalar = BS.compute_detector_I0(ws.geom, protocol_high, sum(ws.weights)) * Float64(ws.η_eff)
    air_ref = ws.bowtie_air_reference === nothing ? ones(Float32, ws.geom.n_cols, ws.geom.n_rows) :
        Array(ws.bowtie_air_reference)
    result = (sino = Array(ws.sinogram), geom = ws.geom,
        I0_ray = Float32.(I0_scalar .* Float64.(air_ref)))
    ws = nothing; GC.gc(true)
    result
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

VMI Pipeline

01. Jensen Debias

Log-Poisson Bias Correction

Per ray: p ← p − 1/(2N), N = I₀(col,row)·e^{−p} — the first-order bias of −log of Poisson counts. Deterministic (σ untouched); on photon-starved rays behind the dense Ca rods (N ≈ 2–50 at 80 kVp) it removes the 1–2.5 % p inflation Cong would amplify into low-keV HU bias. Runs on the raw sinograms — before any denoising — so N is the true detected count.

sino_debiased = let
    # First-order log-Poisson debias: E[−log(N/I0)] = p_true + 1/(2N) for
    # Poisson counts.  Deterministic bias correction (σ untouched); MUST run
    # on the RAW sinograms so N is the true detected count.
    debias(p, I0_ray) = begin
        out = Float32.(p)
        nc, nr, nv = size(out)
        for v in 1:nv, r in 1:nr, c in 1:nc
            N = max(I0_ray[c, r] * exp(-out[c, r, v]), 1.0f0)
            out[c, r, v] -= 1.0f0 / (2.0f0 * N)
        end
        out
    end
    (low  = debias(sim_low.sino,  sim_low.I0_ray),
     high = debias(sim_high.sino, sim_high.I0_ray))
end;

02. SVD Denoise

Edge-Aware Bilateral (Projection Domain)

Per-row cross-channel SVD on the debiased (low, high) pair: the common anatomy U[:,1] is kept pixel-perfect and the residual U[:,2] (streaks

  • decorrelated quantum noise) is cleaned with an edge-aware joint bilateral —

zero resolution cost (unlike apply_sino_svd_denoise's Gaussian). Runs after the debias, so it also removes the noise the debias amplifies in photon-starved rays. APPLY_SVD = false ⇒ debias-only passthrough.

sino_denoised = let
    APPLY_SVD = true
    if APPLY_SVD
        out = BS.apply_sino_svd_denoise_bilateral(
            [sino_debiased.low, sino_debiased.high];
            # ── how FAR it can reach to average. Bigger = smoother but slower;
            #    never averages across an edge. Secondary knob; keep ≈ σ_s.
            bilat_radius  = 3,    # search-window radius (px)
            # ── spatial falloff inside that window. ↑ = stronger flat-region
            #    (streak/noise) smoothing; ↓ = gentler. Pair with radius.
            bilat_sigma_s = 2.0,  # spatial Gaussian σ (px)
            # ── THE important knob (edge sensitivity): how big an intensity
            #    jump counts as a real edge to PROTECT vs. noise to smooth.
            #    ↓ (≈1.0) = guard edges harder, less smoothing, safest for
            #    resolution. ↑ (3–4) = smooth more, risks eating faint detail.
            bilat_range_k = 2.0,
        )
        (low = out[1], high = out[2])
    else
        (low = sino_debiased.low, high = sino_debiased.high)
    end
end;

03. Material Decomposition

Cong (Projection Domain)

Per-ray Newton solver on the polychromatic transmission integral (iodine + water basis, seeded water_basis = (a = 0, c = 1)), yielding basis line integrals sino_iodine = ∫c_iodine dr and sino_water = ∫c_water dr.

Full-Spectrum Awareness

The basis builder uses BS.resolve_source_spectrum_full, which returns a per-ray 3D spectral weight ŵ[col, row, E] containing EVERY spectrum-shaping factor the simulation applied — bowtie, anode heel, and detector efficiency η(E). The decomposition kernel detects the 3D shape automatically and uses the right column–row spectrum for each detector position, so the inversion's forward model matches the data exactly.

material_basis = let
    # FULL detected spectrum — tube × filters × bowtie × heel × η(E), each
    # gated on sim_opts.use_*.  The decomposition's forward model must match
    # the forward model simulate! actually applied (same doctrine as the
    # knobless water BHC); the old bowtie-only resolver omitted η + heel and
    # left a keV-dependent bias on every ray.
    e_L, ŵ_L = BS.resolve_source_spectrum_full(
        sim_opts, protocol_low; scanner = scanner, geom = sim_low.geom,
    )
    e_H, ŵ_H = BS.resolve_source_spectrum_full(
        sim_opts, protocol_high; scanner = scanner, geom = sim_high.geom,
    )

    ŵ_L_f32 = Float32.(ŵ_L ./ sum(ŵ_L; dims = ndims(ŵ_L)))
    ŵ_H_f32 = Float32.(ŵ_H ./ sum(ŵ_H; dims = ndims(ŵ_H)))

    iodine_mat = BS.XA.Elements.Iodine     # pure-element iodine basis
    water_mat = BS.XA.Materials.water     # H₂O compound water basis

    p_L = Float32[Float32(BS.compute_mass_μ_at_energy(iodine_mat, Float64(E))) for E in e_L]
    q_L = Float32[Float32(BS.compute_mass_μ_at_energy(water_mat, Float64(E))) for E in e_L]
    p_H = Float32[Float32(BS.compute_mass_μ_at_energy(iodine_mat, Float64(E))) for E in e_H]
    q_H = Float32[Float32(BS.compute_mass_μ_at_energy(water_mat, Float64(E))) for E in e_H]

    (
        ŵ_L = ŵ_L_f32, p_L = p_L, q_L = q_L,
        ŵ_H = ŵ_H_f32, p_H = p_H, q_H = q_H,
    )
end;
sino_basis = let
    # Debias + SVD-bilateral denoise already applied upstream (in the
    # `sino_denoised` cell, correct order: debias RAW → denoise).  Just move
    # the finished line-integral sinograms to the GPU for Cong.
    sino_low_gpu = to_gpu(sino_denoised.low)
    sino_high_gpu = to_gpu(sino_denoised.high)

    sino_y = similar(sino_low_gpu)   # iodine basis line integrals
    sino_c = similar(sino_low_gpu)   # water  basis line integrals
    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),
    )

    result = (
        sino_iodine = Array(sino_y),
        sino_water = Array(sino_c),
        geom = sim_low.geom,
    )
    sino_low_gpu = nothing; sino_high_gpu = nothing
    sino_y = nothing; sino_c = nothing; cong_ws = nothing
    GC.gc(true)
    result
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

04. FBP Basis Maps

Per-Basis Apodization

Two FDK passes with BS.SoftFilter() — one per basis sinogram. The iodine + water reconstructions land in basis-density units (g/cm³) directly; no post-decomposition step needed.

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

    # PER-BASIS apodization (NOT per-keV).  The FBP filter is applied ONCE to
    # each basis sinogram; every VMI is then synthesized as VMI_E = W + α(E)·I.
    # The photon-starvation STREAKS live in the IODINE channel, so a SOFT
    # iodine apodization crushes the 50 keV streaks (α(50)=54) while barely
    # touching 140 keV (α=5.4, water-dominated).  The WATER channel keeps its
    # sharp filter, so anatomy resolution is preserved at EVERY energy.  The
    # 50-vs-140 selectivity is emergent from α(E), not a per-energy filter.
    #
    # ── TUNE the iodine softness here: lower the mid/high window values ⇒
    #    softer ⇒ less 50-keV streak 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: halfway between SoftFilter and StandardFilter (per-point
    # average) — a touch sharper anatomy than Soft, a touch less high-keV
    # noise than Standard.  Sets the σ_W floor / anatomy resolution.
    water_filter = BS.CustomFilter(
        (0.0, 0.25, 0.5, 0.75, 1.0),
        (1.0, 0.8744, 0.6003, 0.3031, 0.0266),
    )

    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/03_dual_kvp_switching_vmi.jl#==#05000009-0000-4000-8000-000000000030", :source_package => nothing, :call => "top-level scope", :linfo_type => "Core.CodeInfo", :line => 22, :file => "03_dual_kvp_switching_vmi.jl#==#05000009-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/03_dual_kvp_switching_vmi.jl#==#05000009-0000-4000-8000-000000000030:22")

05. 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

    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
        # is subtracted exactly, positively-correlated (structure) pixels are
        # clamped to zero correction and stay BIT-untouched.  No smoothing
        # operator touches signal: resolution preservation by construction.
        info = BS.apply_acnr_kalender!(W, I)
        @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)) · anti-correlated HF removed pixelwise, structure clamp-protected"
    else
        @info "[ACNR] OFF (passthrough)"
    end

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

06. 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 Cong decomposition + image-domain ACNR, 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;
de_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 de_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/03_dual_kvp_switching_vmi.jl#==#0500000b-0000-4000-8000-000000000040", :source_package => nothing, :call => "macro expansion", :linfo_type => "Nothing", :line => 13, :file => "03_dual_kvp_switching_vmi.jl#==#0500000b-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/03_dual_kvp_switching_vmi.jl#==#0500000b-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 de_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(de_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(de_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/03_dual_kvp_switching_vmi.jl#==#0500000d-0000-4000-8000-000000000005", :source_package => nothing, :call => "top-level scope", :linfo_type => "Core.CodeInfo", :line => 11, :file => "03_dual_kvp_switching_vmi.jl#==#0500000d-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/03_dual_kvp_switching_vmi.jl#==#0500000d-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 (σ ≈ 8 mm) to stay clear of every rod edge and partial-volume halo. This is the canonical water ROI for all noise measurements in this notebook (not a tiny central circle), so the statistics sample the full background the way a clinical noise readout would.

Right panel = σ vs VMI energy. Diagnoses how the textbook (c_water, c_iodine) → HU(E) synth propagates noise through the dual-kVp pipeline (Cong + 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`) on the final VMIs — 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 de_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/03_dual_kvp_switching_vmi.jl#==#0500000d-0000-4000-8000-000000000070", :source_package => nothing, :call => "macro expansion", :linfo_type => "Nothing", :line => 10, :file => "03_dual_kvp_switching_vmi.jl#==#0500000d-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/03_dual_kvp_switching_vmi.jl#==#0500000d-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/03_dual_kvp_switching_vmi.jl#==#0500000e-0000-4000-8000-000000000010", :source_package => nothing, :call => "macro expansion", :linfo_type => "Nothing", :line => 6, :file => "03_dual_kvp_switching_vmi.jl#==#0500000e-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/03_dual_kvp_switching_vmi.jl#==#0500000e-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/03_dual_kvp_switching_vmi.jl#==#0500000e-0000-4000-8000-000000000030", :source_package => nothing, :call => "top-level scope", :linfo_type => "Core.CodeInfo", :line => 5, :file => "03_dual_kvp_switching_vmi.jl#==#0500000e-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/03_dual_kvp_switching_vmi.jl#==#0500000e-0000-4000-8000-000000000030:5")

Verification

Quantitative PASS/FAIL against first-principles theory — per rod, per VMI energy. Theory is mono HU from the same XrayAttenuation data that drove the simulation. This is the notebook's contract: the projection-domain direct solve should put every rod on its theoretical line at every keV.

✅ NB03 VERIFICATION: PASS (3/3)

checkvalueexpectedpass
solid water worstHUacross keV1.1
noise monotonic ↓ with keV: σ = 75.6 > 52.0 > 44.7 > 43.41.0[1.0, 1.0]
rods passing at ALL energies14.0[14, 14]

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

rod50 keV70 keV100 keV140 keV
50 mg/mL237.0 / 239.0 ✅175.0 / 175.0 ✅143.0 / 145.0 ✅130.0 / 134.0 ✅
100 mg/mL468.0 / 482.0 ✅313.0 / 313.0 ✅232.0 / 235.0 ✅199.0 / 206.0 ✅
200 mg/mL973.0 / 994.0 ✅616.0 / 612.0 ✅432.0 / 436.0 ✅356.0 / 370.0 ✅
300 mg/mL1471.0 / 1492.0 ✅911.0 / 899.0 ✅621.0 / 627.0 ✅502.0 / 525.0 ✅
400 mg/mL1945.0 / 1990.0 ✅1199.0 / 1187.0 ✅813.0 / 817.0 ✅655.0 / 679.0 ✅
500 mg/mL2428.0 / 2488.0 ✅1487.0 / 1474.0 ✅1001.0 / 1008.0 ✅801.0 / 833.0 ✅
600 mg/mL2925.0 / 3008.0 ✅1791.0 / 1776.0 ✅1204.0 / 1210.0 ✅963.0 / 997.0 ✅
2.0 mg/mL105.0 / 111.0 ✅52.0 / 55.0 ✅25.0 / 26.0 ✅14.0 / 14.0 ✅
2.5 mg/mL135.0 / 138.0 ✅66.0 / 68.0 ✅30.0 / 31.0 ✅15.0 / 16.0 ✅
5.0 mg/mL265.0 / 271.0 ✅126.0 / 130.0 ✅55.0 / 57.0 ✅25.0 / 27.0 ✅
7.5 mg/mL382.0 / 402.0 ✅183.0 / 192.0 ✅81.0 / 82.0 ✅38.0 / 38.0 ✅
10.0 mg/mL528.0 / 534.0 ✅250.0 / 253.0 ✅106.0 / 108.0 ✅47.0 / 49.0 ✅
15.0 mg/mL782.0 / 803.0 ✅370.0 / 380.0 ✅157.0 / 160.0 ✅69.0 / 71.0 ✅
20.0 mg/mL1042.0 / 1086.0 ✅499.0 / 518.0 ✅218.0 / 223.0 ✅102.0 / 103.0 ✅

Summary

Simulate 80 + 140 kVp  (DICOM-faithful GSI technique, per-view flux from
                         the clinical Revolution Apex scan)
   → Jensen debias + projection-domain SVD-bilateral denoise
   → Projection-domain material decomposition  (Cong univariate, iodine + water)
   → FBP × 2 with per-basis apodization  (soft iodine, sharp water)
   → Kalender-1988 true ACNR  (BS.apply_acnr_kalender! — per-pixel HF
                               regression, structure bit-untouched, zero blur)
   → Monoenergetic VMI synthesis  (textbook 2-basis, mono μρ_water divisor)
   → Automated verification  (water HU · monotonic noise-vs-keV · 14-rod NIST regression)

The debias, SVD-bilateral denoise, and Cong decomposition all run upstream of FBP, so quantum noise and beam-hardening residuals can't propagate into the basis maps. The per-basis apodization softens only the iodine channel, so low-keV VMI noise drops preferentially (α(E)² weighting) while water/anatomy resolution stays sharp at every energy. Kalender true ACNR then removes the anti-correlated basis noise (the VMI-U) without resolution loss — yielding HU-quantitative VMIs with low streak content.