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_skeletoncounts. Every Nth node of every segment, geometry ignored. Cheapest, and the right answer when the skeleton is already evenly sampled. This isnavis.downsample_neuron.simplify_rdpasks 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_vwasks 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.
navis_fastcore.downsample_skeleton(node_ids, parent_ids, factor, preserve=None, weights=None)
¶
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
|
TYPE:
|
parent_ids
|
TYPE:
|
factor
|
TYPE:
|
preserve
|
TYPE:
|
weights
|
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
node_ids
|
The surviving node IDs, in their original relative order.
TYPE:
|
parent_ids
|
Their new parent IDs. Roots are -1.
TYPE:
|
weights
|
Length of each node's edge to its new parent, i.e. the summed
length of the chain it replaces. Roots are 0.
TYPE:
|
node_map
|
For each input node, the ID of the surviving node its data
belongs to now - indexed like
TYPE:
|
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:
navis_fastcore.simplify_rdp(node_ids, parent_ids, coords, epsilon, preserve=None, weights=None, threads=None)
¶
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
|
TYPE:
|
parent_ids
|
TYPE:
|
coords
|
TYPE:
|
epsilon
|
TYPE:
|
preserve
|
TYPE:
|
weights
|
TYPE:
|
threads
|
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
node_ids
|
The surviving node IDs, in their original relative order.
TYPE:
|
parent_ids
|
Their new parent IDs. Roots are -1.
TYPE:
|
weights
|
Length of each node's edge to its new parent.
TYPE:
|
node_map
|
For each input node, the ID of the surviving node its data
belongs to now - indexed like
TYPE:
|
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...
...and one above it does not:
navis_fastcore.simplify_vw(node_ids, parent_ids, coords, min_area, preserve=None, weights=None, threads=None)
¶
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
|
TYPE:
|
parent_ids
|
TYPE:
|
coords
|
TYPE:
|
min_area
|
TYPE:
|
preserve
|
TYPE:
|
weights
|
TYPE:
|
threads
|
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
node_ids
|
The surviving node IDs, in their original relative order.
TYPE:
|
parent_ids
|
Their new parent IDs. Roots are -1.
TYPE:
|
weights
|
Length of each node's edge to its new parent.
TYPE:
|
node_map
|
For each input node, the ID of the surviving node its data
belongs to now - indexed like
TYPE:
|
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:
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.
navis_fastcore.resample_skeleton(node_ids, parent_ids, coords, spacing, threads=None)
¶
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
|
TYPE:
|
parent_ids
|
TYPE:
|
coords
|
TYPE:
|
spacing
|
TYPE:
|
threads
|
TYPE:
|
| 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
TYPE:
|
parent_ids
|
Their parent IDs. Roots are -1.
TYPE:
|
coords
|
Their coordinates.
TYPE:
|
source
|
For each new node, the indices into the original
TYPE:
|
alpha
|
How far along that edge each new node lies, from the child end. Zero for a node carried over unchanged.
TYPE:
|
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
TYPE:
|
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:
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.
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:
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:
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.
navis_fastcore.smooth_skeleton(node_ids, parent_ids, coords, window=5, threads=None)
¶
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
|
TYPE:
|
parent_ids
|
TYPE:
|
coords
|
TYPE:
|
window
|
TYPE:
|
threads
|
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
coords
|
New values, in the same order and shape as
TYPE:
|
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:
navis_fastcore.smooth_skeleton_gaussian(node_ids, parent_ids, coords, sigma, truncate=4.0, values=None, threads=None)
¶
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
|
TYPE:
|
parent_ids
|
TYPE:
|
coords
|
TYPE:
|
sigma
|
TYPE:
|
truncate
|
TYPE:
|
values
|
TYPE:
|
threads
|
TYPE:
|
| RETURNS | DESCRIPTION |
|---|---|
coords
|
New coordinates, in the same order as
TYPE:
|
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: