← Examples/

09 · Siemens SOMATOM Force · UFC MC LUT · Dual-Source VMI

The Siemens SOMATOM Force third-generation dual-source scanner with its UFC (Ultra-Fast Ceramic, Gd₂O₂S:Pr,Ce) scintillator, modelled through the package's Monte-Carlo detector-efficiency table BS.UFC_MC_EFFICIENCY_LUT.

One dual-source DE acquisition feeds both outputs, the routine-looking mixed image and the spectral results:

UFC MC η(E) LUT  (BS.UFC_MC_EFFICIENCY_LUT, 1–140 keV)
        │ via EICTScanner(detector_material = :ufc)
        ▼
100 kVp (tube A) + Sn140 kVp (tube B), view_samples = 5
   each tube projected once → draws 0 (noise-free), 1 (measured), 9 (calibration)
        ├─→ POLY: per-tube η-aware BHC → FDK → HU
        │         → Siemens-style mixed image M_w      (water-HU validation, §7)
        ▼
   spectral basis (BS.spectral_basis_from_acquisitions, shared by every draw) (§8)
   pair_basis, composite_energy ← calibration draw
   BS.vmi_pipeline(; denoiser = SpectralHYPR(), fbp_filter = SoftFilter(), …)
        → VMI 50/70/100/140 keV vs the noise-free reference              (§9–10)
        → Per-Rod Measured vs Theoretical Regression

The acquisition and the VMI chain follow the basis-spectral-denoising paper (view integration, one projection per tube shared by every noise draw, the noise-free reference, SpectralHYPR, the pair basis fixed from a calibration draw) on this notebook's own Force model; §9 explains why the Force reconstructs with one window rather than a fitted pair.

Single-energy vs dual-energy on the Force

The Force is not inherently spectral: routine protocols run both tubes at the same kVp (dual source buys temporal resolution and power, not spectra) and DE is a selectable mode. But within a DE acquisition there is no third "plain" scan — the routine-equivalent grayscale output is the mixed image, a weighted blend of the low-kV and high-kV reconstructions. This notebook models the DE acquisition and derives both readouts from it.

How the UFC table enters the simulation

src/detector/detector_efficiency.jl ships UFC_MC_EFFICIENCY_LUT, get_ufc_mc_efficiency(E) and the detector_efficiency_ufc() factory; build_physics_config dispatches detector_material = :ufc to it (as :lumex dispatches to the GE Gemstone table). This notebook sets EICTScanner(detector_material = :ufc) with the default use_detector_efficiency = true, and the EICT forward model weights every energy by w(E) · η_UFC(E) · exp(-∫μ dl). The spectral basis and the BHC calibration resolve the same η-folded spectrum through resolve_source_spectrum_full, so the forward and inverse spectral models match exactly.

Notebook Setup

Activate the shared docs environment, load the simulator and plotting stack, detect the available compute backend, and build the notebook table of contents.

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

Backend detected: CuArray

1. The UFC Monte-Carlo Efficiency LUT

Per-energy absorbed fraction η(E) for the Siemens UFC Gd₂O₂S scintillator, from a full Monte-Carlo transport simulation of the SOMATOM Force StellarInfinity detector.

Provenance: a Monte-Carlo transport simulation of the Force detector by Hamidreza Khodajou-Chokami, PhD (UC Irvine Medical Imaging Laboratory), efficiency_results.csv, 2026-06-08. The table ships in the package as BS.UFC_MC_EFFICIENCY_LUT (src/detector/detector_efficiency.jl): 140 values on a 1-keV grid (1–140 keV), verbatim from that dataset, next to the GE BS.GEMSTONE_MC_EFFICIENCY_LUT.

Physics signatures (same class of MC-only features the Gemstone LUT captures — Beer-Lambert cannot model these):

  1. Gd K-edge fluorescence escape at 50.24 keV: η drops 0.969 → 0.741 between 50 and 51 keV. Just above the K-edge, photoabsorption produces Gd Kα fluorescence (~43 keV) that escapes the thin crystal, so the deposited fraction falls even though attenuation rises. Beer-Lambert would predict the opposite jump.

  2. Gd L-edge structure near 7–8 keV (L₃ 7.24 / L₂ 7.93 / L₁ 8.38 keV): the small dip at 8 keV.

  3. Gradual high-energy roll-off (0.897 at 100 keV → 0.816 at 140 keV) from primary transmission + Compton escape.

2. Phantom: Gammex Model 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,
);

3. EICTScanner: Siemens SOMATOM Force

Third-generation dual-source: two Vectron tubes + two StellarInfinity UFC detectors at 95° in the same gantry. Spec sheet (sources below; the geometry was measured from an actual clinical Force by Wang et al.):

ParameterValueSource
Source–isocenter (SID)595.0 mmWang et al. 2021, arXiv:2001.09471 §IV
Source–detector (SDD)1085.6 mmWang et al. 2021
Tube A–B angular offset95° (z-offset 0.88 mm)Siemens Force whitepaper; Wang et al.
Detector A channels920 (50 cm FOV)Wang et al.; whitepaper §1.2
Detector B channels640 (35.5 cm FOV)Wang et al.; Flohr/Schmidt DE-DSCT chapter
Rows96 × 0.6 mm at iso (57.6 mm z-coverage)whitepaper §1.2
Column pitch at iso0.054°/ch ≈ 0.561 mmWang et al. (1.023 mm at detector / 1.825× mag)
ScintillatorUFC Gd₂O₂S:Pr,Ce, ρ ≈ 7.34 g/cm³Siemens UFC page; Rossner & Grabmaier 1991
Tube (Vectron)2 × 120 kW, 70–150 kVp / 10 kV steps, ≤1300 mAwhitepaper §1.1; datasheet
Focal spots0.4×0.5 mm² smallest; ~0.8 / 1.2 mm nominalwhitepaper; Duan AAPM 2014
Tin filter0.6 mm Sn on the high-kV tube (SnX modes)Faby et al. Siemens DECT white paper; PMC12745887
Rotation0.25 s min (0.5 s routine)whitepaper
Projections1160 / rotation per focal-spot positionFlohr et al. Med Phys 2005 (Siemens family figure)

Documented modeling assumptions (no public source exists)

  • Anode angle: Vectron's angle is unpublished → IPEM 8° spectrum (typical 7–9° CT anode).

  • Flat filtration: unpublished → 3.0 mm Al + 0.9 mm Ti, the same Vectron-family stack this repo already uses for the Naeotom Alpha (notebook 08). The 0.6 mm Sn is added on tube B only.

  • Bowtie: Siemens body bowtie shape is unpublished → CatSim large-body profile as stand-in (same convention as notebooks 04 and 08).

  • Scintillator thickness 1.4 mm / fill factor 0.9: proprietary; thickness is inert here (η comes from the MC LUT, and the Beer-Lambert fallback is not used), fill factor auto-cancels in the air-scan calibration.

  • Electronic noise = 0: Stellar's TrueSignal ASIC has no published absolute noise figure; its design point is "electronic noise negligible vs quantum noise" (Duan et al. AJR 2013).

Dual source → two co-registered scans

Exactly like notebook 03 models GE rapid-kVp switching as two sequential scans, the Force's two tubes are modeled as two CTProtocols run back-to-back on one EICTScanner (identical detector geometry), each with its own noise seed:

  • Detector arc: tube B gets detector-A's 920-channel arc so the (low, high) sinogram pair is per-ray co-registered for the K = 2 decomposition — physically defensible because the 33 cm Gammex body fits inside detector B's real 35.5 cm FOV, so no ray we use would be missing on the real detector B.

  • 95° in-plane tube offset: both modeled scans run a full axial rotation on the same angle grid, which is exactly what the clinical rebinning produces when it aligns the B data onto the A grid — a constant angular offset has no effect on a full-rotation axial scan.

  • 0.88 mm tube-B z-offset: modeled explicitly in §6 by shifting the phantom origin −0.88 mm in z for the tube-B scan (tube B images a z-shifted slab during the same rotation). For the z-invariant Gammex 472 this is an exact no-op, but the mechanism is in place so z-varying phantoms (XCAT, QRM) inherit the real misalignment.

  • DE-mode collimation: the Force reads out 128 × 0.6 mm in DE mode; we use 4.8 mm (8 × 0.6 mm) — the thin-collimation equivalent that fits the 1 cm Gammex z-extent, same convention as notebook 03's 5 mm.

# Tube/detector A geometry — shared by both modeled tubes (see md above).
scanner = BS.EICTScanner(
    source_to_isocenter = 595.0,
    source_to_detector = 1085.6,

    detector_rows = 96,
    detector_cols = 920,
    detector_row_size = 0.6,
    detector_col_size = 0.561,

    focal_spot_width = 0.8,
    focal_spot_length = 1.2,
    target_angle = 8.0,

    flat_filter_material = :aluminum,
    flat_filter_thickness = 3.0,
    bowtie_filter = :large_body,

    detector_material = :ufc,    # free-form tag; η comes from the MC LUT below
    detector_depth = 1.4,
    fill_factor_row = 0.9,
    fill_factor_col = 0.9,

    electronic_noise = 0,
    detection_gain = 10.0,
);

