02 · Limb Pipeline
Limb Pipeline
From the raw XCAT leg to a vascular tree that reaches 10 µm terminal arterioles, grown in two passes.
The leg is built the way the heart (00_cardiac_pipeline) and the lungs (01_pulmonary_pipeline) are, with one difference of scale: a leg needs about 60 million terminals, far more than one growth can hold. So it grows in two passes. The first grows the whole leg to 10 mL patches; the second grows each patch to 10 µm on its own, and the patches run in parallel.
This page runs the stages that take seconds or minutes: the geometry, the first pass, and the second pass on three sample patches, which time the full second pass. The full second pass, the flow solve, the contrast transport and the phantom follow the same steps as the heart and lungs, and are laid out at the end.
| Stage | What happens | Run here |
|---|---|---|
| 1. Load | XCAT's leg: its skin, bones and imaged femoral artery and vein, as NURBS surfaces | yes |
| 2. Prepare | the soft tissue to grow into, the bones as blockers, the imaged trees with penetrating stubs | yes |
| 3. Terminal count | Murray's law fixes the count from the femoral artery and a 10 µm terminal arteriole | yes |
| 4. Pass 1 | the whole leg grown to 10 mL patches, arteries and veins | yes |
| 5. Patches | each pass-1 terminal's own tissue | yes |
| 6. Pass 2 | three patches grown to 10 µm terminals; the full pass timed | three patches |
| 7. Flow, transport, phantom | the leg's own physiology, the femoral AIF, the phantom | laid out |
| 8. Design choices | what carries over from the heart and lungs | — |
| 9. Completing the build | the remaining steps, in order | — |
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: TableOfContentsusing Printf: @sprintfusing Serialization: serialize, deserializeusing Statistics: mean, median, quantileimport Vasculature as Vimport CairoMakie as Mkeimport GPUSelectbegin
# licensed: this file and anything made from it stay on lab storage
const LEG_NRB = get(
ENV,
"XCAT_LEG_NRB",
joinpath(homedir(), "Documents", "dev", "MolloiLab", "xcat-data", "legs", "both_legs_1.nrb"),
)
# every cached stage (pass 1, the patches, each pass-2 patch); on a cluster, a shared scratch folder
const CACHE_DIR = get(ENV, "LIMB_CACHE_DIR", joinpath(homedir(), "xcat-perfused", "limb_pipeline_cache"))
mkpath(CACHE_DIR)
end;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()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
endThe 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 Leg
XCAT's legs come as one grouped NURBS file: every surface of both legs, each a set of patches under one name. The right leg is at negative x. This notebook reads its skin (leg_right and the foot), its bones, and its imaged arteries (arteries_rleg) and veins (veins_rleg). XCAT's femoral artery enters at the top of the leg with a radius of about 3.9 mm.
Vasculature.XCAT.read_nrb reads XCAT's single-object format, so this grouped one has its own reader below.
begin
const ENVELOPE = ["leg_right", "foot_right", "foot_right1", "foot_right2", "foot_right3", "foot_right4", "foot_right5"]
const BONES = ["r_femur", "r_tibia", "r_fibula", "r_patella", "r_talus", "r_calcaneus", "rpelvis1", "rpelvis2", "rpelvis3"]
const LONG_BONES = ["r_femur", "r_tibia", "r_fibula"] # the fine blockers
const ARTERIES = "arteries_rleg"
const VEINS = "veins_rleg"
"A surface's object name, without the `__occNNN` a repeated name gets."
group(name) = replace(name, r"__occ\d+$" => "")
"Foot bones XCAT does not name by side: the right ones are told apart by x."
side_bone(name) = startswith(name, "metatarsal") || startswith(name, "phalanx")
"The surfaces this notebook reads."
wanted(name) = name in ENVELOPE || name in BONES || name in (ARTERIES, VEINS) || (side_bone(name) && !endswith(name, "_inner"))
end;read_grouped_nrb(path, keep) -> Vector{NurbsSurface}
XCAT's grouped NRB: per patch a name, an object code line, a patch-scale line, M :M, N :N, then the knots and control points of Vasculature.XCAT.read_nrb's format. A name repeated over several patches gets __occNNN. Only names keep accepts are parsed.
"""
read_grouped_nrb(path, keep) -> Vector{NurbsSurface}
XCAT's grouped NRB: per patch a name, an object code line, a patch-scale line, `M :M`, `N :N`, then the knots and control
points of `Vasculature.XCAT.read_nrb`'s format. A name repeated over several patches gets `__occNNN`. Only names `keep`
accepts are parsed.
"""
function read_grouped_nrb(path::AbstractString, keep)
L = readlines(path)
n = length(L)
out = V.XCAT.NurbsSurface[]
seen = Dict{String, Int}()
i = 1
while i <= n - 4
if isempty(strip(L[i])) || !endswith(rstrip(L[i + 3]), ":M") || !endswith(rstrip(L[i + 4]), ":N")
i += 1
continue
end
name = String(strip(L[i]))
M = parse(Int, split(L[i + 3])[1])
N = parse(Int, split(L[i + 4])[1])
i += 5
startswith(strip(L[i]), "U Knot") || error("$name: expected U knots at line $i")
i += 1
u = Float64[]
while !startswith(strip(L[i]), "V Knot")
push!(u, parse(Float64, strip(L[i])))
i += 1
end
i += 1
v = Float64[]
while !startswith(strip(L[i]), "Control")
push!(v, parse(Float64, strip(L[i])))
i += 1
end
i += 1
k = get(seen, name, 0) + 1
seen[name] = k
if keep(name)
P = Array{Float64, 3}(undef, 3, N, M)
for q in 1:(M * N)
f = split(L[i + q - 1], (',', ' ', '\t'); keepempty = false)
jj = mod1(q, N)
ii = cld(q, N)
for d in 1:3
P[d, jj, ii] = parse(Float64, f[d])
end
end
push!(out, V.XCAT.NurbsSurface(k == 1 ? name : @sprintf("%s__occ%03d", name, k), u, v, P))
end
i += M * N
end
return out
endleg_surfaces = cached("leg_surfaces") do
S = read_grouped_nrb(LEG_NRB, wanted)
# the unsided foot bones of the right leg (negative x)
filter!(s -> !side_bone(group(s.name)) || mean(@view s.P[1, :, :]) < 0, S)
end;| object | patches |
|---|---|
| skin (leg and foot) | 7 |
| bones | 28 |
imaged arteries (arteries_rleg) | 32 |
imaged veins (veins_rleg) | 35 |
2. Prepare
The tissue. The leg's soft tissue: XCAT's skin surfaces minus its bones, on a 1 mm grid. It is muscle, fat and skin together, one tissue for growth and for flow (LIMB_FLOW in section 7 is a composite of the three). Separate muscle and fat masks (XCAT has the muscles) would let each take its own physiology later.
The blockers. No new vessel may cross bone: the femur, tibia and fibula at 0.4 mm, every bone at 1 mm out of the domain.
The inlet trees. XCAT's imaged artery and vein patches, as centrelines joined into one tree each, rooted where the femoral vessel enters the leg. Their nodes are spaced 2.8 mm × the organ's size scale, as for the heart and lungs. Every imaged vessel then gets a penetrating stub about every spacing where none is imaged (V.XCAT.penetrating_stubs), so growth starts along the whole vessel and not only from its few imaged ends. The imaged lumens join the domain as voxels vessels may cross but where no terminal ends (V.NOTERM).
A NURBS surface's triangle mesh, finer for surfaces with more control points.
"A NURBS surface's triangle mesh, finer for surfaces with more control points."
mesh(s) = V.XCAT.tessellate(s; nu = clamp(4 * size(s.P, 2), 64, 480), nv = clamp(4 * size(s.P, 3), 64, 480))"""
The leg on a 1 mm grid: its grid, the skin envelope, the bones, the soft tissue (envelope minus bone), and the fine
blockers (femur, tibia, fibula at 0.4 mm) as `(mask, origin, h)`.
"""
LEG = cached("leg") do
skin = [mesh(s) for s in leg_surfaces if group(s.name) in ENVELOPE]
bones = [s for s in leg_surfaces if group(s.name) in BONES || side_bone(group(s.name))]
around = V.grid_around(reduce(hcat, first.(skin)), 1.0; margin = 3)
grid = V.VoxelGrid(Float64.(round.(Int, around.origin)), 1.0, around.dims)
envelope = reduce(.|, (V.voxelize(X, T, grid) for (X, T) in skin))
bone = reduce(.|, (V.voxelize(mesh(s)..., grid) for s in bones))
long = [mesh(s) for s in bones if group(s.name) in LONG_BONES]
fine_grid = V.grid_around(reduce(hcat, first.(long)), 0.4; margin = 2)
fine = reduce(.|, (V.voxelize(X, T, fine_grid) for (X, T) in long))
(grid = grid, envelope = envelope, bone = bone, soft = envelope .& .!bone, blockers = (fine, fine_grid.origin, 0.4))
end;The soft tissue's volume (mL): the tissue every growth pass and the flow are sized by.
"The soft tissue's volume (mL): the tissue every growth pass and the flow are sized by."
const SOFT_ML = count(LEG.soft) / 1000Whether the point p (mm) lies in the voxels of mask on the leg grid.
"Whether the point `p` (mm) lies in the voxels of `mask` on the leg grid."
function inside(mask, p; grid = LEG.grid)
k = round.(Int, (p .- grid.origin) ./ grid.h) .+ 1
return all(1 .<= k .<= size(mask)) && mask[k...]
endNode spacing of the imaged centrelines (mm): the heart's 2.8 mm × the leg's size scale (its tissue over the 148.1 mL reference, cube root). A branch leaves its host on the host's centreline, and the collision test exempts only the host, so nodes closer than the host's final radius would make every new branch hit the next imaged piece.
"""
Node spacing of the imaged centrelines (mm): the heart's 2.8 mm × the leg's size scale (its tissue over the 148.1 mL
reference, cube root). A branch leaves its host on the host's centreline, and the collision test exempts only the host,
so nodes closer than the host's final radius would make every new branch hit the next imaged piece.
"""
const STEP = 2.8 * cbrt(SOFT_ML / 148.1)A centreline resampled every step mm by arc length.
"A centreline resampled every `step` mm by arc length."
function resample(c::V.XCAT.Centerline, step::Real)
arc = [0.0; cumsum([sqrt(sum(abs2, c.X[:, k + 1] .- c.X[:, k])) for k in 1:(size(c.X, 2) - 1)])]
n = max(2, round(Int, arc[end] / step) + 1)
X = zeros(3, n)
r = zeros(n)
for (q, t) in enumerate(range(0, arc[end], length = n))
k = clamp(searchsortedlast(arc, t), 1, length(arc) - 1)
w = (t - arc[k]) / max(arc[k + 1] - arc[k], eps())
X[:, q] .= (1 - w) .* c.X[:, k] .+ w .* c.X[:, k + 1]
r[q] = (1 - w) * c.r[k] + w * c.r[k + 1]
end
return V.XCAT.Centerline(c.name, X, r)
endimaged_tree(surfaces) -> (tree, r_root, attach_tol)
The imaged patches of one vessel system joined into one tree: each patch's centreline (u and v swapped where the patch runs the other way), the highest patch as the root, cut where it enters the leg's skin, resampled every STEP mm and joined with the smallest attachment tolerance that joins them all. r_root is the root's mean radius over its first 10 mm.
"""
imaged_tree(surfaces) -> (tree, r_root, attach_tol)
The imaged patches of one vessel system joined into one tree: each patch's centreline (u and v swapped where the patch
runs the other way), the highest patch as the root, cut where it enters the leg's skin, resampled every `STEP` mm and
joined with the smallest attachment tolerance that joins them all. `r_root` is the root's mean radius over its first
10 mm.
"""
function imaged_tree(surfaces)
lines = map(surfaces) do s
c = V.XCAT.centerline(s; step = 1.0)
# parametrized the other way round (u along the vessel): swap u and v
median(c.r) > 15 ? V.XCAT.centerline(V.XCAT.NurbsSurface(s.name, s.v, s.u, permutedims(s.P, (1, 3, 2))); step = 1.0) : c
end
top = argmax([maximum(@view l.X[3, :]) for l in lines])
c = lines[top]
c.X[3, 1] < c.X[3, end] && (c = V.XCAT.Centerline(c.name, c.X[:, end:-1:1], reverse(c.r)))
k0 = findfirst(k -> inside(LEG.envelope, c.X[:, k]), axes(c.X, 2))
lines[top] = V.XCAT.Centerline(c.name, c.X[:, k0:end], c.r[k0:end])
r_root = mean(lines[top].r[1:min(10, end)])
pieces = [resample(l, STEP) for l in lines]
for tol in (3.0, 5.0, 8.0, 12.0, 20.0)
try
return (tree = only(V.XCAT.base_forest(pieces; roots = [c.name], attach_tol = tol)), r_root, attach_tol = tol)
catch e
(e isa ErrorException && occursin("base_forest", e.msg)) || rethrow()
end
end
return error("the imaged $(group(c.name)) patches do not join into one tree within 20 mm")
end"""
The growth inputs of one vessel system: its imaged tree with penetrating stubs, the stub report, the root radius, and the
lumen voxels outside the tissue that vessels may cross but where no terminal ends.
"""
inlets = cached("inlets") do
map((arteries = ARTERIES, veins = VEINS)) do name
base = imaged_tree([s for s in leg_surfaces if group(s.name) == name])
tree, report = V.XCAT.penetrating_stubs(
base.tree,
LEG.grid,
LEG.soft;
scale = STEP / 2.8,
forbid = LEG.bone,
blockers = (LEG.bone, LEG.grid.origin, LEG.grid.h),
)
pass = V.XCAT.vessel_tube(tree, LEG.grid; margin = 1.5) .& .!LEG.soft .& .!LEG.bone
(tree = tree, report = report, r_root = base.r_root, attach_tol = base.attach_tol, pass = pass)
end
end;| arteries | veins | |
|---|---|---|
| root radius (mm) | 3.89 | 4.79 |
| imaged nodes | 719 | 983 |
| penetrating stubs | 337 of 356 sites | 463 of 490 sites |
| lumen outside the tissue (mL) | 1 | 1 |
Soft tissue 12661 mL (skin envelope 13929 mL, bone 1267 mL). Centrelines resampled every 12.3 mm. XCAT's femoral artery is 7.8 mm across; its femoral vein (9.6 mm) is narrower than the measured supine 13.6 ± 3.0 mm (Keiler et al. 2019).
The leg's front (x–z) and side (y–z) silhouettes, with segments drawn over them: one linesegments! per view.
"The leg's front (x–z) and side (y–z) silhouettes, with segments drawn over them: one `linesegments!` per view."
function leg_figure(trees; title = "", size = (900, 900))
fig = Mke.Figure(; size)
g = LEG.grid
xs = [g.origin[d] .+ (0:(g.dims[d] - 1)) .* g.h for d in 1:3]
for (column, (view, label)) in enumerate(((1, "front (x–z)"), (2, "side (y–z)")))
ax = Mke.Axis(fig[1, column]; title = label, aspect = Mke.DataAspect(), xlabel = view == 1 ? "x (mm)" : "y (mm)", ylabel = "z (mm)")
silhouette = dropdims(any(LEG.soft; dims = 3 - view); dims = 3 - view)
Mke.heatmap!(ax, xs[view], xs[3], silhouette; colormap = [:white, :gray88])
for (segments, colour, scale) in trees
a, b, r = segments
points = Mke.Point2f[]
for j in eachindex(r)
push!(points, Mke.Point2f(a[view, j], a[3, j]), Mke.Point2f(b[view, j], b[3, j]))
end
widths = repeat(max.(0.4, scale .* r); inner = 2)
Mke.linesegments!(ax, points; color = colour, linewidth = widths)
end
Mke.hidedecorations!(ax; label = false, ticklabels = false, ticks = false)
end
Mke.Label(fig[0, :], title; fontsize = 18, font = :bold)
return fig
endAn inlet tree's segments as (a, b, r): each node with its parent.
"An inlet tree's segments as `(a, b, r)`: each node with its parent."
function base_segments(tree)
k = 2:length(tree.parent)
return (tree.X[:, tree.parent[k]], tree.X[:, k], tree.r[k])
end3. The Terminal Count
Murray's law fixes the count. With the bifurcation exponent γ = 3 (Murray) and every terminal carrying an equal share of the flow, a tree's terminal radius is
$$r_\text{terminal} = r_\text{root} \, N^{-1/3}.$$
The femoral artery's radius is imaged. A terminal arteriole is about 10 µm in radius. So the leg needs N = (r_root / 10 µm)³ terminals: about 59 million. The heart's terminal density (957,045 in 148.1 mL) over this leg would give 82 million, the same order: the leg's tissue is less densely supplied than the myocardium.
Why γ = 3 and not the coronary 2.33. A leg grown with the coronary exponent has conduits at 0.54–0.76 of XCAT's imaged radii. They take almost all of the arteriovenous pressure drop, and 39% of the capillary beds reverse. With γ = 3 the imaged vessels get 0.87–0.95 of their XCAT radii and no bed reverses. Lowering γ does give small terminals (9 µm at γ = 2.33), but only by starving them of pressure: a 10 mL block of thigh muscle grown that way loses about 800 mmHg at maximal flow, against 8 mmHg at γ = 3 with the full count.
Why two passes. 59 million terminals means 118 million segments, too many for one growth on a workstation. Growth is local, though: a terminal's patch of tissue does not depend on how the rest of the leg is drawn. So the leg grows in two passes:
Pass 1 grows the whole leg to about 10 mL patches, one terminal each.
Pass 2 grows each patch from its pass-1 terminal down to 10 µm, with the patch's share of the 59 million.
Every radius in both passes then follows Murray's law from the terminals it feeds, so the result is one Murray tree from the femoral artery to the terminals. The patches share no vessels, so they run in batches on a GPU or in parallel on a cluster.
begin
"Radius (mm) of a terminal arteriole, the size growth must reach."
const TERMINAL_RADIUS_MM = 0.01
"Murray's exponent: the leg's conduits need it (γ = 2.33 starves the beds)."
const GAMMA = 3.0
"The terminal count Murray's law gives for the imaged femoral artery and a 10 µm terminal."
const N_TOTAL = round(Int, (inlets.arteries.r_root / TERMINAL_RADIUS_MM)^GAMMA)
"Tissue (mL) one pass-1 terminal feeds: one pass-2 patch."
const PATCH_ML = 10.0
"Pass-1 terminals: one per patch."
const N_PASS1 = round(Int, SOFT_ML / PATCH_ML)
end;| value | |
|---|---|
| femoral artery radius | 3.89 mm |
| terminal radius | 10 µm |
| terminals, Murray (γ = 3) | 58.7 million |
| terminals at the heart's density, for comparison | 81.8 million |
| pass 1: patches (10 mL each) | 1266 |
| pass 2: terminals per patch | about 46 thousand |
| pass-1 terminal radius, Murray | 359 µm |
4. Pass 1: The Whole Leg to 10 mL Patches
HCT-CCO's staged growth (the only schedule the package grows with), from the imaged femoral artery and vein with their stubs, into the soft tissue, around the bones. The root radius is fixed at the imaged one; γ = 3. Veins grow the same way from the femoral vein, with the same count.
The leg's growth preset: HEART_GROWTH's generic values with the leg's own tissue, imaged root and Murray's exponent. A draft: once the leg is built it belongs in src/organs.jl (as LIMB_GROWTH), with its sources in test/reference/.
"""
The leg's growth preset: `HEART_GROWTH`'s generic values with the leg's own tissue, imaged root and Murray's exponent.
A draft: once the leg is built it belongs in `src/organs.jl` (as `LIMB_GROWTH`), with its sources in
`test/reference/`.
"""
limb_growth(nterm, tissue_ml, root_radius) = V.GrowthSpec(
V.HEART_GROWTH;
nterm,
flow_ml_min = nothing,
tissue_ml,
root_caps = [Float64(root_radius)],
root_fixed = true,
symmetry_floor = Tuple{Float64, Float64}[],
gamma = GAMMA,
device = DEVICE,
)Grow one tree into tissue (a mask on grid): pass voxels may be crossed but take no terminal. Returns the grown tree (V.vtree) and the seconds it took.
"""
Grow one tree into `tissue` (a mask on `grid`): `pass` voxels may be crossed but take no terminal. Returns the grown
tree (`V.vtree`) and the seconds it took.
"""
function grow_tree(spec, inlet, tissue, pass, grid, blockers, name)
dir = mktempdir()
allow = zeros(UInt16, size(tissue))
allow[pass] .= V.NOTERM
domain = V.write_vol(joinpath(dir, "$name.vol"), tissue .| pass, Tuple(round.(Int, grid.origin ./ grid.h)))
gxl = V.XCAT.write_base_gxl(joinpath(dir, "$name.xml"), inlet)
seconds = @elapsed engine = V.grow(spec, domain, [gxl]; blockers, territories = allow)
return (tree = V.vtree(engine), seconds = seconds)
endpass1 = cached("pass1") do
map(inlets) do inlet
spec = limb_growth(N_PASS1, SOFT_ML, inlet.r_root)
grow_tree(spec, inlet.tree, LEG.soft, inlet.pass, LEG.grid, LEG.blockers, "pass1")
end
end;The terminal segments of a grown tree: the segments without children.
"The terminal segments of a grown tree: the segments without children."
function terminals(tree)
has_child = falses(length(tree.r))
foreach(p -> p > 0 && (has_child[p] = true), tree.parent)
return findall(!, has_child)
end| tree | segments | terminals | terminal radius, median (5–95%) | Murray: r_root · N^(-1/3) | growth |
|---|---|---|---|---|---|
| arteries | 3248 | 1605 | 298 µm (183–456) | 332 µm | 36.0 s |
| veins | 3512 | 1736 | 352 µm (213–556) | 399 µm | 33.3 s |
Each pass-1 terminal is the root of one pass-2 patch. Pass 1 sets the tree's geometry; section 6 replaces its radii with Murray's, from the terminals each vessel feeds.
5. The Patches
Each pass-1 terminal's patch is the tissue nearer to it, through the tissue, than to any other terminal: a flood from every terminal at once, one voxel step at a time, which goes around bone instead of through it. Staged growth gives every terminal an equal share of the tissue, so the patches come out near 10 mL.
flood(mask, grid, points) -> (label, voxels, box)
Each voxel of mask labelled with the nearest of points (mm) through the mask (a breadth-first flood from all of them at once, six neighbours per step), the voxel count per label, and each label's bounding box (CartesianIndices).
"""
flood(mask, grid, points) -> (label, voxels, box)
Each voxel of `mask` labelled with the nearest of `points` (mm) through the mask (a breadth-first flood from all of them
at once, six neighbours per step), the voxel count per label, and each label's bounding box (`CartesianIndices`).
"""
function flood(mask, grid, points)
label = zeros(Int32, size(mask))
queue = CartesianIndex{3}[]
for (k, p) in enumerate(eachcol(points))
c = CartesianIndex(Tuple(clamp.(round.(Int, (p .- grid.origin) ./ grid.h) .+ 1, 1, size(mask))))
mask[c] && label[c] == 0 || continue
label[c] = k
push!(queue, c)
end
head = 1
while head <= length(queue)
c = queue[head]
head += 1
for s in SIX_STEPS
n = c + s
checkbounds(Bool, mask, n) && mask[n] && label[n] == 0 || continue
label[n] = label[c]
push!(queue, n)
end
end
n = size(points, 2)
voxels = zeros(Int, n)
lo = fill(CartesianIndex(size(mask)), n)
hi = fill(CartesianIndex(1, 1, 1), n)
for c in CartesianIndices(label)
k = label[c]
k == 0 && continue
voxels[k] += 1
lo[k] = min(lo[k], c)
hi[k] = max(hi[k], c)
end
return (label = label, voxels = voxels, box = [lo[k]:hi[k] for k in 1:n])
end"The arterial patches: each pass-1 arterial terminal's segment and its tissue."
PATCHES = cached("patches") do
t = pass1.arteries.tree
ends = terminals(t)
f = flood(LEG.soft, LEG.grid, t.b[:, ends])
(segment = ends, f...)
end;| value | |
|---|---|
| patches | 1605 (1605 with tissue) |
| patch volume, median (5–95%) | 7.3 mL (2.6–15.0) |
| soft tissue in a patch | 100.0% |
6. Pass 2: Patches to 10 µm, Stitched into One Tree
Every radius follows Murray's law from the terminals it feeds. Pass 1 gives the tree's geometry and its patches, not its radii. Growth balances each terminal's path resistance, so pass-1 terminals that carry the same flow come out up to a third thinner or thicker than each other. A patch grown from a thin one would end in terminals too thin for their flow. So every vessel takes the radius Murray's law gives for the 10 µm terminals below it, r = 10 µm · n^(1/3): each patch's root from the patch's own count, each pass-1 vessel from the counts of all the patches below it. The femoral artery comes out at its imaged radius, because the total count was chosen from it.
Each patch grows on its own, then joins the tree. Its tissue is the patch. Its inlet is the pass-1 terminal segment that feeds it, at its Murray radius. Its count is its share of the 59 million by tissue volume, so every terminal carries the same flow. The segment's lumen, and the neighbouring patches' tissue within 3 mm, may be crossed but take no terminal, so terminals reach the patch's border and neighbouring patches meet without a seam. The grown tree then replaces that pass-1 segment, so the two passes become one tree.
Here every patch under one pass-1 branch in the calf is grown and stitched in: a contiguous piece of the leg, complete to 10 µm. The checks:
terminal radius near 10 µm;
pressure lost from the femoral artery to each terminal at maximal flow (every vessel dilated, each terminal an equal share of the leg's flow, Poiseuille with the Pries–Secomb in vivo viscosity). The capillaries must keep a pressure above the femoral vein's 11 mmHg.
The segments of a tree (its parent vector) in breadth-first order from the root, and each segment's children.
"The segments of a tree (its `parent` vector) in breadth-first order from the root, and each segment's children."
function walk(parent)
children = [Int[] for _ in eachindex(parent)]
order = Int[]
for j in eachindex(parent)
parent[j] == 0 ? push!(order, j) : push!(children[parent[j]], j)
end
for j in order # `order` grows as it is read
append!(order, children[j])
end
return order, children
enddownstream(parent, weight) -> (order, children, n)
The terminals below each segment: a terminal j counts weight(j) (the final terminals it stands for), every other segment the sum over its children.
"""
downstream(parent, weight) -> (order, children, n)
The terminals below each segment: a terminal `j` counts `weight(j)` (the final terminals it stands for), every other
segment the sum over its children.
"""
function downstream(parent, weight)
order, children = walk(parent)
n = zeros(length(parent))
for j in reverse(order)
n[j] = isempty(children[j]) ? weight(j) : sum(n[c] for c in children[j])
end
return order, children, n
endEach patch's final terminal count: its share of N_TOTAL by tissue volume, so every terminal carries the same flow.
"Each patch's final terminal count: its share of `N_TOTAL` by tissue volume, so every terminal carries the same flow."
const PATCH_TERMINALS = round.(Int, N_TOTAL .* PATCHES.voxels ./ sum(PATCHES.voxels))"""
The pass-1 arteries at their Murray radii: each segment's final terminal count below it (`below`) and its radius
`TERMINAL_RADIUS_MM · below^(1/γ)` (mm). A pass-1 terminal counts its patch's terminals (at least one).
"""
PASS1_MURRAY = let t = pass1.arteries.tree, patch_of = Dict(j => k for (k, j) in enumerate(PATCHES.segment))
_, _, below = downstream(t.parent, j -> max(PATCH_TERMINALS[patch_of[j]], 1))
(below = below, radius = TERMINAL_RADIUS_MM .* below .^ (1 / GAMMA))
end;| value | |
|---|---|
| femoral artery: imaged / Murray from the counts | 3.89 / 3.89 mm |
| pass-1 terminals as grown, median (5–95%) | 298 µm (183–456) |
| pass-1 terminals at their Murray radius, median (5–95%) | 323 µm (229–412) |
| every pass-1 vessel: Murray ÷ grown, median (5–95%) | 1.04 (0.53–1.57) |
grow_patch(k) -> (tree, seconds, nterm, ml, root_radius, segment)
Pass 2 for patch k: the patch cropped to its box (and the pass-1 segment that feeds it), grown from that segment at its Murray radius with the patch's share of N_TOTAL terminals. The neighbouring patches' tissue within PATCH_MARGIN_MM and the segment's lumen may be crossed but take no terminal, so the patch's terminals reach its border and neighbouring patches meet without a seam.
"""
grow_patch(k) -> (tree, seconds, nterm, ml, root_radius, segment)
Pass 2 for patch `k`: the patch cropped to its box (and the pass-1 segment that feeds it), grown from that segment at
its Murray radius with the patch's share of `N_TOTAL` terminals. The neighbouring patches' tissue within
`PATCH_MARGIN_MM` and the segment's lumen may be crossed but take no terminal, so the patch's terminals reach its border
and neighbouring patches meet without a seam.
"""
function grow_patch(k; tree = pass1.arteries.tree)
j = PATCHES.segment[k]
a = Float64.(tree.a[:, j])
b = Float64.(tree.b[:, j])
r = Float64(PASS1_MURRAY.radius[j])
g = LEG.grid
# the patch's box, grown to hold its feeding segment and the margin
pad = ceil(Int, r / g.h) + ceil(Int, PATCH_MARGIN_MM / g.h) + 2
ka = round.(Int, (a .- g.origin) ./ g.h) .+ 1
lo = max.(Tuple(min(first(PATCHES.box[k]), CartesianIndex(Tuple(ka)))) .- pad, 1)
hi = min.(Tuple(max(last(PATCHES.box[k]), CartesianIndex(Tuple(ka)))) .+ pad, g.dims)
box = CartesianIndices(Tuple(UnitRange.(lo, hi)))
grid = V.VoxelGrid(g.origin .+ (Tuple(first(box)) .- 1) .* g.h, g.h, size(box))
tissue = PATCHES.label[box] .== k
soft = LEG.soft[box]
margin = dilate(tissue, round(Int, PATCH_MARGIN_MM / g.h)) .& soft .& .!tissue
inlet = V.XCAT.BaseTree("patch_$k", [0, 1], hcat(a, b), [r, r], ["pass1", "pass1"])
pass = (V.XCAT.vessel_tube(inlet, grid; margin = 1.5) .& .!LEG.bone[box] .| margin) .& .!tissue
ml = count(tissue) / 1000
nterm = PATCH_TERMINALS[k]
grown = grow_tree(limb_growth(nterm, ml, r), inlet, tissue, pass, grid, LEG.blockers, "patch_$k")
return (tree = grown.tree, seconds = grown.seconds, nterm = nterm, ml = ml, root_radius = r, segment = j)
endmask grown steps voxels through its six face neighbours.
begin
"Neighbouring tissue (mm) a patch may grow through but not end in, so patches meet without a seam."
const PATCH_MARGIN_MM = 3.0
"`mask` grown `steps` voxels through its six face neighbours."
function dilate(mask, steps)
out = copy(mask)
for _ in 1:steps
grown = copy(out)
for c in CartesianIndices(out)
out[c] || continue
for s in SIX_STEPS
n = c + s
checkbounds(Bool, out, n) && (grown[n] = true)
end
end
out = grown
end
return out
end
endThe region grown here: the pass-1 branch with 8 to 16 patches below it whose end lies nearest 30% of the way up the leg (the calf), and the patches below it.
"""
The region grown here: the pass-1 branch with 8 to 16 patches below it whose end lies nearest 30% of the way up the leg
(the calf), and the patches below it.
"""
REGION = let t = pass1.arteries.tree, z = t.b[3, PATCHES.segment]
_, _, patches = downstream(t.parent, j -> 1)
height = minimum(z) + 0.3 * (maximum(z) - minimum(z))
candidates = findall(n -> 8 <= n <= 16, patches)
branch = candidates[argmin(abs.(t.b[3, candidates] .- height))]
function under(j)
while j != 0 && j != branch
j = t.parent[j]
end
return j == branch
end
(branch = branch, patches = [k for (k, j) in enumerate(PATCHES.segment) if under(j) && PATCHES.voxels[k] > 0])
end"Pass 2 for every patch of the region, each cached on its own."
pass2 = [
cached("patch_$k") do
grow_patch(k)
end for k in REGION.patches
];stitch(tree, radius, grown) -> (parent, a, b, r, pass, from)
One tree from the pass-1 tree (at its Murray radius) and the pass-2 trees grown: each grown tree replaces the pass-1 terminal segment it grew from and hangs from that segment's parent. pass is each segment's pass (1 or 2) and from a pass-1 segment's index in tree (0 for a grown one).
"""
stitch(tree, radius, grown) -> (parent, a, b, r, pass, from)
One tree from the pass-1 `tree` (at its Murray `radius`) and the pass-2 trees `grown`: each grown tree replaces the
pass-1 terminal segment it grew from and hangs from that segment's parent. `pass` is each segment's pass (1 or 2) and
`from` a pass-1 segment's index in `tree` (0 for a grown one).
"""
function stitch(tree, radius, grown)
replaced = Set(p.segment for p in grown)
keep = [j for j in eachindex(tree.r) if !(j in replaced)]
index = zeros(Int, length(tree.r))
index[keep] = eachindex(keep)
parent = Int[p == 0 ? 0 : index[p] for p in tree.parent[keep]]
a = [tree.a[:, keep]]
b = [tree.b[:, keep]]
r = [Float32.(radius[keep])]
pass = [fill(1, length(keep))]
from = [keep]
n = length(keep)
for p in grown
t = p.tree
up = index[tree.parent[p.segment]]
append!(parent, [q == 0 ? up : q + n for q in t.parent])
push!(a, t.a)
push!(b, t.b)
push!(r, t.r)
push!(pass, fill(2, length(t.r)))
push!(from, zeros(Int, length(t.r)))
n += length(t.r)
end
return (
parent = parent, a = reduce(hcat, a), b = reduce(hcat, b), r = reduce(vcat, r),
pass = reduce(vcat, pass), from = reduce(vcat, from),
)
end"The leg's arterial tree with the region complete to 10 µm: pass 1 at its Murray radii, the region's patches grafted in."
stitched = stitch(pass1.arteries.tree, PASS1_MURRAY.radius, pass2);The final terminals a terminal of stitched stands for: one if it was grown in pass 2, its patch's count if not.
"The final terminals a terminal of `stitched` stands for: one if it was grown in pass 2, its patch's count if not."
terminal_weight = let patch_of = Dict(j => k for (k, j) in enumerate(PATCHES.segment))
j -> stitched.pass[j] == 2 ? 1.0 : Float64(max(PATCH_TERMINALS[patch_of[stitched.from[j]]], 1))
endpressure_drop(tree, q, weight) -> (terminals, drop)
Pressure lost (mmHg) from the root to each terminal when the tree carries q mL/min, each terminal taking a share in proportion to weight(j), the final terminals it stands for. Every vessel at its radius (fully dilated), Poiseuille with the Pries–Secomb in vivo viscosity at the discharge hematocrit.
"""
pressure_drop(tree, q, weight) -> (terminals, drop)
Pressure lost (mmHg) from the root to each terminal when the tree carries `q` mL/min, each terminal taking a share in
proportion to `weight(j)`, the final terminals it stands for. Every vessel at its radius (fully dilated), Poiseuille
with the Pries–Secomb in vivo viscosity at the discharge hematocrit.
"""
function pressure_drop(tree, q, weight; phys = LIMB_FLOW)
order, children, n = downstream(tree.parent, weight)
unit = q / 60 / n[order[1]] # mL/s per final terminal
P = zeros(length(tree.r))
for j in order
L = Float32(sqrt(sum(abs2, tree.b[:, j] .- tree.a[:, j])))
r = tree.r[j]
drop = V.Flow.poiseuille(L, r, phys.plasma_viscosity_mPas * V.Flow.mu_rel(2000 * r, phys.hd)) * unit * n[j]
P[j] = (tree.parent[j] == 0 ? 0.0 : P[tree.parent[j]]) + drop
end
ends = findall(isempty, children)
return (terminals = ends, drop = P[ends])
end| patch | tissue | root radius (Murray) | terminals | terminal radius, median (5–95%) | growth |
|---|---|---|---|---|---|
| 484 | 11.8 mL | 380 µm | 54956 | 9.6 µm (7.3–12.5) | 7 s |
| 714 | 5.4 mL | 293 µm | 25221 | 9.6 µm (7.4–12.5) | 5 s |
| 725 | 4.9 mL | 283 µm | 22618 | 9.7 µm (7.4–12.4) | 5 s |
| 993 | 10.2 mL | 361 µm | 47227 | 9.7 µm (7.4–12.4) | 7 s |
| 1014 | 3.6 mL | 255 µm | 16512 | 9.6 µm (7.4–12.5) | 5 s |
| 1147 | 7.1 mL | 320 µm | 32821 | 9.7 µm (7.4–12.4) | 6 s |
| 1148 | 2.9 mL | 238 µm | 13524 | 9.6 µm (7.4–12.5) | 4 s |
| 1227 | 8.0 mL | 333 µm | 37006 | 9.6 µm (7.4–12.5) | 7 s |
| 1228 | 6.2 mL | 307 µm | 28984 | 9.7 µm (7.4–12.4) | 6 s |
| 1263 | 9.5 mL | 353 µm | 44109 | 9.7 µm (7.4–12.4) | 7 s |
The region: 10 patches, 69.6 mL, 0.32 million terminals, 0.6 million segments, grown in 60 s. At maximal flow (3.9 L/min for the whole leg), the pressure lost from the femoral artery to the region's 10 µm terminals is 52 mmHg (median; 95%: 57), which leaves the capillaries 38 mmHg of the 90 mmHg arterial pressure, above the femoral vein's 11 mmHg.
The full pass 2, from this region. 186 µs of growth per terminal, so the 1266 patches (58.7 million terminals) take about 3.0 hours one after another on this machine, or about 5 minutes spread over 40 GPUs. The arterial tree holds about 117 million segments, 4.7 GB at 40 bytes a segment; the veins as many again.
7. Flow, Transport and the Phantom
The leg's own physiology
LIMB_FLOW takes every value from measurements in human legs where one exists, and derives the rest from cited measurements. The leg is supine and at rest. Its soft tissue is one composite tissue: muscle 0.62, fat 0.34 and skin 0.04 by volume. Fourteen fields differ from the heart's. These set the oxygen and the flow:
O₂ demand
mvo20.00146 mL O₂/min/g, from the measured resting leg flow (2.6 mL/min/100 mL, Koch Esteves et al. 2023) and the femoral venous saturation;O₂ extraction
oef0.315: femoral venous SO₂ 67.1% against arterial 97.9% (Pearson et al. 2011);venous pressure
p_out11 mmHg (common femoral vein, supine);maximal dilation
r_total_dilated300 mmHg·min·g/mL: 90 mmHg over the PET flow under adenosine (muscle 0.40, fat 0.105 mL/min/g; Heinonen et al. 2010, 2014);tone
d_active300 µm: skeletal-muscle feed arteries regulate;blood and exchange
fb0.037,ve0.11,ps0.014 mL/min/g (EDTA-derived),rho1.02 g/mL.
Flow is pressure-driven: growth knows nothing of these values, and the leg's flow comes out of the arterial and venous pressures, the trees and the O₂-driven tone. The resting flow below is what the O₂ balance implies; the solve must find it, not be tuned to it.
The leg's flow physiology: the fields that differ from the heart's, each with its source. It moves into src/organs.jl as a preset once its sources are in test/reference/, as every preset's are. The heart-only fields take the leg's no-change values: no measured blood-volume rise under adenosine (fb_dilated = fb) and a constant PS.
"""
The leg's flow physiology: the fields that differ from the heart's, each with its source. It moves into `src/organs.jl`
as a preset once its sources are in `test/reference/`, as every preset's are. The heart-only fields take the leg's
no-change values: no measured blood-volume rise under adenosine (`fb_dilated = fb`) and a constant PS.
"""
const LIMB_FLOW = V.FlowSpec(
V.HEART_FLOW;
p_out = 11.0f0, # mmHg. Common femoral vein, supine rest, catheter (Pearson 2011)
fb = 0.037f0, # mL/mL. Muscle 3.3 mL/100 g by PET (Raitakari 1995), fat and skin (Leggett 1991), + conduits
fb_dilated = 0.037f0, # no measured rise under adenosine
share_art = 0.15f0, # systemic arteries : veins = 13 : 64 (Guyton & Hall), applied to 1 - share_cap
share_cap = 0.11f0, # muscle capillary volume 0.48 of muscle blood (Gidlöf 1988; Raitakari 1995)
share_ven = 0.74f0, # the remainder
cap_length_mm = 0.44f0, # functional capillary path 438 µm (Sarelius 1986)
cap_diameter_um = 5.3f0, # human quadriceps capillary lumen (Gidlöf 1988)
ve = 0.11f0, # muscle 0.07, fat 0.14, skin 0.46 by composition (Goodall 2020; Thompson 2005; Levitt 2003)
ps = 0.014f0, # forearm EDTA PS (Keiding 1988) scaled to iodinated contrast (Levitt 2002)
ps_slope = 0.0f0,
rho = 1.02f0, # ICRU-44 muscle, adipose, skin by composition (Hubbell & Seltzer 1995)
r_total_dilated = 300.0f0, # 90 mmHg / PET flow under adenosine (Heinonen 2010, 2014)
d_active = 300.0f0, # muscle feed arteries regulate (Williams & Segal 1993; Ives 2012)
oef = 0.315f0, # supine femoral venous SO2 67.1% vs arterial 97.9% (Pearson 2011)
mvo2 = 0.00146f0, # 2.6 mL/min/100 mL (Koch Esteves 2023) × O2 content × oef / rho
)| the O₂ balance implies | measured | |
|---|---|---|
| resting flow | 0.0255 mL/min/g, 330 mL/min for this 12914 g leg | 300 ± 30 mL/min (Chiesa 2016), 353 mL/min (Klein 2003) |
| leg VO₂ | 18.9 mL/min | 18–28 mL/min |
| maximal flow | 0.30 mL/min/g, 3.9 L/min | nutritive, under adenosine |
| flow reserve | 11.7 | — |
| contrast extraction, rest / maximal | 0.63 / 0.08 | — |
| mean blood transit at rest, fb / flow | 85 s | — |
The resting transit is long: at rest a leg holds iodine for over a minute, so very little leaves between two volume scans a few seconds apart. Under adenosine it is about 7 s, and venous outflow matters as it did for the heart at stress.
Transport, compartments and the phantom
These follow the heart and lungs step for step, on the full pass-2 trees.
Veins, pass 2. The same patches from the femoral vein's pass-1 terminals (
grow_patchwithpass1.veins.treeand a venous flood).Flow.
V.flow(LIMB_FLOW, arteries, veins, tissue_centres), at rest and at maximal dilation. With 118 million segments per tree, each patch is solved on its own and enters the pass-1 tree as one resistance. The patches share no vessels, so this is exact. The checks: resting inflow about 330 mL/min, femoral venous SO₂ 0.66, VO₂ 18–28 mL/min, no reversed beds, tone not saturated.Transport. The injection (
INJECTION, below) through the whole-body model. The leg is the body's right-leg bed, fed from the abdominal aorta and draining to the femoral vein. That bed's share of the cardiac output must be the grown leg's own flow, ascoronary_sharesets it for the heart andorgan_flowfor the lungs, so the body and the leg carry the same blood.transportdoes this for the heart and lungs today; a peripheral bed needs the same.The AIF. A tracking point in the imaged femoral artery, at the top of the scanned slab. The package gives a tracking point in a vessel its own label at the segment's blood concentration, so its curve is the true arterial input. Put the AIF ROI inside that one segment's lumen: the other imaged segments are painted at their mass over XCAT's lumen, which differs from the grown radius.
Compartments. The muscles (or thigh, calf and foot), each also grouped with the conduit vessels that feed and drain it, as the heart's "myocardium + epicardial vessels" and the lungs' "lungs + hilar vessels": the ideal first-pass compartment counts the iodine in transit.
The phantom. XCAT has no 0.2 mm label volume of the leg, and the whole leg at 0.2 mm is 8 billion voxels. Build the phantom for one scanned slab (the 160 mm beam: the thigh, or the calf), voxelizing XCAT's leg surfaces there.
The injection, the same as the heart's and lungs' (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.
"""
The injection, the same as the heart's and lungs' (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.
"""
const INJECTION = let kg = 70, conc = 350
V.Body.Injection(; phases = [(0.5 * kg, 5, conc), (0.5 * kg, 5, 0.3 * conc)])
end8. Design Choices Carried Over from the Heart and Lungs
One source of truth
One code path for every organ: grow → flow → transport → phantom. Organs differ only in their spec values (
GrowthSpec,FlowSpec,TransportSpec,PhantomSpec). The leg's presets live in this notebook until they join the others insrc/organs.jl.Every organ has its own physiology. The leg takes none of the heart's values where a leg measurement exists: γ = 3, its own O₂ demand, extraction, pressures and blood volumes.
Flow is pressure-driven. Ground truth comes out of the trees, the pressures and the tone; measured flow is only a comparator. Growth and flow are never tuned to make a perfusion measurement agree.
Growth
HCT-CCO's staged schedule only.
Imaged vessels get penetrating stubs, so growth starts along their whole length (the heart's and lungs' imaged trees had too few ends without them).
The imaged lumens are crossable but take no terminal; blockers keep vessels out of bone (the lungs' fissures, the heart's chambers).
Terminal size follows the count, r_root · N^(-1/γ). Reach real terminal arterioles by growing more terminals (two passes), never by lowering γ.
Anatomy and timing
Check XCAT against measurements. Its aorta is about three standard deviations narrow, so the aorta's timing was taken from the body model (
V.XCAT.scale_tree). Its main pulmonary artery is large. Its femoral vein is narrower than measured.The AIF must be in time with the compartment's inlet. The ideal compartment counts the iodine still in transit in its conduit vessels (epicardial, hilar; here the femoral and popliteal branches).
V2 about 1 s before the arterial peak loses the least iodine out of the veins. At rest the leg barely loses any; under adenosine it will.
One injection for every organ: the lab's 0.5 mL/kg protocol.
Painting
An imaged vessel's radius is set by its flow, not by XCAT's lumen. Its iodine is spread over XCAT's lumen, so its painted concentration can differ from its blood's.
A tracking point's segment is painted at its true blood concentration. Keep the CT AIF ROI inside that segment.
Notebooks
Loops over notebook globals are slow; they run inside functions.
Every heavy step is cached.
Many segments are drawn with one
linesegments!.
9. Completing the Build
Pass 2 for every arterial patch, in batches on a GPU or as parallel jobs. Each patch is independent and cached on its own; the patches are then grafted onto pass 1.
Veins: the same two passes from the femoral vein.
Terminal size from leg data. The 10 µm terminal is the size the heart's terminals reach. Skeletal-muscle (and fat) terminal arteriole sizes, or microvascular unit volumes, set
TERMINAL_RADIUS_MMor the count for the leg.The leg's presets in the package:
LIMB_GROWTHandLIMB_FLOWinsrc/organs.jl, their sources intest/reference/, with tests.Flow: each patch, then the leg, at rest and at maximal dilation, against the checks in section 7.
Transport: the peripheral bed's share of the cardiac output from the grown leg's flow, then the injection with the femoral AIF.
Compartments and a phantom slab, then idealized first-pass perfusion against ground truth, as for the heart and lungs.