← Examples/08 · QRM Thorax Pure-Material PCCT
View source on GitHub

QRM-Thorax Pure-Material PCCT VMI: Full-Resolution True-Scan Reference

Intent: 1-to-1 PCCT counterpart of notebook 07. Same QRM-Thorax phantom with four pure-material rods (water · triolein · collagen · iodine), same full-fidelity recon grid, same per-rod measured-vs- theoretical VMI regression — but the acquisition is a single 140 kVp photon-counting scan with four energy bins, bin-combined to a (low, high) pair and run through the SF-JSD joint sinogram denoiser before the Cong PCCT-Φ_k decomposition.

StageMatrixVoxel (mm)Extent
GT phantom1600 × 1100 × 200.2 isotropic320 × 220 × 4 mm
Recon512 × 512 × 30.625 isotropicFOV 32 cm × 1.875 mm z
Collimation5.0 mm nominalcone-guard rows added automatically
ScannerSiemens Naeotom Alpha0.353 × 0.302 mm pixels144 × ~1192 detector
ProtocolPCCT, 4 bins140 kVp · 174 mA · 1200 views · 0.5 s rot.clinical PCCT dose
QRM-Thorax mid-slice mask  → relabel rods 9–12 → tile z → BS.Phantom
                                       │
                            Simulate 140 kVp PCCT  (4 bins)
                                       │
                       Per-Bin Pile-up + Scatter Correction
                                       │
                          Bin Combine  (1+2 → low,  3+4 → high)
                                       │
                       Material Decomp → FBP × 2 → z-Median →
                       2-basis VMI →
                                       │
                       Per-Rod Measured-vs-Theoretical Regression
                                       at 40 / 70 / 100 / 140 keV

When to reach for this notebook

Use 08 when you want PCCT clinical-realism evidence — VMI accuracy on a body-sized phantom with a body-sized FOV at the resolutions a real Siemens Naeotom Alpha would deliver. For the dual-kVp counterpart on the same phantom / rods, see notebook 07.

Why pure end-members?

XA.Materials.basis_fat is ICRU-44 adipose tissue (≈83 % triolein

  • 17 % water + trace electrolytes) — fine as a generic fat

reference, but it's not a mathematically pure lipid. The new XA.Materials.basis_lipid (H/C/O at 0.92 g/cm³) and basis_collagen (H/C/N/O at 1.26 g/cm³) end members were added in XrayAttenuation 0.3.0 specifically for clean water/lipid/collagen decomposition validation.

References

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

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

  • Grant et al. (2014) — Mono+ frequency-split rule.

Notebook Setup

begin
    import Pkg
    Pkg.activate(joinpath(@__DIR__, ".."))
end
using Markdown: @md_str, Markdown
using Statistics: mean, std, quantile
using Unitful: @u_str
using Random: MersenneTwister, randn!
import BasisSimulator as BS
# import CairoMakie as Mke
import WasmMakie as Mke
import PlutoUI
begin
    import GPUSelect
    AT = GPUSelect.Storage()     # the backend array type, directly: MtlArray / CuArray / ROCArray
    to_gpu(x) = AT(x)
    GPU_BACKEND = (name = string(nameof(AT)),)
end

Backend detected: MtlArray

Scan Setup and Simulation

One clinical PCCT acquisition: the QRM-Thorax phantom, the Siemens Naeotom Alpha photon-counting scanner, the 140 kVp protocol, and the 4-bin forward projection.

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

Identical to notebook 07. Read the prepared QRM-Thorax mask — already rotated to CT display orientation (spine at the bottom) and 2× downsampled, cached at docs/notebooks/data/qrm_thorax/qrm_thorax_1600x1100_rot_uint8.raw. Phantom shape after z-tiling: 1600 × 1100 × 20 at 0.2 mm isotropic (physical extent 320 × 220 × 4 mm; body envelope ≈ 30 × 20 cm matches QRM-Thorax-small spec).

Source mask labels:

| Label | Material | | Label | Material | |–––-|–––––––––––-|–|–––-|––––––––––––| | 1 | air | | 7 | air rod | | 2 | lung | | 8 | heart | | 3 | soft tissue | | 9 | rod 1 → water | | 4 | bone | | 10| rod 2 → triolein | | 5 | bone marrow | | 11| rod 3 → collagen | | 6 | water rod (lung) | | 12| rod 4 → gammex\472\i5\_0 |

Rod inserts 9–12 in the source were 4 × Ca-HA concentrations (50 / 100 / 200 / 400 mg/mL). We relabel them to four pure- material end members below — same rod geometry, replaced compositions.

const QRM_CACHE_PATH = joinpath(@__DIR__, "data", "qrm_thorax", "qrm_thorax_1600x1100_rot_uint8.raw");
const QRM_TARGET_NX = 1600;  # full prepared cache, no extra in-plane downsample
const QRM_TARGET_NY = 1100;  # full prepared cache, no extra in-plane downsample
const QRM_TARGET_NZ = 20;    # 20 × 0.2 mm = 4 mm — short z-invariant reference phantom
const QRM_VOXEL_SIZE_CM = (0.02, 0.02, 0.02);   # (x, y, z) cm — 0.2 mm isotropic ground truth (320 × 220 × 4 mm physical extent)
const QRM_SHARED_DRIVE_DIR = "/Volumes/Molloilab/Shu Nie/water-lipid";
const QRM_SHARED_FULL_PATH = joinpath(QRM_SHARED_DRIVE_DIR, "qrm_thorax_3200x2200_rot_uint8.raw");
const QRM_SHARED_DOWN_PATH = joinpath(QRM_SHARED_DRIVE_DIR, "qrm_thorax_1600x1100_rot_uint8.raw");