4. Protocols: One Dual-Source DE Acquisition

The Force's clinical abdomen DE pairs are x/Sn150 (x ∈ 70–100 kVp, 0.6 mm Sn on tube B). The bundled IPEM spectra top out at 140 kVp, so this notebook runs the 100 / Sn140 pair — the same pair the second-generation Definition Flash ran clinically (with 0.4 mm Sn; we keep the Force's 0.6 mm). Mean-energy separation is within ~2 keV of the 100/Sn150 target.

Tube currents follow the published Force abdomen 100/Sn150 reference (190 mAs A / 95 mAs B at 0.5 s → 2:1). This single acquisition feeds both the §7 poly/mixed readout and the §8+ VMI readout — no separate plain scan exists on the real scanner in DE mode.

TubekVpFiltersmAviewsrotation
A (low)1003 Al + 0.9 Ti38011600.5 s
B (high)1403 Al + 0.9 Ti + 0.6 Sn19011600.5 s
protocol_low = BS.CTProtocol(
    kVp = 100,
    mA = 380.0,
    views = 1160,
    rotation_time = 0.5,
    collimation_mm = 4.8,    # nominal beam width; axial cone guards are automatic
    anode_angle = 8,
    additional_filters = [("Ti", 0.9)],
);
protocol_high = BS.CTProtocol(
    kVp = 140,
    mA = 190.0,
    views = 1160,
    rotation_time = 0.5,
    collimation_mm = 4.8,
    anode_angle = 8,
    additional_filters = [("Ti", 0.9), ("Sn", 0.6)],
);

5. SimOptions and ReconOptions

force_opts are the physics switches the basis-spectral-denoising paper settled on for its dual-source scanner, applied to the Force:

  • Detector efficiency. use_detector_efficiency = true (the default) routes through the src UFC MC LUT: build_physics_config sees EICTScanner(detector_material = :ufc) and dispatches to detector_efficiency_ufc(), so the EICT forward model weights every energy by w(E) · η_UFC(E) and the detected flux (and therefore the Poisson noise level) reflects the UFC absorption.

  • Heel effect off (use_heel_effect = false) keeps the forward spectral model exactly equal to the η-folded response the spectral basis inverts (with 4.8 mm collimation the heel is a small row-direction effect).

  • View integration (view_samples = 5). A detector integrates while the gantry turns, so each view reads the transmission averaged over the arc it sweeps, r · Δθ at radius r: a blur of the object, largest far from the isocentre, that the noise, counted once per view, does not share. Five sub-views sample that arc. Without it the simulated signal is sharper, relative to its noise, than a physical scanner's. Each Force tube reads its own detector for the whole view period, so both integrate over the full view arc (view_arc = 1.0).

Each tube gets its own seed (realization_seed): the two tube/detector chains are physically independent, so their noise must be too. Two acquisitions drawn from one seed carry the same noise pattern, correlated between the channels, which the decomposition would then amplify.

begin
    VIEW_SAMPLES = 5
    force_opts = BS.SimOptions(projector = :dd_fast, use_heel_effect = false, view_samples = VIEW_SAMPLES)
    "The same physics switches with one field replaced."
    with_option(opts, field, value) = BS.SimOptions(;
        (f => getfield(opts, f) for f in fieldnames(BS.SimOptions) if f !== field)...,
        (field => value,)...,
    )
end;
begin
    # The two exposures: tube A sees the phantom, tube B the phantom through its −0.88 mm z-offset (§6).
    # Draw 0 is the noise-free expectation; draw r ≥ 1 gives each tube the seed `seed + 1000 (r − 1)`,
    # so the tubes stay independent within a draw, and draws of each other.
    EXPOSURES = [
        (label = "100 kVp", protocol = protocol_low, seed = 1234, view_arc = 1.0, tube = :a),
        (label = "Sn140 kVp", protocol = protocol_high, seed = 4321, view_arc = 1.0, tube = :b),
    ]
    DE_DRAWS = (reference = 0, measured = 1, calibration = 9)
    realization_seed(e, r) = e.seed + 1000 * (max(r, 1) - 1)
    opts_for(e, r) = with_option(with_option(with_option(force_opts, :seed, realization_seed(e, r)),
        :use_noise, r > 0), :view_arc, e.view_arc)
end;
# Keep the intended centered 5 × 0.6 mm saved grid. The axial workspace
# automatically adds symmetric detector guard rows so peripheral voxels
# on both terminal slices retain measured cone-beam support.
recon_opts = BS.ReconOptions(
    matrix_size = (512, 512, 5),
    fov_cm = 35.0,
    z_cm = 0.30,
);
"""
    ufc_detected_spectrum(protocol) -> (e, w_eta)

Display helper: tube spectrum × flat filtration × protocol filters
(IPEM, absolute flux) with the src UFC MC η(E) folded in — what the
detector actually integrates (centered ray, no bowtie).
"""
function ufc_detected_spectrum(protocol)
    e, w = BS.resolve_source_spectrum_without_bowtie(
        force_opts, protocol; scanner = scanner,
    )
    return e, Float64.(w) .* BS.get_ufc_mc_efficiency.(e)
end;

6. Forward Project: One Projection per Tube, Three Draws

acquire simulates the DE acquisition the way the basis-spectral-denoising paper does. Each tube builds its workspace (BS.create_workspace; the UFC η enters through the detector_efficiency pathway) and is projected once: everything before the noise is the same for every draw, so the first draw keeps it (keep_projection = true) and every further draw passes it back (projection) and only draws its noise, bit-identically to simulating afresh. Three draws share the projection:

  • draw 0, use_noise = false: the noise-free reference, the expectation every noisy result below is scored against;

  • draw 1: the measured acquisition every result comes from;

  • draw 9: the calibration draw, which only fixes the reconstruction's pair (§9); nothing is measured on it.

The first draw also keeps what the spectral basis needs: the per-ray air counts I0_ray (detector air count × bowtie air profile) and the per-ray detected spectrum from resolve_source_spectrum_full (source × filtration × bowtie × UFC η), the model the forward projector applied.

Tube B sees the phantom through a −0.88 mm z-shifted origin — the real detector-B z-offset (Wang et al. 2021). For the z-invariant Gammex this changes nothing, but the mechanism mirrors the physical scanner so z-varying phantoms inherit the misalignment.

# Tube-B view of the phantom: origin shifted by the real −0.88 mm detector
# z-offset (no-op for the z-invariant Gammex; see §6 md).
phantom_b = BS.Phantom(
    phantom.mask,
    phantom.materials,
    phantom.voxel_size,
    (phantom.origin[1], phantom.origin[2], phantom.origin[3] - 0.088),
    phantom.extent,
);
"""
    acquire(draws) -> Dict{Int, NamedTuple}

The DE acquisition of the Gammex (`EXPOSURES`, both tubes) for every draw in `draws`: draw 0 the
noise-free expectation, draw `r ≥ 1` a noisy realisation with the seeds `realization_seed(e, r)`.
Each tube is projected once, by the first draw, and every other draw re-uses that projection
(`keep_projection`, `projection`), only its noise drawn. Each entry holds the two log-transmission
channels, the spectral basis (shared by every draw), the geometry and the wall time per tube.
"""
function acquire(draws)
    parts = map(EXPOSURES) do e
        ph = e.tube === :b ? phantom_b : phantom
        projection = nothing
        channels = Dict{Int, Array{Float32, 3}}()
        seconds = Dict{Int, Float64}()
        model = nothing
        for r in draws
            opts = opts_for(e, r)
            t = time()
            ws = BS.create_workspace(scanner, e.protocol, opts, recon_opts, ph)
            try
                result = BS.simulate!(ws, ph, e.protocol, opts; report_dose = false,
                    keep_projection = projection === nothing, projection)
                projection === nothing && (projection = result.projection)
                channels[r] = Array(ws.sinogram)
                if model === nothing
                    air = ws.bowtie_air_reference === nothing ?
                        ones(Float32, ws.geom.n_cols, ws.geom.n_rows) :
                        Float32.(Array(ws.bowtie_air_reference))
                    I0 = BS.compute_detector_I0(ws.geom, e.protocol, sum(ws.weights)) * Float64(ws.η_eff)
                    energies, response = BS.resolve_source_spectrum_full(
                        opts, e.protocol; scanner = scanner, geom = ws.geom)
                    model = (geom = ws.geom, I0_ray = Float32.(I0 .* air),
                        energies = Float64.(energies), response = Float32.(response))
                end
            finally
                BS.release_backend!(ws)
            end
            seconds[r] = time() - t
        end
        projection = nothing
        (; channels, seconds, model...)
    end
    GC.gc(true)
    basis = BS.spectral_basis_from_acquisitions(acquisitions = [
        (energies = p.energies, response = p.response, I0_ray = p.I0_ray) for p in parts])
    Dict(r => (draw = r, channels = [p.channels[r] for p in parts], basis = basis,
               geom = first(parts).geom, seconds = [p.seconds[r] for p in parts])
         for r in draws)
end;
de_acq = acquire((DE_DRAWS.reference, DE_DRAWS.measured, DE_DRAWS.calibration));
DrawRoleSeeds (A, B)Seconds (A, B)
0noise-free referencenone64.7, 32.3
1measured1234, 43210.6, 0.3
9calibration9234, 123210.3, 0.3

Draw 0 projects each tube, 5 sub-views per view; draws 1 and 9 re-use that projection and only draw their noise (wall time on this render, compilation included in the first call).

# The measured draw: every result below comes from it.
de_measured = de_acq[DE_DRAWS.measured];

7. Poly Readout: Per-Tube EICT Recon + Siemens Mixed Image

In DE mode the scanner's routine-equivalent grayscale output is the mixed image — a linear image-domain blend of the two per-tube reconstructions (Yu et al., Med Phys 2009: M = w·I_low + (1−w)·I_high; Eusemann et al., SPIE 2008). On Sn150-class pairs the clinical weight is w ≈ 0.5–0.6 (Lenga et al., Br J Radiol 2021).

So the poly validation of the UFC LUT runs the current notebook 01 correction stack per tube — η-aware water sinogram BHC → FDK → HU, with residual cupping measured as QA — then blends. If the η fold is right, solid water lands at ≈ 0 HU in both per-tube recons (and therefore in any blend).

η-aware BHC

The BHC water polynomial must see the same detected spectrum the forward model used. We resolve the bowtie-hardened per-column spectrum with the UFC η(E) already folded in (resolve_source_spectrum_full), and feed the low-level calibrate_bhc_water(e, w_col) — same per-column fit the high-level API performs, but with the UFC fold included.

"""
    ufc_bhc_calibration(protocol, geom)

η-aware per-tube BHC: bowtie-hardened per-column spectrum × UFC η(E) →
per-column two-material polynomial.  Returns `(model, μ_water, ref_E_keV)`.
"""
function ufc_bhc_calibration(protocol, geom)
    # resolve_source_spectrum_full folds bowtie AND the src UFC η(E)
    # (via the same build_physics_config the forward model used).
    e, ŵ = BS.resolve_source_spectrum_full(
        force_opts, protocol; scanner = scanner, geom = geom,
    )
    e2, w_col = BS.bhc_spectrum_per_column(e, ŵ)          # [n_E, n_col]

    # Single mono-equivalent target = mean energy of the η-folded mean spectrum
    w_mean = vec(sum(w_col; dims = 2)) ./ size(w_col, 2)
    ref_E = sum(e2 .* w_mean) / sum(w_mean)

    # Parameter-free water BHC from the custom UFC-η per-column spectrum — zero
    # segmentation thresholds (the two-material bone pass is deprecated:
    # its 450–600 HU window misclassified dense iodine as bone).
    model = BS.calibrate_bhc_water(
        e2, w_col;
        reference_energy_keV = ref_E,
    )
    return (model = model, μ_water = model.μ_water_ref, ref_E_keV = model.reference_energy_keV)
end;
"""
    ufc_poly_recon(sino_cpu, geom, bhc) -> Array{Float32, 3}

Doctrine correction stack for one tube: knobless water sino-BHC → FDK → HU.
"""
function ufc_poly_recon(sino_cpu, geom, bhc)
    matrix_size = recon_opts.matrix_size

    sino_gpu = to_gpu(sino_cpu)
    # Knobless water BHC: one sinogram-domain pass, no recon round-trip.
    sino_bhc = BS.apply_bhc_water(sino_gpu, bhc.model)
    sino_gpu = sino_bhc

    ws_fdk = BS.create_fdk_recon_workspace(sino_gpu, geom, matrix_size)
    recon_μ = BS.reconstruct!(ws_fdk, sino_gpu, geom)

    # (image-domain BHC + applied cupping removed — deprecated; cupping is a
    #  QA metric via measure_radial_cupping)
    hu = Float32.(BS.to_hounsfield(Array(recon_μ); μ_water = bhc.μ_water))

    ws_fdk = nothing; sino_gpu = nothing; recon_μ = nothing
    GC.gc(true)
    return hu
end;
bhc_low = ufc_bhc_calibration(protocol_low, de_measured.geom);
bhc_high = ufc_bhc_calibration(protocol_high, de_measured.geom);

Calibrated (η-aware): tube A ref energy = 65.3 keV · lac water = 0.19835 cm⁻¹ — tube B ref energy = 98.6 keV · lac water = 0.1715 cm⁻¹

hu_tube = (
    low = ufc_poly_recon(de_measured.channels[1], de_measured.geom, bhc_low),
    high = ufc_poly_recon(de_measured.channels[2], de_measured.geom, bhc_high),
);
# Siemens linear mixed image: M = w·I_low + (1−w)·I_high (image domain,
# Yu 2009).  w = 0.5 is the common Sn150-pair default (0.3–0.7 clinical).
MIX_W_LOW = 0.5f0;
hu_mixed = MIX_W_LOW .* hu_tube.low .+ (1.0f0 - MIX_W_LOW) .* hu_tube.high;
poly_water_stats = let
    ERODE_PX = 12.0
    mask_2d_raw = phantom_cpu.mask[:, :, size(phantom_cpu.mask, 3) ÷ 2]
    sw_bool = BS.erode_mask_2d(
        mask_2d_raw .== UInt8(BS.REGION_SOLID_WATER); erode_px = ERODE_PX,
    )
    sw_idx = findall(sw_bool)
    n_z = size(hu_mixed, 3)

    function _stats(vol)
        vals = Float64[Float64(vol[ci, z]) for z in 1:n_z, ci in sw_idx]
        (mean = mean(vals), std = std(vals), n = length(vals))
    end
    stats = (
        low = _stats(hu_tube.low),
        high = _stats(hu_tube.high),
        mixed = _stats(hu_mixed),
    )
    for (tag, s) in pairs(stats)
        @info "[poly · UFC] $(tag) SW ROI: ⟨HU⟩ = $(round(s.mean, digits = 2)), σ = $(round(s.std, digits = 2)) HU (n = $(s.n))"
    end
    (stats..., mask_2d = collect(sw_bool))
end;

Poly Water Values

Water-HU validation of the UFC LUT in the plain EICT chain, before any spectral decomposition: solid-water ⟨HU⟩ ± σ for each per-tube recon and the mixed image. All three should cluster at ≈ 0 HU.

8. Spectral Basis from the Two Tubes

acquire built the basis with spectral_basis_from_acquisitions, which merges the two tubes' energy grids onto their union and scales each tube's per-ray detected spectrum by its own air counts, so the likelihood sees the absolute response $\Phi_k(E)$ of every ray and channel: source × flat filters × bowtie × UFC η(E), the identical model the forward projector applied (resolve_source_spectrum_full builds it from the same build_physics_config, and therefore the same UFC table, that simulate! used). No calibration scan is involved, and the noise-free, measured and calibration draws all share it.

basis = de_measured.basis;

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

9. The VMI Chain: vmi_pipeline

