Skip to content
psweensPublic

About

A project to create synthetic vascular networks utilising L-Systems.

Topics

Resources

Stars

25 stars

Watchers

1 watching

Forks

Latest commit

 

History

793 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

V-System: Vascular Lindenmayer Systems for Synthetic Vessel Generation

V-System generates synthetic 3D vascular networks with stochastic, parametric Lindenmayer systems (L-systems) and renders them as binary image volumes for training and validating vessel segmentation methods.

The grammars, the turtle interpreter and the bifurcation model follow Galarreta-Valverde's 2012 dissertation and the SPIE 2013 paper by Galarreta-Valverde et al.; daughter diameters obey Murray's law and bifurcation angles follow Zamir's minimum-volume rule.

Example


Pipeline

  1. Grammar generation (vSystem.py): the tree rule F is expanded for a chosen number of drawn generations into a string of turtle instructions.
  2. Interpretation (analyseGrammar.py): the string is executed by a 3D turtle that keeps a direction vector and a perpendicular vector, giving the centreline points and diameters.
  3. Interpolation (utils.py): each stem is smoothed with a cubic B-spline.
  4. Voxelisation (computeVoxel.py): the network is mapped into the volume with a single isotropic scale factor and every segment is drawn as a tapered capsule, so calibre and bifurcation angles survive into the image, plus the face-connected chain of voxels it passes through, so a vessel thinner than a voxel stays unbroken.

Steps 1–3 produce the centreline, which is saved alongside the volume. The centreline is the source of truth for the geometry and the TIFF is one rasterisation of it, so step 4 can be repeated at any resolution without regenerating the network.

Three optional stages shape the geometry between steps 2 and 4 without changing what the grammar decides: a persistent random walk that bends each stem smoothly (--tortuosity walk), collision avoidance between branches (--avoid-collisions) and anastomosis, which joins tips into loops (--anastomose). --family bundles them, and describe.py measures what they change. See Geometry below.


Units

One grammar unit is one micrometre. Nothing in the code enforces this — the generator is scale free — but every output declares it, so that a dataset cannot be silently mis-scaled:

  • --d0 and --d-min are vessel diameters in micrometres;
  • --voxel-size is grammar units per voxel, and therefore a modality's physical voxel size in micrometres per voxel;
  • epsilon (the length-to-diameter ratio) is dimensionless, so segment lengths follow the diameters;
  • every JSON sidecar records "units": "um".

A vessel of diameter d rendered with --fit voxel_size --voxel-size v is d / v voxels across, so calibre in voxels scales as 1 / v and the same centreline rendered at two voxel sizes gives two correctly scaled datasets. Rasterising once and resampling the binary volume afterwards does not: it destroys vessels a voxel wide. Render each modality from the centreline instead.

The 1 / v law holds down to the grid: a vessel below one voxel across is drawn at the one-voxel connectivity floor rather than scaled, so its rendered calibre is overstated. That is the honest rendering of an unresolvable vessel, but it means a network must be chosen against the voxel size it will be rendered at — see Rendering one network for several modalities below.

To work in another unit, keep the convention self-consistent — interpret --d0 and --voxel-size in the same unit — and declare it with --units mm. The flag labels the data; it rescales nothing.


Installation

Python 3.10 or newer.

git clone https://github.com/psweens/V-System.git
cd V-System
pip install -e .              # numpy and tifffile only
pip install -e ".[viz]"       # plus matplotlib, for plotting
pip install -e ".[preprocess]"  # plus OpenCV, for resize_volume

Usage

Generate five networks into ./output, reproducibly:

python main.py --count 5 --out ./output --seed 1

Each network is written as three files sharing the stem Lnet_i<generations>_s<seed>:

File Contents
.tiff a uint8 volume of 0 and 255 whose pages are z, rows y and columns x
.npz the centreline: nodes, the (4, N) array of x, y, z and diameter in grammar units with NaN column separators; edges, the (2, E) graph of column indices; node_kind and tree, per-column labels; program, the grammar string; and metadata, the sidecar record
.json the seed, the unit convention and every parameter used

Network i of a run uses seed + i, and the same seed and parameters reproduce the same volume. The default volume is an imaging slab, fitted in x and y and clipped in z, so vessels leave and re-enter it and a deep network renders as several pieces; see Connectivity below for what that means and how to change it. The centreline archive grows with the number of drawn generations rather than with the volume — tens of kilobytes at four generations and about seven megabytes at twelve, against 37 MB for a 512 × 512 × 140 volume whatever the network inside it. Its arrays are exact; being a zip, its entries carry a modification time, so compare the arrays rather than the bytes.

Useful options (python main.py --help lists them all):

Option Default Meaning
--volume NX NY NZ 512 512 140 volume shape in voxels
--iterations MIN MAX 4 12 drawn generations, drawn uniformly
--d0 MEAN STD 20 5 root diameter, grammar units, truncated at --d0-min
--d-min none smallest drawn vessel diameter; a branch stops bifurcating below it
--capillary-generations M 0 replace a daughter that --d-min would end by a capillary tree of M symmetric generations drawn at --d-min; needs --d-min
--capillary-runs E 1 draw every capillary stem, and every stem whose daughters both end, as E consecutive stem blocks
--epsilon MIN MAX 4 10 length-to-diameter ratio of a segment
--randmarg MIN MAX 0.1 0.3 relative half-width of the segment-length distribution
--sigma 5 d_opt / sigma is the spread of the first daughter diameter
--roll-angle 70 roll of the bifurcation plane after each daughter turn, degrees
--stem-angle 25 turn between the five sub-segments of a stem, degrees
--aneurysm-prob, --stenosis-prob 0.02 per sub-segment probability of a local ×1.5 dilation or ×0.5 constriction
--fit isotropic isotropic, voxel_size (fixed --voxel-size) or stretch (legacy per-axis fill)
--voxel-size none grammar units per voxel for --fit voxel_size: the modality's voxel size
--clip-axes z axes left out of the isotropic fit and clipped, as an imaging slab would
--grow-in-volume off confine growth to the volume's proportions so no vessel is cut
--no-connect off rasterise bare capsules, leaving sub-voxel vessels dotted
--units um the unit one grammar unit stands for, recorded in the sidecar
--family tree preset bundle of the geometry options: tree, mesh, tumour, aligned, aligned_tight, aligned_bed, capillary_bed, foam (the last five need --d-min)
--tortuosity stems stems: five sub-segments smoothed by a B-spline; walk: a persistent random walk of the same arc length
--persistence none persistence length of the walk in vessel diameters; required by walk
--avoid-collisions off keep branches apart by at least --collision-margin (default 1 µm), redrawing or shortening, and count what could not be placed
--anastomose off join --anastomosis-fraction (0.5) of the tips to partners within --anastomosis-radius (25) tip diameters, never closer kin than --anastomosis-min-separation (3) segments, by bridges routed clear of the network at --collision-margin; --anastomose-mode arteriovenous grows a second tree from the opposite face
--guidance JSON none steer the walk towards an axis or a plane per calibre class: a list of rules, each with a bound below in d_min (null for none), a field (axis, plane or null), its axis or normal, a length G in diameters, an onset (2) and, for an axis, a sense and polarity, for a plane bank; needs --tortuosity walk
--root-offsets JSON none move the roots from their default positions on the faces of the growth box, staying inside it: one [x, y, z] per tree in units of d_min; needs --grow-in-volume and --d-min
--cross-connect / --no-cross-connect off after anastomosis, join capillary vessels side to side by rungs at gaps of about --rung-spacing (20) diameters, to partners within --rung-radius (8) site diameters whose chord stands at least --rung-lateral-deg (60°) off the vessel (or off --rung-axis); vessels below --rung-below (2) d_min are capillaries; needs --d-min
--bed JSON none grow an explicit capillary bed between the tips of two feeder trees in the mesh layout instead of anastomosing them: a JSON object of bed settings (kind, spacing, girth, min_angle, reach, diameter, persistence, feeder_stop, tip_edges, stretch, density, jitter, step), the ones left out at their defaults; null turns a preset's bed off; needs --grow-in-volume and --d-min

--d-min and --iterations are both stopping criteria and whichever comes first wins. --d-min is the one a modality states directly, as its smallest resolvable calibre; with --deterministic daughters, diameters fall by exactly 2^(-1/k) a generation and it is reached after log(d0 / d_min) / log(2^(1/k)) of them. Under the default stochastic daughter diameters that is only an estimate, and the two daughters of a bifurcation reach it at different depths, so the tree terminates unevenly. Either way, raise --iterations above the estimate to let --d-min decide. It bounds the diameter a branch is drawn at, not the diameter after a local anomaly: a stenosis still narrows a drawn sub-segment by --stenosis-prob's factor of 0.5, so set --stenosis-prob 0 for a hard floor.

Connectivity

A capsule sets only the voxels whose centres it contains. A vessel thinner than a voxel contains no centre along much of its length, so on its own it rasterises as a dotted line and a connected tree falls apart into fragments — with the default parameters, more than a third of the centreline is that thin and a single tree renders as dozens of pieces. Every segment is therefore also drawn as the chain of voxels it passes through, walked one face at a time (Amanatides & Woo, 1987), which renders a sub-voxel vessel one voxel wide instead of dotted. It is not a dilation: every voxel the chain sets lies within sqrt(3)/2 of the centreline, so for a radius of 0.866 voxels or more it is already inside the capsule and calibre is untouched. --no-connect restores the bare capsule rasterisation.

The chain is face-connected (6-connected), not merely 26-connected. The distinction matters downstream: a chain of voxels touching only at edges or corners is one piece to a 26-connected label but shatters under the six-connected flood fill, label or morphological operation most tools default to — scipy.ndimage.label among them — and looks like a string of beads in a viewer. Face connectivity is the weakest assumption any of them makes, so it is the one the rasteriser guarantees.

check_connectivity.py answers the question for a given network. It reports the components of a volume under both 6- and 26-connectivity and, for every piece but the largest, whether that piece touches a face of the volume — a piece that does is a branch the volume cut, a piece that does not is a real break. It also checks a saved .npz centreline as pure geometry, independently of any volume, where anything but one component is a defect. --generate N makes fresh networks to check instead of reading files, passing every other option through to the generator, and the exit status is 1 only for a break the volume boundary does not explain:

