Skip to content

Downsampling

Functions that change how densely a skeleton is sampled, without changing what it is.

All six work on the skeleton's linear segments — the runs between roots, branch points and leafs — and none of them ever moves or drops the nodes at the ends of those runs. That is what keeps them topology-preserving: the leaf count, the branch count and the shape of the tree come out the other side untouched, and only the sampling along each neurite changes. It is also what makes them parallel, since segments meet only at the nodes nothing here is allowed to touch.

They come in three families:

Node count Node IDs Coordinates
downsample_skeleton, simplify_rdp, simplify_vw falls a subset of the originals unchanged
resample_skeleton either way new ones for the new nodes interpolated
smooth_skeleton, smooth_skeleton_gaussian unchanged unchanged moved (or any other column)

Taking your data with you

A skeleton rarely travels alone. Synapses, soma tags and manual annotations all hang off particular nodes, and renumbering the nodes strands them — so every function here that changes the node table also says where each input node's data should go, as a node_map:

ids, parents, weights, node_map = fastcore.simplify_rdp(
    node_ids, parent_ids, coords, epsilon=100
)

# node_map is indexed like node_ids and valued in the returned ids, so this is a
# lookup table from old node to new.
lookup = pd.Series(node_map, index=node_ids)
synapses["node_id"] = lookup[synapses["node_id"]].values

It is total: every input node names exactly one output node — the nearest one along the neurite, ties going towards the root — so there is no sentinel to mask off. Nodes that survive map to themselves.

The two smoothers have no node_map and need none: they rewrite per-node values and nothing else, so anything attached to a node is still attached to it afterwards. The one thing that does go stale is a copy of a node's position taken beforehand.

Dropping nodes

The three thinning methods share an output contract with simplify_skeleton — surviving IDs, their new parents, the edge weights that replace the dropped chains, and the node_map — so they are interchangeable at the call site. Because the replacement edges carry the summed length of the chains they stand in for, total cable length and geodesic distances survive exactly, even where the geometry has been cut across.

All three take a preserve list of node IDs that must survive whatever the rule decides — nodes carrying synapses, say. They differ in what they spend the node budget on:

  • downsample_skeleton counts. Every Nth node of every segment, geometry ignored. Cheapest, and the right answer when the skeleton is already evenly sampled. This is navis.downsample_neuron.
  • simplify_rdp asks how far the path would move. Straight stretches collapse to their two ends, tight curves keep every node they need. One tolerance, in the units of your coordinates.
  • simplify_vw asks how much area each node contributes, and removes the smallest first. Under aggressive simplification RDP will keep one spike and flatten everything around it; Visvalingam-Whyatt sheds detail evenly and so keeps a neurite looking like itself.
import navis_fastcore as fastcore

# Same skeleton, three ways to make it a fifth of the size.
ids, parents, weights, node_map = fastcore.downsample_skeleton(
    node_ids, parent_ids, 5, weights=weights
)
ids, parents, weights, node_map = fastcore.simplify_rdp(
    node_ids, parent_ids, coords, epsilon=100, weights=weights
)
ids, parents, weights, node_map = fastcore.simplify_vw(
    node_ids, parent_ids, coords, min_area=1e4, weights=weights
)

RDP is quadratic in the worst case

Its worst case is a segment on which it keeps almost everything: each split then peels off one node and re-scans a span one shorter. An epsilon well below the tracing jitter on a very long unbranched neurite is how to hit it — but an RDP that keeps every node is not buying anything anyway, so the fix is a larger epsilon or downsample_skeleton.

Keep every Nth node, dropping the rest.

The plain "make this skeleton smaller" operation: it pays no attention to geometry, so reach for it when the skeleton is already evenly sampled and you just want fewer nodes. Roots, branch points and leafs always survive, so the result is still the same neuron - only its unbranched stretches are sampled factor times more coarsely.

See navis_fastcore.simplify_rdp and navis_fastcore.simplify_vw for the geometry-aware alternatives, which spend the same node budget where the neuron actually curves.

PARAMETER DESCRIPTION
node_ids
     Array of node IDs.

TYPE: (N, ) array

parent_ids
     Array of parent IDs for each node. Root nodes' parents
     must be -1.

TYPE: (N, ) array

factor
     Keep one node in every `factor`, counting from each segment's
     distal end. ``1`` keeps everything; the useful range starts at 2.

TYPE: int

preserve
     IDs of extra nodes that must survive - nodes carrying synapses,
     say, or the ends of a region of interest.

TYPE: (M, ) array DEFAULT: None

weights
     Array of distances for each child -> parent connection.
     If ``None`` all node-to-node distances are set to 1.

TYPE: (N, ) float32 array DEFAULT: None

RETURNS DESCRIPTION
node_ids

The surviving node IDs, in their original relative order.

TYPE: (M, ) array

parent_ids

Their new parent IDs. Roots are -1.

TYPE: (M, ) array

weights

Length of each node's edge to its new parent, i.e. the summed length of the chain it replaces. Roots are 0. None exactly when weights was None.

TYPE: (M, ) float32 array or None

node_map

For each input node, the ID of the surviving node its data belongs to now - indexed like node_ids, valued in the returned node_ids. Surviving nodes map to themselves; a dropped node maps to whichever end of its chain is nearer, measured in weights (in hops if weights is None), with ties going towards the root. Use it to re-attach anything you keep per node, such as synapses.

TYPE: (N, ) array

Examples:

>>> import navis_fastcore as fastcore
>>> import numpy as np
>>> node_ids = np.arange(7)
>>> parent_ids = np.array([-1, 0, 1, 2, 1, 4, 5])

Every second node of each segment, plus the root, the branch point and the leafs:

>>> fastcore.downsample_skeleton(node_ids, parent_ids, 2)[:3]
(array([0, 1, 3, 4, 6]), array([-1,  0,  1,  1,  4]), None)

A factor nothing can satisfy leaves just the root, the branch point and the two leafs - and total cable length is preserved, the dropped nodes' edges having moved into the edges that replaced them:

>>> weights = np.array([0, 1, 1, 1, 1, 1, 1], dtype=np.float32)
>>> ids, _, w, node_map = fastcore.downsample_skeleton(
...     node_ids, parent_ids, 100, weights=weights
... )
>>> ids
array([0, 1, 3, 6])
>>> w
array([0., 1., 2., 3.], dtype=float32)

The dropped nodes 2, 4 and 5 hand their data to whichever survivor is nearer:

>>> node_map
array([0, 1, 1, 3, 1, 6, 6])

Drop the nodes that don't bend a neurite (Ramer-Douglas-Peucker).

Where navis_fastcore.downsample_skeleton thins by counting, this thins by shape: a node survives only if removing it would move the traced path by more than epsilon. Long straight stretches collapse to their two ends while a tight curve keeps every node it needs, so the same tolerance buys a much better skeleton per node than a fixed factor does.

Roots, branch points and leafs always survive, and each replacement edge carries the length of the chain it stands in for - so geodesic distances stay right even where the geometry has been cut across.

PARAMETER DESCRIPTION
node_ids
     Array of node IDs.

TYPE: (N, ) array

parent_ids
     Array of parent IDs for each node. Root nodes' parents
     must be -1.

TYPE: (N, ) array

coords
     Array of coordinates for each node.

TYPE: (N, 3) array

epsilon
     How far the simplified path may stray from the original, in the
     units of `coords`. ``0`` still drops nodes that are *exactly*
     collinear, and nothing else.

TYPE: float

preserve
     IDs of extra nodes that must survive - nodes carrying synapses,
     say, or the ends of a region of interest.

TYPE: (M, ) array DEFAULT: None

weights
     Array of distances for each child -> parent connection.
     If ``None`` all node-to-node distances are set to 1.

TYPE: (N, ) float32 array DEFAULT: None

threads
     Number of threads to use. ``None`` uses all available cores.

TYPE: int DEFAULT: None

RETURNS DESCRIPTION
node_ids

The surviving node IDs, in their original relative order.

TYPE: (M, ) array

parent_ids

Their new parent IDs. Roots are -1.

TYPE: (M, ) array

weights

Length of each node's edge to its new parent. None exactly when weights was None.

TYPE: (M, ) float32 array or None

node_map

For each input node, the ID of the surviving node its data belongs to now - indexed like node_ids, valued in the returned node_ids. Surviving nodes map to themselves; a dropped node maps to whichever end of its chain is nearer, measured in weights (in hops if weights is None), with ties going towards the root. Use it to re-attach anything you keep per node, such as synapses.

TYPE: (N, ) array

Examples:

>>> import navis_fastcore as fastcore
>>> import numpy as np
>>> node_ids = np.arange(5)
>>> parent_ids = np.array([-1, 0, 1, 2, 3])

