← Examples/05 · XCAT UHR → CT Scan: Affine Round-Trip
View source on GitHub

XCAT UHR → CT: Grid Mapping and the Recon-Affine Round-Trip

Take a 0.4 mm UHR XCAT phantom, crop it down to a cardiac sub-region as the input to a clinical CT acquisition, and use the simulator's affine matrices to overlay the original ground truth on the reconstructed volume — pixel-perfect.

This notebook teaches one specific thing the API can be subtle about: how the phantom voxel grid (your input ground truth) and the reconstruction voxel grid (what the scanner outputs) relate. In particular:

  1. The simulator's recon grid is always centered at isocenter — there is no off-center FOV / scan-field placement parameter.

  2. So to "scan a sub-region of a body phantom" — the way a real scanner's SFOV crops out everything outside the bore — you do it on the input phantom, before forward projection. Cropping the phantom early is strictly more memory-efficient than cropping at the recon stage: nothing outside the cropped extent ever gets ray-traced.

  3. BS.phantom_to_world_affine and BS.recon_to_world_affine give you the two grids' relationships in 4×4 matrices. BS.resample_to_recon(phantom, geom, matrix_size; method = :nearest|:linear) round-trips the ground-truth labels onto the recon grid for ROI extraction, segmentation evaluation, etc.

Pipeline:

Full UHR XCAT (0.4 mm, ~32 × 28 × 10 cm)
   → label-name match (heart / atrium / ventricle / coronary / aorta)
   → cardiac voxel bbox + margin
   → crop UHR mask to bbox            ←  the SFOV-equivalent step
   → BS.Phantom(...)                  ←  origin auto-centers crop at iso
   → GE Apex Elite scan + tight FOV recon (centered)
   → BS.resample_to_recon(...; method = :nearest | :linear)
   → overlay ground truth on the HU recon — same shape, same world coords.

Notebook Setup

Same project + GPU detection idiom as notebooks 02 / 04.

begin
    import Pkg
    Pkg.activate(joinpath(@__DIR__, ".."))
end
using Markdown: @md_str, Markdown
using Statistics: mean, std
using Unitful: @u_str
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

Load the XCAT Phantom

Shared by both scans below: locate the bin, load the UHR mask, build the materials dict, and find the cardiac bounding box.

01. Locate the XCAT data

Same env-var pattern as notebook 02 — the bin lives outside the repo under BASISSIM_XCAT_DIR (default: docs/notebooks/data/xcat/). All heavy compute is gated on HAS_XCAT so this notebook still renders cleanly when the bin isn't available.

"/Users/daleblack/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/data/xcat"
const XCAT_DIR = get(
    ENV, "BASISSIM_XCAT_DIR",
    joinpath(@__DIR__, "data", "xcat")
)
"/Users/daleblack/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/data/xcat/vmale_50_1600x1400x500_8bit_little_endian_act_1.bin"
const PHANTOM_PATH = joinpath(
    XCAT_DIR,
    "vmale_50_1600x1400x500_8bit_little_endian_act_1.bin"
)
true
const HAS_XCAT = isfile(PHANTOM_PATH)

XCAT located: $(PHANTOM_PATH) (1068.1 MB)

02. Load the UHR mask (DOWNSAMPLE_FACTOR = 2)

XCAT vmale50 ships at 1600 × 1400 × 500 voxels @ 0.2 mm isotropic. Notebook 02 downsamples 5× for speed (1 mm voxels — clinical-typical). Here we downsample only 2× → 800 × 700 × 250 voxels @ 0.4 mm isotropic = roughly 32 × 28 × 10 cm physical, 140 MB as UInt8.

The point of going UHR is to make the phantom finer than the recon grid so the affine round-trip has something interesting to interpolate across.

load_xcat_bin (generic function with 1 method)
function load_xcat_bin(
        filepath::AbstractString;
        cols::Int = 1600, rows::Int = 1400, slices::Int = 500,
    )
    expected = cols * rows * slices * sizeof(UInt8)
    actual = filesize(filepath)
    actual == expected ||
        error("XCAT file size mismatch: expected $(expected) bytes, got $(actual)")

    data = Vector{UInt8}(undef, cols * rows * slices)
    open(filepath, "r") do io
        read!(io, data)
    end

    phantom = reshape(data, (cols, rows, slices))
    return reverse(phantom; dims = (2, 3))
end
downsample_labeled

Nearest-neighbor 3D downsample by an integer factor — preserves labels.

"""Nearest-neighbor 3D downsample by an integer factor — preserves labels."""
function downsample_labeled(phantom::AbstractArray{T, 3}, factor::Int) where {T}
    factor == 1 && return phantom
    nx, ny, nz = size(phantom) .÷ factor
    out = similar(phantom, (nx, ny, nz))
    @inbounds for k in 1:nz, j in 1:ny, i in 1:nx
        ii = (i - 1) * factor + factor ÷ 2 + 1
        jj = (j - 1) * factor + factor ÷ 2 + 1
        kk = (k - 1) * factor + factor ÷ 2 + 1
        out[i, j, k] = phantom[ii, jj, kk]
    end
    return out
end
2
const DOWNSAMPLE_FACTOR = 2
const VOXEL_SIZE_CM = (
    0.02 * DOWNSAMPLE_FACTOR,    # 0.4 mm at DS=2
    0.02 * DOWNSAMPLE_FACTOR,
    0.02 * DOWNSAMPLE_FACTOR,
);
phantom_full_uhr = HAS_XCAT ?
    downsample_labeled(load_xcat_bin(PHANTOM_PATH), DOWNSAMPLE_FACTOR) :
    nothing;

