← Examples/10 · Titanium Implant Artifacts
View source on GitHub

10 · Titanium Implant · Metal Artifacts

A user-defined metal material, and the artifacts it produces.

Metal artifacts in CT come from two physical mechanisms that BasisSimulator.jl models by construction:

  1. Beam hardening — the polychromatic forward model accumulates $I = \sum_E w_E\, e^{-L_E}$ before the negative-log transform, so a high-$Z$, high-density object hardens the transmitted spectrum and the log line integrals stop being proportional to path length. Reconstructed: dark bands, especially between two metal objects.

  2. Photon starvation — behind thick metal only a handful of photons reach the detector, so Poisson noise (plus electronic noise) dominates those rays. Reconstructed: noisy streaks along the metal-crossing directions.

No metal ships in the built-in material registry — and that is the point of this notebook: any implant material can be registered by the user as an elemental composition and density resolved against NIST XCOM, the same mechanism used for the built-in tissues and Gammex rods. Note that BasisSimulator currently ships no metal-artifact-reduction (MAR) algorithm; the bundled beam-hardening correction is a water-based correction.

Backend detected: MtlArray

Define titanium → build water + implant phantom → simulate with :dd_fast
   → uncorrected FDK → soft-tissue / wide-window comparison
   → quantitative dark-band, water, and peak-HU checks

Notebook Setup

This notebook uses the shared docs environment and the same backend-neutral GPUSelect.Storage() pattern as notebooks 01–04. The simulation is intentionally left uncorrected: the goal is to expose the metal artifact mechanisms, not to present a MAR algorithm.

begin
    import Pkg
    Pkg.activate(joinpath(@__DIR__, ".."))
end
import BasisSimulator as BS
# import CairoMakie as Mke
import WasmMakie as Mke
import PlutoUI
using Statistics: mean, std
using Unitful: @u_str
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. Titanium as a custom XA.Material

Titanium is exposed by XrayAttenuation.jl as an Element, not a prebuilt Material, so we construct the material ourselves: pure Ti ($Z = 22$, mass fraction 1.0) at 4.54 g/cm³. ZA_ratio and the mean excitation energy I come from the element data (0.45948, 233 eV).

titanium = BS.XA.Material(
    "Titanium (implant)",
    0.45948,            # ⟨Z/A⟩ for Ti
    233.0u"eV",         # mean excitation energy
    4.54u"g/cm^3",      # bulk density
    Dict(22 => 1.0),    # elemental composition: pure Ti
)

2. Phantom: water cylinder + two titanium rods

A 26 cm water cylinder with two 1.5 cm titanium rods, 4 cm either side of center — two rods so the classic between-rod dark band shows up. The rods give a maximum line integral of ≈ 10, comfortably inside the simulator's dynamic range, and leave only a few detected photons on the rays crossing both rods (starvation streaks).

phantom = let
    n = 128
    nz = 8
    fov = 30.0                      # cm
    vox = fov / n
    mask = zeros(UInt8, n, n, nz)
    c = (n + 1) / 2
    r_water = 13.0 / vox            # 26 cm diameter water cylinder
    r_ti = 0.75 / vox               # 1.5 cm diameter rods
    rod1 = (c - 4.0 / vox, c)
    rod2 = (c + 4.0 / vox, c)
    for k in 1:nz, j in 1:n, i in 1:n
        d2 = (i - c)^2 + (j - c)^2
        if d2 <= r_water^2
            mask[i, j, k] = 1
            if (i - rod1[1])^2 + (j - rod1[2])^2 <= r_ti^2 ||
               (i - rod2[1])^2 + (j - rod2[2])^2 <= r_ti^2
                mask[i, j, k] = 2
            end
        end
    end
    BS.Phantom(
        to_gpu(UInt8.(mask)),
        Dict(1 => BS.XA.Materials.water, 2 => titanium),
        (vox, vox, vox),
    )
end;

3. Scan and reconstruct

Standard 120 kVp / 200 mA axial acquisition on the generic scanner, FDK reconstruction, shown uncorrected so the artifacts are what the physics produced.