A straight run with one node nudged off the line:

>>> coords = np.array(
...     [[0, 0, 0], [1, 0, 0], [2, 1, 0], [3, 0, 0], [4, 0, 0]], dtype=float
... )

A tolerance below the bump keeps it...

>>> fastcore.simplify_rdp(node_ids, parent_ids, coords, 0.5)[0]
array([0, 2, 4])

...and one above it does not:

>>> fastcore.simplify_rdp(node_ids, parent_ids, coords, 2.0)[0]
array([0, 4])

Drop the nodes that contribute least area (Visvalingam-Whyatt).

The other geometry-aware thinning. Where navis_fastcore.simplify_rdp asks how far the path moves, this asks how much area each node adds to it and repeatedly removes whichever node adds least. The difference shows under aggressive simplification: RDP will happily keep one spike and flatten everything around it, while Visvalingam-Whyatt sheds detail evenly and so keeps a neurite looking like itself.

PARAMETER DESCRIPTION
node_ids
     Array of node IDs.

TYPE: (N, ) array

parent_ids
     Array of parent IDs for each node. Root nodes' parents
     must be -1.

TYPE: (N, ) array

coords
     Array of coordinates for each node.

TYPE: (N, 3) array

min_area
     Remove a node while the triangle it forms with its two surviving
     neighbours is smaller than this, in the *squared* units of
     `coords`. ``0`` or less is a no-op.

TYPE: float

preserve
     IDs of extra nodes that must survive - nodes carrying synapses,
     say, or the ends of a region of interest.

TYPE: (M, ) array DEFAULT: None

weights
     Array of distances for each child -> parent connection.
     If ``None`` all node-to-node distances are set to 1.

TYPE: (N, ) float32 array DEFAULT: None

threads
     Number of threads to use. ``None`` uses all available cores.

TYPE: int DEFAULT: None

RETURNS DESCRIPTION
node_ids

The surviving node IDs, in their original relative order.

TYPE: (M, ) array

parent_ids

Their new parent IDs. Roots are -1.

TYPE: (M, ) array

weights

Length of each node's edge to its new parent. None exactly when weights was None.

TYPE: (M, ) float32 array or None

node_map

For each input node, the ID of the surviving node its data belongs to now - indexed like node_ids, valued in the returned node_ids. Surviving nodes map to themselves; a dropped node maps to whichever end of its chain is nearer, measured in weights (in hops if weights is None), with ties going towards the root. Use it to re-attach anything you keep per node, such as synapses.

TYPE: (N, ) array

Examples:

>>> import navis_fastcore as fastcore
>>> import numpy as np
>>> node_ids = np.arange(5)
>>> parent_ids = np.array([-1, 0, 1, 2, 3])

Two bumps off a straight line, one ten times taller than the other. The small one goes:

>>> coords = np.array(
...     [[0, 0, 0], [1, 0.1, 0], [2, 0, 0], [3, 1, 0], [4, 0, 0]], dtype=float
... )
>>> fastcore.simplify_vw(node_ids, parent_ids, coords, 0.5)[0]
array([0, 2, 3, 4])

Resampling

resample_skeleton is the inverse problem: rather than thinning what is there, it re-samples each segment from scratch, so a skeleton whose node density varies tenfold between neurites comes out evenly sampled throughout. Anything that averages a quantity per node wants this in front of it — otherwise the average is weighted by how finely each neurite happened to be traced.

Because it creates nodes, it is the one function here that returns a new node table rather than a subset — and the only one that reports both directions. source and alpha name the input edge each output node sits on and how far along it, so any per-node column interpolates in one expression; node_map points the other way, for whatever is attached to a node.

ids, parents, xyz, source, alpha, node_map = fastcore.resample_skeleton(
    node_ids, parent_ids, coords, spacing=1000
)

# Per-node columns interpolate onto the new nodes...
new_radius = radius[source[:, 0]] * (1 - alpha) + radius[source[:, 1]] * alpha

# ...and per-node attachments follow node_map to their new home.
synapses["node_id"] = pd.Series(node_map, index=node_ids)[synapses["node_id"]].values

You need both because neither derives from the other: an input node that fell between two output nodes has no output row of its own, so source/alpha does not invert.

Place nodes at a fixed spacing along every neurite.

The inverse problem to navis_fastcore.downsample_skeleton: rather than thinning what is there, this re-samples each segment from scratch, so a skeleton whose node density varies tenfold between neurites comes out evenly sampled throughout. It is the step most morphometrics want in front of them - anything that averages a quantity per node is otherwise weighted by how finely each neurite happened to be traced.

Each segment is divided into round(length / spacing) equal parts (at least one), so both of its endpoints land exactly and no runt edge is left over. A segment shorter than spacing / 2 collapses to a single straight edge.

PARAMETER DESCRIPTION
node_ids
     Array of node IDs.

TYPE: (N, ) array

parent_ids
     Array of parent IDs for each node. Root nodes' parents
     must be -1.

TYPE: (N, ) array

coords
     Array of coordinates for each node.

TYPE: (N, 3) array

spacing
     Target distance between adjacent nodes, in the units of `coords`.

TYPE: float

threads
     Number of threads to use. ``None`` uses all available cores.

TYPE: int DEFAULT: None

RETURNS DESCRIPTION
node_ids

Node IDs for the resampled skeleton. Roots, branch points and leafs keep their original ID and come first, in their original order; the interpolated nodes get fresh IDs counting up from max(node_ids) + 1.

TYPE: (M, ) array

parent_ids

Their parent IDs. Roots are -1.

TYPE: (M, ) array

coords

Their coordinates.

TYPE: (M, 3) array

source

For each new node, the indices into the original node_ids of the edge it sits on: column 0 the child (distal) end, column 1 the parent (proximal) end. A node carried over unchanged has its own index in both columns.

TYPE: (M, 2) int32 array

alpha

How far along that edge each new node lies, from the child end. Zero for a node carried over unchanged.

TYPE: (M, ) float64 array

node_map

The other direction: for each input node, the ID of the output node nearest it along the neurite, with ties going towards the root. Indexed like node_ids, valued in the returned node_ids. Nodes carried over map to themselves.

TYPE: (N, ) array

Notes

source/alpha and node_map point opposite ways, and which you want depends on what you are moving.

source and alpha carry a per-node column forward, so this function does not have to know what else you keep per node. Radius, label, confidence and anything else numeric interpolate the same way, over the whole output at once:

new_radius = (
    radius[source[:, 0]] * (1 - alpha) + radius[source[:, 1]] * alpha
)

node_map re-homes whatever is attached to a node - a synapse, a soma tag, a manual annotation. That question cannot be answered from source and alpha: an input node between two output nodes has no output row of its own, so the mapping does not invert.

synapses["node_id"] = pd.Series(node_map, index=node_ids)[synapses["node_id"]].values

Examples:

>>> import navis_fastcore as fastcore
>>> import numpy as np
>>> node_ids = np.arange(3)
>>> parent_ids = np.array([-1, 0, 1])
>>> coords = np.array([[0, 0, 0], [1, 0, 0], [2, 0, 0]], dtype=float)

Two units of cable at a spacing of 0.5 gives four edges, so five nodes. The root and the leaf keep their IDs and lead the way; the three new ones follow:

>>> ids, parents, xyz, source, alpha, node_map = fastcore.resample_skeleton(
...     node_ids, parent_ids, coords, 0.5
... )
>>> ids
array([0, 2, 3, 4, 5])
>>> parents
array([-1,  3,  4,  5,  0])

The new nodes are laid down walking each segment from its distal end, which here is the leaf - hence the descending x:

>>> xyz[:, 0]
array([0. , 2. , 1.5, 1. , 0.5])

Input node 1 sat at x = 1, where new node 4 now is, so that is where its data goes; the root and the leaf were carried over and map to themselves:

>>> node_map
array([0, 4, 2])

Smoothing

The two smoothers rewrite per-node values and nothing else — every node keeps its ID and its parent. Roots, branch points and leafs are pinned, since a branch point that drifted would drag three neurites apart, so this is safe to run before measuring angles, tortuosity or tangent vectors, all of which a raw traced skeleton overstates.

Pick by what you want the amount of smoothing to be tied to: smooth_skeleton's window is a count of nodes, smooth_skeleton_gaussian's sigma is a distance. The distance is usually the better choice — it does not change meaning when the skeleton is resampled.

The kernel measures distance along the neurite

Not between the points. Node spacing in a traced skeleton varies by an order of magnitude, and a kernel over straight-line distance would let the far arm of a hairpin pull on the near one.

Smoothing something other than the coordinates

