medial op• Data kinds: voxel → table
• Call: import fullseye as fs; fs.ledger.skeleton_graph3d(vol, distance=None, spacing=(1.0, 1.0, 1.0), min_branch_len=0.0) (to call the implementation directly, import medial; medial.skeleton_graph3d(vol, distance=None, spacing=(1.0, 1.0, 1.0), min_branch_len=0.0); from the registry, ops3d.get("skeleton_graph3d"))
Assembles a 3D skeleton into a graph of nodes (junctions and endpoints) and branches (length and radius).
skeleton_junctions3d / skeleton_endpoints3d / skeleton_branches3d only return "which voxels are nodes / branches" as a mask; they do not return which branch connects which node to which node. Turning it into a circuit needs that connectivity — in the cable theory of neuron morphology, the axial conductance of a compartment is determined by diameter and length, and the way the compartments connect is the circuit itself. This op fills in that 1 step.
Args:
vol: a 3-D skeleton (bool or {0, 1}; the output of skeletonize_vol). If the content
is a blob (has an interior voxel whose 6-neighbourhood is all foreground), then, like the other ops of the family,
skeletonize_vol is applied internally first.
distance: optional. A distance-transform volume of the same shape (output of
vol_distance_transform). If given, each node and each branch gets a radius. The unit follows the distance field you passed
— if physical units are needed, pass vol_distance_transform(mask, spacing).
spacing: (sz, sy, sx). Branch lengths are measured in real distance (anisotropic EM voxels are
the default assumption). Also accepts VolumeMeta.
min_branch_len: prunes terminal branches (spurs) shorter than this (default 0 = no pruning).
The unit is the real distance of spacing. What gets pruned is a branch with "one end an endpoint (degree 1) and the other
end of degree 2 or more" — a branch with both ends endpoints is not pruned. Such a branch
is a whole connected component on its own (1 short isolated tube), so pruning it would make the whole structure
vanish. The number pruned is returned in n_pruned_branches.
Returns: dict (the ledger's declared out type = table).
• nodes: node table. id / z,y,x (centroid in voxel indices; for real coordinates multiply by spacing) / kind / degree / n_voxels / radius (when distance is given, the maximum over the node's voxels = inscribed radius) / component.
• edges: branch table. u / v (pair of node ids) / length (real distance along the skeleton, including spacing) / radius_mean / radius_min / n_points (number of voxels on the path, including the node voxels at both ends) / component.
• n_nodes / n_edges / n_components / n_cycles / n_pruned_branches / n_skeleton_voxels / spacing / has_radius.
Conventions (these decide the topology):
• A voxel of 26-neighbourhood degree 2 is the middle of a branch and does not become a node. Degree 1 or less is an endpoint (including isolated voxels), 3 or more is a junction.
• A junction is not necessarily 1 voxel (in a discrete skeleton a branching becomes a clump of several voxels). Clumps are grouped by 26-connectivity and counted as 1 node, with their centroid as coordinate.
• Multiple connected components are never silently joined. Their count is returned in n_components, and every node and every branch gets a component.
• A component made only of a closed loop (a ring with not even 1 voxel that would become a node) gets exactly 1 seed node at the first voxel of that component and becomes 1 self-loop branch. This way Euler's relation n_cycles = n_edges - n_nodes + n_components holds even for rings (no tree is assumed).
• kind is determined by the degree after pruning: 0 = isolated / 1 = endpoint / 2 = chain (the seed node of a ring, or a node that ended up that way through pruning) / 3 or more = junction. Pruning can drop a junction to degree 2, and that node remains (correct as a compartment boundary, but note that the rule above, "degree 2 does not become a node", applies before pruning).
• When junction clumps are directly adjacent (no degree-2 path between them), then 1 branch is created for that pair (length = the shortest adjacent voxel pair).
Validation (all fail-closed with ValueError): not 3-D / empty array / NaN, Inf / values that are neither bool nor {0,1} (intermediate "probably foreground" values are not silently rounded) / zero foreground / distance of a different shape, negative or non-finite / spacing not 3 positive finite values / negative min_branch_len. For input that needs thinning, if scikit-image is missing, ImportError.
Caveats (honest, all measured):
• Length is the sum of a 26-neighbourhood polyline, so curved branches come out longer than the continuous curve. A closed tube of radius 14 voxels (true circumference 87.96) gives 94.7 = +7.7 %.
• Thick input shrinks at the ends. Thinning of a free end stops short of the end cap. Measured on a straight cylinder of length 35: radius 1 and 2 give 35.00 (zero shrinkage), radius 3 gives 33, radius 4 gives 31 — from radius 3 upward each end moves inward by 1 to 2 voxels. To compare lengths against the ground truth, pass a 1-voxel-wide skeleton that does not go through thinning (then they agree exactly: 30.0 for a straight line of 31 voxels).
• At branchings, a branch loses the part swallowed by the junction clump (degree 3 or more grouped by 26-connectivity). Spurs shorter than the junction do not become branches; they are absorbed into the node's n_voxels and vanish (they are not even candidates for pruning with min_branch_len).
• The radius is the value of distance itself. At an endpoint it is determined by the distance to the end cap, so it comes out smaller than the tube radius (for a cylinder of radius 3: endpoint 2.0, branch mean 3.07). To judge branch thickness, use radius_mean / radius_min.
• 90-degree rotation: with a 1-voxel-wide skeleton as input, the graphs are isomorphic and the lengths agree exactly (9 cases measured). With a thick blob, node and branch counts agree, but lengths differ by up to 1.41 (= 1 diagonal step) — what deviates is not this op but the thinning helper (skimage Lee), which does not produce exactly the same skeleton under rotation.
• Sample-data catalog (download URLs / licences) — 2-D uses skimage.data (BSD/public domain) plus synthetic images; 3-D lists download URLs for real data sources (Stanford, PDS, …).
• Operator provenance and references — the sources of the research/methods this op family came from.
• medial_topology — py -3.11 examples_3d/medial_topology.py
table as input)fuse_to_voxel · mesh_select_lod
medial)distance_ridge · skeletonize_vol · medial_axis_points · topology_signature · medial_match · skeleton_junctions3d · skeleton_endpoints3d · skeleton_prune3d
*Provenance: medial.py — 3D operator registry. This per-op note is generated by tools/opdocs.py md (do not hand-edit).*
© 2026 Kazufumi Furuse — Fullseye operator documentation. Licensed under Apache-2.0.