Skip to content

Note

Click here to download the full example code

Masking#

Restrict analyses, plots and edits to part of a neuron - reversibly.

New in NAVis 2.0

Masking is new and we are keen to hear how it holds up on real data. Please read Caveats and Antipatterns before relying on it for anything important.

You have always been able to carve a piece out of a neuron with navis.subset_neuron. That is a one-way street: you get a new neuron and the relationship to the one it came from is gone. Masking keeps that relationship, which buys you two things.

  • Look at a part


    A masked neuron is the masked region. Every function, property and plot sees only that part - no special-casing anywhere. (With one sharp edge, if your mask cuts across branches: see Caveats.)

  • Edit a part


    Prune, downsample or smooth just the axon, then put the neuron back together with the edit folded in.

graph LR
    A[whole neuron] -->|"mask(axon)"| B[axon only]
    B -->|analyse / plot| B
    B -->|"unmask()"| A
    B -->|"unmask(reset=False)"| C[whole neuron<br>with edits]

Quick start#

The navis.masked context manager is the way to use masks. It masks on the way in and unmasks on the way out - including if something raises inside the block, in which case the neuron is restored rather than left half-edited.

import navis

n = navis.example_neurons(kind="skeleton", n=1)

# Label axon and dendrites so we have something to mask by
navis.split_axon_dendrite(n, label_only=True)

with navis.masked(n, lambda x: x.nodes.compartment == "axon"):
    axon_cable = n.cable_length * n.units

print("Axon:  ", axon_cable)
print("Whole: ", n.cable_length * n.units)

Out:

Axon:   363469.75 nanometer
Whole:  2131815.0 nanometer

Note that n is the same object throughout - it was the axon inside the block and is the whole neuron again outside it. Nothing had to be reassigned, so code holding a reference to n sees the masked version too.

What can be a mask#

Masks are inclusive: elements where the mask is True are kept.

Neuron type A boolean mask must be one per…
Skeleton node
Mesh vertex or face
Dotprops point

Anything navis.subset_neuron accepts works, so you rarely need to build the boolean array yourself:

Evaluated per neuron - the only option that works unchanged across a whole NeuronList.

navis.masked(n, lambda x: x.nodes.compartment == "axon")

A numpy array or pandas.Series, one value per element.

navis.masked(n, n.nodes.y > 35_000)

Node IDs for skeletons; vertex indices for meshes; point indices for dotprops.

navis.masked(n, n.nodes.node_id.values[:2000])

One entry per neuron, keyed by ID - for when each neuron in a list needs its own.

navis.masked(nl, {n.id: some_mask for n in nl})

Reading through a mask#

Because a masked neuron is just a neuron, every navis function works on it. Here we measure each compartment without writing any compartment-aware code:

comp = n.nodes.compartment

for label in ("axon", "dendrite"):
    with navis.masked(n, comp == label):
        print(f"{label:9} {n.cable_length * n.units}  ({n.n_branches} branch points)")

Out:

axon      363469.75 nanometer  (59 branch points)
dendrite  1410770.0 nanometer  (515 branch points)

The same goes for plotting:

import matplotlib.pyplot as plt

with navis.masked(n, comp == "axon"):
    fig, ax = navis.plot2d(n, view=("x", "-y"), color="red", radius=True, method="2d")
plt.tight_layout()

tutorial basic 03 masking

Plotting compartments is not what masks are for

For a picture like the one above, plot2d's color_by argument is simpler - see coloring neurons. Reach for a mask when you want analyses restricted, not just colors.

Many neurons at once#

Pass a NeuronList and a callable, and each neuron gets its own mask:

nl = navis.example_neurons(3, kind="skeleton")
navis.split_axon_dendrite(nl, label_only=True)

with navis.masked(nl, lambda x: x.nodes.compartment == "axon"):
    print("Axon cable:    ", nl.cable_length.round(1))

print("Whole neurons: ", nl.cable_length.round(1))

Out:

Axon cable:     [45433.7 51393.4 48779. ]
Whole neurons:  [266476.9 304332.6 274703.4]

Editing through a mask#

This is where masking earns its keep. Pass reset=False and edits made inside the block are folded back into the whole neuron when it closes.

Say we want to downsample the axon but leave the dendrites at full resolution:

n2 = navis.example_neurons(kind="skeleton", n=1)
navis.split_axon_dendrite(n2, label_only=True)
original = n2.copy()

with navis.masked(n2, lambda x: x.nodes.compartment == "axon", reset=False):
    navis.downsample_neuron(n2, 5, inplace=True)

print(f"Before: {original.n_nodes:,} nodes")
print(f"After:  {n2.n_nodes:,} nodes")

Out:

Before: 4,465 nodes
After:  4,161 nodes

The neuron is whole again - still one connected tree, with the dendrites untouched:

fig, axes = plt.subplots(1, 2, figsize=(10, 5))
navis.plot2d(
    [original, original.nodes[["x", "y", "z"]].values],
    ax=axes[0], view=("x", "-y"), method="2d",
)
navis.plot2d(
    [n2, n2.nodes[["x", "y", "z"]].values],
    ax=axes[1], view=("x", "-y"), method="2d",
)
axes[0].set_title(f"original ({original.n_nodes:,} nodes)")
axes[1].set_title(f"axon downsampled ({n2.n_nodes:,} nodes)")
plt.tight_layout()

original (4,465 nodes), axon downsampled (4,161 nodes)

Deletions propagate too. Pruning twigs inside the mask removes them from the whole neuron - the nodes the mask covered are replaced by whatever the mask left behind:

n3 = navis.example_neurons(kind="skeleton", n=1)
navis.split_axon_dendrite(n3, label_only=True)

before = n3.n_nodes
with navis.masked(n3, lambda x: x.nodes.compartment == "axon", reset=False):
    navis.prune_twigs(n3, 5000, inplace=True)

print(f"{before:,} -> {n3.n_nodes:,} nodes, still {len(n3.root)} root")

Out:

4,465 -> 4,313 nodes, still 1 root

reset=False is all-or-nothing

The edit is folded back only if the block runs to completion. If anything raises inside it, the block resets instead - you get the neuron you started with, and the exception. There is no state in which a half-finished edit has landed.

with navis.masked(n, axon, reset=False):
    navis.prune_twigs(n, 5000, inplace=True)
    raise RuntimeError("...")     # n is unchanged, the pruning is discarded

Over a NeuronList each neuron is unmasked independently, so one that cannot be merged back does not strand the rest: it is restored, the others keep their edits, and you get a MaskingError naming the one that failed.

Nesting#

Masks stack. An inner mask does not need to know an outer one exists, and each unmask peels off exactly one layer:

n4 = navis.example_neurons(kind="skeleton", n=1)
navis.split_axon_dendrite(n4, label_only=True)

with navis.masked(n4, lambda x: x.nodes.compartment == "axon"):
    print(f"axon:            {n4.n_nodes:,} nodes")
    with navis.masked(n4, lambda x: x.nodes.type == "end"):
        print(f"axon end nodes:  {n4.n_nodes:,} nodes")
    print(f"back to axon:    {n4.n_nodes:,} nodes")
print(f"whole neuron:    {n4.n_nodes:,} nodes")

Out:

axon:            474 nodes
axon end nodes:  61 nodes
back to axon:    474 nodes
whole neuron:    4,465 nodes

Without a block#

Sometimes a mask has to outlive a with block - it is opened in one function and closed in another. The methods underneath are available directly:

Method Does
.mask() apply a mask (push a layer)
.unmask() remove the innermost mask (pop a layer)
.apply_mask() make the mask permanent - no going back
.is_masked is there a mask on this neuron right now?
n5 = navis.example_neurons(kind="skeleton", n=1)
navis.split_axon_dendrite(n5, label_only=True)

n5.mask(n5.nodes.compartment == "axon", inplace=True)
print(f"masked:   {n5.n_nodes:,} nodes, is_masked={n5.is_masked}")

n5.unmask()
print(f"unmasked: {n5.n_nodes:,} nodes, is_masked={n5.is_masked}")

Out:

masked:   474 nodes, is_masked=True
unmasked: 4,465 nodes, is_masked=False

Prefer the context manager

.mask() and .unmask() are easy to leave unbalanced - one early return or one exception and a neuron stays masked, silently, for the rest of your session. navis.masked cannot get this wrong.

The primitives underneath#

Masking is a thin layer over two public functions, and you can use them directly if you would rather pass the piece around as a separate object:

n6 = navis.example_neurons(kind="skeleton", n=1)
navis.split_axon_dendrite(n6, label_only=True)

# `track=True` records where each node came from
axon = navis.subset_neuron(n6, n6.nodes.compartment == "axon", track=True)
axon = navis.prune_twigs(axon, 5000)

# ... and that record is what lets the edit be folded back in
merged = navis.merge_subset(n6, axon)

print(f"{n6.n_nodes:,} -> {merged.n_nodes:,} nodes")

Out:

4,465 -> 4,313 nodes

Caveats#

Masking copies data

A mask is not a view. Masking allocates a new node table (or vertex array), and the pre-mask state is kept alongside so it can be restored. Masking a large neuron in a tight loop will cost you; masking it once around a block of work will not.

Element order is not preserved by reset=False

Identity is: a node keeps its node_id, and connectors, tags and the soma follow their elements. But merging puts the untouched part first and the formerly-masked part after it, so positional indexing is not stable across the block.

n.nodes.iloc[0]              # ❌ may be a different node afterwards
n.nodes.node_id == 12345     # ✅ stable

Only element edits are carried back

A mask covers one kind of thing: a skeleton's nodes, a mesh's vertices, a dotprops' points. Those are what merging brings back. Everything else in the neuron points at them, and merging rebuilds it from the unmasked neuron, re-pointed at whichever elements survived - it does not look at the masked copy's version at all.

So edits that change elements (pruning, downsampling, moving nodes) carry back, and the tables that hang off them follow along. An edit to one of those tables that does not touch a single element is silently dropped:

Edited inside the mask On reset=False
nodes / vertices / points ✅ carried back
connectors, tags, soma — via the elements they sit on ✅ follow their element
connectors, tags, soma — on their own ❌ dropped
mesh faces on their own ❌ dropped

Mesh faces are the one that surprises people, because they look like data rather than a reference. They are a reference: three vertex indices each. Deleting faces inside a mask without deleting their vertices leaves nothing for the merge to notice, and the original faces come back. Drop the vertices instead - with navis.subset_neuron - and the faces that used them go with them.

Your own data can ride along too

"The tables that hang off the elements" is not a fixed list - it is a declaration, and .attach() lets you add to it. Per-vertex labels, per-connector scores or a table of your own are then masked, subset and merged back exactly like the neuron's own. See attaching data.

A mask that cuts across branches invents endings

This is the sharpest edge in masking, and worth understanding before you use reset=False.

A masked skeleton is a real skeleton. A node whose children fell outside the mask has no children now, so it is indistinguishable from a genuine tip — to navis, and to you. Anything that works from the tips therefore measures from the cut:

With reset=False the damage lands on the whole neuron: the eroded boundary is deleted, severing everything distal to it.

>>> mask = navis.graph.geodesic_matrix(n, from_=[n.root[0]]).values[0] < 12_000
>>> with navis.masked(n, mask, reset=False):
...     navis.prune_twigs(n, 10_000, inplace=True)
>>> len(n.root)
45                     # ← was 1; the neuron is now 45 disconnected pieces

navis warns you twice about this, and both checks are precise — they stay quiet for masks that keep whole subtrees:

  • on the way in, if the mask cuts across branches at all. A heads-up: your leaf counts are already wrong, whatever you go on to do.
Mask Warns
compartment == "axon" no — a compartment ends where the neuron does
in_volume(...) over a tuft no
y > 35_000, a geodesic radius yes
  • on the way out, with reset=False only, if folding the mask back left the neuron in more pieces than it was. That one fires on damage rather than on risk, so it is the one to take seriously.

Pass warn_cut=False to either once you know.

The fix: mask=, not a mask

Where a function takes its own mask argument, use that instead. It runs on the intact neuron — so every tip it sees is a real tip — and restricts only where the edit applies. Same intent, right answer:

with navis.masked(n, mask, reset=False):
    navis.prune_twigs(n, 10_000, inplace=True)
# 2,340 nodes, 45 roots
navis.prune_twigs(n, 10_000, mask=mask, inplace=True)
# 2,530 nodes, 1 root