Neither smoother reads a geometric meaning into the columns it averages, so a radius, a confidence or any other numeric per-node field smooths by the same code as an x. Columns are independent, which makes stacking them exactly equivalent to — and one pass cheaper than — a call each:

# The window is a node count, so the field is the only array involved.
xyzr = fastcore.smooth_skeleton(
    node_ids, parent_ids, np.column_stack([coords, radius]), window=5
)

# The kernel is a distance, so the geometry stays a separate argument.
xyzr = fastcore.smooth_skeleton_gaussian(
    node_ids, parent_ids, coords, sigma=2000,
    values=np.column_stack([coords, radius]),
)

That asymmetry is the one thing to keep straight. smooth_skeleton has a single array because there is nothing for it to measure; smooth_skeleton_gaussian weighs its neighbours by distance along the neurite, so handing it a radius column as though it were geometry would make "distance" the cumulative absolute change in radius. That is not an error anything downstream could catch — it is a plausible-looking number and a meaningless kernel — so coords and values stay separate. A (N, ) field comes back as (N, ) from either.

Compared with navis.smooth_skeleton

This covers the same ground as navis' to_smooth parameter, but the two do not agree numerically and are not meant to. navis smooths with a trailing rolling(window, min_periods=1).mean(), which lags the result half a window towards each segment's distal end, and it lets branch points move — they are the last row of each segment, so they take a full one-sided mean, which the parent segment then reads back. Here the window is centred, shrinks symmetrically at segment ends, and segment endpoints do not move at all. Both smoothers have always differed from navis this way; smoothing arbitrary columns does not change it.

Smooth a skeleton with a moving average along each neurite.

Takes the tracing jitter out of a skeleton without touching its topology or its node count: every node keeps its ID and its parent, and only its coordinates move. Roots, branch points and leafs are pinned - a branch point that drifted would drag three neurites apart - so this is safe to run before measuring angles, tortuosity or tangent vectors, all of which a raw traced skeleton overstates.

The window shrinks symmetrically as it approaches a segment's ends, which keeps the smoothed path centred on the original rather than letting it pull towards the middle.

See navis_fastcore.smooth_skeleton_gaussian for the version whose kernel is a distance rather than a node count.

PARAMETER DESCRIPTION
node_ids
     Array of node IDs.

TYPE: (N, ) array

parent_ids
     Array of parent IDs for each node. Root nodes' parents
     must be -1.

TYPE: (N, ) array

coords
     The values to smooth, one row per node. Usually the coordinates,
     but any numeric per-node field works and several can go in one
     call - see the notes.

TYPE: (N, ) or (N, K) array

window
     Nodes in the window, counting the node itself. Even values round
     down to the odd value below, since the window is symmetric.
     ``0`` and ``1`` are no-ops.

TYPE: int DEFAULT: 5

threads
     Number of threads to use. ``None`` uses all available cores.

TYPE: int DEFAULT: None

RETURNS DESCRIPTION
coords

New values, in the same order and shape as coords. A (N, ) column comes back as (N, ).

TYPE: (N, ) or (N, K) float64 array

Notes

Nothing here reads a geometric meaning into the columns: the window is a count of nodes, so this is a moving average of whatever it is handed. Radius, confidence or any other numeric column smooths the same way as an x, and columns are independent, so stacking them is exactly equivalent to - and one pass cheaper than - smoothing each on its own:

xyzr = fastcore.smooth_skeleton(
    node_ids, parent_ids, np.column_stack([coords, radius]), window=5
)

navis_fastcore.smooth_skeleton_gaussian is the one that cannot be handed a bare field, because its kernel is a distance along the neurite and so needs the geometry told apart from what is being smoothed; it takes a separate values.

There is no node_map here, unlike the functions that drop or add nodes: this changes per-node values only, so every node keeps its ID and its parent and anything attached to a node is still attached to it afterwards. The one thing that does go stale is a copy of a node's position taken beforehand.

Examples:

>>> import navis_fastcore as fastcore
>>> import numpy as np
>>> node_ids = np.arange(5)
>>> parent_ids = np.array([-1, 0, 1, 2, 3])
>>> coords = np.array(
...     [[0, 0, 0], [1, 1, 0], [2, -1, 0], [3, 1, 0], [4, 0, 0]], dtype=float
... )
>>> fastcore.smooth_skeleton(node_ids, parent_ids, coords, window=3)[:, 1]
array([0.        , 0.        , 0.33333333, 0.        , 0.        ])

A single non-coordinate column, in and out as (N, ). The root and the leaf are pinned; each node between them becomes the mean of itself and its two neighbours:

>>> radius = np.array([1.0, 5.0, 1.0, 5.0, 1.0])
>>> fastcore.smooth_skeleton(node_ids, parent_ids, radius, window=3)
array([1.        , 2.33333333, 3.66666667, 2.33333333, 1.        ])

Smooth a skeleton with a Gaussian kernel along each neurite.

The same operation as navis_fastcore.smooth_skeleton with a softer, scale-based kernel: sigma is a distance in the units of coords rather than a count of nodes, so the amount of smoothing does not change when the skeleton is resampled. That is usually what you want - and it is why the kernel measures distance along the neurite rather than between the points, which would let the far arm of a hairpin pull on the near one.

Segment ends are pinned by reflecting the neurite about them, so a node one step in from a leaf is smoothed against a symmetric neighbourhood rather than being dragged inwards by a one-sided one.

PARAMETER DESCRIPTION
node_ids
     Array of node IDs.

TYPE: (N, ) array

parent_ids
     Array of parent IDs for each node. Root nodes' parents
     must be -1.

TYPE: (N, ) array

coords
     Array of coordinates for each node. Read only to measure distance
     along each neurite; what actually gets smoothed is `values`, which
     defaults to these coordinates.

TYPE: (N, 3) array

sigma
     Kernel width, as a distance along the neurite.

TYPE: float

truncate
     How many `sigma` out to keep summing. 4 covers all but 1e-4 of the
     kernel's mass.

TYPE: float DEFAULT: 4.0

values
     A per-node field to smooth *instead of* `coords` - a radius, say.
     See the notes.

TYPE: (N, ) or (N, K) array DEFAULT: None

threads
     Number of threads to use. ``None`` uses all available cores.

TYPE: int DEFAULT: None

RETURNS DESCRIPTION
coords

New coordinates, in the same order as node_ids - or, when values was given, the new values in the same order and shape as values.

TYPE: (N, 3) float64 array

Notes

coords and values are separate arguments where navis_fastcore.smooth_skeleton has only the one, and they cannot be collapsed: this kernel's weights come from distance along the neurite, so handing it a radius column as though it were geometry would make "distance" the cumulative absolute change in radius. Nothing downstream could catch that - it is a plausible-looking number and a meaningless kernel - so the geometry stays put.

To smooth the coordinates and another column in one pass, stack them into values. The kernel is still measured over the untouched input geometry, which is what it would have been anyway - arc lengths are computed once, before anything moves:

xyzr = fastcore.smooth_skeleton_gaussian(
    node_ids, parent_ids, coords, sigma=2.0,
    values=np.column_stack([coords, radius]),
)

The endpoint reflection generalises unchanged: 2 * end - p is an odd extension of whatever field it is applied to, which is the property being relied on - a field that ramps linearly into an endpoint is reproduced by its mirror, so the ramp is not flattened. Its virtual samples need not lie in the field's natural range (a mirrored radius can be negative); they are summands in a weighted mean, not outputs.

There is no node_map here, unlike the functions that drop or add nodes: this changes per-node values only, so every node keeps its ID and its parent and anything attached to a node is still attached to it afterwards. The one thing that does go stale is a copy of a node's position taken beforehand.

Examples:

>>> import navis_fastcore as fastcore
>>> import numpy as np
>>> node_ids = np.arange(5)
>>> parent_ids = np.array([-1, 0, 1, 2, 3])
>>> coords = np.array(
...     [[0, 0, 0], [1, 1, 0], [2, -1, 0], [3, 1, 0], [4, 0, 0]], dtype=float
... )

The ends stay put, the wobble in between is flattened:

>>> smoothed = fastcore.smooth_skeleton_gaussian(
...     node_ids, parent_ids, coords, sigma=2.0
... )
>>> bool(np.abs(smoothed[2, 1]) < np.abs(coords[2, 1]))
True
>>> np.allclose(smoothed[[0, 4]], coords[[0, 4]])
True

A radius, smoothed over the same geometry:

>>> radius = np.array([1.0, 5.0, 1.0, 5.0, 1.0])
>>> out = fastcore.smooth_skeleton_gaussian(
...     node_ids, parent_ids, coords, sigma=2.0, values=radius
... )
>>> out.shape
(5,)
>>> bool(out[2] > radius[2]) and out[[0, 4]].tolist() == [1.0, 1.0]
True