00 · Cardiac Pipeline

Cardiac Pipeline

From the raw XCAT heart to a perfused CT phantom, and the same heart with a 90% LAD stenosis.

StageWhat happens
1. LoadXCAT's 0.2 mm label volume and NURBS surfaces
2. Preparethe imaged coronary trees and the myocardium to grow into
3. Grow~1 million arterial terminals and a venous tree
4. Flowblood flow from pressures alone, at rest and adenosine stress
5. Transporta clinical contrast injection through the whole body
6. Compartmentsthe regions ground truth is given for, and their flow
7. Voxelize and Savethe 0.2 mm perfused phantom
8. Inspectthe saved phantom: labels, compartments, idealized images, ground truth
9. Stenosisa 90% proximal LAD lesion with plaque, as its own phantom
10. Next Stepsscanning the phantom with BasisSimulator.jl

Every heavy step is cached in CACHE_DIR, so a rerun takes seconds. The XCAT phantom is licensed: its files and anything made from them stay on lab storage.

Setup

begin
    import Pkg
    Pkg.activate(joinpath(@__DIR__, ".."))

    # a writable font cache for CairoMakie (a notebook worker may have no HOME)
    ENV["XDG_CACHE_HOME"] = get(ENV, "XDG_CACHE_HOME", joinpath(homedir(), ".cache"))
end;
import Vasculature as V
import CairoMakie as Mke
import GPUSelect
using PlutoUI: Slider, Select, TableOfContents
using Printf: @sprintf
using Serialization: serialize, deserialize
using Mmap: mmap
using Statistics: median
begin
    # licensed: these files and anything made from them stay on lab storage
    const XCAT_DIR = get(
        ENV,
        "XCAT_DIR",
        joinpath(
            homedir(),
            "Documents",
            "dev",
            "MolloiLab",
            "xcat-data",
            "vmale_50_heart_1600_1400_867",
        ),
    )
    # the full chest, arms included, at the same 0.2 mm: XCAT's heart block inside it and its surface meshes voxelized
    # around it (built once by xcat-data/work/phantom/build_chest.jl), so a scanner sees the whole body
    const XCAT_CHEST_DIR = get(
        ENV,
        "XCAT_CHEST_DIR",
        joinpath(
            homedir(),
            "Documents",
            "dev",
            "MolloiLab",
            "xcat-data",
            "work",
            "phantom",
        ),
    )
    const CACHE_DIR = joinpath(homedir(), "xcat-perfused", "cardiac_pipeline_cache")
    const PHANTOM_DIR = joinpath(homedir(), "xcat-perfused", "cardiac_pipeline")

    mkpath(CACHE_DIR)
    mkpath(PHANTOM_DIR)
end;
DEVICE

Arrays to this machine's GPU (CUDA, Metal, ROCm, oneAPI), or the CPU if there is none.

"Arrays to this machine's GPU (CUDA, Metal, ROCm, oneAPI), or the CPU if there is none."
const DEVICE = GPUSelect.Storage()
cached

Run compute once and keep its result in CACHE_DIR/name.jls; afterwards load it.

"Run `compute` once and keep its result in `CACHE_DIR/name.jls`; afterwards load it."
function cached(compute, name)
    path = joinpath(CACHE_DIR, name * ".jls")
    isfile(path) && return deserialize(path)

    seconds = @elapsed result = compute()
    serialize(path, result)
    @info "$name: $(round(seconds; digits = 1)) s"
    return result
end

1. Load the Raw XCAT Chest

XCAT vmale_50's whole chest, arms included: one UInt8 label per 0.2 mm voxel (XCAT's heart block inside it), and the NURBS surfaces of every heart structure, the coronary arteries and veins among them. The phantom is saved on this full chest so a CT scan sees the whole body.

begin
    # the chest grid: origin (mm, the centre of voxel (1, 1, 1)), voxels, voxel size (mm)
    const CHEST_GRID = readlines(joinpath(XCAT_CHEST_DIR, "chest_grid.txt"))
    const XCAT_ORIGIN = Tuple(parse.(Float64, split(CHEST_GRID[1])))
    const XCAT_DIMS = Tuple(parse.(Int, split(CHEST_GRID[2])))
    const XCAT_VOXEL_MM = parse(Float64, CHEST_GRID[3])

    xcat_labels = mmap(
        open(joinpath(XCAT_CHEST_DIR, "chest_labels_0p2.u8")),
        Array{UInt8, 3},
        XCAT_DIMS,
    )
    xcat_surfaces = V.XCAT.read_nrb(joinpath(XCAT_DIR, "vmale_50_heart.nrb"))
end;
begin
    "XCAT label groups for pictures: (name, colour, XCAT labels)."
    const LABEL_GROUPS = [
        ("myocardium", :darkorange, (15, 16)),
        ("chamber blood", :mediumpurple, (19, 20, 21, 22)),
        ("great arteries", :firebrick, (25, 28)),
        ("great veins", :royalblue, (24,)),
        ("coronary arteries", :red, (26,)),
        ("coronary veins", :deepskyblue, (27,)),
        ("lung", :gray25, (6,)),
        ("fat", :khaki, (1,)),
        ("muscle", :rosybrown, (4,)),
        ("bone", :ivory, (5, 8, 9)),
        ("other", :gray60, ()),
    ]

    "The picture group of every XCAT label (0 = air)."
    const GROUP_OF_LABEL = let group = fill(UInt8(length(LABEL_GROUPS)), 256)
        group[1] = 0
        for (k, (_, _, labels)) in enumerate(LABEL_GROUPS), label in labels
            group[label + 1] = k
        end
        group
    end

    label_group_colormap() = Mke.cgrad(
        [:black; [colour for (_, colour, _) in LABEL_GROUPS]],
        length(LABEL_GROUPS) + 1;
        categorical = true,
    )
    xcat_slice(z) = clamp(
        round(Int, (z - XCAT_ORIGIN[3]) / XCAT_VOXEL_MM) + 1,
        1,
        XCAT_DIMS[3],
    )
    xcat_x_mm = XCAT_ORIGIN[1] .+ (0:(XCAT_DIMS[1] - 1)) .* XCAT_VOXEL_MM
    xcat_y_mm = XCAT_ORIGIN[2] .+ (0:(XCAT_DIMS[2] - 1)) .* XCAT_VOXEL_MM
end;

z (mm): 255.1

begin
    # shades for vessels by radius, pale for the thinnest shown and deep for the largest:
    # arteries red, veins blue
    ARTERY_COLOURS = Mke.cgrad(["#f4a582", "#d6604d", "#b2182b", "#67001f"])
    VEIN_COLOURS = Mke.cgrad(["#92c5de", "#4393c3", "#2166ac", "#053061"])
end;
vessel_axis

A 3-D axis for the heart in true proportions, seen from the front: head up, the patient's left on the right. A gentle perspective, no box or grid.

"""
A 3-D axis for the heart in true proportions, seen from the front: head up, the patient's left on the right. A gentle
perspective, no box or grid.
"""
function vessel_axis(position; title)
    ax = Mke.Axis3(
        position;
        title = title,
        aspect = :data,
        azimuth = 1.5π,
        elevation = 0.08π,
        perspectiveness = 0.3,
        viewmode = :fit,
        protrusions = 0,
    )
    Mke.hidedecorations!(ax)
    Mke.hidespines!(ax)
    return ax
end

2. Prepare the Growth Inputs

Growth runs on a 1 mm grid around the pericardium, into the LV and RV myocardium.

  • Inlet trees. The imaged arteries become two trees (LCA, RCA), each with a penetrating stub about every 2.8 mm where it runs beside the wall, so growth starts along the whole vessel. The coronary veins become one tree draining into the coronary sinus.

  • Pass-through voxels. The imaged lumens and short bridges to the wall: vessels may cross them, terminals may not.

  • Blockers. Blood pools at 0.4 mm that no new vessel may cross.

growth_grid = let (points, _) = V.XCAT.tessellate(
        xcat_surfaces["dias_pericardium"];
        nu = 60,
        nv = 60,
    )
    lower = floor.(Int, vec(minimum(points; dims = 2))) .- 2
    upper = ceil.(Int, vec(maximum(points; dims = 2))) .+ 2
    V.VoxelGrid(Tuple(Float64.(lower)), 1.0, Tuple(upper .- lower .+ 1))
end
heart_masks = cached("masks") do
    pericardium = V.voxelize(
        V.XCAT.tessellate(
            xcat_surfaces["dias_pericardium"];
            nu = 160,
            nv = 160,
        )...,
        growth_grid,
    )
    V.XCAT.label_masks(
        xcat_labels,
        XCAT_ORIGIN,
        XCAT_VOXEL_MM,
        growth_grid;
        roi = pericardium,
    )
end;
begin
    myocardium = heart_masks.lvmyo .| heart_masks.rvmyo
    forbid = (heart_masks.pool .& .!myocardium) .| heart_masks.lvcav .| heart_masks.rvcav
end;
"""
The myocardium's surface: each surface voxel's centre (mm) and its number among the myocardial voxels, so per-voxel
values (such as MBF) can be painted on it.
"""
MYOCARDIUM_SURFACE = let
    neighbours = (
        CartesianIndex(1, 0, 0),
        CartesianIndex(-1, 0, 0),
        CartesianIndex(0, 1, 0),
        CartesianIndex(0, -1, 0),
        CartesianIndex(0, 0, 1),
        CartesianIndex(0, 0, -1),
    )
    on_surface(I) = any(
        !checkbounds(Bool, myocardium, I + d) || !myocardium[I + d] for d in neighbours
    )
    voxels = findall(myocardium)
    surface = [n for (n, I) in enumerate(voxels) if on_surface(I)]
    (
        points = [Mke.Point3f(Tuple(voxels[n]) .- 1 .+ growth_grid.origin) for n in surface],
        voxels = surface,
    )
