Code for tracing blood vessels in fluorescence retina images and then measuring the shape of the network they form. Two pieces: a segmentation method built for the vessels a standard filter tends to drop (the faint ones, and the ones sitting a little out of focus), and a morphometry panel that turns the binary mask into network shape numbers.
Individual passes build vessel masks to limit background but also capture dim/slightly out of focus fields to recover an increased true vessel network.
Three passes
Pass 1, the bright tree. A white tophat with a disk wider than the widest vessel strips the smooth bright haze, so the faint mesh sits on a flat background instead of drowning in it. Then a multiscale Frangi map with a locally adaptive threshold for the fine mesh, plus a plain threshold on the flattened image to fill the thick vessels the ridge filter leaves hollow down the middle.
Pass 2, the faint mesh. A mild contrast lift before the ridge filter. Tubeness taken as the max of a Frangi and a Sato response, because Sato picks up faint tubes Frangi under-responds to. Then a permissive threshold gives a faint mask, keeping only the faint pixels whose connected component touches the confident network from pass 1.
Pass 3, the out-of-focus vessels. An out-of-focus vessel loses ridge contrast (the exact cue every vesselness filter keys on) even though its fluorescence is plainly still there. So this pass sharpens first to partly undo the blur, then widens the scale range, since out-of-focus vessels read wider and softer than the small scales respond to. The faint step here stays a shape cue, not a brightness one: in a single flat image an out-of-focus vessel and a patch of bright haze sit at the same brightness, so a brightness floor would just flood the gaps between capillaries. A tube is locally long with two edges; haze is a formless blob. The shape cue keeps one and rejects the other.
For a large stitched field with dark background around the tissue, segment --tissue runs
a masked version of pass 3 — it finds the tissue first, then takes every threshold and
brightness statistic inside the tissue only, so the dark surround never pulls the trace
around.
morphometry.py and morphometry2d.py take a binary vessel mask, and an optional tissue
mask, and return a fixed panel of network descriptors: density and caliber, branch and
endpoint counts, segment length and length in vessel-widths, tortuosity, the size and
count of the enclosed avascular loops, bifurcation angle and its spread, lacunarity, and a
couple of tissue-at-risk distances for how far tissue sits from the nearest vessel. Some
are in pixels and only compare within one imaging setup; the dimensionless ratios carry
across magnification. The metric code is pure geometry and knows nothing about what the
image is of.
Python 3.9+.
pip install -r requirements.txt
python run.py demo
The demo makes a synthetic field, runs all three passes, and writes a comparison figure,
an overlay, and the metrics table into an output folder. synthetic.py is there so the
whole thing runs before you point it at your own data — it draws a branching tree plus a
mesh, and it isn't meant to resemble a real retina.
The steps also run on their own:
python run.py generate --out output/synthetic.png
python run.py segment --input output/synthetic.png --version v3
python run.py measure --mask output/synthetic_v3_mask.png --out output/metrics.csv
Point segment at your own grayscale image to trace it. Add --tissue for a stitched
image with a dark surround.
python run.py segment --input your_image.tif --version v3 --tissue
segmentation.py: the three passes and the tissue-masked variantmorphometry.py,morphometry2d.py: the metric panelssynthetic.py: the demo image generatorrun.py: the CLI
The flatten radius, the scale ranges, and the local-threshold block size are set by eye for the images I work with, and they're the first knobs I reach for on a new image set. Calibrate the trace against a hand-traced region before reading any of the metrics quantitatively, so you know it matches what you'd draw by hand.