CatSim vs BasisSimulator: Agreement and Runtime
One scanner, one protocol, one Gammex 472 phantom, simulated and reconstructed three ways: XCIST/CatSim (GE's open-source reference simulator, Python + C), BasisSimulator on the CPU, and BasisSimulator on the GPU.
This is a cross-validation of the forward model and the reconstruction against an independent implementation. Both simulators are configured to the same beam and the same physics, both apply a water beam-hardening correction referenced to the same monoenergetic water μ, and both reconstruct with FDK and a standard kernel. The comparison therefore reads in HU: the per-rod table below puts CatSim, BasisSimulator and the XrayAttenuation theory side by side. The runtime of each pipeline comes after.
Noise is off in all three runs. The two simulators calibrate tube flux independently, so a noisy comparison would mix a flux-model difference into what should be a projector and reconstruction check.
Gammex 472 @ 128² × 8 (2.7 mm voxels)
→ GE Apex Elite geometry · 120 kVp / 200 mA / 500 views · 7 mm Al · large-body bowtie
→┬→ CatSim (Python): forward project → water BHC → FDK
├→ BasisSim CPU: simulate! → water BHC → reconstruct!
└→ BasisSim GPU: simulate! → water BHC → reconstruct! (same code, device mask)
→ images · differences · per-rod HU · runtime
Notebook setup
This is the only docs notebook that uses Python. docs/CondaPkg.toml pins gecatsim to the MolloiLab fork of XCIST (git+https://github.com/MolloiLab/main), which adds the Gammex 472 material definitions, together with numpy and pydicom. The first import PythonCall in the docs/ environment makes CondaPkg install Python and those packages into docs/.CondaPkg/ (a few minutes and about 1 GB, once). If PythonCall or gecatsim cannot be loaded, the CatSim cells skip with a notice and the BasisSimulator CPU and GPU runs still execute.
import BasisSimulator as BS# PythonCall, loaded so that a missing Python environment degrades to "CatSim skipped"
PC = try
Base.require(Base.PkgId(Base.UUID("6099a3de-0909-46bc-b1f4-468b9a2dfc0d"), "PythonCall"))
catch err
@warn "PythonCall could not be loaded; the CatSim cells will skip" exception = (err, catch_backtrace())
nothing
end;begin
import GPUSelect
AT = GPUSelect.Storage() # CuArray / MtlArray / ROCArray / oneArray, or Array on a CPU-only host
to_gpu(x) = AT(x)
GPU_BACKEND = (name = string(nameof(AT)),)
end;Backend detected: CuArray
const HAS_GECATSIM = PC !== nothing && try
PC.pyimport("gecatsim")
true
catch err
@warn "gecatsim is not importable; the CatSim cells will skip" exception = err
false
end;gecatsim 1.5.4 loaded (Python 3.14.6): the CatSim cells run.
Scan and phantom set-up
One scanner, one protocol and one phantom, shared verbatim by the three pipelines, and a thin wrapper that hands them to CatSim.
1. Scanner
The GE Revolution Apex Elite geometry of notebook 01, with its large-body bowtie. BasisSimulator's :ge_revolution_large bowtie is read from the same large.txt table CatSim ships, so both simulators see the same bowtie. Electronic noise is zero, as noise is off everywhere here.
scanner = BS.EICTScanner(
source_to_isocenter = 625.6,
source_to_detector = 1100.0,
detector_rows = 256,
detector_cols = 834,
detector_row_size = 0.625,
detector_col_size = 0.6,
focal_spot_width = 1.0,
focal_spot_length = 1.0,
target_angle = 10.0,
flat_filter_material = :aluminum,
flat_filter_thickness = 2.5,
bowtie_filter = :ge_revolution_large,
detector_material = :lumex,
detector_depth = 3.0,
fill_factor_row = 0.9,
fill_factor_col = 0.9,
electronic_noise = 0,
detection_gain = 10.0,
);2. Protocol, physics and grid
120 kVp / 200 mA, 500 views in a 1 s rotation, 4 mm of collimation, 4.5 mm of extra aluminium. The physics is set to what both simulators model by default: polychromatic transport through the filters and bowtie, energy-dependent detector efficiency, fill factor and focal-spot blur. Noise, scatter, detector lag and the heel effect are switched off in BasisSimulator because the CatSim defaults have them off (CatSim leaves its scatter, lag and crosstalk callbacks empty). The reconstruction is 256 × 256 over 35 cm, 0.625 mm slices.
Point views on both sides
The other notebooks model a clinical scanner's detector integrating each view while the gantry turns (SimOptions(; view_samples = 5), notebook 01). This one does not: it is a like-for-like check of the projector and the reconstruction, so both simulators sample each view at a single angle. BasisSimulator keeps its default view_samples = 1, and the wrapper sets CatSim's physics.viewSampleCount (2 by default) to 1.
protocol = BS.CTProtocol(
kVp = 120,
mA = 200.0,
views = 500,
rotation_time = 1.0,
collimation_mm = 4.0,
additional_filters = [("Al", 4.5)],
);sim_opts = BS.SimOptions(
seed = 1234, projector = :dd_fast,
use_noise = false, use_scatter = false, use_lag = false, use_heel_effect = false,
);recon_opts = BS.ReconOptions(
matrix_size = (256, 256, round(Int, protocol.collimation_mm / 0.625)),
fov_cm = 35.0,
z_cm = protocol.collimation_mm / 10,
);3. The CatSim wrapper
A few Julia functions map EICTScanner / CTProtocol / ReconOptions / Phantom onto CatSim's configuration:
Geometry. BasisSimulator gives the detector pitch at the isocentre; CatSim wants it at the detector face, so it is multiplied by
SDD/SID. Every column is its own module with no gap (detectorColsPerMod = 1,detectorColSkip = 0); without that CatSim braids or squashes the sinogram.Beam. CatSim's 120 kVp tungsten spectrum for a 10° target (the protocol's anode angle), the scanner's flat filter plus the protocol's filters, the same
large.txtbowtie, the same target angle and focal spot, and no graphite detector prefilter (BasisSimulator models none).Views. One angular sample per view (
physics.viewSampleCount = 1), the point views of BasisSimulator'sview_samples = 1.Preprocessing. CatSim's own water BHC,
Prep_BHC_Accurate: a degree-5 polynomial per detector cell fitted to air scans through 1–50 cm of water, mapped to the same monoenergetic water μ that BasisSimulator'scalibrate_bhc_wateruses. HU conversion uses that water μ too.
function catsim_configure_scanner!(ct, scanner, protocol)
magnification = scanner.source_to_detector / scanner.source_to_isocenter
n_rows = protocol.collimation_mm === nothing ? scanner.detector_rows :
round(Int, protocol.collimation_mm / scanner.detector_row_size)
ct.scanner.sid = scanner.source_to_isocenter
ct.scanner.sdd = scanner.source_to_detector
ct.scanner.detectorColCount = scanner.detector_cols
ct.scanner.detectorRowCount = n_rows
ct.scanner.detectorColSize = scanner.detector_col_size * magnification # isocentre → face
ct.scanner.detectorRowSize = scanner.detector_row_size * magnification
ct.scanner.detectorColOffset = scanner.detector_col_offset
ct.scanner.detectorColsPerMod = 1 # every column its own module …
ct.scanner.detectorRowsPerMod = n_rows
ct.scanner.detectorColSkip = 0.0 # … with no inter-module gap
ct.scanner.detectorRowSkip = 0.0
ct.scanner.targetAngle = scanner.target_angle
ct.scanner.focalspotWidth = scanner.focal_spot_width
ct.scanner.focalspotLength = scanner.focal_spot_length
ct.scanner.detectorMaterial = "Lumex"
ct.scanner.detectorDepth = scanner.detector_depth
ct.scanner.detectorColFillFraction = scanner.fill_factor_col
ct.scanner.detectorRowFillFraction = scanner.fill_factor_row
ct.scanner.detectorPrefilter = PC.pylist([])
return ct
end;function catsim_configure_protocol!(ct, scanner, protocol; μ_water_cm)
ct.protocol.mA = protocol.mA
ct.protocol.viewsPerRotation = protocol.views
ct.protocol.viewCount = protocol.views
ct.protocol.stopViewId = protocol.views - 1
ct.protocol.rotationTime = protocol.rotation_time
ct.protocol.spectrumFilename = "tungsten_tar$(protocol.anode_angle).0_$(Int(protocol.kVp))_filt.dat"
scanner.bowtie_filter in (:ge_revolution_large, :large_body) ||
error("this wrapper maps only the large-body bowtie")
ct.protocol.bowtie = "large.txt"
catsim_name(m) = Dict("aluminum" => "Al", "Al" => "Al", "copper" => "Cu", "Cu" => "Cu")[string(m)]
filters = Any[catsim_name(scanner.flat_filter_material), scanner.flat_filter_thickness]
for (m, t) in protocol.additional_filters
push!(filters, catsim_name(m), t)
end
ct.protocol.flatFilter = PC.pylist(filters)
ct.physics.viewSampleCount = 1 # point views, like BasisSimulator's view_samples = 1
ct.physics.enableQuantumNoise = 0
ct.physics.enableElectronicNoise = 0
ct.physics.callback_post_log = "Prep_BHC_Accurate" # CatSim's water BHC
ct.physics.EffectiveMu = μ_water_cm # cm⁻¹, the mono target of the fit
ct.physics.BHC_poly_order = 5
ct.physics.BHC_max_length_mm = 500
ct.physics.BHC_length_step_mm = 10
return ct
end;function catsim_configure_recon!(ct, recon_opts; μ_water_cm)
xc, _, cfg = catsim_init()
xc.source_cfg(joinpath(cfg, "Recon_Sample_2d.cfg"), ct)
n_slices = recon_opts.matrix_size[3]
ct.recon.fov = recon_opts.fov_cm * 10.0
ct.recon.imageSize = recon_opts.matrix_size[1]
ct.recon.sliceCount = n_slices
ct.recon.sliceThickness = recon_opts.z_cm * 10.0 / n_slices
ct.recon.reconType = "fdk_equiAngle"
ct.recon.kernelType = "standard"
ct.recon.unit = "HU"
ct.recon.mu = μ_water_cm / 10.0 # cm⁻¹ → mm⁻¹
ct.recon.huOffset = -1000
return ct
end;Phantom → CatSim voxelized JSON
create_gammex_472 labels the solid-water body 3, the calcium rods 10–16 and the iodine rods 20–26; the MolloiLab fork defines the matching Gammex472_* materials. CatSim's Phantom_Voxelized reads a JSON header plus one Float32 volume-fraction map per material; for a hard-segmented mask each map is the indicator mask .== label.
4. The Gammex 472 phantom
n_voxels = 128 gives 2.7 mm voxels over 35 cm: coarse enough that CatSim's voxelized projector finishes in minutes, fine enough to resolve the 28 mm rods. Eight slices cover the 4 mm beam.
phantom_cpu = BS.create_gammex_472(
n_voxels = 128,
n_slices = 8,
fov_cm = 35.0,
z_cm = protocol.collimation_mm / 10.0,
);phantom_gpu = BS.Phantom(
to_gpu(phantom_cpu.mask),
phantom_cpu.materials,
phantom_cpu.voxel_size,
phantom_cpu.origin,
phantom_cpu.extent,
);5. The shared water reference
calibrate_bhc_water resolves BasisSimulator's detected spectrum per detector column and returns the per-column correction with its monoenergetic reference (the spectrum's mean energy). That reference water μ is given to CatSim as the target of its own BHC fit and used for the HU conversion of all three reconstructions.
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,
);bhc = BS.calibrate_bhc_water(sim_opts, protocol; scanner, geom = geom_inspect);Water reference: 69.5 keV, water μ = 0.19335 cm⁻¹
Run the three pipelines
Each timing covers the whole job from the phantom to an HU volume: forward projection, preprocessing and BHC, FDK. Both BasisSimulator runs are compiled by one untimed call first, so the timings are steady-state. CatSim's includes writing and reading its intermediate files and fitting its BHC polynomials.
1. CatSim
catsim_result = !HAS_GECATSIM ? nothing : let
work_dir = mktempdir(; prefix = "basissim_catsim_06_")
tag = joinpath(work_dir, "gammex472")
local recon
elapsed = @elapsed begin
json = export_phantom_for_catsim(phantom_cpu, work_dir, "gammex472")
ct = catsim_create_simulation()
catsim_configure_phantom!(ct, json)
catsim_configure_scanner!(ct, scanner, protocol)
catsim_configure_protocol!(ct, scanner, protocol; μ_water_cm = bhc.μ_water_ref)
catsim_configure_recon!(ct, recon_opts; μ_water_cm = bhc.μ_water_ref)
catsim_forward_project(ct; results_name = tag)
recon = catsim_reconstruct_fdk(ct; results_name = tag)
end
rm(work_dir; recursive = true, force = true)
(recon = recon, elapsed = elapsed)
end;2. BasisSimulator, CPU and GPU
One function, two phantoms: phantom_cpu.mask is an Array, so the first run stays on the host; phantom_gpu.mask lives on the GPU, so the second runs there. Nothing else differs.
"""
basissim_pipeline(phantom) -> HU volume
`create_workspace` → `simulate!` → water BHC → FDK → HU, on the phantom's backend.
"""
function basissim_pipeline(phantom)
ws = BS.create_workspace(scanner, protocol, sim_opts, recon_opts, phantom)
BS.simulate!(ws, phantom, protocol, sim_opts; report_dose = false)
sino = BS.apply_bhc_water(ws.sinogram, bhc)
ws_fdk = BS.create_fdk_recon_workspace(sino, ws.geom, recon_opts.matrix_size; filter = :standard)
μ = Array(BS.reconstruct!(ws_fdk, sino, ws.geom))
return Float32.(BS.to_hounsfield(μ; μ_water = bhc.μ_water_ref))
end;basissim_cpu_result = let
basissim_pipeline(phantom_cpu); GC.gc(true) # compile, untimed
elapsed = @elapsed recon = basissim_pipeline(phantom_cpu)
GC.gc(true)
(recon = recon, elapsed = elapsed)
end;basissim_gpu_result = let
basissim_pipeline(phantom_gpu); GC.gc(true) # compile, untimed
elapsed = @elapsed recon = basissim_pipeline(phantom_gpu)
GC.gc(true)
(recon = recon, elapsed = elapsed)
end;Results
Image agreement
The central slice of each reconstruction in one HU window, and the difference of each BasisSimulator image from CatSim's. CatSim writes its image with its own axis convention; it is brought onto BasisSimulator's orientation by the flip or transpose (of the eight in-plane symmetries) that best matches the BasisSimulator image, which the figure reports. The thin rings along the body and rod edges are where the two images differ in edge sharpness; the flat interiors are what the per-rod table below measures.
Per-rod HU
Mean HU inside each rod (the phantom labels resampled onto the reconstruction grid, eroded by one voxel) on the central slice, next to the rod's theoretical monoenergetic HU at the water reference energy. A water-only BHC leaves dense calcium and iodine rods below their theory in both simulators (the rod's own beam hardening beyond the water curve; notebook 01 discusses it). What this table tests is whether the two simulators agree with each other.
| region | voxels | theory (HU) | CatSim (HU) | BasisSim (HU) | BasisSim − CatSim | ÷ theory |
|---|---|---|---|---|---|---|
| solid water body | 38956 | 0.0 | 3.8 | 0.3 | -3.5 | |
| Gammex 472 (50.0 mg/ml Calcium) | 252 | 175.0 | 170.7 | 175.2 | 4.5 | 2.6 % |
| Gammex 472 (100.0 mg/ml Calcium) | 248 | 315.0 | 291.0 | 303.3 | 12.2 | 3.9 % |
| Gammex 472 (200.0 mg/ml Calcium) | 252 | 617.0 | 550.5 | 580.6 | 30.1 | 4.9 % |
| Gammex 472 (300.0 mg/ml Calcium) | 248 | 907.0 | 792.7 | 836.3 | 43.6 | 4.8 % |
| Gammex 472 (400.0 mg/ml Calcium) | 248 | 1197.0 | 1028.2 | 1081.9 | 53.6 | 4.5 % |
| Gammex 472 (500.0 mg/ml Calcium) | 252 | 1487.0 | 1258.7 | 1322.2 | 63.5 | 4.3 % |
| Gammex 472 (600.0 mg/ml Calcium) | 248 | 1792.0 | 1506.9 | 1581.1 | 74.2 | 4.1 % |
| Gammex 472 (2.0 mg/ml Iodine) | 248 | 56.0 | 50.7 | 53.8 | 3.1 | 5.6 % |
| Gammex 472 (2.5 mg/ml Iodine) | 256 | 69.0 | 59.8 | 63.5 | 3.7 | 5.4 % |
| Gammex 472 (5.0 mg/ml Iodine) | 256 | 132.0 | 111.4 | 121.3 | 9.9 | 7.5 % |
| Gammex 472 (7.5 mg/ml Iodine) | 244 | 195.0 | 156.8 | 173.5 | 16.7 | 8.6 % |
| Gammex 472 (10.0 mg/ml Iodine) | 256 | 257.0 | 205.4 | 229.9 | 24.5 | 9.5 % |
| Gammex 472 (15.0 mg/ml Iodine) | 256 | 385.0 | 301.7 | 338.3 | 36.6 | 9.5 % |
| Gammex 472 (20.0 mg/ml Iodine) | 248 | 526.0 | 411.9 | 461.5 | 49.7 | 9.4 % |
Water: BasisSim − CatSim = -3.5 HU. Over the 14 rods the difference is 2.6 to 9.5 % of the rod's theoretical HU. Both simulators read every dense rod below its theory, and they differ mainly in how far: the two use independent source-spectrum models (IPEM tables in BasisSimulator, CatSim's own tungsten spectra) and independently fitted water corrections, and a spectrum difference would show exactly there, in the beam-hardening residual that grows with rod density. BasisSimulator CPU vs GPU: largest voxel difference 0.00922 HU over the whole volume (the same code on two backends; floating-point summation order is all that differs).
Runtime
Wall-clock of each end-to-end pipeline, on a log scale so the GPU bar stays visible. The CPU bar is the like-for-like comparison with CatSim (both run on the host); the GPU bar is what BasisSimulator is built for. This page was rendered on an NVIDIA RTX PRO 6000 (CUDA).
| pipeline | wall-clock | speed-up vs CatSim |
|---|---|---|
| CatSim (voxelized projector, Python + C) | 48.57 s | 1.0× (reference) |
| BasisSimulator.jl, CPU (16 Julia threads) | 7.57 s | 6.4× |
| BasisSimulator.jl, CuArray | 0.76 s | 64.3× |
Why BasisSimulator is faster
No disk round trip. CatSim writes
.air,.offset,.scanand.prepfiles and reads the.prepback for FDK, then writes the image volume. BasisSimulator keeps the sinogram on the device from forward projection through FBP.One volume walk for the whole spectrum. The default
:dd_fastprojector accumulates the path length through each material once and weights all energies from it, instead of tracing every energy separately.The same kernels on any backend. CatSim's voxelized projector is C on the host. BasisSimulator's kernels are written once with AcceleratedKernels.jl and run on CUDA, Metal, ROCm, oneAPI or the CPU, as the CPU and GPU runs above show.
The phantom here is deliberately small so that CatSim finishes in minutes. For a larger phantom or a clinical-size detector, scale n_voxels and the scanner; the wrapper forwards any EICTScanner / CTProtocol pair, with the bowtie restriction noted in catsim_configure_protocol!.