scanner = BS.Scanner(
    source_to_isocenter = 540.0,
    source_to_detector = 950.0,
    detector_rows = 16,
    detector_cols = 512,
    detector_row_size = 1.0,
    detector_col_size = 1.0,
    detector_material = :lumex,
    detector_depth = 3.0,
);
protocol = BS.CTProtocol(kVp = 120.0, mA = 200.0, views = 360, rotation_time = 0.5);
sim_opts = BS.SimOptions(fidelity = :eict, seed = 42, projector = :dd_fast);
recon_opts = BS.ReconOptions(matrix_size = (256, 256, 8), fov_cm = 30.0);
hu = let
    ws = BS.create_eict_workspace(scanner, protocol, sim_opts, recon_opts, phantom)
    BS.simulate!(ws, phantom, protocol, sim_opts)

    sino = ws.sinogram
    ws_fdk = BS.create_fdk_recon_workspace(sino, ws.geom, recon_opts.matrix_size)
    recon_μ = BS.reconstruct!(ws_fdk, sino, ws.geom)

    μ_water = BS.compute_polychromatic_μ_water(
        sim_opts, protocol;
        scanner = scanner, geom = ws.geom, water_path_cm = 26.0,
    )
    result = Float32.(BS.to_hounsfield(Array(recon_μ); μ_water = μ_water))

    ws = nothing
    ws_fdk = nothing
    recon_μ = nothing
    GC.gc(true)

    result
end;

Results

01. The artifacts

Left: soft-tissue window — the between-rod beam-hardening dark band and the radiating photon-starvation streaks. Right: wide window — the rods themselves (titanium reconstructs at several thousand HU).

Dict{Symbol, Any}(:msg => "MethodError: no method matching WasmMakie.Axis(; title::String, aspect::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\", \"aspect\"\e[39m\n\e[0m\e[90m @\e[39m \e[35mWasmMakie\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\", \"aspect\"\e[39m\n\e[0m\e[90m @\e[39m \e[35mWasmMakie\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 \"aspect\"\e[39m\n\e[0m\e[90m @\e[39m \e[35mWasmMakie\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, aspect::Int64}, args::Type)", :linfo_type => "Core.CodeInstance", :line => 175, :file => "error.jl", :func => "kwerr", :parent_modul
fig_artifacts = let
    sl = hu[:, :, 4]
    fig = Mke.Figure(size = (900, 460))
    ax1 = Mke.Axis(fig[1, 1], title = "Soft-tissue window [-500, 500] HU", aspect = 1)
    Mke.heatmap!(ax1, sl; colorrange = (-500, 500), colormap = :grays)
    Mke.hidedecorations!(ax1)
    ax2 = Mke.Axis(fig[1, 2], title = "Wide window [-1000, 4000] HU", aspect = 1)
    Mke.heatmap!(ax2, sl; colorrange = (-1000, 4000), colormap = :grays)
    Mke.hidedecorations!(ax2)
    Mke.save(joinpath(@__DIR__, "..", "assets", "titanium_artifacts.png"), fig)
    fig
end
MeasurementHU
Between-rod dark band (mean)-668
Water ROI away from rods (mean)66
Titanium rod (peak)9661

The band between the rods drops hundreds of HU below water — the classic beam-hardening signature — while starvation streaks radiate along the rod-crossing directions.

Verification and Scope

  • The bundled sinogram/image-domain beam-hardening correction is a water polynomial; it is not designed for metal and will not remove these bands. A dedicated MAR method (e.g. sinogram inpainting over the metal trace) is a natural extension on the shared reconstruction stack, but no MAR algorithm ships with this release.

  • Any other implant material works the same way: stainless steel, CoCr, or amalgam are one XA.Material(...) constructor call each, with NIST XCOM providing the cross-sections.

  • On CPU this notebook takes a few minutes (the polychromatic spectral path is GPU-oriented); with Metal/CUDA/ROCm available it runs in seconds.

Summary

  • A user-defined XA.Material enters the same attenuation pipeline as every built-in tissue.

  • :dd_fast remains the default projector and retains the anti-aliased DD footprint in the severe beam-hardening region around titanium.

  • The uncorrected reconstruction intentionally demonstrates the between-rod dark band and photon-starvation streaks.

  • The notebook makes no MAR claim; its quantitative table is the regression target to preserve when a future MAR method is added.