One package call from the two corrected sinograms to the VMI stack, the chain of the basis-spectral-denoising paper: vmi_pipeline(; denoiser = SpectralHYPR(), fbp_filter, pair_basis, composite_energy).

  1. Projection HYPR-LR (ProjectionHYPR): a 3 × 3 window (columns × views of one parity, view_stride = 2, so the odd and even views stay independent) on the counts of each detector row, with each tube's dispersion measured from its own air rays.

  2. K = 2 n-channel decomposition: per ray, the Poisson maximum-likelihood iodine + water pair under the exact polychromatic mean of both tubes, every detector row kept.

  3. The spectral pair (spectral_pair): FDK of the basis pair and of its odd- and even-view halves, whose difference measures the noise of everything downstream; the composite M (the minimum-noise VMI) and its complement I⊥ (noise uncorrelated with M's) at the energy fixed from the calibration draw.

  4. ACNR on the complement only (acnr_complement!): the composite is left as reconstructed.

  5. Image HYPR (ImageHYPR): the composite kept, the complement pooled within its slice with weights from the composite, over the window with the least estimated risk.

  6. VMI synthesis at 50 / 70 / 100 / 140 keV from the one basis pair.

One window, not a fitted pair: the Force has none

The other dual-energy notebooks reconstruct the composite and its complement with a PairFilter of two windows fitted in basis-spectral-denoising to the MTF and NPS of a physical scan of the Gammex on that scanner. No physical Force scan was fitted, so no Force pair exists. The GE and Flash pairs are not transferred either: a fitted pair absorbs everything that shapes one scanner's resolution and noise texture — its detector pitch (the Force's 0.561 mm against the Flash's 0.705 mm), focal spot and the vendor kernel of the series it was matched to — so on the Force it would impose another scanner's resolution. This notebook reconstructs both with one window, the soft-tissue SoftFilter().

pair_basis and composite_energy, from the calibration draw. The pair (E*, β) and the composite energy ACNR and the image HYPR act on are properties of the scanner and protocol, not of one noise draw: near its minimum the VMI noise hardly changes with energy, so an argmin measured on the draw being evaluated would itself be noise. They are measured once, on the calibration draw: E* and β from its plain decomposition, the composite energy from its decomposition after the projection HYPR. The measured draw, the same draw without denoising, and the noise-free reference are then reconstructed alike.

# basis-spectral-denoising's HYPR_CHAIN: ProjectionHYPR(3 × 3 Box, view_stride = 2) and ImageHYPR()
HYPR_CHAIN = BS.SpectralHYPR();
VMI_CHAIN = (method = :nchannel, controls = BS.NChannelControls(), use_tlbf = false, antialias = true);
# One window for the composite and its complement alike: no fitted Force pair exists (see above).
FORCE_FILTER = BS.SoftFilter();
# `pair_basis` and `composite_energy` (basis-spectral-denoising's `PAIR_BASIS`), from the calibration draw
calibration = let a = de_acq[DE_DRAWS.calibration]
    decompose(denoiser) = BS.vmi_pipeline(; channels = a.channels, basis = a.basis, geom = a.geom,
        to_backend = to_gpu, matrix_size = recon_opts.matrix_size, vmi_energies = Tuple(de_vmi_energies),
        denoiser, keep_sinograms = true, VMI_CHAIN..., use_acnr = false).sinograms
    sp(d; kw...) = BS.spectral_pair(d.water, d.iodine, a.geom, recon_opts.matrix_size;
        filter = BS.SoftFilter(), antialias = VMI_CHAIN.antialias, to_backend = to_gpu, kw...)
    x = sp(decompose(nothing))                                   # the plain decomposition
    c = sp(decompose(BS.SpectralHYPR(image = nothing));          # after the projection HYPR
        basis = (Estar = x.Estar, β = x.β))
    (pair_basis = (Estar = x.Estar, β = x.β), composite_energy = c.Estar)
end;
de_vmi_energies = [50.0, 70.0, 100.0, 140.0];
de_vmi = BS.vmi_pipeline(;
    channels = de_measured.channels,
    basis,
    geom = de_measured.geom,
    to_backend = to_gpu,
    matrix_size = recon_opts.matrix_size,
    vmi_energies = Tuple(de_vmi_energies),
    denoiser = HYPR_CHAIN,
    fbp_filter = FORCE_FILTER,
    pair_basis = calibration.pair_basis,
    composite_energy = calibration.composite_energy,
    keep_sinograms = true,
    VMI_CHAIN...,
);
# The same draw and reconstruction without the denoising (no HYPR, no ACNR), and the noise-free
# reference (draw 0, the same projection), reconstructed alike.
de_none, de_ref = (BS.vmi_pipeline(;
        channels = de_acq[r].channels, basis, geom = de_measured.geom, to_backend = to_gpu,
        matrix_size = recon_opts.matrix_size, vmi_energies = Tuple(de_vmi_energies),
        denoiser = nothing, use_acnr = false, fbp_filter = FORCE_FILTER,
        pair_basis = calibration.pair_basis, composite_energy = calibration.composite_energy,
        VMI_CHAIN...,
    ) for r in (DE_DRAWS.measured, DE_DRAWS.reference));

The decomposition solved 8537600 rays in 1.4 s with 2.5 outer iterations on average; 0.0% did not converge and 0.004% / 0.0% touched the iodine / water bounds. The projection HYPR measured dispersions (variance / mean of the counts on the air rays) of 1.3 and 1.29 for tube A and tube B.

From the calibration draw: the pair at E* = 78.0 keV, β = -0.0132, and the composite ACNR and the image HYPR act on at 75.0 keV. The image HYPR pooled the complement over a 31 × 31 window.

10. VMIs

vmi_pipeline synthesizes each VMI from the basis pair (McCollough 2015):

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

The figure shows the three reconstructions of §9 side by side: the measured draw without denoising, the measured draw through the full chain, and the noise-free reference. The solid_water_basis diagnostic reports the basis pair in the eroded solid-water region: a perfect decomposition reads water density ≈ 1 g/cm³ (solid water is not pure water, so a small offset is expected) and iodine ≈ 0.

solid_water_basis = let
    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."
    )

    sw_idx = findall(sw_bool)
    _mean(vol) = mean(vol[ci, z] for z in axes(vol, 3) for ci in sw_idx)

    c_w = Float64(_mean(de_vmi.images.water))
    c_i = Float64(_mean(de_vmi.images.iodine))
    @info "solid_water_basis: ⟨c_water⟩_SW = $(round(c_w, digits = 4)) g/cm³, " *
        "⟨c_iodine⟩_SW = $(round(1000c_i, digits = 3)) mg/mL"

    (
        c_water = c_w, c_iodine = c_i, n_voxels = length(sw_idx) * size(de_vmi.images.water, 3),
        mask_2d = collect(sw_bool),
    )