UHR phantom loaded:

  • shape = 800 × 700 × 250 (UInt8, 133.5 MB)

  • voxel = (0.4, 0.4, 0.4) mm

  • extent = (32.0, 28.0, 10.0) cm

  • 33

    unique organ labels present

03. Custom materials from XCAT spreadsheet

Same xlsx loader as notebook 02 — one row per organ in vmale_50_materials_heart_high_contrast.xlsx → 33 XA.Material entries keyed by integer organ label. This loader is unchanged from nb02; only the phantom grid is different here.

We need the materials dict before §4 because we'll use the materials' names to identify which integer labels correspond to "heart"-related anatomy for the bbox crop.

import XLSX
"/Users/daleblack/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/data/xcat/Material_Spreadsheets/vmale_50_materials_heart_high_contrast.xlsx"
const MATERIAL_XLSX_PATH = joinpath(
    XCAT_DIR, "Material_Spreadsheets",
    "vmale_50_materials_heart_high_contrast.xlsx",
)
const _ATOMIC_MASSES = Dict(
    1 => 1.008, 6 => 12.011, 7 => 14.007, 8 => 15.999, 11 => 22.99, 12 => 24.305,
    15 => 30.974, 16 => 32.06, 17 => 35.45, 19 => 39.098, 20 => 40.078, 26 => 55.845, 53 => 126.904,
)
const _I_VALUES_EV = Dict(
    1 => 19.2, 6 => 81.0, 7 => 82.0, 8 => 95.0, 11 => 149.0, 12 => 156.0,
    15 => 173.0, 16 => 180.0, 17 => 174.0, 19 => 190.0, 20 => 191.0, 26 => 286.0, 53 => 491.0,
)
compute_ZA_ratio (generic function with 1 method)
function compute_ZA_ratio(comp::Dict{Int, Float64})
    Z_sum = sum(w * Z / get(_ATOMIC_MASSES, Z, Float64(Z) * 2) for (Z, w) in comp)
    A_sum = sum(values(comp))
    return Z_sum / A_sum
end
compute_mean_excitation_energy (generic function with 1 method)
function compute_mean_excitation_energy(comp::Dict{Int, Float64})
    log_I_sum = 0.0
    Z_A_sum = 0.0
    for (Z, w) in comp
        A = get(_ATOMIC_MASSES, Z, Float64(Z) * 2)
        I = get(_I_VALUES_EV, Z, 10.0 * Z)
        Z_A = w * Z / A
        log_I_sum += Z_A * log(I)
        Z_A_sum += Z_A
    end
    return exp(log_I_sum / Z_A_sum) * u"eV"
end
load_materials_from_xlsx (generic function with 1 method)
function load_materials_from_xlsx(xlsx_path::AbstractString)
    sheet = XLSX.readxlsx(xlsx_path)["Sheet1"]
    data = sheet["A2:P34"]
    out = Dict{Int, BS.XA.Material}()

    Z_cols = (1, 6, 7, 8, 11, 12, 15, 16, 17, 19, 20, 26, 53)

    for r in 1:size(data, 1)
        name = data[r, 1]
        oid = data[r, 16]
        ρ = data[r, 15]
        (name === nothing || oid === nothing || ρ === nothing) && continue

        comp = Dict{Int, Float64}()
        for (k, Z) in enumerate(Z_cols)
            v = data[r, k + 1]
            v isa Number && v > 0 && (comp[Z] = Float64(v))
        end
        isempty(comp) && continue

        out[Int(oid)] = BS.XA.Material(
            String(name),
            compute_ZA_ratio(comp),
            compute_mean_excitation_energy(comp),
            Float64(ρ) * u"g/cm^3",
            comp,
        )
    end
    return out
end
materials_full = phantom_full_uhr === nothing ? nothing : let
        base = load_materials_from_xlsx(MATERIAL_XLSX_PATH)
        for l in unique(phantom_full_uhr)
            haskey(base, Int(l)) || (base[Int(l)] = BS.XA.Materials.water)
    end
        base
end;

04. Cardiac bbox by label-name match

This is the core idea of the notebook.

A real CT scanner has a scan field of view (SFOV) — anything outside it isn't reconstructed. This simulator's recon grid is hard-locked to isocenter-centered, so there's no recon_offset_cm knob. The equivalent operation is to crop the input phantom to the region of interest before forward projection. Done early it's also strictly more efficient: voxels outside the crop never get ray-traced and never enter the workspace's per-energy scratch buffers.

We pick the cardiac region by filtering materials_full for organ names matching /heart|atrium|ventric|coronary|aorta/i, then computing the voxel bounding box of all matching labels and padding by ~1 cm.

Heuristic, not segmentation

Name-match is a robust heuristic against XCAT's organ catalog. If you're working off a different phantom whose label names don't follow the same conventions, replace the regex with an explicit list of integer label IDs.

heart_label_ids = materials_full === nothing ? nothing : let
        pattern = r"heart|atrium|ventric|coronary|aorta"i
        ids = sort(
            UInt8[
                UInt8(oid) for (oid, mat) in materials_full
                if occursin(pattern, mat.name)
            ]
        )
        @info "[cardiac bbox] $(length(ids)) organ labels match: $(ids)"
        for oid in ids
            @info "    label $(Int(oid)) → $(materials_full[Int(oid)].name)"
    end
        ids
end;
heart_bbox = (phantom_full_uhr === nothing || heart_label_ids === nothing) ? nothing : let
        is_heart = falses(256)
        for oid in heart_label_ids
            is_heart[Int(oid) + 1] = true
    end

        nx, ny, nz = size(phantom_full_uhr)
        i_lo, i_hi = nx + 1, 0
        j_lo, j_hi = ny + 1, 0
        k_lo, k_hi = nz + 1, 0
        n_voxels = 0
        @inbounds for k in 1:nz, j in 1:ny, i in 1:nx
            if is_heart[Int(phantom_full_uhr[i, j, k]) + 1]
                i < i_lo && (i_lo = i)
                i > i_hi && (i_hi = i)
                j < j_lo && (j_lo = j)
                j > j_hi && (j_hi = j)
                k < k_lo && (k_lo = k)
                k > k_hi && (k_hi = k)
                n_voxels += 1
        end
    end
        n_voxels == 0 && error("[cardiac bbox] no heart-labeled voxels found in phantom")

        # Pad ~1 cm in every direction.
        pad_vox_x = round(Int, 1.0 / VOXEL_SIZE_CM[1])
        pad_vox_y = round(Int, 1.0 / VOXEL_SIZE_CM[2])
        pad_vox_z = round(Int, 1.0 / VOXEL_SIZE_CM[3])

        i_lo = max(1, i_lo - pad_vox_x);  i_hi = min(nx, i_hi + pad_vox_x)
        j_lo = max(1, j_lo - pad_vox_y);  j_hi = min(ny, j_hi + pad_vox_y)
        k_lo = max(1, k_lo - pad_vox_z);  k_hi = min(nz, k_hi + pad_vox_z)

        @info "[cardiac bbox] tight bbox + 1 cm pad:"
        @info "  voxel range = ($(i_lo):$(i_hi), $(j_lo):$(j_hi), $(k_lo):$(k_hi))"
        @info "  size  = $(i_hi - i_lo + 1) × $(j_hi - j_lo + 1) × $(k_hi - k_lo + 1) voxels"
        @info "  extent = $(round.((i_hi - i_lo + 1, j_hi - j_lo + 1, k_hi - k_lo + 1) .* VOXEL_SIZE_CM, digits = 2)) cm"
        @info "  cardiac voxels (pre-pad)  = $(n_voxels)"

        (i_lo = i_lo, i_hi = i_hi, j_lo = j_lo, j_hi = j_hi, k_lo = k_lo, k_hi = k_hi)
end;

Scan A: Axial, Zoomed Cardiac FOV

Crop the UHR phantom tight to the cardiac bbox (the SFOV-equivalent step), scan it with a single axial rotation and a tight centered FOV, then resample the ground truth back onto the recon grid and overlay it — pixel-perfect.

01. Crop the phantom: the SFOV-equivalent step

Just an indexing op on the UHR mask. This is the moment the simulator's "FOV cropping" actually happens: the cropped block is what gets handed to simulate!, so everything outside this bbox costs zero compute and zero memory in the forward projection.

phantom_cropped = (phantom_full_uhr === nothing || heart_bbox === nothing) ? nothing : let
        b = heart_bbox
        out = phantom_full_uhr[b.i_lo:b.i_hi, b.j_lo:b.j_hi, b.k_lo:b.k_hi]
        full_voxels = length(phantom_full_uhr)
        cropped_voxels = length(out)
        @info "[crop] $(round(full_voxels / 1.0e6, digits = 1))M voxels → $(round(cropped_voxels / 1.0e6, digits = 1))M voxels  ($(round(100 * cropped_voxels / full_voxels, digits = 1))% kept)"
        @info "[crop] memory: $(round(sizeof(phantom_full_uhr) / 1024^2, digits = 1)) MB → $(round(sizeof(out) / 1024^2, digits = 1)) MB"
        @info "[crop] forward-projection ray count drops by the same ratio — $(round(full_voxels / cropped_voxels, digits = 1))× faster simulate!"
        out
end;
materials_cropped = (phantom_cropped === nothing || materials_full === nothing) ? nothing : let
        base = copy(materials_full)
        for l in unique(phantom_cropped)
            haskey(base, Int(l)) || (base[Int(l)] = BS.XA.Materials.water)
    end
        base
end;

Visualize the crop

Mid-z slice of the full UHR phantom with the bbox drawn over it (left) next to the cropped block (right). This is the picture that justifies the technique — the SFOV is just a rectangle, applied at input time.

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/05_xcat_grid_to_recon.jl#==#05000007-0000-4000-8000-000000000010", :source_package => nothing, :call => "macro expansion", :linfo_type => "Nothing", :line => 14, :file => "05_xcat_grid_to_recon.jl#==#05000007-0000-4000-8000-000000000010", :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/05_xcat_grid_to_recon.jl#==#05000007-0000-4000-8000-000000000010:14 [inlined]")

02. Build the Phantom and its world affine

Default origin behavior: when you don't pass origin = … to Phantom, the constructor computes origin = -extent/2 + voxel/2 — i.e. it centers the phantom's physical extent at isocenter for free. Since we cropped before constructing, the cropped block lands centered at (0, 0, 0) — exactly where a centered recon FOV will pick it up.

phantom = (phantom_cropped === nothing || materials_cropped === nothing) ? nothing :
    BS.Phantom(to_gpu(phantom_cropped), materials_cropped, VOXEL_SIZE_CM);
phantom_cpu = (phantom_cropped === nothing || materials_cropped === nothing) ? nothing :
    BS.Phantom(phantom_cropped, materials_cropped, VOXEL_SIZE_CM);

phantom_to_world_affine

The 4×4 matrix A_phantom maps a 0-indexed phantom voxel (i, j, k) to world coordinates (x, y, z) in cm:

[ x ]     [ vx  0   0   ox ]   [ i ]
[ y ]  =  [ 0   vy  0   oy ] · [ j ]
[ z ]     [ 0   0   vz  oz ]   [ k ]
[ 1 ]     [ 0   0   0    1 ]   [ 1 ]

