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.
- Grammar generation (
vSystem.py): the tree ruleFis expanded for a chosen number of drawn generations into a string of turtle instructions. - 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. - Interpolation (
utils.py): each stem is smoothed with a cubic B-spline. - 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.
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:
--d0and--d-minare vessel diameters in micrometres;--voxel-sizeis 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.
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_volumeGenerate five networks into ./output, reproducibly:
python main.py --count 5 --out ./output --seed 1Each 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; 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 |
--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 |
--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.
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/*.tiffTwo 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-volumeremoves 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 a512 × 512 × 140slab 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-axesis 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 noneis 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-axesand the volume shape control this. With--clip-axes noneeach 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-minto 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.
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 the vessel calibre distribution of each result is that modality's — which is exactly what an unpaired image-to-image model learns as its output prior. 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 fractionFor 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.
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.
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.
- 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.
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.
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).
For bugs, ideas, or contributions, open an issue on the GitHub repository.