prune_twigs, heal_skeleton and heal_mesh take one today. Where a function does not, there is no way around doing the work on the whole neuron and subsetting afterwards.

Masking is still the right tool for everything else — measuring, plotting, and edits that do not work from the tips (downsampling, smoothing, transforms).

Meshes and dotprops are stricter than skeletons

Skeleton nodes carry IDs, so navis can always tell which node is which. Mesh vertices and dotprops points are identified by position, so if something restructures them in a way navis cannot follow, unmasking with reset=False raises MergeError rather than guessing. reset=True always works.

A masked mesh loses its skeleton

Mesh.skeleton is regenerated rather than kept in sync, so it is recomputed after masking. Cheap for one neuron, less so in bulk.

Voxels cannot be masked

Voxels have no element axis declared, so masking them raises. Convert to another representation first.

Antipatterns#

Each of these does something, just not what it looks like.

1. Expecting edits to survive the default#

reset=True is the default, and it throws your work away on purpose - that is what makes read-only masking safe. It fails silently, because discarding edits is exactly what you asked for.

with navis.masked(n, axon):
    navis.prune_twigs(n, 5000, inplace=True)
# n is unchanged - the pruning is gone
with navis.masked(n, axon, reset=False):
    navis.prune_twigs(n, 5000, inplace=True)

2. Holding a reference across the boundary#

Masking replaces the neuron's data. A .nodes table you grabbed beforehand is still the old table - it does not track the mask, and writing to it does not reach the neuron.

nodes = n.nodes                  # the whole neuron's table
with navis.masked(n, axon):
    nodes["flag"] = True         # writes to a table nothing is using
with navis.masked(n, axon):
    n.nodes["flag"] = True       # re-read inside the block

3. Masking in a loop#

Each mask copies. If the mask is the same every time, hoist it - or just use navis.subset_neuron, which is what you actually want for a one-way workflow.

for threshold in thresholds:
    with navis.masked(n, n.nodes.compartment == "axon"):
        results.append(navis.prune_twigs(n, threshold).cable_length)
axon = navis.subset_neuron(n, n.nodes.compartment == "axon")
results = [navis.prune_twigs(axon, t).cable_length for t in thresholds]

4. Building the mask from a stale table#

A mask has to match the neuron as it is now. Inside another mask, or after an edit, an array built earlier is the wrong length - and if it happens to be the right length, it is silently the wrong mask.

mask = n.nodes.y > 35_000        # one value per node of the WHOLE neuron
with navis.masked(n, axon):
    with navis.masked(n, mask):  # wrong length - the axon has fewer nodes
        ...
with navis.masked(n, axon):
    with navis.masked(n, lambda x: x.nodes.y > 35_000):   # evaluated in context
        ...

5. Unbalanced .mask() / .unmask()#

Masks nest, which means they also leak. A function that masks and returns early leaves its caller holding a neuron that is quietly a fragment of itself.

def measure(n):
    n.mask(n.nodes.compartment == "axon", inplace=True)
    if not n.n_nodes:
        return 0                 # never unmasked
    result = n.cable_length
    n.unmask()
    return result
def measure(n):
    with navis.masked(n, n.nodes.compartment == "axon"):
        return n.cable_length if n.n_nodes else 0

6. Reaching past the neuron's own API#

Assigning to private arrays skips the bookkeeping that makes merging possible. For meshes and dotprops this is caught (MergeError); nothing can catch it in general.

with navis.masked(m, verts, reset=False):
    m._vertices = m._vertices[:100]      # nothing knows these moved
with navis.masked(m, verts, reset=False):
    navis.subset_neuron(m, np.arange(100), inplace=True)

Summary#

If you want to… Use
measure or plot part of a neuron with navis.masked(n, mask):
edit part of a neuron, keep the rest with navis.masked(n, mask, reset=False):
carve a piece off and never look back navis.subset_neuron
pass the piece around, merge later subset_neuron(..., track=True) + navis.merge_subset
keep the masked region as the neuron .apply_mask()

Total running time of the script: ( 0 minutes 1.138 seconds)

Download Python source code: tutorial_basic_03_masking.py

Download Jupyter notebook: tutorial_basic_03_masking.ipynb

Gallery generated by mkdocs-gallery