Share-drive (lab volume) copies of the QRM-Thorax phantom

Both rotated phantom files are mirrored on the lab volume so any lab member can pick them up without rerunning prep.

resolutionpath
full-resolution (3200 × 2200, ~7 MB)$(QRM_SHARED_FULL_PATH)
2× downsampled (1600 × 1100, ~1.7 MB) ← used here$(QRM_SHARED_DOWN_PATH)

If you don't have the local cache yet:

cp "$(QRM_SHARED_DOWN_PATH)" "$(QRM_CACHE_PATH)"
mask_3d_raw = let
    isfile(QRM_CACHE_PATH) || error(
        "QRM-Thorax cache not found at $(QRM_CACHE_PATH).\n" *
            "Either:\n" *
            "  • copy the prepared cache from the lab volume:\n" *
            "      cp \"$(QRM_SHARED_DOWN_PATH)\" \"$(QRM_CACHE_PATH)\"\n" *
            "  • or run the prep notebook once to rebuild the cache from source."
    )
    cache_2d = reshape(read(QRM_CACHE_PATH), QRM_TARGET_NX, QRM_TARGET_NY)
    repeat(cache_2d; outer = (1, 1, QRM_TARGET_NZ))
end;

QRM-Thorax body-envelope probe

Mid-slice bounding box of non-air voxels (label ≠ 1):

metricvalue
body bbox$(round(body_w_mm; digits=1)) × $(round(body_h_mm; digits=1)) mm ($(round(bodywmm/10; digits=1)) × 20.0 cm)
body fills94.0 % × 90.9 % of frame
frame$(round(frame_w_mm; digits=1)) × $(round(frame_h_mm; digits=1)) mm ($(size(slice, 1)) × 1100 voxels @ 0.2 mm/voxel)
reference specQRM-Thorax small (no fat ring) ≈ 28–30 × 18–22 cm

Bore 4 rod inserts into the heart cavity at cardinal positions (N / E / S / W) and assign them new labels 9–12 to match materials_dict.

DirectionLabelMaterial
North9basis_water
East10basis_lipid
South11basis_collagen
West12gammex_472_i5_0
const ROD_HEART_CENTER_PX = (800, 654);   # ≈ (50%, 59%) of the 1600 × 1100 frame
const ROD_RADIUS_MM = 7.5;                  # mm — each rod radius (physical)
const ROD_OFFSET_MM = 25.0;                  # mm — heart-center → rod-center distance (physical)
mask_3d = let
    out = copy(mask_3d_raw)
    nx, ny, nz = size(out)

    px_mm = QRM_VOXEL_SIZE_CM[1] * 10
    cx_px, cy_px = ROD_HEART_CENTER_PX
    r_px = ROD_RADIUS_MM / px_mm
    o_px = ROD_OFFSET_MM / px_mm

    rod_specs = (
        (0.0, +o_px, UInt8(9)),    # North → basis_water
        (+o_px, 0.0, UInt8(10)),   # East  → basis_lipid
        (0.0, -o_px, UInt8(11)),   # South → basis_collagen
        (-o_px, 0.0, UInt8(12)),   # West  → gammex_472_i5_0
    )

    for (dx, dy, lab) in rod_specs
        rx, ry = cx_px + dx, cy_px + dy
        i_lo = max(1, floor(Int, rx - r_px))
        i_hi = min(nx, ceil(Int, rx + r_px))
        j_lo = max(1, floor(Int, ry - r_px))
        j_hi = min(ny, ceil(Int, ry + r_px))
        @inbounds for j in j_lo:j_hi, i in i_lo:i_hi
            ((i - rx)^2 + (j - ry)^2) <= r_px^2 || continue
            for k in 1:nz
                out[i, j, k] = lab
            end
        end
    end

    out
end;
materials_dict = Dict{Int, BS.XA.Material}(
    # Anatomy
    1 => BS.XA.Materials.air,
    2 => BS.XA.Materials.lung,
    3 => BS.XA.Materials.muscle,
    4 => BS.XA.Materials.corticalbone,
    5 => BS.XA.Materials.marrow_red,
    6 => BS.XA.Materials.water,
    7 => BS.XA.Materials.air,
    8 => BS.XA.Materials.water,
    # Pure-material rod inserts (relabeled from Ca-HA)
    9 => BS.XA.Materials.basis_water,
    10 => BS.XA.Materials.basis_lipid,
    11 => BS.XA.Materials.basis_collagen,
    12 => BS.XA.Materials.gammex_472_i5_0,
);
phantom_cpu = BS.create_phantom_from_mask(
    Array{Int, 3}(mask_3d),
    materials_dict,
    QRM_VOXEL_SIZE_CM,
);
phantom = BS.Phantom(
    to_gpu(phantom_cpu.mask),
    phantom_cpu.materials,
    phantom_cpu.voxel_size,
    phantom_cpu.origin,
    phantom_cpu.extent,
);
QRM_LABEL_NAMES = Dict{Int, String}(
    1 => "1 air",
    2 => "2 lung",
    3 => "3 muscle / soft tissue",
    4 => "4 cortical bone",
    5 => "5 marrow",
    6 => "6 water rod (lung-side)",
    7 => "7 air rod",
    8 => "8 heart (water-filled)",
    9 => "9 → basis_water rod",
    10 => "10 → basis_lipid rod",
    11 => "11 → basis_collagen rod",
    12 => "12 → gammex_472_i5_0 rod",
);
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/08_qrm_thorax_pure_material_pcct.jl#==#08020001-0000-4000-8000-000000000061", :source_package => nothing, :call => "macro expansion", :linfo_type => "Nothing", :line => 7, :file => "08_qrm_thorax_pure_material_pcct.jl#==#08020001-0000-4000-8000-000000000061", :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/08_qrm_thorax_pure_material_pcct.jl#==#08020001-0000-4000-8000-000000000061:7 [inlined]")