(vx, vy, vz) is the phantom's voxel size; (ox, oy, oz) is the world position of voxel (0, 0, 0). After the crop+default-origin trick, this matrix tells us exactly where in the bore each phantom voxel sits.

A_phantom = phantom_to_world_affine(phantom) (cm)

col 1col 2col 3col 4
0.040.00.0-6.6
0.00.040.0-7.54
0.00.00.04-4.98
0.00.00.01.0
  • voxel = (0.4, 0.4, 0.4) mm

  • origin = (-6.6, -7.54, -4.98) cm (world position of voxel (0, 0, 0))

  • extent = (13.24, 15.12, 10.0) cm

  • center of cropped block ≈ (-0.0, -0.0, -0.0) cm (should be ≈ isocenter)

03. Scanner, protocol, and tight-FOV recon

Same hardware as notebooks 01 / 02 / 03 — clinical 64-row CT, large bowtie, GE Revolution Apex Elite-class detector. We're doing single-kVp EICT here; the affine machinery has nothing to do with spectral imaging, so any scanner works.

scanner = BS.Scanner(
    source_to_isocenter = 625.6,
    source_to_detector = 1100.0,
    detector_rows = 256,
    detector_cols = 834,
    detector_row_size = 0.625,
    detector_col_size = 0.6,
    focal_spot_width = 1.0,
    focal_spot_length = 1.0,
    target_angle = 10.0,
    flat_filter_material = :aluminum,
    flat_filter_thickness = 2.5,
    bowtie_filter = :ge_revolution_large,
    detector_material = :lumex,
    detector_depth = 3.0,
    fill_factor_row = 0.9,
    fill_factor_col = 0.9,
    # DAS/electronic noise enters the counts before the log transform, so it
    # propagates through reconstruction (see `add_system_noise_floor!` docstring).
    electronic_noise = 3500.0,   # e⁻ — clinical GE Apex Elite DAS readout noise
    detection_gain = 10.0,
);

CTProtocol: clinical cardiac CTA

120 kVp / 250 mA, 1 s rotation, 5 mm collimation, 500 views. The recon slab will derive its z-extent from the protocol collimation, which is how this simulator decides how many detector rows are active (see CTGeometry).

protocol = BS.CTProtocol(
    kVp = 120,
    mA = 250.0,
    views = 500,
    rotation_time = 1.0,
    collimation_mm = 5.0,
    additional_filters = [("Al", 4.5)],
);
sim_opts = BS.SimOptions(fidelity = :eict, seed = 1234, projector = :dd_fast);

ReconOptions: tight cardiac FOV

The recon FOV is always centered at isocenter in this simulator. That's fine for us — we centered the cropped phantom at iso for free in §6. We use a 14 cm × 14 cm in-plane FOV (smaller than the cropped extent in xy by design: lets us see what happens when the recon FOV is tighter than the input). The recon z-extent is derived from the protocol collimation.

matrix_size = (384, 384, n_z) gives ~0.36 mm recon voxels — coarser than the 0.4 mm phantom voxels so the affine round-trip will downsample slightly.

recon_opts = let
    slice_thickness_mm = 0.625
    n_z = max(1, round(Int, protocol.collimation_mm / slice_thickness_mm))
    BS.ReconOptions(
        matrix_size = (384, 384, n_z),
        fov_cm = 14.0,
        z_cm = protocol.collimation_mm / 10.0,
    )
end;

recon_to_world_affine

We can build the CTGeometry directly from (scanner, protocol, recon_opts) — no need to wait for simulate! to inspect the recon grid. Same affine shape as A_phantom; the values reflect the recon voxel size (fov / matrix_size) and a centered origin (-fov/2 + voxel/2).

geom_inspect = BS.CTGeometry(
    scanner;
    n_angles = protocol.views,
    fov_cm = recon_opts.fov_cm,
    z_cm = recon_opts.z_cm,
    collimation_mm = protocol.collimation_mm,
);

A_recon = recon_to_world_affine(geom, matrix_size) (cm)

col 1col 2col 3col 4
0.03650.00.0-6.9818
0.00.03650.0-6.9818
0.00.00.0625-0.2188
0.00.00.01.0
  • matrix size = 384 × 384 × 8 voxels

  • voxel size = (0.365, 0.365, 0.625) mm

  • FOV = (14.0, 14.0, 0.5) cm

  • origin = -6.982, -6.982, -0.219 cm (centered at iso)

Side-by-side grid comparison

The two grids share world coordinates (cm) but differ in voxel size, shape, and FOV. resample_to_recon (and the affines under it) handle all of this for you.

propertyphantom (cropped UHR)recon (centered, tight FOV)
shape (voxels)331 × 378 × 250384 × 384 × 8
voxel size (mm)0.4 × 0.4 × 0.40.365 × 0.365 × 0.625
extent (cm)13.24 × 15.12 × 10.014.0 × 14.0 × 0.5
origin (cm)(-6.6, -7.54, -4.98)(-6.982, -6.982, -0.219)
total voxels312795001179648

04. Forward project and reconstruct

Standard EICT path. We skip the BHC pipeline (see notebook 02 §7 for that) and use a quick analytic μ_water for HU conversion — the focus here is geometry, not HU accuracy.

sim = phantom === nothing ? nothing : let
        @info "Simulating cardiac CTA: 120 kVp / 250 mA / cropped UHR phantom…"
        ws = BS.create_eict_workspace(scanner, protocol, sim_opts, recon_opts, phantom)
        BS.simulate!(ws, phantom, protocol, sim_opts)

        result = (sino = Array(ws.sinogram), geom = ws.geom)
        ws = nothing
        GC.gc(true)
        result
