11 · Helical Scanning · Narrow Collimation, Long Coverage
One new kwarg — pitch — turns any protocol into a spiral scan.
Helical (spiral) CT solves a geometry problem no detector can: covering a long $z$ range with a narrow beam. Before spiral CT, long coverage meant step-and-shoot: scan a slab, move the table, scan the next slab. Each station is a clean axial scan — but every station has cone-beam edges, and the stitched volume carries a seam at every station boundary. A helical scan instead sweeps the collimator past every slice continuously: each $z$ position is, at some moment of the spiral, at the centre of the beam.
This notebook covers a 32 cm z-slab both ways at matched exposure, on a wide-cone 256 × 0.625 mm (16 cm) volume scanner:
| collimation | rotations | table | |
|---|---|---|---|
| volume axial | 160 mm (full detector) | 2 × axial | steps 16 cm between stations |
| helical | 20 mm (1/8th!) | 16 turns | glides at pitch 1.0 |
The forward projection is the anti-aliased :dd_fast projector — helical costs it nothing (the projectors consume per-view source/detector arrays; a helix is just a z-ramp in those arrays). Helical reconstruction is rebinned WFBP (Stierstorfer et al. 2004 — the production spiral algorithm family), dispatched automatically whenever the geometry is helical. Both pipelines use the current notebook-01 correction stack: detected-spectrum water BHC → recon → HU. Quantum and DAS noise are already generated in the counts domain by simulate!; radial cupping is measured as QA rather than applied as a correction.
Backend detected: MtlArray
Notebook Setup
Activate the shared docs environment, select Metal/CUDA/ROCm/CPU through GPUSelect, and expose the section hierarchy through Pluto's table of contents. No acquisition is executed until section 4.
begin
import Pkg
Pkg.activate(joinpath(@__DIR__, ".."))
endusing Statistics: mean, stdusing PlutoUIimport BasisSimulator as BS# import CairoMakie as Mke
import WasmMakie as Mkebegin
import GPUSelect
AT = GPUSelect.Storage() # the backend array type, directly: MtlArray / CuArray / ROCArray
to_gpu(x) = AT(x)
GPU_BACKEND = (name = string(nameof(AT)),)
end1. A phantom that changes along $z$ — with no strong hardeners
Two design rules. First, a uniform cylinder would look identical at every slice — useless for judging long-$z$ fidelity — so every slice must be different. Second, no high-Z inserts: beam hardening is its own topic (notebook 10); here it would only confound the geometry comparison.
30 cm water body, 36 cm long (coarse 2.3 mm in-plane / 2 mm z voxels);
a lung-density rod (≈ −700 HU, radiologically soft) that winds helically around the body axis — one turn per 12 cm of $z$, so its angular position tags every slice;
an adipose cone (≈ −90 HU) on the axis, radius tapering 4 cm → 0 across the slab — its diameter tags every slice.
phantom_data = let
n = 128 # in-plane grid
nz = 180 # 2 mm z-voxels → 36 cm slab
fov = 30.0 # cm, in-plane phantom extent
zext = 36.0 # cm, phantom z extent
vox = fov / n
voxz = zext / nz
mask = zeros(UInt8, n, n, nz)
c = (n + 1) / 2
r_body = 12.0 / vox # 24 cm water body
for k in 1:nz, j in 1:n, i in 1:n
if (i - c)^2 + (j - c)^2 <= r_body^2
mask[i, j, k] = 1
end
end
# helically winding lung-density rod: r = 7 cm, one turn per 12 cm of z
for k in 1:nz
θ = 2π * (k * voxz) / 12.0
cx = c + (7.0 / vox) * cos(θ)
cy = c + (7.0 / vox) * sin(θ)
r = 1.0 / vox # 2 cm diameter rod
for j in 1:n, i in 1:n
if (i - cx)^2 + (j - cy)^2 <= r^2 && mask[i, j, k] == 1
mask[i, j, k] = 2
end
end
end
# central adipose cone: radius tapers 4 cm → 0 across the slab
for k in 1:nz
r = (4.0 * (1 - (k - 1) / (nz - 1))) / vox
for j in 1:n, i in 1:n
if (i - c)^2 + (j - c)^2 <= r^2 && mask[i, j, k] == 1
mask[i, j, k] = 3
end
end
end
(mask = mask, vox = vox, voxz = voxz, n = n, nz = nz, zext = zext)
end;phantom_materials = Dict(
1 => BS.XA.Materials.water,
2 => BS.XA.Materials.lung,
3 => BS.XA.Materials.adipose,
);phantom = BS.Phantom(
to_gpu(phantom_data.mask),
phantom_materials,
(phantom_data.vox, phantom_data.vox, phantom_data.voxz),
);2. The helical scan: pitch is the whole API
IEC pitch = table feed per rotation ÷ active collimation. With 20 mm collimation (32 of the scanner's 64 × 0.625 mm rows), pitch 1.0, and 16 rotations, the table travels
$$\text{travel} = \text{pitch} \times \text{collimation} \times n_\text{rot} = 1.0 \times 20\,\text{mm} \times 16 = 32\,\text{cm}.$$
Everything else — Phantom, Scanner, SimOptions, ReconOptions, simulate!, the corrections, the recon call — is untouched from an axial workflow.
scanner = BS.Scanner(
source_to_isocenter = 541.0,
source_to_detector = 949.0,
detector_rows = 256, # 256 × 0.625 mm = 16 cm wide-cone volume scanner
detector_cols = 512,
detector_row_size = 0.625,
detector_col_size = 1.0,
);begin
protocol_helical = BS.CTProtocol(
kVp = 120.0, mA = 200.0, views = 360,
rotation_time = 0.5,
collimation_mm = 20.0, # NARROW: half the physical detector
pitch = 1.0, # ← the one helical kwarg
n_rotations = 16, # 16 × 2 cm = 32 cm table travel
)
protocol_axial = BS.CTProtocol(
kVp = 120.0, mA = 200.0, views = 360,
rotation_time = 0.5,
collimation_mm = 160.0, # WIDE: the full 16 cm detector, volume mode
)
# use_heel_effect = false: the anode heel is a per-ROW spectral gradient.
# Axial scans absorb it (each slice keeps its rows); a helical scan sweeps
# the rows past every voxel, which needs the per-row water calibration real
# scanners apply. Until BasisSimulator ships per-row BHC, disable it here
# so the comparison isolates GEOMETRY (same choice as notebook 09).
sim_opts = BS.SimOptions(fidelity = :eict, seed = 42, projector = :dd_fast,
use_heel_effect = false)
recon_opts = BS.ReconOptions(matrix_size = (160, 160, 150), fov_cm = 30.0, z_cm = 30.0)
recon_opts_station = BS.ReconOptions(matrix_size = (160, 160, 80), fov_cm = 30.0, z_cm = 16.0)
end;3. Corrections — the current notebook-01 stack
One BHC model per protocol (the bowtie-hardened spectrum depends on the collimation), then the standard chain per reconstruction: sinogram-domain water BHC → recon → HU using the BHC-calibrated $\mu_\text{water}$. The simulator already produces quantum and electronic/DAS noise in counts; no second HU-domain noise floor is added. Residual cupping is a non-mutating QA measurement, not a correction stage.
"""
corrected_recon(sino_gpu, geom, matrix_size, bhc) -> Array{Float32,3} (HU)
Notebook-01 correction chain around a single reconstruction (helical
geometries dispatch to WFBP inside `reconstruct!` automatically). Noise is
already present in the simulated counts; cupping is measured separately as QA.
"""
function corrected_recon(sino_gpu, geom, matrix_size, bhc)
sino_bhc = BS.apply_bhc_water(sino_gpu, bhc.model)
sino_g = to_gpu(Float32.(sino_bhc))
ws_fdk = BS.create_fdk_recon_workspace(sino_g, geom, matrix_size)
recon_μ = BS.reconstruct!(ws_fdk, sino_g, geom)
# NOTE: no image-domain BHC — audit found it deflates dense-material HU
# (a scaled self-subtraction, not So et al.); the sinogram-domain BHC
# (calibrated on the full detected spectrum) is the whole correction.
return Float32.(BS.to_hounsfield(Array(recon_μ); μ_water = bhc.μ_water))
end;4. Run both acquisitions
Helical: one simulate!, one reconstruction. Volume axial (step-and-shoot): two independent 16 cm wide-cone stations back to back — for each, the table (here: the phantom window) moves so the station is centred at the isocentre — then the two slabs are stitched at $z = 0$.
helical_result = let
ws = BS.create_eict_workspace(scanner, protocol_helical, sim_opts, recon_opts, phantom)
t = @elapsed BS.simulate!(ws, phantom, protocol_helical, sim_opts)
bhc = let
model = BS.calibrate_bhc_water(sim_opts, protocol_helical;
scanner = scanner, geom = ws.geom,
)
(model = model, μ_water = model.μ_water_ref)
end
t += @elapsed (hu = corrected_recon(ws.sinogram, ws.geom, recon_opts.matrix_size, bhc))
ws = nothing
GC.gc()
# quantum + DAS noise arrive in the counts domain from simulate!
# (scanner.electronic_noise); do not add an HU-domain floor on top.
# (cupping is QA-only now — measure_radial_cupping; never applied)
(hu = hu, t = t)
end;sns_result = let
station_zs = [-8.0, 8.0] # 2 stations × 16 cm, back to back
n_slab = 80 # 16 cm at 2 mm recon slices
hu = zeros(Float32, 160, 160, 150)
t_total = 0.0
bhc_ax = nothing
for (s, z0) in enumerate(station_zs)
# move the "table": phantom window (±10 cm) centred on this station
k0 = round(Int, 90 + z0 / phantom_data.voxz)
window = (k0 - 49):(k0 + 50) # 100 slices = 20 cm (covers the cone)
ph_st = BS.Phantom(
to_gpu(phantom_data.mask[:, :, window]),
phantom_materials,
(phantom_data.vox, phantom_data.vox, phantom_data.voxz),
)
ws = BS.create_eict_workspace(scanner, protocol_axial, sim_opts, recon_opts_station, ph_st)
t_total += @elapsed BS.simulate!(ws, ph_st, protocol_axial, sim_opts)
if bhc_ax === nothing
model = BS.calibrate_bhc_water(sim_opts, protocol_axial;
scanner = scanner, geom = ws.geom,
)
bhc_ax = (model = model, μ_water = model.μ_water_ref)
end
t_total += @elapsed (hu_st = corrected_recon(
ws.sinogram, ws.geom, recon_opts_station.matrix_size, bhc_ax))
ws = nothing
GC.gc()
# station slab → global z index: station s covers z0 ± 8 cm. The two
# stations overhang the ±15 cm recon volume by 1 cm (they cover ±16 —
# real volume scans over-range too): clip.
k_lo = round(Int, (z0 - 8.0 + 15.0) / 0.2) + 1
valid = max(k_lo, 1):min(k_lo + n_slab - 1, 150)
hu[:, :, valid] .= hu_st[:, :, (first(valid) - k_lo + 1):(last(valid) - k_lo + 1)]
end
# quantum + DAS noise arrive in the counts domain from simulate!
# (cupping is QA-only now — measure_radial_cupping; never applied)
(hu = hu, t = t_total)
end;5. Slide through $z$: phantom truth vs both scans
Top row: the phantom ground truth as a labelled categorical map. Bottom row: helical (20 mm collimation) and volume axial (the full 16 cm detector, 8× wider). Watch the rod rotate and the cone shrink — and watch what the wide cone does away from each station centre, with the seam at $z = 0$.
Results
01. Water flatness and the coronal view
Mean HU in a fixed water ROI (clear of rod and cone), slice by slice — the helical scan should hold water flat across the full 30 cm, while the step-and-shoot volume shows its station structure. The coronal reformats show the same thing spatially.
Summary
pitchandn_rotationsare the only additions needed to turn the axial acquisition geometry into a centered helical trajectory.:dd_fastconsumes the per-view geometry arrays unchanged and remains the default forward projector.Helical geometries dispatch automatically to rebinned WFBP; axial stations retain the ordinary FDK path.
The slice slider, water-HU profile, and coronal comparison jointly verify longitudinal coverage, station seams, and z-dependent anatomy.
Cached figures and reported wall times will be regenerated only after the source audit across all eleven notebooks is complete.