Labels present in the loaded mid-slice:

labelname
11 air
22 lung
33 muscle / soft tissue
44 cortical bone
55 marrow
66 water rod (lung-side)
77 air rod
99 → basis_water rod
1010 → basis_lipid rod
1111 → basis_collagen rod
1212 → gammex472i5_0 rod

02. Scanner: 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

No bowtie filter on the Naeotom Alpha (uses the Vectron tube's inherent 0.9 mm titanium window stacked on top of the 3 mm Al flat filter).

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,
        # No bowtie — the physical Naeotom Alpha config (matches nb04).  The
        # decomposition uses the 1-D applied-W spectrum, so a per-pixel bowtie
        # would need the per-pixel-ŵ path to stay consistent; :none keeps the
        # setup coherent with nb04's certified chain.
        bowtie_filter = :none,

        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: 140 kVp / 174 mA / 5.0 mm collimation

Clinical 140 kVp single-energy PCCT 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.

Nominal collimation = 5.0 mm at iso. The saved reconstruction remains the same centered 3-slice grid as notebook 07; the workspace automatically adds symmetric detector guard rows for full cone support.

protocol = BS.CTProtocol(
    kVp = 140,
    mA = 174.0,    # clinical PCCT dose (matches header + canonical nb04); was 5.0
    views = 1200,
    rotation_time = 0.5,
    collimation_mm = 5.0,    # nominal width; full-FOV axial cone guards are automatic
    additional_filters = [("Ti", 0.9)],
);

04. SimOptions and ReconOptions

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

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 ───
    use_scatter = false,                  # EICT scatter flag — OFF (PCCT uses use_pcct_scatter)
    use_noise = true,                     # quantum noise inside simulate!()  (src :count, nr below)
    use_pcct_scatter = true,              # ← PCCT scatter injection, inside simulate!()
    use_pcct_scatter_correction = true,   # ← PCCT model-based scatter correction, inside simulate!()
    use_pcct_pileup = true,               # ← PCCT pileup forward, inside simulate!()
    use_pcct_pileup_correction = true,    # ← PCCT pileup correction (inverse S), inside simulate!()
    # DETECTOR-LEVEL CORRECTION SURROGATE — NOT a recon-level (QIR) stand-in
    # (chain is pure FBP; accuracy is independent of this knob).  Stands in
    # for the vendor's detector-side algorithms (anti-coincidence /
    # charge-sharing event reconstruction, count-rate linearization,
    # threshold compensation) whose degradations we Monte-Carlo simulate
    # but whose corrections we do not implement.
    pcct_noise_reduction = 0.7,
)
# Same saved recon grid as notebook 07: 512 × 512 in-plane at 0.625 mm
# isotropic, 3 slices. Automatic detector guards provide full-FOV support.
recon_opts = BS.ReconOptions(
    matrix_size = (512, 512, 3),
    fov_cm = 32.0,
    z_cm = 0.1875,
);

05. Forward Project (PCCT)

Run BS.simulate! once on the PCCT protocol. The simulator returns (pcct_sino, I0_bins, pileup_S) — the 4 per-bin log-line-integral sinograms, their matching reference photon counts, and the MC-LUT pile-up migration matrix S. Pile-up correction is applied directly on the GPU bins (no-op if use_pcct_pileup = false), then a model- based per-bin scatter correction strips the simulator-injected scatter field.

Inside simulate!:

  • Forward projection uses the MC-LUT detector response matrix (compute_mc_drmcdte_response_v4.jls) — captures CdTe transport, Fano noise, charge cloud (Dreier 2018), 3×3 charge sharing, and threshold comparison in a single Monte-Carlo-derived R(E,b).

  • Pulse pileup is the MC-LUT spectral-migration matrix S (compute_mc_pileup_matrix). Toggle with SimOptions(use_pcct_pileup=…); default ON for :pcct.

# === SLOW CELL (~6 min) — Full measured sinogram via STANDARD simulate!() ===
# Single src call producing the 4 per-bin log-line-integral sinograms with the
# COMPLETE PCCT physics + corrections, all gated by `sim_opts` flags:
#   forward → scatter inject (use_pcct_scatter) → quantum noise (use_noise,
#   pcct_noise_reduction) → pile-up fwd (use_pcct_pileup) → pile-up correction
#   (use_pcct_pileup_correction) → scatter correction (use_pcct_scatter_correction).
# Consumed directly by the bin-combine below — the former inline forward cells
# (pile-up / scatter fwd + correct) are GONE; this is the src-proper path.
#
sim_raw = let
    @info "simulate!(): $(Int(protocol.kVp)) kVp / $(round(protocol.mA, digits = 1)) mA — full PCCT physics + corrections (nr = $(sim_opts.pcct_noise_reduction))"
    ws = BS.create_workspace(scanner, protocol, sim_opts, recon_opts, phantom)
    result = BS.simulate!(ws, phantom, protocol, sim_opts)

    bins_raw = [Array(b) for b in result.pcct_sino.bins]   # full physics + corrections (scatter+noise+pileup, per sim_opts)
    I0_bins = copy(result.I0_bins)
    geom = ws.geom
    energies = Float64.(ws.energies)
    weights = copy(ws.weights)
    # The EXACT per-bin detected spectra the forward applied (w·η·DRM with the
    # workspace's MC-LUT η + centre-pixel bowtie fold) — the decomposition
    # basis consumes THESE, so the inversion's forward model is the model
    # simulate! actually applied (matches nb04).
    W_applied = Float64.(Array(ws.W_matrix_gpu))[1:length(ws.energies), :]
    bf = scanner.binning_factor

    ws = nothing; result = nothing
    GC.gc(true)
    (
        bins_raw = bins_raw, I0_bins = I0_bins, W_applied = W_applied,
        geom = geom, energies = energies, weights = weights, bf = bf,
    )
end;
# Resample the phantom labels onto the recon grid via BS's affine
# round-trip — used downstream for ROI construction in recon coords.
phantom_in_recon = BS.resample_to_recon(
    phantom_cpu, sim_raw.geom, recon_opts.matrix_size; method = :nearest,
);
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

VMI Pipeline

4-bin combine → projection-domain material decomposition → per-basis FBP → image-domain ACNR → monoenergetic synthesis. The PCCT counterpart of the notebook 04 chain.

01. Bin Combine: 4 Bins → Low / High Pair

I₀-weighted Beer recombination of the 4 raw PCCT bins:

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

  • High = bin 4 ( > 70 keV)

Each combined sinogram represents a polychromatic measurement at the I₀-weighted average spectrum of its bin group — the two-channel (low, high) pair the Cong PCCT-Φ_k decomposition then consumes directly, exactly as nb04 does — 123|4 partition, physical DAS floor + Jensen debias, applied-W Cong basis.

sim_lohi = let
    low_bins = [1, 2, 3]     # 123|4 partition — analytic noise optimum (matches nb04)
    high_bins = [4]

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

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

    # Physical DAS floor (1 count) + first-order log-Poisson DEBIAS (matches
    # nb04): E[−log(N/I0)] = p_true + s²/(2N), s = 1 − pcct_noise_reduction.
    # Deterministic bias correction (σ untouched).
    s_nr = Float32((1.0 - sim_opts.pcct_noise_reduction)^2)
    N_lo_f = max.(N_lo, 1.0f0)
    N_hi_f = max.(N_hi, 1.0f0)
    sino_low = Float32.(.- log.(N_lo_f ./ I0_lo) .- s_nr ./ (2.0f0 .* N_lo_f))
    sino_high = Float32.(.- log.(N_hi_f ./ I0_hi) .- s_nr ./ (2.0f0 .* N_hi_f))

    (
        sino_low = sino_low, sino_high = sino_high,
        I0_lo = I0_lo, I0_hi = I0_hi,
        geom = sim_raw.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

02. Projection-Domain Material Decomposition (PCCT-Φ_k)

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 and PCCT acquisitions without code changes. The bin-combine partition (123 → low, 4 → high) is baked into Φk by summing the relevant DRM columns:

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

(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 and sino_water = ∫c_water(r)dr (g/cm²) — calibration-free, no forward-projected step-wedge fit.

4:4
# Bin-combine partition feeding the two Cong channels.  Must match the
# §6 combine — change here AND there together.
begin
    cong_low_bins = 1:3          # PCCT bins forming the "low"  channel (123|4, matches §6)
    cong_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, identical to nb04.  (The
    # forward applies the MC-LUT η + centre-pixel bowtie fold; rebuilding
    # w·η·R with the analytic η here would mismatch it and reintroduce a
    # keV-dependent HU bias.  With no bowtie the per-pixel ŵ collapses to
    # this 1-D spectrum, so this is the exact effective model.)
    e = sim_raw.energies
    ΦL = Float32.(vec(sum(sim_raw.W_applied[:, collect(cong_low_bins)]; dims = 2)))
    ΦH = Float32.(vec(sum(sim_raw.W_applied[:, collect(cong_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)
    sino_c = similar(sino_low_gpu)
    fill!(sino_y, 0.0f0); fill!(sino_c, 0.0f0)

    @info "Cong polychromatic decomposition: $(size(sim_lohi.sino_low))"
    cong_elapsed = @elapsed begin
        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),
        )
    end
    @info "Cong done in $(round(cong_elapsed, digits = 1)) s"

    result = (
        sino_iodine = Array(sino_y),
        sino_water = Array(sino_c),
        geom = sim_lohi.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
# === Basis-sinogram covariance readout (sinogram domain, PRE-FBP) ===
# Measures the iodine↔water correlation in the basis SINOGRAMS that feed FBP — the
# quantity an ACNR-before-FBP step would act on.  Two views:
#   • GLOBAL    — cov over all rays.  With noise OFF this is the deterministic
#                 STRUCTURE correlation Cong bakes in; with noise ON it also carries it.
#   • Δcol HF   — cov of the adjacent-detector-column difference (isolates the
#                 high-frequency / noise-like component — what ACNR actually targets).
# ρ is scale-free (iodine sino tiny, water sino large), so it is the honest correlation.
# ρ < 0 ⇒ anti-correlated basis content ⇒ ACNR has something to remove.  Single-pass
# scalar accumulation — no full-sinogram temporaries (memory budget).
let
    io = sino_basis.sino_iodine
    wa = sino_basis.sino_water
    n = length(io)

    # ── GLOBAL covariance (two scalar passes) ──
    si = 0.0; sw = 0.0
    @inbounds for k in eachindex(io)
        si += io[k]; sw += wa[k]
    end
    mi = si / n; mw = sw / n
    Vi = 0.0; Vw = 0.0; Ciw = 0.0
    @inbounds for k in eachindex(io)
        a = io[k] - mi; b = wa[k] - mw
        Vi += a * a; Vw += b * b; Ciw += a * b
    end
    Vi /= n; Vw /= n; Ciw /= n
    ρ_g = Ciw / sqrt(max(Vi * Vw, 1.0e-30))

    # ── HIGH-FREQ covariance: adjacent-detector-column difference Δcol ──
    nc, nr, nv = size(io)
    sdi = 0.0; sdw = 0.0; ndf = 0
    @inbounds for v in 1:nv, r in 1:nr, c in 2:nc
        sdi += io[c, r, v] - io[c - 1, r, v]
        sdw += wa[c, r, v] - wa[c - 1, r, v]
        ndf += 1
    end
    mdi = sdi / ndf; mdw = sdw / ndf
    Vih = 0.0; Vwh = 0.0; Ciwh = 0.0
    @inbounds for v in 1:nv, r in 1:nr, c in 2:nc
        a = (io[c, r, v] - io[c - 1, r, v]) - mdi
        b = (wa[c, r, v] - wa[c - 1, r, v]) - mdw
        Vih += a * a; Vwh += b * b; Ciwh += a * b
    end
    Vih /= ndf; Vwh /= ndf; Ciwh /= ndf
    ρ_hf = Ciwh / sqrt(max(Vih * Vwh, 1.0e-30))

    @info "[basis-sino cov · GLOBAL]  σ_iod = $(round(sqrt(Vi), sigdigits = 3)) · σ_wat = $(round(sqrt(Vw), sigdigits = 3)) g/cm² · ρ(iod,wat) = $(round(ρ_g, digits = 3))"
    @info "[basis-sino cov · Δcol HF] σ_iod = $(round(sqrt(Vih), sigdigits = 3)) · σ_wat = $(round(sqrt(Vwh), sigdigits = 3)) g/cm² · ρ_hf(iod,wat) = $(round(ρ_hf, digits = 3))  ← ACNR target"
    @info "  ρ<0 ⇒ anti-correlated basis content (ACNR has something to remove).  Noise is OFF → these are the STRUCTURE baseline; turn channel noise ON to read the NOISE correlation."
    (ρ_global = ρ_g, ρ_highfreq = ρ_hf, V_iodine = Vi, V_water = Vw, C_iw = Ciw)
end

03. FBP: Iodine and Water Basis Maps

Two FDK passes — one per basis sinogram. Output volumes are in basis-density units (g/cm³).

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

    function _fbp(sino_cpu)
        sino_gpu = to_gpu(Float32.(sino_cpu))
        ws = BS.create_fdk_recon_workspace(
            sino_gpu, geom, matrix_size; filter = BS.SoftFilter(),
        )
        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),
        vol_water_raw = _fbp(sino_basis.sino_water),
        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/08_qrm_thorax_pure_material_pcct.jl#==#08030008-0000-4000-8000-000000000030", :source_package => nothing, :call => "top-level scope", :linfo_type => "Core.CodeInfo", :line => 21, :file => "08_qrm_thorax_pure_material_pcct.jl#==#08030008-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/08_qrm_thorax_pure_material_pcct.jl#==#08030008-0000-4000-8000-000000000030:21")

04. ACNR: Edge-Aware Anti-Correlated Noise Reduction (Image Domain)

Material decomposition stamps strongly anti-correlated noise onto the basis maps (measured ρ_basis ≈ −0.92) — that anti-correlation is the VMI-noise U. ACNR removes it. Now runs via the src-proper BS.apply_acnr_kalender! (per-pixel regression, zero blur) (denoising/acnr.jl).

Data-adaptive cov-ACNR. A closed-form 2×2 eigen-rotation of the joint W–I covariance learns the signal/noise axes instead of assuming them. The large-variance axis e1 (correlated structure) is kept pixel-perfect; only the small-variance axis e2 (the anti-correlated noise that is the U) is denoised. Targeting the true noise eigenvector removes more |C_iw| per unit blur than a fixed anchor.

Resolution is preserved two ways: (1) e1 is kept pixel-perfect, and (2) the denoised e2 axis is smoothed with a joint bilateral guided by BOTH basis maps, so any real water/iodine edge survives — only locally-flat anti-correlated noise is removed. The resolution check below shows the removed component: it must be structureless noise (no rod rings). Runs on the FBP basis maps, before the §9 Kalender ACNR.

# Image-domain cov-ACNR on the FBP basis maps via the src-proper
# `BS.apply_acnr_kalender!` (denoising/acnr.jl) — no knobs, no blur.
basis_acnr = let
    APPLY_ACNR = true        # ON — image-domain edge-aware cov-ACNR

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

    if APPLY_ACNR
        info = BS.apply_acnr_kalender!(W, I)
        @info "[ACNR · Kalender-1988 true ACNR] ρ_hp(W,I)=$(round(info.ρ_hp, digits = 3))"
    else
        @info "[ACNR] OFF (passthrough)"
    end

    (vol_iodine = I, vol_water = W, geom = basis_volumes.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", :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/08_qrm_thorax_pure_material_pcct.jl#==#08030008-0000-4000-8000-000000000058", :source_package => nothing, :call => "macro expansion", :linfo_type => "Nothing", :line => 13, :file => "08_qrm_thorax_pure_material_pcct.jl#==#08030008-0000-4000-8000-000000000058", :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/08_qrm_thorax_pure_material_pcct.jl#==#08030008-0000-4000-8000-000000000058:13 [inlined]")
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/08_qrm_thorax_pure_material_pcct.jl#==#08030009-0000-4000-8000-000000000030", :source_package => nothing, :call => "top-level scope", :linfo_type => "Core.CodeInfo", :line => 21, :file => "08_qrm_thorax_pure_material_pcct.jl#==#08030009-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/08_qrm_thorax_pure_material_pcct.jl#==#08030009-0000-4000-8000-000000000030:21")

05. VMI Synthesis

BS.synth_vmi_2basis(c_water, c_iodine_mg_per_mL; 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)

VMI grid: 40, 70, 100, 140 keV — matched to notebook 07. The solid_water_basis cell measures ⟨c_water⟩ and ⟨c_iodine⟩ over the dedicated water-rod core ROI (label 9, basis_water) — a diagnostic used to log Δ% drift between the water-rod-anchored synth μ_water and the textbook mono divisor.

solid_water_basis = let
    WATER_ROD_LABEL = UInt8(9)
    ROI_RADIUS_PX = 8

    mask_2d = phantom_in_recon[:, :, size(phantom_in_recon, 3) ÷ 2 + 1]
    nx_r, ny_r, nz_r = size(basis_acnr.vol_water)

    rod_idx = findall(==(WATER_ROD_LABEL), mask_2d)
    isempty(rod_idx) && error(
        "solid_water_basis: no label-$(Int(WATER_ROD_LABEL)) (water rod) voxels in resampled phantom mask."
    )
    cx = sum(ci -> Float64(ci[1]), rod_idx) / length(rod_idx)
    cy = sum(ci -> Float64(ci[2]), rod_idx) / length(rod_idx)

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

    n_voxels = count(sw_bool)
    @info "solid_water_basis: water-rod core ROI center = " *
        "($(round(cx, digits = 1)), $(round(cy, digits = 1))) recon voxels, " *
        "$(n_voxels) voxels in core"

    sw_idx = findall(sw_bool)
    function _mean(vol)
        s = 0.0; n = 0
        for z in 1:nz_r, ci in sw_idx
            s += vol[ci, z]; n += 1
        end
        return s / n
    end

    c_w = Float64(_mean(basis_acnr.vol_water))
    c_i = Float64(_mean(basis_acnr.vol_iodine))
    @info "solid_water_basis: ⟨c_water⟩_water-rod = $(round(c_w, digits = 4)) g/cm³, " *
        "⟨c_iodine⟩_water-rod = $(round(c_i, digits = 6)) g/cm³"

    (
        c_water = c_w, c_iodine = c_i, n_voxels = length(sw_idx) * nz_r,
        mask_2d = collect(sw_bool),
    )
end;
# Per-rod basis-decomp sanity check.  For each of the 4 rods, measure
# the mean (c_water, c_iodine) inside an 8-px-radius core ROI at the
# rod centroid and compare to the rod material's density.
#
# Expected:
#   rod 9  (basis_water)    → c_water ≈ 1.00 g/cm³, c_iodine ≈ 0
#   rod 10 (basis_lipid)    → c_water ≈ 0.92,        c_iodine ≈ 0
#   rod 11 (basis_collagen) → c_water ≈ 1.26,        c_iodine ≈ 0
#   rod 12 (gammex_472_i5_0)→ c_water ≈ 1.0,         c_iodine ≈ 0.005 (5 mg/mL)
let
    mask_2d = phantom_in_recon[:, :, size(phantom_in_recon, 3) ÷ 2 + 1]
    nx, ny = size(mask_2d)
    ROI_R_PX = 8

    n_z = size(basis_acnr.vol_water, 3)

    function rod_centroid(label::UInt8)
        idx = findall(==(label), mask_2d)
        cx = sum(ci -> Float64(ci[1]), idx) / length(idx)
        cy = sum(ci -> Float64(ci[2]), idx) / length(idx)
        return (cx, cy)
    end

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

    function _mean(vol, roi)
        s = 0.0; n = 0
        for z in 1:n_z, ci in roi
            s += vol[ci, z]; n += 1
        end
        return s / n
    end

    @info "Per-rod basis-decomp diagnostic (8-px core ROI in RECON coords, mean over z):"
    for (lab, name, mat) in zip(ROD_LABELS, ROD_NAMES, ROD_MATERIALS)
        cx, cy = rod_centroid(lab)
        roi = _disc_idx(cx, cy)
        c_w = _mean(basis_acnr.vol_water, roi)
        c_i = _mean(basis_acnr.vol_iodine, roi)
        ρ = round(BS.XA.val(mat.density), digits = 3)
        @info "  rod $(lab) ($(rpad(name, 9))): c_water = $(round(c_w, digits = 4)) g/cm³, " *
            "c_iodine = $(round(c_i, digits = 6)) g/cm³  (truth ρ = $(ρ))"
    end
end
pcct_vmi_energies = [40.0, 70.0, 100.0, 140.0];
vmi_HU_final = let
    c_iodine_mg_per_mL = basis_acnr.vol_iodine .* 1000.0f0

    out = Dict{Float64, Array{Float32, 3}}()
    for E in pcct_vmi_energies
        μρ_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, 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/08_qrm_thorax_pure_material_pcct.jl#==#0803000a-0000-4000-8000-000000000040", :source_package => nothing, :call => "macro expansion", :linfo_type => "Nothing", :line => 13, :file => "08_qrm_thorax_pure_material_pcct.jl#==#0803000a-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/08_qrm_thorax_pure_material_pcct.jl#==#0803000a-0000-4000-8000-000000000040:13 [inlined]")

Results

Per-rod measured vs theoretical HU at 40 / 70 / 100 / 140 keV.

  • Measured HU = mean over an 8-px-radius circular ROI at each rod centroid, broadcast across all z slices.

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

Phantom-Recon Alignment Verification

Sanity-check the BS affine round-trip before trusting any ROI built on phantom_in_recon. Recon HU and the resampled phantom mask side-by- side on the same recon grid, then overlaid to confirm rod / heart-cavity / bone-wall edges land cleanly on the corresponding recon edges.

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/08_qrm_thorax_pure_material_pcct.jl#==#0803000c-0000-4000-8000-00000000000a", :source_package => nothing, :call => "macro expansion", :linfo_type => "Nothing", :line => 29, :file => "08_qrm_thorax_pure_material_pcct.jl#==#0803000c-0000-4000-8000-00000000000a", :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/08_qrm_thorax_pure_material_pcct.jl#==#0803000c-0000-4000-8000-00000000000a:29 [inlined]")

Water ROI

Water-rod core ROI (label 9 = basis_water) overlaid in red on the 70 keV VMI slice. Right panel: mean HU over that ROI vs VMI energy. Bars should cluster near 0 HU; consistent ~few-HU offset = residual basis-decomp bias, energy-dependent drift = upstream spectral problem.

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/08_qrm_thorax_pure_material_pcct.jl#==#0803000c-0000-4000-8000-000000000003", :source_package => nothing, :call => "top-level scope", :linfo_type => "Core.CodeInfo", :line => 10, :file => "08_qrm_thorax_pure_material_pcct.jl#==#0803000c-0000-4000-8000-000000000003", :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/08_qrm_thorax_pure_material_pcct.jl#==#0803000c-0000-4000-8000-000000000003:10")

Heart-Center Noise ROI

HU noise (σ) inside a circular ROI offset from the water-rod centroid (label 9 — the only label guaranteed present in phantom_in_recon after :nearest resample at 0.625 mm). Default offset (dx, dy) = (0, -40) puts the ROI in the heart cavity between the water rod and the cavity center — tweak HEART_NOISE_ROI_OFFSET_PX to iterate.

Right panel = σ vs VMI energy. Diagnoses how the textbook (cwater, ciodine) → HU(E) synth propagates noise through the PCCT pipeline (Cong-Φ_k + SF-JSD). Expectation: σ(40) ≫ σ(70) ≳ σ(140) — the natural noise-optimal energy is near 70 keV.

const HEART_NOISE_ROI_OFFSET_PX = (0, -40);   # (dx, dy) recon vx — default: below water-rod centroid
const HEART_NOISE_ROI_RADIUS_PX = 12;   # ≈7.5 mm at 0.625 mm/voxel
heart_noise_roi = let
    WATER_ROD_LABEL = UInt8(9)

    mask_2d = phantom_in_recon[:, :, size(phantom_in_recon, 3) ÷ 2 + 1]
    nx_r, ny_r, nz_r = size(basis_acnr.vol_water)

    rod_idx = findall(==(WATER_ROD_LABEL), mask_2d)
    isempty(rod_idx) && error(
        "heart_noise_roi: no label-$(Int(WATER_ROD_LABEL)) (water rod) voxels in resampled phantom mask."
    )
    rod_cx = sum(ci -> Float64(ci[1]), rod_idx) / length(rod_idx)
    rod_cy = sum(ci -> Float64(ci[2]), rod_idx) / length(rod_idx)

    dx, dy = HEART_NOISE_ROI_OFFSET_PX
    cx = rod_cx + dx
    cy = rod_cy + dy

    roi_bool = falses(nx_r, ny_r)
    r² = Float64(HEART_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 "heart_noise_roi: water-rod centroid = ($(round(rod_cx, digits = 1)), $(round(rod_cy, digits = 1))), " *
        "offset = $(HEART_NOISE_ROI_OFFSET_PX), ROI center = ($(round(cx, digits = 1)), $(round(cy, digits = 1))), " *
        "$(n_vox) vx × $(nz_r) z = $(n_vox * nz_r) total"

    (
        rod_center_xy = (rod_cx, rod_cy),
        center_xy = (cx, cy),
        mask_2d = roi_bool,
        n_voxels = n_vox,
        n_total = n_vox * nz_r,
    )
end;
# === Image-domain basis covariance (heart ROI) — the VMI-noise theorem inputs ===
# The quantity that ACTUALLY governs the U:
#   σ_HU(E)² = 1e6·V_w + α(E)²·V_i + 2e3·α(E)·C_iw ,   min at  α* = −1e3·C_iw/V_i
#   monotonic-DECREASING ⟺ α* ≤ α(140) ⟺ C_iw not too negative.
# Measured over the heart ROI on the ACNR basis maps VMI synth consumes
# (c_iodine in mg/mL).  ρ_basis<0 = anti-correlated basis noise = the U + the ACNR
# target.  Small ROI → tiny vectors, no memory concern.
let
    roi = findall(heart_noise_roi.mask_2d)
    nz = size(basis_acnr.vol_water, 3)
    cw = Float64[Float64(basis_acnr.vol_water[p, z])           for z in 1:nz, p in roi]
    ci = Float64[Float64(basis_acnr.vol_iodine[p, z]) * 1000.0 for z in 1:nz, p in roi]  # mg/mL
    mw = mean(cw); mi = mean(ci)
    Vw = mean((cw .- mw) .^ 2); Vi = mean((ci .- mi) .^ 2)
    Ciw = mean((cw .- mw) .* (ci .- mi))
    ρ_b = Ciw / sqrt(max(Vw * Vi, 1.0e-30))
    αf(E) = Float64(BS.compute_mass_μ_at_energy(BS.XA.Elements.Iodine, E)) /
        Float64(BS.compute_mass_μ_at_energy(BS.XA.Materials.water, E))
    α_star = -1000.0 * Ciw / max(Vi, 1.0e-30)
    verdict = α_star ≤ αf(140.0) ? "MONOTONIC-decreasing predicted ✓" :
        α_star ≥ αf(40.0) ? "monotonic-INCREASING" :
        "U-shape (min near α=α*)"
    @info "[basis cov · heart ROI, image]  σ_water = $(round(sqrt(Vw), sigdigits = 3)) g/mL · " *
        "σ_iod = $(round(sqrt(Vi), sigdigits = 3)) mg/mL · ρ_basis = $(round(ρ_b, digits = 3)) · " *
        "water floor = $(round(1000 * sqrt(Vw), digits = 1)) HU"
    @info "  α* = $(round(α_star, digits = 1))  vs  α(40)=$(round(αf(40.0), digits = 1)), " *
        "α(70)=$(round(αf(70.0), digits = 1)), α(140)=$(round(αf(140.0), digits = 1))  →  $(verdict)"
    (V_water = Vw, V_iodine = Vi, C_iw = Ciw, ρ_basis = ρ_b, α_star = α_star)
end
vmi_noise_by_keV = let
    roi_idx = findall(heart_noise_roi.mask_2d)
    nz_r = size(basis_acnr.vol_water, 3)

    out = Dict{Float64, NamedTuple}()
    for E in pcct_vmi_energies
        vol = vmi_HU_final[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 "heart 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/08_qrm_thorax_pure_material_pcct.jl#==#0803000d-0000-4000-8000-000000000030", :source_package => nothing, :call => "macro expansion", :linfo_type => "Nothing", :line => 10, :file => "08_qrm_thorax_pure_material_pcct.jl#==#0803000d-0000-4000-8000-000000000030", :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/08_qrm_thorax_pure_material_pcct.jl#==#0803000d-0000-4000-8000-000000000030:10 [inlined]")
const ROD_LABELS = (UInt8(9), UInt8(10), UInt8(11), UInt8(12));
const ROD_NAMES = ("water", "lipid", "collagen", "iodine_5");
const ROD_MATERIALS = (
    BS.XA.Materials.basis_water,
    BS.XA.Materials.basis_lipid,
    BS.XA.Materials.basis_collagen,
    BS.XA.Materials.gammex_472_i5_0,
);
rod_data = let
    mask_2d = phantom_in_recon[:, :, size(phantom_in_recon, 3) ÷ 2 + 1]
    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 ROD_LABELS)

    μ_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

    n_rods = length(ROD_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 enumerate(ROD_LABELS)
        mat = ROD_MATERIALS[i]
        for (j, E) in enumerate(pcct_vmi_energies)
            meas[i, j] = measured_hu(vmi_HU_final[E], lab)
            theo[i, j] = theoretical_hu(mat, E)
        end
    end
    (
        labels = ROD_LABELS, names = ROD_NAMES,
        measured = meas, theoretical = theo,
    )
end;

Per-Rod Regression

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", :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/08_qrm_thorax_pure_material_pcct.jl#==#0803000c-0000-4000-8000-000000000031", :source_package => nothing, :call => "macro expansion", :linfo_type => "Nothing", :line => 4, :file => "08_qrm_thorax_pure_material_pcct.jl#==#0803000c-0000-4000-8000-000000000031", :func => "macro expansion", :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\n @ ./Base_compiler.jl:47 [inlined]\n [2] macro expansion\n @ ~/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/08_qrm_thorax_pure_material_pcct.jl#==#0803000c-0000-4000-8000-000000000031:4 [inlined]")

Summary

QRM-Thorax mid-slice mask (1600 × 1100 × 20 phantom @ 0.2 mm iso,
                           rods bored at labels 9–12)
   → Forward-project (140 kVp PCCT, 4 bins, scatter-injected)
   → Per-Bin Pile-up + Scatter Correction
   → Bin Combine  (1+2 → low,  3+4 → high)
   → Projection-Domain Material Decomposition  (Cong PCCT-Φ_k)
   → FBP × 2  (iodine, water basis maps)
   → Monoenergetic VMI Synthesis  (textbook 2-basis, mono μρ_water divisor)
   → Per-rod Measured vs Theoretical Regression
        (water · lipid · collagen · iodine at 40 / 70 / 100 / 140 keV)

1:1 parity with notebook 07's QRM-Thorax pure-material pipeline, swapping the dual-kVp GSI acquisition for a Siemens Naeotom Alpha PCCT 4-bin acquisition. The 4-bin PCCT measurement is bin-combined into a two-channel (low, high) pair that Cong's PCCT-Φ_k decomposition then consumes directly — exactly the same downstream pipeline as notebook 07 (Cong → FBP × 2 → Kalender ACNR → VMI → per-rod regression). Phantom, recon grid, VMI energies, ROI definitions, and per-rod regression style are identical so the two notebooks are directly comparable.