python check_connectivity.py --generate 10 --seed 1
python check_connectivity.py output/*.npz output/*.tiff

Two ways of looking can still suggest breaks that are not there. A thin vessel running obliquely through a stack shows up in any single slice as isolated voxels even though it is unbroken in three dimensions, so judge connectivity with a 3-D component count rather than slice by slice. And a rendered network does still fall into several genuine pieces under two conditions, neither of them a defect:

  • Clipping. A branch that leaves a slab and re-enters it is two vessels in the image, exactly as it would be in a real acquisition. --grow-in-volume removes this at the source: growth is confined to a box with the volume's proportions and a branch that would leave it is terminated along with its subtree, so the tree takes the volume's shape and no vessel is ever cut part way along. On a 512 × 512 × 140 slab at twelve generations that turns 43 pieces into one while raising the vessel count, because branches that used to grow out of the slab and be discarded now stay inside it. --clip-axes is unused when it is set, and it is off by default so existing output is unchanged. A volume with equal axes needs none of this: the grammar grows a roughly cubical tree, so --volume 512 512 512 --clip-axes none is one connected piece at the same calibre. The default volume is such a slab — fitted in x and y, clipped in z — so this is what the quickstart command produces: its deepest network, at twelve generations, renders as 48 pieces, every one of them touching the top or bottom face of the slab, with 95% of the vessel voxels in the largest. --clip-axes and the volume shape control this. With --clip-axes none each of the five quickstart networks renders as a single connected component, at the cost of scaling the whole tree into 140 slices and so shrinking every vessel.
  • Resolution. A vessel the modality cannot resolve is still drawn, one voxel wide. To stop generating them instead, set --d-min to the smallest resolvable calibre.

--clip-axes is read by the isotropic fit only; voxel_size and stretch ignore it. The command line clips z by default because the default volume is an imaging slab, whereas the library functions computeVoxel.process_network and fit_to_volume default to clipping nothing, so that a direct call fits the whole network unless told otherwise. Axes may be named or indexed in either place, and an axis that is neither raises.

From Python:

from main import generate_network, save_network

volume, program, nodes = generate_network(
    niter=8, d0=20.0, properties={"epsilon": 7.0, "randmarg": 0.2}, tVol=(512, 512, 140))
save_network("Lnet.npz", nodes, program=program)

volume is a uint8 array of 0 and 1 indexed (x, y, z); program is the grammar string; nodes is the (4, N) centreline of x, y, z and diameter with NaN columns separating branches. nodes is in grammar units and independent of tVol, fit and voxel_size, so it is the artefact worth keeping.

Rendering one network for several modalities

Because the mapping from grammar units to voxels happens at rasterisation time, a saved centreline can be rendered at each modality's own voxel size. The field of view is shape * voxel_size, so shape has to follow the voxel size to keep it fixed; deriving it from the network's own extent does that:

import numpy as np
from computeVoxel import process_network
from main import load_network

# written by: python main.py --count 1 --seed 1 --iterations 8 8 --out output
network = load_network("output/Lnet_i8_s1.npz")
nodes = network["nodes"]
extent = np.nanmax(nodes[:3], axis=1) - np.nanmin(nodes[:3], axis=1)  # micrometres

for modality, voxel_size in [("two-photon", 1.0), ("light-sheet", 2.0)]:
    shape = np.ceil(extent / voxel_size).astype(int) + 8   # same field of view, finer grid
    volume = process_network(nodes, shape, fit="voxel_size", voxel_size=voxel_size)

Only the sampling changes between the two, so each result carries the vessel calibre distribution at that modality's resolution. A 20 µm vessel is 20 voxels across at 1 µm and 10 voxels across at 2 µm.

Holding shape fixed instead would render the same network into two different fields of view. That is the mistake to avoid: at 20 µm/voxel a 512 × 512 × 140 volume covers 10 240 × 10 240 × 2800 µm, and the network above occupies 37 × 33 × 21 of its 36.7 million voxels.

One centreline serves a modality only while that modality can resolve it. Every vessel thinner than a voxel is drawn at the one-voxel connectivity floor, so once most of the network is sub-voxel its calibre distribution collapses to a spike at 1 voxel instead of matching anything. Check before trusting a render:

d = nodes[3][~np.isnan(nodes[3])] / voxel_size          # diameters in voxels
print(np.percentile(d, [50, 90]), (d < 1.0).mean())     # ... and the sub-voxel fraction

For a modality whose voxel size approaches the network's finest vessels, generate a network for it — a larger --d0, or --d-min set to its smallest resolvable calibre — rather than rendering an existing one more coarsely.

To target a fixed acquisition geometry instead, a 512 × 512 × 140 slab at 2 µm say, choose --d0, --epsilon and --iterations (or --d-min) so that the network spans the field of view shape * voxel_size: under voxel_size the network is centred and clipped rather than scaled to fit.


Geometry

The grammar fixes the tree's topology, calibres, segment lengths and bifurcation angles. What a segmentation model trained on these volumes also learns is the local geometry — how smoothly vessels curve, whether they cross, how often they end — and the plain grammar has three properties real vasculature lacks: stems are zig-zags of five straight pieces, branches pass through one another, and every terminal branch ends in a free tip. Three opt-in stages address them. None is used unless asked for, and the default command line writes the same nodes, program and TIFF as before for a given seed (tests/fixtures pins this).

The graph in the archive

Every archive carries edges, a (2, E) array of column indices into nodes: one edge between consecutive points of a polyline and, because a daughter polyline starts on a bitwise copy of its parent's branch point, one shared vertex wherever polylines meet. Coincident columns (within graph.DEFAULT_TOL, 10⁻⁶ µm) are one vertex, represented by the lowest column index. node_kind labels every column interior (degree 2), junction (degree 3 or more) or tip (degree 1; the root counts as a tip), and tree labels the tree a column belongs to (0, 1) or marks it as an anastomosis bridge (2). graph.py rebuilds all of this from nodes alone — edges_from_nodes, node_kind, betti, segments — so an archive written before this version gains a graph when read with load_network. The rasteriser ignores the graph.

Smooth tortuosity: --tortuosity walk --persistence P

A stem is normally the grammar's five straight sub-segments with alternating turns of --stem-angle, smoothed by an approximating B-spline; its arc/chord ratio is fixed by the stem angle and does not vary. With walk, the path of each stem is a persistent random walk on the unit tangent: at every step of length h (a sub-segment divided into 2^--subdivisions steps) the tangent is rotated by an angle drawn from N(0, √(h / l_p)) about a random perpendicular axis, with persistence length l_p = P × the local diameter. Total arc length equals the grammar's, the daughters leave from where the walk actually ends and at the grammar's angles relative to the walk's final direction, and the walk draws from its own random stream seeded from the run seed, so the grammar's draws are untouched. Lower P is more tortuous; docs/geometry tabulates arc/chord and curvature against P. Under --grow-in-volume a step that would leave the box is redrawn up to a budget, turning more sharply each time, then the branch terminates and the sidecar counts it.

The rotation rule gives a tangent autocorrelation of exp(−s / 2 l_p) in three dimensions, so P is a shape parameter calibrated against measured arc/chord ratios rather than a literal persistence length; tortuosity.py documents the factor of two.

Collision avoidance: --avoid-collisions

Every accepted point is stored in a spatial index (--collision-index grid or kdtree; auto is the grid hash, which was faster than the k-d tree at both 10⁵ and 10⁶ points: 1.3 s against 1.8 s, and 16 s against 60 s, for the insert-and-query mix of python spatial.py --benchmark). A proposed point collides when it lies closer than the sum of the two radii plus --collision-margin to a stored point that is not on its own stem within an arc-length window, nor on its parent or sibling close to their common junction (the two distances to the junction, measured along the vessels, summing to less than 1.7 times the collision threshold, which is how far tubes meeting at Zamir's angles overlap; collisions.py derives the bound). A colliding walk step is redrawn up to --collision-attempts times, each redraw turning more sharply than the last (the rotation's spread grows by one multiple of its base per attempt) so the walk steers away from what it hit rather than repeating almost the same step; a spline stem is shortened to its longest clear prefix of control points; a branch that cannot be placed terminates. The sidecar counts redraws, shortened stems and terminations. Terminated branches are new tips, so avoidance raises tip density unless anastomosis absorbs them. The check is on centreline points, so keep the margin at or above the point spacing of the finest vessels. describe.py verifies the result independently as the minimum surface clearance over the whole network.

Anastomosis: --anastomose

After growth, a seeded random fraction of the tips (--anastomosis-fraction) each search within --anastomosis-radius tip diameters for a partner: another tip first, otherwise an interior point of a segment that is not their own, nearest first, never a junction, a root, or a point inside a junction's overlap zone, and never closer kin than --anastomosis-min-separation segments along the tree (default 3, which rules out the parent stem and the sister stem, two segments away). Without that rule the commonest bridge joins a tip to its sister's tip, usually the nearest, closing the two sister stems into a small triangle. The nearest partner beyond the sister lies about 2 ε tip diameters away (8 at ε = 4, 14 at ε = 7, 20 at ε = 10), which is why the default radius is 25; a radius in multiples of the segment length would be self-scaling and is an open question. A branch that avoidance or the growth box terminated within a step or two of its junction leaves a stub whose tip sits inside that zone; such stubs are neither sources nor partners, and are counted. Partners are sought ahead of the tip, within 120° of its direction, as a sprouting tip fuses with what it grows towards rather than doubling back. A bridge of diameter min(d_tip, d_partner) is traced by the pinned form of the walk: it leaves the tip along the tip's direction, arrives into a partner tip along that vessel's direction (so the two tips become one continuous vessel) or into the side of a vessel along the chord, and carries the walk's curvature at --persistence diameters, or at 8 when the stems are not walked, so that it is a vessel rather than a straight strut. Every bridge is routed clear of the network at --collision-margin, whether or not the tree was grown with --avoid-collisions, redrawn on a collision and abandoned (and counted) when no candidate clears. It is appended as a new polyline whose end columns copy the two joined points, so the graph gains a cycle (β₁ = E − V + C rises by one) or joins two components. --anastomose-mode arteriovenous grows a second tree from the opposite face of the volume, heading back towards the first, and ranks partners in the other tree first — the arteriole → capillary → venule design of a capillary bed; it works best with --grow-in-volume, which the mesh preset turns on. The second root is placed at the nearest spot on its face that is clear of the first tree, and both root positions are recorded in the sidecar. Tips that found no partner, or whose every candidate bridge collided, are counted. The rasteriser draws polylines independently, so cycles need no special handling.

Directional guidance: --guidance and --root-offsets

The walk can be steered. Before every step's random turn, the heading h is turned deterministically towards a target t by

ω = min(θ, r sin θ cos θ)   for a nematic axis or a plane,
ω = min(θ, r sin θ)         for a polar axis,

where θ is the angle from h to t and r = min(1, step / (G d)) with G the guidance length in local diameters d. The drift draws nothing: with guidance off the walk draws exactly what it drew before, and with guidance on it still draws two numbers per attempt, each redraw turning about the steered frame. The target depends on the field of the rule that governs the stem: for a nematic axis a it is the nearer of a and −a; for a polar axis it is s a with a sense s fixed per tree, +1 (fixed), the sign of the tree's root heading along a (root), or the sign along a of the partner tree's root offset minus this tree's (partner, from the --root-offsets entries, so it points towards the partner root when the axis lies in the roots' faces, as in aligned); for a plane with normal n it is the projection of h onto the plane, undefined when h lies along n. A plane rule may also bank: after the turn the frame's perpendicular is turned towards the normal by the same rule, so that the branching turns the grammar writes about the perpendicular stay in the plane despite the roll of 70° before every daughter.

The rules are a JSON list, each governing the stems whose diameter at the first move lies below below × d_min and above the bound of the rule before it, so the rules partition the calibres from the finest upwards; the last rule may have below null and a rule with field null leaves its class unguided. A stem is not steered while the arc walked along it is below onset × its diameter, so that daughters clear their sisters before being pulled into line; the rule is chosen once per walked polyline, from the diameter of its first move, and held, so a local anomaly does not switch class part way along. Bare moves and spline stems are never steered, so --guidance needs --tortuosity walk, and a finite bound needs --d-min. --root-offsets adds one vector per tree, in units of d_min, to the roots' default positions on the faces of the growth box before the second root is cleared of the first tree; a component along a face's normal moves its root into the box. It needs --grow-in-volume and --d-min, and a root moved outside the box is refused. Every refusal fires before the network it concerns is written; a malformed rule or offset is refused before anything is written, but without --fit voxel_size the box comes from each grown tree, so a root outside it is refused when that network grows, after the earlier networks of a --count run have been written.

The law. The walk diffuses the heading with D = 1 / (4 P d) per unit length and the drift descends Φ = −cos²θ / 2 (nematic), −cos θ (polar) or sin²β / 2 (plane, β the angle out of the plane), so on a stem much longer than G d the heading settles into a stationary law: Watson exp(K cos²θ) with K = 2P / G for a nematic axis, Fisher exp(κ cos θ) with κ = 4P / G for a polar axis, and the girdle exp(−K sin²β) with K = 2P / G for a plane; the small-angle rms angles are √(G / 2P) to an axis and √(G / 4P) out of a plane. G follows from a target K. The steps are finite, so the measured concentration carries a discretisation bias of order step / (G d): tests/test_guidance.py grows one stem of 2000 sub-segments at P 10 and step 0.175 d and measures, over seeds 1 to 4 at G 3, ⟨cos²θ⟩ = 0.821, ⟨cos θ⟩ = 0.922 and ⟨sin²β⟩ = 0.078 against 0.830, 0.925 and 0.0747 from the laws: the laws' moments at 0.96 of their concentrations, at step / (G d) = 0.058. The nematic law is Watson, not Fisher-axial: Fisher-axial is exp(K |cos θ|), whose ⟨|cos θ|⟩ is 1 / (1 − e^−K) − 1 / K, which approaches the polar law's ⟨cos θ⟩ at κ = K, coth K − 1 / K, as K grows, the two differing by the polar law's backward mass.

Every run counts its steering: guided_steps, guidance_onset_steps, unguided_steps and guidance_undefined_steps partition the walk's steps, guidance_sense_flips counts the changes of the nearer end of a nematic axis between consecutive guided steps of one polyline, and guidance_bank_steps counts bank rotations. Guidance costs about 90 µs per guided step on an Intel Xeon Gold 5220 workstation (Linux, Python 3.12, numpy 2.5), and a bank about 45 µs more (tests/test_topology_benchmark.py): that doubles the cost of a bare walk step (89 µs there), but adds about 7% to a network grown with avoidance and anastomosis, the aligned preset taking 1.38 ms per point at R 8 against 1.29 ms with guidance None (seeds 1 to 3, 87 thousand points in all either way).

Every network grown from 3.5 records its frame in the sidecar and in the archive's metadata: the kind of the field rule with the smallest bound (none, axis or plane), its axis or normal, the sense and, for a polar rule, the +1 or −1 of each tree, every rule normalised, the growth frame (grow_direction, grow_perpendicular) and an origin (the centre of the growth box, else the first root), all in the coordinates and unit of nodes. With guidance off the kind is none and the rules are empty. frames.py places networks by their frame (below), and describe.py measures against it.

Capillary fill: --capillary-generations and --capillary-runs

A grammar tree ends every branch once its diameter falls below d_min, so it keeps only a small, fixed share of its length and volume in its finest calibres. The fill extends each such branch into a capillary bed of its own. With --capillary-generations M (M > 0, which needs --d-min), a daughter that d_min would end becomes a capillary tree instead: M generations of symmetric bifurcations, every stem drawn at d_min, turned by the Zamir angle of a bifurcation into equal daughters (acos 2^(−1/3), about 37.5°, the angle the grammar itself would draw for them) and rolled by the roll angle, so that the substitution draws nothing for its turns. The iteration count is checked first, so a daughter that --iterations ends stays ended, and the M capillary generations do not count against it; a root thinner than d_min is still drawn as nothing. With --capillary-runs E, every capillary stem, and every stem whose two daughters both end, is drawn as E consecutive stem blocks, each with its own length, mirror and anomaly draws, in one pair of braces, so the finest vessels run E times as far before they branch or stop. Every drawn diameter stays at or above d_min, apart from the middle of a stenosis.

With both options at their defaults (0 and 1) the grammar is the released one, string for string and draw for draw (the tests compare it with the 3.5 modules); the fill draws only on its own path: each capillary stem block and each extra run block takes the six random.random and five np.random.uniform calls of one stem, and nothing else. On the command line the fill applies only where --d-min ends a branch before --iterations does, so give an iteration range above the depth at which d_min is reached. With stochastic parameters, randomness margin and anomalies off and ε 7, the length and volume shares of a filled program below 2 d_min follow a closed form for symmetric Murray trees, which the tests check to 1e-9.

Cross-connections: --cross-connect

Anastomosis closes a tree into loops only at its tips; a capillary bed is a mesh whose capillaries also meet side to side. --cross-connect adds such connections ("rungs") after anastomosis, on a random stream of their own (tag 5), so that turning them on changes nothing drawn before them. The vessels are the segments of the trees between branch points and tips; a vessel whose median vertex diameter lies below --rung-below (2) × d_min is a capillary. Along each capillary, sites are drawn as a Poisson process with mean gap --rung-spacing (20, at least 1) vessel diameters and snapped to a vertex, then taken in random order. Each site is joined to the nearest capillary point of another vessel within --rung-radius (8) site diameters that is not touching it, not on its own vessel (or, with --rung-min-separation 3, not on a vessel meeting it either), not near a junction, and whose chord stands at least --rung-lateral-deg (60°) off the site's vessel tangent, or off --rung-axis when one is given. The rung has the smaller of the two diameters, is traced along the chord by the walk of an anastomosis bridge, keeps --collision-margin from the network and the rungs before it, and is redrawn up to --collision-attempts times. It joins both vessels at new junctions with bitwise shared end points, carries the bridge label, and is listed under rungs in the return, the sidecar (a count) and the library metadata; no existing column moves. Every site has exactly one outcome: rung_sites = rung_sites_near_junction + rung_sites_consumed + rung_no_partner + rung_collision_failed + rung_bridges, with rung_bridges_cross_tree, rung_kin_skipped, rung_angle_skipped and rung_bridge_redraws beside them, appended to the counters. With --cross-connect off no generator is created and every counter stays at zero.

Explicit capillary bed: --bed

Anastomosis, the fill and the rungs make a capillary bed out of the trees' own branches. --bed JSON builds one explicitly instead: two feeder trees grow in the mesh layout, from opposite faces of the growth box, with their grammar stopping below feeder_stop × d_min, and a bed of vessels diameter × d_min across is built in the box and joined to their tips. The construction is a greedy degree-3 graph with a girth bound, after Smith 2019's organising requirements (not Smith's Voronoi construction): a nearly isotropic, connected, space-filling capillary mesh whose junctions mostly join three vessels and whose tissue domains share one characteristic size (Smith, Doyeux, Berg, Peyrounette, Haft-Javaherian, Larue, Slater, Lauwers, Blinder, Tsai, Kleinfeld, Schaffer, Nishimura, Davit and Lorthois, Brain capillary networks across species: a few simple organizational requirements are sufficient to reproduce both structure and function, Frontiers in Physiology 10:233 (2019), DOI 10.3389/fphys.2019.00233). The settings are a JSON object whose keys left out take their defaults, given in brackets below, lengths in d_min unless stated; --bed null turns a preset's bed off. bed.py states every rule, draw and law in its docstring.

Seeds. Seeds are placed by hard-core random sequential addition, one proposal at a time, uniformly in the growth box inset by half a bed diameter d. A proposal is refused when it lies within a feeder column's vertex radius plus d / 2 plus --collision-margin of that column, and otherwise when it lies within h = spacing (7.5) of a seed already placed; the placement stops at the target count N_t = ⌊density (0.5) × V / h³⌋, V the volume of the inset box, which is known before anything is drawn. A placement that has considered ⌈64 V / h³⌉ proposals short of N_t is refused (bed.SeedsNotPlaced); nothing is shrunk. With stretch s above 1 (1, at most 4), distances between seeds are taken with their component along the growth direction divided by s, so that seeds and candidate edges stand up to s times further apart along it, V is divided by s, and the network's frame is recorded with kind axis along that direction (nematic, with no rules); at s = 1 the frame is that of an unguided pair, of kind none.

Tips and candidates. A feeder tip is a vertex of degree one other than a root. A tip inside the overlap zone of a junction of its own polyline is a stub, as in anastomosis, and takes no edge; every other tip is eligible, whatever its calibre. Every pair of seeds, and every pair of a seed and an eligible tip, closer than reach (2.5) × h is a candidate edge; two tips never are, since that would be an anastomosis. The candidates form one list, taken shortest first with jitter: in increasing order of the chord × (1 + jitter (0.25) × u), u one uniform draw per pair.

The greedy pass. A candidate becomes an edge unless it fails one of four rules, and is counted under the first it fails, in this order: the degree cap (three bed edges at a seed, tip_edges (2) at a tip, whose feeder stem makes it three); the angle (the edge would make less than min_angle (60°) with an edge already at either end, or at a tip with the feeder's direction into its vessel); the girth (the ends are already joined within girth − 2 edges, so the edge would close a cycle shorter than girth (8, at most 16), counted in edges of the bed together with the feeders, each feeder path between junctions and tips one edge); and the clearance (two bed edges meeting at an end at less than 72.06°, a chord passing within d + --collision-margin of a bed chord it shares no end with, or a point of the chord, sampled every step (0.5) bed diameters, colliding with a feeder). Two straight chords meeting below 2 asin(1 / 1.7) = 72.06° overlap beyond the junction excuse of collisions.py, so the smallest angle between two bed edges is in effect 72.06° at min_angle 60, and the candidates refused between the two count as clearance, in bed_refused_clearance_at_end; at a tip the feeder is a curved vessel, not a chord, and the angle against it is min_angle's. Seeds are not obstacles to chords: a seed that an accepted chord passes close to may be left with no edge that clears it, and is pruned.

Pruning and the walk. Every seed with at most one edge is removed with its edge until none is left (dead ends), then every component that holds no feeder tip (islands). Tips are never removed, so a bed that reaches one tree only is kept. What remains is cut into chains, each running through seeds of degree two between vertices of other degree or tips, and each chain is walked as one vessel: one tortuosity.bridge_path per edge, the pinned walk of an anastomosis bridge, at a persistence of persistence (8) bed diameters and a step of step bed diameters, through each interior seed along the direction from the seed before it to the seed after it, and leaving and reaching the chain's ends along the chord. A walk is checked against the feeders, the chains placed before it, the chords of those not yet placed and itself, and redrawn up to --collision-attempts times; when every attempt collides, the straight chain the greedy pass checked is placed instead (a chord), and when that collides too the chain is dropped, its edges removed and the bed pruned again. The check is the rule of describe.clearance, as the rungs apply it, so that a bed that passes it passes describe.py's clearance; a chain that returns to its junction (a lasso) is written from its middle seed, so that its closing points are excused at the junction. Walked chains are not confined to the growth box, as bridges and rungs are not.

Attachment and records. The feeders' columns are kept as they are, and each placed chain is appended after a NaN separator, d across and labelled 2 in tree, as bridges and rungs are, with its end and seed columns written from the stored coordinates, so that the graph rebuilt from coordinates joins it bitwise to the feeder tips and to the other chains; a tip with k bed edges ends with degree 1 + k. bridges in the return, the sidecar and the library index stays anastomose's, empty (0) for a bed, and the sidecar's bridge_persistence stays that of anastomosis bridges: the bed's persistence, like each of its settings, is in the shaping key bed, which the sidecar, the archive's metadata and a library member's kwargs record normalised, all thirteen values whichever were given. No return, describe or index key is added.

Draws. The feeders' two programs, F(niter, d0, feeder_stop (2) × d_min), are the only draws from the global generators. The bed draws on sub-streams of its own, numpy.random.default_rng([seed, 7, k]): k = 1 the proposals, k = 2 the jitter and k = 3 the walks, each created only when it draws. Tag 7 is bed.RNG_TAG, kept in bed.py as join's 3 and the library's 4 are kept in theirs, so main.RNG_STREAMS is unchanged; the sidecar of a run with a bed records "bed": [seed, 7] under rng_streams, and no other sidecar does. With the feeders fixed, changing the bed's persistence or the walk's redraws (bed.build's attempts) leaves the seeds and the edges of the greedy pass as they were, and changing the jitter or the step leaves the seeds. --collision-attempts also sets the redraws of the feeders' collision avoidance, which foam turns on, so changing it changes the feeders, and with them the seeds and the edges. Every distance and dot product the seeds, the candidates and the greedy pass decide on is computed elementwise (np.linalg.norm(..., axis=1), and products summed in order), never through @, np.dot or a one-dimensional np.linalg.norm, whose last bit depends on the BLAS kernel, so the seeds, the candidates and the greedy pass of a bed alone are the same on every kernel; the walks and the greedy pass's test against the feeders go through bridge_path, which depends on the kernel as every walked vessel does.

Refusals. bed.parse_settings refuses an unknown key, a kind other than foam, a spacing, diameter or persistence that is not positive, a reach not above 1, a girth that is not a whole number in [3, 16], a min_angle outside [0, 120], a tip_edges other than 1 or 2, a feeder_stop below 1, a stretch outside [1, 4], a density outside (0, 0.6], a negative jitter and a step below 0.1; bed.check_settings refuses a spacing that does not exceed the diameter plus the margin, at which seeds would touch. With a bed, grow_network and the command line also refuse anastomosis, --cross-connect, a capillary fill, --guidance, a run without --grow-in-volume or --d-min, a negative seed (the bed's streams take none; grow_network also refuses a missing one) and a collision margin that is not finite and at least 0, all before anything is drawn or written. A root diameter below feeder_stop × d_min, which would grow feeders without a tip, is refused when it is sampled. Under --fit voxel_size the box is known, so a --voxel-size that is not positive and finite, a box that holds no seed (main.BedBoxTooSmall, a ValueError beside RootOutsideBox) and a root offset outside the box are refused before the feeders' grammar draws; under the other fits (isotropic or stretch) the box is the first feeder's, so the last two are refused once the grammar has drawn, after the earlier networks of a --count run have been written. bed.SeedsNotPlaced comes after the seeds have drawn. On the command line the refusals that depend on a network's sampled root or grown feeders (a root below feeder_stop × d_min, a box too small or an offset outside it under the other fits, and bed.SeedsNotPlaced) exit naming the seed, and the others exit before anything is written; with --generate, check_connectivity.py skips a seed whose root lies below feeder_stop × d_min, as it skips one below d_min.

Counters. Twenty-nine counters are appended to the events after the rung counters, all zero without a bed. bed_proposals = bed_proposals_on_feeders + bed_proposals_overlapping + bed_seeds, and bed_seeds is N_t. bed_tips counts the feeder vertices of degree one other than roots, bed_tips_inside_junction the stubs among them. bed_candidates = bed_refused_degree + bed_refused_angle + bed_refused_girth + bed_refused_clearance + bed_edges, bed_edges being the edges the greedy pass accepted; bed_candidates_tip counts the candidates with a tip and bed_refused_clearance_at_end the clearance refusals at a shared end. bed_edges = the kept edges + bed_edges_dead_end + bed_edges_island + bed_edges_dropped, and bed_seeds = the kept seeds + bed_seeds_dead_end + bed_seeds_island. bed_segments, the chains whose walk was attempted, = bed_segments_walked + bed_segments_chord + bed_segments_dropped; bed_segments_removed counts placed chains that the pruning after a later drop removed, and bed_walk_redraws is at most --collision-attempts per chain. bed_tips_attached counts the eligible tips with a kept edge and bed_tip_edges their kept edges; bed_components counts the components of the kept bed and bed_components_one_tree those whose tips all belong to one tree.

The girth bounds cycles in edges of the bed graph. describe.py counts loops in segments, and a chain merges its seeds of degree two into one segment, so loops shorter than the girth in segments do occur: in foam networks at R 4, 8 and 16 (seeds 1 to 3 pooled), 71%, 43% and 28% of the loops through a segment are shorter than 8 segments (the shortest has 3), while no cycle of a bed is shorter than 8 edges. A pair whose feeders have few eligible tips may stay unjoined: when no kept component reaches both trees, as bed_components_one_tree shows, check_connectivity.py reports the network as two components. Over these nine networks, 513 of the 535 eligible tips (96%) attach, 432 of them by two bed edges, and the bed joins both trees in 8 of them. At R 4, 8 and 16 (means over the seeds) their bed's capillary segments have a median length of 11.6, 10.1 and 9.8 d_min (10th to 90th percentiles 8.3 to 30.7, 8.2 to 22.9 and 8.1 to 20.6 d_min, coefficients of variation 0.64, 0.55 and 0.50), the mean distance from tissue in the growth box to the nearest vessel wall is 3.37, 3.08 and 2.98 d_min (3.61, 3.26 and 3.15 d_min to the bed's vessels alone), and the median smallest, middle and largest angles at junctions of three vessels are 83°, 107° and 139° at R 4, 84°, 107° and 138° at R 8 and 85°, 107° and 138° at R 16 (tests/test_topology_benchmark.py).

Fibre beds: "kind": "fibre"

A bed of kind fibre is made of capillaries that run nearly parallel to one axis a, with short cross-connections between neighbours; its density across a, its alignment and its cross-connections are set independently, so alignment costs no density. Its keys, bed.FIBRE_SETTING_KEYS, are lengths in d_min unless stated, and every default is provisional except axis and layout: spacing h (5), the lanes' hard core across a; density (0.5), ρ h², in (0, 0.6]; girth (8), min_angle (60), reach (2, the longest tip edge in spacings), diameter d (1), persistence (16), feeder_stop (2), tip_edges (2), jitter (0.25) and step (0.5), as foam's; axis (null: the frame's axis, else its growth direction); knot ℓ (4), the axial spacing of a lane's knots; sway σ (1), the standard deviation of each transverse knot offset; link_spacing λ (20), the planned mean axial spacing of cross-connection ends along one capillary, null for none; link_reach (1.75 spacings); run, the mean axial distance between lane breaks, null for unbroken lanes (it needs link_spacing: without links a broken lane peels away entirely); layout, pair or sheets; and, for the sheets layout, unit (60) and sheet_length (2). A key of the other kind is refused, stretch with fibre and knot with foam, and foam's settings are 3.7's byte for byte.

The rules (F0-F8 of bed.py's docstring): N = ⌊density A / h²⌋ lane points, A the inset box's area projected along a, are placed by a two-dimensional hard core in the plane through the box centre across a, each lane its line clipped to the box; a lane carries a knot on each face it meets and interior knots every ℓ from a random phase, each offset across a by a Gaussian of σ per component cut radially at w = (h − d − margin) / 2, so that two lanes' chords never come within d + margin; breaks remove lane edges at exponential gaps; a share ℓ / (2λ) of the interior knots are link sites, each joined to the knot nearest in t of a neighbouring lane within link_reach h, drawn among the neighbours whose link clears max(min_angle, 72.06°) at both ends against the lane edges there, so that no link is refused by the angle or at a shared end against a lane. Lane edges, then tip edges, then links pass foam's greedy pass; dead ends are peeled with the face knots anchored, islands removed, and the chains walked and attached as foam's. The counters from bed_proposals_outside on are the fibre bed's (0 for foam), and their laws F1-F9 are in the docstring. On a bed alone along a coordinate axis without breaks every plane across a inside the lanes' common extent meets each lane's chord path once, whatever the sway or the link rate, so the chord graph's crossings are N plus the links that span the plane; bed.chord_crossing_ratio(settings) gives the lane chords' length-weighted crossing ratio J(β) = 1 + β √(π/2) exp(1/(2β²)) erfc(1/(√2 β)), β = √2 σ / ℓ, for offsets well below w. planned_seeds and planned_candidates (N (⌈E/ℓ⌉ + 1) + ⌈N ⌈E/ℓ⌉ ℓ / (2λ)⌉, E the inset box's extent along a) take the axis as a keyword when the settings have none, and frame_record gives a fibre bed an axis record about the domain's centre even without a frame.

Layouts. pair grows foam's feeder pair. sheets grows n = max(2, ⌊B / (unit d_min) + 1/2⌋) feeder trees, B the box's side along a, in planes across a at s_k = (k + 1/2) B / n: tree k is arterial from the face at the low end of the growth direction (y = 0 on the command line) for even k and venous from the opposite face for odd k, its root at s_k and the box centre, labelled k + (k ≥ 2) so that labels skip the bridge label, each steered by a plane rule of normal a and length sheet_length. It needs a bed axis of ±x or ±z, the growth direction ±y and the walk, holds at most 126 sheets, and takes no root offsets: it places its own roots. The sidecar and archive metadata list the trees under feeder_trees (label, role, sheet, root).

Measured (tests/test_fibre_benchmark.py, beds alone along x, d_min 1, margin 1). In a cube of side 96 at the defaults (seeds 1-10) 0.83 of the link candidates and 0.70 of the link sites become links; no lane edge is refused and no chain dropped. A dense bed (spacing 3.2, density 0.6, links on) in a cube of side 256, 246,663 knots and 1.09 × 10⁶ d_min of walked chain, took 363 s and 995 MB on one core: 1.06 times foam's cost per unit of placed chain length on the same machine.

A field of view can be filled between a forest's tips with bed.build directly and a settings axis:

import bed
result = bed.build((low, high), {"kind": "fibre", "axis": [1.0, 0.0, 0.0]}, d_min=1.0, seed=1,
                   collision_margin=1.0, feeders=(nodes, tree))

With one feeder label every end of degree one but the first column of the label is a tip, so ends cut at the box faces are tips too.

Families: --family

tree is the plain grammar. mesh is walk (P = 10) + collision avoidance + arteriovenous anastomosis of half the tips, grown in the volume. tumour is a low-persistence walk (P = 3), avoidance, anastomosis of 80% of the tips within one tree, a root calibre of 20 ± 10 µm and aneurysm and stenosis probabilities of 0.1. aligned is an arteriovenous pair in the mesh layout whose roots are moved by −15 and +15 d_min along x, 30 d_min apart (root_offsets), and whose vessels below 2 d_min are steered along x towards the other tree's root (a polar axis with polarity partner, G 4.9, so κ = 4P / G ≈ 8.2, after an onset of 2 diameters) while the larger vessels are kept in planes perpendicular to x (a plane with normal x, G 5, girdle K = 4): each tree feeds one sheet and the capillaries run from sheet to sheet, with 80% of the tips seeking an arteriovenous partner. It imitates capillaries running along muscle fibres from terminal arterioles to collecting venules with cross-connections (Skalak and Schmid-Schönbein 1986; Sarelius 1986; Emerson and Segal 1997), whose orientation has been fitted with a Fisher-axial law (Mathieu et al. 1983; Mathieu-Costello 1987). It needs --d-min, and its guidance values are provisional. aligned_tight is aligned with its capillaries steered harder and from their first step (G 2, so κ = 4P / G = 20, and an onset of 0 for both rules) and its roots moved by −20 and +20 d_min along x, 40 d_min apart, in a cube of side 20 R in a library; it also needs --d-min, and its values are provisional too. aligned_bed is aligned_tight filled and cross-connected: capillary trees of two generations whose stems run as four blocks, and rungs at a spacing of 40 diameters whose chords stand at least 60° off x, for capillaries running along fibres with transverse connections (Skalak and Schmid-Schönbein 1986; Kassab and Fung 1994). capillary_bed is an unguided arteriovenous pair grown in the volume, every tip seeking a partner of the other tree, filled with capillary trees of three generations whose stems run as two blocks and cross-connected at a spacing of 20 diameters off the vessel tangent, for a homogeneous capillary mesh fed and drained by trees (Lorthois and Cassot 2010; Blinder et al. 2013; Ji et al. 2021). Both need --d-min; their fill and rung values are provisional. foam is two feeder trees in the mesh layout, walked (P = 10) with collision avoidance and grown in the volume, whose grammar stops below 2 d_min, joined by the explicit bed of the section above, a greedy degree-3 graph with a girth bound after the organising requirements of Smith et al. (2019), with every bed setting written out in the preset: spacing 7.5, girth 8, min_angle 60, reach 2.5, diameter 1, persistence 8, feeder_stop 2, tip_edges 2, stretch 1, density 0.5, jitter 0.25 and step 0.5. Its frame is of kind none, about the centre of the growth box, and a library grows it in a cube of side 15 R. It needs --d-min; its bed values are provisional. Released presets are frozen, so a changed value takes a new family name. Options given explicitly override the preset. The persistence values of the presets are provisional calibrations from the sweep in docs/geometry. A preset listed as None in main.FAMILIES is a family this version does not offer, and is refused.

fibre is feeder trees in the sheets layout, walked (P = 10) with collision avoidance and grown in the volume, their grammar stopping below 2 d_min, joined by a fibre bed along x with every bed setting written out: spacing 5, girth 8, min_angle 60, reach 2, diameter 1, persistence 16, feeder_stop 2, tip_edges 2, density 0.5, jitter 0.25, step 0.5, axis x, knot 4, sway 1, link_spacing 20, link_reach 1.75, run null, layout sheets, unit 60 and sheet_length 2. It imitates the capillary bed of striated muscle, capillaries running along the fibres between terminal arterioles and collecting venules in alternating sheets, with cross-connections (Plyley and Groom 1975, American Journal of Physiology, doi:10.1152/ajplegacy.1975.228.5.1376; Bassingthwaighte et al. 1974, Microvascular Research, doi:10.1016/0026-2862(74)90008-9; Potter and Groom 1983, Microvascular Research, doi:10.1016/0026-2862(83)90044-4; Skalak and Schmid-Schönbein 1986, Microvascular Research, doi:10.1016/0026-2862(86)90069-5; Sarelius 1986, American Journal of Physiology, doi:10.1152/ajpheart.1986.250.6.H899; Kassab and Fung 1994, American Journal of Physiology, doi:10.1152/ajpheart.1994.267.1.H319; Emerson and Segal 1997, Journal of Applied Physiology, doi:10.1152/jappl.1997.82.1.42), whose orientation has been fitted with a Fisher-axial law (Mathieu et al. 1983, Journal of Microscopy, doi:10.1111/j.1365-2818.1983.tb04240.x; Mathieu-Costello 1987, Microvascular Research, doi:10.1016/0026-2862(87)90010-0). Its frame is of kind axis along x; a library grows it in a cube of side 15 R. It needs --d-min, --bed null turns its bed off, and its numeric values are provisional.

Cost

The default command line is unchanged in what it draws and about 8% slower at twelve generations (21 s against 20 s for a 512 × 512 × 140 volume on one core), the difference being the graph built for the archive; shallow trees take the same 1.5 s as before. The opt-in stages are Python loops over walk steps: at twelve generations the walk adds a quarter, stems-mode avoidance a half, walk with avoidance takes 2.5×, with anastomosis 4×, and the two-tree mesh preset 7× (146 s). docs/geometry/audit.md has the table. The aligned preset of 3.5 costs about what a mesh costs: grown as the library grows it on an Intel Xeon Gold 5220 workstation, it takes 8.8, 49 and 185 s at R 4, 8 and 16 (means over seeds 1 to 3, 6.6, 28 and 87 thousand points) and 600 s at R 25 (298 thousand points, peak RSS 356 MB), where a mesh takes 6.7, 29, 199 and 818 s (tests/test_topology_benchmark.py). Grown the same way with 3.6, aligned_tight takes 5.6, 31 and 170 s at R 4, 8 and 16 (5.6, 21 and 102 thousand points) and 890 s at R 25 (358 thousand points). The capillary fill multiplies the grammar's moves by about 13 in aligned_bed and 15 in capillary_bed. aligned_bed takes 13, 54 and 690 s at R 4, 8 and 16 (11, 43 and 300 thousand points) and 28 minutes at R 25 (708 thousand points, peak RSS 0.93 GB). capillary_bed takes 34 and 335 s at R 4 and 8 (17 and 107 thousand points) and 55 minutes for one network at R 16 (634 thousand points, peak RSS 0.83 GB); on one network at R 4, four fifths of its time went on anastomosis, most of it walking bridges between its many tips. Both vary several-fold between seeds at one R. Grown the same way with 3.7 on a 4-core 2.8 GHz Xeon, foam has 6.8, 58 and 476 thousand points at R 4, 8 and 16 (means over seeds 1 to 3) and 1.81 million at R 25 (seed 1, peak RSS 1.24 GB, archive 47 MB). Its cost follows its bed, whose seeds number about 4 R³ in the library's cube of side 15 R (243, 1 997, 16 180 and 62 001 at R 4, 8, 16 and 25, before pruning, a number fixed by the box): a bed's time is linear in its seeds, and walking the bed takes 80% of it at R 4 and 85% at R 8 and 16. A bed grown alone with the foam settings, one process at a time, takes 5.8 and 67 s at 10³ and 10⁴ seeds (means over seeds 1 to 3) and 675 s at 10⁵ (seed 1), the walk taking 89% of it at 10⁵, with a peak RSS of 21 MB, 137 MB and 1.25 GB above that of its imports; its time grows with an exponent of 1.03 in the seeds (1.02 for the walk), and its peak RSS with one of 0.88.

Measuring: describe.py

python describe.py output/Lnet_i8_s1.npz
python describe.py output/*.npz --margin 1 --volume 1024 1024 280

reports, as JSON, length-weighted diameter percentiles and total length, per-segment arc/chord ratios and mean absolute curvature, tip count and tips per mm, the junction degree histogram, components β₀ and cycles β₁, the length-weighted tangent covariance and its anisotropy, length density, the minimum surface clearance between branches with the number of pairs closer than the margin, and every counted event from the sidecar. The docstring of describe.describe is the definition of each quantity, so that the same descriptors can be computed from voxel skeletons of real images and compared.

From 3.5, describe(nodes, edges, ..., frame=None, d_ref=None, class_bound=2.0) also measures against the network's frame and by calibre class. frame defaults to the metadata's frame record and d_ref to the metadata's d_min (top level, else grow_kwargs); an edge is capillary-class when its diameter is below class_bound × d_ref, and the tangent of an edge runs from its lower to its higher column, the direction of growth for a network the generator wrote. The appended keys are frame (a copy), classes (the bound, d_ref and where it came from), frame_orientation (per class about an axis: the order parameter S = ⟨(3 (t·a)² − 1) / 2⟩, ⟨|t·a|⟩ and its reciprocal the crossing ratio, the mean angle, the fractions within 20° and 45°, the Watson K and fisher_axial_K that invert ⟨(t·a)²⟩ and ⟨|t·a|⟩, and from 3.6 fisher_axial_K_exact, the K of the Fisher-axial law exp(K |t·a|) itself, whose ⟨|t·a|⟩ is 1 / (1 − e^−K) − 1 / K, so that isotropic tangents read 0 where fisher_axial_K reads about 1.8; about a plane: the in-plane fraction ⟨1 − (t·n)²⟩ and S_n), polar_order (polar axis frames with tree labels: ⟨s_k t·a⟩ with each tree's sense s_k, bridges excluded), orientation_by_class (the tangent covariance per class with S_max and the planarity 1 − 3λ₃), calibre_shares (of length and of volume below 1.5, 2 and 3 d_ref), segments_by_class (segment lengths between junctions and tips, split by their mean diameter) and transverse_spacing (axis frames: on nine planes perpendicular to the axis over the central 80% of the capillary extent, the nearest-neighbour distance between the crossings of capillary edges and the crossings per unit area of plane inside the growth box, else inside the capillary bounding rectangle), and from 3.8 cross_connections and cross_section (axis frames with d_ref). cross_connections counts the transverse capillary segments, at least describe.CROSS_ANGLE_DEG = 60° from the axis, whose two ends are junctions of capillary segments only: "count", "per_length" and "per_length_d", "end_spacing_d" (the axial capillary length over twice the count, the mean axial distance between cross-connection ends along a capillary, comparable with a fibre bed's link_spacing), "chord_d", "length_share", "axial_segments_d" (the unbranched runs) and "junction_types" (T: one branch transverse and one each way along the axis; Y: none transverse, a split or a merge along it; other). cross_section gives, on transverse_spacing's nine planes, "nn_ratio" (the mean nearest-neighbour distance times the square root of the crossing density: 0.5 for a Poisson pattern, 1 for a square lattice, 1.0746 for a hexagonal one), "per_plane_density_d" and their "density_cv". Lengths come in the archive's unit and in d_ref (suffix _d). An archive without a frame reports frame None and every frame-relative key None; one without a d_ref reports every class-dependent key None. The pre-3.5 keys are unchanged. describe_archive with every new key takes 21 s on the largest network tests/test_topology_benchmark.py grows, a tree of 1.08 million points at R 25, and 1.9 s on an aligned network of 139 thousand points at R 16, on an Intel Xeon Gold 5220 workstation.

From 3.6, describe(..., evd_spacing=None) also measures the topology of a network, appending five keys. junctions gives, among vertices of degree three or more, the fractions of degree 3, 4 and at least 5, the mean degree and the segments per junction (every segment between vertices of degree other than two, divided by the junctions). branch_angles_deg gives, at vertices of degree three, the three pairwise angles between unit vectors to the points at arc min(2 d_v, half the segment) along each arm, as medians over the junctions of each one's smallest, middle and largest angle. loops gives the shortest cycle through a segment (by_segment: 1 for a closed segment, 2 for a parallel pair, else one more than the distance between its ends without it, by a two-way breadth-first search) and through a junction (by_node: by Dijkstra over segment lengths, the shortest cycle by length, reported by its segment count and its length), each on every k-th segment or junction up to 2000, searched only where cycles can lie (the network's 2-core) and capped at a depth of 16 and 4096 vertices: median, mean, histogram, the share with no cycle within the caps, and the cycles per unit length. segment_diameter_variation gives, per segment and per calibre class, (max − min)/mean of its vertex diameters with its junction ends left out (otherwise every daughter would measure its parent's step down), as a median and p90. With evd_spacing, tissue_distance gives the distance from the points of a regular grid of at most 64³ points (the spacing raised, and recorded, when needed) to the nearest vessel wall, by the voxeliser's capsule rule, exactly and without any random draw: mean, median, p90, p99 and maximum outside the vessels, and the inside fraction. The grid spans describe's volume when given, else the growth box (growth_box_um from the command line, or growth_box in a library network's metadata, which the length density does not read), else the voxeliser's field of an archive fitted by voxel size, else the bounding box. The topology keys leave describe_archive at 21 s on the largest network the benchmark grows, the tree of 1.08 million points at R 25, which has no cycle to search; it takes 14 s on an aligned_bed network of 708 thousand points at R 25, and the tissue distance at evd_spacing 1 adds 2 to 6.5 s on the largest networks of each family.

Placing networks by their frame: frames.py

frames.py is a caller-side module, outside the generator's import closure and importing nothing but numpy, that moves an archive and its frame record together: transform_nodes(nodes, Q, t, scale) gives xyz' = scale Q xyz + t with diameters scaled, and transform_frame(frame, Q, t, scale) rotates every vector of the record, the rules' included, and scales the origin, so that a descriptor measured about the frame's axis is the same before and after the move. rotation_about(axis, angle), rotation_between(u, w, nematic=False) (a proper rotation taking u to w, with w flipped first when nematic and u·w < 0, in a form that does not lose precision near antiparallel pairs), rotation_of_frames(u1, u2, w1, w2) and random_rotation(rng) (Haar) build the rotations, and align(frame, axis=A, spin=φ) (or normal=N) takes the frame's axis (or normal) to the given one and spins about it. Two recipes: a bank of networks along one shared axis A takes Q_i = align(frame_i, axis=A, spin=U(0, 2π)); random placement takes Q_i = random_rotation(rng). Rescaling is a transform with Q the identity and the same scale for nodes and frame. A missing record (every archive before 3.5), a frame of kind none, an axis asked of a plane frame or a normal of an axis frame, and any Q that is not a proper rotation to 10⁻⁹ are refused. join.py needs nothing of this. docs/geometry/sweep.py regenerates the descriptor tables in docs/geometry, and docs/geometry/mips.py renders one seed under each option as maximum-intensity projections (docs/geometry/mips), so the effect of every stage can be seen as well as measured.

Joining networks inside a field of view: join.py

A volume assembled from many independently grown networks is a forest: every network is its own connected component and ends in free tips everywhere, whereas the vasculature inside a real field of view is essentially one connected network whose only free ends are the vessels the field's edge cuts. join.join_networks applies the anastomosis rules across a forest. The caller places the networks (rotates and translates them into one frame and unit); join_networks adds bridges and returns them separately from the inputs.

import numpy as np
from join import crop_network, join_networks

lo, hi = np.zeros(3), np.array([512.0, 512.0, 140.0]) * 2.0   # the field of view, caller units
margin = 1.0                                                  # clearance between vessel surfaces
r_max = max(np.nanmax(n["nodes"][3]) for n in placed) / 2.0
cropped = [crop_network(n, lo, hi, r_max + margin) for n in placed]
result = join_networks(cropped, np.random.default_rng([seed, 3]), fraction=0.9,
                       collision_margin=margin, box=(lo, hi), boundary_margin=2.0)

Each placed network is a dict with nodes ((4, N) x, y, z, diameter with NaN separators), node_kind (the kind each column had in the uncropped network), and optionally tree and roots (root columns, empty meaning none). A network's identity is its position in the sequence, an int32, never the int8 tree label, so hundreds of networks can be joined. Edges on input are ignored: each network's graph is rebuilt from its own coordinates with tol, per network, so networks never merge by coincidence.

crop_network(network, lo, hi, margin) crops by whole columns: a column is kept when either segment it belongs to has a radius-padded bounding box meeting [lo - margin, hi + margin], kept columns stay in archive order, and each dropped run becomes one NaN column. Polylines are never split into per-segment pairs: the tip tangent, the sampling step, the stub test and the collision excusal are all per polyline, and a tree split into two-column chains makes every one of its tips a stub. It returns the cropped nodes, node_kind and tree, the roots remapped, and columns, the original column of each kept one (-1 at separators). Make the crop margin at least the largest vessel radius plus collision_margin, so that bridges near a face are checked against the vessels just outside it. A network with no kept column comes back empty and join_networks accepts it.

Keyword arguments, all required unless a default is shown:

Argument Meaning
rng a numpy.random.Generator seeded by the caller; join.RNG_STREAMS["join"] is the tag to pair with a run seed
fraction share of the eligible tips drawn as sources
collision_margin clearance every bridge keeps from every input vessel and every earlier bridge, caller units
box (lo, hi), the field of view in caller units
boundary_margin a tip within this distance of a face is a cut end, caller units
radius=25.0 partner search radius in tip diameters
policy="cross" cross: partners on other networks only; any: also the same network at a graph separation of at least min_separation segments (another component of the same network counts as far enough)
min_separation=3 the kin rule of anastomosis under any; 3 excludes the parent and sister stems
persistence=8.0 curvature of the bridge walk, in bridge diameters
attempts=10, max_candidates=5 redraws per candidate, partners tried per tip
tol=graph.DEFAULT_TOL coincidence tolerance of each network's graph, caller units
max_bridge_volume=None budget for the summed bridge volumes, caller units cubed
merged=False also return inputs and bridges as one archive with its graph
events=None counters incremented in place (join.EVENT_KEYS)
attach_roots=False let each eligible root attach, as a side branch, to a vessel of another network at least as thick (below)
root_fraction=1.0 share of the eligible roots drawn as sources
root_partner_min_ratio=1.0 a root's partner must be at least this many root diameters across, measured at the vertex; at least 1
root_radius=None partner search radius for a root in root diameters; None uses radius

Roots come from roots when given; otherwise the first finite column of each tree label other than the bridge label (so both trees of an arteriovenous mesh are roots), or the first finite column when tree is absent, a default valid only for uncropped networks. A root is never a source or a partner, unless attach_roots makes the roots inside the box sources (below), and is reported as root inside the box, cut_end outside it. A degree-one vertex is a cut_end when the crop made it (its input node_kind is not a tip) or when it lies outside the box or within boundary_margin of a face; cut ends are vessels that continue beyond the field and are never sources or partners. A network's own anastomosis bridges (tree == 2) are ordinary vessels of that network.

The rules kept from anastomose: partner search within radius tip diameters; partners within 120° of the tip's direction; candidates ranked tip before interior point, then by distance, then by (network, column); exclusions of the tip's own polyline, kin closer than min_separation under any, junctions, roots, stubs, junction zones, the zone of the junction the tip's polyline leaves, and points already touching; bridge diameter min(d_tip, d_partner); step the median spacing of the tip's polyline (0.2 d_tip when undefined); departure along the tip tangent and arrival along a partner tip's inward tangent; the walk's curvature at persistence diameters; the collision rule of collisions.py (strict r + r + margin against every input vessel and every earlier bridge, excused only along the tip's and partner's polylines, plus the bridge against itself); bitwise end columns, so the graph closes; a tip used as a partner stops being a source and a bridged interior point becomes a junction. The deviations: bridge points are collision obstacles only, never partners, so every bridge end is a (network, column) of an input network; partners must lie inside the box; a bridge whose centreline leaves the box counts as a redraw (join_box_redraws); the selected tips are processed in a permutation drawn right after the Bernoulli draws, so the result does not depend on the order of the networks beyond the draws. The walk takes its angle and axis draws in bulk, one array of each per bridge, where tortuosity.bridge_path interleaves them step by step, so the same generator state gives a different (equally distributed) bridge here.

In a forest packed into a field of view, many networks have their root inside the field, and each such root is a blunt start; real vasculature has none, since a vessel's upstream end is a branch off a parent vessel. With attach_roots, an eligible root (a degree-one root vertex inside the box, clear of the faces by boundary_margin, whose input node_kind is a tip; whether it lies in a junction zone does not matter) becomes the source of a bridge to a vessel of another network at least as thick, so that the blunt start becomes a side branch, with the machinery of the tip bridges. The root's upstream direction is the unit vector from the first distinct point of its polyline to the root, which points out of the vessel; the bridge departs along it and the 120° cone is taken about it (a root with no such point gets no cone and a chord departure, as a tip without a tangent does). A root's partner is a vertex of degree one or two that the partner rules above allow, on another network whatever policy is, not already touching the root, inside the cone, within root_radius root diameters, and whose vertex diameter (the largest over the columns it gathers, so an aneurysm point counts at its bulged diameter) is at least root_partner_min_ratio times the root's; the zone of the junction the polyline leaves (a disc around the root itself) and the kin rule (no same-network partner is possible) do not apply. Interior points rank before tips, then distance, then (network, column), so a root prefers a side branch to a tip-to-tip junction; as for tips, a tip already bridged has degree two and counts as an interior partner. The bridge has the root's diameter, not the smaller of the two, so the vessel continues upstream at its own calibre; its step, persistence, redraws, collision rule and excusals, bitwise end columns, the junction at an attached interior point and the consumed attached tip are those of a tip bridge. An ineligible root (outside the box, within boundary_margin of a face, or not a tip of the uncropped network) is reported as cut_end, and an eligible one as exactly one of root_attached, root_not_selected, root_no_partner, root_collision_failed and root_over_budget; the code root never occurs with the option on. With the option on, a root column given as a NaN separator is refused.

Draws come from rng in a fixed order: one Bernoulli per eligible tip in (network, column) order, the processing permutation, then the bridge walks in processing order. With attach_roots and at least one eligible root, a root phase comes first: one Bernoulli per eligible root in (network, column) order (root_fraction), a permutation of the selected roots, then the root bridges in that order, each an obstacle for every later bridge; the tip draws follow, and are made even when the budget was spent during the root phase, in which case the remaining selected roots are root_over_budget and the selected tips over_budget. With no eligible root nothing is drawn, so the bridges, the generator state and the summary equal the option off (the tips table then differs only where a root inside the box is ineligible, reported as cut_end rather than root); with root_fraction 0 the root Bernoulli draws are still made. A tip consumed by a root bridge counts in join_source_consumed when its turn comes in the tip phase, as one consumed by a tip bridge does. Identical inputs, parameters and generator state give identical output. The result is also invariant under a change of unit: scaling the nodes, collision_margin, box, boundary_margin and tol by λ (and max_bridge_volume by λ³) gives the same topology and outcomes and bridges equal to λ times the original (root_radius is in root diameters and is not scaled). To keep a library's clearance when joining library networks rescaled by λ, pass λ times the library margin, and work in a frame where one library unit maps to at least one caller unit, or scale tol by λ as well.

With max_bridge_volume set (π r² × arc per bridge, caller units cubed), no new bridge starts once the sum would exceed it; the remaining selected tips are reported as over_budget (and the remaining selected roots as root_over_budget: root and tip bridges share the budget). Each bridge reports its volume, and the first k bridges of any result are themselves a valid result (each was checked against the inputs and the bridges before it only), so a caller may truncate after measuring what it rendered: a run with max_bridge_volume equal to the volume of the first k bridges reproduces exactly those k.

The result holds bridges (per bridge: tip and partner as (network, column), source_kind (tip or root: a root bridge's tip field holds the root's (network, column), so readers of the record work unchanged), partner_kind, geometry as a (4, m) float64 array in the caller's frame whose first and last columns are bitwise copies of the joined columns, chord, arc, diameter, volume and redraws), bridge_nodes (the bridges concatenated with NaN separators), tips (a structured array with one row per degree-one vertex of the input: network, column and an outcome code into join.TIP_OUTCOMES, exactly one of root, cut_end, stub, not_selected, bridged_source, bridged_partner, no_partner, collision_failed, over_budget, root_attached, root_not_selected, root_no_partner, root_collision_failed, root_over_budget; degree-zero columns are counted in events["join_isolated"] and are not tips), events and summary. The counters join_root_eligible, join_root_selected, join_root_attached, join_root_not_selected, join_root_no_partner, join_root_collision_failed and join_root_over_budget are appended to join.EVENT_KEYS after those of 3.3 (as the root codes are to TIP_OUTCOMES), so existing codes and keys keep their values; the last five are read from the final table like the other outcome counters, so the outcome counters still partition join_tips. Root bridges also count in join_redraws, join_box_redraws, join_behind_skipped, join_partner_tips or join_partner_interior and join_components_joined, never in join_eligible, join_selected, join_bridges or join_source_consumed, so len(bridges) == join_bridges + join_root_attached. The summary holds, before and after, the components, the length, free_tips (tips that are neither roots, under any root code, nor cut ends), blunt_roots (the eligible roots, less those attached), free_ends (both together) and each of the three per unit length, plus the bridge volume; with the option off blunt_roots is the same before and after. With merged=True it also holds merged: nodes with the bridges appended after NaN separators, edges, node_kind, a network id per column (-2 on bridges) and tree.

To render placed geometry, map it to voxels yourself and call the rasteriser directly; process_network re-centres the network and must not be used here. With voxel i centred at i, the field of view of a volume of n voxels is [-0.5, n - 0.5] per axis, so a box (lo, hi) renders into (hi - lo) / v voxels when lo maps to -0.5, which an origin half a voxel inside the box does:

from computeVoxel import rasterise_segments

v = 2.0                                                        # caller units per voxel
shape = tuple(int(round(s)) for s in (hi - lo) / v)
origin = lo + v / 2.0
result = join_networks(cropped, rng, fraction=0.9, collision_margin=margin, box=(lo, hi),
                       boundary_margin=2.0, merged=True)
xyz = result["merged"]["nodes"]                               # inputs and bridges, NaN separated
volume = rasterise_segments((xyz[:3] - origin[:, None]) / v, xyz[3] / (2.0 * v), shape, connect=True)

result["bridge_nodes"] alone renders the bridges on their own, for measuring what they add.

Measured cost (one process, this machine): the test forest of 253 networks of 4-generation trees (186 thousand points) goes from 310 components to 31 and from 0.0141 to 0.0002 free in-box tips per unit length with 721 bridges. The two benchmark forests of grown trees placed at random rotations and offsets with a point-clearance rejection and cropped with crop_network (VSYSTEM_SLOW_TESTS=1 python -m unittest tests.test_join.BenchmarkTests):

forest networks points eligible tips selected bridges joining per selected tip peak RSS
typical 317 256 208 3 778 3 365 1 908 8.5 s 2.5 ms 160 MB
large 650 2 117 568 17 118 15 408 7 293 143 s 9.3 ms 968 MB

The large forest is denser (6-generation trees), so a bridge needs 8.5 redraws on average against 3.2 in the typical one, which is where the extra time per tip goes; the fixed setup (graph, per-column arrays, indexes) is about 1.3 s per 0.35 million points. The cost per tip splits roughly into the collision checks (half, through one grid per octave of vessel radius and a growing grid of the earlier bridges), the bridge walk (a quarter) and the partner search with its vectorised filtering.

Root attachment, measured on the typical forest grown with root diameters spread log-uniformly over [2, 8] at d_min = 1 (330 networks, 235 thousand points, VSYSTEM_SLOW_TESTS=1 python -m unittest tests.test_join_roots), on a machine where the option off takes 15.5 s: the option on takes 26.2 s; of 266 eligible roots 220 attach, 30 find no partner (nothing as thick within reach and the cone) and 16 fail every redraw; the root bridges have a median chord of 14.7 and a median arc of 16.8 root diameters at the default root_radius (4.1 and 4.3 at root_radius 5, where 9 roots attach), all to interior points; a root bridge holds 1 773 units cubed on average against 106 for a tip bridge, since it is as thick as the root and long; the free ends per unit length go from 0.0289 to 0.0067 with the option off and to 0.0053 with it on, the blunt roots per unit length from 0.0021 to 0.0002. The attachment rate is 74 to 93 % in every root-diameter bin.


Network libraries: vsystem-library

A library is a fixed, reproducible set of networks grown once and sampled from afterwards, rendered at whatever scale a use needs. V-System is scale free: growth at (λ d0, λ d_min, λ margin, same seed) gives λ times the nodes up to float rounding, so a library grown in relative units, with the smallest drawn vessel diameter d_min = 1 unit, can be rescaled to any resolution. Its networks are described by the root ratio R = d0 / d_min.

vsystem-library --out lib --count 2000 --seed 1 --workers 8
Option Default Meaning
--out DIR required output directory; a run into a directory holding a manifest resumes it
--count N required number of networks
--families tree mesh tumour families to grow, in id order (aligned, aligned_tight, aligned_bed, capillary_bed, foam and fibre when listed); only presets that exist are accepted, so a family without one and unknown names are refused before anything is grown
--family-shares 1 1 1 relative share of each family, by largest remainder with ties to the family listed first (2000 at 1 1 1 gives 667 667 666)
--ratio-range R_LO R_HI 2.52 25 range of R, log-uniform and stratified; 2^(4/3) gives at least about four generations
--seed S required library seed
--workers W 1 worker processes
--collision-margin 1.0 clearance between vessel surfaces, units of d_min
--mesh-box-c 15 a mesh grows in a cube of side this many times its root diameter, which keeps its two trees within reach of each other
--box-c FAMILY C mesh 15, aligned 15, aligned_tight 20, aligned_bed 20, capillary_bed 15, foam 15, fibre 15 the cube side of a box family (mesh and every aligned or bed family) in root diameters (repeatable); refused for a family that grows free, and with --mesh-box-c for mesh; an aligned member whose root offsets leave a small cube is recorded as a failure
--iteration-cap 64 generations allowed; d_min stops growth first
--bed-max-candidates N 2e6 refuse, and record as a failure, a member with an explicit bed whose planned candidate edges exceed N, before anything is drawn; a whole number of at least 1, given as integer or float text; it bounds the candidates only, not the walk
--family-settings FAMILY JSON the presets grow FAMILY with some of its preset values changed: a JSON object of library.FAMILY_SETTING_KEYS, a bed merged over the preset's bed and every other value replacing the preset's (repeatable, once per family listed); see Family settings below
--avoid-collisions / --no-avoid-collisions on collision avoidance for the tree family; the mesh and tumour presets avoid collisions already

Ids run 0 .. N-1 in contiguous family blocks in --families order and the archives are named net_{id:05d}_{family}.npz. Network id grows from the seed int(np.random.SeedSequence([S, id]).generate_state(1)[0]). For family f at index j in --families with n_f networks, default_rng([S, 4, j]) draws u = rng.random(n_f) and perm = rng.permutation(n_f), and the k-th network of f gets R = R_LO (R_HI / R_LO) ** ((perm[k] + u[k]) / n_f): one network per bin of equal width in log R, in a random order. The bins depend on n_f, so a library of a different size or share is a new library, not an extension of an old one. The law (log-uniform), range, bin and u are recorded per network.

Each network is grown exactly as vsystem would grow it from

--family f --d0 R 0 --d0-min R --d-min 1.0 --iterations 64 64 --collision-margin 1.0
--volume 3 3 3 --fit voxel_size --voxel-size (c R / 3 for a box family, c from library.BOX_C, 1.0 otherwise) [--avoid-collisions]

with the global generators seeded from the network's seed, through main.build_parser(f), sample_parameters and grow_network, so a library network's nodes and program equal what vsystem writes for the same arguments and seed on the same machine (the tests check this). The tumour preset's root calibre is replaced by R; its aneurysm and stenosis probabilities reach the properties through the parser's defaults as on the command line. Tree and tumour growth never read the volume; a mesh and the aligned and bed families grow in a cube of side 3 × voxel size = c R, with c from library.BOX_C (15 for mesh, aligned, capillary_bed and foam, 20 for aligned_tight and aligned_bed, whose roots are 40 d_min apart; --mesh-box-c for mesh, --box-c FAMILY C for any box family, not both for mesh). A member whose family grows an explicit bed is refused before anything is drawn, and recorded as a failure, when its planned candidate edges, ⌈N_t × density × (4π / 3) × reach³ / 2⌉ for the N_t seeds its cube plans (seed pairs only, since the tips are not known before the grammar draws), exceed --bed-max-candidates: at the default 2 × 10⁶ and the cube of side 15 R that admits every foam member up to R 31.3, beyond the default range, and a foam member at R 25 plans 62 001 seeds and about 1.0 × 10⁶ candidate edges. The cap bounds the candidates, not the walk, whose cost follows the seeds. A library in another unit is the same library rescaled: growth at (2R, d_min 2, margin 2, box 2 × 15 R) equals twice the library network to float rounding. The default library holds tree, mesh and tumour (library.DEFAULT_FAMILIES); the other families are grown when listed, a box family's constant then recorded under growth.box_c in the manifest (and the cap on a bed's candidates under growth.bed_max_candidates when a family with a bed is listed, so that a library of the default families records its growth as before), and every archive's metadata carries the network's frame and its d_min. The index holds the 3.4 columns, then frame_kind, capillary_order and larger_order (S about the frame axis), capillary_polar_order, capillary_length_share, capillary_volume_share, capillary_segment_median and transverse_spacing_median (in d_min), empty where a network has no frame axis or no polar sense, then from 3.6 rungs, the number of cross-connections; archive metadata lists the rungs themselves. 3.7 adds no column: a foam network's bridges is 0, and its bed's counters are in events. 3.8 appends capillary_crossing_density (transverse_spacing's crossings per unit section area, per d_min²), capillary_crossing_ratio and cross_connections_per_length (per d_min), each empty where the frame has no axis; the library describes every member over its growth box, so that section areas are the box's (area_source growth_box_um), which no earlier column reads.

Every archive is written atomically (save_network to a temporary file, then renamed) with metadata recording "units": "d_min", the family, R, bin, u and seed, every keyword passed to grow_network, the counters, the root column of each tree, the smallest per-point diameter (stenoses and spline blending dip to about 0.5–0.67 d_min) and the sha256 of nodes and program. index.json and index.csv hold, per network and in units of d_min, id, family, R, seed, file, generations reached, points, polylines, tips, junctions, cycles, components, total length, length- and volume-weighted diameter percentiles (P50, P90, P99), the smallest and largest diameter, the minimum clearance with the count of pairs below the margin, the bridges, the counters, and in separate columns the time and the peak resident set of the growth. manifest.json has two parts: content (every parameter, the ratio law, the family counts, a code hash over library.py and main's import closure, per-network nodes and program hashes, and failures with their reasons), whose canonical JSON (sort_keys) is hashed into content_sha256, and run (host, Python and numpy versions, per-network time and peak memory, dates). Archive bytes are not compared: zip entries carry timestamps and BLAS rounding differs between machines.

A run into a directory that holds a manifest resumes it: a different parameter set or a different code hash is refused, a different Python or numpy version is recorded with a warning, every archive that loads and matches its plan (seed, family, ratio, arguments and recorded hash) is kept, an unreadable one is grown again, an archive of another network under a planned name is refused, and failures are listed in the manifest and reported, never dropped. Networks grow in fresh processes (multiprocessing with a spawned worker per network and the BLAS thread counts set to one), so the peak memory recorded per network is that network's own; the grammar's global generators and libGenerator's module globals rule out threads.

library.library_weights(index, exponent=3.0, mask=None) gives the weight to draw each network with so that the drawn root ratios follow a power law, p_target(R) ∝ R^(-exponent) per unit log R, instead of the library's law: w_i ∝ p_target(R_i) / p_library(R_i), with p_library read from the recorded law, constant per unit log R for log-uniform, so w_i ∝ R_i^(-exponent). The weights are normalised to sum one over mask and are zero elsewhere. Writing x = ln R, uniform on [a, b] = [ln R_LO, ln R_HI], the effective sample size (Σw)² / Σw² of N draws is N (E[e^(-3x)])² / E[e^(-6x)] = N (e^(-3a) - e^(-3b))² / (2 (b - a) (e^(-6a) - e^(-6b))) ≈ 0.29 N at the defaults, so a library of 2000 networks is worth about 580 equally weighted draws from the R^(-3) law.

Measured cost (one process per network, this machine, seed 777, growth and the descriptors timed separately; peak RSS is the worker's resident set, which includes numpy's own 70 MB):

family R generations points tips growth descriptors archive peak RSS clearance violations
tree 2.52 7 1 364 8 0.0 s 0.2 s 0.04 MB 75 MB 0
tree (no avoidance) 2.52 7 1 449 8 0.0 s 0.2 s 0.04 MB 73 MB 1
tumour 2.52 7 431 3 0.1 s 0.2 s 0.02 MB 70 MB 0
mesh 2.52 8 2 166 9 1.3 s 0.2 s 0.06 MB 77 MB 0
tree 4 12 5 678 36 0.2 s 0.4 s 0.17 MB 95 MB 0
tree (no avoidance) 4 12 6 420 37 0.1 s 0.3 s 0.20 MB 92 MB 11
tumour 4 12 1 078 6 0.7 s 0.2 s 0.04 MB 72 MB 0
mesh 4 12 3 511 19 2.7 s 0.3 s 0.10 MB 82 MB 0
tree 6.3 17 24 280 131 0.7 s 0.9 s 0.74 MB 127 MB 0
tree (no avoidance) 6.3 17 26 302 137 0.2 s 0.6 s 0.79 MB 123 MB 13
tumour 6.3 17 1 950 6 1.2 s 0.2 s 0.11 MB 75 MB 0
mesh 6.3 17 17 272 72 14.0 s 0.6 s 0.48 MB 104 MB 0
tree 10 20 91 527 519 3.1 s 2.9 s 2.76 MB 165 MB 0
tree (no avoidance) 10 20 104 846 557 0.8 s 2.0 s 3.13 MB 153 MB 1 417
tumour 10 20 8 530 30 7.7 s 0.4 s 0.47 MB 101 MB 0
mesh 10 20 47 565 179 31.2 s 1.4 s 1.39 MB 124 MB 0
tree 16 23 383 934 2 116 22.5 s 12.2 s 11.50 MB 314 MB 0
tree (no avoidance) 16 23 432 769 2 257 3.6 s 8.2 s 12.85 MB 320 MB 20 337
tumour 16 23 83 294 264 75.4 s 2.6 s 3.12 MB 285 MB 0
mesh 16 24 72 169 283 67.3 s 2.0 s 2.65 MB 159 MB 0
tree 25 28 547 328 3 110 62.6 s 18.2 s 18.40 MB 442 MB 0
tree (no avoidance) 25 28 1 651 750 8 715 15.5 s 36.8 s 48.42 MB 1 039 MB 186 777
tumour 25 28 30 133 114 43.1 s 1.1 s 4.75 MB 280 MB 0
mesh 25 28 391 572 1 331 444.9 s 11.3 s 12.85 MB 449 MB 0

Points grow as about 105 R³ for a plain tree (1.65 million at R = 25); with avoidance a tree keeps a third of that at R = 25, since branches that would cross are terminated, and the tumour preset, which always avoids collisions and bends at low persistence, stays an order of magnitude smaller again (and varies a lot between seeds: one seed gives 83 thousand points at R = 16 and 30 thousand at R = 25). A mesh costs the most: its two trees are grown with the walk and avoidance and then anastomosed, 7.4 minutes at R = 25 for 392 thousand points, within 0.45 GB. The cost of --avoid-collisions for trees is the collision index: the growth takes 4 to 6 times longer from R = 10 up (3.1 s against 0.8 s at R = 10, 62.6 s against 15.5 s at R = 25), and it buys a network with no pair of vessels inside each other, where a plain tree has 1 417 overlapping point pairs at R = 10 and 186 777 at R = 25 (margin 1). The tumour and mesh networks show no clearance violation at any ratio measured, and neither does the avoided tree.

Projection for N = 2000 at the default shares and range, taking the log-uniform mean of growth plus descriptors interpolated in (log R, log t) between the measured points: about 15 s per tree, 56 s per mesh and 19 s per tumour on this machine, 16.7 CPU hours in all, about 2.1 h of wall time at 8 workers, 5.0 GB of archives, and at most 8 × 0.45 GB = 3.6 GB of memory with every worker at the largest peak measured.

Family settings: --family-settings

vsystem-library --out lib --count 600 --seed 1 --families foam capillary_bed \
    --family-settings foam '{"bed": {"spacing": 5}}' \
    --family-settings capillary_bed '{"capillary_generations": 4}'

--family-settings FAMILY JSON (repeatable, once per family listed in --families, or among the default families) grows FAMILY with some of its preset values changed, as vsystem --family FAMILY does with options given explicitly, while the library stays reproducible. The JSON object takes the keys of library.FAMILY_SETTING_KEYS (append-only), the parser's own names: persistence, anastomosis_fraction, anastomosis_radius, anastomosis_min_separation, aneurysm_prob, stenosis_prob, root_offsets, guidance, capillary_generations, capillary_runs, cross_connect, rung_below, rung_spacing, rung_radius, rung_lateral_deg, rung_axis, rung_min_separation and bed.

  • bed is an object merged over the preset's bed and normalised by bed.parse_settings, so that {"bed": {"spacing": 5}} is the preset's bed at spacing 5 and every key of bed.KIND_KEYS[kind] is recorded (13 for foam, 21 for fibre). It is refused for a family whose preset grows no bed, as null, and with a kind or layout other than the preset's.
  • Every other value replaces the preset's: an array (root_offsets, guidance, rung_axis) as a whole, a number as a float, a whole number for anastomosis_min_separation, capillary_generations, capillary_runs and rung_min_separation, and cross_connect as true or false. A bool is refused where a number is expected, and null where an array is.

library.normalise_family_settings(family, spec) applies these checks and returns the normalised dict, its keys in FAMILY_SETTING_KEYS order; normalising twice changes nothing. It raises ValueError, which the command line turns into an exit naming the family, as it does for a family not listed, a family given twice, and JSON that is malformed, not an object or empty. The other parser options are refused, by class:

  • What the library fixes for every member: d0, d0_min, d_min, iterations, volume, fit, voxel_size, grow_in_volume, collision_margin, avoid_collisions, seed, count and out. R and the seed come from the plan; d_min = 1, the iteration cap, the margin and the trees' collision avoidance from the library's options, recorded in the manifest's growth (the other presets avoid collisions already); and the volume, fit, voxel size and growth in the volume make the box layout, a box family growing in its cube of side c R and tree and tumour growing free. A value for one family would contradict what the manifest records or change the box or the unit (d_min and the margin) a member is grown and measured in.
  • What makes a family what it is: family, tortuosity, anastomose and anastomose_mode, and in the bed a different kind, a fibre bed's feeder layout (sheets to pair), or turning the bed on or off. A family with a different value is another family, which takes a preset of its own: released presets are frozen, and a changed family takes a new name.
  • The grammar's own ranges and the numerical and output settings: epsilon, randmarg, sigma, k, roll_angle, stem_angle, deterministic, subdivisions, collision_attempts, collision_index, clip_axes, units and connect. No preset sets them: every family draws its grammar from the same ranges and is computed at the same resolution, which keeps the members of different families comparable.
  • A negative number in exponent notation, such as -1e-05 (reachable only for the two probabilities, which the command line does not bound): argparse reads it as an option, so the member's command line would not run.

Each member of the family carries its settings as flags after the others on its command line, in FAMILY_SETTING_KEYS order and named as main.build_parser(family) names them: a JSON value compact with sorted keys (--bed '{"density":0.5,...}'), a float as its repr, a whole number as its digits, and --cross-connect or --no-cross-connect. The recorded argv therefore stays a runnable vsystem command line that grows the same network (the tests check this).

Before anything is grown, the command line of each family with settings, at R_LO and at R_HI, is parsed by main.build_parser(family) and checked by main.validate_shaping, so that whatever the command line refuses, such as a bed with a capillary fill, a bed value out of bounds or root offsets outside the cube, refuses the library and names the family. (A family without settings is not checked up front: a member the command line refuses at some ratios only, such as an aligned member whose root offsets leave a small cube, is recorded as a failure.) For a family with settings and a bed, one line is printed before growth: the candidate edges its bed plans at R_HI against --bed-max-candidates and the largest R the cap admits (library.largest_admitted_ratio); a larger member is recorded as a failure under the cap's unchanged rule. Where the bed's feeder_stop exceeds R_LO, a second line gives the smallest R it admits, R = feeder_stop; a smaller member is refused by grow_network and by vsystem alike, and recorded as a failure.

The manifest records the normalised settings under growth.family_settings ({family: settings}, absent when no family has settings, so that a library without them records its growth as before), so a rerun with different settings, or none, is refused as any other change of growth is. Each archive of the family records them in its metadata under family_settings, beside the argv and grow_kwargs it already records, and a resumed archive is checked against its command line with the settings. No index column is added. A family grown with settings keeps its name (net_00000_foam.npz, "family": "foam"): growth.family_settings in the manifest and family_settings in an archive's metadata are where a consumer tells a variant library from a default one. Settings for one family leave the plan, the seeds, the ratios and every other family's members bitwise as they are without them. In Python, library.member_argv(..., settings=...) and library.grow_member(..., settings=...) take one family's settings, keyword-only; None gives the member without them.

Measured with tests/test_library_settings.SettingsBenchmark (each member grown by grow_member with its settings in a fresh process of its own, one at a time, seed 1, on a 4-core 2.1 GHz Xeon; growth is timed alone and the peak RSS is the process's VmHWM; the length density is the total edge length over the growth box's volume; the tissue distance is describe(nodes, edges, metadata={"growth_box_um": box}, d_ref=1, evd_spacing=side / 64)'s, side the box's edge; the capillary length share is that call's calibre_shares["length"]["below_2"]):

family R setting growth peak RSS points planned candidates length density (d_min⁻²) tissue mean / p90 / max (d_min) capillary length share
foam 4 spacing 7.5, density 0.5 1.2 s 56 MB 6 766 3 977 0.0142 3.45 / 5.90 / 17.27 0.935
foam 4 spacing 7.5, density 0.6 1.5 s 56 MB 8 253 5 734 0.0174 3.06 / 5.23 / 12.75 0.947
foam 4 spacing 6, density 0.5 2.0 s 57 MB 10 836 7 773 0.0227 2.58 / 4.41 / 11.22 0.959
foam 4 spacing 6, density 0.6 3.1 s 58 MB 12 732 11 192 0.0268 2.32 / 3.94 / 10.03 0.965
foam 4 spacing 5, density 0.5 4.2 s 62 MB 15 667 13 434 0.0325 2.07 / 3.55 / 11.72 0.971
foam 4 spacing 5, density 0.6 4.6 s 65 MB 18 434 19 341 0.0382 1.85 / 3.16 / 8.72 0.976
foam 4 spacing 4.5, density 0.5 5.1 s 66 MB 19 533 18 425 0.0402 1.81 / 3.11 / 11.68 0.977
foam 4 spacing 4.5, density 0.6 5.4 s 71 MB 22 265 26 547 0.0457 1.65 / 2.83 / 8.28 0.980
foam 8 spacing 7.5, density 0.5 13.1 s 85 MB 59 988 32 676 0.0161 3.08 / 5.18 / 16.13 0.934
foam 8 spacing 7.5, density 0.6 16.0 s 92 MB 69 165 47 046 0.0186 2.81 / 4.71 / 13.15 0.943
foam 8 spacing 6, density 0.5 21.2 s 105 MB 92 026 63 814 0.0244 2.40 / 4.06 / 10.49 0.956
foam 8 spacing 6, density 0.6 24.3 s 114 MB 106 310 91 892 0.0281 2.18 / 3.68 / 10.06 0.962
foam 8 spacing 5, density 0.5 31.9 s 126 MB 131 909 110 283 0.0344 1.93 / 3.29 / 8.42 0.969
foam 8 spacing 5, density 0.6 41.0 s 142 MB 153 748 158 808 0.0400 1.75 / 2.97 / 8.53 0.973
foam 8 spacing 4.5, density 0.5 40.9 s 146 MB 163 392 151 288 0.0422 1.70 / 2.91 / 7.13 0.975
foam 8 spacing 4.5, density 0.6 54.9 s 162 MB 189 694 217 850 0.0488 1.54 / 2.63 / 7.79 0.978
foam 16 spacing 7.5, density 0.5 104.9 s 300 MB 476 655 264 745 0.0162 3.01 / 5.03 / 12.48 0.966
foam 16 spacing 7.5, density 0.6 134.1 s 353 MB 559 496 381 233 0.0190 2.73 / 4.54 / 10.54 0.971
foam 16 spacing 6, density 0.5 187.2 s 433 MB 739 713 517 071 0.0247 2.35 / 3.93 / 9.32 0.978
foam 16 spacing 6, density 0.6 225.5 s 498 MB 868 855 744 578 0.0289 2.12 / 3.56 / 8.16 0.981
foam 16 spacing 5, density 0.5 271.2 s 591 MB 1 065 008 893 505 0.0349 1.90 / 3.21 / 8.91 0.984
foam 16 spacing 5, density 0.6 363.6 s 711 MB 1 248 175 1 286 659 0.0407 1.71 / 2.90 / 6.76 0.987
foam 16 spacing 4.5, density 0.5 362.3 s 761 MB 1 321 917 1 225 663 0.0427 1.67 / 2.84 / 6.62 0.987
foam 16 spacing 4.5, density 0.6 483.0 s 859 MB 1 544 327 1 764 967 0.0497 1.51 / 2.57 / 7.20 0.989
capillary_bed 4 generations 3 60.6 s 95 MB 36 290 – 0.0198 4.07 / 8.42 / 25.21 0.946
capillary_bed 4 generations 4 52.4 s 90 MB 30 712 – 0.0166 4.79 / 9.91 / 27.80 0.954
capillary_bed 8 generations 3 618.8 s 292 MB 191 771 – 0.0134 6.60 / 15.45 / 54.26 0.927
capillary_bed 8 generations 4 882.5 s 335 MB 234 272 – 0.0160 5.40 / 12.37 / 43.47 0.943

At density 0.5, spacing 6, 5 and 4.5 multiply foam's length density by 1.60, 2.29 and 2.83 at R 4, 1.51, 2.14 and 2.62 at R 8, and 1.52, 2.15 and 2.64 at R 16; density 0.6 adds 14–23 % at any spacing. The mean tissue distance falls from 3.45, 3.08 and 3.01 d_min with the preset's bed to 1.65, 1.54 and 1.51 d_min at spacing 4.5 and density 0.6 (R 4, 8 and 16). Growth takes 1.2–4.6 times the preset's time, up to 483 s and 859 MB at R 16, spacing 4.5 and density 0.6, whose bed plans 1 764 967 candidate edges. One more capillary generation lowers capillary_bed's length density by 16 % at R 4 and raises it by 20 % at R 8 for this seed, so it does not raise length consistently; at R 8 it is the slowest member measured, 882 s. The largest R the default cap of 2 × 10⁶ candidate edges admits, rounded down as the printed line gives it:

foam bed density 0.5 density 0.6
spacing 7.5 (the preset's) 31.33 27.75
spacing 6 25.07 22.21
spacing 5 20.90 18.52
spacing 4.5 18.82 16.67

Grammar

The alphabet is the dissertation's. f(l, d) moves by l along the direction vector and records diameter d; +(θ) and -(θ) rotate the direction about the perpendicular vector; /(β) and *(β) rotate the perpendicular about the direction; [ and ] push and pop the whole state; { and } delimit a stem that is interpolated as one smooth curve. A [ inside a stem pins the stem at the branch point — the part before it and the part after it are interpolated as separate curves that meet exactly there — so a branch may leave a stem midway without disconnecting the centreline. An operand left out takes its default: the segment length for the current diameter, the Zamir angle θ1, or the roll angle.

Rule Production Source
F(n, d0) {S(d0)} [+(th1) /(roll) F(n-1, d1)] [-(th2) /(roll) F(n-1, d2)] §4.3.1 tree grammar
S(d0) D +(a) D -(a) D -(a) D +(a) D or its mirror, each with probability ½ §4.3.1
D(d0) f(co/5, d0); with probability aneurysm_prob or stenosis_prob, f(co/25, d0) f(3co/25, d0·factor) f(co/25, d0) §3.3.1 and grammars (i), (j)
A(n, d0) {S(d0)} [+(th1) A(n-1, d1)] [-(th2) A(n-1, d2)] example (b)
B(n, d0) C C C /(90) A(n-1, d0) example (b)
R(n, d0) f(co/3) C C C [B(n-1, d1)] f(co/2, d2) B(n-1, d2) example (b)
I(n, d0) f(co/3, d0) +(a) [R(n-1, d0)] example (b)

th1, th2, d1, d2 and co come from libGenerator.calBifurcation: d1 is drawn from a Gaussian about the symmetric optimum d0 / 2^(1/k), d2 follows from Murray's law d0^k = d1^k + d2^k, the angles from Zamir's rule in the asymmetry ratio d2 / d1, and co = epsilon · d0 scaled by a uniform factor in [1 - randmarg, 1 + randmarg].

Departures from the source, all deliberate: stems are always drawn, so an iteration count is a count of drawn generations; the length margin is relative rather than absolute, so lengths stay positive at every depth; and anomalies are drawn per sub-segment with configurable probabilities rather than written into a grammar by hand.


Tests

python -m unittest discover -s tests -t .

The suite covers the turtle semantics, grammar balance, the bifurcation law, the capsule volume at three orientations, angle preservation under the isotropic fit, the d_min stopping criterion, seed determinism and the command-line entry point. It also pins the centreline contract: a saved centreline re-renders the volume it was generated with, calibre in voxels scales as 1 / voxel_size, and a sidecar plus its .npz reproduce the written TIFF exactly. Connectivity is covered too: an unclipped network renders as one component under both 6- and 26-connectivity, the chain drawn for a segment is face-connected at every orientation and never strays more than sqrt(3)/2 from it, bare capsules do break, and connecting changes nothing once a vessel fills a voxel. tests/test_geometry.py covers the geometry stages: the default command line draws bit for bit what the previous generator (kept under tests/fixtures/reference_code) draws in the same environment, centreline and TIFF alike, and matches the stored reference centrelines to rounding (the B-spline's matrix products go through BLAS kernels whose last bits differ between machines), every new option is reproducible from its seed, edges round-trip and the degree-derived tip count matches the grammar's, avoided networks have no pair of branches closer than the margin while plain trees do, anastomosed networks have cycles and a lower tip density than the same seed without, arc/chord rises as persistence falls, a cycle-containing archive renders through process_network(..., connect=True), and computeVoxel imports under Python 3.9 when an interpreter is available. tests/test_pinned_geometry.py pins the mesh and tumour geometry: the 3.2 modules (tests/fixtures/reference_code_3_2) and the current ones draw the same nodes, tree and bridges for two seeds per family in the same environment. tests/test_join.py checks that two facing networks join into one component with no intra-network bridge, that bridges keep the margin from every vessel and from each other, that cut ends are never used and no bridge leaves the box, that the tip outcomes partition the degree-one vertices and agree with the counters, determinism, invariance under a change of unit, more than 127 networks, a mesh's two roots, a forest losing components and free tips, that any prefix of the bridges is a valid result, and, with VSYSTEM_SLOW_TESTS=1, the two performance targets. tests/test_pinned_join.py pins the joining of 3.3: the 3.3 join.py (tests/fixtures/reference_code_3_3) and the current one draw the same bridges, tips table, counters, summary and generator state for two small forests in the same environment, and with root attachment off nothing added since is live. tests/test_join_roots.py checks root attachment: a root near a thicker vessel attaches as a side branch at the root's own diameter and makes a junction, a root never attaches to a thinner vessel, root bridges depart upstream, the cone is taken about the upstream direction and the search radius limits it, interior points rank before tips and an attached tip is consumed, a root in a junction zone or without a tangent still attaches, cut-end roots never attach, root partners lie on other networks whatever the policy, the outcomes partition the degree-one vertices (a mesh pair's four roots included) and agree with the counters, determinism and invariance under a change of unit with root bridges present, the shared budget with roots first, the prefix property, that in-box roots that are ineligible change only their own rows and that the option draws nothing without an eligible root, and, with VSYSTEM_SLOW_TESTS=1, the typical forest with root attachment off and on. tests/test_library.py checks the library's determinism, that the presets take effect, that no drawn diameter falls below d_min but a stenosis middle, the scaling with the unit, equality with the command line's output, independence from the volume, the stratification, resume with the refusal of a different library or code, the stability of the manifest hash, the weights against a power law, the refusal of a family without a preset and of unknown families, a tiny end-to-end run, and from 3.5 the aligned family's box, frame and index columns, the box constants and their refusals, and a tiny aligned library described and resumed with every new column. tests/test_pinned_release_3_4.py pins what 3.4 drew, wrote, measured and joined: the 3.4 modules (tests/fixtures/reference_code_3_4) and the current ones run one driver side by side in the same environment over growth, library members and plans, description, the command line, cropping and joining, and a three-network library, every record restricted to the keys 3.4 wrote, so that a later version may append keys but must keep what 3.4 wrote, with every appended counter at zero and the frame of kind none while the new options are off; it takes about 30 s on a laptop (Apple M4) and 95 s on an Intel Xeon Gold 5220 workstation. tests/test_guidance.py checks the guidance rules and their refusals, the turn and the bank of one step, the draw count, that the walk without guidance is what it was, the stationary laws on a long stem, the counters, the choice of rule, the polar senses, the free extent, root offsets, scale invariance, the frame record in the return, sidecar and archive, and the aligned family end to end, and, with VSYSTEM_SLOW_TESTS=1, the capillary order, polar order and feeder planarity of aligned against the same seeds unguided, the bank's gain in planarity and the onset's effect on collision redraws over ten seeds, and the fall of the order with the guidance length over four seeds. tests/test_frames.py checks the rotations, align and the transforms, and that a random transform leaves the order about the carried axis unchanged. tests/test_describe.py checks the frame descriptors on exact constructions (S of 1 and −0.5, the Watson and Fisher-axial inversions on 10⁴ samples, exact calibre shares and transverse spacing of a square array, each d_ref source) and their unit invariance. From 3.6, tests/test_vsystem.py checks the capillary fill against the 3.5 grammar, a literal statement of its rule and the closed form of a symmetric Murray tree's calibre shares; tests/test_connections.py checks the rungs on two parallel lines (their angles and lengths, gaps that pass a Kolmogorov-Smirnov test against an exponential law, one cycle per rung after the first, no clearance violation) and on grown trees; tests/test_fill.py checks the options and their refusals, that the rungs leave everything drawn before them unchanged, and the new families; and tests/test_describe.py checks the topology keys on ladders, square, hexagonal and cubic lattices, trees, a symmetric Y and a straight cylinder, against independent brute-force searches. tests/test_pinned_release_3_5.py pins 3.5 as the 3.4 pin pins 3.4. tests/test_pinned_release_3_6.py pins 3.6, as released in 3.6.1 (tests/fixtures/reference_code_3_6), the same way: it keeps one case of each kind of growth from the 3.5 pin and adds the cases 3.6 opened (the fill, the rungs, the three families 3.6 added, and the tissue distance from each of its sources), and it checks that with the bed off every record holds bed as None, no sidecar records the bed's stream, no bed counter counts and no stage creates a generator; it took about 90 s in one run on a 4-core 2.8 GHz Xeon, and its hashes are recorded on Linux x86_64 with the two sets of kernels of the 3.5 pin. From 3.7, tests/test_bed.py checks the bed's settings and every refusal before a bed generator exists (from bed.parse_settings, grow_network and the command line), the seeds against a loop over the proposals one at a time, the rules re-evaluated from the record (degree, angle, girth, no dead end, no island) on beds alone, on foam and on constructed lassos and tips with two edges, bitwise ends and interior seeds, no clearance violation and agreement with describe.clearance, the chord fallback, the laws of the counters, the independence of the sub-streams, that nothing of the bed is created or counted without one, that grow_network's body after the bed's return is the released source, scale, the frame record and the order about the axis rising with the stretch, the cap on planned candidates, and foam end to end on the command line, in check_connectivity.py and as a library member; tests/test_join.py checks that tag 7 is the bed's alone and recorded only by a bed run, and tests/test_library.py foam's box, cap, index row and resume. tests/test_topology_benchmark.py, with VSYSTEM_SLOW_TESTS=1, grows every family at three root ratios (the filled families and foam in their own invocations, through VSYSTEM_BENCHMARK_FAMILIES and VSYSTEM_BENCHMARK_RATIOS) and the aligned preset over its guidance length and onset, prints the descriptors and the cost, and projects a 2000-network library. Foam's invocation,

VSYSTEM_SLOW_TESTS=1 VSYSTEM_BENCHMARK_FAMILIES=foam python -m unittest tests.test_topology_benchmark

also reports each member's bed (its counters and their laws, the walk's outcomes, the attachment of the feeders' tips by tree and calibre, describe's loops in segments, the descriptors of the bed alone) and runs BedCostBenchmark: a bed grown alone with the foam settings at 10³ to 10⁵ seeds, each in a fresh process, timed per stage with its peak memory, and its girth and density swept at 10⁴ seeds, where the bed's cycles in edges are reported beside describe's loops in segments. That no cycle of a foam network's bed is shorter than the girth is checked by tests/test_bed.py.


From 3.8, tests/test_pinned_release_3_7.py runs the 3.7.0 modules (under tests/fixtures/reference_code_3_7) beside the current ones in the same way, on growth, the bed, description, the command line and libraries; tests/test_describe_cross.py checks the cross-connections and the cross section on constructed ladders, forks, lattices and archives; tests/test_fibre.py checks the fibre bed's settings and refusals, the planned counts, the frame record, the lanes against a one-at-a-time loop, every sub-stream recreated, scale, the density and chord laws, the link sites and partners (each link against the greedy pass itself), the rules, the written columns and the counters' laws; and tests/test_fibre_family.py checks the sheets layout, its refusals, the fibre preset end to end and its library members. tests/test_fibre_benchmark.py, with VSYSTEM_SLOW_TESTS=1, measures beds alone over their settings, beds between a mesh member's and a packed forest's feeders, the family's members and a dense bed's cost.

From 3.9, tests/test_pinned_release_3_8.py runs the 3.8.0 modules (under tests/fixtures/reference_code_3_8) beside the current ones on the library: member_argv for every family under each library option, the manifest's growth settings, a member of every family 3.8 offers, a plan of every family, and the default library beside one of foam, capillary_bed and aligned_bed, each grown and then resumed with its index removed; the global generators start from a fixed state, so that a case which seeds nothing is held to draw nothing. tests/test_library_settings.py checks the family settings: their normalisation and every refusal, the flags and their parse, members equal to vsystem's output on their recorded command lines, the lines printed and the cap they report, the records in the manifest and the archives, resuming, and the other families' members, bitwise as they are without settings; with VSYSTEM_SLOW_TESTS=1 it measures foam and capillary_bed members over their settings.

Applications

  • Generating realistic 3D vascular structures for simulation.
  • Training datasets for segmentation algorithms (e.g. VAN-GAN).
  • Creating synthetic benchmarks for vascular imaging pipelines.
  • Testing robustness of deep learning models under anatomical variability.

Citation

Please cite the following if you use V-System in your research:

Version 1.0

Quantification of vascular networks in photoacoustic mesoscopy Emma L. Brown, Thierry L. Lefebvre, Paul W. Sweeney et al.

Version 2.0

Unsupervised Segmentation of 3D Microvascular Photoacoustic Images Using Deep Generative Learning Paul W. Sweeney et al., Advanced Science, 2024

Version 3.0 changes the interpreter, the grammars and the voxeliser as described above; volumes generated with it are not identical to those of earlier versions. Version 3.1 adds the centreline archive, the declared unit convention and the d_min stopping criterion. It also renders sub-voxel vessels as face-connected one-voxel paths rather than dotted lines, so its volumes contain vessels that 3.0 dropped and hold together under a six-connected label; --no-connect reproduces the 3.0 rasterisation. Version 3.2 adds the graph to the archive, the walk, collision avoidance, anastomosis, the family presets and describe.py; the default command line still writes the 3.1 geometry. Version 3.3 adds join.py, which joins separately grown networks inside a field of view, and vsystem-library, which grows reproducible network libraries in relative units; the default command line and the family presets draw what 3.2 drew. Version 3.4 adds root attachment to join_networks, an option off by default; with it off the joining draws what 3.3 drew.


Acknowledgements

This project builds upon foundational work by Miguel A. Galarreta-Valverde: Geração de redes vasculares sintéticas tridimensionais utilizando sistemas de Lindenmayer estocásticos e parametrizados, MSc dissertation, University of São Paulo, 2012 (DOI 10.11606/D.45.2012.tde-30112012-172822), and Galarreta-Valverde, Macedo, Mekkaoui and Jackowski, Three-dimensional synthetic blood vessel generation using stochastic L-systems, Proc. SPIE 8669 (2013).


Contact

For bugs, ideas, or contributions, open an issue on the GitHub repository.

About

A project to create synthetic vascular networks utilising L-Systems.

Topics

Resources

Stars

25 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages