01 · Pulmonary Pipeline

Pulmonary Pipeline

From the raw XCAT chest to a perfused lung phantom, and the same lungs with a left lower lobe artery occluded.

StageWhat happens
1. LoadXCAT's 0.2 mm label volume, its NURBS heart and its lung and vessel meshes
2. Preparethe imaged pulmonary arteries and veins, the airways and lobes, the lungs to grow into
3. Growone arterial terminal and one venule per acinus, 29,912 of each
4. Flowblood flow from the pulmonary arterial and left atrial pressures alone
5. Transporta clinical contrast injection through the whole body
6. Compartmentsthe lobes 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. Occlusiona left lower lobe artery blocked, 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;
using PlutoUI: Slider, Select, TableOfContents
using Printf: @sprintf
using Serialization: serialize, deserialize
using Mmap: mmap
using Statistics: median, quantile
import TOML
import Vasculature as V
import CairoMakie as Mke
import GPUSelect
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", "pulmonary_pipeline_cache")
    const PHANTOM_DIR = joinpath(homedir(), "xcat-perfused", "pulmonary_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
SIX_STEPS

The six face neighbours of a voxel.

"The six face neighbours of a voxel."
const SIX_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),
)

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), the NURBS surfaces of the heart (the main pulmonary artery and the left atrium among them), and XCAT's triangle meshes of the two lungs and of the vessels inside them: the pulmonary arteries and veins, each drawn as a few hundred short tubes. XCAT also labels the airways, their walls and the air inside them. The phantom is saved on the 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"))

    # XCAT's surface meshes lie this far (mm) from its NURBS and label frame: the shift the chest volume was built with
    # (its lungs land on XCAT's own lung labels, section 2)
    const MESH_OFFSET = (131.4, 238.4, 193.8)
    const ARTERY_TUBES = ("right_pulmonary_arts", "left_pulmonary_arts")
    const VEIN_TUBES = ("right_pulmonary_veins", "left_pulmonary_veins")
    lung_meshes = V.XCAT.read_mesh_blocks(
        joinpath(XCAT_DIR, "vmale_50_heart_1_organs.raw");
        names = ("rlung", "llung"),
        offset = MESH_OFFSET,
    )
    vessel_tubes = V.XCAT.read_mesh_blocks(
        joinpath(XCAT_DIR, "vmale_50_heart_1_vessels.raw");
        names = (ARTERY_TUBES..., VEIN_TUBES...),
        offset = MESH_OFFSET,
    )
end;
begin
    "XCAT label groups for pictures: (name, colour, XCAT labels)."
    const LABEL_GROUPS = [
        ("lung", :gray40, (6,)),
        ("airways", :paleturquoise, (7, 11)),
        ("arteries", :red, (25, 28, 125)),
        ("veins", :royalblue, (24, 124)),
        ("heart blood", :mediumpurple, (19, 20, 21, 22)),
        ("myocardium", :darkorange, (15, 16, 17, 18)),
        ("fat", :khaki, (1,)),
        ("muscle", :rosybrown, (4, 13)),
        ("bone", :ivory, (5, 8, 9)),
        ("other", :gray65, ()),
    ]

    "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

    "The XCAT label at `p` (mm); 0 outside the chest."
    function xcat_label(p)
        k = round.(Int, (p .- XCAT_ORIGIN) ./ XCAT_VOXEL_MM) .+ 1
        return all(1 .<= k .<= XCAT_DIMS) ? xcat_labels[k...] : 0x00
    end
end;

z (mm): 290.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 chest 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 chest 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 = :fitzoom, # zoomed to the data, not to the box it could rotate in
        protrusions = 0,
    )
    Mke.hidedecorations!(ax)
    Mke.hidespines!(ax)
    return ax
end

2. Prepare the Growth Inputs

Growth runs on a 1 mm grid around both lungs, into the lung voxels.

  • Inlet trees. The main pulmonary artery from its valve, with XCAT's lung artery tubes joined to it, is one arterial tree. Each lung's vein tubes, joined and carried to the left atrium, are one venous tree. Every imaged vessel gets stubs, short side branches one vessel segment apart, so growth starts along its whole length, as real pulmonary vessels branch along theirs. The segment length is human pulmonary morphometry's for vessels of the imaged ones' calibre (Huang et al. 1996).

  • Lobes. XCAT draws no fissures, so the lobes come from the airways, as a lobe is assigned without fissures: the lobar bronchi are found from the airway tree's branching, and each lung voxel takes the lobe of the nearest lobar airway, by the distance through the lung.

  • Blockers. No new vessel may cross the airways, the heart's chambers, the aorta, the other system's imaged lumens, or a fissure: the boundary between two lobes, away from the hilum where the lobar vessels and bronchi enter.

growth_grid = let points = hcat(first(only(lung_meshes["rlung"])), first(only(lung_meshes["llung"])))
    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
"The lung: the 1 mm cells that are mostly XCAT's lung (label 6), the voxels the phantom paints as lung."
LUNG = cached("lung") do
    V.XCAT.label_mask(xcat_labels, XCAT_ORIGIN, XCAT_VOXEL_MM, growth_grid, (0x06,))
end;

The lung: 2752.3 mL (XCAT's lung meshes: 2813.4 mL; Dice 0.958 between the two, so the meshes sit on the labels).

STUB_LENGTHS

Stub spacing and length, from human pulmonary morphometry (Huang et al. 1996, test/reference): the segment length (between two branchings) of a vessel of the imaged vessels' length-weighted median diameter, and of one two orders smaller, the typical side branch, as the stub's length.

"""
Stub spacing and length, from human pulmonary morphometry (Huang et al. 1996, `test/reference`): the segment length (between
two branchings) of a vessel of the imaged vessels' length-weighted median diameter, and of one two orders smaller, the
typical side branch, as the stub's length.
"""
STUB_LENGTHS = let
    huang = TOML.parsefile(
        joinpath(pkgdir(V), "test", "reference", "huang1996_human_pulmonary_morphometry.toml"),
    )["data"]
    # segment length (mm) at a diameter (mm) `below` orders smaller, log-linear between orders
    function segment_length(table, diameter; below = 0)
        d = log.(table["diameter_mean_mm"])
        l = log.(table["length_mean_mm"])
        x = clamp(log(diameter), d[1], d[end])
        k = clamp(searchsortedlast(d, x), 1, length(d) - 1)
        order = clamp(k + (x - d[k]) / (d[k + 1] - d[k]) - below, 1, length(d))
        j = clamp(floor(Int, order), 1, length(d) - 1)
        w = order - j
        return exp((1 - w) * l[j] + w * l[j + 1])
    end
    function median_diameter(names)
        lines = [V.XCAT.tube_centerline(points) for name in names for (points, _) in vessel_tubes[name]]
        lengths = [sum(sqrt.(sum(abs2, diff(line.X; dims = 2); dims = 1))) for line in lines]
        diameters = [2 * line.r[1] for line in lines]
        order = sortperm(diameters)
        covered = cumsum(lengths[order])
        return diameters[order][findfirst(>=(covered[end] / 2), covered)]
    end
    artery = median_diameter(ARTERY_TUBES)
    vein = median_diameter(VEIN_TUBES)
    (
        artery = (
            diameter = artery,
            spacing = segment_length(huang["arterial_segments"], artery),
            depth = segment_length(huang["arterial_segments"], artery; below = 2),
        ),
        vein = (
            diameter = vein,
            spacing = segment_length(huang["venous_segments"], vein),
            depth = segment_length(huang["venous_segments"], vein; below = 2),
        ),
    )
end
"""
The arterial inlet tree: XCAT's main pulmonary artery (its NURBS surface, from the valve), with the lung artery tubes joined
into trees and grafted onto it (`V.XCAT.graft`). Centrelines are sampled at the stub spacing.
"""
artery_base = cached("artery_base") do
    step = STUB_LENGTHS.artery.spacing
    trunk = V.XCAT.centerline(xcat_surfaces["dias_pul_art"]; step)
    hub = vec(sum(trunk.X; dims = 2)) ./ size(trunk.X, 2)
    lines = [V.XCAT.tube_centerline(points; name, step) for name in ARTERY_TUBES for (points, _) in vessel_tubes[name]]
    V.XCAT.graft(trunk, V.XCAT.tube_forest(lines; toward = hub); name = "pulmonary_arteries")
end;
"""
The venous inlet trees, one per lung: its vein tubes joined into trees, the largest taking the others, carried straight to
the nearest blood of the left atrium (XCAT's label, so the veins end where the atrium's blood begins; `V.XCAT.drain`). The
ostium's radius is the lung's two pulmonary veins (Huang 1996 venous order 15, 12.97 mm) as one cube-law equivalent.
"""
vein_bases = cached("vein_bases") do
    step = STUB_LENGTHS.vein.spacing
    points, _ = V.XCAT.tessellate(xcat_surfaces["dias_la"]; nu = 60, nv = 60)
    grid = V.grid_around(points, 1.0; margin = 10)
    atrium = V.XCAT.label_mask(xcat_labels, XCAT_ORIGIN, XCAT_VOXEL_MM, grid, (0x15,))
    wall = [c for c in findall(atrium) if any(s -> !checkbounds(Bool, atrium, c + s) || !atrium[c + s], SIX_STEPS)]
    surface = reduce(hcat, [collect(V.center(grid, Tuple(c)...)) for c in wall])
    hub = vec(sum(surface; dims = 2)) ./ size(surface, 2)
    [
        V.XCAT.drain(
            V.XCAT.tube_forest(
                [V.XCAT.tube_centerline(points; name, step) for (points, _) in vessel_tubes[name]];
                toward = hub,
            ),
            surface;
            root_radius = cbrt(2 * (12.97 / 2)^3),
            name,
        ) for name in VEIN_TUBES
    ]
end;
airway_tree

The airway tree, from XCAT's airway labels (wall 7 and air 11) on a 0.6 mm grid: its distance from the trachea's top through the airway, cut into 3 mm shells; each shell's connected pieces are the tree's nodes, each joined to the piece of the shell before it that it touches. Returns the grid, the airway mask, each node's cells, parent and shell, and each cell's node.

"""
The airway tree, from XCAT's airway labels (wall 7 and air 11) on a 0.6 mm grid: its distance from the trachea's top through
the airway, cut into 3 mm shells; each shell's connected pieces are the tree's nodes, each joined to the piece of the shell
before it that it touches. Returns the grid, the airway mask, each node's cells, parent and shell, and each cell's node.
"""
function airway_tree(; cell = 0.6, shell_mm = 3.0)
    grid = V.VoxelGrid(growth_grid.origin, cell, Tuple(ceil.(Int, growth_grid.dims ./ cell)))
    airway = V.XCAT.label_mask(xcat_labels, XCAT_ORIGIN, XCAT_VOXEL_MM, grid, (0x07, 0x0b); fraction = 1.0e-9)
    steps = [CartesianIndex(i, j, k) for i in -1:1, j in -1:1, k in -1:1 if (i, j, k) != (0, 0, 0)]
    # keep the largest connected piece
    piece = zeros(Int32, size(airway))
    sizes = Int[]
    for seed in findall(airway)
        piece[seed] == 0 || continue
        push!(sizes, 0)
        stack = [seed]
        piece[seed] = length(sizes)
        while !isempty(stack)
            c = pop!(stack)
            sizes[end] += 1
            for s in steps
                n = c + s
                checkbounds(Bool, airway, n) && airway[n] && piece[n] == 0 || continue
                piece[n] = length(sizes)
                push!(stack, n)
            end
        end
    end
    airway .= piece .== argmax(sizes)
    # distance (cells) from the trachea's top, through the airway
    top = maximum(c[3] for c in findall(airway))
    distance = fill(Int32(-1), size(airway))
    queue = [c for c in findall(airway) if c[3] >= top - 1]
    foreach(c -> distance[c] = 0, queue)
    head = 1
    while head <= length(queue)
        c = queue[head]
        head += 1
        for s in steps
            n = c + s
            checkbounds(Bool, airway, n) && airway[n] && distance[n] < 0 || continue
            distance[n] = distance[c] + 1
            push!(queue, n)
        end
    end
    shell_of(c) = floor(Int, distance[c] * cell / shell_mm)
    # nodes: the connected pieces of each shell
    node = zeros(Int32, size(airway))
    members = Vector{Vector{CartesianIndex{3}}}()
    shell = Int[]
    for seed in queue
        node[seed] == 0 || continue
        push!(members, CartesianIndex{3}[])
        push!(shell, shell_of(seed))
        stack = [seed]
        node[seed] = length(members)
        while !isempty(stack)
            c = pop!(stack)
            push!(members[end], c)
            for s in steps
                n = c + s
                checkbounds(Bool, airway, n) && airway[n] && node[n] == 0 && shell_of(n) == shell[end] || continue
                node[n] = length(members)
                push!(stack, n)
            end
        end
    end
    parent = zeros(Int, length(members))
    for (id, cells) in enumerate(members)
        for c in cells
            k = findfirst(s -> checkbounds(Bool, airway, c + s) && airway[c + s] && shell[node[c + s]] == shell[id] - 1, steps)
            k === nothing || (parent[id] = node[c + steps[k]]; break)
        end
    end
    return (; grid, airway, members, parent, shell)
end
AIRWAYS = cached("airways") do
    airway_tree()
end;
"""
The five lobar bronchi, as nodes of the airway tree. Walking down from the trachea, a split is the first node whose second
largest branch holds at least `share` of the airway below it. The carina's two branches are the main bronchi (the right at
smaller x). The right main bronchus's first split gives off the upper lobe bronchus, the branch that goes up; the bronchus
intermedius then splits into the middle lobe bronchus (the anterior one) and the lower lobe bronchus. The left main bronchus
splits into the upper and lower lobe bronchi.
"""
LOBAR_BRONCHI = let tree = AIRWAYS, n = length(tree.members)
    children = [Int[] for _ in 1:n]
    foreach(i -> tree.parent[i] > 0 && push!(children[tree.parent[i]], i), 1:n)
    # airway cells below each node, and their summed positions
    below = Float64.(length.(tree.members))
    position = zeros(3, n)
    for i in 1:n
        position[:, i] = sum(collect(Tuple(c)) for c in tree.members[i])
    end
    for i in sortperm(tree.shell; rev = true)
        p = tree.parent[i]
        p > 0 || continue
        below[p] += below[i]
        position[:, p] .+= position[:, i]
    end
    centre(i) = collect(tree.grid.origin) .+ (position[:, i] ./ below[i] .- 1) .* tree.grid.h
    function first_split(i, share)
        while true
            branches = sort(children[i]; by = c -> below[c], rev = true)
            isempty(branches) && error("no airway split below node $i")
            length(branches) >= 2 && below[branches[2]] >= share * below[i] && return branches[1:2]
            i = branches[1]
        end
        return
    end
    # a split's two branches ordered by one coordinate of their centres (1 x, 2 y front to back, 3 z up)
    ordered(pair, axis; rev = false) = pair[sortperm([centre(i)[axis] for i in pair]; rev)]
    trachea = argmax([tree.parent[i] == 0 ? below[i] : 0.0 for i in 1:n])
    right, left = ordered(first_split(trachea, 0.25), 1)
    upper, intermedius = ordered(first_split(right, 0.15), 3; rev = true)
    middle, lower = ordered(first_split(intermedius, 0.15), 2)
    left_upper, left_lower = ordered(first_split(left, 0.15), 3; rev = true)
    (
        names = ["right upper", "right middle", "right lower", "left upper", "left lower"],
        nodes = [upper, middle, lower, left_upper, left_lower],
        children = children,
    )
end;
"""
Each 1 mm lung voxel's lobe (1-5, `LOBAR_BRONCHI.names`; 0 outside the lung): the lobe of the nearest lobar airway, by the
distance through the lung (6-connected, all lobes spreading at once from their own airways).
"""
LOBES = cached("lobes") do
    lobe = zeros(Int8, growth_grid.dims)
    for (k, node) in enumerate(LOBAR_BRONCHI.nodes)
        stack = [node]
        while !isempty(stack)
            i = pop!(stack)
            append!(stack, LOBAR_BRONCHI.children[i])
            for c in AIRWAYS.members[i]
                p = AIRWAYS.grid.origin .+ (Tuple(c) .- 1) .* AIRWAYS.grid.h
                q = round.(Int, (p .- growth_grid.origin) ./ growth_grid.h) .+ 1
                all(1 .<= q .<= growth_grid.dims) && (lobe[q...] = k)
            end
        end
    end
    queue = findall(!=(0), lobe)
    head = 1
    while head <= length(queue)
        c = queue[head]
        head += 1
        for s in SIX_STEPS
            n = c + s
            checkbounds(Bool, lobe, n) && lobe[n] == 0 && LUNG[n] || continue
            lobe[n] = lobe[c]
            push!(queue, n)
        end
    end
    lobe .* LUNG
end;
fissures
fissures(lobes, airways, grid; hilum_mm = 5.0) -> BitArray

The fissures on grid: lung voxels of lobes beside a voxel of another lobe, except within hilum_mm of an airway (airways, as airway_tree returns it), where the lobar vessels and bronchi enter the lobes.

"""
    fissures(lobes, airways, grid; hilum_mm = 5.0) -> BitArray

The fissures on `grid`: lung voxels of `lobes` beside a voxel of another lobe, except within `hilum_mm` of an airway
(`airways`, as `airway_tree` returns it), where the lobar vessels and bronchi enter the lobes.
"""
function fissures(lobes, airways, grid; hilum_mm = 5.0)
    near_airway = falses(size(lobes))
    r = ceil(Int, hilum_mm)
    for c in findall(airways.airway)
        p = airways.grid.origin .+ (Tuple(c) .- 1) .* airways.grid.h
        q = round.(Int, (p .- grid.origin) ./ grid.h) .+ 1
        for k in -r:r, j in -r:r, i in -r:r
            i^2 + j^2 + k^2 <= hilum_mm^2 || continue
            n = q .+ (i, j, k)
            all(1 .<= n .<= size(lobes)) && (near_airway[n...] = true)
        end
    end
    fissure = falses(size(lobes))
    for c in findall(!=(0), lobes)
        near_airway[c] && continue
        other(s) = checkbounds(Bool, lobes, c + s) && lobes[c + s] != 0 && lobes[c + s] != lobes[c]
        any(other, SIX_STEPS) && (fissure[c] = true)
    end
    return fissure
end
AIRWAYS_AND_HEART

The labels no new vessel may cross, for both trees: the airways (wall 7, air 11), the heart's chambers and the aorta.

"The labels no new vessel may cross, for both trees: the airways (wall 7, air 11), the heart's chambers and the aorta."
const AIRWAYS_AND_HEART = (0x07, 0x0b, 0x13, 0x14, 0x15, 0x16, 0x1c)
"""
The blockers, at 0.4 mm, one set per tree: `AIRWAYS_AND_HEART`, the other system's imaged lumens (veins 24 and 124 for the
arteries, arteries 25 and 125 for the veins) and the fissures.
"""
BLOCKERS = cached("blockers") do
    fissure = fissures(LOBES, AIRWAYS, growth_grid)
    blocked(labels) = with_fissures(
        V.XCAT.label_blockers(xcat_labels, XCAT_ORIGIN, XCAT_VOXEL_MM, growth_grid, labels),
        fissure,
        growth_grid,
    )
    (
        artery = blocked((AIRWAYS_AND_HEART..., 0x18, 0x7c)),
        vein = blocked((AIRWAYS_AND_HEART..., 0x19, 0x7d)),
        fissure = fissure,
    )
end;
with_fissures

A tree's blockers (mask, origin, cell) with every cell whose centre lies in a fissure voxel (on grid) added.

"A tree's blockers `(mask, origin, cell)` with every cell whose centre lies in a fissure voxel (on `grid`) added."
function with_fissures((mask, origin, cell), fissure, grid)
    for c in CartesianIndices(mask)
        p = origin .+ (Tuple(c) .- 1) .* cell
        k = round.(Int, (p .- grid.origin) ./ grid.h) .+ 1
        all(1 .<= k .<= size(fissure)) && fissure[k...] && (mask[c] = 0x01)
    end
    return (mask, origin, cell)
end
"The 1 mm cells no terminal or stub may end in: mostly airway, chamber or aortic blood."
FORBID = V.XCAT.label_mask(xcat_labels, XCAT_ORIGIN, XCAT_VOXEL_MM, growth_grid, AIRWAYS_AND_HEART);
inlet

An inlet tree with its stubs (V.XCAT.penetrating_stubs at the morphometric spacing; where a vessel runs through the lung, as almost all of them do, the stub leaves sideways) and its pass-through voxels: its lumens outside the lung, which new vessels may cross but no terminal may end in.

"""
An inlet tree with its stubs (`V.XCAT.penetrating_stubs` at the morphometric spacing; where a vessel runs through the lung,
as almost all of them do, the stub leaves sideways) and its pass-through voxels: its lumens outside the lung, which new
vessels may cross but no terminal may end in.
"""
function inlet(tree, lengths, blockers)
    stubbed, report = V.XCAT.penetrating_stubs(
        tree,
        growth_grid,
        LUNG;
        spacing = lengths.spacing,
        depth = lengths.depth,
        reach = lengths.spacing,
        domain = LUNG,
        forbid = FORBID,
        blockers,
    )
    return (tree = stubbed, report = report, pass = V.XCAT.vessel_tube(stubbed, growth_grid) .& .!FORBID .& .!LUNG)
end
artery_inputs = cached("artery_inputs") do
    inlet(artery_base, STUB_LENGTHS.artery, BLOCKERS.artery)
end;
vein_inputs = cached("vein_inputs") do
    [inlet(tree, STUB_LENGTHS.vein, BLOCKERS.vein) for tree in vein_bases]
end;
LOBE_COLOURS

Lobe colours: right lobes warm, left lobes cool.

"Lobe colours: right lobes warm, left lobes cool."
const LOBE_COLOURS = ["#e41a1c", "#ff7f00", "#ffd92f", "#377eb8", "#984ea3"]

y (mm, front to back): 262.0

Lobeshare of the lung's volume
right upper19.6%
right middle10.4%
right lower19.8%
left upper25.2%
left lower25.1%

Fissure voxels: 46107 (1.7% of the lung). Stubs every 1.76 mm on the arteries (median imaged diameter 1.38 mm; 3459 of 3722 sites took one), every 2.45 mm on the veins (1.73 mm; 2437 of 2574 sites).

3. Grow

HCT-CCO's staged constrained constructive optimization, on the GPU: LUNG_GROWTH grows one arterial terminal per acinus (29,912, by micro-CT stereology of human lungs) from the main pulmonary artery, and LUNG_VEIN_GROWTH one venule per acinus into each lung's pulmonary veins. The vessels inside each acinus are not grown: the flow model carries them as Huang's per-order tables below each terminal.

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, blockers, 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"), LUNG .| 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, territories = allow)
    return V.vtree(engine)
end
arteries = cached("arteries") do
    # the preset is this subject's: its tissue is these lung voxels and its root the trunk's median ring
    @assert V.LUNG_GROWTH.tissue_ml ≈ count(LUNG) / 1000 && V.LUNG_GROWTH.root_caps[1] ≈ artery_base.r[2]
    grow_forest(V.LUNG_GROWTH, [artery_inputs.tree], artery_inputs.pass, BLOCKERS.artery, "arteries")
end;
veins = cached("veins") do
    @assert all(V.LUNG_VEIN_GROWTH.root_caps .≈ [tree.r[1] for tree in vein_bases])
    grow_forest(
        V.LUNG_VEIN_GROWTH,
        [v.tree for v in vein_inputs],
        reduce(.|, [v.pass for v in vein_inputs]),
        BLOCKERS.vein,
        "veins",
    )
end;
"""
The lung's surface: each surface voxel's centre (mm) and its number among the lung voxels, so per-voxel values (such as
perfusion) can be painted on it.
"""
LUNG_SURFACE = let
    function surface_of(lung, origin)
        voxels = findall(lung)
        on_surface(I) = any(s -> !checkbounds(Bool, lung, I + s) || !lung[I + s], SIX_STEPS)
        surface = [n for (n, I) in enumerate(voxels) if on_surface(I)]
        return (points = [Mke.Point3f(Tuple(voxels[n]) .- 1 .+ origin) for n in surface], voxels = surface)
    end
    surface_of(LUNG, growth_grid.origin)
end;
draw_lung_shell!

Draw every fourth point of the lungs' surface as a faint shell around the vessels.

"Draw every fourth point of the lungs' surface as a faint shell around the vessels."
function draw_lung_shell!(ax)
    return Mke.scatter!(ax, LUNG_SURFACE.points[1:4:end]; color = (:gray50, 0.04), markersize = 4, 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

show vessels of radius ≥

4. Flow

No flow is prescribed: the mean pulmonary arterial pressure (17 mmHg) and the left atrial pressure (11 mmHg) drive blood through every vessel, the alveolar capillary sheets and the veins. Normoxic pulmonary vessels carry little tone, so the lung is passive: vessels distend with their pressure and the capillary sheets with theirs, and there is one state. Each 1 mm lung voxel gets its flow from the terminals that feed it.

The lung carries the whole cardiac output, so in this phantom the cardiac output is the lung's own flow (section 5): the body and the lung carry the same blood.

tissue_centres

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

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

Blood flow (mL/min per mL of lung) of every 1 mm lung voxel in circuit circuit.

"Blood flow (mL/min per mL of lung) of every 1 mm lung voxel in circuit `circuit`."
function voxel_perfusion(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
    return vec(V.OrganCT.tissue_iodine(terminal_flow, flows.pairs, flows.terr, voxels)) .* 60
end
lung_coronal

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

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

y (mm, front to back): 262.0

modelmeasured
lung blood flow (L/min)3.474.8 ± 0.8 at rest (Bentley 2020)
pressure drop: arteries · capillary sheets · veins0.23 · 0.17 · 0.60arteries ≈ veins, sheets < 0.16 (Hakim 1982, dog lobes)
pulmonary blood volume (mL)579526 ± 87 (Nelsson 2021), 471 ± 122 (Kanski 2013)
mean pulmonary transit, valve to left atrium (s)10.05.7 ± 1.4 (Nelsson 2021), 5.3 (Dock 1961)

The flow is about a quarter below the measured mean, within two standard deviations, and the grown veins take more of the pressure drop than the arteries. Both come from the venous tree: it is grown with the arteries' branching exponent from two ostia per lung (no lung venous exponent is measured), which makes its small veins narrower than human casts show. The blood volume is close to the measured, so the lower flow makes the mean transit longer than measured: iodine stays in the lungs longer, and less of it has left by any scan time than in a real lung. These are stated, not tuned: the flow is a prediction from pressures and geometry, and the phantom's ground truth is whatever flow the model gives.

5. Transport

The lab's current injection (INJECTION, below) into the right arm, carried through the whole-body circulation, the right heart and the lung's own trees and capillary sheets, then the left heart and the body, with recirculation. The cardiac output is the lung's solved flow (V.organ_flow), so the bolus is diluted into the blood the lung really carries.

INJECTION

The injection: the lab's current protocol (Hadjiabdolhamid et al. 2024, Shojazadeh et al. 2026), the same as the heart's: 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.

"""
The injection: the lab's current protocol (Hadjiabdolhamid et al. 2024, Shojazadeh et al. 2026), the same as the heart's:
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.
"""
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 lung circuit circuit, in its one (passive) state.

"Contrast transport through the body and the lung circuit `circuit`, in its one (passive) state."
function transport_state(circuit)
    spec = V.TransportSpec(V.LUNG_TRANSPORT; phys = circuit.phys, tissue_ml = Float32(size(tissue_centres, 2) / 1000))
    return ("rest", circuit, V.transport(spec, circuit, INJECTION; times = V.LUNG_PHANTOM.times))
end
transport_cache

Contrast transport through circuit, solved once and kept in CACHE_DIR/name.jls; returns that file. The full result (every vessel's iodine at every frame) is large, so the notebook does not hold it: save_phantom reads it while it writes, and the figures use transport_curves.

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

The RV and LA blood and the whole lung's iodine (mg/mL) over time, and the cardiac output, from a transport file.

"The RV and LA blood and the whole lung's iodine (mg/mL) over time, and the cardiac output, from a transport file."
function transport_curves(path)
    tissue_ml = size(tissue_centres, 2) / 1000
    _, circuit, result = only(deserialize(path))
    held = vec(sum(result.tissue; dims = 2) .+ sum(result.artery; dims = 2) .+ sum(result.vein; dims = 2))
    return (
        blood_times = result.body.t,
        rv = result.body.mean[:rv],
        la = result.body.mean[:la],
        lung = held ./ tissue_ml,
        co_l_min = 60 * result.body.circ.co / 1000,
    )
end
TRANSPORT_CURVES = transport_curves(TRANSPORT);

6. Compartments

A compartment is the region ground truth is given for: a region of the lungs with everything in it, the lung tissue and every artery, vein and lumen that lies in it or overlaps it. Here: the five lobes, and each lung's hilar vessels, its pulmonary arteries and veins between the lung and the heart. The main pulmonary artery, where the arterial input is measured, is in neither. The lungs, and the right and left lung, are groups of the lobes. Each is also grouped with its hilar vessels: the ideal first-pass compartment, which counts the iodine on its way in through the hilar arteries and out through the hilar veins.

Edit the compartments here: split_lobes = false gives one compartment per lung.

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.

true
split_lobes = true # the five lobes apart; false: one compartment per lung
begin
    ROI_NAMES = [split_lobes ? LOBAR_BRONCHI.names : ["right lung", "left lung"]; "right hilar vessels"; "left hilar vessels"]
    ROI_GROUPS = let n = length(ROI_NAMES) - 2, right = split_lobes ? [1, 2, 3] : [1], left = split_lobes ? [4, 5] : [2]
        [
            "lungs" => collect(1:n),
            "lungs + hilar vessels" => collect(1:(n + 2)),
            (split_lobes ? ["right lung" => right, "left lung" => left] : Pair{String, Vector{Int}}[])...,
            "right lung + hilar vessels" => [right; n + 1],
            "left lung + hilar vessels" => [left; n + 2],
        ]
    end
    ROI_NAMES, ROI_GROUPS
end
"""
Where the lungs would be with their vessels: XCAT's lung closed by 5 mm on the 1 mm grid, so a vessel lumen inside or
overlapping the lung is in it (the hilar vessels outside the lung are not); and the lobes carried 15 mm out from the lung, so
those lumens get the lobe they lie in.
"""
LUNG_ENVELOPE = cached("lung_envelope") do
    envelope = V.XCAT.closing(V.XCAT.label_mask(xcat_labels, XCAT_ORIGIN, XCAT_VOXEL_MM, growth_grid, (0x06,); fraction = 1.0e-9), 5)
    (inside = envelope, lobe = carried(LOBES, 15))
end;
carried

labels with each nonzero label carried steps voxels out into the zeros around it, nearest first.

"`labels` with each nonzero label carried `steps` voxels out into the zeros around it, nearest first."
function carried(labels, steps)
    out = copy(labels)
    front = findall(!=(0), out)
    for _ in 1:steps
        next = CartesianIndex{3}[]
        for c in front, s in SIX_STEPS
            n = c + s
            checkbounds(Bool, out, n) && out[n] == 0 || continue
            out[n] = out[c]
            push!(next, n)
        end
        front = next
    end
    return out
end
make_roi
make_roi(labels, envelope, grid, classify, hilar; split_lobes) -> roi

The compartment rule roi(p) for the 0.2 mm voxel at p (0 = none): lung tissue takes its lobe; a vessel lumen takes the lobe it lies in when it is inside the lungs' envelope (both on grid); outside it, an imaged pulmonary segment's lumen (classify) takes its lung's hilar vessels (hilar, 1 right, 2 left, 0 the main pulmonary artery). (Only tissue and vessel voxels take a compartment.) Everything it reads is captured with its type, so it compiles to fast code.

"""
    make_roi(labels, envelope, grid, classify, hilar; split_lobes) -> roi

The compartment rule `roi(p)` for the 0.2 mm voxel at `p` (0 = none): lung tissue takes its lobe; a vessel lumen takes the
lobe it lies in when it is inside the lungs' envelope (both on `grid`); outside it, an imaged pulmonary segment's lumen
(`classify`) takes its lung's hilar vessels (`hilar`, 1 right, 2 left, 0 the main pulmonary artery). (Only tissue and
vessel voxels take a compartment.) Everything it reads is captured with its type, so it compiles to fast code.
"""
function make_roi(labels, envelope, grid, classify, hilar; split_lobes)
    inside = envelope.inside
    lobe = envelope.lobe
    origin = grid.origin
    n_lungs = split_lobes ? 5 : 2
    return function roi(p)
        voxel = round.(Int, (p .- XCAT_ORIGIN) ./ XCAT_VOXEL_MM) .+ 1
        all(1 .<= voxel .<= XCAT_DIMS) || return 0
        k = round.(Int, p .- origin) .+ 1
        all(1 .<= k .<= size(lobe)) || return 0
        if labels[voxel...] == 0x06 || inside[k...]
            l = Int(lobe[k...])
            l == 0 && return 0
            return split_lobes ? l : (l <= 3 ? 1 : 2)
        end
        kind, j, _ = classify(p)
        kind == V.VOX_VESSEL || return 0
        side = j > 0 ? hilar.artery[j] : hilar.vein[-j]
        return side == 0 ? 0 : n_lungs + side
    end
end
"""
Each imaged segment's lung (1 right, 2 left; 0 the main pulmonary artery and every grown segment), by the inlet tree's
vessel nearest its middle: the right and left pulmonary artery tubes, and each lung's venous tree.
"""
HILAR_SIDE = let
    function sides(tree, bases)
        side = zeros(Int8, length(tree.r))
        for j in findall(tree.isbase)
            middle = (tree.a[:, j] .+ tree.b[:, j]) ./ 2
            side[j] = argmin(((d, s),) -> d, ((minimum(sum(abs2, X .- middle; dims = 1)), s) for (X, s) in bases))[2]
        end
        return side
    end
    trunk = artery_base.vessel .== "dias_pul_art"
    right = startswith.(artery_base.vessel, "right")
    left = startswith.(artery_base.vessel, "left")
    (
        artery = sides(arteries, [(artery_base.X[:, trunk], 0), (artery_base.X[:, right], 1), (artery_base.X[:, left], 2)]),
        vein = sides(veins, [(vein_bases[1].X, 1), (vein_bases[2].X, 2)]),
    )
end;
roi (generic function with 1 method)
roi = make_roi(xcat_labels, LUNG_ENVELOPE, growth_grid, classify, HILAR_SIDE; split_lobes)
roi_colormap

Categorical colours of the compartments (LOBE_COLOURS, or one per lung; teal for the hilar vessels, dark on the right).

"Categorical colours of the compartments (`LOBE_COLOURS`, or one per lung; teal for the hilar vessels, dark on the right)."
roi_colormap() = Mke.cgrad(
    [:black; split_lobes ? LOBE_COLOURS : ["#e41a1c", "#377eb8"]; "#00838f"; "#80deea"],
    length(ROI_NAMES) + 1;
    categorical = true,
)

7. Voxelize and Save

The 0.2 mm phantom of the whole XCAT chest, 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 lungs' iodine ledger does not balance.

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

    # the imaged segments of the grown trees, indexed for naming XCAT's drawn pulmonary lumens near them
    const NEAR_MM = 2.0 # a drawn lumen is an imaged segment's when it lies within this of the segment's surface
    # (the arteries' reaches 10 mm: inside the main pulmonary artery's surface every voxel is the trunk's, wherever its
    # grown radius puts the segment's own surface)
    const ARTERY_INDEX = V.XCAT.segment_index(V.XCAT.imaged_segments(arteries); reach = 10.0)
    const VEIN_INDEX = V.XCAT.segment_index(V.XCAT.imaged_segments(veins); reach = NEAR_MM)
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 (`V.XCAT.great_vessel_transit`). Each state's transit is volume over flow (`V.pool_delay`).

XCAT vmale_50's aorta is about three standard deviations narrow (21–22 mm ascending and 15 mm descending, against
33 ± 4 and 24 ± 3 mm in adult men; Wolak et al. 2008): it holds 27 mL from the valve to the arch's top, where the body
model the bolus is carried through holds 60 mL. The aortic tree's volumes are scaled to the body model's
(`V.XCAT.scale_tree`), as for the heart, so the aorta's timing comes from the same body as everything else.
"""
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.LUNG_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 main pulmonary artery's lumen, at 0.4 mm: inside XCAT's `dias_pul_art` surface. XCAT's label volume paints this lumen
as the right ventricle's blood (label 20), so its surface, not its label, tells the trunk from the ventricle; its volume
(mL) is the blood between the valve and the pulmonary arteries the label volume does show.
"""
PULMONARY_TRUNK = cached("pulmonary_trunk") do
    points, triangles = V.XCAT.tessellate(xcat_surfaces["dias_pul_art"]; nu = 160, nv = 160)
    grid = V.grid_around(points, 0.4; margin = 2)
    inside = V.voxelize(points, triangles, grid)
    (grid = grid, inside = inside, ml = count(inside) * 0.4^3 / 1000)
end;
"""
The lungs' phantom spec: the body pools, with the descending aorta timed from this subject's arch and the pulmonary
arteries the label volume shows timed from the valve (the trunk's blood before them).
"""
PHANTOM_SPEC = let pools = copy(V.LUNG_PHANTOM.pools)
    function lead(name, ml)
        i = findfirst(p -> p.name == name, pools)
        d = pools[i]
        return pools[i] = V.Pool(d.name, d.material, d.segment, d.what; through = d.through, lead_ml = ml, lead_through = d.lead_through)
    end
    lead("descending aorta", GREAT_VESSEL_TRANSIT.apex_ml)
    lead("pulmonary arteries", Float32(PULMONARY_TRUNK.ml))
    V.PhantomSpec(V.LUNG_PHANTOM; pools)
end;
make_classifier
make_classifier(labels, grid, numbers, artery_index, vein_index, transit, trunk) -> classify

The classifier classify(p): what is at the 0.2 mm voxel centre p. Lung tissue is its 1 mm voxel; a pulmonary vessel lumen (XCAT's labels 25 and 125 on the pulmonary arterial tree, 24 and 124 on the pulmonary venous tree, and the ventricle's label inside the main pulmonary artery's surface, trunk) is the imaged segment it lies in or beside, else its vessel's pool and its place along the vessel; every other great-vessel voxel is its pool; every other XCAT label is decided by the label alone (a body pool, V.XCAT_POOL_OF_LABEL, the heart lumped, or a static material). Everything it reads is captured with its type, so it compiles to fast code.

"""
    make_classifier(labels, grid, numbers, artery_index, vein_index, transit, trunk) -> classify

The classifier `classify(p)`: what is at the 0.2 mm voxel centre `p`. Lung tissue is its 1 mm voxel; a pulmonary vessel
lumen (XCAT's labels 25 and 125 on the pulmonary arterial tree, 24 and 124 on the pulmonary venous tree, and the ventricle's
label inside the main pulmonary artery's surface, `trunk`) is the imaged segment it lies in or beside, else its vessel's pool
and its place along the vessel; every other great-vessel voxel is its
pool; every other XCAT label is decided by the label alone (a body pool, `V.XCAT_POOL_OF_LABEL`, the heart lumped, 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_index, vein_index, transit, trunk)
    # 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 (0x06, 0x14, 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)
    ventricle = (V.VOX_POOL, Int32(V.XCAT_POOL_OF_LABEL[0x14]), 0.0f0)
    # a pulmonary lumen: the imaged segment it lies in or beside, else its pool along the vessel
    function imaged_or_pool(p, index, sign, t)
        j, distance = V.XCAT.nearest_segment(index, p)
        return j > 0 && distance <= NEAR_MM ? (V.VOX_VESSEL, sign * Int32(j), 0.0f0) : vessel_pool(p, t)
    end

    in_trunk(p) = (
        k = round.(Int, (p .- trunk.grid.origin) ./ trunk.grid.h) .+ 1;
        all(1 .<= k .<= size(trunk.inside)) && trunk.inside[k...]
    )

    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]

        # the main pulmonary artery, painted as the ventricle's blood: its imaged trunk segment
        if label == 0x14
            in_trunk(p) || return ventricle
            j, _ = V.XCAT.nearest_segment(artery_index, p)
            return j > 0 ? (V.VOX_VESSEL, Int32(j), 0.0f0) : ventricle
        end
        if label == 0x06
            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 == 0x7d && return imaged_or_pool(p, artery_index, Int32(1), 0x02)
        label == 0x7c && return imaged_or_pool(p, vein_index, Int32(-1), 0x05)
        label == 0x1c && return vessel_pool(p, 0x01)
        if label == 0x19
            t = V.XCAT.vessel_tree_at(transit, p, (0x01, 0x02))
            return t == 0x02 ? imaged_or_pool(p, artery_index, Int32(1), t) : vessel_pool(p, t)
        end
        t = V.XCAT.vessel_tree_at(transit, p, (0x03, 0x04, 0x05, 0x06, 0x07))
        return t == 0x05 ? imaged_or_pool(p, vein_index, Int32(-1), t) : vessel_pool(p, t)
    end
end
classify (generic function with 1 method)
classify = make_classifier(
    xcat_labels,
    growth_grid,
    TISSUE_VOXEL_NUMBER,
    ARTERY_INDEX,
    VEIN_INDEX,
    GREAT_VESSEL_TRANSIT,
    PULMONARY_TRUNK,
)
TRACKING_POINTS

The bolus-tracking points: the main pulmonary artery at the middle of its imaged trunk (the centre of its lumen there), and the right ventricle at its blood's deepest point (farthest from its wall, on a 1 mm grid).

"""
The bolus-tracking points: the main pulmonary artery at the middle of its imaged trunk (the centre of its lumen there), and
the right ventricle at its blood's deepest point (farthest from its wall, on a 1 mm grid).
"""
TRACKING_POINTS = let
    trunk = findall(==("dias_pul_art"), artery_base.vessel)
    artery = Tuple(artery_base.X[:, trunk[(length(trunk) + 1) ÷ 2]])
    ["main pulmonary artery" => artery, "right ventricle" => deepest_point(xcat_labels, 0x14)]
end
deepest_point

The point (mm) of label label's voxels deepest inside them (farthest from their surface), on a 1 mm grid.

"The point (mm) of label `label`'s voxels deepest inside them (farthest from their surface), on a 1 mm grid."
function deepest_point(labels, label)
    step = round(Int, 1 / XCAT_VOXEL_MM)
    coarse = labels[1:step:end, 1:step:end, 1:step:end]
    blood = coarse .== label
    depth = fill(-1, size(blood))
    queue = [c for c in findall(blood) if any(s -> !checkbounds(Bool, blood, c + s) || !blood[c + s], SIX_STEPS)]
    foreach(c -> depth[c] = 0, queue)
    head = 1
    while head <= length(queue)
        c = queue[head]
        head += 1
        for s in SIX_STEPS
            n = c + s
            checkbounds(Bool, blood, n) && blood[n] && depth[n] < 0 || continue
            depth[n] = depth[c] + 1
            push!(queue, n)
        end
    end
    return Tuple(XCAT_ORIGIN .+ (Tuple(argmax(depth)) .- 1) .* (step * XCAT_VOXEL_MM))
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" => "lungs (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 part of the phantom is read back: an 80 mm slab through the hila for the axial views, and one coronal plane through the main pulmonary artery. Labels and compartments are categorical; the idealized 70 keV image (the phantom as a perfect scanner would see it) is gray, in a mediastinal and a lung window; iodine and ground-truth perfusion use :turbo.

Ground-truth perfusion 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, lung tissue and the vessels in it alike.

inspect

The lungs' box of a saved phantom, 80 mm of it about the main pulmonary artery's tracking point (z), the only part read back. Returns the bundle (labels and compartments) and its first z (mm). Ground truth is each compartment's own, from the bundle's tables.

"""
The lungs' box of a saved phantom, 80 mm of it about the main pulmonary artery's tracking point (z), the only part read back.
Returns the bundle (labels and compartments) and its first z (mm). Ground truth is each compartment's own, from the bundle's
tables.
"""
function inspect(dir; half_mm = 40)
    point = V.DynamicPhantom.load(dir; volume = false).meta["tracking"]["main pulmonary artery"]["point_mm"]
    lower = collect(growth_grid.origin) .- 5
    upper = collect(growth_grid.origin) .+ collect(growth_grid.dims) .+ 5
    voxel(mm, d) = clamp(round(Int, (mm - XCAT_ORIGIN[d]) / XCAT_VOXEL_MM) + 1, 1, XCAT_DIMS[d])
    bundle = V.DynamicPhantom.load(
        dir;
        rois = true,
        zrange = voxel(point[3] - half_mm, 3):voxel(point[3] + half_mm, 3),
        xrange = voxel(lower[1], 1):voxel(upper[1], 1),
        yrange = voxel(lower[2], 2):voxel(upper[2], 2),
    )
    return (bundle = bundle, z0 = bundle.origin_mm[3])
end
healthy = inspect(HEALTHY_DIR);
label_lookups

Per-label lookups of a bundle at time t: kind (for pictures), idealized 70 keV HU and iodine (mg/mL).

"Per-label lookups of a bundle at time `t`: kind (for pictures), idealized 70 keV HU and iodine (mg/mL)."
function label_lookups(bundle, t)
    kind_of = zeros(UInt8, 65536)
    for (label, row) in bundle.table
        label == 0 && continue
        kind_of[label + 1] =
            row.kind == "static" ? 1 :
            row.kind == "pool" ? (occursin("blood", row.base) ? 3 : 2) :
            Dict("tissue" => 4, "artery" => 5, "vein" => 6)[row.kind]
    end
    hu = fill(-1000.0f0, 65536)
    for (label, value) in V.DynamicPhantom.hu(bundle, "rest", t)
        hu[label + 1] = value
    end
    iodine = zeros(Float32, 65536)
    for (label, value) in V.DynamicPhantom.iodine(bundle, "rest", t)
        iodine[label + 1] = value
    end
    return (; kind_of, hu, iodine)
end
# the kinds of label, for pictures
const KIND_COLOURS, KIND_NAMES = (
    [:gray30, :gray65, :mediumpurple, :gray45, :red, :deepskyblue],
    ["static (air, cartilage)", "body tissue", "blood", "lung", "artery", "vein"],
)
panel!

One heatmap panel with its own scale underneath (a colour bar, or a legend for categories), at fig[row, column]; xs and ys (mm) span image.

"""
One heatmap panel with its own scale underneath (a colour bar, or a legend for categories), at `fig[row, column]`; `xs`
and `ys` (mm) span `image`.
"""
function panel!(fig, row, column, title, xs, ys, image; colormap, colorrange, label = nothing, legend = nothing, yreversed = true)
    cell = fig[row, column] = Mke.GridLayout()
    ax = Mke.Axis(cell[1, 1]; title, aspect = Mke.DataAspect(), yreversed)
    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
slice_figure

One axial phantom slice in six panels: labels by kind, compartments and ground-truth perfusion; the idealized 70 keV image at time t in a mediastinal and a lung window, and iodine at t.

"""
One axial phantom slice in six panels: labels by kind, compartments and ground-truth perfusion; the idealized 70 keV image
at time `t` in a mediastinal and a lung window, and iodine at `t`.
"""
function slice_figure(view, 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]
    rois = bundle.rois[is, js, k]
    look = label_lookups(bundle, t)
    # each compartment's ground truth over all of it: its beds' flow over the mass of everything in it
    truth = [0.0f0; [Float32(bundle.roi_table[("rest", name)].mbf) for name in ROI_NAMES]]

    fig = Mke.Figure(; size = (1500, 1000))
    kinds = [look.kind_of[l + 1] for l in labels]
    panel!(
        fig, 1, 1, "Labels by kind", xs, ys, kinds;
        colormap = Mke.cgrad([:black; KIND_COLOURS], 7; categorical = true), colorrange = (-0.5, 6.5), legend = (KIND_COLOURS, KIND_NAMES)
    )
    panel!(
        fig, 1, 2, "Compartments", xs, ys, rois;
        colormap = roi_colormap(), colorrange = (-0.5, length(ROI_NAMES) + 0.5), legend = (roi_colormap().colors[2:end], ROI_NAMES)
    )
    panel!(
        fig, 1, 3, "Ground-truth perfusion by compartment", xs, ys, [truth[r + 1] for r in rois];
        colormap = :turbo, colorrange = (0, 12), label = "mL/min/g"
    )
    hu = [look.hu[l + 1] for l in labels]
    panel!(fig, 2, 1, "Idealized 70 keV, t = $t s", xs, ys, hu; colormap = :grays, colorrange = (-200, 600), label = "HU")
    panel!(fig, 2, 2, "Idealized 70 keV, lung window, t = $t s", xs, ys, hu; colormap = :grays, colorrange = (-1000, 200), label = "HU")
    panel!(
        fig, 2, 3, "Iodine, t = $t s", xs, ys, [look.iodine[l + 1] for l in labels];
        colormap = :turbo, colorrange = (0, 8), label = "mg/mL"
    )
    Mke.Label(fig[0, :], "z = $(round(z; digits = 1)) mm"; fontsize = 20, font = :bold)
    for column in 1:3
        Mke.colsize!(fig.layout, column, Mke.Relative(1 / 3))
    end
    return fig
end
  • time 10 s

  • z 290.1 mm

slice_figure(healthy, healthy_time, healthy_z)
coronal_figure

One coronal plane of a saved phantom, through the main pulmonary artery's tracking point, at time t: compartments, ground-truth perfusion by compartment, the idealized 70 keV image in a lung window, and iodine. Reads the whole phantom once for this one plane.

"""
One coronal plane of a saved phantom, through the main pulmonary artery's tracking point, at time `t`: compartments,
ground-truth perfusion by compartment, the idealized 70 keV image in a lung window, and iodine. Reads the whole phantom
once for this one plane.
"""
function coronal_figure(dir, t)
    point = V.DynamicPhantom.load(dir; volume = false).meta["tracking"]["main pulmonary artery"]["point_mm"]
    j = clamp(round(Int, (point[2] - XCAT_ORIGIN[2]) / XCAT_VOXEL_MM) + 1, 1, XCAT_DIMS[2])
    voxel(mm, d) = clamp(round(Int, (mm - XCAT_ORIGIN[d]) / XCAT_VOXEL_MM) + 1, 1, XCAT_DIMS[d])
    bundle = V.DynamicPhantom.load(
        dir;
        rois = true,
        yrange = j:j,
        xrange = voxel(growth_grid.origin[1] - 5, 1):voxel(growth_grid.origin[1] + growth_grid.dims[1] + 5, 1),
        zrange = voxel(growth_grid.origin[3] - 5, 3):voxel(growth_grid.origin[3] + growth_grid.dims[3] + 5, 3),
    )
    xs = bundle.origin_mm[1] .+ (axes(bundle.labels, 1) .- 1) .* XCAT_VOXEL_MM
    zs = bundle.origin_mm[3] .+ (axes(bundle.labels, 3) .- 1) .* XCAT_VOXEL_MM
    labels = bundle.labels[:, 1, :]
    rois = bundle.rois[:, 1, :]
    look = label_lookups(bundle, t)
    truth = [0.0f0; [Float32(bundle.roi_table[("rest", name)].mbf) for name in ROI_NAMES]]

    fig = Mke.Figure(; size = (1600, 560))
    panel!(
        fig, 1, 1, "Compartments", xs, zs, rois;
        colormap = roi_colormap(), colorrange = (-0.5, length(ROI_NAMES) + 0.5), legend = (roi_colormap().colors[2:end], ROI_NAMES), yreversed = false
    )
    panel!(
        fig, 1, 2, "Ground-truth perfusion by compartment", xs, zs, [truth[r + 1] for r in rois];
        colormap = :turbo, colorrange = (0, 12), label = "mL/min/g", yreversed = false
    )
    panel!(
        fig, 1, 3, "Idealized 70 keV, lung window, t = $t s", xs, zs, [look.hu[l + 1] for l in labels];
        colormap = :grays, colorrange = (-1000, 200), label = "HU", yreversed = false
    )
    panel!(
        fig, 1, 4, "Iodine, t = $t s", xs, zs, [look.iodine[l + 1] for l in labels];
        colormap = :turbo, colorrange = (0, 8), label = "mg/mL", yreversed = false
    )
    Mke.Label(fig[0, :], "coronal y = $(round(point[2]; digits = 1)) mm"; fontsize = 20, font = :bold)
    for column in 1:4
        Mke.colsize!(fig.layout, column, Mke.Relative(1 / 4))
    end
    return fig
end
coronal_figure(HEALTHY_DIR, 14)
curves_figure

Each compartment's iodine, with the tracking arrival and peak, and the lungs' iodine ledger.

"Each compartment's iodine, with the tracking arrival and peak, and the lungs' iodine ledger."
function curves_figure(view)
    bundle = view.bundle
    fig = Mke.Figure(size = (1400, 520))
    ax = Mke.Axis(fig[1, 1]; title = "Compartments", xlabel = "time after injection (s)", ylabel = "iodine (mg)")
    for (k, name) in enumerate(ROI_NAMES)
        row = bundle.roi_table[("rest", name)]
        Mke.lines!(
            ax,
            bundle.times,
            row.mass;
            linewidth = 2,
            color = roi_colormap().colors[k + 1],
            label = @sprintf("%s · %.2f mL/min/g", name, row.mbf),
        )
    end
    timing = V.DynamicPhantom.scan_times(bundle; state = "rest", roi = "roi:main pulmonary artery")
    Mke.vlines!(ax, [timing.arrival, timing.peak_time]; color = :gray40, linestyle = :dash)
    Mke.xlims!(ax, 0, 40)
    Mke.axislegend(ax; position = :rt, labelsize = 11)

    ledger = bundle.ledger["rest"]
    ax = Mke.Axis(fig[1, 2]; title = "Iodine ledger", 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 pulmonary 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 lungs through the pulmonary veins, 1, 2 and 3 s after arrival and at the arterial peak (bolus tracking in the main pulmonary artery).

"""
The share of the delivered iodine that has already left the lungs through the pulmonary veins, 1, 2 and 3 s after arrival
and at the arterial peak (bolus tracking in the main pulmonary artery).
"""
function outflow_check(view)
    bundle = view.bundle
    ledger = bundle.ledger["rest"]
    function share_out(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
    timing = V.DynamicPhantom.scan_times(bundle; state = "rest", roi = "roi:main pulmonary artery")
    shares = [@sprintf("%.0f%%", 100 * share_out(t)) for t in [timing.arrival .+ [1, 2, 3]; timing.peak_time]]
    text = """
    !!! note "Iodine already out of the lungs"
        | | arrival + 1 s | arrival + 2 s | arrival + 3 s | arterial peak |
        |---|---|---|---|---|
        | arrival at $(round(timing.arrival; digits = 1)) s, peak at $(round(timing.peak_time; digits = 1)) s | $(join(shares, " | ")) |

        A measurement that counts the iodine the lungs have taken up misses this share.
    """
    return Markdown.parse(text)
end
curves_figure(healthy)

Iodine already out of the lungs

arrival + 1 sarrival + 2 sarrival + 3 sarterial peak
arrival at 6.0 s, peak at 11.0 s0%0%0%0%

A measurement that counts the iodine the lungs have taken up misses this share.

outflow_check(healthy)

9. A Left Lower Lobe Artery Occluded

The same lungs with one artery of the left lower lobe blocked, as a balloon blocks it in the lab's swine studies: the largest imaged artery whose terminals lie in the left lower lobe and that feeds at most a third of it. The solver needs a finite resistance, so the blocked lumen keeps 0.1% of its area; it passes about 0.3% of its healthy flow. The right ventricle keeps the cardiac output, as it does after an acute embolism, so the pulmonary artery pressure rises until the smaller bed carries it. Saved as its own scenario.

OCCLUSION

The occluded artery: of the imaged arterial segments whose terminals lie ≥ 95% in the left lower lobe and that feed at most a third of it, the one feeding the most; its lumen closed to area of its own over its length (at most 10 mm), as circuit takes a narrowing.

"""
The occluded artery: of the imaged arterial segments whose terminals lie ≥ 95% in the left lower lobe and that feed at most
a third of it, the one feeding the most; its lumen closed to `area` of its own over its length (at most 10 mm), as `circuit`
takes a narrowing.
"""
OCCLUSION = occluded_artery(arteries, LOBES, growth_grid; lobe = 5, area = 1.0e-3)
occluded_artery

The occluded artery of tree: the imaged segment that feeds the most of lobe lobe (lobes on grid) among those whose terminals lie ≥ 95% in it and that feed at most a third of it; with circuit's narrowing to area of its lumen.

"""
The occluded artery of `tree`: the imaged segment that feeds the most of lobe `lobe` (`lobes` on `grid`) among those whose
terminals lie ≥ 95% in it and that feed at most a third of it; with `circuit`'s narrowing to `area` of its lumen.
"""
function occluded_artery(tree, lobes, grid; lobe, area)
    n = length(tree.r)
    lobe_at(p) = (k = round.(Int, (p .- grid.origin) ./ grid.h) .+ 1; all(1 .<= k .<= grid.dims) ? Int(lobes[k...]) : 0)
    children = [Int[] for _ in 1:n]
    foreach(j -> tree.parent[j] > 0 && push!(children[tree.parent[j]], j), 1:n)
    depth = [
        (
            k = j; d = 0; while tree.parent[k] != 0
                k = tree.parent[k]; d += 1
            end; d
        ) for j in 1:n
    ]
    counts = zeros(Int, 5, n)
    for j in 1:n
        isempty(children[j]) && (l = lobe_at(tree.b[:, j]); l > 0 && (counts[l, j] += 1))
    end
    for j in sortperm(depth; rev = true)
        p = tree.parent[j]
        p > 0 && (counts[:, p] .+= counts[:, j])
    end
    total = sum(counts[lobe, j] for j in 1:n if isempty(children[j]))
    candidates = [
        j for j in 1:n if tree.isbase[j] && sum(counts[:, j]) > 0 &&
            counts[lobe, j] >= 0.95 * sum(counts[:, j]) && counts[lobe, j] <= total / 3
    ]
    j = candidates[argmax([counts[lobe, c] for c in candidates])]
    return (segment = j, share = counts[lobe, j] / total, stenosis = [(j, area, 10.0)])
end
"""
The occluded lungs' circuit. The right ventricle keeps the cardiac output: the inlet pressure is raised (by the secant rule)
until the lungs carry the healthy flow again.
"""
flows_occluded = cached("flow_occluded") do
    target = V.organ_flow(flows.rest)
    solve(p_in) = V.Flow.circuit(
        arteries,
        flows.terr;
        state = :rest,
        veins,
        vleaf = flows.vleaf,
        reference = flows.rest,
        phys = V.FlowSpec(flows.rest.phys; p_in = Float32(p_in)),
        stenosis = OCCLUSION.stenosis,
        to = DEVICE,
    )
    p0 = Float64(flows.rest.phys.p_in)
    c0 = solve(p0)
    p1 = p0 + 1
    c1 = solve(p1)
    for _ in 1:12
        f0 = V.organ_flow(c0) - target
        f1 = V.organ_flow(c1) - target
        abs(f1) <= 1.0e-4 * target && break
        p0, c0, p1 = p1, c1, p1 - f1 * (p1 - p0) / (f1 - f0)
        c1 = solve(p1)
    end
    (rest = c1, stress = c1, p_in = p1)
end;
TRANSPORT_OCCLUDED = transport_cache("transport_occluded", flows_occluded.rest);
OCCLUDED_DIR = save_phantom("lll_occlusion", TRANSPORT_OCCLUDED);
occluded = inspect(OCCLUDED_DIR);
  • time 10 s

  • z 270.1 mm

slice_figure(occluded, occluded_time, occluded_z)
coronal_figure(OCCLUDED_DIR, 14)
curves_figure(occluded)

Iodine already out of the lungs

arrival + 1 sarrival + 2 sarrival + 3 sarterial peak
arrival at 6.0 s, peak at 11.0 s0%0%0%0%

A measurement that counts the iodine the lungs have taken up misses this share.

outflow_check(occluded)

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 in the main pulmonary artery and at its peak. The detector covers 160 mm, so one rotation centred on the hila covers most of the lungs (their apices and bases lie outside it); a 160 mm slab of the full chest takes about 17 GB while it is scanned. 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/pulmonary_pipeline/healthy") # or lll_occlusion
const STATE = "rest" # the lungs have one state

# ---- 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 40 cm field over both
# lungs (512², 0.78 mm)
const FOV_CM, SCAN_FOV_CM = 40.0, 50.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 lung 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 lung 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): the main pulmonary artery
track = meta.meta["tracking"]["main pulmonary artery"]["point_mm"]
# arrival (+80 HU at the point) and the arterial peak
timing = V.DynamicPhantom.scan_times(meta; state = STATE, roi = "roi:main pulmonary artery")
# two scans: 160 mm about the tracking point, 0.625 mm slices, 300 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 LUNG_Z = (track[3] - 80, track[3] + 80) # mm: the 160 mm beam about the hila
lungs = V.DynamicPhantom.load(
    DIR;
    zrange = max(1, first(slices(LUNG_Z[1] - 15, 0))):last(slices(0, LUNG_Z[2] + 15)),
)
# the 40 cm field on both lungs (x, y mm of their middle)
field_centre_mm = (136.0, 244.0) .- Tuple(centre)
early, early_dose = scan(
    lungs,
    timing.arrival;
    mA = 300,
    collimation_mm = 160.0,
    slice_mm = 0.625,
    seed = 7,
    field_centre_mm,
)
peak, peak_dose = scan(
    lungs,
    timing.peak_time;
    mA = 300,
    collimation_mm = 160.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 = lungs.roi_table[(STATE, "lungs")]

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