begin
import Pkg
Pkg.activate(joinpath(@__DIR__, ".."))
endusing Markdown: @md_str, Markdownusing Statistics: mean, std, quantile09 · Siemens SOMATOM Force · UFC MC LUT · Dual-Source VMI
Standalone validation of a new EICT Monte-Carlo detector-response LUT for the Siemens UFC (Ultra-Fast Ceramic, Gd₂O₂S:Pr,Ce) scintillator on the SOMATOM Force third-generation dual-source scanner — before it is promoted into src/ alongside the GE Gemstone MC LUT.
One dual-source DE acquisition feeds both outputs — exactly like a PCCT scan can be read out as a routine-looking image or as spectral results:
UFC MC η(E) LUT (Khodajou-Chokami MC, 1–140 keV)
│ via Scanner(detector_material = :ufc) (src MC-LUT pathway)
▼
Simulate 100 kVp (tube A) ──┬─→ POLY: per-tube η-aware BHC → FDK → HU
Simulate Sn140 kVp (tube B)─┘ → Siemens-style mixed image M_w
│ (water-HU validation, §7)
▼
Cong Decomp (raw sinograms) → FBP × 2
→ cov-ACNR → z-median → VMI 50/70/100/140 → Mono+
→ Per-Rod Measured vs Theoretical Regression (§8–11)
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.
The UFC LUT is src-proper (sister pathway to Gemstone)
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 (exactly as :lumex dispatches to Gemstone). So this notebook simply sets Scanner(detector_material = :ufc) + SimOptions(use_detector_efficiency = true) and the EICT forward model weights every energy bin by w(E) · η_UFC(E) · exp(-∫μ dl). The Cong basis and the BHC calibration resolve the same η-folded spectrum via resolve_source_spectrum_full, so the forward and inverse spectral models match exactly. (This notebook originally validated the LUT standalone via spectrum_override before the src promotion — the two paths are algebraically identical.)
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# import CairoMakie as Mke
import WasmMakie as Mkeimport PlutoUIbegin
import GPUSelect
AT = GPUSelect.Storage() # the backend array type, directly: MtlArray / CuArray / ROCArray
to_gpu(x) = AT(x)
GPU_BACKEND = (name = string(nameof(AT)),)
endBackend detected: MtlArray
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: Hamidreza Khodajou-Chokami, PhD (UC Irvine Medical Imaging Laboratory), efficiency_results.csv, received 2026-06-08. The 140 values (1-keV grid, 1–140 keV) live verbatim in src as BS.UFC_MC_EFFICIENCY_LUT (src/detector/detector_efficiency.jl), the sister of BS.GEMSTONE_MC_EFFICIENCY_LUT; an archival copy of the CSV is at docs/notebooks/data/ufc_mc_efficiency_v1.csv (gitignored).
Physics signatures (same class of MC-only features the Gemstone LUT captures — Beer-Lambert cannot model these):
Gd K-edge fluorescence escape at 50.24 keV: η drops
0.969 → 0.741between 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.Gd L-edge structure near 7–8 keV (L₃ 7.24 / L₂ 7.93 / L₁ 8.38 keV): the small dip at 8 keV.
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. Scanner: 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.):
| Parameter | Value | Source |
|---|---|---|
| Source–isocenter (SID) | 595.0 mm | Wang et al. 2021, arXiv:2001.09471 §IV |
| Source–detector (SDD) | 1085.6 mm | Wang et al. 2021 |
| Tube A–B angular offset | 95° (z-offset 0.88 mm) | Siemens Force whitepaper; Wang et al. |
| Detector A channels | 920 (50 cm FOV) | Wang et al.; whitepaper §1.2 |
| Detector B channels | 640 (35.5 cm FOV) | Wang et al.; Flohr/Schmidt DE-DSCT chapter |
| Rows | 96 × 0.6 mm at iso (57.6 mm z-coverage) | whitepaper §1.2 |
| Column pitch at iso | 0.054°/ch ≈ 0.561 mm | Wang et al. (1.023 mm at detector / 1.825× mag) |
| Scintillator | UFC 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 mA | whitepaper §1.1; datasheet |
| Focal spots | 0.4×0.5 mm² smallest; ~0.8 / 1.2 mm nominal | whitepaper; Duan AAPM 2014 |
| Tin filter | 0.6 mm Sn on the high-kV tube (SnX modes) | Faby et al. Siemens DECT white paper; PMC12745887 |
| Rotation | 0.25 s min (0.5 s routine) | whitepaper |
| Projections | 1160 / rotation per focal-spot position | Flohr 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 (nb08). 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 nb04/nb08).
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 nb03 models GE rapid-kVp switching as two sequential scans, the Force's two tubes are modeled as two Scanner + CTProtocol configs run back-to-back on identical detector geometry:
Detector arc: tube B gets detector-A's 920-channel arc so the (low, high) sinogram pair is per-ray co-registered for the Cong solver — 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 nb03's 5 mm.
# Tube/detector A geometry — shared by both modeled tubes (see md above).
scanner = BS.Scanner(
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.
| Tube | kVp | Filters | mA | views | rotation |
|---|---|---|---|---|---|
| A (low) | 100 | 3 Al + 0.9 Ti | 380 | 1160 | 0.5 s |
| B (high) | 140 | 3 Al + 0.9 Ti + 0.6 Sn | 190 | 1160 | 0.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
use_detector_efficiency = true (the :eict preset default) routes through the src UFC MC LUT: build_physics_config sees Scanner(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) automatically reflects the UFC absorption.
use_heel_effect = false keeps the forward spectral model exactly equal to the η-folded basis the Cong inversion uses (heel is a small row-direction effect; with 4.8 mm collimation at center it is negligible).
sim_opts = BS.SimOptions(
fidelity = :eict,
seed = 1234,
use_heel_effect = false, # exact forward/inverse spectral match
projector = :dd_fast, # same DD physics, single-pass fused kernels.
# BHC (ufc_poly_recon) reads sim_opts.projector to match.
);# 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(
sim_opts, protocol; scanner = scanner,
)
return e, Float64.(w) .* BS.get_ufc_mc_efficiency.(e)
end;6. Forward Project (one DE acquisition = two tube scans)
Each tube builds its own workspace (the UFC η enters via the src detector_efficiency pathway) and keeps only the noisy log-line-integral sinogram + geometry.
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 (and any future z-rebinning step has something real to correct).
# 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,
);sim_low = let
@info "Simulating: 100 kVp / $(round(protocol_low.mA, digits = 1)) mA (tube A, UFC η folded)…"
ws = BS.create_eict_workspace(
scanner, protocol_low, sim_opts, recon_opts, phantom,
)
BS.simulate!(ws, phantom, protocol_low, sim_opts)
I0_scalar = BS.compute_detector_I0(ws.geom, protocol_low, sum(ws.weights)) * Float64(ws.η_eff)
air_ref = ws.bowtie_air_reference === nothing ? ones(Float32, ws.geom.n_cols, ws.geom.n_rows) :
Array(ws.bowtie_air_reference)
result = (sino = Array(ws.sinogram), geom = ws.geom,
I0_ray = Float32.(I0_scalar .* Float64.(air_ref)))
ws = nothing; GC.gc(true)
result
end;sim_high = let
@info "Simulating: Sn140 kVp / $(round(protocol_high.mA, digits = 1)) mA (tube B, UFC η folded, z-offset −0.88 mm)…"
ws = BS.create_eict_workspace(
scanner, protocol_high, sim_opts, recon_opts, phantom_b,
)
BS.simulate!(ws, phantom_b, protocol_high, sim_opts)
I0_scalar = BS.compute_detector_I0(ws.geom, protocol_high, sum(ws.weights)) * Float64(ws.η_eff)
air_ref = ws.bowtie_air_reference === nothing ? ones(Float32, ws.geom.n_cols, ws.geom.n_rows) :
Array(ws.bowtie_air_reference)
result = (sino = Array(ws.sinogram), geom = ws.geom,
I0_ray = Float32.(I0_scalar .* Float64.(air_ref)))
ws = nothing; GC.gc(true)
result
end;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 nb01 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(
sim_opts, protocol; scanner = scanner, geom = geom,
)
e2, w_col = BS.bhc_spectrum_per_column(e, ŵ) # [n_E, n_col]
w_col_η = w_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)
# KNOBLESS 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, sim_low.geom);bhc_high = ufc_bhc_calibration(protocol_high, sim_high.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(sim_low.sino, sim_low.geom, bhc_low),
high = ufc_poly_recon(sim_high.sino, sim_high.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. Projection-Domain Material Decomposition (Cong)
Per-ray Cong univariate solver on the polychromatic transmission integral, iodine + water material basis, running on the raw noisy sinograms (no projection-domain denoising — the anti-correlated basis noise is handled by cov-ACNR after FBP). The per-ray spectral weights are source × flat filters × bowtie × UFC η(E) — the identical model the forward projector applied, because resolve_source_spectrum_full builds ŵ from the same build_physics_config (and therefore the same src UFC LUT) that simulate! used.
material_basis = let
# Per-ray effective spectrum: source × filters × bowtie × src UFC η(E),
# normalized per ray (Σ_E ŵ = 1) — the nb07 pattern.
e_L, ŵ_L = BS.resolve_source_spectrum_full(
sim_opts, protocol_low; scanner = scanner, geom = sim_low.geom,
diagnostic = true, label = "low·UFC",
)
e_H, ŵ_H = BS.resolve_source_spectrum_full(
sim_opts, protocol_high; scanner = scanner, geom = sim_high.geom,
diagnostic = true, label = "high·UFC",
)
iodine_mat = BS.XA.Elements.Iodine
water_mat = BS.XA.Materials.water
p_L = Float32[Float32(BS.compute_mass_μ_at_energy(iodine_mat, Float64(E))) for E in e_L]
q_L = Float32[Float32(BS.compute_mass_μ_at_energy(water_mat, Float64(E))) for E in e_L]
p_H = Float32[Float32(BS.compute_mass_μ_at_energy(iodine_mat, Float64(E))) for E in e_H]
q_H = Float32[Float32(BS.compute_mass_μ_at_energy(water_mat, Float64(E))) for E in e_H]
(
ŵ_L = ŵ_L, p_L = p_L, q_L = q_L,
ŵ_H = ŵ_H, p_H = p_H, q_H = q_H,
)
end;sino_basis = let
# First-order log-Poisson DEBIAS (deterministic; matches nb03).
debias(p, I0_ray) = begin
out = Float32.(p)
nc, nr, nv = size(out)
for v in 1:nv, r in 1:nr, c in 1:nc
N = max(I0_ray[c, r] * exp(-out[c, r, v]), 1.0f0)
out[c, r, v] -= 1.0f0 / (2.0f0 * N)
end
out
end
sino_low_gpu = to_gpu(debias(sim_low.sino, sim_low.I0_ray))
sino_high_gpu = to_gpu(debias(sim_high.sino, sim_high.I0_ray))
sino_y = similar(sino_low_gpu) # iodine basis line integrals
sino_c = similar(sino_low_gpu) # water basis line integrals
fill!(sino_y, 0.0f0); fill!(sino_c, 0.0f0)
cong_ws = BS.create_cong_workspace(sino_low_gpu, material_basis)
BS.apply_cong!(
cong_ws, sino_y, sino_c, sino_low_gpu, sino_high_gpu;
water_basis = (a = 0.0f0, c = 1.0f0),
)
result = (
sino_iodine = Array(sino_y),
sino_water = Array(sino_c),
geom = sim_low.geom,
)
sino_low_gpu = nothing; sino_high_gpu = nothing
sino_y = nothing; sino_c = nothing; cong_ws = nothing
GC.gc(true)
result
end;9. FBP × 2 + Kalender ACNR
Identical post-decomposition chain to nb03: two FDK passes (BS.SoftFilter()) → image-domain data-adaptive cov-ACNR (BS.apply_acnr_kalender!, per-pixel regression, zero blur).
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;basis_acnr = let
W = copy(basis_volumes.vol_water_raw)
I = copy(basis_volumes.vol_iodine_raw)
info = BS.apply_acnr_kalender!(W, I)
@info "[ACNR · Kalender-1988 true ACNR] ρ_hp(W,I)=$(round(info.ρ_hp, digits = 3))"
(vol_iodine_raw = I, vol_water_raw = W, geom = basis_volumes.geom)
end;10. VMI Synthesis
Textbook 2-basis mix (McCollough 2015) at 50 / 70 / 100 / 140 keV:
μ(E) = c_water(r) · (μ/ρ)_water(E) + c_iodine(r) · (μ/ρ)_iodine(E)
HU(E) = 1000 · (μ(E) − (μ/ρ)_water(E)) / (μ/ρ)_water(E)
The solid_water_basis diagnostic logs the basis-decomp residual bias as a Δ% between the SW-ROI synth μ_water and the textbook mono divisor.
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."
)
@info "solid_water_basis: SW mid-slice voxel count $(n_raw) → $(n_eroded) " *
"after $(ERODE_PX)-px erosion"
sw_idx = findall(sw_bool)
n_z = size(basis_acnr.vol_water_raw, 3)
function _mean(vol)
s = 0.0; n = 0
for z in 1:n_z, ci in sw_idx
s += vol[ci, z]; n += 1
end
return s / n
end
c_w = Float64(_mean(basis_acnr.vol_water_raw))
c_i = Float64(_mean(basis_acnr.vol_iodine_raw))
@info "solid_water_basis: ⟨c_water⟩_SW = $(round(c_w, digits = 4)) g/cm³, " *
"⟨c_iodine⟩_SW = $(round(c_i, digits = 6)) g/cm³"
(
c_water = c_w, c_iodine = c_i, n_voxels = length(sw_idx) * n_z,
mask_2d = collect(sw_bool),
)
end;de_vmi_energies = [50.0, 70.0, 100.0, 140.0];vmi_HU_final = let
# synth_vmi_2basis expects c_iodine in mg/mL; basis maps are g/cm³ (= g/mL)
c_iodine_mg_per_mL = basis_acnr.vol_iodine_raw .* 1000.0f0
out = Dict{Float64, Array{Float32, 3}}()
for E in de_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_raw, c_iodine_mg_per_mL;
energy_keV = E,
)
end
out
end;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)fromBS.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
const WATER_NOISE_ROI_RADIUS_PX = 12; # ≈8.2 mm at 0.683 mm/pxwater_noise_roi = let
nx_r, ny_r, nz_r = size(basis_acnr.vol_water_raw)
cx = nx_r ÷ 2 + 1
cy = ny_r ÷ 2 + 1
roi_bool = falses(nx_r, ny_r)
r² = Float64(WATER_NOISE_ROI_RADIUS_PX)^2
@inbounds for j in 1:ny_r, i in 1:nx_r
((i - cx)^2 + (j - cy)^2) ≤ r² && (roi_bool[i, j] = true)
end
n_vox = count(roi_bool)
@info "water_noise_roi: center = ($(cx), $(cy)), radius = $(WATER_NOISE_ROI_RADIUS_PX) px, " *
"$(n_vox) vx × $(nz_r) z = $(n_vox * nz_r) total"
(
center_xy = (Float64(cx), Float64(cy)), mask_2d = roi_bool,
n_voxels = n_vox, n_total = n_vox * nz_r,
)
end;vmi_noise_by_keV = let
roi_idx = findall(water_noise_roi.mask_2d)
nz_r = size(vmi_HU_final[70.0], 3)
out = Dict{Float64, NamedTuple}()
for E in de_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 "water-region noise @ $(Int(E)) keV: ⟨HU⟩ = $(round(μ, digits = 2)), σ = $(round(σ, digits = 2)) HU (n = $(length(vals)))"
end
out
end;Per-Rod Regression
Linear Regression
Summary
UFC MC η(E) LUT (Khodajou-Chokami, Gd₂O₂S, 1–140 keV)
→ src pathway: Scanner(detector_material = :ufc) → detector_efficiency_ufc()
Simulate 100 kVp + Sn140 kVp (one SOMATOM Force dual-source DE acquisition)
├─→ POLY: per-tube η-aware BHC → FDK → HU → mixed image M_w (SW ≈ 0 HU ×3)
└─→ VMI: Cong Decomposition on raw sinograms (iodine + water,
bowtie + η_UFC per ray)
→ FBP × 2 → cov-ACNR → z-median
→ VMI 50/70/100/140 keV → Mono+
→ Per-Rod Measured vs Theoretical Regression
What validates the UFC LUT here:
§7 poly: solid water ≈ 0 HU in the 100 kVp recon, the Sn140 recon, and the Siemens-style mixed image, each under its own η-aware BHC — the LUT's spectral shape is consistent with the detected sinogram on both very different tube spectra independently.
§10–11 VMI: water-ROI bars ≈ 0 HU at every keV and tight per-rod measured-vs-theoretical overlays — the LUT survives the much 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).
src status: the LUT lives in src/detector/detector_efficiency.jl next to GEMSTONE_MC_EFFICIENCY_LUT (UFC_MC_EFFICIENCY_LUT, get_ufc_mc_efficiency, detector_efficiency_ufc(), :ufc branches in compute_eid_efficiency_vector + build_physics_config, covered by test/detector.jl). This notebook consumes it directly via Scanner(detector_material = :ufc) — the original standalone spectrum_override validation path is retired.