← Examples/11 · Helical Scanning
View source on GitHub

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:

collimationrotationstable
volume axial160 mm (full detector)2 × axialsteps 16 cm between stations
helical20 mm (1/8th!)16 turnsglides 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__, ".."))
end
using Statistics: mean, std
using PlutoUI
import BasisSimulator as BS
# import CairoMakie as Mke
import WasmMakie as Mke
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

1. 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;
Dict{Symbol, Any}(:msg => "Full-FOV axial support requires 356 detector rows, but the\nscanner has only 256. Reduce fov_cm/z_cm or\nuse a scanner with sufficient physical row coverage.\n", :stacktrace => Dict{Symbol, Any}[Dict(:call_short => "error(s::String)", :inlined => false, :url => "https://github.com/JuliaLang/julia/tree/15346901f0039751c5488744f1f62de7d87510a8/base/error.jl#L44", :path => "./error.jl", :source_package => "Main", :call => "error(s::String)", :linfo_type => "Core.MethodInstance", :line => 44, :file => "error.jl", :func => "error", :parent_module => "Base", :from_c => false), Dict(:call_short => "BasisSimulator.CTGeometry(scanner::BasisSimulator.Scanner{…}; n_angles::Int64, fov_cm::Float64, z_cm::Float64, n_rows::Nothing, n_cols::Nothing, collimation_mm::Float64, extended_collimation::Bool, pitch::Nothing, n_rotations::Float64)", :inlined => false, :url => nothing, :path => "/Users/daleblack/Documents/dev/MolloiLab/BasisSimulator.jl/src/geometry/scanner.jl", :source_package => nothing, :call => "BasisSimulator.CTGeometry(scanner::BasisSimulator.Scanner{Float64}; n_angles::Int64, fov_cm::Float64, z_cm::Float64, n_rows::Nothing, n_cols::Nothing, collimation_mm::Float64, extended_collimation::Bool, pitch::Nothing, n_rotations::Float64)", :linfo_type => "Core.CodeInstance", :line => 642, :file => "scanner.jl", :func => "#CTGeometry#19", :parent_module => nothing, :from_c => false), Dict(:call_short => "create_eict_workspace(scanner::BasisSimulator.Scanner{…}, protocol::BasisSimulator.CTProtocol, sim_opts::BasisSimulator.SimOptions, recon_opts::BasisSimulator.ReconOptions, phantom::BasisSimulator.Phantom{…}; T::Type{…}, spectrum_override::Nothing, extended_collimation::Bool)", :inlined => false, :url => nothing, :path => "/Users/daleblack/Documents/dev/MolloiLab/BasisSimulator.jl/src/api/workspace.jl", :source_package => nothing, :call => "create_eict_workspace(scanner::BasisSimulator.Scanner{Float64}, protocol::BasisSimulator.CTProtocol, sim_opts::BasisSimulator.SimOptions, recon_opts::BasisSimulator.ReconOptions, phantom::BasisSimulator.Phantom{UInt8, Metal.MtlArray{UInt8, 3, Metal.PrivateStorage}, Vector{XrayAttenuation.Material}}; T::Type{Float32}, spectrum_override::Nothing, extended_collimation::Bool)", :linfo_type => "Core.CodeInstance", :line => 529, :file => "workspace.jl", :func => "#create_eict_workspace#404", :parent_module => nothing, :from_c => false), Dict(:call_short => "create_eict_workspace(scanner::BasisSimulator.Scanner{…}, protocol::BasisSimulator.CTProtocol, sim_opts::BasisSimulator.SimOptions, recon_opts::BasisSimulator.ReconOptions, phantom::BasisSimulator.Phantom{…})", :inlined => false, :url => nothing, :path => "/Users/daleblack/Documents/dev/MolloiLab/BasisSimulator.jl/src/api/workspace.jl", :source_package => nothing, :call => "create_eict_workspace(scanner::BasisSimulator.Scanner{Float64}, protocol::BasisSimulator.CTProtocol, sim_opts::BasisSimulator.SimOptions, recon_opts::BasisSimulator.ReconOptions, phantom::BasisSimulator.Phantom{UInt8, Metal.MtlArray{UInt8, 3, Metal.PrivateStorage}, Vector{XrayAttenuation.Material}})", :linfo_type => "Core.CodeInstance", :line => 519, :file => "workspace.jl", :func => "create_eict_workspace", :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/11_helical_scanning.jl#==#11000006-0000-4000-8000-000000000003", :source_package => nothing, :call => "macro expansion", :linfo_type => "Nothing", :line => 16, :file => "11_helical_scanning.jl#==#11000006-0000-4000-8000-000000000003", :func => "macro expansion", :parent_module => nothing, :from_c => false)], :plain_error => "Full-FOV axial support requires 356 detector rows, but the\nscanner has only 256. Reduce fov_cm/z_cm or\nuse a scanner with sufficient physical row coverage.\n\nStacktrace:\n [1] error(s::String)\n @ Base ./error.jl:44\n [2] BasisS
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$.

75
Dict{Symbol, Any}(:msg => "UndefVarError: `Label` not defined in `WasmMakie`\nSuggestion: check for spelling errors or missing imports.", :stacktrace => Dict{Symbol, Any}[Dict(:call_short => "getproperty(x::Module, f::Symbol)", :inlined => false, :url => "https://github.com/JuliaLang/julia/tree/15346901f0039751c5488744f1f62de7d87510a8/base/Base_compiler.jl#L47", :path => "./Base_compiler.jl", :source_package => "Main", :call => "getproperty(x::Module, f::Symbol)", :linfo_type => "Core.MethodInstance", :line => 47, :file => "Base_compiler.jl", :func => "getproperty", :parent_module => "Base", :from_c => false), Dict(:call_short => "top-level scope", :inlined => false, :url => nothing, :path => "/Users/daleblack/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/11_helical_scanning.jl#==#11000007-0000-4000-8000-000000000003", :source_package => nothing, :call => "top-level scope", :linfo_type => "Core.CodeInfo", :line => 9, :file => "11_helical_scanning.jl#==#11000007-0000-4000-8000-000000000003", :func => "top-level scope", :parent_module => nothing, :from_c => false)], :plain_error => "UndefVarError: `Label` not defined in `WasmMakie`\nSuggestion: check for spelling errors or missing imports.\nStacktrace:\n [1] getproperty(x::Module, f::Symbol)\n @ Base ./Base_compiler.jl:47\n [2] top-level scope\n @ ~/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/11_helical_scanning.jl#==#11000007-0000-4000-8000-000000000003:9")

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.

Dict{Symbol, Any}(:msg => "UndefVarError: `sns_result` not defined in `Main.var\"workspace#3\"`\nSuggestion: check for spelling errors or missing imports.", :stacktrace => Dict{Symbol, Any}[Dict(:call_short => "(::var\"#8#9\"{…})(k::Int64)", :inlined => false, :url => nothing, :path => "./none", :source_package => nothing, :call => "(::var\"#8#9\"{UnitRange{Int64}, UnitRange{Int64}})(k::Int64)", :linfo_type => "Core.CodeInstance", :line => -1, :file => "none", :func => "#8", :parent_module => nothing, :from_c => false), Dict(:call_short => "iterate", :inlined => true, :url => nothing, :path => "./generator.jl", :source_package => nothing, :call => "iterate", :linfo_type => "Nothing", :line => 48, :file => "generator.jl", :func => "iterate", :parent_module => nothing, :from_c => false), Dict(:call_short => "collect(itr::Base.Generator{…})", :inlined => false, :url => nothing, :path => "./array.jl", :source_package => nothing, :call => "collect(itr::Base.Generator{UnitRange{Int64}, var\"#8#9\"{UnitRange{Int64}, UnitRange{Int64}}})", :linfo_type => "Core.CodeInstance", :line => 790, :file => "array.jl", :func => "collect", :parent_module => nothing, :from_c => false), Dict(:call_short => "top-level scope", :inlined => false, :url => nothing, :path => "/Users/daleblack/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/11_helical_scanning.jl#==#11000008-0000-4000-8000-000000000002", :source_package => nothing, :call => "top-level scope", :linfo_type => "Core.CodeInfo", :line => 5, :file => "11_helical_scanning.jl#==#11000008-0000-4000-8000-000000000002", :func => "top-level scope", :parent_module => nothing, :from_c => false)], :plain_error => "UndefVarError: `sns_result` not defined in `Main.var\"workspace#3\"`\nSuggestion: check for spelling errors or missing imports.\nStacktrace:\n [1] (::Main.var\"workspace#3\".var\"#8#9\"{UnitRange{Int64}, UnitRange{Int64}})(k::Int64)\n @ Main.var\"workspace#3\" ./none:-1\n [2] iterate\n @ ./generator.jl:48 [inlined]\n [3] collect(itr::Base.Generator{UnitRange{Int64}, Main.var\"workspace#3\".var\"#8#9\"{UnitRange{Int64}, UnitRange{Int64}}})\n @ Base ./array.jl:790\n [4] top-level scope\n @ ~/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/11_helical_scanning.jl#==#11000008-0000-4000-8000-000000000002:5")
Dict{Symbol, Any}(:msg => "MethodError: no method matching WasmMakie.Axis(; title::String, titlesize::Int64)\nThis method does not support all of the given keyword arguments (and may not support any).\n\n\e[0mClosest candidates are:\n\e[0m WasmMakie.Axis(\e[91m::Int64\e[39m, \e[91m::Int64\e[39m, \e[91m::Int64\e[39m, \e[91m::Int64\e[39m, \e[91m::String\e[39m, \e[91m::String\e[39m, \e[91m::String\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::String\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Int64\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Int64\e[39m, \e[91m::Int64\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Bool\e[39m, \e[91m::Float64\e[39m, \e[91m::Float64\e[39m, \e[91m::Bool\e[39m, \e[91m::Int64\e[39m, \e[91m::Int64\e[39m, \e[91m::Int64\e[39m, \e[91m::Vector{WasmMakie.LinesPlot}\e[39m, \e[91m::Vector{WasmMakie.ScatterPlot}\e[39m, \e[91m::Vector{WasmMakie.BarPlotData}\e[39m, \e[91m::Vector{WasmMakie.HeatmapPlot}\e[39m, \e[91m::Vector{WasmMakie.ImagePlot}\e[39m, \e[91m::Vector{WasmMakie.HVLines}\e[39m, \e[91m::Vector{WasmMakie.HVSpan}\e[39m, \e[91m::Vector{WasmMakie.ABLines}\e[39m, \e[91m::Vector{WasmMakie.SegmentsPlot}\e[39m, \e[91m::Vector{WasmMakie.FilledCurve}\e[39m, \e[91m::Vector{WasmMakie.BandPlot}\e[39m, \e[91m::Vector{WasmMakie.PolyPlot}\e[39m, \e[91m::Vector{WasmMakie.MeshPlot}\e[39m, \e[91m::Vector{Tuple{Int64, Int64}}\e[39m)\e[91m got unsupported keyword arguments \"title\", \"titlesize\"\e[39m\n\e[0m\e[90m @\e[39m \e[32mWasmMakie\e[39m \e[90m~/.julia/packages/WasmMakie/PSjmW/src/core/\e[39m\e[90m\e[4mfigure.jl:14\e[24m\e[39m\n\e[0m WasmMakie.Axis(\e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m, \e[91m::Any\e[39m)\e[91m got unsupported keyword arguments \"title\", \"titlesize\"\e[39m\n\e[0m\e[90m @\e[39m \e[32mWasmMakie\e[39m \e[90m~/.julia/packages/WasmMakie/PSjmW/src/core/\e[39m\e[90m\e[4mfigure.jl:14\e[24m\e[39m\n\e[0m WasmMakie.Axis(; title, xlabel, ylabel, subtitle, titlealign)\e[91m got unsupported keyword argument \"titlesize\"\e[39m\n\e[0m\e[90m @\e[39m \e[32mWasmMakie\e[39m \e[90m~/.julia/packages/WasmMakie/PSjmW/src/core/\e[39m\e[90m\e[4mfigure.jl:84\e[24m\e[39m\n\e[0m ...\n", :stacktrace => Dict{Symbol, Any}[Dict(:call_short => "kwerr(kw::@NamedTuple{…}, args::Type)", :inlined => false, :url => nothing, :path => "./error.jl", :source_package => nothing, :call => "kwerr(kw::@NamedTuple{title::String, titlesize::Int64}, args::Type)", :linfo_type => "Core.CodeInstance", :line => 175, :file => "error.jl", :func => "kwerr"
Dict{Symbol, Any}(:msg => "UndefVarError: `sns_result` not defined in `Main.var\"workspace#3\"`\nSuggestion: check for spelling errors or missing imports.", :stacktrace => Dict{Symbol, Any}[Dict(:call_short => "top-level scope", :inlined => false, :url => nothing, :path => "/Users/daleblack/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/11_helical_scanning.jl#==#11000009-0000-4000-8000-000000000001", :source_package => nothing, :call => "top-level scope", :linfo_type => "Core.CodeInfo", :line => 1, :file => "11_helical_scanning.jl#==#11000009-0000-4000-8000-000000000001", :func => "top-level scope", :parent_module => nothing, :from_c => false)], :plain_error => "UndefVarError: `sns_result` not defined in `Main.var\"workspace#3\"`\nSuggestion: check for spelling errors or missing imports.\nStacktrace:\n [1] top-level scope\n @ ~/Documents/dev/MolloiLab/BasisSimulator.jl/docs/notebooks/11_helical_scanning.jl#==#11000009-0000-4000-8000-000000000001:1")

Summary

  • pitch and n_rotations are the only additions needed to turn the axial acquisition geometry into a centered helical trajectory.

  • :dd_fast consumes 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.