end;
# The VMI stack as one volume per energy (keV → (nx, ny, nz) HU).
vmi_HU_final = Dict(
    Float64(E) => de_vmi.vmis[:, :, :, k] for (k, E) in pairs(de_vmi.energies)
);

Results

Per-rod measured vs theoretical HU at 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.

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

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_final[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

Water-Region Noise

σ is measured in a central water ROI against the noise-free reference, σ = std(VMI − reference): the reference shares the projection and the reconstruction, so the difference is the noise alone, with no structure mixed in. The same draw without the denoising shows what the chain removes.

const WATER_NOISE_ROI_RADIUS_PX = 12;   # ≈8.2 mm at 0.683 mm/px
water_noise_roi = let
    nx_r, ny_r, nz_r = size(de_vmi.images.water)
    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;
vmi_noise_by_keV = let
    roi_idx = findall(water_noise_roi.mask_2d)
    nz_r = size(de_vmi.vmis, 3)
    vals(run, k) = Float64[Float64(run.vmis[ci, z, k]) for z in 1:nz_r, ci in roi_idx]

    out = Dict{Float64, NamedTuple}()
    for (k, E) in enumerate(de_vmi_energies)
        p, n, r = vals(de_vmi, k), vals(de_none, k), vals(de_ref, k)
        out[E] = (mean = mean(p), reference = mean(r), std = std(p .- r), std_none = std(n .- r),
            n = length(p))
        @info "water ROI @ $(Int(E)) keV: ⟨HU⟩ = $(round(mean(p), digits = 2)) " *
            "(reference $(round(mean(r), digits = 2))), σ = $(round(std(p .- r), digits = 2)) HU " *
            "(no denoising $(round(std(n .- r), digits = 2)) HU, n = $(length(p)))"
    end
    out
end;

Per-Rod Regression

Linear Regression

Summary

UFC MC η(E) LUT (BS.UFC_MC_EFFICIENCY_LUT, Gd₂O₂S, 1–140 keV)
   → EICTScanner(detector_material = :ufc) → detector_efficiency_ufc()
100 kVp + Sn140 kVp  (one SOMATOM Force dual-source DE acquisition,
                      view_samples = 5, one noise seed per tube)
   each tube projected once → draws 0 (noise-free), 1 (measured), 9 (calibration)
   ├─→ POLY: per-tube η-aware BHC → FDK → HU → mixed image M_w     (§7)
   └─→ VMI:  spectral basis (bowtie + η_UFC per ray, shared by every draw)
             pair_basis, composite_energy ← calibration draw
             → BS.vmi_pipeline(; denoiser = SpectralHYPR(), fbp_filter = SoftFilter(), …)
             → VMI 50/70/100/140 keV vs the noise-free reference  (§8–10)
             → Per-Rod Measured vs Theoretical Regression

What validates the UFC table here:

  1. §7 poly: solid water in the 100 kVp recon, the Sn140 recon and the Siemens-style mixed image, each under its own η-aware BHC: the table's spectral shape is consistent with the detected sinogram on two very different tube spectra independently.

  2. §8–10 VMI: water-region HU at every keV and the per-rod measured-vs-theoretical overlays: the table survives the harsher test of per-ray spectral inversion across the two detected spectra (100 kVp vs Sn140 kVp, separated by the 0.6 mm tin filter and sitting on opposite sides of the Gd K-edge fluorescence-escape cliff), each result scored against the noise-free draw of the same projection.