end;
draw_myocardium_shell!

Draw every other point of the myocardium's surface as a faint shell around the vessels.

"Draw every other point of the myocardium's surface as a faint shell around the vessels."
function draw_myocardium_shell!(ax)
    return Mke.scatter!(
        ax,
        MYOCARDIUM_SURFACE.points[1:2:end];
        color = (:gray50, 0.07),
        markersize = 5,
        strokewidth = 0,
    )
end
draw_vessels!

Draw a grown tree's segments of radius ≥ min_radius (mm): width and shade grow with radius, and the largest vessels are drawn last so they sit on top.

"""
Draw a grown tree's segments of radius ≥ `min_radius` (mm): width and shade grow with radius, and the largest vessels
are drawn last so they sit on top.
"""
function draw_vessels!(ax, tree; colormap, min_radius)
    shown = filter(j -> tree.r[j] >= min_radius, sortperm(tree.r))
    segments = [
        Mke.Point3f(tree.a[:, j]) => Mke.Point3f(tree.b[:, j])
            for j in shown
    ]
    radius = tree.r[shown]
    # CairoMakie wants a colour and a width at each end of every segment
    return Mke.linesegments!(
        ax,
        segments;
        color = repeat(log10.(radius); inner = 2),
        colormap = colormap,
        colorrange = (log10(min_radius), log10(maximum(tree.r))),
        linewidth = repeat(1.5 .* sqrt.(radius ./ min_radius); inner = 2),
    )
end
"Blood (chambers and great vessels) at 0.4 mm over the growth grid's box, as `(mask, origin_mm, cell_mm)`: the blockers."
BLOOD_BLOCKERS = cached("blood") do
    first_voxel = [
        round(Int, (growth_grid.origin[d] - 0.5 - XCAT_ORIGIN[d]) / XCAT_VOXEL_MM) + 1
            for d in 1:3
    ]
    cells = [ceil(Int, (growth_grid.dims[d] + 1) / 0.4) for d in 1:3]
    is_blood = falses(256)
    for label in (19, 20, 21, 22, 24, 25, 28)
        is_blood[label + 1] = true
    end

    blood = zeros(UInt8, cells...)
    Threads.@threads for c in 1:cells[3]
        for b in 1:cells[2], a in 1:cells[1], dk in 0:1, dj in 0:1, di in 0:1
            i = first_voxel[1] + 2(a - 1) + di
            j = first_voxel[2] + 2(b - 1) + dj
            k = first_voxel[3] + 2(c - 1) + dk
            (
                1 <= i <= XCAT_DIMS[1] &&
                    1 <= j <= XCAT_DIMS[2] &&
                    1 <= k <= XCAT_DIMS[3]
            ) || continue
            is_blood[xcat_labels[i, j, k] + 1] && (blood[a, b, c] = 0x01)
        end
    end
    origin = ntuple(
        d -> XCAT_ORIGIN[d] + XCAT_VOXEL_MM * (first_voxel[d] - 1) + XCAT_VOXEL_MM / 2,
        3,
    )
    (blood, origin, 0.4)
end;
artery_inputs = cached("artery_inputs") do
    names = ["dias_lad1", "dias_lad2", "dias_lad3", "dias_lcx", "dias_rca1", "dias_rca2"]
    trees = V.XCAT.base_forest(
        [V.XCAT.centerline(xcat_surfaces[name]; step = 2.8) for name in names];
        roots = ["dias_lad1", "dias_rca1"],
    )
    stubbed = [
        V.XCAT.penetrating_stubs(
            tree,
            growth_grid,
            [heart_masks.lvmyo, heart_masks.rvmyo];
            domain = myocardium,
            forbid,
            blockers = BLOOD_BLOCKERS,
        )
            for tree in trees
    ]
    lumens = [
        V.XCAT.vessel_tube(tree, growth_grid) .|
            V.XCAT.vessel_bridges(tree, myocardium, growth_grid)
            for tree in trees
    ]
    (
        trees = first.(stubbed),
        stubs = last.(stubbed),
        pass = reduce(.|, lumens) .& .!forbid .& .!myocardium,
    )
end;
vein_inputs = cached("vein_inputs") do
    vein1 = V.XCAT.centerline(xcat_surfaces["dias_cor_vein1"]; step = 2.8)
    vein2 = V.XCAT.centerline(xcat_surfaces["dias_cor_vein2"]; step = 2.8)

    # the coronary sinus ostium: the point of cor_vein1 nearest the right atrium, joined to it
    # by a 6 mm connector
    atrium, _ = V.XCAT.tessellate(
        xcat_surfaces["dias_ra"];
        nu = 120,
        nv = 120,
    )
    nearest_atrium(p) = begin
        d = vec(sqrt.(sum(abs2, atrium .- p; dims = 1)))
        k = argmin(d)
        (d[k], atrium[:, k])
    end
    ostium = argmin([nearest_atrium(vein1.X[:, k])[1] for k in 1:size(vein1.X, 2)])
    gap, atrium_point = nearest_atrium(vein1.X[:, ostium])
    start =
        vein1.X[:, ostium] .-
        6.0 .* (vein1.X[:, ostium] .- atrium_point) ./ max(gap, 1.0e-6)

    lines = [
        V.XCAT.Centerline(
            "cs_ostium",
            hcat(start, vein1.X[:, ostium]),
            fill(vein1.r[ostium], 2),
        ),
        V.XCAT.Centerline("cor_vein1a", vein1.X[:, ostium:-1:1], vein1.r[ostium:-1:1]),
        V.XCAT.Centerline("cor_vein1b", vein1.X[:, ostium:end], vein1.r[ostium:end]),
        vein2,
    ]
    base = only(
        V.XCAT.base_forest(
            lines;
            roots = ["cs_ostium"],
            attach_tol = 4.0,
        ),
    )
    # tributaries join the imaged veins along their length, as branches join the imaged arteries (imaged vessels are fixed
    # in growth, so without stubs new vessels could join only at their tips)
    tree, stubs = V.XCAT.penetrating_stubs(
        base,
        growth_grid,
        [heart_masks.lvmyo, heart_masks.rvmyo];
        domain = myocardium,
        forbid,
        blockers = BLOOD_BLOCKERS,
    )
    lumen =
        V.XCAT.vessel_tube(tree, growth_grid) .|
        V.XCAT.vessel_bridges(tree, myocardium, growth_grid)
    (tree = tree, stubs = stubs, pass = lumen .& .!forbid .& .!myocardium)
end;

z (mm): 255.0

3. Grow

HCT-CCO's staged constrained constructive optimization, on the GPU: HEART_GROWTH grows 957,045 arterial terminals from the two inlet trees, HEART_VEIN_GROWTH 100,000 venules into the coronary sinus.

The two coronaries are daughters of the aortic root, so they split its flow by the same bifurcation law as every other branch: share ∝ (imaged ostium radius)^γ, from XCAT's left main (2.54 mm) and proximal RCA (1.90 mm), so the RCA carries 34%. A tree grows only while it is at or below its share, and only where it is the near artery, so each claims its own territory: the RCA feeds the inferior LV and most of the RV.

grow_forest

Grow one forest: the domain and its pass-through voxels as a .vol file, the inlet trees as GXL files.

"Grow one forest: the domain and its pass-through voxels as a `.vol` file, the inlet trees as GXL files."
function grow_forest(spec, trees, pass, name)
    dir = mktempdir()
    lower = Tuple(round.(Int, growth_grid.origin))
    allow = zeros(UInt16, growth_grid.dims)
    allow[pass] .= V.NOTERM # vessels may cross these voxels, no terminal may end in them

    domain = V.write_vol(joinpath(dir, "$name.vol"), myocardium .| pass, lower)
    inlets = [
        V.XCAT.write_base_gxl(joinpath(dir, "$(name)_$k.xml"), tree)
            for (k, tree) in enumerate(trees)
    ]
    engine = V.grow(
        V.GrowthSpec(spec; device = DEVICE),
        domain,
        inlets;
        blockers = BLOOD_BLOCKERS,
        territories = allow,
    )
    return V.vtree(engine)
end
arteries = cached("arteries") do
    grow_forest(V.HEART_GROWTH, artery_inputs.trees, artery_inputs.pass, "arteries")
end;
veins = cached("veins") do
    grow_forest(V.HEART_VEIN_GROWTH, [vein_inputs.tree], vein_inputs.pass, "veins")
end;

show vessels of radius ≥

4. Flow

No flow is prescribed: aortic and coronary-sinus pressures drive blood through every vessel, capillary bed and vein, and ~1 million arterioles set their own tone from their O₂ balance. flow solves maximal dilation (stress) and rest. At maximal dilation the imaged epicardial arteries are also 13% wider, as adenosine widens normal human coronaries. Each 1 mm myocardial voxel gets its flow from the terminals that feed it.

tissue_centres

Centres (mm) of the 1 mm myocardial voxels: the tissue the flow is solved for.

"Centres (mm) of the 1 mm myocardial voxels: the tissue the flow is solved for."
tissue_centres = let voxels = findall(myocardium)
    Float32[growth_grid.origin[d] + voxels[k][d] - 1 for d in 1:3, k in eachindex(voxels)]
end
flows = cached("flow") do
    V.flow(
        V.HEART_FLOW,
        arteries,
        veins,
        tissue_centres;
        to = DEVICE,
    )
end;
voxel_mbf

Blood flow (mL/min/g) of every 1 mm myocardial voxel in circuit circuit.

"Blood flow (mL/min/g) of every 1 mm myocardial voxel in circuit `circuit`."
function voxel_mbf(circuit)
    terminal_flow = reshape(Float32[circuit.q[j] for j in circuit.terminals], 1, :)
    voxels = collect(Int32(1):Int32(size(tissue_centres, 2)))
    # mL/s per mL of tissue
    per_ml = V.OrganCT.tissue_iodine(terminal_flow, flows.pairs, flows.terr, voxels)
    return vec(per_ml) .* 60 ./ V.HEART_FLOW.rho
end
myocardium_slice

A 1 mm map (x, y) of values (one per myocardial voxel) at z (mm); NaN elsewhere.

"A 1 mm map (x, y) of `values` (one per myocardial voxel) at z (mm); NaN elsewhere."
function myocardium_slice(values, z)
    k = clamp(round(Int, z - growth_grid.origin[3]) + 1, 1, growth_grid.dims[3])
    slice = fill(NaN32, growth_grid.dims[1], growth_grid.dims[2])
    for (n, voxel) in enumerate(findall(myocardium))
        voxel[3] == k && (slice[voxel[1], voxel[2]] = values[n])
    end
    return slice
end

z (mm): 255.0

5. Transport

The lab's current injection (INJECTION, below) into the right arm, carried through the whole-body circulation, the lungs and the left heart into the coronary tree, with recirculation. Adenosine stress raises cardiac output 1.71×, so the same injection is diluted into more blood and the arterial peak is lower.

INJECTION

The injection: the lab's current protocol (Hadjiabdolhamid et al. 2024, Shojazadeh et al. 2026), 0.5 mL/kg of contrast at 5 mL/s, then 0.5 mL/kg of a 30:70 contrast:saline chaser at the same rate, for the body model's 70 kg: 35 mL of iohexol 350, then 35 mL at 105 mg/mL. A short bolus keeps the first pass inside the scan window: at stress, iodine starts leaving the heart about 2.5 s after it arrives.

"""
The injection: the lab's current protocol (Hadjiabdolhamid et al. 2024, Shojazadeh et al. 2026), 0.5 mL/kg of contrast at
5 mL/s, then 0.5 mL/kg of a 30:70 contrast:saline chaser at the same rate, for the body model's 70 kg: 35 mL of iohexol
350, then 35 mL at 105 mg/mL. A short bolus keeps the first pass inside the scan window: at stress, iodine starts leaving
the heart about 2.5 s after it arrives.
"""
const INJECTION = let kg = 70, conc = 350
    V.Body.Injection(; phases = [(0.5 * kg, 5, conc), (0.5 * kg, 5, 0.3 * conc)])
end
transport_state

Contrast transport through the body and the coronary circuit circuit, at rest or adenosine stress.

"Contrast transport through the body and the coronary circuit `circuit`, at rest or adenosine stress."
function transport_state(state, circuit)
    heart = state == "stress" ? V.Body.adenosine() : (co = 81.6f0, beat = 1.0f0)
    spec = V.TransportSpec(
        V.HEART_TRANSPORT;
        phys = circuit.phys,
        tissue_ml = Float32(size(tissue_centres, 2) / 1000),
        heart...,
    )
    return (
        state,
        circuit,
        V.transport(
            spec,
            circuit,
            INJECTION;
            times = V.HEART_PHANTOM.times,
        ),
    )
end
transport_cache

Contrast transport at rest and stress through circuits, solved once and kept in CACHE_DIR/name.jls; returns that file. The full results (every vessel's iodine at every frame) take several GB, so the notebook does not hold them: save_phantom reads them while it writes, and the figures use transport_curves.

"""
Contrast transport at rest and stress through `circuits`, solved once and kept in `CACHE_DIR/name.jls`; returns that
file. The full results (every vessel's iodine at every frame) take several GB, so the notebook does not hold them:
`save_phantom` reads them while it writes, and the figures use `transport_curves`.
"""
function transport_cache(name, circuits)
    path = joinpath(CACHE_DIR, name * ".jls")
    isfile(path) || serialize(
        path,
        [
            transport_state("rest", circuits.rest),
            transport_state("stress", circuits.stress),
        ],
    )
    return path
end
TRANSPORT = transport_cache("transport", flows);
transport_curves

Each state's LV blood and whole-myocardium iodine (mg/mL) over time, from a transport file.

"Each state's LV blood and whole-myocardium iodine (mg/mL) over time, from a transport file."
function transport_curves(path)
    tissue_ml = size(tissue_centres, 2) / 1000
    return map(deserialize(path)) do (state, _, result)
        held = vec(
            sum(result.tissue; dims = 2) .+
                sum(result.artery; dims = 2) .+
                sum(result.vein; dims = 2),
        )
        (
            state = state,
            blood_times = result.body.t,
            lv = result.body.mean[:lv],
            myocardium = held ./ tissue_ml,
        )
    end
end
TRANSPORT_CURVES = transport_curves(TRANSPORT);

6. Compartments

A compartment is the region ground truth is given for: a region of the heart with everything in it, tissue, and every artery, vein and lumen that lies in it or overlaps it. Here: the LV and RV walls, each split into the LAD, LCx and RCA territories (the left main counts as LAD), and the papillary muscles as their own compartment, left out of the walls. The coronary arteries and veins on the heart's surface (the coronary sinus among them) are compartments of their own, one beside each wall: LV epicardial vessels and RV epicardial vessels. Groups give each wall two ways: its myocardium alone, and its myocardium + epicardial vessels, the ideal first-pass compartment, which also counts the iodine on its way in or out.

Edit the compartments here, e.g. split_walls = false and split_territories = false give one compartment, the whole myocardium.

The rule below is applied once, when the phantom is voxelized (section 7), and every compartment number and picture after that comes from the saved phantom (section 8), so there is one source of truth.

begin
    split_walls = true # LV and RV apart
    split_territories = true # LAD, LCx and RCA apart
    epicardial_compartments = true # the surface coronary arteries and veins as compartments of their own
end;
begin
    wall_names = split_walls ? ["LV", "RV"] : ["Myocardium"]
    territory_names = split_territories ? ["LAD", "LCx", "RCA"] : String[]
    wall_rois =
        isempty(territory_names) ? wall_names :
        ["$w · $t" for w in wall_names for t in territory_names]
    epicardial_names = epicardial_compartments ? ["$w epicardial vessels" for w in wall_names] : String[]
    ROI_NAMES = [wall_rois; "Papillary muscles"; epicardial_names]

    roi_index(name) = findfirst(==(name), ROI_NAMES)
    ROI_GROUPS = Pair{String, Vector{Int}}[]
    # a wall's own compartments: its territories, or the wall itself
    wall_ids(w) = isempty(territory_names) ? [roi_index(w)] : [roi_index("$w · $t") for t in territory_names]
    heart_ids = collect(eachindex(wall_rois))
    length(heart_ids) > 1 && push!(ROI_GROUPS, "heart myocardium" => heart_ids)
    if epicardial_compartments
        push!(ROI_GROUPS, "heart myocardium + epicardial vessels" => [heart_ids; roi_index.(epicardial_names)])
    end
    if length(wall_names) > 1
        for w in wall_names
            length(wall_ids(w)) > 1 && push!(ROI_GROUPS, "$w myocardium" => wall_ids(w))
            epicardial_compartments && push!(ROI_GROUPS, "$w myocardium + epicardial vessels" => [wall_ids(w); roi_index("$w epicardial vessels")])
        end
        if !isempty(territory_names)
            append!(ROI_GROUPS, [t => [roi_index("$w · $t") for w in wall_names] for t in territory_names])
        end
    end
    ROI_NAMES, ROI_GROUPS
end
"""
Each arterial terminal's branch (`of`: 1 LAD, 2 LCx, 3 RCA) and the LAD's first segment (`lad`). The LCA splits at its
first imaged bifurcation where both branches feed ≥ 5% of it; the more anterior one is the LAD. Terminals of the left main
before it count as LAD.
"""
coronary_branch = let tree = arteries, n = length(tree.r)
    children = [Int[] for _ in 1:n]
    for j in 1:n
        tree.parent[j] > 0 && push!(children[tree.parent[j]], j)
    end
    root_of(j) = begin
        k = j
        while tree.parent[k] != 0
            k = tree.parent[k]
        end
        k
    end
    depth(j) = begin
        k = j
        d = 0
        while tree.parent[k] != 0
            k = tree.parent[k]
            d += 1
        end
        d
    end

    # terminals below each segment, and their summed y (front to back)
    terminals_below = Float64.(length.(children) .== 0)
    y_below = terminals_below .* tree.b[2, :]
    for j in sortperm(depth.(1:n); rev = true)
        p = tree.parent[j]
        p > 0 || continue
        terminals_below[p] += terminals_below[j]
        y_below[p] += y_below[j]
    end

    roots = unique(root_of.(1:n))
    lca = roots[argmax(terminals_below[roots])]
    j = lca
    lad = lcx = 0
    while lad == 0
        big = [
            c
                for c in children[j]
                if tree.isbase[c] && terminals_below[c] > 0.05 * terminals_below[lca]
        ]
        if length(big) >= 2
            front_to_back = sortperm([y_below[c] / terminals_below[c] for c in big])
            lad, lcx = big[front_to_back[1]], big[front_to_back[2]]
        else
            imaged = [c for c in children[j] if tree.isbase[c]]
            j = imaged[argmax(terminals_below[imaged])]
        end
    end

    below(j, a) = begin
        k = j
        while k != 0
            k == a && return true
            k = tree.parent[k]
        end
        false
    end
    branch_of(j) = root_of(j) != lca ? 3 : below(j, lcx) ? 2 : 1
    (of = branch_of.(flows.rest.terminals), lad = lad)
end;
"Each 1 mm voxel's territory (1–3), carried 25 mm out from the myocardium so the lumens on its surface get one too."
territory_map = let weights = zeros(Float32, 3, size(tissue_centres, 2)),
        map = zeros(Int8, growth_grid.dims)
    for (voxel, terminal, w) in flows.pairs
        weights[coronary_branch.of[terminal], voxel] += w
    end
    voxels = findall(myocardium)
    for (n, voxel) in enumerate(voxels)
        map[voxel] = argmax(@view weights[:, n])
    end

    steps = (
        CartesianIndex(1, 0, 0),
        CartesianIndex(-1, 0, 0),
        CartesianIndex(0, 1, 0),
        CartesianIndex(0, -1, 0),
        CartesianIndex(0, 0, 1),
        CartesianIndex(0, 0, -1),
    )
    front = voxels
    for _ in 1:25
        next = CartesianIndex{3}[]
        for voxel in front, step in steps
            neighbour = voxel + step
            checkbounds(Bool, map, neighbour) && map[neighbour] == 0 || continue
            map[neighbour] = map[voxel]
            push!(next, neighbour)
        end
        front = next
    end
    map
end;
"Each 1 mm voxel's wall (1 LV, 2 RV), carried 25 mm out from the myocardium: the wall a surface vessel lies beside."
wall_map = let map = zeros(Int8, growth_grid.dims)
    map[heart_masks.lvmyo] .= 1
    map[heart_masks.rvmyo .& .!heart_masks.lvmyo] .= 2
    steps = (
        CartesianIndex(1, 0, 0),
        CartesianIndex(-1, 0, 0),
        CartesianIndex(0, 1, 0),
        CartesianIndex(0, -1, 0),
        CartesianIndex(0, 0, 1),
        CartesianIndex(0, 0, -1),
    )
    front = findall(!=(0), map)
    for _ in 1:25
        next = CartesianIndex{3}[]
        for voxel in front, step in steps
            neighbour = voxel + step
            checkbounds(Bool, map, neighbour) && map[neighbour] == 0 || continue
            map[neighbour] = map[voxel]
            push!(next, neighbour)
        end
        front = next
    end
    map
end;
enclosed_vessels

The coronary vessels a wall wraps, as one segmenting a patient's myocardium would include them: from each vessel voxel (lumen or vessel wall) of box, 26 rays run through the vessel to the first voxel that is not vessel. A vessel's cross-section (its connected voxels in one axial slice) joins the wall whose myocardium more than half of its rays end in, on average; surface vessels, wrapped on one side only, stay out. Returns 0 (neither), 1 (LV) or 2 (RV) for every voxel of box.

"""
The coronary vessels a wall wraps, as one segmenting a patient's myocardium would include them: from each vessel voxel
(lumen or vessel wall) of `box`, 26 rays run through the vessel to the first voxel that is not vessel. A vessel's cross-section
(its connected voxels in one axial slice) joins the wall whose myocardium more than half of its rays end in, on average;
surface vessels, wrapped on one side only, stay out. Returns 0 (neither), 1 (LV) or 2 (RV) for every voxel of `box`.
"""
function enclosed_vessels(box; reach_mm = 8.0)
    vessel(label) = label == 0x1a || label == 0x1b || label == 0x1e
    directions = [
        (i, j, k) ./ sqrt(i^2 + j^2 + k^2)
            for i in -1:1, j in -1:1, k in -1:1 if (i, j, k) != (0, 0, 0)
    ]
    steps = round(Int, reach_mm / XCAT_VOXEL_MM)
    sides = (
        CartesianIndex(1, 0),
        CartesianIndex(-1, 0),
        CartesianIndex(0, 1),
        CartesianIndex(0, -1),
    )
    wall = zeros(UInt8, size(box))
    Threads.@threads for k in axes(box, 3)
        # the share of each vessel voxel's rays that end in each wall
        lv_share = zeros(Float32, size(box, 1), size(box, 2))
        rv_share = zeros(Float32, size(box, 1), size(box, 2))
        for j in axes(box, 2), i in axes(box, 1)
            vessel(box[i, j, k]) || continue
            for d in directions
                for s in 1:steps
                    q = round.(Int, (i, j, k) .+ s .* d)
                    checkbounds(Bool, box, q...) || break
                    label = box[q...]
                    vessel(label) && continue
                    label == 0x0f && (lv_share[i, j] += 1 / length(directions))
                    label == 0x10 && (rv_share[i, j] += 1 / length(directions))
                    break
                end
            end
        end
        # one decision per cross-section: flood each connected patch of vessel in this slice
        seen = falses(size(box, 1), size(box, 2))
        patch = CartesianIndex{2}[]
        for j in axes(box, 2), i in axes(box, 1)
            (vessel(box[i, j, k]) && !seen[i, j]) || continue
            empty!(patch)
            stack = [CartesianIndex(i, j)]
            seen[i, j] = true
            while !isempty(stack)
                c = pop!(stack)
                push!(patch, c)
                for side in sides
                    n = c + side
                    checkbounds(Bool, seen, n) || continue
                    (seen[n] || !vessel(box[n, k])) && continue
                    seen[n] = true
                    push!(stack, n)
                end
            end
            lv = sum(lv_share[c] for c in patch) / length(patch)
            rv = sum(rv_share[c] for c in patch) / length(patch)
            lv + rv > 0.5 || continue
            for c in patch
                wall[c, k] = lv >= rv ? 0x01 : 0x02
            end
        end
    end
    return wall
end
"""
Where the LV and RV walls would be, at 0.2 mm: each wall closed by 3 mm, so a vessel lumen in or overlapping it is in it,
and the vessels each wall wraps (`enclosed_vessels`). And the papillary muscles: the LV myocardium that a 10 mm closing of
the LV cavity fills (thin muscle surrounded by blood).
"""
WALL_ENVELOPES = cached("wall_envelopes_enclosed") do
    walls = findall(label -> label == 0x0f || label == 0x10, xcat_labels)
    margin = ceil(Int, 12 / XCAT_VOXEL_MM)
    lower = max.(Tuple(minimum(walls)) .- margin, 1)
    upper = min.(Tuple(maximum(walls)) .+ margin, XCAT_DIMS)
    box = xcat_labels[CartesianIndices(UnitRange.(lower, upper))]
    (
        # the box's first voxel centre in mm, so the masks stay right whatever grid the labels come on
        origin_mm = XCAT_ORIGIN .+ (lower .- 1) .* XCAT_VOXEL_MM,
        lv = V.XCAT.closing(box .== 0x0f, 3 / XCAT_VOXEL_MM),
        rv = V.XCAT.closing(box .== 0x10, 3 / XCAT_VOXEL_MM),
        papillary = V.XCAT.closing(box .== 0x13, 10 / XCAT_VOXEL_MM) .& (box .== 0x0f),
        enclosed = enclosed_vessels(box),
    )
end;
make_roi
make_roi(labels, envelopes, territories, walls, grid; papillary, epicardial, split_walls, split_territories) -> roi

The compartment rule roi(p) for the 0.2 mm voxel at p (0 = none): the papillary muscles (compartment papillary); else its wall (XCAT's label, or for a vessel lumen or vessel wall the wall envelope it lies in) and its 1 mm voxel's territory. A coronary artery or vein lumen outside both, on the heart's surface, is the epicardial compartment of the wall beside it (walls): epicardial holds their ids (LV, RV; 0 for none).

"""
    make_roi(labels, envelopes, territories, walls, grid; papillary, epicardial, split_walls, split_territories) -> roi

The compartment rule `roi(p)` for the 0.2 mm voxel at `p` (0 = none): the papillary muscles (compartment `papillary`); else
its wall (XCAT's label, or for a vessel lumen or vessel wall the wall envelope it lies in) and its 1 mm voxel's territory.
A coronary artery or vein lumen outside both, on the heart's surface, is the epicardial compartment of the wall beside it
(`walls`): `epicardial` holds their ids (LV, RV; 0 for none).
"""
function make_roi(
        labels,
        envelopes,
        territories,
        walls,
        grid;
        papillary,
        epicardial,
        split_walls,
        split_territories,
    )
    lv = envelopes.lv
    rv = envelopes.rv
    papillary_mask = envelopes.papillary
    return function roi(p)
        voxel = round.(Int, (p .- XCAT_ORIGIN) ./ XCAT_VOXEL_MM) .+ 1
        all(1 .<= voxel .<= XCAT_DIMS) || return 0
        label = labels[voxel...]
        k = round.(Int, (p .- envelopes.origin_mm) ./ XCAT_VOXEL_MM) .+ 1
        inside = all(1 .<= k .<= size(lv))
        inside && papillary_mask[k...] && return papillary

        wall = label == 0x0f ? 1 : label == 0x10 ? 2 : 0
        # coronary artery, vein or vessel wall: in a wall's envelope, or wrapped by a wall
        if wall == 0 && inside && label in (0x1a, 0x1b, 0x1e)
            wall = lv[k...] ? 1 : rv[k...] ? 2 : Int(envelopes.enclosed[k...])
        end
        g = round.(Int, p .- grid.origin) .+ 1
        # a surface coronary artery or vein lumen: the epicardial compartment of the wall beside it
        if wall == 0 && (label == 0x1a || label == 0x1b) && all(1 .<= g .<= size(walls)) && walls[g...] > 0
            return epicardial[split_walls ? Int(walls[g...]) : 1]
        end
        wall == 0 && return 0

        territory = all(1 .<= g .<= size(territories)) ? Int(territories[g...]) : 0
        territory == 0 && return 0

        wall = split_walls ? wall : 1
        return split_territories ? 3 * (wall - 1) + territory : wall
    end
end
roi (generic function with 1 method)
roi = make_roi(
    xcat_labels,
    WALL_ENVELOPES,
    territory_map,
    wall_map,
    growth_grid;
    papillary = findfirst(==("Papillary muscles"), ROI_NAMES),
    epicardial = Tuple(
        something(findfirst(==("$w epicardial vessels"), ROI_NAMES), 0) for w in (split_walls ? ("LV", "RV") : ("Myocardium", "Myocardium"))
    ),
    split_walls,
    split_territories,
)
roi_colormap

Categorical colours of the compartments: hue for the territory (LAD red, LCx green, RCA blue), dark for the LV and light for the RV, gold for the papillary muscles, violet for the epicardial vessels (dark beside the LV, light beside the RV).

"""
Categorical colours of the compartments: hue for the territory (LAD red, LCx green, RCA blue), dark for the LV and light
for the RV, gold for the papillary muscles, violet for the epicardial vessels (dark beside the LV, light beside the RV).
"""
function roi_colormap()
    lv = ["#c62828", "#2e7d32", "#1565c0"]
    rv = ["#ef9a9a", "#a5d6a7", "#90caf9"]
    walls =
        split_territories ? (split_walls ? [lv; rv] : lv) :
        (split_walls ? ["#c62828", "#90caf9"] : ["#c62828"])
    epicardial = ["#6a1b9a", "#ce93d8"][1:count(endswith(" epicardial vessels"), ROI_NAMES)]
    return Mke.cgrad(
        [:black; walls; "#ffc400"; epicardial],
        length(ROI_NAMES) + 1;
        categorical = true,
    )
end

7. Voxelize and Save

The 0.2 mm phantom of the whole XCAT heart, one folder per scenario. Every voxel gets a label (its material and iodine curve) and a compartment. Every label lies in one compartment, so a compartment's iodine and ground truth are sums over its labels, and painting conserves iodine exactly: the build fails if the heart's iodine ledger does not balance.

FileWhat it holds
labels.u16.zstevery voxel's label
rois.u8.zstevery voxel's compartment
mbf_rest.f32.zst, mbf_stress.f32.zstevery voxel's ground-truth MBF (mL/min/g)
label_rest.csv, label_stress.csveach label's compartment, material, ground-truth MBF and iodine every second
ledger_rest.csv, ledger_stress.csvthe heart's iodine every second: delivered, held, out through the veins, painted
phantom.tomlthe grid, the compartment names and groups, the tracking point
begin
    "1 mm myocardial voxel number of each growth-grid voxel (0: not myocardium)."
    const TISSUE_VOXEL_NUMBER = V.XCAT.tissue_numbers(myocardium)

    # the imaged segments of the grown trees, for naming XCAT's drawn coronary lumens
    const IMAGED_ARTERY_SEGMENTS = V.XCAT.imaged_segments(arteries)
    const IMAGED_VEIN_SEGMENTS = V.XCAT.imaged_segments(veins)
end;
"""
Which great vessel each vessel voxel belongs to, and where it lies along it, on a 1 mm grid: its tree and the blood (mL)
between it and the heart. The trees grow by connection through XCAT's vessel labels from where they meet the heart: the
aorta from the LV, the pulmonary arteries from the RV, the venae cavae and brachiocephalic veins from the RA, the pulmonary
veins from the LA (`V.XCAT.great_vessel_transit`). Each state's transit is volume over flow (`V.pool_delay`).

The aorta is timed by the body model's volume, not XCAT's. XCAT vmale_50's aorta is about three standard deviations
narrower than adult men's (21–22 mm ascending and 15 mm descending, against 33 ± 4 and 24 ± 3 mm; Wolak et al. 2008), so its
lumen holds 26.7 mL from the valve to the arch's top. The whole-body model that makes the arterial curves holds 60 mL there
(aortic root 25 mL, arch 35 mL). Each aortic voxel keeps its place along XCAT's aorta, and the blood upstream of it is
scaled by that ratio (`V.XCAT.scale_tree`), so the arterial curves and their timing come from one body.
"""
GREAT_VESSEL_TRANSIT = let
    xcat = cached("great_vessel_transit") do
        V.XCAT.great_vessel_transit(xcat_labels, XCAT_ORIGIN, XCAT_VOXEL_MM)
    end
    body = V.circulation(V.HEART_TRANSPORT)
    volume(segment) = body.segs[findfirst(s -> s.name === segment, body.segs)].v
    V.XCAT.scale_tree(xcat, 0x01, (volume(:aortic_root) + volume(:arch)) / xcat.apex_ml)
end;
"The heart's phantom spec: the body pools, with the descending aorta timed from this subject's arch."
PHANTOM_SPEC = let pools = copy(V.HEART_PHANTOM.pools)
    i = findfirst(p -> p.name == "descending aorta", pools)
    d = pools[i]
    pools[i] = V.Pool(
        d.name,
        d.material,
        d.segment,
        d.what;
        through = d.through,
        lead_ml = GREAT_VESSEL_TRANSIT.apex_ml,
        lead_through = d.lead_through,
    )
    V.PhantomSpec(V.HEART_PHANTOM; pools)
end;
make_classifier
make_classifier(labels, grid, numbers, artery_segments, vein_segments, transit) -> classify

The classifier classify(p): what is at the 0.2 mm voxel centre p. Myocardium is its 1 mm voxel, a coronary lumen its imaged segment, a great-vessel voxel its vessel's pool and its place along the vessel (transit); every other XCAT label is decided by the label alone (a body pool, V.XCAT_POOL_OF_LABEL, or a static material). Everything it reads is captured with its type, so it compiles to fast code.

"""
    make_classifier(labels, grid, numbers, artery_segments, vein_segments, transit) -> classify

The classifier `classify(p)`: what is at the 0.2 mm voxel centre `p`. Myocardium is its 1 mm voxel, a coronary lumen its
imaged segment, a great-vessel voxel its vessel's pool and its place along the vessel (`transit`); every other XCAT
label is decided by the label alone (a body pool, `V.XCAT_POOL_OF_LABEL`, or a static material). Everything it reads is
captured with its type, so it compiles to fast code.
"""
function make_classifier(
        labels,
        grid,
        numbers,
        artery_segments,
        vein_segments,
        transit,
    )
    # by the label alone: 0 needs the voxel's geometry
    by_label = fill((V.VOX_POOL, Int32(24), 0.0f0), 256)
    needs_geometry = falses(256)
    for label in 0x00:0xff
        if label in (0x0f, 0x10, 0x1a, 0x1b, 0x18, 0x19, 0x1c, 0x7c, 0x7d)
            needs_geometry[label + 1] = true
        elseif haskey(V.XCAT_POOL_OF_LABEL, label)
            by_label[label + 1] = (V.VOX_POOL, Int32(V.XCAT_POOL_OF_LABEL[label]), 0.0f0)
        elseif haskey(V.XCAT_STATIC, Int(label))
            by_label[label + 1] = (V.VOX_STATIC, Int32(label), 0.0f0)
        end
    end
    vessel_pool(p, t) = V.XCAT.great_vessel_pool(transit, p, t)

    return function classify(p)
        voxel = round.(Int, (p .- XCAT_ORIGIN) ./ XCAT_VOXEL_MM) .+ 1
        all(1 .<= voxel .<= XCAT_DIMS) || return (V.VOX_STATIC, Int32(0), 0.0f0)
        label = labels[voxel...]
        needs_geometry[label + 1] || return by_label[label + 1]

        if label == 0x0f || label == 0x10
            number = V.XCAT.nearest_tissue_voxel(numbers, round.(Int, p .- grid.origin) .+ 1)
            return number > 0 ?
                (V.VOX_TISSUE, number, 0.0f0) :
                (V.VOX_POOL, Int32(26), 0.0f0)
        end
        label == 0x1a && return (
            V.VOX_VESSEL,
            Int32(first(V.XCAT.nearest_segment(artery_segments, p))),
            0.0f0,
        )
        label == 0x1b && return (
            V.VOX_VESSEL,
            -Int32(first(V.XCAT.nearest_segment(vein_segments, p))),
            0.0f0,
        )
        label == 0x1c && return vessel_pool(p, 0x01)
        label == 0x19 && return vessel_pool(p, V.XCAT.vessel_tree_at(transit, p, (0x01, 0x02)))
        label == 0x7d && return vessel_pool(p, 0x02)
        label == 0x7c && return vessel_pool(p, 0x05)
        return vessel_pool(p, V.XCAT.vessel_tree_at(transit, p, (0x03, 0x04, 0x05, 0x06, 0x07)))
    end
end
classify (generic function with 1 method)
classify = make_classifier(
    xcat_labels,
    growth_grid,
    TISSUE_VOXEL_NUMBER,
    IMAGED_ARTERY_SEGMENTS,
    IMAGED_VEIN_SEGMENTS,
    GREAT_VESSEL_TRANSIT,
)
TRACKING_POINTS

The bolus-tracking points: the descending aorta at the myocardium's mid level, and the ascending aorta 15 mm above where it leaves the heart (clear of the root's sinuses), the centre of its cross-section there.

"""
The bolus-tracking points: the descending aorta at the myocardium's mid level, and the ascending aorta 15 mm above where it
leaves the heart (clear of the root's sinuses), the centre of its cross-section there.
"""
TRACKING_POINTS = let voxels = findall(myocardium)
    z_mid =
        (minimum(v[3] for v in voxels) + maximum(v[3] for v in voxels)) / 2 +
        growth_grid.origin[3] - 1
    k = xcat_slice(z_mid)
    aorta = [
        (i, j)
            for j in 1:XCAT_DIMS[2], i in 1:XCAT_DIMS[1]
            if xcat_labels[i, j, k] == 0x1c
    ]
    back = maximum(last, aorta)
    descending = [
        (i, j)
            for (i, j) in aorta
            if j > back - 15 / XCAT_VOXEL_MM # within 15 mm of its back
    ]
    descending_point = (
        XCAT_ORIGIN[1] + (sum(first, descending) / length(descending) - 1) * XCAT_VOXEL_MM,
        XCAT_ORIGIN[2] + (sum(last, descending) / length(descending) - 1) * XCAT_VOXEL_MM,
        XCAT_ORIGIN[3] + (k - 1) * XCAT_VOXEL_MM,
    )

    # the ascending aorta: the aortic tree's cells before the arch's top, 15 mm above the lowest of them
    transit = GREAT_VESSEL_TRANSIT
    step = round(Int, transit.h / XCAT_VOXEL_MM)
    ascending = [
        c
            for c in CartesianIndices(transit.tree)
            if transit.tree[c] == 0x01 && !transit.descending[c] && !isnan(transit.volume[c]) &&
            transit.volume[c] < transit.apex_ml && xcat_labels[((Tuple(c) .- 1) .* step .+ 1)...] == 0x1c
    ]
    z_low = minimum(c[3] for c in ascending)
    level = [c for c in ascending if c[3] == z_low + round(Int, 15 / transit.h)]
    ascending_point = Tuple(
        transit.origin_mm[d] + (sum(c[d] for c in level) / length(level) - 1) * transit.h for d in 1:3
    )
    ["descending aorta" => descending_point, "ascending aorta" => ascending_point]
end
save_phantom

Write one scenario's phantom into PHANTOM_DIR/name, once, from its transport file (read here, and let go once the phantom is written).

"""
Write one scenario's phantom into `PHANTOM_DIR/name`, once, from its transport file (read here, and let go once the
phantom is written).
"""
function save_phantom(
        name,
        transport_file;
        spec = PHANTOM_SPEC,
        classify = classify,
    )
    dir = joinpath(PHANTOM_DIR, name)
    isfile(joinpath(dir, "phantom.toml")) && return dir

    scans = deserialize(transport_file)
    seconds = @elapsed V.phantom(
        spec,
        scans,
        classify,
        V.VoxelGrid(XCAT_ORIGIN, XCAT_VOXEL_MM, XCAT_DIMS);
        pairs = flows.pairs,
        terr = flows.terr,
        nvox = size(tissue_centres, 2),
        out = dir,
        roi = roi,
        roi_names = ROI_NAMES,
        roi_groups = ROI_GROUPS,
        tracking = TRACKING_POINTS,
        facts = ["organ" => "heart (XCAT vmale_50, 0.2 mm)", "model" => "Vasculature.jl"],
        verbose = true,
    )
    @info "$name: $(round(seconds; digits = 1)) s"
    scans = nothing
    GC.gc()
    return dir
end
HEALTHY_DIR = save_phantom("healthy", TRANSPORT);

8. Inspect the Saved Phantom

Only the heart's slab is read back. Labels and compartments are categorical; the idealized 70 keV image (the phantom as a perfect scanner would see it) is gray; iodine and ground-truth MBF use :turbo.

Ground-truth MBF is shown per compartment, the number a CT measurement of that region is compared with: the flow the compartment's capillary beds receive over the mass of everything in it, tissue and the vessels in it alike. It covers the whole compartment, vessels included, as a CT region does.

inspect

The heart's box of a saved phantom, the only part read back: the growth grid's (the pericardium's) box and the descending aorta's tracking point, with 15 mm to spare. Returns the bundle (labels and compartments) and its first z (mm). Ground-truth MBF is each compartment's own (its beds' flow over the mass of everything in it, tissue and vessels), from the bundle's tables, so the mbf_<state>.f32.zst volumes are not read back.

"""
The heart's box of a saved phantom, the only part read back: the growth grid's (the pericardium's) box and the
descending aorta's tracking point, with 15 mm to spare. Returns the bundle (labels and
compartments) and its first z (mm). Ground-truth MBF is each compartment's own (its beds' flow over the mass of
everything in it, tissue and vessels), from the bundle's tables, so the `mbf_<state>.f32.zst` volumes are not read back.
"""
function inspect(dir)
    tracking = V.DynamicPhantom.load(dir; volume = false).meta["tracking"]
    aorta_mm = tracking["descending aorta"]["point_mm"]
    heart_lower_mm = collect(growth_grid.origin)
    heart_upper_mm = heart_lower_mm .+ collect(growth_grid.dims) .- 1
    lower_mm = [min.(heart_lower_mm[1:2], aorta_mm[1:2]); heart_lower_mm[3]] .- 15
    upper_mm = [max.(heart_upper_mm[1:2], aorta_mm[1:2]); heart_upper_mm[3]] .+ 15
    function voxel(mm, d)
        number = round(Int, (mm - XCAT_ORIGIN[d]) / XCAT_VOXEL_MM) + 1
        return clamp(number, 1, XCAT_DIMS[d])
    end
    bundle = V.DynamicPhantom.load(
        dir;
        rois = true,
        zrange = voxel(lower_mm[3], 3):voxel(upper_mm[3], 3),
        xrange = voxel(lower_mm[1], 1):voxel(upper_mm[1], 1),
        yrange = voxel(lower_mm[2], 2):voxel(upper_mm[2], 2),
    )
    return (bundle = bundle, z0 = bundle.origin_mm[3])
end
healthy = inspect(HEALTHY_DIR);
slice_figure

One phantom slice in six panels, each with its own scale underneath: labels by kind, compartments and ground-truth MBF in state; the idealized 70 keV image and iodine at time t; ground-truth MBF in the other state.

"""
One phantom slice in six panels, each with its own scale underneath: labels by kind, compartments and ground-truth MBF in
`state`; the idealized 70 keV image and iodine at time `t`; ground-truth MBF in the other state.
"""
function slice_figure(view, state, t, z)
    bundle = view.bundle
    k = clamp(round(Int, (z - view.z0) / XCAT_VOXEL_MM) + 1, 1, size(bundle.labels, 3))
    is, js = axes(bundle.labels, 1), axes(bundle.labels, 2)
    xs = bundle.origin_mm[1] .+ (is .- 1) .* XCAT_VOXEL_MM
    ys = bundle.origin_mm[2] .+ (js .- 1) .* XCAT_VOXEL_MM
    labels = bundle.labels[is, js, k]
    other = state == "rest" ? "stress" : "rest"

    # per-label lookups: kind, idealized HU and iodine at time t
    kind_colours = [:gray30, :gray65, :mediumpurple, :darkorange, :red, :deepskyblue]
    kind_names = [
        "static (air, cartilage)",
        "body tissue",
        "blood",
        "myocardium",
        "artery",
        "vein",
    ]
    function kind_code(row)
        row.kind == "static" && return 1
        row.kind == "pool" && return occursin("blood", row.base) ? 3 : 2
        return Dict("tissue" => 4, "artery" => 5, "vein" => 6)[row.kind]
    end
    kind_of = zeros(UInt8, 65536)
    for (label, row) in bundle.table
        kind_of[label + 1] = label == 0 ? 0 : kind_code(row)
    end
    # each compartment's ground truth over all of it, tissue and vessels: its beds' flow over the mass of everything in it
    mbf = Dict(st => zeros(Float32, length(ROI_NAMES) + 1) for st in ("rest", "stress"))
    for st in ("rest", "stress"), (r, name) in enumerate(ROI_NAMES)
        mbf[st][r + 1] = bundle.roi_table[(st, name)].mbf
    end
    rois = bundle.rois[is, js, k]
    hu = fill(-1000.0f0, 65536)
    for (label, value) in V.DynamicPhantom.hu(bundle, state, t)
        hu[label + 1] = value
    end
    iodine = zeros(Float32, 65536)
    for (label, value) in V.DynamicPhantom.iodine(bundle, state, t)
        iodine[label + 1] = value
    end

    fig = Mke.Figure(; size = (1500, 1120))
    function panel(
            row,
            column,
            title,
            image;
            colormap,
            colorrange,
            label = nothing,
            legend = nothing,
        )
        cell = fig[row, column] = Mke.GridLayout()
        ax = Mke.Axis(
            cell[1, 1];
            title,
            aspect = Mke.DataAspect(),
            yreversed = true,
        )
        map = Mke.heatmap!(
            ax,
            xs,
            ys,
            image;
            colormap,
            colorrange,
        )
        Mke.hidedecorations!(ax)
        if legend === nothing
            Mke.Colorbar(
                cell[2, 1],
                map;
                label,
                vertical = false,
                flipaxis = false,
                width = Mke.Relative(0.9),
                tellwidth = false,
            )
        else
            colours, names = legend
            Mke.Legend(
                cell[2, 1],
                [Mke.PolyElement(; color = c) for c in colours],
                names;
                orientation = :horizontal,
                nbanks = 3,
                framevisible = false,
                tellwidth = false,
            )
        end
        return ax
    end

    panel(
        1,
        1,
        "Labels by kind",
        [kind_of[l + 1] for l in labels];
        colormap = Mke.cgrad([:black; kind_colours], 7; categorical = true),
        colorrange = (-0.5, 6.5),
        legend = (kind_colours, kind_names),
    )
    panel(
        1,
        2,
        "Compartments",
        rois;
        colormap = roi_colormap(),
        colorrange = (-0.5, length(ROI_NAMES) + 0.5),
        legend = (roi_colormap().colors[2:end], ROI_NAMES),
    )
    panel(
        1,
        3,
        "Ground-truth MBF by compartment, $state",
        [mbf[state][r + 1] for r in rois];
        colormap = :turbo,
        colorrange = (0, 6),
        label = "mL/min/g",
    )
    panel(
        2,
        1,
        "Idealized 70 keV, $state, t = $t s",
        [hu[l + 1] for l in labels];
        colormap = :grays,
        colorrange = (-200, 600),
        label = "HU",
    )
    panel(
        2,
        2,
        "Iodine, $state, t = $t s",
        [iodine[l + 1] for l in labels];
        colormap = :turbo,
        colorrange = (0, 8),
        label = "mg/mL",
    )
    panel(
        2,
        3,
        "Ground-truth MBF by compartment, $other",
        [mbf[other][r + 1] for r in rois];
        colormap = :turbo,
        colorrange = (0, 6),
        label = "mL/min/g",
    )
    Mke.Label(
        fig[0, :],
        "z = $(round(z; digits = 1)) mm";
        fontsize = 20,
        font = :bold,
    )
    # three equal columns: no legend or colour bar widens its own
    for column in 1:3
        Mke.colsize!(fig.layout, column, Mke.Relative(1 / 3))
    end
    return fig
end
  • state

  • time 20 s

  • z 259.1 mm

curves_figure

Each compartment's iodine, with the tracking arrival and peak, and the heart's iodine ledger at stress.

"Each compartment's iodine, with the tracking arrival and peak, and the heart's iodine ledger at stress."
function curves_figure(view)
    bundle = view.bundle
    fig = Mke.Figure(size = (1500, 540))
    for (column, state) in enumerate(("rest", "stress"))
        ax = Mke.Axis(
            fig[1, column];
            title = "Compartments, $state",
            xlabel = "time after injection (s)",
            ylabel = "iodine (mg)",
        )
        for name in ROI_NAMES
            row = bundle.roi_table[(state, name)]
            Mke.lines!(
                ax,
                bundle.times,
                row.mass;
                linewidth = 2,
                label = @sprintf("%s · %.2f mL/min/g", name, row.mbf),
            )
        end
        timing = V.DynamicPhantom.scan_times(bundle; state, roi = "roi:descending aorta")
        Mke.vlines!(
            ax,
            [timing.arrival, timing.peak_time];
            color = :gray40,
            linestyle = :dash,
        )
        Mke.xlims!(ax, 0, 40)
        column == 1 && Mke.axislegend(
            ax;
            position = :lt,
            labelsize = 11,
        )
    end

    ledger = bundle.ledger["stress"]
    ax = Mke.Axis(
        fig[1, 3];
        title = "Iodine ledger, stress",
        xlabel = "time after injection (s)",
        ylabel = "iodine (mg)",
    )
    entries = [
        ("delivered_mg", :black, "delivered", :solid),
        ("held_mg", :darkorange, "held", :solid),
        ("outflow_mg", :royalblue, "out through the veins", :solid),
        ("painted_mg", :red, "painted", :dash),
    ]
    for (column, colour, name, style) in entries
        Mke.lines!(
            ax,
            ledger["t_s"],
            ledger[column];
            color = colour,
            linewidth = 2,
            linestyle = style,
            label = name,
        )
    end
    Mke.xlims!(ax, 0, 40)
    Mke.axislegend(ax; position = :lt)
    return fig
end
outflow_check

The share of the delivered iodine that has already left the heart through the coronary sinus, 1, 2 and 3 s after arrival, 1 s before the arterial peak and at the peak.

"""
The share of the delivered iodine that has already left the heart through the coronary sinus, 1, 2 and 3 s after
arrival, 1 s before the arterial peak and at the peak.
"""
function outflow_check(view)
    bundle = view.bundle
    function share_out(ledger, t)
        ts = ledger["t_s"]
        k = clamp(searchsortedlast(ts, t), 1, length(ts) - 1)
        w = (t - ts[k]) / (ts[k + 1] - ts[k])
        out = (1 - w) * ledger["outflow_mg"][k] + w * ledger["outflow_mg"][k + 1]
        delivered = (1 - w) * ledger["delivered_mg"][k] + w * ledger["delivered_mg"][k + 1]
        return out / max(delivered, eps())
    end
    rows = map(("rest", "stress")) do state
        ledger = bundle.ledger[state]
        timing = V.DynamicPhantom.scan_times(bundle; state, roi = "roi:descending aorta")
        times = [timing.arrival .+ [1, 2, 3]; timing.peak_time - 1; timing.peak_time]
        shares = [@sprintf("%.0f%%", 100 * share_out(ledger, t)) for t in times]
        @sprintf(
            "| **%s** (arrival at %.1f s, peak at %.1f s) | %s |",
            state,
            timing.arrival,
            timing.peak_time,
            join(shares, " | "),
        )
    end
    text = """
    !!! note "Iodine already out of the heart"
        | | arrival + 1 s | arrival + 2 s | arrival + 3 s | 1 s before the peak | arterial peak |
        |---|---|---|---|---|---|
    $(join("    " .* rows, "\n"))

        A measurement that counts the iodine the heart has taken up misses this share.
    """
    return Markdown.parse(text)
end

Iodine already out of the heart

arrival + 1 sarrival + 2 sarrival + 3 s1 s before the peakarterial peak
rest (arrival at 12.5 s, peak at 18.0 s)0%0%0%1%1%
stress (arrival at 9.0 s, peak at 14.0 s)6%12%18%24%30%

A measurement that counts the iodine the heart has taken up misses this share.

9. A 90% LAD Stenosis

The same heart with a lesion over the first 10 mm of the proximal LAD, graded on the imaged lumen. Flow is solved again with the healthy tone constant, so the arterioles downstream relax on their own. The lesion is drawn with plaque (fibrous tissue, a lipid core, calcification) in place of the lumen it takes, and saved as its own scenario.

0.9
const DIAMETER_STENOSIS = 0.9
LESION

The lesion along the LAD's first 10 mm of imaged path: its segments, their imaged radii and lengths, and circuit's list.

"The lesion along the LAD's first 10 mm of imaged path: its segments, their imaged radii and lengths, and `circuit`'s list."
LESION = let tree = arteries, n = length(tree.r)
    children = [Int[] for _ in 1:n]
    for j in 1:n
        tree.parent[j] > 0 && push!(children[tree.parent[j]], j)
    end
    segment_length(j) = sqrt(sum(abs2, tree.b[:, j] .- tree.a[:, j]))

    path = Int[]
    covered = 0.0f0
    j = coronary_branch.lad
    while covered < 10.0f0
        push!(path, j)
        covered += segment_length(j)
        imaged = [c for c in children[j] if tree.isbase[c]]
        isempty(imaged) && break
        j = imaged[argmax([segment_length(c) * tree.r[c]^2 for c in imaged])]
    end

    # the imaged lumen radius at each segment: XCAT's LAD centreline radius nearest to it
    lad = V.XCAT.centerline(xcat_surfaces["dias_lad1"]; step = 0.5)
    imaged_radius(j) = lad.r[
        argmin(vec(sum(abs2, lad.X .- (tree.a[:, j] .+ tree.b[:, j]) ./ 2; dims = 1))),
    ]
    radii = Float32[imaged_radius(j) for j in path]
    lengths = Float32[segment_length(j) for j in path]
    area_fraction(j, r) = min(1.0f0, (r * (1 - DIAMETER_STENOSIS) / tree.r[j])^2)
    (
        path = path,
        radii = radii,
        stenosis = [(j, area_fraction(j, r), l) for (j, r, l) in zip(path, radii, lengths)],
    )
end
flows_lad90 = cached("flow_lad90") do
    stress = V.Flow.circuit(
        arteries,
        flows.terr;
        state = :stress,
        veins,
        vleaf = flows.vleaf,
        reference = flows.stress,
        kappa = flows.stress.resist.kappa,
        stenosis = LESION.stenosis,
        phys = flows.stress.phys,
        to = DEVICE,
    )
    rest = V.Flow.circuit(
        arteries,
        flows.terr;
        state = :rest,
        veins,
        vleaf = flows.vleaf,
        reference = flows.rest,
        kappa = flows.stress.resist.kappa,
        stenosis = LESION.stenosis,
        phys = flows.rest.phys,
        demand = flows.demand,
        to = DEVICE,
    )
    (rest = rest, stress = stress)
end;
TRANSPORT_LAD90 = transport_cache("transport_lad90", flows_lad90);
begin
    "Plaque materials, as static labels: fibrous tissue (collagen), a lipid core, calcification (hydroxyapatite)."
    const PLAQUE = Dict(40 => "basis_collagen", 41 => "basis_lipid", 42 => "hydroxyapatite")
    const STENOSIS_SPEC = V.PhantomSpec(
        PHANTOM_SPEC;
        materials = merge(V.XCAT_STATIC, PLAQUE),
    )

    "Two unit vectors perpendicular to `axis` and to each other."
    function perpendiculars(axis)
        a = axis ./ sqrt(sum(abs2, axis))
        e = abs(a[3]) < 0.9 ? [0.0, 0.0, 1.0] : [1.0, 0.0, 0.0]
        u = e .- sum(e .* a) .* a
        u ./= sqrt(sum(abs2, u))
        v = [
            a[2] * u[3] - a[3] * u[2],
            a[3] * u[1] - a[1] * u[3],
            a[1] * u[2] - a[2] * u[1],
        ]
        return u, v
    end

    """
    The plaque at `p` inside XCAT's LAD lumen along the lesion: calcium in a 60° arc at the outer rim, a lipid core behind the
    cap on the opposite side, fibrous tissue elsewhere; `nothing` where `p` is lumen or outside the lesion.
    """
    function plaque(p)
        for (j, r) in zip(LESION.path, LESION.radii)
            a = arteries.a[:, j]
            b = arteries.b[:, j]
            axis = b .- a
            s = clamp(sum((p .- a) .* axis) / sum(abs2, axis), 0, 1)
            offset = p .- (a .+ s .* axis)
            distance = sqrt(sum(abs2, offset))
            (0 < s < 1 && distance <= r + 0.2) || continue

            lumen = r * (1 - DIAMETER_STENOSIS)
            distance <= lumen && return nothing
            u, v = perpendiculars(axis)
            angle = atan(sum(offset .* v), sum(offset .* u))
            # 0 at the lumen, 1 at the vessel wall
            depth = (distance - lumen) / max(r - lumen, 1.0e-6)
            abs(angle) < π / 6 && depth > 0.6 && return 42
            abs(abs(angle) - π) < π / 3 && 0.25 < depth < 0.75 && return 41
            return 40
        end
        return nothing
    end

    "The box (mm) around the lesion's segments and their imaged radius: plaque is only looked for inside it."
    const LESION_BOX = let
        ends = reduce(hcat, [hcat(arteries.a[:, j], arteries.b[:, j]) for j in LESION.path])
        reach = maximum(LESION.radii) + 0.2
        (vec(minimum(ends; dims = 2)) .- reach, vec(maximum(ends; dims = 2)) .+ reach)
    end

    """
    The healthy classifier `healthy`, with plaque where the lesion narrows XCAT's LAD lumen. A factory, like the healthy
    one: the returned closure holds `healthy` and the lesion's box as concrete values, so the billions of voxel calls
    are compiled for them rather than looked up as untyped notebook globals each time.
    """
    function make_plaque_classifier(healthy, box)
        lower, upper = box
        return function (p)
            what = healthy(p)
            if what[1] == V.VOX_VESSEL && what[2] > 0 && all(lower .<= p .<= upper)
                material = plaque(collect(Float64, p))
                material !== nothing && return (V.VOX_STATIC, Int32(material), 0.0f0)
            end
            return what
        end
    end

    classify_with_plaque = make_plaque_classifier(classify, LESION_BOX)
end;
STENOSIS_DIR = save_phantom(
    "lad_stenosis_90",
    TRANSPORT_LAD90;
    spec = STENOSIS_SPEC,
    classify = classify_with_plaque,
);
stenosis = inspect(STENOSIS_DIR);
  • state

  • time 20 s

  • z 280.1 mm

Iodine already out of the heart

arrival + 1 sarrival + 2 sarrival + 3 s1 s before the peakarterial peak
rest (arrival at 12.5 s, peak at 18.0 s)0%0%0%0%1%
stress (arrival at 9.0 s, peak at 14.0 s)4%9%15%21%27%

A measurement that counts the iodine the heart has taken up misses this share.

10. Next Steps: Scanning a Phantom with BasisSimulator.jl

A standalone script: the saved phantom scanned at any time t on the validated GE Revolution Apex Elite model (BasisSimulator.jl 0.15+), here at the bolus arrival and at the arterial peak. Ground truth for any compartment comes straight from the phantom.

import Vasculature as V
import BasisSimulator as BS
import GPUSelect

const GPU = GPUSelect.Storage()
const DIR = expanduser("~/xcat-perfused/cardiac_pipeline/healthy") # or lad_stenosis_90
const STATE = "rest" # or "stress"

# ---- the GE Revolution Apex Elite model (MolloiLab/basis-verification, validated against
# the clinical scanner)
const GE_MODEL = (
    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,
    detector_col_offset = 0.25,
    detector_shape = :arc,
    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 = 1500,
    detection_gain = 10.0,
)
# the scanner's large spot at high tube power
focal_spot(mA) = mA >= 250 ? (1.2, 1.6) : (0.7, 1.0)
scanner(mA) = BS.EICTScanner(;
    GE_MODEL...,
    focal_spot_width = focal_spot(mA)[1],
    focal_spot_length = focal_spot(mA)[2],
)
const SIM = BS.SimOptions(
    projector = :dd_fast,
    detector_efficiency_mode = :mc_lut,
    view_samples = 5,
)
with_seed(seed) = BS.SimOptions(;
    (f => getfield(SIM, f) for f in fieldnames(BS.SimOptions) if f !== :seed)...,
    seed,
)

# the STANDARD kernel: an apodized ramp fitted to the clinical one, on a 22 cm cardiac field
# (512², 0.43 mm)
const FOV_CM, SCAN_FOV_CM = 22.0, 30.0
const KNOTS = Tuple(range(0.0, 1.0; length = 11))
const WINDOW = (fc = 0.7 * (10FOV_CM / 512) / (350 / 512), p = 1.5)
const STANDARD = BS.CustomFilter(
    KNOTS,
    Tuple(round(exp(-(x / WINDOW.fc)^WINDOW.p); digits = 5) for x in KNOTS),
)

# ---- one axial rotation of the phantom slab at time t: project, then water BHC, FDK and HU
# on the cardiac field
function scan(bundle, t; mA, collimation_mm, slice_mm, seed, field_centre_mm)
    # ≤ 64 materials, every compartment's iodine kept
    P0, _ = V.DynamicPhantom.bs_phantom(
        bundle,
        BS;
        state = STATE,
        t,
    )
    phantom = BS.Phantom(
        GPU(P0.mask),
        Dict(i - 1 => m for (i, m) in enumerate(P0.materials)),
        P0.voxel_size,
    )
    protocol = BS.CTProtocol(;
        kVp = 100,
        mA,
        views = 984,
        rotation_time = 0.35,
        collimation_mm,
        additional_filters = [("Al", 4.5)],
    )
    sim = with_seed(seed)
    recon = BS.ReconOptions(;
        matrix_size = (512, 512, round(Int, collimation_mm / slice_mm)),
        fov_cm = SCAN_FOV_CM,
        z_cm = collimation_mm / 10,
    )
    ws = BS.create_eict_workspace(scanner(mA), protocol, sim, recon, phantom)
    dose = BS.simulate!(ws, phantom, protocol, sim).dose
    bhc = BS.calibrate_bhc_water(
        sim,
        protocol;
        scanner = scanner(mA),
        geom = ws.geom,
    )
    sinogram = GPU(BS.apply_bhc_water(GPU(ws.sinogram), bhc))
    # the cardiac field: every source and detector position moved by −(field centre −
    # isocentre), exact for FDK
    g = ws.geom
    d = [field_centre_mm[1] / 10, field_centre_mm[2] / 10, 0.0]
    geom = BS.CTGeometry(
        g.SAD,
        g.SDD,
        g.n_angles,
        g.n_rows,
        g.n_cols,
        g.pixel_size,
        g.pixel_row_size,
        g.angles,
        g.source_positions .- d,
        g.detector_centers .- d,
        g.detector_u,
        g.detector_v,
        (FOV_CM, FOV_CM, g.fov[3]),
        g.pitch,
        g.table_feed,
        g.detector_shape,
        g.column_offset,
    )
    wr = BS.create_fdk_recon_workspace(
        sinogram,
        geom,
        recon.matrix_size;
        filter = STANDARD,
    )
    hu = Float32.(
        BS.to_hounsfield(
            Array(BS.reconstruct!(wr, sinogram, geom));
            μ_water = bhc.μ_water_ref,
        ),
    )
    BS.release_backend!(ws)
    BS.release_backend!((wr, sinogram))
    BS.release_backend!(phantom.mask)
    return hu, dose
end

# ---- the protocol. The phantom is centred on the isocentre; positions below are in the
# phantom's mm frame minus its centre.
meta = V.DynamicPhantom.load(DIR; volume = false)
# the tracking point (x, y, z mm)
track = meta.meta["tracking"]["descending aorta"]["point_mm"]
# arrival (+80 HU at the point) and the arterial peak
timing = V.DynamicPhantom.scan_times(meta; state = STATE, roi = "roi:descending aorta")
# two scans: 100 mm over the heart, 0.625 mm slices, 500 mA, at the arrival and at the peak
grid = meta.meta["grid"]
# the phantom's centre (x, y), on the isocentre
centre = grid["origin_mm"][1:2] .+ (grid["dims"][1:2] .- 1) .* grid["voxel_mm"] ./ 2
slices(zlo, zhi) = (
    (round(Int, (zlo - grid["origin_mm"][3]) / grid["voxel_mm"]) + 1):
    round(Int, (zhi - grid["origin_mm"][3]) / grid["voxel_mm"])
)
const HEART_Z = (207.4, 307.4) # mm: the 100 mm beam over the heart
heart = V.DynamicPhantom.load(
    DIR;
    zrange = max(1, first(slices(HEART_Z[1] - 15, 0))):last(slices(0, HEART_Z[2] + 15)),
)
# the 22 cm field on the heart and the descending aorta
field_centre_mm = (151.6, 227.8) .- Tuple(centre)
early, early_dose = scan(
    heart,
    timing.arrival;
    mA = 500,
    collimation_mm = 100.0,
    slice_mm = 0.625,
    seed = 7,
    field_centre_mm,
)
peak, peak_dose = scan(
    heart,
    timing.peak_time;
    mA = 500,
    collimation_mm = 100.0,
    slice_mm = 0.625,
    seed = 8,
    field_centre_mm,
)

# ---- ground truth: any compartment or group is a sum over its labels
# .mbf (mL/min/g), .mass (mg of iodine every second), .mass_g, .voxels
truth = heart.roi_table[(STATE, "LV myocardium")] # or "LV myocardium + epicardial vessels"

V.DynamicPhantom.hu(bundle, state, t) gives each label's idealized 70 keV HU, the scanner-free reference.