end;
μ_water_120 = phantom_cpu === nothing ? nothing : let
        # Phantom-aware water_path: pull the body chord straight off the
        # cropped XCAT mask, so the calibration tracks any change to the
        # crop bbox / DOWNSAMPLE_FACTOR without hardcoded cm.
        body_diameter_cm = BS.estimate_phantom_diameter_cm(
            phantom_cpu.mask, phantom_cpu.voxel_size .* 10.0,
        )
        μ = BS.compute_polychromatic_μ_water(
            sim_opts, protocol;
            scanner = scanner,
            geom = geom_inspect,
            water_path_cm = body_diameter_cm,
        )
        @info "[analytic μ_water]  120 kVp + 4.5 mm Al + $(round(body_diameter_cm, digits = 1)) cm body hardening: $(round(μ, digits = 5)) cm⁻¹"
        μ
end;
recon_HU = sim === nothing ? nothing : let
        sino_gpu = to_gpu(Float32.(sim.sino))
        ws = BS.create_fdk_recon_workspace(sino_gpu, sim.geom, recon_opts.matrix_size; filter = :standard)
        recon_μ = Array(BS.reconstruct!(ws, sino_gpu, sim.geom))
        ws = nothing; sino_gpu = nothing; GC.gc(true)

        # quantum + DAS noise already carried by the sinogram (counts domain)
        HU = Float32.(BS.to_hounsfield(recon_μ; μ_water = μ_water_120))
        HU
end;

05. Resample ground truth and overlay

BS.resample_to_recon is the convenience wrapper. It pulls the phantom mask to CPU, computes each recon voxel's world coordinate via A_recon, maps to the continuous phantom-voxel index via inv(A_phantom), and samples.

Two interpolation methods built in:

methodoutput typeuse when
:nearestUInt8 (label-preserving)overlaying labels for ROI extraction / segmentation evaluation
:linearFloat32 (trilinear)continuous fields (HU, density, fractional volume)
gt_resampled_nn = (phantom_cpu === nothing || sim === nothing) ? nothing :
    BS.resample_to_recon(phantom_cpu, sim.geom, recon_opts.matrix_size; method = :nearest);
gt_resampled_lin = (phantom_cpu === nothing || sim === nothing) ? nothing :
    BS.resample_to_recon(phantom_cpu, sim.geom, recon_opts.matrix_size; method = :linear);
# Fractional cardiac coverage: build a *binary* cardiac mask, then resample
# `:linear`.  Trilinear on a multi-label integer mask (gt_resampled_lin
# above) arithmetically-mixes label IDs and isn't physically meaningful;
# trilinear on a 0/1 mask gives true partial-volume fractions ∈ [0, 1].
cardiac_coverage_lin = (
        phantom_cpu === nothing || sim === nothing ||
        heart_label_ids === nothing
    ) ? nothing : let
        binary_mask = zeros(UInt8, size(phantom_cropped))
        for oid in heart_label_ids
            binary_mask[phantom_cropped .== oid] .= 0x01
    end
        binary_phantom = BS.Phantom(
            binary_mask,
            Dict(0 => BS.XA.Materials.water, 1 => BS.XA.Materials.water),
            VOXEL_SIZE_CM,
        )
        BS.resample_to_recon(binary_phantom, sim.geom, recon_opts.matrix_size; method = :linear)
end;
  • gt_resampled_nn shape = (384, 384, 8) · eltype = UInt8

  • gt_resampled_lin shape = (384, 384, 8) · eltype = Float32

  • recon_HU shape = (384, 384, 8) · eltype = Float32

All three live on the same world-coordinate grid — index (i, j, k) in any of them corresponds to the same physical voxel inside the bore.

Bring-your-own-interpolator pattern

When :nearest and :linear aren't enough — e.g. you want a B-spline, a sinc kernel, or some learned upsampling — the affines give you the recon-voxel → phantom-voxel map directly. Compute

M = inv(A_phantom) * A_recon

and you have a 4×4 that takes any (i_recon, j_recon, k_recon, 1) to the continuous phantom voxel index. Hand that to your interpolator of choice (Interpolations.jl, ImageTransformations.jl, a custom kernel, PyTorch via PyCall, whatever) and you're done.

M = inv(A_phantom) * A_recon — recon voxel → continuous phantom voxel

col 1col 2col 3col 4
0.91150.00.0-9.5443
0.00.91150.013.9557
0.00.01.5625119.0313
0.00.00.01.0

Quick sanity:

  • recon voxel (0, 0, 0) → phantom voxel (-9.54, 13.96, 119.03)

  • recon volume center → phantom voxel (165.0, 188.5, 124.5) (should be near the cropped phantom's center)

Pass M to the interpolator of your choice. In pseudocode:

for k in 0:(nz_r-1), j in 0:(ny_r-1), i in 0:(nx_r-1)
    p = M * [i, j, k, 1.0]               # phantom voxel index (Float64)
    out[i+1, j+1, k+1] = my_interpolator(phantom.mask, p[1], p[2], p[3])
end

The verification mosaic

Four panels, all on the recon grid at the same mid-slice. Top row shows the two raw inputs; bottom row overlays the masks on the HU recon to demonstrate alignment.

panelwhat it shows
(top-left) HU reconwhat the scanner produced — clinical recon grid, isocenter-centered
(top-right) all structures (:nearest, no overlay)full multi-label resample on the recon grid — every organ, no masking
(bottom-left) HU + cardiac labelscardiac labels (NaN-masked) over the HU at α=0.6 — alignment check
(bottom-right) HU + cardiac coverage (:linear, binary mask)true partial-volume fraction ∈ [0, 1] over the HU, soft at boundaries
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/05_xcat_grid_to_recon.jl#==#05000010-0000-4000-8000-000000000010", :source_package => nothing, :call => "macro expansion", :linfo_type => "Nothing", :line => 43, :file => "05_xcat_grid_to_recon.jl#==#05000010-0000-4000-8000-000000000010", :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/05_xcat_grid_to_recon.jl#==#05000010-0000-4000-8000-000000000010:43 [inlined]")

Scan B: Helical, Extended-Z FOV

Same phantom, same affine machinery — but a taller crop scanned with a helical acquisition, reconstructed into a tall stack of axial slices you can scroll through in z. The recon grid has its own recon→world affine (a bigger z extent than Scan A); resample_to_recon still lands the ground truth pixel-perfectly on every slice.

01. Extended-z crop

Scan A cropped tight to the heart. For the helical demo we keep the same in-plane (x, y) bbox but extend the z range (± a few cm beyond the cardiac extent) so there's a tall stack to scroll — the extra z is exactly what the helix sweeps through.

heart_bbox_tall = (phantom_full_uhr === nothing || heart_bbox === nothing) ? nothing : let
    nz = size(phantom_full_uhr, 3)
    extra_z = round(Int, 4.0 / VOXEL_SIZE_CM[3])   # +4 cm each side beyond the cardiac bbox
    b = heart_bbox
    tall = (i_lo = b.i_lo, i_hi = b.i_hi, j_lo = b.j_lo, j_hi = b.j_hi,
            k_lo = max(1, b.k_lo - extra_z), k_hi = min(nz, b.k_hi + extra_z))
    @info "[Scan B tall bbox] z range $(b.k_lo):$(b.k_hi) → $(tall.k_lo):$(tall.k_hi)  ($(round((tall.k_hi - tall.k_lo + 1) * VOXEL_SIZE_CM[3], digits = 1)) cm z extent)"
    tall
end;
phantom_cropped_tall = (phantom_full_uhr === nothing || heart_bbox_tall === nothing) ? nothing : let
    b = heart_bbox_tall
    phantom_full_uhr[b.i_lo:b.i_hi, b.j_lo:b.j_hi, b.k_lo:b.k_hi]
end;
materials_tall = (phantom_cropped_tall === nothing || materials_full === nothing) ? nothing : let
    base = copy(materials_full)
    for l in unique(phantom_cropped_tall)
        haskey(base, Int(l)) || (base[Int(l)] = BS.XA.Materials.water)
    end
    base
end;
phantom_helical = (phantom_cropped_tall === nothing || materials_tall === nothing) ? nothing :
    BS.Phantom(to_gpu(phantom_cropped_tall), materials_tall, VOXEL_SIZE_CM);
phantom_helical_cpu = (phantom_cropped_tall === nothing || materials_tall === nothing) ? nothing :
    BS.Phantom(phantom_cropped_tall, materials_tall, VOXEL_SIZE_CM);
recon_opts_helical = let
    slice_thickness_mm = 0.625
    z_cm = 4.0                        # taller recon slab than Scan A — the helix supplies the z coverage
    n_z = max(1, round(Int, z_cm * 10 / slice_thickness_mm))   # ~64 slices to scroll
    BS.ReconOptions(matrix_size = (384, 384, n_z), fov_cm = 14.0, z_cm = z_cm)
end;

02. Helical protocol and z-ramped acquisition

Everything above used an axial scan. The affine machinery is trajectory-agnostic by design: a helical acquisition changes the SOURCE path (z ramps with view), but the RECON grid is still a stack of axial slices centred on the scanned range — so recon_to_world_affine, and therefore resample_to_recon, apply unchanged. This section proves it end-to-end on the new spiral chain: CTProtocol(pitch = …) → z-ramped CTGeometry:dd_fast forward on the (default) arc detector → rebinned-WFBP reconstruction → label overlay on the helical recon grid.

sim_helical = phantom_helical === nothing ? nothing : let
    protocol_hel = BS.CTProtocol(
        kVp = 120, mA = 250.0, views = 500, rotation_time = 1.0,
        collimation_mm = 10.0, additional_filters = [("Al", 4.5)],
        pitch = 1.0, n_rotations = 8.0,
    )
    @info "Simulating HELICAL cardiac CTA: pitch 1.0 × 8 rotations, 10 mm collimation…"
    ws = BS.create_eict_workspace(scanner, protocol_hel, sim_opts, recon_opts_helical, phantom_helical)
    BS.simulate!(ws, phantom_helical, protocol_hel, sim_opts)
    result = (sino = Array(ws.sinogram), geom = ws.geom)
    ws = nothing
    GC.gc(true)
    result
end;

03. WFBP reconstruct and its own recon affine

is_helical(geom) routes reconstruct! to the rebinned-WFBP path. The recon grid is a taller stack of axial slices than Scan A — its own recon_to_world_affine, with a bigger z extent — but still centered at isocenter, so the affine round-trip is unchanged.

recon_HU_helical = sim_helical === nothing ? nothing : let
    sino_gpu = to_gpu(Float32.(sim_helical.sino))
    # is_helical(geom) routes reconstruct! to the rebinned-WFBP path
    ws = BS.create_fdk_recon_workspace(sino_gpu, sim_helical.geom, recon_opts_helical.matrix_size; filter = :standard)
    recon_μ = Array(BS.reconstruct!(ws, sino_gpu, sim_helical.geom))
    ws = nothing; sino_gpu = nothing; GC.gc(true)
    Float32.(BS.to_hounsfield(recon_μ; μ_water = μ_water_120))
end;

Helical A_recon = recon_to_world_affine(helical geom, matrix_size) (cm)

col 1col 2col 3col 4
0.03650.00.0-6.9818
0.00.03650.0-6.9818
0.00.00.0625-1.9688
0.00.00.01.0
  • matrix size = 384 × 384 × 64 voxels (64 z slices vs Scan A's 8)

  • z extent = 4.0 cm (vs Scan A's 0.5 cm)

  • same centered, isocenter origin — only the z stack is taller.

04. Resample ground truth onto the helical grid

Same resample_to_recon, now against the taller helical recon grid — the ground-truth labels land on the exact voxels of the WFBP stack.

gt_helical_nn = (phantom_helical_cpu === nothing || sim_helical === nothing) ? nothing :
    BS.resample_to_recon(phantom_helical_cpu, sim_helical.geom, recon_opts_helical.matrix_size; method = :nearest);

05. Scroll through z: slider-driven overlay

Drag the slider to scrub through the helical recon in z. Left is the WFBP HU recon; right overlays the resampled cardiac labels (translucent fill) — they sit on the recon anatomy on every slice, so the affine holds across the full z stack, not just the mid-plane.

32
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/05_xcat_grid_to_recon.jl#==#05000012-0000-4000-8000-000000000040", :source_package => nothing, :call => "macro expansion", :linfo_type => "Nothing", :line => 28, :file => "05_xcat_grid_to_recon.jl#==#05000012-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/05_xcat_grid_to_recon.jl#==#05000012-0000-4000-8000-000000000040:28 [inlined]")

06. :nearest vs :linear at boundaries

The affine mapping is exact (see the round-trip audit below), so any apparent "fuzziness" at a label edge is resampling, not misregistration. With :nearest, every recon voxel snaps to the single closest UHR phantom voxel (nearest in all three axes, z included) — so label boundaries stair-step onto the coarse recon grid, a half-voxel quantization. Resampling a binary cardiac mask with :linear instead gives a fully 3D partial-volume coverage ∈ [0, 1] per recon voxel: the trilinear blend weights x, y and z, so a voxel straddling the cardiac surface in z (0.625 mm helical slices) picks up a fractional value too — not just in-plane. It follows the sub-voxel boundary smoothly. Same slice, same slider — scrub z and compare.

Caveat on "partial volume": trilinear samples at each recon-voxel centre from the 8 bracketing phantom voxels — that equals the true occupied-volume fraction when the phantom and recon grids are comparable in resolution, as they are here (phantom 0.4 mm vs recon 0.37 mm in-plane / 0.625 mm in z). If you push the phantom much finer than the recon (smaller DOWNSAMPLE_FACTOR), a recon voxel would enclose several phantom voxels that a centre-sample ignores, and a true volumetric coverage would need box-averaging / supersampling instead.

# Fractional cardiac coverage on the HELICAL grid: binary mask → `:linear`
# resample (the multi-label `:nearest` map mixes IDs under trilinear, so we
# resample a 0/1 mask to get physical partial-volume fractions).
cardiac_coverage_lin_helical = (
        phantom_cropped_tall === nothing || sim_helical === nothing ||
        heart_label_ids === nothing
    ) ? nothing : let
        binary_mask = zeros(UInt8, size(phantom_cropped_tall))
        for oid in heart_label_ids
            binary_mask[phantom_cropped_tall .== oid] .= 0x01
        end
        binary_phantom = BS.Phantom(
            binary_mask,
            Dict(0 => BS.XA.Materials.water, 1 => BS.XA.Materials.water),
            VOXEL_SIZE_CM,
        )
        BS.resample_to_recon(binary_phantom, sim_helical.geom, recon_opts_helical.matrix_size; method = :linear)
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/05_xcat_grid_to_recon.jl#==#05000016-0000-4000-8000-000000000020", :source_package => nothing, :call => "macro expansion", :linfo_type => "Nothing", :line => 27, :file => "05_xcat_grid_to_recon.jl#==#05000016-0000-4000-8000-000000000020", :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/05_xcat_grid_to_recon.jl#==#05000016-0000-4000-8000-000000000020:27 [inlined]")

07. The affine round-trip is exact (1-to-1)

The overlays above are exact by construction — not "≥ 80 % aligned". This proves it with numbers, for both the axial and the helical geometry. Three independent checks:

  1. Affine ≡ reconstructor grid. The FDK and WFBP backprojectors both place recon voxel (i,j,k) at world -fov/2 + (idx − ½)·(fov/n) (1-indexed) — the same isocenter-centered rule recon_to_world_affine encodes. So the grid the reconstructor writes into is bit-for-bit the grid the resampler samples.

  2. Round-trip identity. A⁻¹ · A · v = v — the map is a diagonal scale + translate, algebraically invertible to floating-point roundoff.

  3. Recon registered to the map. Checks 1–2 certify the coordinate map; the last cell certifies the image — that the forward projector images the phantom at the exact world position the backprojector reconstructs it (a forward↔backprojector half-voxel / rebinning offset would displace the recon and stay invisible to 1–2). Best edge-alignment shift = (0,0) for both.

Helical carries no special-casing: is_helical(geom) only switches the backprojection algorithm, never the grid geometry — so the mapping is 1-to-1 for the spiral scan exactly as it is for the axial one. Any softness you see at a boundary when you zoom in is :nearest half-voxel quantization (§06) or the recon's point-spread blur — never the affine, and never a registration offset.

geometryaffine ≡ reconstructor gridround-trip A⁻¹·A·v = v
axialmax Δ = 8.9e-12 µmmax Δ = 0.0 voxel
helicalmax Δ = 8.9e-12 µmmax Δ = 0.0 voxel

Both geometries: sub-µm grid agreement and machine-precision round-trip ⇒ the UHR-phantom → recon mapping is exactly 1-to-1, axial and helical alike. Any softness at boundaries is :nearest quantization (§06) or recon PSF, never the affine.

Registration offset (recon edges vs label boundaries, mid-slice):

  • axial : best shift = (0, 0) voxels · (0,0) score = 1.0 of peak

  • helical : best shift = (0, 0) voxels · (0,0) score = 1.0 of peak

(0,0) best shift ⇒ registered; a consistent nonzero shift ⇒ a real forward↔backprojector offset. Score near 1.0 ⇒ (0,0) is already the peak.

# The affine audit proves the MAP is exact; this proves the RECON is registered
# TO that map — i.e. the forward projector images the phantom at the same world
# position the backprojector reconstructs it (a half-voxel or rebinning-column
# mismatch would displace the recon and stay invisible to the round-trip audit).
# Correlate recon edge-magnitude against the label-boundary map over integer
# (dx,dy) shifts; the peak shift is the real offset.  (0,0) = registered.
# (Integer resolution: a systematic ≤½-voxel shift also reads (0,0) — but that is
#  the same scale as :nearest quantization / recon PSF, not a registration bug.)
let
    reg_offset = function (recon, gt; R = 5)
        mid = size(recon, 3) ÷ 2
        hu = Float32.(recon[:, :, mid]); lab = gt[:, :, mid]
        nx, ny = size(hu)
        gr = zeros(Float32, nx, ny)          # recon edge strength
        lb = zeros(Float32, nx, ny)          # gt label-boundary map
        for j in 2:(ny - 1), i in 2:(nx - 1)
            gx = hu[i + 1, j] - hu[i - 1, j]; gy = hu[i, j + 1] - hu[i, j - 1]
            gr[i, j] = sqrt(gx * gx + gy * gy)
            (lab[i, j] != lab[i + 1, j] || lab[i, j] != lab[i, j + 1]) && (lb[i, j] = 1f0)
        end
        best = (0, 0); bs = -Inf; sc = Dict{Tuple{Int,Int},Float64}()
        for dx in -R:R, dy in -R:R
            s = 0.0
            for j in (1 + R):(ny - R), i in (1 + R):(nx - R)
                s += gr[i, j] * lb[i + dx, j + dy]
            end
            sc[(dx, dy)] = s
            s > bs && (bs = s; best = (dx, dy))
        end
        (best, sc[(0, 0)] / bs)              # best shift + how good (0,0) is vs peak
    end
    ax = (sim === nothing) ? "—" : reg_offset(recon_HU, gt_resampled_nn)
    hx = (sim_helical === nothing) ? "—" : reg_offset(recon_HU_helical, gt_helical_nn)
    Markdown.parse("""
    **Registration offset (recon edges vs label boundaries, mid-slice):**

    - axial   : best shift = $(ax isa String ? ax : ax[1]) voxels · (0,0) score = $(ax isa String ? ax : round(ax[2], digits = 3)) of peak
    - helical : best shift = $(hx isa String ? hx : hx[1]) voxels · (0,0) score = $(hx isa String ? hx : round(hx[2], digits = 3)) of peak

    `(0,0)` best shift ⇒ registered; a consistent nonzero shift ⇒ a real
    forward↔backprojector offset.  Score near 1.0 ⇒ `(0,0)` is already the peak.
    """)
end

Results and Interpretation

Why the Affine Round-Trip Matters

Once you have ground truth on the recon grid, the rest is bookkeeping:

  • Per-organ ROI HU stats. mean(recon_HU[gt_resampled_nn .== UInt8(label)]) for any organ label. No polar-coordinate ROI placement, no manual segmentation, no resampling drift.

  • Segmentation evaluation. Train your segmenter on recon_HU, evaluate against gt_resampled_nn — Dice / Hausdorff are well-defined because the voxel grids agree.

  • Partial-volume analysis. Resample a binary mask of one organ with method = :linear (see the cardiac_coverage_lin cell above) to get true fractional coverage ∈ [0, 1] per recon voxel — useful for boundary-aware metrics or partial-volume-corrected ROI stats. Don't resample the multi-label mask with :linear and expect meaningful fractions: trilinear arithmetically mixes integer label IDs.

  • Custom interpolation. When :nearest / :linear aren't sharp enough, the M = inv(A_phantom) * A_recon pattern from the bring-your-own-interpolator step lets you drop in any third-party interpolator with three lines.

  • SFOV-equivalent cropping is physical. Because the crop happens on the input phantom, it shows up in the forward-projection pass itself — fewer rays hit anatomy, fewer voxels enter scratch buffers, and the simulator runs faster. Same observable behavior as a real scanner's reduced SFOV; better memory characteristics than recon-side cropping ever could be.

Summary

Two acquisitions, one affine round-trip:

  • Scan A (axial, zoomed FOV) — crop the UHR phantom to the cardiac bbox (the SFOV-equivalent step), reconstruct a tight centered FOV, and overlay the resampled ground truth pixel-perfectly.

  • Scan B (helical, extended z) — a taller crop scanned with a spiral trajectory, reconstructed via rebinned WFBP into a tall axial stack you can scroll through; its recon grid has its own (taller) recon→world affine, and resample_to_recon lands the ground truth on every slice unchanged.

The takeaway: phantom_to_world_affine / recon_to_world_affine / resample_to_recon are trajectory-agnostic — axial or helical, tight or tall FOV, the ground-truth-to-recon mapping is the same three calls.